fix(notification): notify Suite submitters after review outcomes

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-11-06 10:58:00 +08:00
parent 58ae93873d
commit d26154b228
14 changed files with 419 additions and 2 deletions

View file

@ -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);

View file

@ -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<String, Object>();
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());
}
}
}

View file

@ -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(

View file

@ -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());
}
}

View file

@ -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<String> 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<Notification> 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")));
}
}

View file

@ -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");
}
}

View file

@ -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());

View file

@ -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<SkillSuiteReviewCompletedEvent> 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);

View file

@ -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',

View file

@ -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})`) : ''

View file

@ -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."

View file

@ -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}}의 검토를 요청했습니다."

View file

@ -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}} отправлен на проверку."

View file

@ -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}} 已提交审核。"