From 20cf45eda153f854769160bb9892b3cfb52e75e7 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:10:57 +0800 Subject: [PATCH 1/3] test(review): reproduce missing suite review notification target Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../portal/NotificationControllerTest.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java index 90e195bd4..0f4f9f410 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java @@ -68,6 +68,30 @@ class NotificationControllerTest { verify(notificationService).list(org.mockito.ArgumentMatchers.eq("user-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class)); } + @Test + void list_shouldExposeReviewTargetRouteForSubmittedSuiteReviewNotifications() { + Notification notification = notification( + 12L, + NotificationCategory.REVIEW, + "SUITE_REVIEW_SUBMITTED", + "{\"namespace\":\"demo\",\"slug\":\"writers\",\"suiteName\":\"Writing suite\"}", + "REVIEW", + 101L + ); + when(notificationService.list(org.mockito.ArgumentMatchers.eq("admin-1"), + org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), + org.mockito.ArgumentMatchers.any(Pageable.class))) + .thenReturn(new PageImpl<>(java.util.List.of(notification))); + + PageResponse page = controller.list("admin-1", "REVIEW", 0, 20).data(); + + assertThat(page.items()).singleElement().satisfies(item -> { + assertThat(item.targetType()).isEqualTo("REVIEW"); + assertThat(item.targetId()).isEqualTo(101L); + assertThat(item.targetRoute()).isEqualTo("/dashboard/reviews/101"); + }); + } + @Test void list_shouldExposeProfileReviewTargetRouteForSubmittedProfileReviewNotifications() { Notification notification = notification( From 58ae93873d72b09f6d71df1ace2d0a6e46f5157a Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:19:59 +0800 Subject: [PATCH 2/3] fix(notification): send suite review submission notifications Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/05-business-flows.md | 3 + .../portal/NotificationController.java | 3 +- .../SkillSuiteReviewNotificationListener.java | 101 +++++++++ ...llSuiteReviewNotificationListenerTest.java | 202 ++++++++++++++++++ ...uiteReviewNotificationTransactionTest.java | 92 ++++++++ .../event/SkillSuiteReviewSubmittedEvent.java | 11 + .../suite/SkillSuiteLifecycleService.java | 7 + .../suite/SkillSuiteLifecycleServiceTest.java | 59 ++++- .../notification/notification-content.test.ts | 22 ++ .../notification/notification-content.ts | 8 +- web/src/i18n/locales/en.json | 4 + web/src/i18n/locales/ko.json | 4 + web/src/i18n/locales/ru.json | 4 + web/src/i18n/locales/zh.json | 4 + 14 files changed, 514 insertions(+), 10 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListener.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListenerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewSubmittedEvent.java diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index b130eec62..4e1239302 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -251,11 +251,14 @@ Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确 | `SkillDownloadedEvent` | 已发布版本下载结果构建成功并完成同步计数后 | 当前未发现生产消费者;下载计数由下载服务同步完成 | | `SkillStarredEvent` | 收藏/取消 | 收藏计数 | | `SkillRatedEvent` | 评分提交 | 评分重算 | +| `SkillSuiteReviewSubmittedEvent` | PUBLIC 或 NAMESPACE_ONLY 套件版本进入待审核 | 事务提交后异步创建 `REVIEW` 类站内通知;团队空间通知其 Owner/Admin,全局空间通知平台技能管理员,通知目标为对应审核任务 | | `ReviewCompletedEvent` | 审核完成 | 预留给后续通知能力(当前可不消费) | | `SkillPublishedEvent`(提升路径) | 首次提升或提升更新审核通过 | 重建目标全局 skill 的搜索文档;不是独立的 `SkillPromotedEvent` | 一期用 Spring ApplicationEvent + `@Async` 实现,后续可替换为消息队列。 +套件提交审核通知沿用用户的“审核通知”偏好,关闭该类别后不创建站内通知。通知从本次提交的套件版本读取名称和版本号,点击后进入统一审核中心的对应任务;进入详情和批准权限仍由审核接口校验,收到通知不等于获得审核权限。通知为提交后的 best-effort 副作用,不保证进程终止后的补发,也不新增邮件投递。 + ### 审计日志写入策略 审计要求是同步落库,与业务操作在同一请求内记录,不以异步事件替代。审计是企业内部平台的刚性需求;普通 Web / CLI 下载链的已知实现缺口见第 3 节,不能将本要求视为所有入口均已实现的证明。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java index cb8f7d4cf..c16bde8d9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java @@ -100,7 +100,8 @@ public class NotificationController extends BaseApiController { String namespace = body.get("namespace") instanceof String value ? value : null; String slug = body.get("slug") instanceof String value ? value : null; - if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) { + if (("REVIEW_SUBMITTED".equals(eventType) || "SUITE_REVIEW_SUBMITTED".equals(eventType)) + && entityId != null) { return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId); } if ("PROFILE_REVIEW_SUBMITTED".equals(eventType) && entityId != null) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListener.java new file mode 100644 index 000000000..63ab79257 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListener.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.listener; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.service.NotificationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Keeps Suite review notifications separate from Skill-specific event payloads. */ +@Component +public class SkillSuiteReviewNotificationListener { + + private static final Logger log = LoggerFactory.getLogger(SkillSuiteReviewNotificationListener.class); + + private final SkillSuiteRepository suiteRepository; + private final SkillSuiteVersionRepository versionRepository; + private final NamespaceRepository namespaceRepository; + private final RecipientResolver recipientResolver; + private final NotificationService notificationService; + private final ObjectMapper objectMapper; + + public SkillSuiteReviewNotificationListener( + SkillSuiteRepository suiteRepository, + SkillSuiteVersionRepository versionRepository, + NamespaceRepository namespaceRepository, + RecipientResolver recipientResolver, + NotificationService notificationService, + ObjectMapper objectMapper + ) { + this.suiteRepository = suiteRepository; + this.versionRepository = versionRepository; + this.namespaceRepository = namespaceRepository; + this.recipientResolver = recipientResolver; + this.notificationService = notificationService; + this.objectMapper = objectMapper; + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onReviewSubmitted(SkillSuiteReviewSubmittedEvent event) { + var suite = suiteRepository.findById(event.suiteId()).orElse(null); + if (suite == null || !Objects.equals(suite.getNamespaceId(), event.namespaceId())) { + return; + } + var version = versionRepository.findById(event.versionId()).orElse(null); + if (version == null || !Objects.equals(version.getSuiteId(), suite.getId())) { + return; + } + var namespace = namespaceRepository.findById(event.namespaceId()).orElse(null); + if (namespace == null) { + return; + } + + String displayName = version.getDisplayName(); + String name = displayName != null && !displayName.isBlank() ? displayName : suite.getSlug(); + Map body = new LinkedHashMap<>(); + body.put("suiteId", suite.getId()); + body.put("suiteName", name); + body.put("namespace", namespace.getSlug()); + body.put("slug", suite.getSlug()); + body.put("version", version.getVersion()); + body.put("reviewId", event.reviewId()); + body.put("submitterId", event.submitterId()); + String json = toJson(body, event.reviewId()); + var recipients = namespace.getType() == NamespaceType.GLOBAL + ? recipientResolver.resolvePlatformSkillAdmins() + : recipientResolver.resolveNamespaceAdmins(event.namespaceId()); + for (String recipient : recipients.stream().distinct().toList()) { + try { + notificationService.create(recipient, NotificationCategory.REVIEW, + "SUITE_REVIEW_SUBMITTED", "Suite review submitted", + json, "REVIEW", event.reviewId()); + } catch (RuntimeException exception) { + log.warn("Failed to create Suite review notification [reviewId={}, recipientId={}]", + event.reviewId(), recipient); + } + } + } + + private String toJson(Map body, Long reviewId) { + try { + return objectMapper.writeValueAsString(body); + } catch (JsonProcessingException exception) { + log.warn("Failed to serialize Suite review notification [reviewId={}]", reviewId); + return "{}"; + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListenerTest.java new file mode 100644 index 000000000..6300e50a1 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListenerTest.java @@ -0,0 +1,202 @@ +package com.iflytek.skillhub.listener; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.notification.domain.Notification; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.domain.NotificationChannel; +import com.iflytek.skillhub.notification.domain.NotificationRepository; +import com.iflytek.skillhub.notification.service.NotificationPreferenceService; +import com.iflytek.skillhub.notification.service.NotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Clock; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillSuiteReviewNotificationListenerTest { + + @Mock private SkillSuiteRepository suiteRepository; + @Mock private SkillSuiteVersionRepository versionRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private RecipientResolver recipientResolver; + @Mock private NotificationService notificationService; + + private final ObjectMapper mapper = new ObjectMapper(); + private SkillSuiteReviewNotificationListener listener; + private SkillSuite suite; + private SkillSuiteVersion version; + private Namespace namespace; + private final SkillSuiteReviewSubmittedEvent event = + new SkillSuiteReviewSubmittedEvent(100L, 10L, 20L, "author", 5L); + + @BeforeEach + void setUp() { + listener = listener(notificationService); + suite = new SkillSuite(5L, "writers", "Previously published name", "author"); + ReflectionTestUtils.setField(suite, "id", 10L); + version = new SkillSuiteVersion(10L, "2.0.0", "Writing suite", "New summary", + SkillVisibility.PUBLIC, "author"); + ReflectionTestUtils.setField(version, "id", 20L); + namespace = new Namespace("team", "Team", "owner"); + } + + @Test + void teamSubmissionNotifiesOnlyResolvedNamespaceAdministratorsOnceWithTheBoundVersion() throws Exception { + stubResources(); + when(recipientResolver.resolveNamespaceAdmins(5L)) + .thenReturn(List.of("owner", "admin", "owner")); + + listener.onReviewSubmitted(event); + + ArgumentCaptor json = ArgumentCaptor.forClass(String.class); + verify(notificationService).create(eq("owner"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), eq("Suite review submitted"), json.capture(), + eq("REVIEW"), eq(100L)); + verify(notificationService).create(eq("admin"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L)); + var body = mapper.readTree(json.getValue()); + assertThat(body.path("suiteId").asLong()).isEqualTo(10L); + assertThat(body.path("suiteName").asText()).isEqualTo("Writing suite"); + assertThat(body.path("namespace").asText()).isEqualTo("team"); + assertThat(body.path("slug").asText()).isEqualTo("writers"); + assertThat(body.path("version").asText()).isEqualTo("2.0.0"); + assertThat(body.path("reviewId").asLong()).isEqualTo(100L); + assertThat(body.path("submitterId").asText()).isEqualTo("author"); + assertThat(body.has("skillId")).isFalse(); + verify(recipientResolver, never()).resolvePlatformSkillAdmins(); + verify(notificationService, never()).create(eq("unrelated-member"), any(), any(), any(), any(), any(), any()); + } + + @Test + void globalSubmissionNotifiesPlatformSkillAdministratorsInsteadOfNamespaceMembers() { + namespace.setType(NamespaceType.GLOBAL); + stubResources(); + when(recipientResolver.resolvePlatformSkillAdmins()).thenReturn(List.of("skill-admin", "super-admin")); + + listener.onReviewSubmitted(event); + + verify(notificationService).create(eq("skill-admin"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L)); + verify(notificationService).create(eq("super-admin"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L)); + verify(recipientResolver, never()).resolveNamespaceAdmins(anyLong()); + } + + @Test + void missingSuiteDoesNotCreateANotification() { + when(suiteRepository.findById(10L)).thenReturn(Optional.empty()); + + listener.onReviewSubmitted(event); + + verifyNoInteractions(notificationService, recipientResolver); + } + + @Test + void mismatchedNamespaceDoesNotExposeSuiteMetadataToAnotherSpace() { + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + + listener.onReviewSubmitted(new SkillSuiteReviewSubmittedEvent(100L, 10L, 20L, "author", 6L)); + + verifyNoInteractions(notificationService, recipientResolver, namespaceRepository, versionRepository); + } + + @Test + void mismatchedVersionDoesNotCreateANotificationForAnotherSuite() { + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + SkillSuiteVersion otherVersion = new SkillSuiteVersion(11L, "1.0.0", SkillVisibility.PUBLIC, "author"); + when(versionRepository.findById(20L)).thenReturn(Optional.of(otherVersion)); + + listener.onReviewSubmitted(event); + + verifyNoInteractions(notificationService, recipientResolver, namespaceRepository); + } + + @Test + void oneRecipientFailureDoesNotStopOtherAdministratorsFromReceivingTheRequest() { + stubResources(); + when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("unavailable-admin", "owner")); + doThrow(new IllegalStateException("Controlled notification storage failure")) + .when(notificationService).create(eq("unavailable-admin"), any(), any(), any(), any(), any(), any()); + + listener.onReviewSubmitted(event); + + verify(notificationService).create(eq("owner"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L)); + } + + @Test + void suiteReviewUsesTheExistingReviewPreferenceRatherThanASeparateNotificationSetting() { + stubResources(); + when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("muted-owner", "admin")); + NotificationRepository notifications = mock(NotificationRepository.class); + NotificationPreferenceService preferences = mock(NotificationPreferenceService.class); + when(preferences.isEnabled("muted-owner", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(false); + when(preferences.isEnabled("admin", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(true); + NotificationService service = new NotificationService(notifications, preferences, Clock.systemUTC()); + + listener(service).onReviewSubmitted(event); + + ArgumentCaptor saved = ArgumentCaptor.forClass(Notification.class); + verify(notifications).save(saved.capture()); + assertThat(saved.getValue().getRecipientId()).isEqualTo("admin"); + assertThat(saved.getValue().getCategory()).isEqualTo(NotificationCategory.REVIEW); + } + + @Test + void maximumLengthSuiteNameRemainsInTheBodyWithoutOverflowingTheNotificationTitle() throws Exception { + String longName = "套".repeat(256); + version.setDisplayName(longName); + stubResources(); + when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("owner")); + + listener.onReviewSubmitted(event); + + ArgumentCaptor title = ArgumentCaptor.forClass(String.class); + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + verify(notificationService).create(eq("owner"), any(), anyString(), title.capture(), body.capture(), + eq("REVIEW"), eq(100L)); + assertThat(title.getValue()).hasSizeLessThanOrEqualTo(200); + assertThat(mapper.readTree(body.getValue()).path("suiteName").asText()).isEqualTo(longName); + } + + private void stubResources() { + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + when(versionRepository.findById(20L)).thenReturn(Optional.of(version)); + when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace)); + } + + private SkillSuiteReviewNotificationListener listener(NotificationService notifications) { + return new SkillSuiteReviewNotificationListener(suiteRepository, versionRepository, namespaceRepository, + recipientResolver, notifications, mapper); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java new file mode 100644 index 000000000..ccf712c7f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java @@ -0,0 +1,92 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.service.NotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.after; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** Exercises Spring's real transactional event delivery; database lifecycle coverage is separate. */ +@SpringBootTest +@ActiveProfiles("test") +class SkillSuiteReviewNotificationTransactionTest { + + @Autowired private ApplicationEventPublisher events; + @Autowired private PlatformTransactionManager transactions; + @MockBean private SkillSuiteRepository suites; + @MockBean private SkillSuiteVersionRepository versions; + @MockBean private NamespaceRepository namespaces; + @MockBean private RecipientResolver recipients; + @MockBean private NotificationService notifications; + + private final SkillSuiteReviewSubmittedEvent event = + new SkillSuiteReviewSubmittedEvent(912350L, 912351L, 912352L, "author", 912353L); + + @BeforeEach + void setUp() { + SkillSuite suite = new SkillSuite(912353L, "suite-event-test", "Suite", "author"); + ReflectionTestUtils.setField(suite, "id", 912351L); + SkillSuiteVersion version = new SkillSuiteVersion( + 912351L, "1.0.0", "Suite snapshot", "Summary", SkillVisibility.PUBLIC, "author"); + Namespace namespace = new Namespace("suite-review-team", "Team", "owner"); + when(suites.findById(912351L)).thenReturn(Optional.of(suite)); + when(versions.findById(912352L)).thenReturn(Optional.of(version)); + when(namespaces.findById(912353L)).thenReturn(Optional.of(namespace)); + when(recipients.resolveNamespaceAdmins(912353L)).thenReturn(List.of("suite-review-admin")); + } + + @Test + void committedSubmissionNotifiesOnlyAfterTheTransactionCompletes() { + new TransactionTemplate(transactions).executeWithoutResult(status -> { + events.publishEvent(event); + verify(notifications, never()).create(any(), any(), any(), any(), any(), any(), any()); + }); + + verify(notifications, timeout(5000)).create(eq("suite-review-admin"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(912350L)); + } + + @Test + void rolledBackSubmissionDoesNotNotify() { + new TransactionTemplate(transactions).executeWithoutResult(status -> { + events.publishEvent(event); + status.setRollbackOnly(); + }); + + verify(notifications, after(500).never()).create(any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void eventOutsideATransactionDoesNotBypassTheCommitBoundary() { + events.publishEvent(event); + + verify(notifications, after(500).never()).create(any(), any(), any(), any(), any(), any(), any()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewSubmittedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewSubmittedEvent.java new file mode 100644 index 000000000..204704adb --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewSubmittedEvent.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.event; + +/** A Suite version has entered review; consumers run only after its transaction commits. */ +public record SkillSuiteReviewSubmittedEvent( + Long reviewId, + Long suiteId, + Long versionId, + String submitterId, + Long namespaceId +) { +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java index 6d487ebb5..043298ef5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.suite; import com.iflytek.skillhub.domain.audit.AuditDetail; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -18,6 +19,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -41,6 +43,7 @@ public class SkillSuiteLifecycleService { private final NamespaceRepository namespaceRepository; private final SkillSuitePublicationValidator publicationValidator; private final AuditLogService auditLogService; + private final ApplicationEventPublisher eventPublisher; private final Clock clock; private final boolean reviewWritesEnabled; @@ -52,6 +55,7 @@ public class SkillSuiteLifecycleService { NamespaceRepository namespaceRepository, SkillSuitePublicationValidator publicationValidator, AuditLogService auditLogService, + ApplicationEventPublisher eventPublisher, Clock clock, @Value("${skillhub.suite.review-writes-enabled}") boolean reviewWritesEnabled ) { @@ -62,6 +66,7 @@ public class SkillSuiteLifecycleService { this.namespaceRepository = namespaceRepository; this.publicationValidator = publicationValidator; this.auditLogService = auditLogService; + this.eventPublisher = eventPublisher; this.clock = clock; this.reviewWritesEnabled = reviewWritesEnabled; if (!reviewWritesEnabled) { @@ -91,6 +96,8 @@ public class SkillSuiteLifecycleService { versionId, suiteId, loaded.suite().getNamespaceId(), loaded.version().getVersion(), context.actorUserId())); audit(context, "SUBMIT_SKILL_SUITE_REVIEW", "SKILL_SUITE_VERSION", versionId, null); + eventPublisher.publishEvent(new SkillSuiteReviewSubmittedEvent( + task.getId(), suiteId, versionId, context.actorUserId(), loaded.suite().getNamespaceId())); log.info("Suite review submitted [suiteId={}, versionId={}, actorId={}, requestId={}]", suiteId, versionId, context.actorUserId(), context.requestId()); return task; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java index af5e6441f..d0945af29 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.suite; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -17,8 +18,11 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import java.time.Clock; import java.time.Instant; @@ -45,6 +49,7 @@ class SkillSuiteLifecycleServiceTest { @Mock private NamespaceRepository namespaceRepository; @Mock private SkillSuitePublicationValidator publicationValidator; @Mock private AuditLogService auditLogService; + @Mock private ApplicationEventPublisher eventPublisher; private SkillSuiteLifecycleService service; private SkillSuite suite; @@ -55,7 +60,7 @@ class SkillSuiteLifecycleServiceTest { Clock clock = Clock.fixed(Instant.parse("2026-09-07T08:00:00Z"), ZoneOffset.UTC); service = new SkillSuiteLifecycleService( suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(), namespaceRepository, - publicationValidator, auditLogService, clock, true); + publicationValidator, auditLogService, eventPublisher, clock, true); suite = new SkillSuite(1L, "writers", "Writers", "author"); version = new SkillSuiteVersion(10L, "1.0.0", SkillVisibility.PRIVATE, "author"); setId(suite, 10L); @@ -75,6 +80,7 @@ class SkillSuiteLifecycleServiceTest { verify(auditLogService).record( "author", "PUBLISH_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", 20L, "request-1", "127.0.0.1", "test", null); + verify(eventPublisher, never()).publishEvent(any(Object.class)); } @Test @@ -93,12 +99,17 @@ class SkillSuiteLifecycleServiceTest { verify(suiteRepository, never()).save(any()); } - @Test - void publicDraftCreatesTypedReviewAndBecomesPending() { - version.setVisibility(SkillVisibility.PUBLIC); + @ParameterizedTest + @EnumSource(value = SkillVisibility.class, names = {"PUBLIC", "NAMESPACE_ONLY"}) + void shareableDraftCreatesTypedReviewAndBecomesPending(SkillVisibility visibility) throws Exception { + version.setVisibility(visibility); stubLoaded(); when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - when(reviewTaskRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(reviewTaskRepository.save(any())).thenAnswer(invocation -> { + ReviewTask task = invocation.getArgument(0); + setId(task, 30L); + return task; + }); ReviewTask task = service.submitForReview(10L, 20L, context()); @@ -107,6 +118,39 @@ class SkillSuiteLifecycleServiceTest { assertThat(task.getSubjectId()).isEqualTo(10L); assertThat(task.getSubjectVersionId()).isEqualTo(20L); verify(publicationValidator).validateForPublication(suite, version); + verify(eventPublisher).publishEvent(new SkillSuiteReviewSubmittedEvent( + 30L, 10L, 20L, "author", 1L)); + } + + @Test + void repeatedSubmissionDoesNotCreateAnotherReviewOrEmitAnotherNotificationEvent() { + version.setVisibility(SkillVisibility.NAMESPACE_ONLY); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + stubLoaded(); + + assertThatThrownBy(() -> service.submitForReview(10L, 20L, context())) + .isInstanceOf(DomainBadRequestException.class) + .hasMessage("error.suite.review.notDraft"); + + verify(reviewTaskRepository, never()).save(any()); + verify(versionRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any(Object.class)); + } + + @Test + void failedSubmissionAuditDoesNotEmitANotificationEvent() { + version.setVisibility(SkillVisibility.PUBLIC); + stubLoaded(); + when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(reviewTaskRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + doThrow(new IllegalStateException("Controlled audit failure")).when(auditLogService).record( + "author", "SUBMIT_SKILL_SUITE_REVIEW", "SKILL_SUITE_VERSION", 20L, + "request-1", "127.0.0.1", "test", null); + + assertThatThrownBy(() -> service.submitForReview(10L, 20L, context())) + .isInstanceOf(IllegalStateException.class); + + verify(eventPublisher, never()).publishEvent(any(Object.class)); } @Test @@ -123,6 +167,7 @@ class SkillSuiteLifecycleServiceTest { assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.DRAFT); verify(reviewTaskRepository, never()).save(any()); verify(versionRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any(Object.class)); } @Test @@ -210,7 +255,7 @@ class SkillSuiteLifecycleServiceTest { void rolloutGateRejectsTypedReviewWritesBeforeLoadingTheSuite() { SkillSuiteLifecycleService disabled = new SkillSuiteLifecycleService( suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(), - namespaceRepository, publicationValidator, auditLogService, + namespaceRepository, publicationValidator, auditLogService, eventPublisher, Clock.systemUTC(), false); assertThatThrownBy(() -> disabled.submitForReview(10L, 20L, context())) @@ -224,7 +269,7 @@ class SkillSuiteLifecycleServiceTest { version.setVisibility(SkillVisibility.PUBLIC); SkillSuiteLifecycleService disabled = new SkillSuiteLifecycleService( suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(), - namespaceRepository, publicationValidator, auditLogService, + namespaceRepository, publicationValidator, auditLogService, eventPublisher, Clock.systemUTC(), false); Set actions = disabled.allowedActions( diff --git a/web/src/features/notification/notification-content.test.ts b/web/src/features/notification/notification-content.test.ts index 7ff569731..3f44e1d3a 100644 --- a/web/src/features/notification/notification-content.test.ts +++ b/web/src/features/notification/notification-content.test.ts @@ -2,6 +2,28 @@ import { describe, expect, it } from 'vitest' import { resolveNotificationDisplay } from './notification-content' describe('resolveNotificationDisplay', () => { + it.each([ + ['zh-CN', '套件审核提交'], + ['en', 'Suite review submitted'], + ['ko-KR', '스킬 모음 검토 제출'], + ['ru', 'Набор отправлен на проверку'], + ])('renders a Suite submission with its own title and bound version in %s', (language, title) => { + const display = resolveNotificationDisplay({ + id: 1, + category: 'REVIEW', + eventType: 'SUITE_REVIEW_SUBMITTED', + title: 'Suite review submitted', + bodyJson: JSON.stringify({ suiteName: 'Writing suite', version: '2.0.0' }), + status: 'UNREAD', + createdAt: '2026-11-05T00:00:00Z', + }, language) + + expect(display.title).toBe(title) + expect(display.description).toContain('Writing suite') + expect(display.description).toContain('2.0.0') + expect(display.description).not.toContain('{{') + }) + it('renders review submitted content in Chinese', () => { const display = resolveNotificationDisplay({ category: 'REVIEW', diff --git a/web/src/features/notification/notification-content.ts b/web/src/features/notification/notification-content.ts index 6a2c09191..83f6ab20c 100644 --- a/web/src/features/notification/notification-content.ts +++ b/web/src/features/notification/notification-content.ts @@ -8,6 +8,7 @@ export type NotificationDisplay = { type NotificationBody = { skillName?: string + suiteName?: string version?: string } @@ -24,6 +25,7 @@ function parseBody(bodyJson?: string): NotificationBody { } const localizedEvents = new Set([ + 'SUITE_REVIEW_SUBMITTED', 'REVIEW_SUBMITTED', 'REVIEW_APPROVED', 'REVIEW_REJECTED', 'PROMOTION_SUBMITTED', 'PROMOTION_APPROVED', 'PROMOTION_REJECTED', 'REPORT_SUBMITTED', 'REPORT_RESOLVED', 'SKILL_PUBLISHED', @@ -35,6 +37,8 @@ export function resolveNotificationDisplay(item: NotificationItem, language: str const t = i18n.getFixedT(language) const body = parseBody(item.bodyJson) const skillName = body.skillName ?? '' + const suiteName = body.suiteName ?? '' + const resourceName = item.eventType === 'SUITE_REVIEW_SUBMITTED' ? suiteName : skillName const version = body.version ?? '' const versionSuffix = version ? (zh ? `(${version})` : ` (${version})`) : '' @@ -44,8 +48,8 @@ export function resolveNotificationDisplay(item: NotificationItem, language: str return { title: t(`notificationEvents.${item.eventType}.title`), - description: skillName - ? t(`notificationEvents.${item.eventType}.description`, { skillName, versionSuffix }) + description: resourceName + ? t(`notificationEvents.${item.eventType}.description`, { skillName, suiteName, versionSuffix }) : '', } } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 51260cf04..96cf5f0be 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3443,6 +3443,10 @@ "days": "{{count}}d" }, "notificationEvents": { + "SUITE_REVIEW_SUBMITTED": { + "title": "Suite review submitted", + "description": "{{suiteName}}{{versionSuffix}} was submitted for review." + }, "REVIEW_SUBMITTED": { "title": "Review submitted", "description": "{{skillName}}{{versionSuffix}} was submitted for review." diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index 6eecfc60e..6ec9bb1c7 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -3723,6 +3723,10 @@ "days": "{{count}}일 전" }, "notificationEvents": { + "SUITE_REVIEW_SUBMITTED": { + "title": "스킬 모음 검토 제출", + "description": "{{suiteName}}{{versionSuffix}}의 검토를 요청했습니다." + }, "REVIEW_SUBMITTED": { "title": "스킬 검토 제출", "description": "{{skillName}}{{versionSuffix}}의 검토를 요청했습니다." diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index db02bf1cd..c759b3f55 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3447,6 +3447,10 @@ "days": "{{count}} д" }, "notificationEvents": { + "SUITE_REVIEW_SUBMITTED": { + "title": "Набор отправлен на проверку", + "description": "{{suiteName}}{{versionSuffix}} отправлен на проверку." + }, "REVIEW_SUBMITTED": { "title": "Проверка отправлена", "description": "{{skillName}}{{versionSuffix}} отправлен на проверку." diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 2b1eb7d23..28655eb96 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -3441,6 +3441,10 @@ "days": "{{count}}天" }, "notificationEvents": { + "SUITE_REVIEW_SUBMITTED": { + "title": "套件审核提交", + "description": "{{suiteName}}{{versionSuffix}} 已提交审核。" + }, "REVIEW_SUBMITTED": { "title": "技能审核提交", "description": "{{skillName}}{{versionSuffix}} 已提交审核。" From d26154b228e03a91ceb86dc00d4fa2cbadf49ab5 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:58:00 +0800 Subject: [PATCH 3/3] fix(notification): notify Suite submitters after review outcomes Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../portal/NotificationController.java | 3 + ...SuiteReviewResultNotificationListener.java | 91 ++++++++++++ .../portal/NotificationControllerTest.java | 15 ++ ...uiteReviewNotificationTransactionTest.java | 35 +++++ ...eReviewResultNotificationListenerTest.java | 137 ++++++++++++++++++ .../event/SkillSuiteReviewCompletedEvent.java | 27 ++++ .../suite/SkillSuiteLifecycleService.java | 10 ++ .../suite/SkillSuiteLifecycleServiceTest.java | 50 +++++++ .../notification/notification-content.test.ts | 17 +++ .../notification/notification-content.ts | 4 +- web/src/i18n/locales/en.json | 8 + web/src/i18n/locales/ko.json | 8 + web/src/i18n/locales/ru.json | 8 + web/src/i18n/locales/zh.json | 8 + 14 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListener.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListenerTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewCompletedEvent.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java index c16bde8d9..6bbdfa29c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java @@ -100,6 +100,9 @@ public class NotificationController extends BaseApiController { String namespace = body.get("namespace") instanceof String value ? value : null; String slug = body.get("slug") instanceof String value ? value : null; + if ("SUITE_REVIEW_APPROVED".equals(eventType) || "SUITE_REVIEW_REJECTED".equals(eventType)) { + return new NotificationTarget("REVIEW", entityId, "/dashboard/review-progress"); + } if (("REVIEW_SUBMITTED".equals(eventType) || "SUITE_REVIEW_SUBMITTED".equals(eventType)) && entityId != null) { return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListener.java new file mode 100644 index 000000000..f74764c06 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListener.java @@ -0,0 +1,91 @@ +package com.iflytek.skillhub.listener; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.service.NotificationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.LinkedHashMap; +import java.util.Objects; + +/** Review outcomes belong to the submitter, independently of reviewer notifications. */ +@Component +public class SkillSuiteReviewResultNotificationListener { + private static final Logger log = LoggerFactory.getLogger(SkillSuiteReviewResultNotificationListener.class); + private final ReviewTaskRepository reviews; + private final SkillSuiteRepository suites; + private final SkillSuiteVersionRepository versions; + private final NamespaceRepository namespaces; + private final NotificationService notifications; + private final ObjectMapper mapper; + + public SkillSuiteReviewResultNotificationListener(ReviewTaskRepository reviews, SkillSuiteRepository suites, + SkillSuiteVersionRepository versions, NamespaceRepository namespaces, + NotificationService notifications, ObjectMapper mapper) { + this.reviews = reviews; + this.suites = suites; + this.versions = versions; + this.namespaces = namespaces; + this.notifications = notifications; + this.mapper = mapper; + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onReviewCompleted(SkillSuiteReviewCompletedEvent event) { + var task = reviews.findById(event.reviewId()).orElse(null); + if (task == null || task.getSubjectType() != ReviewSubjectType.SUITE_VERSION + || task.getStatus() != event.status() + || !Objects.equals(task.getSubjectId(), event.suiteId()) + || !Objects.equals(task.getSubjectVersionId(), event.versionId()) + || !Objects.equals(task.getNamespaceId(), event.namespaceId()) + || !Objects.equals(task.getSubmittedBy(), event.submitterId())) { + return; + } + var suite = suites.findById(event.suiteId()).orElse(null); + if (suite == null || !Objects.equals(suite.getNamespaceId(), event.namespaceId())) { + return; + } + var version = versions.findById(event.versionId()).orElse(null); + if (version == null || !Objects.equals(version.getSuiteId(), event.suiteId())) { + return; + } + var namespace = namespaces.findById(event.namespaceId()).orElse(null); + if (namespace == null) { + return; + } + var body = new LinkedHashMap(); + body.put("suiteId", suite.getId()); + body.put("suiteName", version.getDisplayName() == null || version.getDisplayName().isBlank() + ? suite.getSlug() : version.getDisplayName()); + body.put("namespace", namespace.getSlug()); + body.put("slug", suite.getSlug()); + body.put("version", version.getVersion()); + body.put("reviewId", event.reviewId()); + body.put("submitterId", event.submitterId()); + body.put("eventId", event.eventId()); + body.put("occurredAt", event.occurredAt().toString()); + boolean approved = event.status() == ReviewTaskStatus.APPROVED; + try { + notifications.create(event.submitterId(), NotificationCategory.REVIEW, + approved ? "SUITE_REVIEW_APPROVED" : "SUITE_REVIEW_REJECTED", + approved ? "Suite review approved" : "Suite review rejected", + mapper.writeValueAsString(body), "REVIEW", event.reviewId()); + } catch (JsonProcessingException | RuntimeException failure) { + log.warn("Failed to create Suite review result notification [reviewId={}, recipientId={}]", + event.reviewId(), event.submitterId()); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java index 0f4f9f410..ce3c4ee3a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java @@ -92,6 +92,21 @@ class NotificationControllerTest { }); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"SUITE_REVIEW_APPROVED", "SUITE_REVIEW_REJECTED"}) + void completedSuiteReviewLinksToTheSubmittersProgressInsteadOfReviewerOnlyDetails(String eventType) { + Notification item = notification(13L, NotificationCategory.REVIEW, eventType, "{}", "REVIEW", 101L); + when(notificationService.list(org.mockito.ArgumentMatchers.eq("user-1"), + org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), + org.mockito.ArgumentMatchers.any(Pageable.class))) + .thenReturn(new PageImpl<>(java.util.List.of(item))); + var page = controller.list("user-1", "REVIEW", 0, 20).data(); + assertThat(page.items()).singleElement().satisfies(result -> { + assertThat(result.targetId()).isEqualTo(101L); + assertThat(result.targetRoute()).isEqualTo("/dashboard/review-progress"); + }); + } + @Test void list_shouldExposeProfileReviewTargetRouteForSubmittedProfileReviewNotifications() { Notification notification = notification( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java index ccf712c7f..06a8ea729 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java @@ -1,6 +1,10 @@ package com.iflytek.skillhub.listener; import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent; +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.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; @@ -45,6 +49,7 @@ class SkillSuiteReviewNotificationTransactionTest { @MockBean private NamespaceRepository namespaces; @MockBean private RecipientResolver recipients; @MockBean private NotificationService notifications; + @MockBean private ReviewTaskRepository reviewTasks; private final SkillSuiteReviewSubmittedEvent event = new SkillSuiteReviewSubmittedEvent(912350L, 912351L, 912352L, "author", 912353L); @@ -89,4 +94,34 @@ class SkillSuiteReviewNotificationTransactionTest { verify(notifications, after(500).never()).create(any(), any(), any(), any(), any(), any(), any()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"}) + void committedResultNotifiesTheSubmitterOnlyAfterCommit(ReviewTaskStatus result) { + var completed = completed(result); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + events.publishEvent(completed); + verify(notifications, never()).create(any(), any(), any(), any(), any(), any(), any()); + }); + verify(notifications, timeout(5000)).create(eq("author"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_" + result), anyString(), anyString(), eq("REVIEW"), eq(912350L)); + } + + @Test + void rolledBackResultDoesNotNotify() { + var completed = completed(ReviewTaskStatus.APPROVED); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + events.publishEvent(completed); + status.setRollbackOnly(); + }); + verify(notifications, after(500).never()).create(any(), any(), any(), any(), any(), any(), any()); + } + + private SkillSuiteReviewCompletedEvent completed(ReviewTaskStatus result) { + ReviewTask task = ReviewTask.forSuiteVersion(912352L, 912351L, 912353L, "1.0.0", "author"); + task.setStatus(result); + when(reviewTasks.findById(912350L)).thenReturn(Optional.of(task)); + return new SkillSuiteReviewCompletedEvent(912350L, 912351L, 912352L, "author", "suite-review-admin", + 912353L, result, "00000000-0000-0000-0000-000000912350", java.time.Instant.now()); + } + } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListenerTest.java new file mode 100644 index 000000000..6bf4d44bb --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListenerTest.java @@ -0,0 +1,137 @@ +package com.iflytek.skillhub.listener; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +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.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.notification.domain.Notification; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.domain.NotificationChannel; +import com.iflytek.skillhub.notification.domain.NotificationRepository; +import com.iflytek.skillhub.notification.service.NotificationPreferenceService; +import com.iflytek.skillhub.notification.service.NotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillSuiteReviewResultNotificationListenerTest { + @Mock private ReviewTaskRepository reviews; + @Mock private SkillSuiteRepository suites; + @Mock private SkillSuiteVersionRepository versions; + @Mock private NamespaceRepository namespaces; + @Mock private NotificationService notifications; + private final ObjectMapper mapper = new ObjectMapper(); + private SkillSuiteReviewResultNotificationListener listener; + + @BeforeEach + void setUp() { + listener = new SkillSuiteReviewResultNotificationListener(reviews, suites, versions, + namespaces, notifications, mapper); + } + + @ParameterizedTest + @EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"}) + void resultNotifiesTheVersionSubmitterWithoutCopyingReviewerComments(ReviewTaskStatus status) throws Exception { + stub(status, "submitter"); + var event = event(status, "submitter"); + listener.onReviewCompleted(event); + + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + verify(notifications).create(eq("submitter"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_" + status), anyString(), body.capture(), eq("REVIEW"), eq(30L)); + var json = mapper.readTree(body.getValue()); + assertThat(json.path("suiteName").asText()).isEqualTo("Current Suite"); + assertThat(json.path("version").asText()).isEqualTo("2.0.0"); + assertThat(json.path("eventId").asText()).isEqualTo(event.eventId()); + assertThat(json.path("occurredAt").asText()).isEqualTo(event.occurredAt().toString()); + assertThat(json.has("skillId")).isFalse(); + assertThat(body.getValue()).doesNotContain("Private review comment"); + verifyNoMoreInteractions(notifications); + } + + @Test + void mismatchedSubmitterDoesNotReceiveAnotherAuthorsResult() { + ReviewTask task = ReviewTask.forSuiteVersion(20L, 10L, 5L, "2.0.0", "real-submitter"); + task.setStatus(ReviewTaskStatus.APPROVED); + when(reviews.findById(30L)).thenReturn(Optional.of(task)); + listener.onReviewCompleted(event(ReviewTaskStatus.APPROVED, "other-user")); + verifyNoInteractions(notifications, suites, versions, namespaces); + } + + @Test + void staleTaskStatusDoesNotProduceAFalseOutcome() { + ReviewTask task = ReviewTask.forSuiteVersion(20L, 10L, 5L, "2.0.0", "submitter"); + when(reviews.findById(30L)).thenReturn(Optional.of(task)); + listener.onReviewCompleted(event(ReviewTaskStatus.APPROVED, "submitter")); + verifyNoInteractions(notifications, suites, versions, namespaces); + } + + @Test + void notificationFailureIsIsolatedFromTheAlreadyCommittedReview() { + stub(ReviewTaskStatus.APPROVED, "submitter"); + doThrow(new IllegalStateException("Controlled notification failure")) + .when(notifications).create(any(), any(), any(), any(), any(), any(), any()); + assertThatCode(() -> listener.onReviewCompleted(event(ReviewTaskStatus.APPROVED, "submitter"))) + .doesNotThrowAnyException(); + } + + @Test + void resultUsesTheExistingReviewPreferenceAndDoesNotRequireAnEmailAddress() { + stub(ReviewTaskStatus.REJECTED, "submitter"); + NotificationRepository stored = mock(NotificationRepository.class); + NotificationPreferenceService preferences = mock(NotificationPreferenceService.class); + when(preferences.isEnabled("submitter", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(true); + var realService = new NotificationService(stored, preferences, Clock.systemUTC()); + new SkillSuiteReviewResultNotificationListener(reviews, suites, versions, namespaces, realService, mapper) + .onReviewCompleted(event(ReviewTaskStatus.REJECTED, "submitter")); + ArgumentCaptor saved = ArgumentCaptor.forClass(Notification.class); + verify(stored).save(saved.capture()); + assertThat(saved.getValue().getRecipientId()).isEqualTo("submitter"); + } + + private SkillSuiteReviewCompletedEvent event(ReviewTaskStatus status, String submitter) { + return new SkillSuiteReviewCompletedEvent(30L, 10L, 20L, submitter, "reviewer", 5L, + status, "00000000-0000-0000-0000-000000000030", Instant.parse("2026-11-06T00:00:00Z")); + } + + private void stub(ReviewTaskStatus status, String submitter) { + ReviewTask task = ReviewTask.forSuiteVersion(20L, 10L, 5L, "2.0.0", submitter); + task.setStatus(status); + task.setReviewComment("Private review comment"); + when(reviews.findById(30L)).thenReturn(Optional.of(task)); + SkillSuite suite = new SkillSuite(5L, "writers", "Old name", "original-creator"); + ReflectionTestUtils.setField(suite, "id", 10L); + when(suites.findById(10L)).thenReturn(Optional.of(suite)); + SkillSuiteVersion version = new SkillSuiteVersion(10L, "2.0.0", "Current Suite", "Summary", + SkillVisibility.PUBLIC, "submitter"); + when(versions.findById(20L)).thenReturn(Optional.of(version)); + when(namespaces.findById(5L)).thenReturn(Optional.of(new Namespace("team", "Team", "namespace-owner"))); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewCompletedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewCompletedEvent.java new file mode 100644 index 000000000..0dbe49828 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewCompletedEvent.java @@ -0,0 +1,27 @@ +package com.iflytek.skillhub.domain.event; + +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; + +import java.time.Instant; +import java.util.Objects; + +/** Immutable result of one Suite review; delivered only after the transaction commits. */ +public record SkillSuiteReviewCompletedEvent( + Long reviewId, + Long suiteId, + Long versionId, + String submitterId, + String reviewerId, + Long namespaceId, + ReviewTaskStatus status, + String eventId, + Instant occurredAt +) { + public SkillSuiteReviewCompletedEvent { + if (status != ReviewTaskStatus.APPROVED && status != ReviewTaskStatus.REJECTED) { + throw new IllegalArgumentException("Suite review result must be approved or rejected"); + } + Objects.requireNonNull(eventId, "eventId"); + Objects.requireNonNull(occurredAt, "occurredAt"); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java index 043298ef5..92d6db5d4 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.suite; import com.iflytek.skillhub.domain.audit.AuditDetail; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -29,6 +30,7 @@ import java.util.Comparator; import java.util.ConcurrentModificationException; import java.util.EnumSet; import java.util.Set; +import java.util.UUID; /** Coordinates Suite version review and publication without changing any member lifecycle. */ @Service @@ -140,6 +142,10 @@ public class SkillSuiteLifecycleService { publish(loaded.suite(), loaded.version(), context.actorUserId()); audit(context, "APPROVE_SKILL_SUITE_REVIEW", "REVIEW_TASK", reviewTaskId, AuditDetail.of("suiteVersionId", loaded.version().getId())); + eventPublisher.publishEvent(new SkillSuiteReviewCompletedEvent( + task.getId(), loaded.suite().getId(), loaded.version().getId(), task.getSubmittedBy(), + context.actorUserId(), loaded.suite().getNamespaceId(), ReviewTaskStatus.APPROVED, + UUID.randomUUID().toString(), Instant.now(clock))); log.info("Suite review approved [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]", reviewTaskId, loaded.suite().getId(), loaded.version().getId(), context.actorUserId(), context.requestId()); @@ -160,6 +166,10 @@ public class SkillSuiteLifecycleService { versionRepository.save(loaded.version()); audit(context, "REJECT_SKILL_SUITE_REVIEW", "REVIEW_TASK", reviewTaskId, AuditDetail.of("suiteVersionId", loaded.version().getId())); + eventPublisher.publishEvent(new SkillSuiteReviewCompletedEvent( + task.getId(), loaded.suite().getId(), loaded.version().getId(), task.getSubmittedBy(), + context.actorUserId(), loaded.suite().getNamespaceId(), ReviewTaskStatus.REJECTED, + UUID.randomUUID().toString(), Instant.now(clock))); log.info("Suite review rejected [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]", reviewTaskId, loaded.suite().getId(), loaded.version().getId(), context.actorUserId(), context.requestId()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java index d0945af29..26bdcade1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.suite; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -21,6 +22,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; @@ -188,6 +190,54 @@ class SkillSuiteLifecycleServiceTest { assertThat(task.getStatus()).isEqualTo(ReviewTaskStatus.APPROVED); } + @ParameterizedTest + @EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"}) + void completedReviewEmitsOnlyOneBoundResultForTheActualSubmitter(ReviewTaskStatus result) throws Exception { + version.setVisibility(SkillVisibility.PUBLIC); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + ReviewTask task = pendingReview("version-submitter"); + stubLoaded(); + when(reviewTaskRepository.findById(30L)).thenReturn(Optional.of(task)); + when(reviewTaskRepository.updateStatusWithVersion(30L, result, "owner", "Decision", 1)).thenReturn(1); + when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + if (result == ReviewTaskStatus.APPROVED) { + when(suiteRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + service.approveReview(30L, "Decision", adminContext()); + } else { + service.rejectReview(30L, "Decision", adminContext()); + } + + ArgumentCaptor event = + ArgumentCaptor.forClass(SkillSuiteReviewCompletedEvent.class); + verify(eventPublisher).publishEvent(event.capture()); + assertThat(event.getValue().reviewId()).isEqualTo(30L); + assertThat(event.getValue().suiteId()).isEqualTo(10L); + assertThat(event.getValue().versionId()).isEqualTo(20L); + assertThat(event.getValue().namespaceId()).isEqualTo(1L); + assertThat(event.getValue().submitterId()).isEqualTo("version-submitter"); + assertThat(event.getValue().reviewerId()).isEqualTo("owner"); + assertThat(event.getValue().status()).isEqualTo(result); + assertThat(event.getValue().occurredAt()).isEqualTo(Instant.parse("2026-09-07T08:00:00Z")); + assertThat(java.util.UUID.fromString(event.getValue().eventId())).isNotNull(); + assertThatThrownBy(() -> service.approveReview(30L, "Again", adminContext())) + .isInstanceOf(DomainBadRequestException.class); + verify(eventPublisher).publishEvent(any(SkillSuiteReviewCompletedEvent.class)); + } + + @ParameterizedTest + @EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"}) + void failedReviewCompareAndSetDoesNotEmitAResult(ReviewTaskStatus result) throws Exception { + version.setVisibility(SkillVisibility.PUBLIC); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + stubLoaded(); + when(reviewTaskRepository.findById(30L)).thenReturn(Optional.of(pendingReview("author"))); + assertThatThrownBy(() -> { + if (result == ReviewTaskStatus.APPROVED) service.approveReview(30L, "Decision", adminContext()); + else service.rejectReview(30L, "Decision", adminContext()); + }).isInstanceOf(java.util.ConcurrentModificationException.class); + verify(eventPublisher, never()).publishEvent(any(Object.class)); + } + @Test void approvalFailureKeepsTheReviewAndSuiteVersionPending() throws Exception { version.setVisibility(SkillVisibility.PUBLIC); diff --git a/web/src/features/notification/notification-content.test.ts b/web/src/features/notification/notification-content.test.ts index 3f44e1d3a..98357ffca 100644 --- a/web/src/features/notification/notification-content.test.ts +++ b/web/src/features/notification/notification-content.test.ts @@ -24,6 +24,23 @@ describe('resolveNotificationDisplay', () => { expect(display.description).not.toContain('{{') }) + it.each([ + ['zh', 'SUITE_REVIEW_APPROVED', '套件审核通过'], + ['zh', 'SUITE_REVIEW_REJECTED', '套件审核未通过'], + ['en', 'SUITE_REVIEW_APPROVED', 'Suite review approved'], + ['ko', 'SUITE_REVIEW_REJECTED', '스킬 모음 검토 반려'], + ['ru', 'SUITE_REVIEW_APPROVED', 'Проверка набора одобрена'], + ])('renders a Suite result with its submitted version in %s', (language, eventType, title) => { + const display = resolveNotificationDisplay({ + id: 2, category: 'REVIEW', eventType, title: 'Server title', + bodyJson: JSON.stringify({ suiteName: 'Writing suite', version: '2.0.0' }), + status: 'UNREAD', createdAt: '2026-11-06T00:00:00Z', + }, language) + expect(display.title).toBe(title) + expect(display.description).toContain('Writing suite') + expect(display.description).toContain('2.0.0') + }) + it('renders review submitted content in Chinese', () => { const display = resolveNotificationDisplay({ category: 'REVIEW', diff --git a/web/src/features/notification/notification-content.ts b/web/src/features/notification/notification-content.ts index 83f6ab20c..b0a477f93 100644 --- a/web/src/features/notification/notification-content.ts +++ b/web/src/features/notification/notification-content.ts @@ -25,7 +25,7 @@ function parseBody(bodyJson?: string): NotificationBody { } const localizedEvents = new Set([ - 'SUITE_REVIEW_SUBMITTED', + 'SUITE_REVIEW_SUBMITTED', 'SUITE_REVIEW_APPROVED', 'SUITE_REVIEW_REJECTED', 'REVIEW_SUBMITTED', 'REVIEW_APPROVED', 'REVIEW_REJECTED', 'PROMOTION_SUBMITTED', 'PROMOTION_APPROVED', 'PROMOTION_REJECTED', 'REPORT_SUBMITTED', 'REPORT_RESOLVED', 'SKILL_PUBLISHED', @@ -38,7 +38,7 @@ export function resolveNotificationDisplay(item: NotificationItem, language: str const body = parseBody(item.bodyJson) const skillName = body.skillName ?? '' const suiteName = body.suiteName ?? '' - const resourceName = item.eventType === 'SUITE_REVIEW_SUBMITTED' ? suiteName : skillName + const resourceName = item.eventType.startsWith('SUITE_REVIEW_') ? suiteName : skillName const version = body.version ?? '' const versionSuffix = version ? (zh ? `(${version})` : ` (${version})`) : '' diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 96cf5f0be..4e6b2674e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3443,6 +3443,14 @@ "days": "{{count}}d" }, "notificationEvents": { + "SUITE_REVIEW_APPROVED": { + "title": "Suite review approved", + "description": "The review of {{suiteName}}{{versionSuffix}} was approved." + }, + "SUITE_REVIEW_REJECTED": { + "title": "Suite review rejected", + "description": "The review of {{suiteName}}{{versionSuffix}} was rejected. Check review progress for details." + }, "SUITE_REVIEW_SUBMITTED": { "title": "Suite review submitted", "description": "{{suiteName}}{{versionSuffix}} was submitted for review." diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index 6ec9bb1c7..22440dad9 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -3723,6 +3723,14 @@ "days": "{{count}}일 전" }, "notificationEvents": { + "SUITE_REVIEW_APPROVED": { + "title": "스킬 모음 검토 승인", + "description": "{{suiteName}}{{versionSuffix}} 검토가 승인되었습니다." + }, + "SUITE_REVIEW_REJECTED": { + "title": "스킬 모음 검토 반려", + "description": "{{suiteName}}{{versionSuffix}} 검토가 반려되었습니다. 검토 진행 상황을 확인하세요." + }, "SUITE_REVIEW_SUBMITTED": { "title": "스킬 모음 검토 제출", "description": "{{suiteName}}{{versionSuffix}}의 검토를 요청했습니다." diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index c759b3f55..7f09dc1ea 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3447,6 +3447,14 @@ "days": "{{count}} д" }, "notificationEvents": { + "SUITE_REVIEW_APPROVED": { + "title": "Проверка набора одобрена", + "description": "Проверка {{suiteName}}{{versionSuffix}} одобрена." + }, + "SUITE_REVIEW_REJECTED": { + "title": "Проверка набора отклонена", + "description": "Проверка {{suiteName}}{{versionSuffix}} отклонена. Подробности доступны в ходе проверки." + }, "SUITE_REVIEW_SUBMITTED": { "title": "Набор отправлен на проверку", "description": "{{suiteName}}{{versionSuffix}} отправлен на проверку." diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 28655eb96..edc9edcf2 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -3441,6 +3441,14 @@ "days": "{{count}}天" }, "notificationEvents": { + "SUITE_REVIEW_APPROVED": { + "title": "套件审核通过", + "description": "{{suiteName}}{{versionSuffix}} 的审核已通过。" + }, + "SUITE_REVIEW_REJECTED": { + "title": "套件审核未通过", + "description": "{{suiteName}}{{versionSuffix}} 的审核未通过,请查看审核进度。" + }, "SUITE_REVIEW_SUBMITTED": { "title": "套件审核提交", "description": "{{suiteName}}{{versionSuffix}} 已提交审核。"