mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
Merge pull request #530 from iflytek/fix/issue-51-profile-review-notifications
fix: admin notifications for profile review requests
This commit is contained in:
commit
a23cbd84eb
17 changed files with 294 additions and 16 deletions
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
|
|||
private static final Set<String> SKIP_PREFIXES = Set.of(
|
||||
"/actuator", "/favicon.ico", "/assets/"
|
||||
);
|
||||
private static final Set<String> 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,22 @@ public class NotificationEventListener {
|
|||
});
|
||||
}
|
||||
|
||||
@Async("skillhubEventExecutor")
|
||||
@TransactionalEventListener
|
||||
public void onProfileReviewSubmitted(ProfileReviewSubmittedEvent event) {
|
||||
String title = "Profile review submitted";
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("profileReviewId", event.profileReviewId());
|
||||
body.put("submitterId", event.submitterId());
|
||||
body.put("fields", event.fields());
|
||||
String json = toJson(body);
|
||||
List<String> 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) {
|
||||
|
|
|
|||
|
|
@ -39,4 +39,14 @@ public class RecipientResolver {
|
|||
List::copyOf
|
||||
));
|
||||
}
|
||||
|
||||
public List<String> 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NotificationResponse> 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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<String> result = resolver.resolvePlatformUserAdmins();
|
||||
|
||||
assertThat(result).containsExactly("user-admin", "super-admin");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ProfileReviewSubmittedEvent(Long profileReviewId, String submitterId, List<String> fields) {
|
||||
|
||||
public ProfileReviewSubmittedEvent {
|
||||
fields = List.copyOf(fields);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> changes,
|
||||
Map<String, String> oldValues, ProfileChangeStatus status,
|
||||
String machineResult, String machineReason) {
|
||||
private ProfileChangeRequest saveChangeRequest(String userId, Map<String, String> changes,
|
||||
Map<String, String> 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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object> 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<List<Object>> 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<Object> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
8
web/src/api/generated/schema.d.ts
vendored
8
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -306,6 +306,9 @@ const dashboardReviewsRoute = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/reviews',
|
||||
beforeLoad: requireAuth,
|
||||
validateSearch: (search: Record<string, unknown>): { type?: 'skill' | 'profile' } => ({
|
||||
type: search.type === 'skill' || search.type === 'profile' ? search.type : undefined,
|
||||
}),
|
||||
component: ReviewsPage,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<Record<ReviewStatus, number>>({
|
||||
|
|
@ -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() {
|
|||
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
|
||||
|
||||
{showTypeTabs ? (
|
||||
<Tabs defaultValue={defaultType}>
|
||||
<Tabs value={activeType} onValueChange={handleTypeChange}>
|
||||
<TabsList className="gap-2 rounded-2xl border-b-0 bg-muted/80 p-1 shadow-sm">
|
||||
<TabsTrigger
|
||||
value="skill"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue