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
|
||||
.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
|
||||
.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 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)
|
||||
public Integer getAverageHeartRate() {
|
||||
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