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 6a1f1c68..ec62ebb9 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 @@ -16,6 +16,7 @@ import jakarta.validation.constraints.Min; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -78,7 +79,7 @@ public class NotificationController extends BaseApiController { return ok("response.success.deleted", null); } - @GetMapping("/sse") + @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter sse(@RequestAttribute("userId") String userId) { return sseEmitterManager.register(userId); } @@ -113,6 +114,9 @@ public class NotificationController extends BaseApiController { if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) { return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId); } + if ("PROFILE_REVIEW_SUBMITTED".equals(eventType) && entityId != null) { + return new NotificationTarget("PROFILE_REVIEW", entityId, "/dashboard/reviews?type=profile"); + } if ("PROMOTION_SUBMITTED".equals(eventType)) { return new NotificationTarget("PROMOTION", entityId, "/dashboard/promotions"); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index cda766ff..0f83faee 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -30,6 +30,9 @@ public class RequestLoggingFilter extends OncePerRequestFilter { private static final Set SKIP_PREFIXES = Set.of( "/actuator", "/favicon.ico", "/assets/" ); + private static final Set SKIP_SUFFIXES = Set.of( + "/sse" + ); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -89,6 +92,11 @@ public class RequestLoggingFilter extends OncePerRequestFilter { return true; } } + for (String suffix : SKIP_SUFFIXES) { + if (uri.endsWith(suffix)) { + return true; + } + } return false; } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java index c0d280fa..c20498a2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java @@ -128,6 +128,22 @@ public class NotificationEventListener { }); } + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onProfileReviewSubmitted(ProfileReviewSubmittedEvent event) { + String title = "Profile review submitted"; + Map body = new LinkedHashMap<>(); + body.put("profileReviewId", event.profileReviewId()); + body.put("submitterId", event.submitterId()); + body.put("fields", event.fields()); + String json = toJson(body); + List admins = recipientResolver.resolvePlatformUserAdmins(); + for (String admin : admins.stream().distinct().toList()) { + dispatcher.dispatch(admin, NotificationCategory.REVIEW, + "PROFILE_REVIEW_SUBMITTED", title, json, "PROFILE_REVIEW", event.profileReviewId()); + } + } + @Async("skillhubEventExecutor") @TransactionalEventListener public void onReviewApproved(ReviewApprovedEvent event) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java index 90e076d7..0ea087d3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java @@ -39,4 +39,14 @@ public class RecipientResolver { List::copyOf )); } + + public List resolvePlatformUserAdmins() { + return userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN")) + .stream() + .map(binding -> binding.getUserId()) + .collect(java.util.stream.Collectors.collectingAndThen( + java.util.stream.Collectors.toCollection(LinkedHashSet::new), + List::copyOf + )); + } } 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 2536180e..2f344f2c 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 @@ -70,6 +70,28 @@ 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_shouldExposeProfileReviewTargetRouteForSubmittedProfileReviewNotifications() { + Notification notification = notification( + 15L, + NotificationCategory.REVIEW, + "PROFILE_REVIEW_SUBMITTED", + "{\"profileReviewId\":77,\"submitterId\":\"user-1\",\"fields\":[\"displayName\"]}", + "PROFILE_REVIEW", + 77L + ); + 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("PROFILE_REVIEW"); + assertThat(item.targetId()).isEqualTo(77L); + assertThat(item.targetRoute()).isEqualTo("/dashboard/reviews?type=profile"); + }); + } + @Test void list_shouldExposeSkillRouteForResolvedWorkflowNotifications() { Notification notification = notification( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index c79708a3..495eeb63 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -78,6 +78,29 @@ class RequestLoggingFilterTest { assertThat(loggedMessages()).noneMatch(message -> message.contains("/actuator/health")); } + @Test + void doFilterInternal_skipsSseEndpointsWithoutWrappingResponse() + throws ServletException, IOException { + RequestLoggingFilter filter = new RequestLoggingFilter(); + attachAppender(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + FilterChain filterChain = (req, res) -> { + assertThat(res).isSameAs(response); + res.setContentType("text/event-stream"); + res.getWriter().write("event:connected\n"); + res.getWriter().flush(); + }; + + filter.doFilter(request, response, filterChain); + + assertThat(response.getHeader("Content-Length")).isNull(); + assertThat(response.getContentAsString()).isEqualTo("event:connected\n"); + assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/notifications/sse")); + } + @Test void doFilterInternal_logsCoreSummaryFields() throws ServletException, IOException { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java index bcdc8953..c9da62e9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -127,6 +127,21 @@ class NotificationEventListenerTest { verify(dispatcher).dispatch(eq("admin-2"), any(), any(), any(), any(), any(), any()); } + @Test + void onProfileReviewSubmitted_shouldDispatchToPlatformUserAdmins() throws Exception { + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + when(recipientResolver.resolvePlatformUserAdmins()) + .thenReturn(List.of("user-admin-1", "super-admin-1", "user-admin-1")); + + listener.onProfileReviewSubmitted( + new ProfileReviewSubmittedEvent(77L, "submitter-1", List.of("displayName"))); + + verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.REVIEW), + eq("PROFILE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("PROFILE_REVIEW"), eq(77L)); + verify(dispatcher).dispatch(eq("user-admin-1"), any(), any(), any(), any(), any(), any()); + verify(dispatcher).dispatch(eq("super-admin-1"), any(), any(), any(), any(), any(), any()); + } + @Test void onReviewApproved_shouldDispatchToSubmitter() throws Exception { Skill skill = mockSkill(1L); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java index 20325b9d..c056a1ad 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java @@ -80,4 +80,20 @@ class RecipientResolverTest { assertThat(result).containsExactly("skill-admin", "super-admin"); } + + @Test + void resolvePlatformUserAdmins_shouldReturnUserAdminsAndSuperAdmins() { + UserRoleBinding userAdmin = mock(UserRoleBinding.class); + UserRoleBinding superAdmin = mock(UserRoleBinding.class); + UserRoleBinding duplicate = mock(UserRoleBinding.class); + when(userAdmin.getUserId()).thenReturn("user-admin"); + when(superAdmin.getUserId()).thenReturn("super-admin"); + when(duplicate.getUserId()).thenReturn("user-admin"); + when(userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN"))) + .thenReturn(List.of(userAdmin, superAdmin, duplicate)); + + List result = resolver.resolvePlatformUserAdmins(); + + assertThat(result).containsExactly("user-admin", "super-admin"); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java new file mode 100644 index 00000000..f0c250d1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.event; + +import java.util.List; + +public record ProfileReviewSubmittedEvent(Long profileReviewId, String submitterId, List fields) { + + public ProfileReviewSubmittedEvent { + fields = List.copyOf(fields); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java index 68b3951b..c6f037c3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java @@ -3,10 +3,13 @@ package com.iflytek.skillhub.domain.user; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -29,19 +32,22 @@ public class UserProfileService { private final ProfileModerationConfig moderationConfig; private final ProfileFieldPolicyConfig fieldPolicyConfig; private final AuditLogService auditLogService; + private final ApplicationEventPublisher eventPublisher; public UserProfileService(UserAccountRepository userAccountRepository, ProfileChangeRequestRepository changeRequestRepository, ProfileModerationService moderationService, ProfileModerationConfig moderationConfig, ProfileFieldPolicyConfig fieldPolicyConfig, - AuditLogService auditLogService) { + AuditLogService auditLogService, + ApplicationEventPublisher eventPublisher) { this.userAccountRepository = userAccountRepository; this.changeRequestRepository = changeRequestRepository; this.moderationService = moderationService; this.moderationConfig = moderationConfig; this.fieldPolicyConfig = fieldPolicyConfig; this.auditLogService = auditLogService; + this.eventPublisher = eventPublisher; } /** @@ -112,8 +118,10 @@ public class UserProfileService { // 5. Queue review changes if (!reviewChanges.isEmpty()) { cancelPendingRequests(userId); - saveChangeRequest(userId, reviewChanges, oldValues, ProfileChangeStatus.PENDING, - machineTag, null); + ProfileChangeRequest pendingRequest = saveChangeRequest(userId, reviewChanges, oldValues, + ProfileChangeStatus.PENDING, machineTag, null); + eventPublisher.publishEvent(new ProfileReviewSubmittedEvent( + pendingRequest.getId(), userId, List.copyOf(reviewChanges.keySet()))); } // 6. Return appropriate result @@ -166,9 +174,9 @@ public class UserProfileService { /** * Persist a change request record for audit and review purposes. */ - private void saveChangeRequest(String userId, Map changes, - Map oldValues, ProfileChangeStatus status, - String machineResult, String machineReason) { + private ProfileChangeRequest saveChangeRequest(String userId, Map changes, + Map oldValues, ProfileChangeStatus status, + String machineResult, String machineReason) { ProfileChangeRequest request = new ProfileChangeRequest( userId, toJson(changes), @@ -177,7 +185,7 @@ public class UserProfileService { machineResult, machineReason ); - changeRequestRepository.save(request); + return changeRequestRepository.save(request); } private String toJson(Object obj) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java index d6af56f6..1346e6a2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java @@ -1,16 +1,21 @@ package com.iflytek.skillhub.domain.user; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent; +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.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import java.lang.reflect.Field; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -41,9 +46,24 @@ class UserProfileServiceTest { @Mock private AuditLogService auditLogService; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private UserProfileService userProfileService; + @BeforeEach + void setUp() { + AtomicLong ids = new AtomicLong(1L); + lenient().when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> { + ProfileChangeRequest request = invocation.getArgument(0); + if (request.getId() == null) { + setField(request, "id", ids.getAndIncrement()); + } + return request; + }); + } + // -- Helper -- private UserAccount testUser() { @@ -136,6 +156,32 @@ class UserProfileServiceTest { verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); } + @Test + void updateProfile_humanReviewEnabled_shouldPublishProfileReviewSubmittedEvent() { + var user = testUser(); + when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(moderationConfig.machineReview()).thenReturn(false); + when(moderationConfig.humanReview()).thenReturn(true); + stubFieldPolicies(true); + when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING)) + .thenReturn(List.of()); + when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> { + ProfileChangeRequest request = invocation.getArgument(0); + setField(request, "id", 77L); + return request; + }); + + userProfileService.updateProfile( + "user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent"); + + var eventCaptor = ArgumentCaptor.forClass(ProfileReviewSubmittedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + ProfileReviewSubmittedEvent event = eventCaptor.getValue(); + assertEquals(77L, event.profileReviewId()); + assertEquals("user-1", event.submitterId()); + assertEquals(List.of("displayName"), event.fields()); + } + // ===== AC-P-005: Overwrite existing PENDING request ===== @Test @@ -221,4 +267,14 @@ class UserProfileServiceTest { userProfileService.updateProfile( "nonexistent", displayNameChange("Name"), "req-1", "127.0.0.1", "TestAgent")); } + + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } } diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java index 82c502c5..43646817 100644 --- a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java @@ -7,10 +7,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import java.io.IOException; import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; class SseEmitterManagerTest { @@ -30,13 +34,19 @@ class SseEmitterManagerTest { @Test void register_shouldReturnEmitter() { - emitters.add(new TestEmitter()); + TestEmitter testEmitter = new TestEmitter(); + emitters.add(testEmitter); SseEmitter emitter = manager.register("user-1"); assertNotNull(emitter); assertEquals(1, manager.totalEmitters()); assertEquals(1, manager.emittersForUser("user-1")); + assertEquals(1, testEmitter.sentEventCount()); + assertTrue(testEmitter.sentEventData(0).stream() + .anyMatch(value -> value.toString().contains("event:connected"))); + assertTrue(testEmitter.sentEventData(0).contains("ok")); + assertTrue(testEmitter.isOpen()); } @Test @@ -93,6 +103,27 @@ class SseEmitterManagerTest { assertEquals(1, manager.emittersForUser("user-1")); } + @Test + void push_shouldSendNotificationEventToRegisteredOpenEmitter() { + TestEmitter emitter = new TestEmitter(); + emitters.add(emitter); + manager.register("user-1"); + + Map payload = Map.of( + "id", 42L, + "eventType", "PROFILE_REVIEW_SUBMITTED" + ); + manager.push("user-1", payload); + + assertEquals(2, emitter.sentEventCount()); + assertTrue(emitter.sentEventData(1).stream() + .anyMatch(value -> value.toString().contains("event:notification"))); + assertTrue(emitter.sentEventData(1).contains(payload)); + assertTrue(emitter.isOpen()); + assertEquals(1, manager.totalEmitters()); + assertEquals(1, manager.emittersForUser("user-1")); + } + @Test void heartbeat_shouldRemoveEmitterWhenSendFails() { TestEmitter healthy = new TestEmitter(); @@ -152,6 +183,8 @@ class SseEmitterManagerTest { private boolean failAfterConnected; private boolean throwOnComplete; private int sendCount; + private boolean completed; + private final List> sentEvents = new ArrayList<>(); private TestEmitter() { super(60_000L); @@ -173,6 +206,18 @@ class SseEmitterManagerTest { errorCallback.accept(new IOException("boom-" + userId + "-" + errorCallbacks.incrementAndGet())); } + boolean isOpen() { + return !completed; + } + + int sentEventCount() { + return sentEvents.size(); + } + + List sentEventData(int index) { + return sentEvents.get(index); + } + @Override public synchronized void onCompletion(Runnable callback) { this.completionCallback = callback; @@ -193,6 +238,7 @@ class SseEmitterManagerTest { if (throwOnComplete) { throw new IllegalStateException("already complete"); } + completed = true; completionCallback.run(); } @@ -202,6 +248,9 @@ class SseEmitterManagerTest { if (failAfterConnected && sendCount > 1) { throw new IOException("send failed"); } + sentEvents.add(builder.build().stream() + .map(ResponseBodyEmitter.DataWithMediaType::getData) + .toList()); } } } diff --git a/web/e2e/reviews-pagination.spec.ts b/web/e2e/reviews-pagination.spec.ts index f9906a35..83626e4d 100644 --- a/web/e2e/reviews-pagination.spec.ts +++ b/web/e2e/reviews-pagination.spec.ts @@ -81,4 +81,12 @@ test.describe('Review Management Pagination (Real API)', () => { } } }) + + test('opens the profile review queue from the review type search param', async ({ page }) => { + await page.goto('/dashboard/reviews?type=profile') + + await expect(page).toHaveURL(/\/dashboard\/reviews\?type=profile$/) + await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Profile Review Queue' })).toBeVisible() + }) }) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 9e056dff..a141fb20 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9717,7 +9717,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SseEmitter"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; @@ -9737,7 +9737,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SseEmitter"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; @@ -9938,6 +9938,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; @@ -9962,6 +9964,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d654e026..c9b3bac8 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -306,6 +306,9 @@ const dashboardReviewsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/reviews', beforeLoad: requireAuth, + validateSearch: (search: Record): { type?: 'skill' | 'profile' } => ({ + type: search.type === 'skill' || search.type === 'profile' ? search.type : undefined, + }), component: ReviewsPage, }) diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index 694a0dce..186c15eb 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' import { createElement } from 'react' +const useSearchMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), + useSearch: () => useSearchMock(), })) vi.mock('lucide-react', () => ({ @@ -119,6 +121,7 @@ describe('ReviewsPage', () => { data: [], isLoading: false, }) + useSearchMock.mockReturnValue({}) useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { if (!enabled) { return { data: null, isLoading: false } @@ -180,4 +183,15 @@ describe('ReviewsPage', () => { expect(paginationProps[0]?.page).toBe(0) expect(paginationProps[0]?.totalPages).toBe(1) }) + + it('uses the profile review tab when the review type search param requests it', () => { + hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN' || role === 'USER_ADMIN') + userMock.platformRoles = ['SUPER_ADMIN'] + useSearchMock.mockReturnValue({ type: 'profile' }) + + renderToStaticMarkup(createElement(ReviewsPage)) + + expect(useReviewListMock).toHaveBeenCalled() + expect(useReviewListMock.mock.calls.every((call) => call[5] === false)).toBe(true) + }) }) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 4ead6c84..94c0ded8 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { useNavigate, useSearch } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' @@ -38,6 +38,7 @@ const PAGE_SIZE = 20 export function ReviewsPage() { const { t, i18n } = useTranslation() const navigate = useNavigate() + const search = useSearch({ from: '/dashboard/reviews' }) const { hasRole, user } = useAuth() const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const [pages, setPages] = useState>({ @@ -56,6 +57,9 @@ export function ReviewsPage() { // Determine default top-level tab const defaultType = isSkillAdmin ? 'skill' : 'profile' + const requestedType = search.type === 'skill' || search.type === 'profile' ? search.type : undefined + const activeType = showTypeTabs ? requestedType ?? defaultType : defaultType + const skillReviewEnabled = hasGlobalReviewAccess && isSkillAdmin && activeType === 'skill' useEffect(() => { if (hasGlobalReviewAccess || isLoadingNamespaces) { @@ -70,9 +74,9 @@ export function ReviewsPage() { void navigate({ to: '/dashboard', replace: true }) }, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate]) - const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'PENDING') - const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'APPROVED') - const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'REJECTED') + const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'PENDING') + const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'APPROVED') + const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'REJECTED') const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language) @@ -93,6 +97,14 @@ export function ReviewsPage() { }) } + function handleTypeChange(value: string) { + void navigate({ + to: '/dashboard/reviews', + search: { type: value === 'profile' ? 'profile' : 'skill' }, + replace: true, + }) + } + function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) { const currentPage = pages[status] return ( @@ -249,7 +261,7 @@ export function ReviewsPage() { {showTypeTabs ? ( - +