- Replace NULLIF with CASE WHEN for proper UUID casting - Fix missing ::uuid casting in WHERE clauses - Resolve 'operator does not exist: uuid = text' errors - Focus on post_repository.go, notification_repository.go, and category_repository.go
913 lines
29 KiB
Go
913 lines
29 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/patbritton/sojorn-backend/internal/models"
|
|
)
|
|
|
|
type PostRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewPostRepository(pool *pgxpool.Pool) *PostRepository {
|
|
return &PostRepository{pool: pool}
|
|
}
|
|
|
|
func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) error {
|
|
// Calculate confidence score if it's a beacon
|
|
if post.IsBeacon {
|
|
var harmonyScore int
|
|
err := r.pool.QueryRow(ctx, "SELECT harmony_score FROM public.trust_state WHERE user_id = $1", post.AuthorID).Scan(&harmonyScore)
|
|
if err == nil {
|
|
// Logic: confidence = harmony_score / 100.0 (legacy parity)
|
|
post.Confidence = float64(harmonyScore) / 100.0
|
|
} else {
|
|
post.Confidence = 0.5 // Default fallback
|
|
}
|
|
}
|
|
|
|
query := `
|
|
INSERT INTO public.posts (
|
|
author_id, category_id, body, status, tone_label, cis_score,
|
|
image_url, video_url, thumbnail_url, duration_ms, body_format, background_id, tags,
|
|
is_beacon, beacon_type, location, confidence_score,
|
|
is_active_beacon, allow_chain, chain_parent_id, visibility, expires_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
|
$14, $15,
|
|
CASE WHEN ($16::double precision) IS NOT NULL AND ($17::double precision) IS NOT NULL
|
|
THEN ST_SetSRID(ST_MakePoint(($17::double precision), ($16::double precision)), 4326)::geography
|
|
ELSE NULL END,
|
|
$18, $19, $20, $21, $22, $23
|
|
) RETURNING id, created_at
|
|
`
|
|
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to start transaction: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
err = tx.QueryRow(ctx, query,
|
|
post.AuthorID, post.CategoryID, post.Body, post.Status, post.ToneLabel, post.CISScore,
|
|
post.ImageURL, post.VideoURL, post.ThumbnailURL, post.DurationMS, post.BodyFormat, post.BackgroundID, post.Tags,
|
|
post.IsBeacon, post.BeaconType, post.Lat, post.Long, post.Confidence,
|
|
post.IsActiveBeacon, post.AllowChain, post.ChainParentID, post.Visibility, post.ExpiresAt,
|
|
).Scan(&post.ID, &post.CreatedAt)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create post: %w", err)
|
|
}
|
|
|
|
// Initialize metrics
|
|
if _, err := tx.Exec(ctx, "INSERT INTO public.post_metrics (post_id) VALUES ($1)", post.ID); err != nil {
|
|
return fmt.Errorf("failed to initialize post metrics: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("failed to commit post transaction: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *PostRepository) GetRandomSponsoredPost(ctx context.Context, userID string) (*models.Post, error) {
|
|
query := `
|
|
SELECT
|
|
p.id, p.author_id, p.category_id, p.body, COALESCE(p.image_url, ''), COALESCE(p.video_url, ''), COALESCE(p.thumbnail_url, ''), p.duration_ms, COALESCE(p.tags, ARRAY[]::text[]), p.created_at,
|
|
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
|
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
|
FALSE as is_liked,
|
|
sp.advertiser_name
|
|
FROM public.sponsored_posts sp
|
|
JOIN public.posts p ON sp.post_id = p.id
|
|
JOIN public.profiles pr ON p.author_id = pr.id
|
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
|
WHERE p.deleted_at IS NULL AND p.status = 'active'
|
|
AND (
|
|
p.category_id IS NULL OR EXISTS (
|
|
SELECT 1 FROM public.user_category_settings ucs
|
|
WHERE ucs.user_id = $1 AND ucs.category_id = p.category_id AND ucs.enabled = true
|
|
)
|
|
)
|
|
ORDER BY RANDOM()
|
|
LIMIT 1
|
|
`
|
|
var p models.Post
|
|
var advertiserName string
|
|
err := r.pool.QueryRow(ctx, query, userID).Scan(
|
|
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
|
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
|
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
|
&advertiserName,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Author = &models.AuthorProfile{
|
|
ID: p.AuthorID,
|
|
Handle: p.AuthorHandle,
|
|
DisplayName: advertiserName, // Display advertiser name for ads
|
|
AvatarURL: p.AuthorAvatarURL,
|
|
}
|
|
p.IsSponsored = true
|
|
return &p, nil
|
|
}
|
|
|
|
func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlug string, hasVideo bool, limit int, offset int) ([]models.Post, error) {
|
|
query := `
|
|
SELECT
|
|
p.id,
|
|
p.author_id,
|
|
p.category_id,
|
|
p.body,
|
|
COALESCE(p.image_url, ''),
|
|
CASE
|
|
WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
|
|
WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
|
|
ELSE ''
|
|
END AS resolved_video_url,
|
|
COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
|
|
COALESCE(p.duration_ms, 0),
|
|
COALESCE(p.tags, ARRAY[]::text[]),
|
|
p.created_at,
|
|
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
|
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
|
FALSE as is_liked
|
|
FROM public.posts p
|
|
JOIN public.profiles pr ON p.author_id = pr.id
|
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
|
LEFT JOIN public.categories c ON p.category_id = c.id
|
|
WHERE p.deleted_at IS NULL
|
|
AND (
|
|
p.author_id = $4 -- My own posts
|
|
OR pr.is_private = FALSE -- Public profiles
|
|
OR EXISTS (
|
|
SELECT 1 FROM public.follows f
|
|
WHERE f.follower_id = $4 AND f.following_id = p.author_id AND f.status = 'accepted'
|
|
)
|
|
)
|
|
AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%%.mp4')))
|
|
ORDER BY p.created_at DESC
|
|
LIMIT $1 OFFSET $2
|
|
`
|
|
rows, err := r.pool.Query(ctx, query, limit, offset, hasVideo, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
posts := []models.Post{}
|
|
for rows.Next() {
|
|
var p models.Post
|
|
err := rows.Scan(
|
|
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
|
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
|
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Author = &models.AuthorProfile{
|
|
ID: p.AuthorID,
|
|
Handle: p.AuthorHandle,
|
|
DisplayName: p.AuthorDisplayName,
|
|
AvatarURL: p.AuthorAvatarURL,
|
|
}
|
|
posts = append(posts, p)
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (r *PostRepository) GetCategories(ctx context.Context) ([]models.Category, error) {
|
|
query := `SELECT id, slug, name, description, is_sensitive, created_at FROM public.categories ORDER BY name ASC`
|
|
rows, err := r.pool.Query(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var categories []models.Category
|
|
for rows.Next() {
|
|
var c models.Category
|
|
err := rows.Scan(&c.ID, &c.Slug, &c.Name, &c.Description, &c.IsSensitive, &c.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
categories = append(categories, c)
|
|
}
|
|
return categories, nil
|
|
}
|
|
|
|
func (r *PostRepository) GetPostsByAuthor(ctx context.Context, authorID string, viewerID string, limit int, offset int) ([]models.Post, error) {
|
|
query := `
|
|
SELECT
|
|
p.id,
|
|
p.author_id,
|
|
p.category_id,
|
|
p.body,
|
|
COALESCE(p.image_url, ''),
|
|
CASE
|
|
WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
|
|
WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
|
|
ELSE ''
|
|
END AS resolved_video_url,
|
|
COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
|
|
COALESCE(p.duration_ms, 0),
|
|
COALESCE(p.tags, ARRAY[]::text[]),
|
|
p.created_at,
|
|
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
|
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
|
FALSE as is_liked
|
|
FROM posts p
|
|
JOIN profiles pr ON p.author_id = pr.id
|
|
LEFT JOIN post_metrics m ON p.id = m.post_id
|
|
WHERE p.author_id = $1 AND p.deleted_at IS NULL AND p.status = 'active'
|
|
AND (
|
|
p.author_id = $4 -- Viewer is author
|
|
OR pr.is_private = FALSE -- Public profile
|
|
OR EXISTS (
|
|
SELECT 1 FROM public.follows f
|
|
WHERE f.follower_id = $4 AND f.following_id = p.author_id AND f.status = 'accepted'
|
|
)
|
|
)
|
|
ORDER BY p.created_at DESC
|
|
LIMIT $2 OFFSET $3
|
|
`
|
|
rows, err := r.pool.Query(ctx, query, authorID, limit, offset, viewerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var posts []models.Post
|
|
for rows.Next() {
|
|
var p models.Post
|
|
err := rows.Scan(
|
|
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
|
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
|
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Author = &models.AuthorProfile{
|
|
ID: p.AuthorID,
|
|
Handle: p.AuthorHandle,
|
|
DisplayName: p.AuthorDisplayName,
|
|
AvatarURL: p.AuthorAvatarURL,
|
|
}
|
|
posts = append(posts, p)
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID string) (*models.Post, error) {
|
|
query := `
|
|
SELECT
|
|
p.id,
|
|
p.author_id,
|
|
p.category_id,
|
|
p.body,
|
|
COALESCE(p.image_url, ''),
|
|
CASE
|
|
WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
|
|
WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
|
|
ELSE ''
|
|
END AS resolved_video_url,
|
|
COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
|
|
p.duration_ms,
|
|
COALESCE(p.tags, ARRAY[]::text[]),
|
|
p.created_at,
|
|
p.chain_parent_id,
|
|
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
|
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
|
CASE WHEN $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2) ELSE FALSE END as is_liked,
|
|
p.allow_chain, p.visibility
|
|
FROM public.posts p
|
|
JOIN public.profiles pr ON p.author_id = pr.id
|
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
|
WHERE p.id = $1 AND p.deleted_at IS NULL
|
|
AND (
|
|
p.author_id = $2
|
|
OR pr.is_private = FALSE
|
|
OR EXISTS (
|
|
SELECT 1 FROM public.follows f
|
|
WHERE f.follower_id = $2 AND f.following_id = p.author_id AND f.status = 'accepted'
|
|
)
|
|
)
|
|
`
|
|
var p models.Post
|
|
err := r.pool.QueryRow(ctx, query, postID, userID).Scan(
|
|
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
|
&p.ChainParentID,
|
|
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
|
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
|
&p.AllowChain, &p.Visibility,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Author = &models.AuthorProfile{
|
|
ID: p.AuthorID,
|
|
Handle: p.AuthorHandle,
|
|
DisplayName: p.AuthorDisplayName,
|
|
AvatarURL: p.AuthorAvatarURL,
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
func (r *PostRepository) UpdatePost(ctx context.Context, postID string, authorID string, body string) error {
|
|
query := `UPDATE public.posts SET body = $1, edited_at = NOW() WHERE id = $2 AND author_id = $3 AND deleted_at IS NULL`
|
|
res, err := r.pool.Exec(ctx, query, body, postID, authorID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if res.RowsAffected() == 0 {
|
|
return fmt.Errorf("post not found or unauthorized")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *PostRepository) DeletePost(ctx context.Context, postID string, authorID string) error {
|
|
query := `UPDATE public.posts SET deleted_at = NOW() WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL`
|
|
res, err := r.pool.Exec(ctx, query, postID, authorID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if res.RowsAffected() == 0 {
|
|
return fmt.Errorf("post not found or unauthorized")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *PostRepository) PinPost(ctx context.Context, postID string, authorID string, pinned bool) error {
|
|
var val *time.Time
|
|
if pinned {
|
|
t := time.Now()
|
|
val = &t
|
|
}
|
|
query := `UPDATE public.posts SET pinned_at = $1 WHERE id = $2 AND author_id = $3 AND deleted_at IS NULL`
|
|
res, err := r.pool.Exec(ctx, query, val, postID, authorID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if res.RowsAffected() == 0 {
|
|
return fmt.Errorf("post not found or unauthorized")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *PostRepository) UpdateVisibility(ctx context.Context, postID string, authorID string, visibility string) error {
|
|
query := `UPDATE public.posts SET visibility = $1 WHERE id = $2 AND author_id = $3 AND deleted_at IS NULL`
|
|
res, err := r.pool.Exec(ctx, query, visibility, postID, authorID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if res.RowsAffected() == 0 {
|
|
return fmt.Errorf("post not found or unauthorized")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *PostRepository) LikePost(ctx context.Context, postID string, userID string) error {
|
|
query := `INSERT INTO public.post_likes (post_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`
|
|
_, err := r.pool.Exec(ctx, query, postID, userID)
|
|
return err
|
|
}
|
|
|
|
func (r *PostRepository) UnlikePost(ctx context.Context, postID string, userID string) error {
|
|
query := `DELETE FROM public.post_likes WHERE post_id = $1 AND user_id = $2`
|
|
_, err := r.pool.Exec(ctx, query, postID, userID)
|
|
return err
|
|
}
|
|
|
|
func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error {
|
|
query := `INSERT INTO public.post_saves (post_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`
|
|
_, err := r.pool.Exec(ctx, query, postID, userID)
|
|
return err
|
|
}
|
|
|
|
func (r *PostRepository) UnsavePost(ctx context.Context, postID string, userID string) error {
|
|
query := `DELETE FROM public.post_saves WHERE post_id = $1 AND user_id = $2`
|
|
_, err := r.pool.Exec(ctx, query, postID, userID)
|
|
return err
|
|
}
|
|
|
|
func (r *PostRepository) CreateComment(ctx context.Context, comment *models.Comment) error {
|
|
query := `
|
|
INSERT INTO public.comments (post_id, author_id, body, status, created_at)
|
|
VALUES ($1, $2, $3, $4, NOW())
|
|
RETURNING id, created_at
|
|
`
|
|
err := r.pool.QueryRow(ctx, query, comment.PostID, comment.AuthorID, comment.Body, comment.Status).Scan(&comment.ID, &comment.CreatedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Increment comment count in metrics
|
|
_, _ = r.pool.Exec(ctx, "UPDATE public.post_metrics SET comment_count = comment_count + 1 WHERE post_id = $1", comment.PostID)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *PostRepository) GetNearbyBeacons(ctx context.Context, lat float64, long float64, radius int) ([]models.Post, error) {
|
|
query := `
|
|
SELECT
|
|
p.id, p.author_id, p.category_id, p.body, COALESCE(p.image_url, ''), p.tags, p.created_at,
|
|
p.beacon_type, p.confidence_score, p.is_active_beacon,
|
|
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as long,
|
|
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url
|
|
FROM public.posts p
|
|
JOIN public.profiles pr ON p.author_id = pr.id
|
|
WHERE p.is_beacon = true
|
|
AND ST_DWithin(p.location, ST_SetSRID(ST_Point($2, $1), 4326)::geography, $3)
|
|
AND p.status = 'active'
|
|
ORDER BY p.created_at DESC
|
|
`
|
|
rows, err := r.pool.Query(ctx, query, lat, long, radius)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var beacons []models.Post
|
|
for rows.Next() {
|
|
var p models.Post
|
|
err := rows.Scan(
|
|
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
|
|
&p.BeaconType, &p.Confidence, &p.IsActiveBeacon, &p.Lat, &p.Long,
|
|
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Author = &models.AuthorProfile{
|
|
ID: p.AuthorID,
|
|
Handle: p.AuthorHandle,
|
|
DisplayName: p.AuthorDisplayName,
|
|
AvatarURL: p.AuthorAvatarURL,
|
|
}
|
|
beacons = append(beacons, p)
|
|
}
|
|
return beacons, nil
|
|
}
|
|
|
|
func (r *PostRepository) GetSavedPosts(ctx context.Context, userID string, limit int, offset int) ([]models.Post, error) {
|
|
query := `
|
|
SELECT
|
|
p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, p.created_at,
|
|
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
|
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
|
EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $1) as is_liked
|
|
FROM public.post_saves ps
|
|
JOIN public.posts p ON ps.post_id = p.id
|
|
JOIN public.profiles pr ON p.author_id = pr.id
|
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
|
WHERE ps.user_id = $1 AND p.deleted_at IS NULL
|
|
ORDER BY ps.created_at DESC
|
|
LIMIT $2 OFFSET $3
|
|
`
|
|
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var posts []models.Post
|
|
for rows.Next() {
|
|
var p models.Post
|
|
err := rows.Scan(
|
|
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
|
|
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
|
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Author = &models.AuthorProfile{
|
|
ID: p.AuthorID,
|
|
Handle: p.AuthorHandle,
|
|
DisplayName: p.AuthorDisplayName,
|
|
AvatarURL: p.AuthorAvatarURL,
|
|
}
|
|
posts = append(posts, p)
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (r *PostRepository) GetLikedPosts(ctx context.Context, userID string, limit int, offset int) ([]models.Post, error) {
|
|
query := `
|
|
SELECT
|
|
p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, p.created_at,
|
|
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
|
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
|
TRUE as is_liked
|
|
FROM public.post_likes pl
|
|
JOIN public.posts p ON pl.post_id = p.id
|
|
JOIN public.profiles pr ON p.author_id = pr.id
|
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
|
WHERE pl.user_id = $1 AND p.deleted_at IS NULL
|
|
ORDER BY pl.created_at DESC
|
|
LIMIT $2 OFFSET $3
|
|
`
|
|
rows, err := r.pool.Query(ctx, query, userID, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var posts []models.Post
|
|
for rows.Next() {
|
|
var p models.Post
|
|
err := rows.Scan(
|
|
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
|
|
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
|
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Author = &models.AuthorProfile{
|
|
ID: p.AuthorID,
|
|
Handle: p.AuthorHandle,
|
|
DisplayName: p.AuthorDisplayName,
|
|
AvatarURL: p.AuthorAvatarURL,
|
|
}
|
|
posts = append(posts, p)
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (r *PostRepository) GetPostChain(ctx context.Context, rootID string) ([]models.Post, error) {
|
|
// Recursive CTE to get the chain
|
|
query := `
|
|
WITH RECURSIVE object_chain AS (
|
|
-- Anchor member: select the root post
|
|
SELECT
|
|
p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, p.created_at, p.chain_parent_id,
|
|
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
|
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
|
1 as level
|
|
FROM public.posts p
|
|
JOIN public.profiles pr ON p.author_id = pr.id
|
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
|
WHERE p.id = $1 AND p.deleted_at IS NULL
|
|
|
|
UNION ALL
|
|
|
|
-- Recursive member: select children
|
|
SELECT
|
|
p.id, p.author_id, p.category_id, p.body, p.image_url, p.tags, p.created_at, p.chain_parent_id,
|
|
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
|
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
|
oc.level + 1
|
|
FROM public.posts p
|
|
JOIN public.profiles pr ON p.author_id = pr.id
|
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
|
JOIN object_chain oc ON p.chain_parent_id = oc.id
|
|
WHERE p.deleted_at IS NULL
|
|
)
|
|
SELECT
|
|
id, author_id, category_id, body, image_url, tags, created_at,
|
|
author_handle, author_display_name, author_avatar_url,
|
|
like_count, comment_count
|
|
FROM object_chain
|
|
ORDER BY level ASC, created_at ASC;
|
|
`
|
|
rows, err := r.pool.Query(ctx, query, rootID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var posts []models.Post
|
|
for rows.Next() {
|
|
var p models.Post
|
|
err := rows.Scan(
|
|
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
|
|
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
|
&p.LikeCount, &p.CommentCount,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Author = &models.AuthorProfile{
|
|
ID: p.AuthorID,
|
|
Handle: p.AuthorHandle,
|
|
DisplayName: p.AuthorDisplayName,
|
|
AvatarURL: p.AuthorAvatarURL,
|
|
}
|
|
posts = append(posts, p)
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (r *PostRepository) SearchPosts(ctx context.Context, query string, viewerID string, limit int) ([]models.Post, error) {
|
|
searchQuery := "%" + query + "%"
|
|
sql := `
|
|
SELECT
|
|
p.id, p.author_id, p.category_id, p.body, COALESCE(p.image_url, ''), COALESCE(p.video_url, ''), COALESCE(p.thumbnail_url, ''), p.duration_ms, COALESCE(p.tags, ARRAY[]::text[]), p.created_at,
|
|
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
|
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
|
FALSE as is_liked
|
|
FROM public.posts p
|
|
JOIN public.profiles pr ON p.author_id = pr.id
|
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
|
WHERE (p.body ILIKE $1 OR $2 = ANY(p.tags))
|
|
AND p.deleted_at IS NULL AND p.status = 'active'
|
|
AND (
|
|
p.author_id = $4
|
|
OR pr.is_private = FALSE
|
|
OR EXISTS (
|
|
SELECT 1 FROM public.follows f
|
|
WHERE f.follower_id = $4 AND f.following_id = p.author_id AND f.status = 'accepted'
|
|
)
|
|
)
|
|
ORDER BY p.created_at DESC
|
|
LIMIT $3
|
|
`
|
|
rows, err := r.pool.Query(ctx, sql, searchQuery, query, limit, viewerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var posts []models.Post
|
|
for rows.Next() {
|
|
var p models.Post
|
|
err := rows.Scan(
|
|
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
|
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
|
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Author = &models.AuthorProfile{
|
|
ID: p.AuthorID,
|
|
Handle: p.AuthorHandle,
|
|
DisplayName: p.AuthorDisplayName,
|
|
AvatarURL: p.AuthorAvatarURL,
|
|
}
|
|
posts = append(posts, p)
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (r *PostRepository) SearchTags(ctx context.Context, query string, limit int) ([]models.TagResult, error) {
|
|
searchQuery := "%" + query + "%"
|
|
sql := `
|
|
SELECT tag, COUNT(*) as count
|
|
FROM (
|
|
SELECT unnest(tags) as tag FROM public.posts WHERE deleted_at IS NULL AND status = 'active'
|
|
) t
|
|
WHERE tag ILIKE $1
|
|
GROUP BY tag
|
|
ORDER BY count DESC
|
|
LIMIT $2
|
|
`
|
|
rows, err := r.pool.Query(ctx, sql, searchQuery, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tags []models.TagResult
|
|
for rows.Next() {
|
|
var t models.TagResult
|
|
if err := rows.Scan(&t.Tag, &t.Count); err != nil {
|
|
return nil, err
|
|
}
|
|
tags = append(tags, t)
|
|
}
|
|
return tags, nil
|
|
}
|
|
|
|
// GetPostFocusContext retrieves minimal data for Focus-Context view
|
|
// Returns: Target Post, Direct Parent (if any), and Direct Children (1st layer only)
|
|
func (r *PostRepository) GetPostFocusContext(ctx context.Context, postID string, userID string) (*models.FocusContext, error) {
|
|
// Get target post
|
|
targetPost, err := r.GetPostByID(ctx, postID, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get target post: %w", err)
|
|
}
|
|
|
|
var parentPost *models.Post
|
|
var children []models.Post
|
|
var parentChildren []models.Post
|
|
|
|
// Get parent post if chain_parent_id exists
|
|
if targetPost.ChainParentID != nil {
|
|
parentPost, err = r.GetPostByID(ctx, targetPost.ChainParentID.String(), userID)
|
|
if err != nil {
|
|
// Parent might not exist or be inaccessible - continue without it
|
|
parentPost = nil
|
|
}
|
|
}
|
|
|
|
// Get direct children (1st layer replies only)
|
|
childrenQuery := `
|
|
SELECT
|
|
p.id,
|
|
p.author_id,
|
|
p.category_id,
|
|
p.body,
|
|
COALESCE(p.image_url, ''),
|
|
CASE
|
|
WHEN COALESCE(p.video_url, '') <> '' THEN p.video_url
|
|
WHEN COALESCE(p.image_url, '') ILIKE '%.mp4' THEN p.image_url
|
|
ELSE ''
|
|
END AS resolved_video_url,
|
|
COALESCE(NULLIF(p.thumbnail_url, ''), p.image_url, '') AS resolved_thumbnail_url,
|
|
p.duration_ms,
|
|
COALESCE(p.tags, ARRAY[]::text[]),
|
|
p.created_at,
|
|
pr.handle as author_handle, pr.display_name as author_display_name, COALESCE(pr.avatar_url, '') as author_avatar_url,
|
|
COALESCE(m.like_count, 0) as like_count, COALESCE(m.comment_count, 0) as comment_count,
|
|
CASE WHEN $2 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $2) ELSE FALSE END as is_liked,
|
|
p.allow_chain, p.visibility
|
|
FROM public.posts p
|
|
JOIN public.profiles pr ON p.author_id = pr.id
|
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
|
WHERE p.chain_parent_id = $1 AND p.deleted_at IS NULL AND p.status = 'active'
|
|
AND (
|
|
p.author_id = $2
|
|
OR pr.is_private = FALSE
|
|
OR EXISTS (
|
|
SELECT 1 FROM public.follows f
|
|
WHERE f.follower_id = $2 AND f.following_id = p.author_id AND f.status = 'accepted'
|
|
)
|
|
)
|
|
ORDER BY p.created_at ASC
|
|
`
|
|
|
|
rows, err := r.pool.Query(ctx, childrenQuery, postID, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get children posts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var p models.Post
|
|
err := rows.Scan(
|
|
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
|
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
|
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
|
&p.AllowChain, &p.Visibility,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan child post: %w", err)
|
|
}
|
|
p.Author = &models.AuthorProfile{
|
|
ID: p.AuthorID,
|
|
Handle: p.AuthorHandle,
|
|
DisplayName: p.AuthorDisplayName,
|
|
AvatarURL: p.AuthorAvatarURL,
|
|
}
|
|
children = append(children, p)
|
|
}
|
|
|
|
// If we have a parent, fetch its direct children (siblings + current)
|
|
if parentPost != nil {
|
|
siblingRows, err := r.pool.Query(ctx, childrenQuery, parentPost.ID.String(), userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get parent children: %w", err)
|
|
}
|
|
defer siblingRows.Close()
|
|
|
|
for siblingRows.Next() {
|
|
var p models.Post
|
|
err := siblingRows.Scan(
|
|
&p.ID, &p.AuthorID, &p.CategoryID, &p.Body, &p.ImageURL, &p.VideoURL, &p.ThumbnailURL, &p.DurationMS, &p.Tags, &p.CreatedAt,
|
|
&p.AuthorHandle, &p.AuthorDisplayName, &p.AuthorAvatarURL,
|
|
&p.LikeCount, &p.CommentCount, &p.IsLiked,
|
|
&p.AllowChain, &p.Visibility,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan parent child post: %w", err)
|
|
}
|
|
p.Author = &models.AuthorProfile{
|
|
ID: p.AuthorID,
|
|
Handle: p.AuthorHandle,
|
|
DisplayName: p.AuthorDisplayName,
|
|
AvatarURL: p.AuthorAvatarURL,
|
|
}
|
|
parentChildren = append(parentChildren, p)
|
|
}
|
|
}
|
|
|
|
return &models.FocusContext{
|
|
TargetPost: targetPost,
|
|
ParentPost: parentPost,
|
|
Children: children,
|
|
ParentChildren: parentChildren,
|
|
}, nil
|
|
}
|
|
|
|
func (r *PostRepository) ToggleReaction(ctx context.Context, postID string, userID string, emoji string) (map[string]int, []string, error) {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to start transaction: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var exists bool
|
|
err = tx.QueryRow(
|
|
ctx,
|
|
`SELECT EXISTS(
|
|
SELECT 1 FROM public.post_reactions
|
|
WHERE post_id = $1 AND user_id = $2 AND emoji = $3
|
|
)`,
|
|
postID,
|
|
userID,
|
|
emoji,
|
|
).Scan(&exists)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to check reaction: %w", err)
|
|
}
|
|
|
|
if exists {
|
|
if _, err := tx.Exec(
|
|
ctx,
|
|
`DELETE FROM public.post_reactions
|
|
WHERE post_id = $1 AND user_id = $2 AND emoji = $3`,
|
|
postID,
|
|
userID,
|
|
emoji,
|
|
); err != nil {
|
|
return nil, nil, fmt.Errorf("failed to remove reaction: %w", err)
|
|
}
|
|
} else {
|
|
if _, err := tx.Exec(
|
|
ctx,
|
|
`INSERT INTO public.post_reactions (post_id, user_id, emoji)
|
|
VALUES ($1, $2, $3)`,
|
|
postID,
|
|
userID,
|
|
emoji,
|
|
); err != nil {
|
|
return nil, nil, fmt.Errorf("failed to add reaction: %w", err)
|
|
}
|
|
}
|
|
|
|
rows, err := tx.Query(
|
|
ctx,
|
|
`SELECT emoji, COUNT(*) FROM public.post_reactions
|
|
WHERE post_id = $1
|
|
GROUP BY emoji`,
|
|
postID,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to load reaction counts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
counts := make(map[string]int)
|
|
for rows.Next() {
|
|
var reaction string
|
|
var count int
|
|
if err := rows.Scan(&reaction, &count); err != nil {
|
|
return nil, nil, fmt.Errorf("failed to scan reaction counts: %w", err)
|
|
}
|
|
counts[reaction] = count
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, nil, fmt.Errorf("failed to iterate reaction counts: %w", rows.Err())
|
|
}
|
|
|
|
userRows, err := tx.Query(
|
|
ctx,
|
|
`SELECT emoji FROM public.post_reactions
|
|
WHERE post_id = $1 AND user_id = $2`,
|
|
postID,
|
|
userID,
|
|
)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to load user reactions: %w", err)
|
|
}
|
|
defer userRows.Close()
|
|
|
|
myReactions := []string{}
|
|
for userRows.Next() {
|
|
var reaction string
|
|
if err := userRows.Scan(&reaction); err != nil {
|
|
return nil, nil, fmt.Errorf("failed to scan user reactions: %w", err)
|
|
}
|
|
myReactions = append(myReactions, reaction)
|
|
}
|
|
if userRows.Err() != nil {
|
|
return nil, nil, fmt.Errorf("failed to iterate user reactions: %w", userRows.Err())
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, nil, fmt.Errorf("failed to commit reaction toggle: %w", err)
|
|
}
|
|
|
|
return counts, myReactions, nil
|
|
}
|