sojorn/go-backend/internal/repository/post_repository.go
Patrick Britton 38653f5854 Sojorn Backend Finalization & Cleanup - Complete Migration from Supabase
##  Phase 1: Critical Feature Completion (Beacon Voting)
- Add VouchBeacon, ReportBeacon, RemoveBeaconVote methods to PostRepository
- Implement beacon voting HTTP handlers with confidence score calculations
- Register new beacon routes: /beacons/:id/vouch, /beacons/:id/report, /beacons/:id/vouch (DELETE)
- Auto-flag beacons at 5+ reports, confidence scoring (0.5 base + 0.1 per vouch)

##  Phase 2: Feed Logic & Post Distribution Integrity
- Verify unified feed logic supports all content types (Standard, Quips, Beacons)
- Ensure proper distribution: Profile Feed + Main/Home Feed for followers
- Beacon Map integration for location-based content
- Video content filtering for Quips feed

##  Phase 3: The Notification System
- Create comprehensive NotificationService with FCM integration
- Add CreateNotification method to NotificationRepository
- Implement smart deep linking: beacon_map, quip_feed, main_feed
- Trigger notifications for beacon interactions and cross-post comments
- Push notification logic with proper content type detection

##  Phase 4: The Great Supabase Purge
- Delete function_proxy.go and remove /functions/:name route
- Remove SupabaseURL, SupabaseKey from config.go
- Remove SupabaseID field from User model
- Clean all Supabase imports and dependencies
- Sanitize codebase of legacy Supabase references

##  Phase 5: Flutter Frontend Integration
- Implement vouchBeacon(), reportBeacon(), removeBeaconVote() in ApiService
- Replace TODO delay in video_comments_sheet.dart with actual publishComment call
- Fix compilation errors (named parameters, orphaned child properties)
- Complete frontend integration with Go API endpoints

##  Additional Improvements
- Fix compilation errors in threaded_comment_widget.dart (orphaned child property)
- Update video_comments_sheet.dart to use proper named parameters
- Comprehensive error handling and validation
- Production-ready notification system with deep linking

##  Migration Status: 100% Complete
- Backend: Fully migrated from Supabase to custom Go/Gin API
- Frontend: Integrated with new Go endpoints
- Notifications: Complete FCM integration with smart routing
- Database: Clean of all Supabase dependencies
- Features: All functionality preserved and enhanced

Ready for VPS deployment and production testing!
2026-01-30 09:24:31 -06:00

829 lines
28 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 = NULLIF($1::text, '')::uuid 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,
CASE WHEN ($4::text) != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = NULLIF($4::text, '')::uuid) 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
LEFT JOIN public.categories c ON p.category_id = c.id
WHERE p.deleted_at IS NULL AND p.status = 'active'
AND (
p.author_id = NULLIF($4::text, '')::uuid -- My own posts
OR pr.is_private = FALSE -- Public profiles
OR EXISTS (
SELECT 1 FROM public.follows f
WHERE f.follower_id = NULLIF($4::text, '')::uuid 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')))
AND ($5 = '' OR c.slug = $5)
ORDER BY p.created_at DESC
LIMIT $1 OFFSET $2
`
rows, err := r.pool.Query(ctx, query, limit, offset, hasVideo, userID, categorySlug)
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,
&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,
}
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,
CASE WHEN $4 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $4::uuid) ELSE FALSE END 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.author_id = $1::uuid AND p.deleted_at IS NULL AND p.status = 'active'
AND (
p.author_id = NULLIF($4, '')::uuid -- Viewer is author
OR pr.is_private = FALSE -- Public profile
OR EXISTS (
SELECT 1 FROM public.follows f
WHERE f.follower_id = NULLIF($4, '')::uuid 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,
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::uuid) ELSE FALSE END 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.id = $1::uuid AND p.deleted_at IS NULL
AND (
p.author_id = NULLIF($2, '')::uuid
OR pr.is_private = FALSE
OR EXISTS (
SELECT 1 FROM public.follows f
WHERE f.follower_id = NULLIF($2, '')::uuid 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.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,
}
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::uuid AND author_id = $3::uuid 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::uuid AND author_id = $2::uuid 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::uuid AND author_id = $3::uuid 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::uuid AND author_id = $3::uuid 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::uuid, $2::uuid) 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::uuid AND user_id = $2::uuid`
_, 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::uuid, $2::uuid) 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::uuid AND user_id = $2::uuid`
_, 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::uuid, $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::uuid", 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,
COALESCE(p.image_url, ''),
COALESCE(p.video_url, ''),
COALESCE(p.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,
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.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) 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,
COALESCE(p.image_url, ''),
COALESCE(p.video_url, ''),
COALESCE(p.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,
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.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) 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,
COALESCE(p.image_url, '') as image_url,
COALESCE(p.video_url, '') as video_url,
COALESCE(p.thumbnail_url, '') as thumbnail_url,
COALESCE(p.duration_ms, 0) as duration_ms,
COALESCE(p.tags, ARRAY[]::text[]) as 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,
COALESCE(p.image_url, '') as image_url,
COALESCE(p.video_url, '') as video_url,
COALESCE(p.thumbnail_url, '') as thumbnail_url,
COALESCE(p.duration_ms, 0) as duration_ms,
COALESCE(p.tags, ARRAY[]::text[]) as 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, video_url, thumbnail_url, duration_ms, tags, created_at, chain_parent_id, -- Fixed: Added chain_parent_id
author_handle, author_display_name, author_avatar_url,
like_count, comment_count, FALSE as is_liked
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
// Fixed: Added Scan for &p.ChainParentID
err := rows.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,
)
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, ''),
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,
CASE WHEN $4 != '' THEN EXISTS(SELECT 1 FROM public.post_likes WHERE post_id = p.id AND user_id = $4::uuid) ELSE FALSE END 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 = NULLIF($4, '')::uuid
OR pr.is_private = FALSE
OR EXISTS (
SELECT 1 FROM public.follows f
WHERE f.follower_id = NULLIF($4, '')::uuid 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
}
func (r *PostRepository) VouchBeacon(ctx context.Context, beaconID string, userID string) error {
// Verify the post is a beacon
var isBeacon bool
err := r.pool.QueryRow(ctx, "SELECT is_beacon FROM public.posts WHERE id = $1::uuid AND deleted_at IS NULL", beaconID).Scan(&isBeacon)
if err != nil {
return fmt.Errorf("beacon not found: %w", err)
}
if !isBeacon {
return fmt.Errorf("post is not a beacon")
}
// Insert vouch record
query := `INSERT INTO public.beacon_vouches (beacon_id, user_id) VALUES ($1::uuid, $2::uuid) ON CONFLICT DO NOTHING`
_, err = r.pool.Exec(ctx, query, beaconID, userID)
if err != nil {
return fmt.Errorf("failed to vouch for beacon: %w", err)
}
// Update beacon confidence score based on vouches
updateQuery := `
UPDATE public.posts
SET confidence_score = (
SELECT COALESCE(
0.5 + (COUNT(*) * 0.1), -- Base 0.5 + 0.1 per vouch
0.5
)
FROM public.beacon_vouches
WHERE beacon_id = $1::uuid
)
WHERE id = $1::uuid
`
_, err = r.pool.Exec(ctx, updateQuery, beaconID)
if err != nil {
return fmt.Errorf("failed to update beacon confidence: %w", err)
}
return nil
}
func (r *PostRepository) ReportBeacon(ctx context.Context, beaconID string, userID string) error {
// Verify the post is a beacon
var isBeacon bool
err := r.pool.QueryRow(ctx, "SELECT is_beacon FROM public.posts WHERE id = $1::uuid AND deleted_at IS NULL", beaconID).Scan(&isBeacon)
if err != nil {
return fmt.Errorf("beacon not found: %w", err)
}
if !isBeacon {
return fmt.Errorf("post is not a beacon")
}
// Insert report record
query := `INSERT INTO public.beacon_reports (beacon_id, user_id) VALUES ($1::uuid, $2::uuid) ON CONFLICT DO NOTHING`
_, err = r.pool.Exec(ctx, query, beaconID, userID)
if err != nil {
return fmt.Errorf("failed to report beacon: %w", err)
}
// Check if beacon should be flagged based on reports
var reportCount int
countQuery := `SELECT COUNT(*) FROM public.beacon_reports WHERE beacon_id = $1::uuid`
err = r.pool.QueryRow(ctx, countQuery, beaconID).Scan(&reportCount)
if err != nil {
return fmt.Errorf("failed to check report count: %w", err)
}
// Auto-flag if too many reports (threshold: 5 reports)
if reportCount >= 5 {
flagQuery := `UPDATE public.posts SET status = 'flagged' WHERE id = $1::uuid`
_, err = r.pool.Exec(ctx, flagQuery, beaconID)
if err != nil {
return fmt.Errorf("failed to flag beacon: %w", err)
}
}
return nil
}
func (r *PostRepository) RemoveBeaconVote(ctx context.Context, beaconID string, userID string) error {
// Remove vouch if it exists
vouchQuery := `DELETE FROM public.beacon_vouches WHERE beacon_id = $1::uuid AND user_id = $2::uuid`
result, err := r.pool.Exec(ctx, vouchQuery, beaconID, userID)
if err != nil {
return fmt.Errorf("failed to remove beacon vouch: %w", err)
}
// Remove report if it exists
reportQuery := `DELETE FROM public.beacon_reports WHERE beacon_id = $1::uuid AND user_id = $2::uuid`
_, err = r.pool.Exec(ctx, reportQuery, beaconID, userID)
if err != nil {
return fmt.Errorf("failed to remove beacon report: %w", err)
}
// If a vouch was removed, update confidence score
if result.RowsAffected() > 0 {
updateQuery := `
UPDATE public.posts
SET confidence_score = (
SELECT COALESCE(
0.5 + (COUNT(*) * 0.1),
0.5
)
FROM public.beacon_vouches
WHERE beacon_id = $1::uuid
)
WHERE id = $1::uuid
`
_, err = r.pool.Exec(ctx, updateQuery, beaconID)
if err != nil {
return fmt.Errorf("failed to update beacon confidence: %w", err)
}
}
return nil
}