From 7995c00683b8ecbbb535360e2eccdc5e8c0856b2 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:20:37 +0800 Subject: [PATCH] feat(review): add author review progress and attempt history Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../controller/portal/ReviewController.java | 22 ++ .../skillhub/dto/ReviewProgressResponse.java | 19 ++ .../JpaGovernanceQueryRepository.java | 25 +- .../JpaReviewProgressQueryRepository.java | 130 +++++++++ .../ReviewProgressQueryRepository.java | 19 ++ .../service/GovernanceWorkflowAppService.java | 15 ++ .../service/ReviewPortalAppService.java | 38 +++ .../V46__preserve_review_attempt_history.sql | 27 ++ .../ReviewPortalControllerTest.java | 54 ++++ ...ApprovalVisibilityFlowIntegrationTest.java | 6 +- ...SkillVersionDeleteFlowIntegrationTest.java | 9 +- .../JpaGovernanceQueryRepositoryTest.java | 26 ++ .../skillhub/stream/ScanTaskConsumerTest.java | 11 + .../skillhub/domain/review/ReviewService.java | 6 +- .../skillhub/domain/review/ReviewTask.java | 21 +- .../domain/review/ReviewTaskRepository.java | 4 + .../skill/service/SkillHardDeleteService.java | 5 +- .../skill/service/SkillPublishService.java | 12 +- .../service/SkillReviewSubmitService.java | 3 +- .../service/SkillHardDeleteServiceTest.java | 2 +- .../service/SkillPublishServiceTest.java | 6 +- .../infra/jpa/ReviewTaskJpaRepository.java | 6 + web/e2e/rejected-version-republish.spec.ts | 44 ++- web/src/api/client.ts | 16 ++ web/src/api/generated/schema.d.ts | 202 ++++++++++++++ web/src/api/types.ts | 15 +- web/src/app/router.tsx | 23 ++ .../features/review/use-my-review-progress.ts | 24 ++ web/src/i18n/locales/en.json | 23 ++ web/src/i18n/locales/ru.json | 23 ++ web/src/i18n/locales/zh.json | 23 ++ .../pages/dashboard/review-progress.test.tsx | 58 ++++ web/src/pages/dashboard/review-progress.tsx | 255 ++++++++++++++++++ web/src/shared/components/user-menu.test.tsx | 14 + web/src/shared/components/user-menu.tsx | 3 + 35 files changed, 1159 insertions(+), 30 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V46__preserve_review_attempt_history.sql create mode 100644 web/src/features/review/use-my-review-progress.ts create mode 100644 web/src/pages/dashboard/review-progress.test.tsx create mode 100644 web/src/pages/dashboard/review-progress.tsx diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index cbca1ada..9ec3ad3f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -8,12 +8,14 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.ReviewActionRequest; import com.iflytek.skillhub.dto.ReviewSkillDetailResponse; +import com.iflytek.skillhub.dto.ReviewProgressResponse; import com.iflytek.skillhub.dto.ReviewTaskRequest; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import jakarta.servlet.http.HttpServletRequest; import java.util.Map; +import java.util.List; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -136,6 +138,26 @@ public class ReviewController extends BaseApiController { return ok("response.success.read", governanceWorkflowAppService.listMyReviewSubmissions(page, size, userId)); } + @GetMapping("/my-progress") + public ApiResponse> listMyProgress( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "") String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId) { + return ok( + "response.success.read", + governanceWorkflowAppService.listMyReviewProgress(status, q, page, size, userId) + ); + } + + @GetMapping("/my-progress/{id}/attempts") + public ApiResponse> listMyAttempts( + @PathVariable Long id, + @RequestAttribute("userId") String userId) { + return ok("response.success.read", governanceWorkflowAppService.listMyReviewAttempts(id, userId)); + } + @GetMapping("/{id}") public ApiResponse getReviewDetail(@PathVariable Long id, @RequestAttribute("userId") String userId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressResponse.java new file mode 100644 index 00000000..f69885b9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressResponse.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +/** + * Author-facing summary for one skill version's review attempts. + */ +public record ReviewProgressResponse( + Long latestReviewTaskId, + Long skillId, + String namespace, + String skillSlug, + String skillVersion, + String latestStatus, + String latestReviewComment, + Instant latestSubmittedAt, + Instant latestReviewedAt, + long attemptCount +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java index 646f032e..8b7edab7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java @@ -104,15 +104,19 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { ? Map.of() : skillVersionRepository.findByIdIn(versionIds).stream() .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); - List skillIds = distinct(versionsById.values().stream().map(SkillVersion::getSkillId).toList()); + Set skillIds = new LinkedHashSet<>(distinct( + versionsById.values().stream().map(SkillVersion::getSkillId).toList())); + skillIds.addAll(distinct(tasks.stream().map(ReviewTask::getSkillId).toList())); Map skillsById = skillIds.isEmpty() ? Map.of() - : skillRepository.findByIdIn(skillIds).stream() + : skillRepository.findByIdIn(List.copyOf(skillIds)).stream() .collect(Collectors.toMap(Skill::getId, Function.identity())); - List namespaceIds = distinct(skillsById.values().stream().map(Skill::getNamespaceId).toList()); + Set namespaceIds = new LinkedHashSet<>(distinct( + skillsById.values().stream().map(Skill::getNamespaceId).toList())); + namespaceIds.addAll(distinct(tasks.stream().map(ReviewTask::getNamespaceId).toList())); Map namespacesById = namespaceIds.isEmpty() ? Map.of() - : namespaceRepository.findByIdIn(namespaceIds).stream() + : namespaceRepository.findByIdIn(List.copyOf(namespaceIds)).stream() .collect(Collectors.toMap(Namespace::getId, Function.identity())); List userIds = distinctStrings(tasks.stream() .flatMap(task -> java.util.stream.Stream.of(task.getSubmittedBy(), task.getReviewedBy())) @@ -168,9 +172,14 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { } private ReviewTaskResponse toReviewTaskResponse(ReviewTask task, ReviewReadBundle bundle) { - SkillVersion version = require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found"); - Skill skill = require(bundle.skillsById(), version.getSkillId(), "skill.not_found"); - Namespace namespace = require(bundle.namespacesById(), skill.getNamespaceId(), "namespace.not_found"); + Long skillId = task.getSkillId() != null + ? task.getSkillId() + : require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getSkillId(); + Skill skill = require(bundle.skillsById(), skillId, "skill.not_found"); + Namespace namespace = require(bundle.namespacesById(), task.getNamespaceId(), "namespace.not_found"); + String skillVersion = task.getSkillVersion() != null + ? task.getSkillVersion() + : require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getVersion(); UserAccount submittedBy = bundle.usersById().get(task.getSubmittedBy()); UserAccount reviewedBy = task.getReviewedBy() != null ? bundle.usersById().get(task.getReviewedBy()) : null; return new ReviewTaskResponse( @@ -178,7 +187,7 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { task.getSkillVersionId(), namespace.getSlug(), skill.getSlug(), - version.getVersion(), + skillVersion, task.getStatus().name(), task.getSubmittedBy(), submittedBy != null ? submittedBy.getDisplayName() : null, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java new file mode 100644 index 00000000..b3b72ce3 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java @@ -0,0 +1,130 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.ReviewProgressResponse; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * PostgreSQL read-model query for review progress. + * + *

Direct SQL is intentional here: the page boundary applies to grouped skill-version attempts, + * not individual review tasks. Window functions keep grouping, latest-attempt selection, counts, + * filtering, and pagination in the database instead of loading an author's full history.

+ */ +@Repository +public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepository { + + private static final String MY_PROGRESS_SQL = """ + WITH ranked AS ( + SELECT task.id, + task.skill_id, + task.namespace_id, + task.skill_version, + task.status, + task.review_comment, + task.submitted_at, + task.reviewed_at, + ROW_NUMBER() OVER ( + PARTITION BY task.skill_id, task.skill_version + ORDER BY task.submitted_at DESC, task.id DESC + ) AS attempt_rank, + COUNT(*) OVER ( + PARTITION BY task.skill_id, task.skill_version + ) AS attempt_count + FROM review_task task + WHERE task.submitted_by = :userId + ), latest AS ( + SELECT * + FROM ranked + WHERE attempt_rank = 1 + AND (:status = '' OR status = :status) + ) + SELECT latest.id, + latest.skill_id, + namespace.slug, + skill.slug, + latest.skill_version, + latest.status, + latest.review_comment, + latest.submitted_at, + latest.reviewed_at, + latest.attempt_count, + COUNT(*) OVER () AS total_groups + FROM latest + JOIN skill ON skill.id = latest.skill_id + JOIN namespace ON namespace.id = latest.namespace_id + WHERE :query = '' + OR LOWER(skill.slug) LIKE :queryPattern + OR LOWER(namespace.slug) LIKE :queryPattern + ORDER BY latest.submitted_at DESC, latest.id DESC + OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY + """; + + private final EntityManager entityManager; + + public JpaReviewProgressQueryRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @Transactional(readOnly = true) + public PageResponse findMyProgress( + String userId, + ReviewTaskStatus status, + String query, + int page, + int size) { + String normalizedQuery = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT); + Query nativeQuery = entityManager.createNativeQuery(MY_PROGRESS_SQL) + .setParameter("userId", userId) + .setParameter("status", status != null ? status.name() : "") + .setParameter("query", normalizedQuery) + .setParameter("queryPattern", "%" + normalizedQuery + "%") + .setParameter("offset", page * size) + .setParameter("size", size); + + @SuppressWarnings("unchecked") + List rows = nativeQuery.getResultList(); + List items = rows.stream().map(this::mapRow).toList(); + long total = rows.isEmpty() ? 0 : number(rows.get(0)[10]).longValue(); + return new PageResponse<>(items, total, page, size); + } + + private ReviewProgressResponse mapRow(Object[] row) { + return new ReviewProgressResponse( + number(row[0]).longValue(), + number(row[1]).longValue(), + (String) row[2], + (String) row[3], + (String) row[4], + String.valueOf(row[5]), + (String) row[6], + instant(row[7]), + instant(row[8]), + number(row[9]).longValue() + ); + } + + private Number number(Object value) { + if (value instanceof Number number) { + return number; + } + throw new IllegalStateException("Expected numeric review progress value, got " + value); + } + + private Instant instant(Object value) { + if (value == null) return null; + if (value instanceof Instant instant) return instant; + if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant(); + if (value instanceof Timestamp timestamp) return timestamp.toInstant(); + throw new IllegalStateException("Expected review progress timestamp, got " + value.getClass().getName()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java new file mode 100644 index 00000000..a6982dd0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.ReviewProgressResponse; + +/** + * Query seam for author-facing review progress grouped by skill version. + */ +public interface ReviewProgressQueryRepository { + + PageResponse findMyProgress( + String userId, + ReviewTaskStatus status, + String query, + int page, + int size + ); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 9aac59d9..087842ca 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -8,10 +8,12 @@ import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.PromotionResponseDto; import com.iflytek.skillhub.dto.ReviewSkillDetailResponse; +import com.iflytek.skillhub.dto.ReviewProgressResponse; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; import java.io.InputStream; +import java.util.List; import java.util.Map; import org.springframework.stereotype.Service; @@ -93,6 +95,19 @@ public class GovernanceWorkflowAppService { return reviewPortalAppService.listMySubmissions(page, size, userId); } + public PageResponse listMyReviewProgress( + String status, + String query, + int page, + int size, + String userId) { + return reviewPortalAppService.listMyProgress(status, query, page, size, userId); + } + + public List listMyReviewAttempts(Long reviewTaskId, String userId) { + return reviewPortalAppService.listMyAttempts(reviewTaskId, userId); + } + public ReviewTaskResponse getReviewDetail(Long reviewTaskId, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java index f944f2d5..1a4ad7a6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -13,9 +13,11 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.ReviewProgressResponse; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import com.iflytek.skillhub.repository.ReviewProgressQueryRepository; import java.util.List; import java.util.Map; import java.util.Set; @@ -34,6 +36,7 @@ public class ReviewPortalAppService { private final ReviewTaskRepository reviewTaskRepository; private final NamespaceRepository namespaceRepository; private final GovernanceQueryRepository governanceQueryRepository; + private final ReviewProgressQueryRepository reviewProgressQueryRepository; private final RbacService rbacService; private final AuditLogService auditLogService; private final RequestIdAccessor requestIdAccessor; @@ -42,6 +45,7 @@ public class ReviewPortalAppService { ReviewTaskRepository reviewTaskRepository, NamespaceRepository namespaceRepository, GovernanceQueryRepository governanceQueryRepository, + ReviewProgressQueryRepository reviewProgressQueryRepository, RbacService rbacService, AuditLogService auditLogService, RequestIdAccessor requestIdAccessor) { @@ -49,6 +53,7 @@ public class ReviewPortalAppService { this.reviewTaskRepository = reviewTaskRepository; this.namespaceRepository = namespaceRepository; this.governanceQueryRepository = governanceQueryRepository; + this.reviewProgressQueryRepository = reviewProgressQueryRepository; this.rbacService = rbacService; this.auditLogService = auditLogService; this.requestIdAccessor = requestIdAccessor; @@ -212,6 +217,39 @@ public class ReviewPortalAppService { )); } + public PageResponse listMyProgress( + String status, + String query, + int page, + int size, + String userId) { + ReviewTaskStatus reviewStatus = status == null || status.isBlank() + ? null + : ReviewTaskStatus.valueOf(status.toUpperCase(java.util.Locale.ROOT)); + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), 100); + return reviewProgressQueryRepository.findMyProgress( + userId, + reviewStatus, + query != null ? query : "", + safePage, + safeSize + ); + } + + public List listMyAttempts(Long reviewTaskId, String userId) { + ReviewTask anchor = reviewTaskRepository.findById(reviewTaskId) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId)); + if (!anchor.getSubmittedBy().equals(userId)) { + throw new DomainForbiddenException("review.no_permission"); + } + + List attempts = reviewTaskRepository + .findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + userId, anchor.getSkillId(), anchor.getSkillVersion()); + return governanceQueryRepository.getReviewTaskResponses(attempts); + } + public ReviewTaskResponse getReviewDetail(Long reviewTaskId, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/main/resources/db/migration/V46__preserve_review_attempt_history.sql b/server/skillhub-app/src/main/resources/db/migration/V46__preserve_review_attempt_history.sql new file mode 100644 index 00000000..3249f4e8 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V46__preserve_review_attempt_history.sql @@ -0,0 +1,27 @@ +ALTER TABLE review_task + ADD COLUMN skill_id BIGINT, + ADD COLUMN skill_version VARCHAR(64); + +UPDATE review_task task +SET skill_id = version.skill_id, + skill_version = version.version +FROM skill_version version +WHERE task.skill_version_id = version.id; + +ALTER TABLE review_task + ALTER COLUMN skill_id SET NOT NULL, + ALTER COLUMN skill_version SET NOT NULL, + ALTER COLUMN skill_version_id DROP NOT NULL; + +ALTER TABLE review_task + DROP CONSTRAINT review_task_skill_version_id_fkey, + ADD CONSTRAINT fk_review_task_skill_version + FOREIGN KEY (skill_version_id) REFERENCES skill_version(id) ON DELETE SET NULL, + ADD CONSTRAINT fk_review_task_skill + FOREIGN KEY (skill_id) REFERENCES skill(id); + +CREATE INDEX idx_review_task_submitter_submitted + ON review_task(submitted_by, submitted_at DESC, id DESC); + +CREATE INDEX idx_review_task_skill_version_attempts + ON review_task(skill_id, skill_version, submitted_at DESC, id DESC); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index 1724c786..53a6fc84 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -16,11 +16,13 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.dto.ReviewSkillDetailResponse; +import com.iflytek.skillhub.dto.ReviewProgressResponse; import com.iflytek.skillhub.dto.SkillDetailResponse; import com.iflytek.skillhub.dto.SkillFileResponse; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillVersionResponse; import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import com.iflytek.skillhub.repository.ReviewProgressQueryRepository; import com.iflytek.skillhub.service.ReviewSkillDetailAppService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -38,6 +40,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.util.List; +import java.time.Instant; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -79,6 +82,9 @@ class ReviewPortalControllerTest { @MockBean private GovernanceQueryRepository governanceQueryRepository; + @MockBean + private ReviewProgressQueryRepository reviewProgressQueryRepository; + @MockBean private RbacService rbacService; @@ -276,6 +282,54 @@ class ReviewPortalControllerTest { verify(reviewTaskRepository, never()).findByStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } + @Test + void listMyReviewProgress_isScopedToAuthenticatedAuthor() throws Exception { + var item = new ReviewProgressResponse( + 12L, + 30L, + "team-a", + "skill-a", + "1.0.0", + "REJECTED", + "Please add tests", + Instant.parse("2026-08-31T10:00:00Z"), + Instant.parse("2026-08-31T11:00:00Z"), + 2L + ); + given(reviewProgressQueryRepository.findMyProgress("author-1", null, "", 0, 20)) + .willReturn(new com.iflytek.skillhub.dto.PageResponse<>(List.of(item), 1, 0, 20)); + + mockMvc.perform(get("/api/v1/reviews/my-progress").with(auth("author-1"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].skillSlug").value("skill-a")) + .andExpect(jsonPath("$.data.items[0].attemptCount").value(2)) + .andExpect(jsonPath("$.data.items[0].latestStatus").value("REJECTED")); + + verify(reviewProgressQueryRepository).findMyProgress("author-1", null, "", 0, 20); + } + + @Test + void listMyReviewAttempts_returnsOnlyTheAuthorsVersionHistory() throws Exception { + ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.REJECTED); + setField(latest, "skillId", 30L); + setField(latest, "skillVersion", "1.0.0"); + ReviewTask previous = createReviewTask(8L, 20L, "author-1", ReviewTaskStatus.REJECTED); + setField(previous, "skillId", 30L); + setField(previous, "skillVersion", "1.0.0"); + given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest)); + given(reviewTaskRepository.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + "author-1", 30L, "1.0.0")) + .willReturn(List.of(latest, previous)); + given(governanceQueryRepository.getReviewTaskResponses(List.of(latest, previous))) + .willReturn(List.of(toReviewResponse(latest), toReviewResponse(previous))); + + mockMvc.perform(get("/api/v1/reviews/my-progress/12/attempts").with(auth("author-1"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].id").value(12)) + .andExpect(jsonPath("$.data[1].id").value(8)); + } + @Test void downloadReviewVersion_streamsZipForAuthorizedReviewer() throws Exception { stubNamespaceRoles("admin", List.of()); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java index 081f5133..ee467c27 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java @@ -212,7 +212,8 @@ class SkillApprovalVisibilityFlowIntegrationTest { version = skillVersionRepository.save(version); skillVersionRepository.flush(); - ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId)); + ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask( + version.getId(), skill.getId(), namespace.getId(), version.getVersion(), ownerId)); return new PendingSkillGraph(namespace, skill, version, reviewTask); } @@ -237,7 +238,8 @@ class SkillApprovalVisibilityFlowIntegrationTest { version = skillVersionRepository.save(version); skillVersionRepository.flush(); - ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId)); + ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask( + version.getId(), skill.getId(), namespace.getId(), version.getVersion(), ownerId)); return new PendingSkillGraph(namespace, skill, version, reviewTask); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillVersionDeleteFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillVersionDeleteFlowIntegrationTest.java index 25d585e5..65fbaf15 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillVersionDeleteFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillVersionDeleteFlowIntegrationTest.java @@ -91,15 +91,18 @@ class SkillVersionDeleteFlowIntegrationTest { retainedVersion.setStatus(SkillVersionStatus.REJECTED); retainedVersion = skillVersionRepository.save(retainedVersion); - ReviewTask rejectedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId); + ReviewTask rejectedTask = new ReviewTask( + rejectedVersion.getId(), skill.getId(), namespace.getId(), rejectedVersion.getVersion(), ownerId); rejectedTask.setStatus(ReviewTaskStatus.REJECTED); rejectedTask = reviewTaskRepository.save(rejectedTask); - ReviewTask approvedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId); + ReviewTask approvedTask = new ReviewTask( + rejectedVersion.getId(), skill.getId(), namespace.getId(), rejectedVersion.getVersion(), ownerId); approvedTask.setStatus(ReviewTaskStatus.APPROVED); approvedTask = reviewTaskRepository.save(approvedTask); - ReviewTask retainedTask = new ReviewTask(retainedVersion.getId(), namespace.getId(), ownerId); + ReviewTask retainedTask = new ReviewTask( + retainedVersion.getId(), skill.getId(), namespace.getId(), retainedVersion.getVersion(), ownerId); retainedTask.setStatus(ReviewTaskStatus.REJECTED); retainedTask = reviewTaskRepository.save(retainedTask); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java index beb53df9..0d019550 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java @@ -86,6 +86,32 @@ class JpaGovernanceQueryRepositoryTest { assertThat(responses.get(0).reviewedByName()).isEqualTo("Reviewer"); } + @Test + void getReviewTaskResponses_usesSnapshotAfterReviewedVersionIsReplaced() { + ReviewTask task = new ReviewTask(null, 201L, 11L, "1.2.0", "submitter"); + setField(task, "id", 6L); + setField(task, "status", ReviewTaskStatus.REJECTED); + + Skill skill = new Skill(11L, "skill-a", "submitter", SkillVisibility.PUBLIC); + setField(skill, "id", 201L); + Namespace namespace = new Namespace("team-a", "Team A", "submitter"); + setField(namespace, "id", 11L); + UserAccount submitter = new UserAccount("submitter", "Submitter", "submitter@example.com", null); + + given(skillRepository.findByIdIn(List.of(201L))).willReturn(List.of(skill)); + given(namespaceRepository.findByIdIn(List.of(11L))).willReturn(List.of(namespace)); + given(userAccountRepository.findByIdIn(List.of("submitter"))).willReturn(List.of(submitter)); + + var responses = repository.getReviewTaskResponses(List.of(task)); + + assertThat(responses).singleElement().satisfies(response -> { + assertThat(response.skillVersionId()).isNull(); + assertThat(response.namespace()).isEqualTo("team-a"); + assertThat(response.skillSlug()).isEqualTo("skill-a"); + assertThat(response.version()).isEqualTo("1.2.0"); + }); + } + @Test void getPromotionResponses_assemblesPromotionReadModel() { PromotionRequest request = new PromotionRequest(201L, 101L, 12L, "submitter"); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index 07d7d9d6..fdf0c24b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -611,6 +611,12 @@ class ScanTaskConsumerTest { throw unsupported(); } + @Override + public List findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + String submittedBy, Long skillId, String skillVersion) { + throw unsupported(); + } + @Override public boolean existsByNamespaceId(Long namespaceId) { return false; @@ -621,6 +627,11 @@ class ScanTaskConsumerTest { throw unsupported(); } + @Override + public void deleteBySkillId(Long skillId) { + throw unsupported(); + } + @Override public void delete(ReviewTask reviewTask) { this.deletedTask = reviewTask; 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 18275f3e..98b19649 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 @@ -111,7 +111,8 @@ public class ReviewService { skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); skillVersionRepository.save(skillVersion); - ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId); + ReviewTask task = new ReviewTask( + skillVersionId, skill.getId(), skill.getNamespaceId(), skillVersion.getVersion(), userId); try { ReviewTask saved = reviewTaskRepository.save(task); eventPublisher.publishEvent(new ReviewSubmittedEvent( @@ -153,7 +154,8 @@ public class ReviewService { skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); skillVersionRepository.save(skillVersion); - ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId); + ReviewTask task = new ReviewTask( + skillVersionId, skill.getId(), skill.getNamespaceId(), skillVersion.getVersion(), userId); try { ReviewTask saved = reviewTaskRepository.save(task); eventPublisher.publishEvent(new ReviewSubmittedEvent( diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java index 4ccd6786..9b908939 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java @@ -11,9 +11,15 @@ public class ReviewTask { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "skill_version_id", nullable = false) + @Column(name = "skill_version_id") private Long skillVersionId; + @Column(name = "skill_id", nullable = false) + private Long skillId; + + @Column(name = "skill_version", nullable = false, length = 64) + private String skillVersion; + @Column(name = "namespace_id", nullable = false) private Long namespaceId; @@ -49,10 +55,23 @@ public class ReviewTask { this.submittedBy = submittedBy; } + public ReviewTask(Long skillVersionId, Long skillId, Long namespaceId, + String skillVersion, String submittedBy) { + this.skillVersionId = skillVersionId; + this.skillId = skillId; + this.namespaceId = namespaceId; + this.skillVersion = skillVersion; + this.submittedBy = submittedBy; + } + public Long getId() { return id; } public Long getSkillVersionId() { return skillVersionId; } + public Long getSkillId() { return skillId; } + + public String getSkillVersion() { return skillVersion; } + public Long getNamespaceId() { return namespaceId; } public ReviewTaskStatus getStatus() { return status; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index 25c7d9f0..991a5c12 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.review; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import java.util.Collection; +import java.util.List; import java.util.Optional; /** @@ -15,8 +16,11 @@ public interface ReviewTaskRepository { Page findByStatus(ReviewTaskStatus status, Pageable pageable); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); + List findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + String submittedBy, Long skillId, String skillVersion); boolean existsByNamespaceId(Long namespaceId); void deleteBySkillVersionIdIn(Collection skillVersionIds); + void deleteBySkillId(Long skillId); void delete(ReviewTask reviewTask); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Integer expectedVersion); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java index dbfd9206..e548a608 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java @@ -107,9 +107,8 @@ public class SkillHardDeleteService { skillRepository.save(skill); skillRepository.flush(); - if (!versionIds.isEmpty()) { - reviewTaskRepository.deleteBySkillVersionIdIn(versionIds); - } + // Also removes detached historical attempts whose replaced skill version no longer exists. + reviewTaskRepository.deleteBySkillId(skill.getId()); promotionRequestRepository.deleteBySourceSkillIdOrTargetSkillId(skill.getId(), skill.getId()); skillTagRepository.deleteBySkillId(skill.getId()); skillStarRepository.deleteBySkillId(skill.getId()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index bc6e0b0a..8313534f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -561,7 +561,8 @@ public class SkillPublishService { // Create review task for PUBLIC/NAMESPACE_ONLY (not PRIVATE) if (!autoPublish && visibility != SkillVisibility.PRIVATE) { - ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); + ReviewTask reviewTask = new ReviewTask( + version.getId(), skill.getId(), namespace.getId(), version.getVersion(), publisherId); ReviewTask savedReviewTask = reviewTaskRepository.save(reviewTask); eventPublisher.publishEvent(new ReviewSubmittedEvent( savedReviewTask.getId(), @@ -608,10 +609,11 @@ public class SkillPublishService { skillRepository.flush(); } - // Every review task referencing this version has to go, not just a PENDING one: - // a rejected version still owns a REJECTED task whose foreign key blocks the - // skill_version delete below, which surfaces to the caller as an HTTP 500. - reviewTaskRepository.deleteBySkillVersionIdIn(List.of(version.getId())); + // A replaceable version may still have one obsolete pending task, but settled attempts are + // durable governance history. The database detaches those settled attempts from the + // replaced version while retaining their skill/version snapshot. + reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING) + .ifPresent(reviewTaskRepository::delete); List files = skillFileRepository.findByVersionId(version.getId()); List storageKeys = new ArrayList<>(); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java index 0b2e396b..348693a1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java @@ -95,7 +95,8 @@ public class SkillReviewSubmitService { skillVersionRepository.save(version); // Create review task - ReviewTask reviewTask = new ReviewTask(versionId, skill.getNamespaceId(), actorUserId); + ReviewTask reviewTask = new ReviewTask( + versionId, skill.getId(), skill.getNamespaceId(), version.getVersion(), actorUserId); reviewTaskRepository.save(reviewTask); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java index 8808acd4..d50c40b0 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java @@ -124,7 +124,7 @@ class SkillHardDeleteServiceTest { inOrder.verify(skillRepository).save(skill); inOrder.verify(skillRepository).flush(); inOrder.verify(skillVersionRepository).deleteBySkillId(7L); - verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(21L, 22L)); + verify(reviewTaskRepository).deleteBySkillId(7L); verify(promotionRequestRepository).deleteBySourceSkillIdOrTargetSkillId(7L, 7L); verify(skillTagRepository).deleteBySkillId(7L); verify(skillStarRepository).deleteBySkillId(7L); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index a680e320..c0fda2b6 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -424,7 +424,7 @@ class SkillPublishServiceTest { } @Test - void testPublishFromEntries_ShouldReplaceRejectedVersionWithSameVersion() throws Exception { + void testPublishFromEntries_ShouldPreserveSettledReviewHistoryWhenReplacingRejectedVersion() throws Exception { String namespaceSlug = "test-ns"; String publisherId = "user-100"; String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; @@ -473,7 +473,7 @@ class SkillPublishServiceTest { assertEquals("1.0.0", result.version().getVersion()); assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); - verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(8L)); + verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(List.of(8L)); verify(skillFileRepository).deleteByVersionId(8L); verify(skillVersionRepository).delete(rejectedVersion); verify(skillVersionRepository, times(2)).flush(); @@ -482,6 +482,8 @@ class SkillPublishServiceTest { ArgumentCaptor reviewTaskCaptor = ArgumentCaptor.forClass(ReviewTask.class); verify(reviewTaskRepository).save(reviewTaskCaptor.capture()); assertEquals(result.version().getId(), reviewTaskCaptor.getValue().getSkillVersionId()); + assertEquals(skill.getId(), reviewTaskCaptor.getValue().getSkillId()); + assertEquals("1.0.0", reviewTaskCaptor.getValue().getSkillVersion()); assertEquals(publisherId, reviewTaskCaptor.getValue().getSubmittedBy()); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java index 286c0eab..c5081c79 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java @@ -11,6 +11,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; +import java.util.List; import java.util.Optional; /** @@ -28,10 +29,15 @@ public interface ReviewTaskJpaRepository extends JpaRepository Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); + List findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + String submittedBy, Long skillId, String skillVersion); + boolean existsByNamespaceId(Long namespaceId); void deleteBySkillVersionIdIn(Collection skillVersionIds); + void deleteBySkillId(Long skillId); + @Modifying @Query(""" UPDATE ReviewTask t diff --git a/web/e2e/rejected-version-republish.spec.ts b/web/e2e/rejected-version-republish.spec.ts index cbbcada4..0b16ef1c 100644 --- a/web/e2e/rejected-version-republish.spec.ts +++ b/web/e2e/rejected-version-republish.spec.ts @@ -24,6 +24,13 @@ test.describe('Rejected version replacement (Real API)', () => { }) test('re-publishes the same version after rejection', async ({ page, browser }, testInfo) => { + const consoleErrors: string[] = [] + const pageErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + page.on('pageerror', (error) => pageErrors.push(error.message)) + const publisherBuilder = new E2eTestDataBuilder(page, testInfo) await publisherBuilder.init() @@ -74,8 +81,43 @@ test.describe('Rejected version replacement (Real API)', () => { expect(replacement.version).toBe(firstPublish.version) expect(replacementReviewId).not.toBe(rejectedReviewId) + const attemptsResponse = await page.request.get( + `/api/web/reviews/my-progress/${replacementReviewId}/attempts`, + ) + expect(attemptsResponse.status()).toBe(200) + const attemptsBody = await attemptsResponse.json() as { + data: Array<{ id: number; status: string; skillVersionId: number | null }> + } + expect(attemptsBody.data.map((attempt) => attempt.id)).toEqual([ + replacementReviewId, + rejectedReviewId, + ]) + expect(attemptsBody.data.map((attempt) => attempt.status)).toEqual(['PENDING', 'REJECTED']) + expect(attemptsBody.data[1]?.skillVersionId).toBeNull() + const replacedReviewResponse = await adminPage.request.get(`/api/web/reviews/${rejectedReviewId}`) - expect(replacedReviewResponse.status()).toBe(404) + expect(replacedReviewResponse.status()).toBe(200) + + await page.goto('/dashboard/review-progress') + await expect(page.getByRole('heading', { name: 'My Review Progress' })).toBeVisible() + const progressCard = page.locator('article').filter({ hasText: replacement.slug }) + await expect(progressCard).toContainText('In review') + await expect(progressCard).toContainText('2 submissions') + await progressCard.getByRole('button', { name: 'Submission history' }).click() + await expect(progressCard).toContainText('Attempt 2') + await expect(progressCard).toContainText('Attempt 1') + await expect(progressCard).toContainText('Rejected by Playwright E2E') + await page.screenshot({ path: testInfo.outputPath('author-review-progress-desktop.png'), fullPage: true }) + + await page.setViewportSize({ width: 390, height: 844 }) + await expect(progressCard).toBeVisible() + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + await page.screenshot({ path: testInfo.outputPath('author-review-progress-mobile.png'), fullPage: true }) + const unexpectedConsoleErrors = consoleErrors.filter((message) => ( + !message.includes("frame-ancestors' is ignored when delivered via a element") + )) + expect(unexpectedConsoleErrors).toEqual([]) + expect(pageErrors).toEqual([]) } finally { await adminBuilder.cleanup() await adminContext.close() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5e4c7756..11366047 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -15,6 +15,7 @@ import type { MergeInitiateResponse, MergeVerifyRequest, ReviewSkillDetail, + ReviewProgress, ReviewTask, PromotionSortBy, PromotionSortDirection, @@ -882,6 +883,21 @@ export const reviewApi = { return fetchJson(`${WEB_API_PREFIX}/reviews/${id}`) }, + async listMyProgress(params: { status?: string; q?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + if (params.status) searchParams.set('status', params.status) + if (params.q) searchParams.set('q', params.q) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: ReviewProgress[]; total: number; page: number; size: number }>( + `${WEB_API_PREFIX}/reviews/my-progress?${searchParams.toString()}`, + ) + }, + + async listMyAttempts(reviewTaskId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/reviews/my-progress/${reviewTaskId}/attempts`) + }, + async getSkillDetail(id: number): Promise { return fetchJson(`${WEB_API_PREFIX}/reviews/${id}/skill-detail`) }, diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 69d55df9..7eb405e4 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2500,6 +2500,70 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/reviews/my-progress/{id}/attempts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyAttempts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/my-progress/{id}/attempts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyAttempts_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/reviews/my-progress": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyProgress"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/my-progress": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyProgress_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/promotions/{id}": { parameters: { query?: never; @@ -4647,6 +4711,50 @@ export interface components { /** Format: int32 */ size?: number; }; + ApiResponseListReviewTaskResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ReviewTaskResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponsePageResponseReviewProgressResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseReviewProgressResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseReviewProgressResponse: { + items?: components["schemas"]["ReviewProgressResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + ReviewProgressResponse: { + /** Format: int64 */ + latestReviewTaskId?: number; + /** Format: int64 */ + skillId?: number; + namespace?: string; + skillSlug?: string; + skillVersion?: string; + latestStatus?: string; + latestReviewComment?: string; + /** Format: date-time */ + latestSubmittedAt?: string; + /** Format: date-time */ + latestReviewedAt?: string; + /** Format: int64 */ + attemptCount?: number; + }; ApiResponsePageResponsePromotionResponseDto: { /** Format: int32 */ code?: number; @@ -10167,6 +10275,100 @@ export interface operations { }; }; }; + listMyAttempts: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + }; + }; + }; + }; + listMyAttempts_1: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + }; + }; + }; + }; + listMyProgress: { + parameters: { + query?: { + status?: string; + q?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseReviewProgressResponse"]; + }; + }; + }; + }; + listMyProgress_1: { + parameters: { + query?: { + status?: string; + q?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseReviewProgressResponse"]; + }; + }; + }; + }; getPromotionDetail: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index e5d62e12..d504b564 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -427,7 +427,7 @@ export interface SkillDeleteResult { export interface ReviewTask { id: number - skillVersionId: number + skillVersionId: number | null namespace: string skillSlug: string version: string @@ -441,6 +441,19 @@ export interface ReviewTask { reviewedAt?: string } +export interface ReviewProgress { + latestReviewTaskId: number + skillId: number + namespace: string + skillSlug: string + skillVersion: string + latestStatus: 'PENDING' | 'APPROVED' | 'REJECTED' + latestReviewComment?: string + latestSubmittedAt: string + latestReviewedAt?: string + attemptCount: number +} + export interface ReviewSkillDetail { skill: SkillDetail versions: SkillVersion[] diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 71d91116..4e54a608 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -103,6 +103,10 @@ const NamespaceReviewDetailPage = createLazyRouteComponent( ) const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage') const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') +const ReviewProgressPage = createLazyRouteComponent( + () => import('@/pages/dashboard/review-progress'), + 'ReviewProgressPage', +) const ReportsPage = createRoleProtectedRouteComponent( () => import('@/pages/dashboard/reports'), 'ReportsPage', @@ -328,6 +332,24 @@ const dashboardReviewsRoute = createRoute({ component: ReviewsPage, }) +const dashboardReviewProgressRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/review-progress', + beforeLoad: requireAuth, + validateSearch: (search: Record): { + status?: 'PENDING' | 'APPROVED' | 'REJECTED' + q?: string + page?: number + } => ({ + status: search.status === 'PENDING' || search.status === 'APPROVED' || search.status === 'REJECTED' + ? search.status + : undefined, + q: typeof search.q === 'string' && search.q.trim() ? search.q.trim() : undefined, + page: typeof search.page === 'number' && search.page > 0 ? search.page : undefined, + }), + component: ReviewProgressPage, +}) + const dashboardReportsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/reports', @@ -478,6 +500,7 @@ const routeTree = rootRoute.addChildren([ dashboardNamespaceReviewDetailRoute, dashboardGovernanceRoute, dashboardReviewsRoute, + dashboardReviewProgressRoute, dashboardReportsRoute, dashboardReviewDetailRoute, dashboardPromotionsRoute, diff --git a/web/src/features/review/use-my-review-progress.ts b/web/src/features/review/use-my-review-progress.ts new file mode 100644 index 00000000..6a7bdfc0 --- /dev/null +++ b/web/src/features/review/use-my-review-progress.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query' +import { reviewApi } from '@/api/client' + +interface MyReviewProgressParams { + status?: string + q?: string + page: number + size: number +} + +export function useMyReviewProgress(params: MyReviewProgressParams) { + return useQuery({ + queryKey: ['reviews', 'my-progress', params], + queryFn: () => reviewApi.listMyProgress(params), + }) +} + +export function useMyReviewAttempts(reviewTaskId: number | null) { + return useQuery({ + queryKey: ['reviews', 'my-progress', reviewTaskId, 'attempts'], + queryFn: () => reviewApi.listMyAttempts(reviewTaskId!), + enabled: reviewTaskId !== null, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 6b11a5eb..58e08207 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -491,6 +491,28 @@ "switchToLight": "Switch to light theme", "switchToDark": "Switch to dark theme" }, + "reviewProgress": { + "title": "My Review Progress", + "subtitle": "Track the current decision, reviewer feedback, and every resubmission of your skills", + "searchLabel": "Search skills or namespaces", + "searchPlaceholder": "Search skill or namespace", + "searchAction": "Search", + "statusFilter": "Filter by review status", + "statusAll": "All statuses", + "statusPending": "In review", + "statusApproved": "Approved", + "statusRejected": "Rejected", + "latestSubmitted": "Latest submission: {{time}}", + "attemptCount": "{{count}} submissions", + "attemptNumber": "Attempt {{number}}", + "history": "Submission history", + "resubmit": "Edit and resubmit", + "loading": "Loading review progress", + "error": "Review progress could not be loaded. Try again later.", + "historyError": "Submission history could not be loaded. Try again later.", + "emptyTitle": "No review history yet", + "emptyDescription": "Reviews for public or team skills will appear here after submission." + }, "reviews": { "title": "Review Center", "subtitle": "Manage platform review tasks", @@ -1332,6 +1354,7 @@ "governance": "Governance Center", "stars": "Starred", "subscriptions": "My Subscriptions", + "reviewProgress": "My Review Progress", "reviews": "Review Management", "promotions": "Promotion Management", "reports": "Report Management", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 31dd013a..b54c2a26 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -491,6 +491,28 @@ "switchToLight": "Переключить на светлую тему", "switchToDark": "Переключить на тёмную тему" }, + "reviewProgress": { + "title": "Мои проверки", + "subtitle": "Текущий статус, комментарии проверяющего и история повторных отправок", + "searchLabel": "Поиск навыков или пространств имён", + "searchPlaceholder": "Навык или пространство имён", + "searchAction": "Найти", + "statusFilter": "Фильтр по статусу проверки", + "statusAll": "Все статусы", + "statusPending": "На проверке", + "statusApproved": "Одобрено", + "statusRejected": "Отклонено", + "latestSubmitted": "Последняя отправка: {{time}}", + "attemptCount": "Отправок: {{count}}", + "attemptNumber": "Попытка {{number}}", + "history": "История отправок", + "resubmit": "Изменить и отправить снова", + "loading": "Загрузка статуса проверки", + "error": "Не удалось загрузить статус проверки. Повторите попытку позже.", + "historyError": "Не удалось загрузить историю. Повторите попытку позже.", + "emptyTitle": "Истории проверок пока нет", + "emptyDescription": "После отправки публичного или командного навыка его статус появится здесь." + }, "reviews": { "title": "Центр рецензирования", "subtitle": "Задачи рецензирования на платформе", @@ -1633,6 +1655,7 @@ "governance": "Центр управления", "stars": "Избранное", "subscriptions": "Мои подписки", + "reviewProgress": "Мои проверки", "reviews": "Управление ревью", "promotions": "Управление продвижением", "reports": "Управление жалобами", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 01899db2..3a7c7f40 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -491,6 +491,28 @@ "switchToLight": "切换到浅色主题", "switchToDark": "切换到深色主题" }, + "reviewProgress": { + "title": "我的审核进度", + "subtitle": "查看技能提交的当前状态、审核意见和每次重提记录", + "searchLabel": "搜索技能或命名空间", + "searchPlaceholder": "搜索 Skill 或命名空间", + "searchAction": "搜索", + "statusFilter": "按审核状态筛选", + "statusAll": "全部状态", + "statusPending": "审核中", + "statusApproved": "已通过", + "statusRejected": "已拒绝", + "latestSubmitted": "最近提交:{{time}}", + "attemptCount": "共 {{count}} 次提交", + "attemptNumber": "第 {{number}} 次", + "history": "提交历史", + "resubmit": "修改并重提", + "loading": "正在加载审核进度", + "error": "审核进度加载失败,请稍后重试。", + "historyError": "提交历史加载失败,请稍后重试。", + "emptyTitle": "还没有审核记录", + "emptyDescription": "提交公开或团队 Skill 后,审核进度会显示在这里。" + }, "reviews": { "title": "审核中心", "subtitle": "管理平台审核事务", @@ -1332,6 +1354,7 @@ "governance": "治理中心", "stars": "我的收藏", "subscriptions": "我的订阅", + "reviewProgress": "我的审核进度", "reviews": "审核管理", "promotions": "推广管理", "reports": "举报管理", diff --git a/web/src/pages/dashboard/review-progress.test.tsx b/web/src/pages/dashboard/review-progress.test.tsx new file mode 100644 index 00000000..d7c4f173 --- /dev/null +++ b/web/src/pages/dashboard/review-progress.test.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { ReviewProgressPage } from './review-progress' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'zh' }, + }), +})) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), + useSearch: () => ({}), + Link: ({ children, to, className }: { children: ReactNode; to: string; className?: string }) => ( + {children} + ), +})) + +vi.mock('@/features/review/use-my-review-progress', () => ({ + useMyReviewProgress: () => ({ + data: { + items: [{ + latestReviewTaskId: 42, + skillId: 7, + namespace: 'team-a', + skillSlug: 'demo-skill', + skillVersion: '1.2.0', + latestStatus: 'REJECTED', + latestReviewComment: 'Please clarify the permission requirements.', + latestSubmittedAt: '2026-09-01T01:00:00Z', + latestReviewedAt: '2026-09-01T02:00:00Z', + attemptCount: 2, + }], + total: 1, + page: 0, + size: 20, + }, + isLoading: false, + isError: false, + }), + useMyReviewAttempts: () => ({ data: [], isLoading: false, isError: false }), +})) + +describe('ReviewProgressPage', () => { + it('renders the author-facing latest result and keeps reviewer management out of the page', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('reviewProgress.title') + expect(html).toContain('@team-a/demo-skill') + expect(html).toContain('v1.2.0') + expect(html).toContain('reviewProgress.statusRejected') + expect(html).toContain('reviewProgress.resubmit') + expect(html).toContain('reviewProgress.history') + expect(html).not.toContain('reviews.typeSkill') + }) +}) diff --git a/web/src/pages/dashboard/review-progress.tsx b/web/src/pages/dashboard/review-progress.tsx new file mode 100644 index 00000000..4be1f93b --- /dev/null +++ b/web/src/pages/dashboard/review-progress.tsx @@ -0,0 +1,255 @@ +import { useState, type FormEvent } from 'react' +import { Link, useNavigate, useSearch } from '@tanstack/react-router' +import { ChevronDown, ChevronUp, Clock3, RotateCcw, Search } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import type { ReviewProgress, ReviewTask } from '@/api/types' +import { useMyReviewAttempts, useMyReviewProgress } from '@/features/review/use-my-review-progress' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { Pagination } from '@/shared/components/pagination' +import { formatLocalDateTime } from '@/shared/lib/date-time' +import { cn } from '@/shared/lib/utils' +import { Button, buttonVariants } from '@/shared/ui/button' +import { Card, CardContent } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' + +type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' +const PAGE_SIZE = 20 + +const statusClassNames: Record = { + PENDING: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-300', + APPROVED: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300', + REJECTED: 'border-red-500/25 bg-red-500/10 text-red-800 dark:text-red-300', +} + +export function ReviewProgressPage() { + const { t, i18n } = useTranslation() + const navigate = useNavigate() + const search = useSearch({ from: '/dashboard/review-progress' }) + const [queryInput, setQueryInput] = useState(search.q ?? '') + const [expandedId, setExpandedId] = useState(null) + const progressQuery = useMyReviewProgress({ + status: search.status, + q: search.q, + page: search.page ?? 0, + size: PAGE_SIZE, + }) + + const page = search.page ?? 0 + const totalPages = progressQuery.data + ? Math.ceil(progressQuery.data.total / progressQuery.data.size) + : 0 + + function updateSearch(next: { status?: ReviewStatus | null; q?: string; page?: number }) { + void navigate({ + to: '/dashboard/review-progress', + search: { + status: next.status === null ? undefined : next.status ?? search.status, + q: next.q ?? search.q, + page: next.page ?? 0, + }, + replace: true, + }) + } + + function submitSearch(event: FormEvent) { + event.preventDefault() + updateSearch({ q: queryInput.trim(), page: 0 }) + } + + return ( +
+ + + + +
+
+
+ + setQueryInput(event.target.value)} + placeholder={t('reviewProgress.searchPlaceholder')} + className="pl-9" + aria-label={t('reviewProgress.searchLabel')} + /> +
+ +
+
+ +
+
+ + {progressQuery.isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ ) : progressQuery.isError ? ( +
+ {t('reviewProgress.error')} +
+ ) : progressQuery.data?.items.length ? ( +
+ {progressQuery.data.items.map((item) => ( + setExpandedId((current) => ( + current === item.latestReviewTaskId ? null : item.latestReviewTaskId + ))} + locale={i18n.language} + /> + ))} +
+ ) : ( +
+ +

{t('reviewProgress.emptyTitle')}

+

{t('reviewProgress.emptyDescription')}

+
+ )} + + {totalPages > 1 ? ( + updateSearch({ page: nextPage })} + /> + ) : null} + + +
+ ) +} + +function ProgressItem({ + item, + expanded, + onToggle, + locale, +}: { + item: ReviewProgress + expanded: boolean + onToggle: () => void + locale: string +}) { + const { t } = useTranslation() + const attemptsQuery = useMyReviewAttempts(expanded ? item.latestReviewTaskId : null) + const status = item.latestStatus + + return ( +
+
+
+
+ + @{item.namespace}/{item.skillSlug} + + + v{item.skillVersion} + + + {t(`reviewProgress.status${status === 'PENDING' ? 'Pending' : status === 'APPROVED' ? 'Approved' : 'Rejected'}`)} + +
+
+ {t('reviewProgress.latestSubmitted', { time: formatLocalDateTime(item.latestSubmittedAt, locale) })} + {t('reviewProgress.attemptCount', { count: item.attemptCount })} +
+ {item.latestReviewComment ? ( +

{item.latestReviewComment}

+ ) : null} +
+ +
+ {status === 'REJECTED' ? ( + + + {t('reviewProgress.resubmit')} + + ) : null} + +
+
+ + {expanded ? ( +
+ {attemptsQuery.isLoading ? ( +
+ ) : attemptsQuery.isError ? ( +

{t('reviewProgress.historyError')}

+ ) : ( + + )} +
+ ) : null} +
+ ) +} + +function AttemptTimeline({ attempts, locale }: { attempts: ReviewTask[]; locale: string }) { + const { t } = useTranslation() + + return ( +
    + {attempts.map((attempt, index) => ( +
  1. + + {t('reviewProgress.attemptNumber', { number: attempts.length - index })} + +
    + + {t(`reviewProgress.status${attempt.status === 'PENDING' ? 'Pending' : attempt.status === 'APPROVED' ? 'Approved' : 'Rejected'}`)} + + {attempt.reviewComment ?

    {attempt.reviewComment}

    : null} +
    + +
  2. + ))} +
+ ) +} diff --git a/web/src/shared/components/user-menu.test.tsx b/web/src/shared/components/user-menu.test.tsx index 3487bd8b..0857a185 100644 --- a/web/src/shared/components/user-menu.test.tsx +++ b/web/src/shared/components/user-menu.test.tsx @@ -77,6 +77,20 @@ describe('user-menu module exports', () => { }) describe('UserMenu security settings visibility', () => { + it('keeps author review progress separate from reviewer management', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).toContain('user.menu.reviewProgress') + expect(html).not.toContain('user.menu.reviews') + }) + it('shows security settings when password changes are allowed, independent of OAuth provider', () => { const html = renderToStaticMarkup( {t('user.menu.subscriptions')} + + {t('user.menu.reviewProgress')} + {reviewCenterVisible ? ( {t('user.menu.reviews')}