diff --git a/internal/db/migrations/015_alter_content_labels_to_jsonb.sql b/internal/db/migrations/015_alter_content_labels_to_jsonb.sql new file mode 100644 --- /dev/null +++ b/internal/db/migrations/015_alter_content_labels_to_jsonb.sql @@ -0,0 +1,47 @@ +-- +goose Up +-- Change content_labels from TEXT[] to JSONB to preserve full com.atproto.label.defs#selfLabels structure +-- This allows storing the optional 'neg' field and future extensions + +-- Create temporary function to convert TEXT[] to selfLabels JSONB +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION convert_labels_to_jsonb(labels TEXT[]) +RETURNS JSONB AS $$ +BEGIN + IF labels IS NULL OR array_length(labels, 1) = 0 THEN + RETURN NULL; + END IF; + + RETURN jsonb_build_object( + 'values', + (SELECT jsonb_agg(jsonb_build_object('val', label)) + FROM unnest(labels) AS label) + ); +END; +$$ LANGUAGE plpgsql IMMUTABLE; +-- +goose StatementEnd + +-- Convert column type using the function +ALTER TABLE posts + ALTER COLUMN content_labels TYPE JSONB + USING convert_labels_to_jsonb(content_labels); + +-- Drop the temporary function +DROP FUNCTION convert_labels_to_jsonb(TEXT[]); + +-- Update column comment +COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})'; + +-- +goose Down +-- Revert JSONB back to TEXT[] (lossy - drops 'neg' field) +ALTER TABLE posts + ALTER COLUMN content_labels TYPE TEXT[] + USING CASE + WHEN content_labels IS NULL THEN NULL + ELSE ARRAY( + SELECT value->>'val' + FROM jsonb_array_elements(content_labels->'values') AS value + ) + END; + +-- Restore original comment +COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels (nsfw, spoiler, violence)'; diff --git a/internal/db/postgres/feed_repo.go b/internal/db/postgres/feed_repo.go --- a/internal/db/postgres/feed_repo.go +++ b/internal/db/postgres/feed_repo.go @@ -11,8 +11,6 @@ "fmt" "strconv" "strings" "time" - - "github.com/lib/pq" ) type postgresFeedRepo struct { @@ -329,7 +327,7 @@ authorView posts.AuthorView communityRef posts.CommunityRef title, content sql.NullString facets, embed sql.NullString - labels pq.StringArray + labelsJSON sql.NullString editedAt sql.NullTime communityAvatar sql.NullString hotRank sql.NullFloat64 @@ -339,7 +337,7 @@ err := rows.Scan( &postView.URI, &postView.CID, &postView.RKey, &authorView.DID, &authorView.Handle, &communityRef.DID, &communityRef.Name, &communityAvatar, - &title, &content, &facets, &embed, &labels, + &title, &content, &facets, &embed, &labelsJSON, &postView.CreatedAt, &editedAt, &postView.IndexedAt, &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, &hotRank, @@ -386,9 +384,9 @@ // Alpha: No viewer state for basic feed // TODO(feed-generator): Implement viewer state (saved, voted, blocked) in feed generator skeleton - // Build the record (required by lexicon - social.coves.post.record structure) + // Build the record (required by lexicon - social.coves.community.post structure) record := map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": communityRef.DID, "author": authorView.DID, "createdAt": postView.CreatedAt.Format(time.RFC3339), @@ -413,8 +411,13 @@ if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil { record["embed"] = embedData } } - if len(labels) > 0 { - record["contentLabels"] = labels + if labelsJSON.Valid { + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure + // Deserialize and include in record + var selfLabels posts.SelfLabels + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil { + record["labels"] = selfLabels + } } postView.Record = record diff --git a/internal/db/postgres/feed_repo_base.go b/internal/db/postgres/feed_repo_base.go --- a/internal/db/postgres/feed_repo_base.go +++ b/internal/db/postgres/feed_repo_base.go @@ -12,8 +12,6 @@ "fmt" "strconv" "strings" "time" - - "github.com/lib/pq" ) // feedRepoBase contains shared logic for timeline and discover feed repositories @@ -283,7 +281,7 @@ authorView posts.AuthorView communityRef posts.CommunityRef title, content sql.NullString facets, embed sql.NullString - labels pq.StringArray + labelsJSON sql.NullString editedAt sql.NullTime communityAvatar sql.NullString hotRank sql.NullFloat64 @@ -293,7 +291,7 @@ err := rows.Scan( &postView.URI, &postView.CID, &postView.RKey, &authorView.DID, &authorView.Handle, &communityRef.DID, &communityRef.Name, &communityAvatar, - &title, &content, &facets, &embed, &labels, + &title, &content, &facets, &embed, &labelsJSON, &postView.CreatedAt, &editedAt, &postView.IndexedAt, &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, &hotRank, @@ -339,7 +337,7 @@ } // Build the record (required by lexicon) record := map[string]interface{}{ - "$type": "social.coves.post.record", + "$type": "social.coves.community.post", "community": communityRef.DID, "author": authorView.DID, "createdAt": postView.CreatedAt.Format(time.RFC3339), @@ -364,8 +362,13 @@ if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil { record["embed"] = embedData } } - if len(labels) > 0 { - record["contentLabels"] = labels + if labelsJSON.Valid { + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure + // Deserialize and include in record + var selfLabels posts.SelfLabels + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil { + record["labels"] = selfLabels + } } postView.Record = record diff --git a/internal/db/postgres/post_repo.go b/internal/db/postgres/post_repo.go --- a/internal/db/postgres/post_repo.go +++ b/internal/db/postgres/post_repo.go @@ -4,11 +4,8 @@ import ( "Coves/internal/core/posts" "context" "database/sql" - "encoding/json" "fmt" "strings" - - "github.com/lib/pq" ) type postgresPostRepo struct { @@ -36,14 +33,13 @@ embedJSON.String = *post.Embed embedJSON.Valid = true } - // Convert content labels to PostgreSQL array - var labelsArray pq.StringArray + // Store content labels as JSONB + // post.ContentLabels contains com.atproto.label.defs#selfLabels JSON: {"values":[{"val":"nsfw","neg":false}]} + // Store the full JSON blob to preserve the 'neg' field and future extensions + var labelsJSON sql.NullString if post.ContentLabels != nil { - // Parse JSON array string to []string - var labels []string - if err := json.Unmarshal([]byte(*post.ContentLabels), &labels); err == nil { - labelsArray = labels - } + labelsJSON.String = *post.ContentLabels + labelsJSON.Valid = true } query := ` @@ -62,7 +58,7 @@ err := r.db.QueryRowContext( ctx, query, post.URI, post.CID, post.RKey, post.AuthorDID, post.CommunityDID, - post.Title, post.Content, facetsJSON, embedJSON, labelsArray, + post.Title, post.Content, facetsJSON, embedJSON, labelsJSON, post.CreatedAt, ).Scan(&post.ID, &post.IndexedAt) if err != nil { @@ -101,13 +97,12 @@ WHERE uri = $1 ` var post posts.Post - var facetsJSON, embedJSON sql.NullString - var contentLabels pq.StringArray + var facetsJSON, embedJSON, labelsJSON sql.NullString err := r.db.QueryRowContext(ctx, query, uri).Scan( &post.ID, &post.URI, &post.CID, &post.RKey, &post.AuthorDID, &post.CommunityDID, - &post.Title, &post.Content, &facetsJSON, &embedJSON, &contentLabels, + &post.Title, &post.Content, &facetsJSON, &embedJSON, &labelsJSON, &post.CreatedAt, &post.EditedAt, &post.IndexedAt, &post.DeletedAt, &post.UpvoteCount, &post.DownvoteCount, &post.Score, &post.CommentCount, ) @@ -126,12 +121,9 @@ } if embedJSON.Valid { post.Embed = &embedJSON.String } - if len(contentLabels) > 0 { - labelsJSON, marshalErr := json.Marshal(contentLabels) - if marshalErr == nil { - labelsStr := string(labelsJSON) - post.ContentLabels = &labelsStr - } + if labelsJSON.Valid { + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure + post.ContentLabels = &labelsJSON.String } return &post, nil