Skip to content

Commit 2077ef9

Browse files
committed
feat(badge): add badge system with event-driven architecture
1 parent 84441f6 commit 2077ef9

21 files changed

+632
-68
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package org.bounswe.jobboardbackend.badge.controller;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.bounswe.jobboardbackend.auth.service.UserDetailsImpl;
6+
import org.bounswe.jobboardbackend.badge.dto.BadgeResponseDto;
7+
import org.bounswe.jobboardbackend.badge.dto.BadgeTypeResponseDto;
8+
import org.bounswe.jobboardbackend.badge.model.Badge;
9+
import org.bounswe.jobboardbackend.badge.model.BadgeType;
10+
import org.bounswe.jobboardbackend.badge.repository.BadgeRepository;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.security.access.prepost.PreAuthorize;
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
14+
import org.springframework.web.bind.annotation.*;
15+
16+
import java.util.Arrays;
17+
import java.util.List;
18+
import java.util.stream.Collectors;
19+
20+
/**
21+
* REST Controller for badge-related endpoints.
22+
* Provides access to user badges and available badge types.
23+
* Badges are independent of Profile.
24+
*/
25+
@RestController
26+
@RequestMapping("/api/badges")
27+
@RequiredArgsConstructor
28+
@Slf4j
29+
public class BadgeController {
30+
31+
private final BadgeRepository badgeRepository;
32+
33+
/**
34+
* Get all badges for the current authenticated user.
35+
*
36+
* @return List of badges earned by the current user
37+
*/
38+
@GetMapping("/my")
39+
@PreAuthorize("isAuthenticated()")
40+
public ResponseEntity<List<BadgeResponseDto>> getMyBadges(
41+
@AuthenticationPrincipal UserDetailsImpl userDetails) {
42+
43+
Long userId = userDetails.getId();
44+
log.info("Getting badges for user ID: {}", userId);
45+
46+
List<Badge> badges = badgeRepository.findAllByUserId(userId);
47+
log.info("Found {} badges for user {}", badges.size(), userId);
48+
49+
List<BadgeResponseDto> response = badges.stream()
50+
.map(this::toBadgeDto)
51+
.collect(Collectors.toList());
52+
53+
return ResponseEntity.ok(response);
54+
}
55+
56+
/**
57+
* Get all badges for a specific user.
58+
*
59+
* @param userId The user's ID
60+
* @return List of badges earned by the user
61+
*/
62+
@GetMapping("/user/{userId}")
63+
@PreAuthorize("isAuthenticated()")
64+
public ResponseEntity<List<BadgeResponseDto>> getUserBadges(@PathVariable Long userId) {
65+
66+
List<Badge> badges = badgeRepository.findAllByUserId(userId);
67+
List<BadgeResponseDto> response = badges.stream()
68+
.map(this::toBadgeDto)
69+
.collect(Collectors.toList());
70+
71+
return ResponseEntity.ok(response);
72+
}
73+
74+
/**
75+
* Get all available badge types in the system.
76+
* Useful for displaying "Available Badges" page.
77+
*
78+
* @return List of all badge types with their details
79+
*/
80+
@GetMapping("/types")
81+
@PreAuthorize("isAuthenticated()")
82+
public ResponseEntity<List<BadgeTypeResponseDto>> getAllBadgeTypes() {
83+
84+
List<BadgeTypeResponseDto> response = Arrays.stream(BadgeType.values())
85+
.map(this::toBadgeTypeDto)
86+
.collect(Collectors.toList());
87+
88+
return ResponseEntity.ok(response);
89+
}
90+
91+
private BadgeResponseDto toBadgeDto(Badge badge) {
92+
return BadgeResponseDto.builder()
93+
.id(badge.getId())
94+
.userId(badge.getUserId())
95+
.name(badge.getName())
96+
.description(badge.getDescription())
97+
.icon(badge.getIcon())
98+
.criteria(badge.getCriteria())
99+
.earnedAt(badge.getEarnedAt())
100+
.build();
101+
}
102+
103+
private BadgeTypeResponseDto toBadgeTypeDto(BadgeType badgeType) {
104+
return BadgeTypeResponseDto.builder()
105+
.type(badgeType.name())
106+
.name(badgeType.getDisplayName())
107+
.description(badgeType.getDescription())
108+
.icon(badgeType.getIcon())
109+
.criteria(badgeType.getCriteria())
110+
.threshold(badgeType.getThreshold())
111+
.build();
112+
}
113+
}
114+
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
1-
package org.bounswe.jobboardbackend.profile.dto;
1+
package org.bounswe.jobboardbackend.badge.dto;
22

33
import lombok.*;
44
import java.time.Instant;
55

6+
/**
7+
* DTO for returning badge information.
8+
*/
69
@Data
710
@NoArgsConstructor
811
@AllArgsConstructor
912
@Builder
1013
public class BadgeResponseDto {
1114
private Long id;
15+
private Long userId;
1216
private String name;
1317
private String description;
14-
private String icon; // optional icon url/name
15-
private String criteria; // e.g., "Forum 100 posts"
18+
private String icon;
19+
private String criteria;
1620
private Instant earnedAt;
17-
}
21+
}
22+
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package org.bounswe.jobboardbackend.badge.dto;
2+
3+
import lombok.*;
4+
5+
/**
6+
* DTO for returning badge type information.
7+
* Used to show all available badges in the system.
8+
*/
9+
@Data
10+
@NoArgsConstructor
11+
@AllArgsConstructor
12+
@Builder
13+
public class BadgeTypeResponseDto {
14+
15+
/**
16+
* The enum type name (e.g., "FIRST_VOICE", "THOUGHT_LEADER")
17+
*/
18+
private String type;
19+
20+
/**
21+
* Display name (e.g., "First Voice", "Thought Leader")
22+
*/
23+
private String name;
24+
25+
/**
26+
* Badge description
27+
*/
28+
private String description;
29+
30+
/**
31+
* Badge icon (emoji or URL)
32+
*/
33+
private String icon;
34+
35+
/**
36+
* Human-readable criteria
37+
*/
38+
private String criteria;
39+
40+
/**
41+
* Numeric threshold to earn this badge
42+
*/
43+
private int threshold;
44+
}
45+
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.bounswe.jobboardbackend.badge.event;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
/**
7+
* Event published when a user creates a new comment on a forum post.
8+
* Used to trigger badge checks for comment-related badges.
9+
*/
10+
@Getter
11+
@AllArgsConstructor
12+
public class CommentCreatedEvent {
13+
private final Long userId;
14+
private final Long commentId;
15+
private final Long postId;
16+
}
17+
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.bounswe.jobboardbackend.badge.event;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
/**
7+
* Event published when a comment receives an upvote.
8+
* Used to trigger badge checks for upvote-related badges.
9+
* Note: The userId here is the COMMENT AUTHOR (who receives the upvote), not the voter.
10+
*/
11+
@Getter
12+
@AllArgsConstructor
13+
public class CommentUpvotedEvent {
14+
private final Long commentAuthorId;
15+
private final Long commentId;
16+
}
17+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package org.bounswe.jobboardbackend.badge.event;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
/**
7+
* Event published when a user creates a new forum post.
8+
* Used to trigger badge checks for post-related badges.
9+
*/
10+
@Getter
11+
@AllArgsConstructor
12+
public class ForumPostCreatedEvent {
13+
private final Long userId;
14+
private final Long postId;
15+
}
16+
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package org.bounswe.jobboardbackend.badge.listener;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.bounswe.jobboardbackend.badge.event.CommentCreatedEvent;
6+
import org.bounswe.jobboardbackend.badge.event.CommentUpvotedEvent;
7+
import org.bounswe.jobboardbackend.badge.event.ForumPostCreatedEvent;
8+
import org.bounswe.jobboardbackend.badge.service.BadgeService;
9+
import org.springframework.stereotype.Component;
10+
import org.springframework.transaction.event.TransactionPhase;
11+
import org.springframework.transaction.event.TransactionalEventListener;
12+
13+
/**
14+
* Listens to application events and triggers badge checks accordingly.
15+
* This keeps badge logic decoupled from the main business services.
16+
*
17+
* Uses @TransactionalEventListener to ensure badges are only awarded
18+
* after the main transaction commits successfully.
19+
*/
20+
@Component
21+
@RequiredArgsConstructor
22+
@Slf4j
23+
public class BadgeEventListener {
24+
25+
private final BadgeService badgeService;
26+
27+
/**
28+
* Handle forum post creation - check for post-related badges.
29+
* Only executes after the transaction commits successfully.
30+
*/
31+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
32+
public void onForumPostCreated(ForumPostCreatedEvent event) {
33+
try {
34+
log.debug("Forum post created by user {}, checking badges...", event.getUserId());
35+
badgeService.checkForumPostBadges(event.getUserId());
36+
} catch (Exception e) {
37+
log.error("Badge check failed for forum post by user {}: {}", event.getUserId(), e.getMessage());
38+
}
39+
}
40+
41+
/**
42+
* Handle comment creation - check for comment-related badges.
43+
* Only executes after the transaction commits successfully.
44+
*/
45+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
46+
public void onCommentCreated(CommentCreatedEvent event) {
47+
try {
48+
log.debug("Comment created by user {}, checking badges...", event.getUserId());
49+
badgeService.checkForumCommentBadges(event.getUserId());
50+
} catch (Exception e) {
51+
log.error("Badge check failed for comment by user {}: {}", event.getUserId(), e.getMessage());
52+
}
53+
}
54+
55+
/**
56+
* Handle comment upvote - check for upvote-related badges for the comment author.
57+
* Only executes after the transaction commits successfully.
58+
*/
59+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
60+
public void onCommentUpvoted(CommentUpvotedEvent event) {
61+
try {
62+
log.debug("Comment by user {} received upvote, checking badges...", event.getCommentAuthorId());
63+
badgeService.checkUpvoteBadges(event.getCommentAuthorId());
64+
} catch (Exception e) {
65+
log.error("Badge check failed for upvote on comment by user {}: {}", event.getCommentAuthorId(), e.getMessage());
66+
}
67+
}
68+
}
69+
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package org.bounswe.jobboardbackend.badge.model;
2+
3+
import jakarta.persistence.*;
4+
import lombok.*;
5+
import org.hibernate.annotations.CreationTimestamp;
6+
7+
import java.time.Instant;
8+
9+
/**
10+
* Represents a badge earned by a user.
11+
* Badges are completely independent of Profile - linked only to User.
12+
*/
13+
@Entity
14+
@Getter
15+
@Setter
16+
@NoArgsConstructor
17+
@AllArgsConstructor
18+
@Builder
19+
@Table(name = "badges",
20+
uniqueConstraints = @UniqueConstraint(
21+
name = "uk_badge_user_type",
22+
columnNames = {"user_id", "badge_type"}
23+
),
24+
indexes = {
25+
@Index(name = "ix_badges_user_id", columnList = "user_id")
26+
})
27+
public class Badge {
28+
29+
@Id
30+
@GeneratedValue(strategy = GenerationType.IDENTITY)
31+
private Long id;
32+
33+
@Column(name = "user_id", nullable = false)
34+
private Long userId;
35+
36+
@Enumerated(EnumType.STRING)
37+
@Column(name = "badge_type", nullable = false)
38+
private BadgeType badgeType;
39+
40+
@Column(nullable = false)
41+
private String name;
42+
43+
@Column(length = 1000)
44+
private String description;
45+
46+
private String icon;
47+
48+
@Column(length = 255)
49+
private String criteria;
50+
51+
@CreationTimestamp
52+
@Column(nullable = false, updatable = false)
53+
private Instant earnedAt;
54+
}
55+

0 commit comments

Comments
 (0)