Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8c75409
feat(subscription): add skill_subscription table migration V40
dongmucat Apr 27, 2026
9c05e91
feat(subscription): add SkillSubscription entity and repository inter…
dongmucat Apr 27, 2026
7391c26
feat(subscription): add subscription and version yanked domain events
dongmucat Apr 27, 2026
1567a67
feat(subscription): add SkillSubscriptionService domain service
dongmucat Apr 27, 2026
7da8ffd
feat(subscription): add JPA implementation for SkillSubscriptionRepos…
dongmucat Apr 27, 2026
79f1aa4
feat(subscription): add subscriptionCount field to Skill entity
dongmucat Apr 27, 2026
9d4422c
feat(subscription): add increment/decrement subscription count methods
dongmucat Apr 27, 2026
a2aa4c3
feat(subscription): update subscription count on subscribe/unsubscribe
dongmucat Apr 27, 2026
4713e86
feat(subscription): add SkillSubscriptionController REST endpoints
dongmucat Apr 27, 2026
4e62698
feat(subscription): add /me/subscriptions endpoint for user subscript…
dongmucat Apr 27, 2026
6f0103f
feat(subscription): publish SkillVersionYankedEvent on version yank
dongmucat Apr 27, 2026
9bc5d0c
feat(subscription): notify subscribers on skill publish and version yank
dongmucat Apr 27, 2026
7063a6d
test(subscription): add SkillSubscriptionService unit tests
dongmucat Apr 27, 2026
ebc3280
feat(subscription): add meApi subscriptions methods
dongmucat Apr 27, 2026
d6ea91e
feat(subscription): add useMySubscriptions query hooks
dongmucat Apr 27, 2026
0175897
feat(subscription): add useSubscription and useToggleSubscription hooks
dongmucat Apr 27, 2026
b062366
feat(subscription): add SubscribeButton component
dongmucat Apr 27, 2026
1afb208
feat(subscription): add SubscribeButton to skill detail page
dongmucat Apr 27, 2026
f0c2403
feat(subscription): add MySubscriptionsPage component
dongmucat Apr 27, 2026
6eee734
feat(subscription): add /dashboard/subscriptions route
dongmucat Apr 27, 2026
6b3855e
feat(subscription): add subscriptions menu item to user menu
dongmucat Apr 27, 2026
12ed6f9
feat(subscription): add subscriptions card to dashboard
dongmucat Apr 27, 2026
e70de0f
feat(subscription): add English i18n for subscription feature
dongmucat Apr 27, 2026
658c446
feat(subscription): add Chinese i18n for subscription feature
dongmucat Apr 27, 2026
c667cab
fix(subscription): replace any type with explicit type assertion
dongmucat Apr 27, 2026
78b7e4f
chore(api): regenerate OpenAPI types for subscription endpoints
dongmucat Apr 28, 2026
fb3035e
test(subscription): add SkillSubscriptionController integration tests
dongmucat Apr 28, 2026
d0e7926
feat(subscription): add i18n for subscription notification events
dongmucat Apr 28, 2026
284b099
test(subscription): add frontend unit and e2e tests
dongmucat Apr 28, 2026
f0760a6
fix(e2e): resolve strict mode violation in subscription tests
dongmucat Apr 29, 2026
62f3c3a
fix(e2e): approve skill review before testing subscription
dongmucat Apr 29, 2026
4882abc
fix(subscription): expose subscription count in skill detail API
dongmucat Apr 29, 2026
7f47f8a
test(subscription): update test DTOs with subscriptionCount field
dongmucat Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,16 @@ public ApiResponse<PageResponse<SkillSummaryResponse>> listMyStars(

return ok("response.success.read", mySkillAppService.listMyStars(principal.userId(), page, size));
}

@GetMapping("/subscriptions")
public ApiResponse<PageResponse<SkillSummaryResponse>> listMySubscriptions(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "12") int size,
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}

return ok("response.success.read", mySkillAppService.listMySubscriptions(principal.userId(), page, size));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ public ApiResponse<SkillDetailResponse> getSkillDetail(
detail.status(),
detail.downloadCount(),
detail.starCount(),
detail.subscriptionCount(),
detail.ratingAvg(),
detail.ratingCount(),
detail.hidden(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.iflytek.skillhub.controller.portal;

import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping({"/api/v1/skills", "/api/web/skills"})
public class SkillSubscriptionController extends BaseApiController {

private final SkillSubscriptionService skillSubscriptionService;

public SkillSubscriptionController(ApiResponseFactory responseFactory,
SkillSubscriptionService skillSubscriptionService) {
super(responseFactory);
this.skillSubscriptionService = skillSubscriptionService;
}

@PutMapping("/{skillId}/subscription")
public ApiResponse<Void> subscribeSkill(
@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal) {
skillSubscriptionService.subscribe(skillId, principal.userId());
return ok("response.success.updated", null);
}

@DeleteMapping("/{skillId}/subscription")
public ApiResponse<Void> unsubscribeSkill(
@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal) {
skillSubscriptionService.unsubscribe(skillId, principal.userId());
return ok("response.success.updated", null);
}

@GetMapping("/{skillId}/subscription")
public ApiResponse<Boolean> checkSubscribed(
@PathVariable Long skillId,
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
return ok("response.success.read", false);
}
boolean subscribed = skillSubscriptionService.isSubscribed(skillId, principal.userId());
return ok("response.success.read", subscribed);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public record SkillDetailResponse(
String status,
Long downloadCount,
Integer starCount,
Integer subscriptionCount,
BigDecimal ratingAvg,
Integer ratingCount,
boolean hidden,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
import org.slf4j.Logger;
Expand All @@ -29,19 +30,22 @@ public class NotificationEventListener {
private final NamespaceRepository namespaceRepository;
private final RecipientResolver recipientResolver;
private final NotificationDispatcher dispatcher;
private final SkillSubscriptionService skillSubscriptionService;
private final ObjectMapper objectMapper;

public NotificationEventListener(SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
NamespaceRepository namespaceRepository,
RecipientResolver recipientResolver,
NotificationDispatcher dispatcher,
SkillSubscriptionService skillSubscriptionService,
ObjectMapper objectMapper) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.namespaceRepository = namespaceRepository;
this.recipientResolver = recipientResolver;
this.dispatcher = dispatcher;
this.skillSubscriptionService = skillSubscriptionService;
this.objectMapper = objectMapper;
}

Expand All @@ -61,6 +65,50 @@ public void onSkillPublished(SkillPublishedEvent event) {
});
}

@Async("skillhubEventExecutor")
@TransactionalEventListener
public void onSkillPublishedForSubscribers(SkillPublishedEvent event) {
skillRepository.findById(event.skillId()).ifPresent(skill -> {
List<String> subscribers = skillSubscriptionService.findSubscribersBySkillId(event.skillId());
if (subscribers.isEmpty()) {
return;
}
String title = "Skill updated: " + skillDisplayName(skill);
Map<String, Object> body = bodyWithSkill(skill);
versionLabel(event.versionId(), body);
String json = toJson(body);
for (String subscriberId : subscribers) {
if (subscriberId.equals(event.publisherId())) {
continue; // skip the publisher
}
dispatcher.dispatch(subscriberId, NotificationCategory.PUBLISH,
"SUBSCRIPTION_NEW_VERSION", title, json, "SKILL", event.skillId());
}
});
}

@Async("skillhubEventExecutor")
@TransactionalEventListener
public void onSkillVersionYankedForSubscribers(SkillVersionYankedEvent event) {
skillRepository.findById(event.skillId()).ifPresent(skill -> {
List<String> subscribers = skillSubscriptionService.findSubscribersBySkillId(event.skillId());
if (subscribers.isEmpty()) {
return;
}
String title = "Skill version yanked: " + skillDisplayName(skill);
Map<String, Object> body = bodyWithSkill(skill);
versionLabel(event.versionId(), body);
String json = toJson(body);
for (String subscriberId : subscribers) {
if (subscriberId.equals(event.actorUserId())) {
continue; // skip the actor
}
dispatcher.dispatch(subscriberId, NotificationCategory.PUBLISH,
"SUBSCRIPTION_VERSION_YANKED", title, json, "SKILL", event.skillId());
}
});
}

@Async("skillhubEventExecutor")
@TransactionalEventListener
public void onReviewSubmitted(ReviewSubmittedEvent event) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.domain.social.SkillStarRepository;
import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.repository.MySkillQueryRepository;
Expand Down Expand Up @@ -32,18 +33,21 @@ public enum MySkillFilter {
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillStarRepository skillStarRepository;
private final SkillSubscriptionRepository skillSubscriptionRepository;
private final MySkillQueryRepository mySkillQueryRepository;
private final SkillLifecycleProjectionService skillLifecycleProjectionService;

public MySkillAppService(
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillStarRepository skillStarRepository,
SkillSubscriptionRepository skillSubscriptionRepository,
MySkillQueryRepository mySkillQueryRepository,
SkillLifecycleProjectionService skillLifecycleProjectionService) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillStarRepository = skillStarRepository;
this.skillSubscriptionRepository = skillSubscriptionRepository;
this.mySkillQueryRepository = mySkillQueryRepository;
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
}
Expand Down Expand Up @@ -90,6 +94,30 @@ public PageResponse<SkillSummaryResponse> listMyStars(String userId, int page, i
return new PageResponse<>(items, starPage.getTotalElements(), starPage.getNumber(), starPage.getSize());
}

public PageResponse<SkillSummaryResponse> listMySubscriptions(String userId, int page, int size) {
Page<com.iflytek.skillhub.domain.social.SkillSubscription> subPage = skillSubscriptionRepository.findByUserId(
userId,
PageRequest.of(page, size)
);
List<com.iflytek.skillhub.domain.social.SkillSubscription> subs = subPage.getContent();

List<Long> skillIds = subs.stream()
.map(com.iflytek.skillhub.domain.social.SkillSubscription::getSkillId)
.distinct()
.toList();
java.util.Map<Long, Skill> skillsById = skillIds.isEmpty()
? java.util.Map.of()
: skillRepository.findByIdIn(skillIds).stream()
.collect(java.util.stream.Collectors.toMap(Skill::getId, java.util.function.Function.identity()));
List<Skill> orderedSkills = subs.stream()
.map(sub -> skillsById.get(sub.getSkillId()))
.filter(java.util.Objects::nonNull)
.toList();
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(orderedSkills, userId);

return new PageResponse<>(items, subPage.getTotalElements(), subPage.getNumber(), subPage.getSize());
}

private Page<Skill> filterSkillsByLifecycle(String userId,
int page,
int size,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public ReviewSkillDetailResponse getReviewSkillDetail(Long reviewId,
snapshot.skill().getStatus().name(),
snapshot.skill().getDownloadCount(),
snapshot.skill().getStarCount(),
snapshot.skill().getSubscriptionCount(),
snapshot.skill().getRatingAvg(),
snapshot.skill().getRatingCount(),
snapshot.skill().isHidden(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Skill subscription: users subscribe to skills for update notifications
CREATE TABLE skill_subscription (
id BIGSERIAL PRIMARY KEY,
skill_id BIGINT NOT NULL REFERENCES skill(id) ON DELETE CASCADE,
user_id VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uk_skill_subscription UNIQUE (skill_id, user_id)
);

CREATE INDEX idx_skill_subscription_user ON skill_subscription(user_id, created_at DESC);
CREATE INDEX idx_skill_subscription_skill ON skill_subscription(skill_id);

-- Add subscription_count column to skill table
ALTER TABLE skill ADD COLUMN subscription_count INTEGER NOT NULL DEFAULT 0;
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ void getReviewSkillDetail_returnsReviewBoundPayload() throws Exception {
"ACTIVE",
8L,
2,
0,
null,
0,
false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ void getSkillDetailShouldExposePendingPreviewFlags() throws Exception {
"ACTIVE",
10L,
2,
0,
null,
0,
false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.iflytek.skillhub.controller.portal;

import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.social.SkillSubscriptionService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;
import java.util.Set;

import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SkillSubscriptionControllerTest {

@Autowired
private MockMvc mockMvc;

@MockBean
private SkillSubscriptionService skillSubscriptionService;

@MockBean
private NamespaceMemberRepository namespaceMemberRepository;

private static UsernamePasswordAuthenticationToken authenticatedUser() {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42",
"tester",
"tester@example.com",
"https://example.com/avatar.png",
"github",
Set.of("SUPER_ADMIN")
);
return new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
}

// --- PUT /api/web/skills/{skillId}/subscription ---

@Test
void subscribe_skill_returns_envelope() throws Exception {
mockMvc.perform(put("/api/web/skills/10/subscription")
.with(authentication(authenticatedUser())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());

verify(skillSubscriptionService).subscribe(eq(10L), eq("user-42"));
}

@Test
void subscribe_skill_unauthenticated_returns_401() throws Exception {
mockMvc.perform(put("/api/web/skills/10/subscription"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}

// --- DELETE /api/web/skills/{skillId}/subscription ---

@Test
void unsubscribe_skill_returns_envelope() throws Exception {
mockMvc.perform(delete("/api/web/skills/10/subscription")
.with(authentication(authenticatedUser())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());

verify(skillSubscriptionService).unsubscribe(eq(10L), eq("user-42"));
}

@Test
void unsubscribe_skill_unauthenticated_returns_401() throws Exception {
mockMvc.perform(delete("/api/web/skills/10/subscription"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}

// --- GET /api/web/skills/{skillId}/subscription ---

@Test
void check_subscribed_returns_true() throws Exception {
when(skillSubscriptionService.isSubscribed(eq(10L), eq("user-42"))).thenReturn(true);

mockMvc.perform(get("/api/web/skills/10/subscription")
.with(authentication(authenticatedUser())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(true))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());
}

@Test
void check_subscribed_returns_false() throws Exception {
when(skillSubscriptionService.isSubscribed(eq(10L), eq("user-42"))).thenReturn(false);

mockMvc.perform(get("/api/web/skills/10/subscription")
.with(authentication(authenticatedUser())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(false))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());
}

@Test
void check_subscribed_unauthenticated_returns_false() throws Exception {
mockMvc.perform(get("/api/web/skills/10/subscription"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(false));
}
}
Loading
Loading