Add likes and comments

This commit is contained in:
Tim Zöller 2025-11-29 21:44:42 +01:00
parent 3808df9dbf
commit 97dcd92657
12 changed files with 854 additions and 0 deletions

View file

@ -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()

View file

@ -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();
}
}

View 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();
}
}

View file

@ -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;

View file

@ -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;
}

View 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();
}
}

View 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();
}
}

View 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;
}
}

View 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;
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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';