Add likes and comments
This commit is contained in:
parent
3808df9dbf
commit
97dcd92657
12 changed files with 854 additions and 0 deletions
|
|
@ -77,6 +77,16 @@ public class SecurityConfig {
|
||||||
// Public endpoints - User's public activities
|
// Public endpoints - User's public activities
|
||||||
.requestMatchers(HttpMethod.GET, "/api/activities/user/*").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/activities/user/*").permitAll()
|
||||||
|
|
||||||
|
// Public endpoints - Likes and Comments (GET only)
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/activities/*/likes").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/activities/*/comments").permitAll()
|
||||||
|
|
||||||
|
// Protected endpoints - Likes and Comments (POST/DELETE)
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/activities/*/likes").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.DELETE, "/api/activities/*/likes").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/activities/*/comments").authenticated()
|
||||||
|
.requestMatchers(HttpMethod.DELETE, "/api/activities/*/comments/*").authenticated()
|
||||||
|
|
||||||
// Protected endpoints - Activities API
|
// Protected endpoints - Activities API
|
||||||
.requestMatchers("/api/activities/**").authenticated()
|
.requestMatchers("/api/activities/**").authenticated()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
package org.operaton.fitpub.controller;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.operaton.fitpub.model.dto.CommentCreateRequest;
|
||||||
|
import org.operaton.fitpub.model.dto.CommentDTO;
|
||||||
|
import org.operaton.fitpub.model.entity.Activity;
|
||||||
|
import org.operaton.fitpub.model.entity.Comment;
|
||||||
|
import org.operaton.fitpub.model.entity.User;
|
||||||
|
import org.operaton.fitpub.repository.ActivityRepository;
|
||||||
|
import org.operaton.fitpub.repository.CommentRepository;
|
||||||
|
import org.operaton.fitpub.repository.UserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for comment operations.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/activities/{activityId}/comments")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class CommentController {
|
||||||
|
|
||||||
|
private final CommentRepository commentRepository;
|
||||||
|
private final ActivityRepository activityRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Value("${fitpub.base-url}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to get user from authenticated UserDetails.
|
||||||
|
*/
|
||||||
|
private User getUser(UserDetails userDetails) {
|
||||||
|
return userRepository.findByUsername(userDetails.getUsername())
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all comments for an activity.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @param page page number (default: 0)
|
||||||
|
* @param size page size (default: 20)
|
||||||
|
* @param userDetails the authenticated user (optional)
|
||||||
|
* @return page of comments
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<Page<CommentDTO>> getComments(
|
||||||
|
@PathVariable UUID activityId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@AuthenticationPrincipal UserDetails userDetails
|
||||||
|
) {
|
||||||
|
// Check if activity exists
|
||||||
|
if (!activityRepository.existsById(activityId)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID currentUserId = null;
|
||||||
|
if (userDetails != null) {
|
||||||
|
User user = getUser(userDetails);
|
||||||
|
currentUserId = user.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
Pageable pageable = PageRequest.of(page, size);
|
||||||
|
Page<Comment> comments = commentRepository.findByActivityIdAndNotDeleted(activityId, pageable);
|
||||||
|
|
||||||
|
UUID finalCurrentUserId = currentUserId;
|
||||||
|
Page<CommentDTO> commentDTOs = comments.map(comment ->
|
||||||
|
CommentDTO.fromEntity(comment, baseUrl, finalCurrentUserId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(commentDTOs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a comment on an activity.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @param request the comment create request
|
||||||
|
* @param userDetails the authenticated user
|
||||||
|
* @return the created comment
|
||||||
|
*/
|
||||||
|
@PostMapping
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<CommentDTO> createComment(
|
||||||
|
@PathVariable UUID activityId,
|
||||||
|
@Valid @RequestBody CommentCreateRequest request,
|
||||||
|
@AuthenticationPrincipal UserDetails userDetails
|
||||||
|
) {
|
||||||
|
User user = getUser(userDetails);
|
||||||
|
|
||||||
|
// Check if activity exists
|
||||||
|
Activity activity = activityRepository.findById(activityId)
|
||||||
|
.orElse(null);
|
||||||
|
if (activity == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create comment
|
||||||
|
Comment comment = Comment.builder()
|
||||||
|
.activityId(activityId)
|
||||||
|
.userId(user.getId())
|
||||||
|
.displayName(user.getDisplayName() != null ? user.getDisplayName() : user.getUsername())
|
||||||
|
.avatarUrl(user.getAvatarUrl())
|
||||||
|
.content(request.getContent().trim())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Comment saved = commentRepository.save(comment);
|
||||||
|
|
||||||
|
log.info("User {} commented on activity {}", user.getUsername(), activityId);
|
||||||
|
|
||||||
|
// TODO: Send ActivityPub Create/Note activity to followers if activity is public
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.body(CommentDTO.fromEntity(saved, baseUrl, user.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a comment.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @param commentId the comment ID
|
||||||
|
* @param userDetails the authenticated user
|
||||||
|
* @return no content
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{commentId}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Void> deleteComment(
|
||||||
|
@PathVariable UUID activityId,
|
||||||
|
@PathVariable UUID commentId,
|
||||||
|
@AuthenticationPrincipal UserDetails userDetails
|
||||||
|
) {
|
||||||
|
User user = getUser(userDetails);
|
||||||
|
|
||||||
|
// Find comment
|
||||||
|
Comment comment = commentRepository.findById(commentId)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (comment == null || !comment.getActivityId().equals(activityId)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if (!comment.getUserId().equals(user.getId())) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
comment.setDeleted(true);
|
||||||
|
commentRepository.save(comment);
|
||||||
|
|
||||||
|
log.info("User {} deleted comment {}", user.getUsername(), commentId);
|
||||||
|
|
||||||
|
// TODO: Send ActivityPub Delete activity to followers if activity is public
|
||||||
|
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/main/java/org/operaton/fitpub/controller/LikeController.java
Normal file
143
src/main/java/org/operaton/fitpub/controller/LikeController.java
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package org.operaton.fitpub.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.operaton.fitpub.model.dto.LikeDTO;
|
||||||
|
import org.operaton.fitpub.model.entity.Activity;
|
||||||
|
import org.operaton.fitpub.model.entity.Like;
|
||||||
|
import org.operaton.fitpub.model.entity.User;
|
||||||
|
import org.operaton.fitpub.repository.ActivityRepository;
|
||||||
|
import org.operaton.fitpub.repository.LikeRepository;
|
||||||
|
import org.operaton.fitpub.repository.UserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for like operations.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/activities/{activityId}/likes")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class LikeController {
|
||||||
|
|
||||||
|
private final LikeRepository likeRepository;
|
||||||
|
private final ActivityRepository activityRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Value("${fitpub.base-url}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to get user from authenticated UserDetails.
|
||||||
|
*/
|
||||||
|
private User getUser(UserDetails userDetails) {
|
||||||
|
return userRepository.findByUsername(userDetails.getUsername())
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all likes for an activity.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @return list of likes
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<LikeDTO>> getLikes(@PathVariable UUID activityId) {
|
||||||
|
// Check if activity exists
|
||||||
|
if (!activityRepository.existsById(activityId)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Like> likes = likeRepository.findByActivityIdOrderByCreatedAtDesc(activityId);
|
||||||
|
List<LikeDTO> likeDTOs = likes.stream()
|
||||||
|
.map(like -> LikeDTO.fromEntity(like, baseUrl))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(likeDTOs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like an activity.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @param userDetails the authenticated user
|
||||||
|
* @return the created like
|
||||||
|
*/
|
||||||
|
@PostMapping
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<LikeDTO> likeActivity(
|
||||||
|
@PathVariable UUID activityId,
|
||||||
|
@AuthenticationPrincipal UserDetails userDetails
|
||||||
|
) {
|
||||||
|
User user = getUser(userDetails);
|
||||||
|
|
||||||
|
// Check if activity exists
|
||||||
|
Activity activity = activityRepository.findById(activityId)
|
||||||
|
.orElse(null);
|
||||||
|
if (activity == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already liked
|
||||||
|
if (likeRepository.existsByActivityIdAndUserId(activityId, user.getId())) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create like
|
||||||
|
Like like = Like.builder()
|
||||||
|
.activityId(activityId)
|
||||||
|
.userId(user.getId())
|
||||||
|
.displayName(user.getDisplayName() != null ? user.getDisplayName() : user.getUsername())
|
||||||
|
.avatarUrl(user.getAvatarUrl())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Like saved = likeRepository.save(like);
|
||||||
|
|
||||||
|
log.info("User {} liked activity {}", user.getUsername(), activityId);
|
||||||
|
|
||||||
|
// TODO: Send ActivityPub Like activity to followers if activity is public
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.body(LikeDTO.fromEntity(saved, baseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlike an activity.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @param userDetails the authenticated user
|
||||||
|
* @return no content
|
||||||
|
*/
|
||||||
|
@DeleteMapping
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Void> unlikeActivity(
|
||||||
|
@PathVariable UUID activityId,
|
||||||
|
@AuthenticationPrincipal UserDetails userDetails
|
||||||
|
) {
|
||||||
|
User user = getUser(userDetails);
|
||||||
|
|
||||||
|
// Check if like exists
|
||||||
|
if (!likeRepository.existsByActivityIdAndUserId(activityId, user.getId())) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
likeRepository.deleteByActivityIdAndUserId(activityId, user.getId());
|
||||||
|
|
||||||
|
log.info("User {} unliked activity {}", user.getUsername(), activityId);
|
||||||
|
|
||||||
|
// TODO: Send ActivityPub Undo Like activity to followers if activity is public
|
||||||
|
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,11 @@ public class ActivityDTO {
|
||||||
private Map<String, Object> simplifiedTrack; // GeoJSON LineString
|
private Map<String, Object> simplifiedTrack; // GeoJSON LineString
|
||||||
private List<Map<String, Object>> trackPoints; // Full track points from JSONB
|
private List<Map<String, Object>> trackPoints; // Full track points from JSONB
|
||||||
|
|
||||||
|
// Social interaction counts (populated separately)
|
||||||
|
private Long likesCount;
|
||||||
|
private Long commentsCount;
|
||||||
|
private Boolean likedByCurrentUser; // True if current user has liked this activity
|
||||||
|
|
||||||
// Convenience getters for flattened metrics (for frontend compatibility)
|
// Convenience getters for flattened metrics (for frontend compatibility)
|
||||||
public Integer getAverageHeartRate() {
|
public Integer getAverageHeartRate() {
|
||||||
return metrics != null ? metrics.getAverageHeartRate() : null;
|
return metrics != null ? metrics.getAverageHeartRate() : null;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package org.operaton.fitpub.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for creating a comment.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CommentCreateRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "Comment content is required")
|
||||||
|
@Size(max = 5000, message = "Comment must not exceed 5000 characters")
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
63
src/main/java/org/operaton/fitpub/model/dto/CommentDTO.java
Normal file
63
src/main/java/org/operaton/fitpub/model/dto/CommentDTO.java
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package org.operaton.fitpub.model.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.operaton.fitpub.model.entity.Comment;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for Comment data transfer.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CommentDTO {
|
||||||
|
|
||||||
|
private UUID id;
|
||||||
|
private UUID activityId;
|
||||||
|
private String actorUri; // Local user URI or remote actor URI
|
||||||
|
private String displayName;
|
||||||
|
private String avatarUrl;
|
||||||
|
private String content;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private boolean local;
|
||||||
|
private boolean canDelete; // True if current user can delete this comment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a DTO from a Comment entity.
|
||||||
|
*/
|
||||||
|
public static CommentDTO fromEntity(Comment comment, String baseUrl, UUID currentUserId) {
|
||||||
|
String actorUri;
|
||||||
|
boolean canDelete = false;
|
||||||
|
|
||||||
|
if (comment.isLocal()) {
|
||||||
|
// Build local actor URI
|
||||||
|
actorUri = String.format("%s/users/%s", baseUrl, comment.getUserId());
|
||||||
|
// User can delete their own comments
|
||||||
|
if (currentUserId != null && currentUserId.equals(comment.getUserId())) {
|
||||||
|
canDelete = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
actorUri = comment.getRemoteActorUri();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CommentDTO.builder()
|
||||||
|
.id(comment.getId())
|
||||||
|
.activityId(comment.getActivityId())
|
||||||
|
.actorUri(actorUri)
|
||||||
|
.displayName(comment.getDisplayName())
|
||||||
|
.avatarUrl(comment.getAvatarUrl())
|
||||||
|
.content(comment.getContent())
|
||||||
|
.createdAt(comment.getCreatedAt())
|
||||||
|
.updatedAt(comment.getUpdatedAt())
|
||||||
|
.local(comment.isLocal())
|
||||||
|
.canDelete(canDelete)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/main/java/org/operaton/fitpub/model/dto/LikeDTO.java
Normal file
51
src/main/java/org/operaton/fitpub/model/dto/LikeDTO.java
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package org.operaton.fitpub.model.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.operaton.fitpub.model.entity.Like;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for Like data transfer.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class LikeDTO {
|
||||||
|
|
||||||
|
private UUID id;
|
||||||
|
private UUID activityId;
|
||||||
|
private String actorUri; // Local user URI or remote actor URI
|
||||||
|
private String displayName;
|
||||||
|
private String avatarUrl;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private boolean local;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a DTO from a Like entity.
|
||||||
|
*/
|
||||||
|
public static LikeDTO fromEntity(Like like, String baseUrl) {
|
||||||
|
String actorUri;
|
||||||
|
if (like.isLocal()) {
|
||||||
|
// Build local actor URI: https://domain/users/userId
|
||||||
|
actorUri = String.format("%s/users/%s", baseUrl, like.getUserId());
|
||||||
|
} else {
|
||||||
|
actorUri = like.getRemoteActorUri();
|
||||||
|
}
|
||||||
|
|
||||||
|
return LikeDTO.builder()
|
||||||
|
.id(like.getId())
|
||||||
|
.activityId(like.getActivityId())
|
||||||
|
.actorUri(actorUri)
|
||||||
|
.displayName(like.getDisplayName())
|
||||||
|
.avatarUrl(like.getAvatarUrl())
|
||||||
|
.createdAt(like.getCreatedAt())
|
||||||
|
.local(like.isLocal())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/main/java/org/operaton/fitpub/model/entity/Comment.java
Normal file
103
src/main/java/org/operaton/fitpub/model/entity/Comment.java
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
package org.operaton.fitpub.model.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity representing a comment on an activity.
|
||||||
|
* Supports both local and federated comments (from remote ActivityPub actors).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "comments", indexes = {
|
||||||
|
@Index(name = "idx_comments_activity_id", columnList = "activity_id"),
|
||||||
|
@Index(name = "idx_comments_user_id", columnList = "user_id"),
|
||||||
|
@Index(name = "idx_comments_created_at", columnList = "created_at")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class Comment {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The activity being commented on.
|
||||||
|
*/
|
||||||
|
@Column(name = "activity_id", nullable = false)
|
||||||
|
private UUID activityId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local user who commented (null if remote).
|
||||||
|
*/
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private UUID userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remote actor URI who commented (null if local).
|
||||||
|
* Format: https://mastodon.social/users/username
|
||||||
|
*/
|
||||||
|
@Column(name = "remote_actor_uri", length = 500)
|
||||||
|
private String remoteActorUri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display name of the commenter (cached for performance).
|
||||||
|
*/
|
||||||
|
@Column(name = "display_name", length = 200)
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avatar URL of the commenter (cached for performance).
|
||||||
|
*/
|
||||||
|
@Column(name = "avatar_url", length = 500)
|
||||||
|
private String avatarUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The comment content.
|
||||||
|
*/
|
||||||
|
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ActivityPub Note/Create activity ID (for federation).
|
||||||
|
* Format: https://mastodon.social/users/username/statuses/123
|
||||||
|
*/
|
||||||
|
@Column(name = "activity_pub_id", length = 500)
|
||||||
|
private String activityPubId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the comment has been deleted (soft delete for federation tracking).
|
||||||
|
*/
|
||||||
|
@Column(name = "deleted", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean deleted = false;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a local comment.
|
||||||
|
*/
|
||||||
|
public boolean isLocal() {
|
||||||
|
return userId != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the actor identifier (local user ID or remote actor URI).
|
||||||
|
*/
|
||||||
|
public String getActorIdentifier() {
|
||||||
|
return isLocal() ? userId.toString() : remoteActorUri;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/main/java/org/operaton/fitpub/model/entity/Like.java
Normal file
86
src/main/java/org/operaton/fitpub/model/entity/Like.java
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package org.operaton.fitpub.model.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity representing a like on an activity.
|
||||||
|
* Supports both local and federated likes (from remote ActivityPub actors).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "likes", indexes = {
|
||||||
|
@Index(name = "idx_likes_activity_id", columnList = "activity_id"),
|
||||||
|
@Index(name = "idx_likes_user_id", columnList = "user_id"),
|
||||||
|
@Index(name = "idx_likes_activity_user", columnList = "activity_id,user_id", unique = true),
|
||||||
|
@Index(name = "idx_likes_activity_actor", columnList = "activity_id,remote_actor_uri", unique = true)
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class Like {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The activity being liked.
|
||||||
|
*/
|
||||||
|
@Column(name = "activity_id", nullable = false)
|
||||||
|
private UUID activityId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local user who liked the activity (null if remote).
|
||||||
|
*/
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private UUID userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remote actor URI who liked the activity (null if local).
|
||||||
|
* Format: https://mastodon.social/users/username
|
||||||
|
*/
|
||||||
|
@Column(name = "remote_actor_uri", length = 500)
|
||||||
|
private String remoteActorUri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display name of the liker (cached for performance).
|
||||||
|
*/
|
||||||
|
@Column(name = "display_name", length = 200)
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avatar URL of the liker (cached for performance).
|
||||||
|
*/
|
||||||
|
@Column(name = "avatar_url", length = 500)
|
||||||
|
private String avatarUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ActivityPub Like activity ID (for federation).
|
||||||
|
* Format: https://mastodon.social/users/username/statuses/123/activity
|
||||||
|
*/
|
||||||
|
@Column(name = "activity_pub_id", length = 500)
|
||||||
|
private String activityPubId;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a local like.
|
||||||
|
*/
|
||||||
|
public boolean isLocal() {
|
||||||
|
return userId != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the actor identifier (local user ID or remote actor URI).
|
||||||
|
*/
|
||||||
|
public String getActorIdentifier() {
|
||||||
|
return isLocal() ? userId.toString() : remoteActorUri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package org.operaton.fitpub.repository;
|
||||||
|
|
||||||
|
import org.operaton.fitpub.model.entity.Comment;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for Comment entity operations.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface CommentRepository extends JpaRepository<Comment, UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all non-deleted comments for an activity, ordered by creation time.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of comments
|
||||||
|
*/
|
||||||
|
@Query("SELECT c FROM Comment c WHERE c.activityId = :activityId AND c.deleted = false ORDER BY c.createdAt ASC")
|
||||||
|
Page<Comment> findByActivityIdAndNotDeleted(@Param("activityId") UUID activityId, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count non-deleted comments for an activity.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @return number of comments
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(c) FROM Comment c WHERE c.activityId = :activityId AND c.deleted = false")
|
||||||
|
long countByActivityIdAndNotDeleted(@Param("activityId") UUID activityId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a comment by ActivityPub ID.
|
||||||
|
*
|
||||||
|
* @param activityPubId the ActivityPub Note/Create activity ID
|
||||||
|
* @return the comment if exists
|
||||||
|
*/
|
||||||
|
Optional<Comment> findByActivityPubId(String activityPubId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all comments by a local user.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @return list of comments
|
||||||
|
*/
|
||||||
|
List<Comment> findByUserIdOrderByCreatedAtDesc(UUID userId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package org.operaton.fitpub.repository;
|
||||||
|
|
||||||
|
import org.operaton.fitpub.model.entity.Like;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for Like entity operations.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface LikeRepository extends JpaRepository<Like, UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all likes for an activity.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @return list of likes
|
||||||
|
*/
|
||||||
|
List<Like> findByActivityIdOrderByCreatedAtDesc(UUID activityId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count likes for an activity.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @return number of likes
|
||||||
|
*/
|
||||||
|
long countByActivityId(UUID activityId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a like by activity and local user.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @param userId the user ID
|
||||||
|
* @return the like if exists
|
||||||
|
*/
|
||||||
|
Optional<Like> findByActivityIdAndUserId(UUID activityId, UUID userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a like by activity and remote actor.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @param remoteActorUri the remote actor URI
|
||||||
|
* @return the like if exists
|
||||||
|
*/
|
||||||
|
Optional<Like> findByActivityIdAndRemoteActorUri(UUID activityId, String remoteActorUri);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a like by ActivityPub ID.
|
||||||
|
*
|
||||||
|
* @param activityPubId the ActivityPub Like activity ID
|
||||||
|
* @return the like if exists
|
||||||
|
*/
|
||||||
|
Optional<Like> findByActivityPubId(String activityPubId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a local user has liked an activity.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @param userId the user ID
|
||||||
|
* @return true if liked
|
||||||
|
*/
|
||||||
|
boolean existsByActivityIdAndUserId(UUID activityId, UUID userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a like by activity and user.
|
||||||
|
*
|
||||||
|
* @param activityId the activity ID
|
||||||
|
* @param userId the user ID
|
||||||
|
*/
|
||||||
|
void deleteByActivityIdAndUserId(UUID activityId, UUID userId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
-- Migration V7: Create likes and comments tables for activity interactions
|
||||||
|
|
||||||
|
-- Create likes table
|
||||||
|
CREATE TABLE likes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
remote_actor_uri VARCHAR(500),
|
||||||
|
display_name VARCHAR(200),
|
||||||
|
avatar_url VARCHAR(500),
|
||||||
|
activity_pub_id VARCHAR(500),
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
-- Constraints: either user_id OR remote_actor_uri must be set, but not both
|
||||||
|
CONSTRAINT chk_likes_actor CHECK (
|
||||||
|
(user_id IS NOT NULL AND remote_actor_uri IS NULL) OR
|
||||||
|
(user_id IS NULL AND remote_actor_uri IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for likes
|
||||||
|
CREATE INDEX idx_likes_activity_id ON likes(activity_id);
|
||||||
|
CREATE INDEX idx_likes_user_id ON likes(user_id);
|
||||||
|
CREATE INDEX idx_likes_created_at ON likes(created_at);
|
||||||
|
CREATE UNIQUE INDEX idx_likes_activity_user ON likes(activity_id, user_id) WHERE user_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX idx_likes_activity_actor ON likes(activity_id, remote_actor_uri) WHERE remote_actor_uri IS NOT NULL;
|
||||||
|
CREATE INDEX idx_likes_activity_pub_id ON likes(activity_pub_id) WHERE activity_pub_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Create comments table
|
||||||
|
CREATE TABLE comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
remote_actor_uri VARCHAR(500),
|
||||||
|
display_name VARCHAR(200),
|
||||||
|
avatar_url VARCHAR(500),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
activity_pub_id VARCHAR(500),
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
|
||||||
|
-- Constraints: either user_id OR remote_actor_uri must be set, but not both
|
||||||
|
CONSTRAINT chk_comments_actor CHECK (
|
||||||
|
(user_id IS NOT NULL AND remote_actor_uri IS NULL) OR
|
||||||
|
(user_id IS NULL AND remote_actor_uri IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for comments
|
||||||
|
CREATE INDEX idx_comments_activity_id ON comments(activity_id);
|
||||||
|
CREATE INDEX idx_comments_user_id ON comments(user_id);
|
||||||
|
CREATE INDEX idx_comments_created_at ON comments(created_at);
|
||||||
|
CREATE INDEX idx_comments_activity_pub_id ON comments(activity_pub_id) WHERE activity_pub_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_comments_not_deleted ON comments(activity_id, deleted) WHERE deleted = false;
|
||||||
|
|
||||||
|
-- Add comments for documentation
|
||||||
|
COMMENT ON TABLE likes IS 'Likes on activities, supporting both local and federated likes';
|
||||||
|
COMMENT ON TABLE comments IS 'Comments on activities, supporting both local and federated comments';
|
||||||
|
COMMENT ON COLUMN likes.user_id IS 'Local user who liked (null if remote)';
|
||||||
|
COMMENT ON COLUMN likes.remote_actor_uri IS 'Remote ActivityPub actor URI (null if local)';
|
||||||
|
COMMENT ON COLUMN likes.activity_pub_id IS 'ActivityPub Like activity ID for federation';
|
||||||
|
COMMENT ON COLUMN comments.user_id IS 'Local user who commented (null if remote)';
|
||||||
|
COMMENT ON COLUMN comments.remote_actor_uri IS 'Remote ActivityPub actor URI (null if local)';
|
||||||
|
COMMENT ON COLUMN comments.activity_pub_id IS 'ActivityPub Note/Create activity ID for federation';
|
||||||
|
COMMENT ON COLUMN comments.deleted IS 'Soft delete flag for federation tracking';
|
||||||
Loading…
Add table
Add a link
Reference in a new issue