diff --git a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java index c961b2a..34991fc 100644 --- a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java +++ b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java @@ -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() diff --git a/src/main/java/org/operaton/fitpub/controller/CommentController.java b/src/main/java/org/operaton/fitpub/controller/CommentController.java new file mode 100644 index 0000000..41c0775 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/CommentController.java @@ -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> 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 comments = commentRepository.findByActivityIdAndNotDeleted(activityId, pageable); + + UUID finalCurrentUserId = currentUserId; + Page 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 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 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(); + } +} diff --git a/src/main/java/org/operaton/fitpub/controller/LikeController.java b/src/main/java/org/operaton/fitpub/controller/LikeController.java new file mode 100644 index 0000000..a7573ce --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/LikeController.java @@ -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> getLikes(@PathVariable UUID activityId) { + // Check if activity exists + if (!activityRepository.existsById(activityId)) { + return ResponseEntity.notFound().build(); + } + + List likes = likeRepository.findByActivityIdOrderByCreatedAtDesc(activityId); + List 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 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 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(); + } +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java b/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java index d07fc0e..7d217e9 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java @@ -49,6 +49,11 @@ public class ActivityDTO { private Map simplifiedTrack; // GeoJSON LineString private List> 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; diff --git a/src/main/java/org/operaton/fitpub/model/dto/CommentCreateRequest.java b/src/main/java/org/operaton/fitpub/model/dto/CommentCreateRequest.java new file mode 100644 index 0000000..2ff0bfa --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/CommentCreateRequest.java @@ -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; +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/CommentDTO.java b/src/main/java/org/operaton/fitpub/model/dto/CommentDTO.java new file mode 100644 index 0000000..44c9963 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/CommentDTO.java @@ -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(); + } +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/LikeDTO.java b/src/main/java/org/operaton/fitpub/model/dto/LikeDTO.java new file mode 100644 index 0000000..704ec74 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/LikeDTO.java @@ -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(); + } +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/Comment.java b/src/main/java/org/operaton/fitpub/model/entity/Comment.java new file mode 100644 index 0000000..ddceee9 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/Comment.java @@ -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; + } +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/Like.java b/src/main/java/org/operaton/fitpub/model/entity/Like.java new file mode 100644 index 0000000..224a2f5 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/Like.java @@ -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; + } +} diff --git a/src/main/java/org/operaton/fitpub/repository/CommentRepository.java b/src/main/java/org/operaton/fitpub/repository/CommentRepository.java new file mode 100644 index 0000000..1e7ecf0 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/CommentRepository.java @@ -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 { + + /** + * 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 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 findByActivityPubId(String activityPubId); + + /** + * Find all comments by a local user. + * + * @param userId the user ID + * @return list of comments + */ + List findByUserIdOrderByCreatedAtDesc(UUID userId); +} diff --git a/src/main/java/org/operaton/fitpub/repository/LikeRepository.java b/src/main/java/org/operaton/fitpub/repository/LikeRepository.java new file mode 100644 index 0000000..64e609a --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/LikeRepository.java @@ -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 { + + /** + * Find all likes for an activity. + * + * @param activityId the activity ID + * @return list of likes + */ + List 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 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 findByActivityIdAndRemoteActorUri(UUID activityId, String remoteActorUri); + + /** + * Find a like by ActivityPub ID. + * + * @param activityPubId the ActivityPub Like activity ID + * @return the like if exists + */ + Optional 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); +} diff --git a/src/main/resources/db/migration/V7__create_likes_and_comments_tables.sql b/src/main/resources/db/migration/V7__create_likes_and_comments_tables.sql new file mode 100644 index 0000000..092dcd3 --- /dev/null +++ b/src/main/resources/db/migration/V7__create_likes_and_comments_tables.sql @@ -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';