A community based topic aggregation platform built on atproto
1package postgres 2 3import ( 4 "context" 5 "database/sql" 6 "fmt" 7 "strings" 8 9 "Coves/internal/core/communities" 10 "github.com/lib/pq" 11) 12 13type postgresCommunityRepo struct { 14 db *sql.DB 15} 16 17// NewCommunityRepository creates a new PostgreSQL community repository 18func NewCommunityRepository(db *sql.DB) communities.Repository { 19 return &postgresCommunityRepo{db: db} 20} 21 22// Create inserts a new community into the communities table 23func (r *postgresCommunityRepo) Create(ctx context.Context, community *communities.Community) (*communities.Community, error) { 24 query := ` 25 INSERT INTO communities ( 26 did, handle, name, display_name, description, description_facets, 27 avatar_cid, banner_cid, owner_did, created_by_did, hosted_by_did, 28 visibility, allow_external_discovery, moderation_type, content_warnings, 29 member_count, subscriber_count, post_count, 30 federated_from, federated_id, created_at, updated_at, 31 record_uri, record_cid 32 ) VALUES ( 33 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 34 $16, $17, $18, $19, $20, $21, $22, $23, $24 35 ) 36 RETURNING id, created_at, updated_at` 37 38 // Handle JSONB field - use sql.NullString with valid JSON or NULL 39 var descFacets interface{} 40 if community.DescriptionFacets != nil && len(community.DescriptionFacets) > 0 { 41 descFacets = community.DescriptionFacets 42 } else { 43 descFacets = nil 44 } 45 46 err := r.db.QueryRowContext(ctx, query, 47 community.DID, 48 community.Handle, 49 community.Name, 50 nullString(community.DisplayName), 51 nullString(community.Description), 52 descFacets, 53 nullString(community.AvatarCID), 54 nullString(community.BannerCID), 55 community.OwnerDID, 56 community.CreatedByDID, 57 community.HostedByDID, 58 community.Visibility, 59 community.AllowExternalDiscovery, 60 nullString(community.ModerationType), 61 pq.Array(community.ContentWarnings), 62 community.MemberCount, 63 community.SubscriberCount, 64 community.PostCount, 65 nullString(community.FederatedFrom), 66 nullString(community.FederatedID), 67 community.CreatedAt, 68 community.UpdatedAt, 69 nullString(community.RecordURI), 70 nullString(community.RecordCID), 71 ).Scan(&community.ID, &community.CreatedAt, &community.UpdatedAt) 72 73 if err != nil { 74 // Check for unique constraint violations 75 if strings.Contains(err.Error(), "duplicate key") { 76 if strings.Contains(err.Error(), "communities_did_key") { 77 return nil, communities.ErrCommunityAlreadyExists 78 } 79 if strings.Contains(err.Error(), "communities_handle_key") { 80 return nil, communities.ErrHandleTaken 81 } 82 } 83 return nil, fmt.Errorf("failed to create community: %w", err) 84 } 85 86 return community, nil 87} 88 89// GetByDID retrieves a community by its DID 90func (r *postgresCommunityRepo) GetByDID(ctx context.Context, did string) (*communities.Community, error) { 91 community := &communities.Community{} 92 query := ` 93 SELECT id, did, handle, name, display_name, description, description_facets, 94 avatar_cid, banner_cid, owner_did, created_by_did, hosted_by_did, 95 visibility, allow_external_discovery, moderation_type, content_warnings, 96 member_count, subscriber_count, post_count, 97 federated_from, federated_id, created_at, updated_at, 98 record_uri, record_cid 99 FROM communities 100 WHERE did = $1` 101 102 var displayName, description, avatarCID, bannerCID, moderationType sql.NullString 103 var federatedFrom, federatedID, recordURI, recordCID sql.NullString 104 var descFacets []byte 105 var contentWarnings []string 106 107 err := r.db.QueryRowContext(ctx, query, did).Scan( 108 &community.ID, &community.DID, &community.Handle, &community.Name, 109 &displayName, &description, &descFacets, 110 &avatarCID, &bannerCID, 111 &community.OwnerDID, &community.CreatedByDID, &community.HostedByDID, 112 &community.Visibility, &community.AllowExternalDiscovery, 113 &moderationType, pq.Array(&contentWarnings), 114 &community.MemberCount, &community.SubscriberCount, &community.PostCount, 115 &federatedFrom, &federatedID, 116 &community.CreatedAt, &community.UpdatedAt, 117 &recordURI, &recordCID, 118 ) 119 120 if err == sql.ErrNoRows { 121 return nil, communities.ErrCommunityNotFound 122 } 123 if err != nil { 124 return nil, fmt.Errorf("failed to get community by DID: %w", err) 125 } 126 127 // Map nullable fields 128 community.DisplayName = displayName.String 129 community.Description = description.String 130 community.AvatarCID = avatarCID.String 131 community.BannerCID = bannerCID.String 132 community.ModerationType = moderationType.String 133 community.ContentWarnings = contentWarnings 134 community.FederatedFrom = federatedFrom.String 135 community.FederatedID = federatedID.String 136 community.RecordURI = recordURI.String 137 community.RecordCID = recordCID.String 138 if descFacets != nil { 139 community.DescriptionFacets = descFacets 140 } 141 142 return community, nil 143} 144 145// GetByHandle retrieves a community by its scoped handle 146func (r *postgresCommunityRepo) GetByHandle(ctx context.Context, handle string) (*communities.Community, error) { 147 community := &communities.Community{} 148 query := ` 149 SELECT id, did, handle, name, display_name, description, description_facets, 150 avatar_cid, banner_cid, owner_did, created_by_did, hosted_by_did, 151 visibility, allow_external_discovery, moderation_type, content_warnings, 152 member_count, subscriber_count, post_count, 153 federated_from, federated_id, created_at, updated_at, 154 record_uri, record_cid 155 FROM communities 156 WHERE handle = $1` 157 158 var displayName, description, avatarCID, bannerCID, moderationType sql.NullString 159 var federatedFrom, federatedID, recordURI, recordCID sql.NullString 160 var descFacets []byte 161 var contentWarnings []string 162 163 err := r.db.QueryRowContext(ctx, query, handle).Scan( 164 &community.ID, &community.DID, &community.Handle, &community.Name, 165 &displayName, &description, &descFacets, 166 &avatarCID, &bannerCID, 167 &community.OwnerDID, &community.CreatedByDID, &community.HostedByDID, 168 &community.Visibility, &community.AllowExternalDiscovery, 169 &moderationType, pq.Array(&contentWarnings), 170 &community.MemberCount, &community.SubscriberCount, &community.PostCount, 171 &federatedFrom, &federatedID, 172 &community.CreatedAt, &community.UpdatedAt, 173 &recordURI, &recordCID, 174 ) 175 176 if err == sql.ErrNoRows { 177 return nil, communities.ErrCommunityNotFound 178 } 179 if err != nil { 180 return nil, fmt.Errorf("failed to get community by handle: %w", err) 181 } 182 183 // Map nullable fields 184 community.DisplayName = displayName.String 185 community.Description = description.String 186 community.AvatarCID = avatarCID.String 187 community.BannerCID = bannerCID.String 188 community.ModerationType = moderationType.String 189 community.ContentWarnings = contentWarnings 190 community.FederatedFrom = federatedFrom.String 191 community.FederatedID = federatedID.String 192 community.RecordURI = recordURI.String 193 community.RecordCID = recordCID.String 194 if descFacets != nil { 195 community.DescriptionFacets = descFacets 196 } 197 198 return community, nil 199} 200 201// Update modifies an existing community's metadata 202func (r *postgresCommunityRepo) Update(ctx context.Context, community *communities.Community) (*communities.Community, error) { 203 query := ` 204 UPDATE communities 205 SET display_name = $2, description = $3, description_facets = $4, 206 avatar_cid = $5, banner_cid = $6, 207 visibility = $7, allow_external_discovery = $8, 208 moderation_type = $9, content_warnings = $10, 209 updated_at = NOW(), 210 record_uri = $11, record_cid = $12 211 WHERE did = $1 212 RETURNING updated_at` 213 214 // Handle JSONB field - use sql.NullString with valid JSON or NULL 215 var descFacets interface{} 216 if community.DescriptionFacets != nil && len(community.DescriptionFacets) > 0 { 217 descFacets = community.DescriptionFacets 218 } else { 219 descFacets = nil 220 } 221 222 err := r.db.QueryRowContext(ctx, query, 223 community.DID, 224 nullString(community.DisplayName), 225 nullString(community.Description), 226 descFacets, 227 nullString(community.AvatarCID), 228 nullString(community.BannerCID), 229 community.Visibility, 230 community.AllowExternalDiscovery, 231 nullString(community.ModerationType), 232 pq.Array(community.ContentWarnings), 233 nullString(community.RecordURI), 234 nullString(community.RecordCID), 235 ).Scan(&community.UpdatedAt) 236 237 if err == sql.ErrNoRows { 238 return nil, communities.ErrCommunityNotFound 239 } 240 if err != nil { 241 return nil, fmt.Errorf("failed to update community: %w", err) 242 } 243 244 return community, nil 245} 246 247// Delete removes a community from the database 248func (r *postgresCommunityRepo) Delete(ctx context.Context, did string) error { 249 query := `DELETE FROM communities WHERE did = $1` 250 251 result, err := r.db.ExecContext(ctx, query, did) 252 if err != nil { 253 return fmt.Errorf("failed to delete community: %w", err) 254 } 255 256 rowsAffected, err := result.RowsAffected() 257 if err != nil { 258 return fmt.Errorf("failed to check delete result: %w", err) 259 } 260 261 if rowsAffected == 0 { 262 return communities.ErrCommunityNotFound 263 } 264 265 return nil 266} 267 268// List retrieves communities with filtering and pagination 269func (r *postgresCommunityRepo) List(ctx context.Context, req communities.ListCommunitiesRequest) ([]*communities.Community, int, error) { 270 // Build query with filters 271 whereClauses := []string{} 272 args := []interface{}{} 273 argCount := 1 274 275 if req.Visibility != "" { 276 whereClauses = append(whereClauses, fmt.Sprintf("visibility = $%d", argCount)) 277 args = append(args, req.Visibility) 278 argCount++ 279 } 280 281 if req.HostedBy != "" { 282 whereClauses = append(whereClauses, fmt.Sprintf("hosted_by_did = $%d", argCount)) 283 args = append(args, req.HostedBy) 284 argCount++ 285 } 286 287 whereClause := "" 288 if len(whereClauses) > 0 { 289 whereClause = "WHERE " + strings.Join(whereClauses, " AND ") 290 } 291 292 // Get total count 293 countQuery := fmt.Sprintf("SELECT COUNT(*) FROM communities %s", whereClause) 294 var totalCount int 295 err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount) 296 if err != nil { 297 return nil, 0, fmt.Errorf("failed to count communities: %w", err) 298 } 299 300 // Build sort clause 301 sortColumn := "created_at" 302 if req.SortBy != "" { 303 switch req.SortBy { 304 case "member_count", "subscriber_count", "post_count", "created_at": 305 sortColumn = req.SortBy 306 } 307 } 308 309 sortOrder := "DESC" 310 if strings.ToUpper(req.SortOrder) == "ASC" { 311 sortOrder = "ASC" 312 } 313 314 // Get communities with pagination 315 query := fmt.Sprintf(` 316 SELECT id, did, handle, name, display_name, description, description_facets, 317 avatar_cid, banner_cid, owner_did, created_by_did, hosted_by_did, 318 visibility, allow_external_discovery, moderation_type, content_warnings, 319 member_count, subscriber_count, post_count, 320 federated_from, federated_id, created_at, updated_at, 321 record_uri, record_cid 322 FROM communities 323 %s 324 ORDER BY %s %s 325 LIMIT $%d OFFSET $%d`, 326 whereClause, sortColumn, sortOrder, argCount, argCount+1) 327 328 args = append(args, req.Limit, req.Offset) 329 330 rows, err := r.db.QueryContext(ctx, query, args...) 331 if err != nil { 332 return nil, 0, fmt.Errorf("failed to list communities: %w", err) 333 } 334 defer rows.Close() 335 336 result := []*communities.Community{} 337 for rows.Next() { 338 community := &communities.Community{} 339 var displayName, description, avatarCID, bannerCID, moderationType sql.NullString 340 var federatedFrom, federatedID, recordURI, recordCID sql.NullString 341 var descFacets []byte 342 var contentWarnings []string 343 344 err := rows.Scan( 345 &community.ID, &community.DID, &community.Handle, &community.Name, 346 &displayName, &description, &descFacets, 347 &avatarCID, &bannerCID, 348 &community.OwnerDID, &community.CreatedByDID, &community.HostedByDID, 349 &community.Visibility, &community.AllowExternalDiscovery, 350 &moderationType, pq.Array(&contentWarnings), 351 &community.MemberCount, &community.SubscriberCount, &community.PostCount, 352 &federatedFrom, &federatedID, 353 &community.CreatedAt, &community.UpdatedAt, 354 &recordURI, &recordCID, 355 ) 356 if err != nil { 357 return nil, 0, fmt.Errorf("failed to scan community: %w", err) 358 } 359 360 // Map nullable fields 361 community.DisplayName = displayName.String 362 community.Description = description.String 363 community.AvatarCID = avatarCID.String 364 community.BannerCID = bannerCID.String 365 community.ModerationType = moderationType.String 366 community.ContentWarnings = contentWarnings 367 community.FederatedFrom = federatedFrom.String 368 community.FederatedID = federatedID.String 369 community.RecordURI = recordURI.String 370 community.RecordCID = recordCID.String 371 if descFacets != nil { 372 community.DescriptionFacets = descFacets 373 } 374 375 result = append(result, community) 376 } 377 378 if err = rows.Err(); err != nil { 379 return nil, 0, fmt.Errorf("error iterating communities: %w", err) 380 } 381 382 return result, totalCount, nil 383} 384 385// Search searches communities by name/description using fuzzy matching 386func (r *postgresCommunityRepo) Search(ctx context.Context, req communities.SearchCommunitiesRequest) ([]*communities.Community, int, error) { 387 // Build query with fuzzy search and visibility filter 388 whereClauses := []string{ 389 "(name ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%')", 390 } 391 args := []interface{}{req.Query} 392 argCount := 2 393 394 if req.Visibility != "" { 395 whereClauses = append(whereClauses, fmt.Sprintf("visibility = $%d", argCount)) 396 args = append(args, req.Visibility) 397 argCount++ 398 } 399 400 whereClause := "WHERE " + strings.Join(whereClauses, " AND ") 401 402 // Get total count 403 countQuery := fmt.Sprintf("SELECT COUNT(*) FROM communities %s", whereClause) 404 var totalCount int 405 err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount) 406 if err != nil { 407 return nil, 0, fmt.Errorf("failed to count search results: %w", err) 408 } 409 410 // Search with relevance ranking using pg_trgm similarity 411 // Filter out results with very low relevance (< 0.2) to avoid noise 412 query := fmt.Sprintf(` 413 SELECT id, did, handle, name, display_name, description, description_facets, 414 avatar_cid, banner_cid, owner_did, created_by_did, hosted_by_did, 415 visibility, allow_external_discovery, moderation_type, content_warnings, 416 member_count, subscriber_count, post_count, 417 federated_from, federated_id, created_at, updated_at, 418 record_uri, record_cid, 419 similarity(name, $1) + similarity(COALESCE(description, ''), $1) as relevance 420 FROM communities 421 %s AND (similarity(name, $1) + similarity(COALESCE(description, ''), $1)) > 0.2 422 ORDER BY relevance DESC, member_count DESC 423 LIMIT $%d OFFSET $%d`, 424 whereClause, argCount, argCount+1) 425 426 args = append(args, req.Limit, req.Offset) 427 428 rows, err := r.db.QueryContext(ctx, query, args...) 429 if err != nil { 430 return nil, 0, fmt.Errorf("failed to search communities: %w", err) 431 } 432 defer rows.Close() 433 434 result := []*communities.Community{} 435 for rows.Next() { 436 community := &communities.Community{} 437 var displayName, description, avatarCID, bannerCID, moderationType sql.NullString 438 var federatedFrom, federatedID, recordURI, recordCID sql.NullString 439 var descFacets []byte 440 var contentWarnings []string 441 var relevance float64 442 443 err := rows.Scan( 444 &community.ID, &community.DID, &community.Handle, &community.Name, 445 &displayName, &description, &descFacets, 446 &avatarCID, &bannerCID, 447 &community.OwnerDID, &community.CreatedByDID, &community.HostedByDID, 448 &community.Visibility, &community.AllowExternalDiscovery, 449 &moderationType, pq.Array(&contentWarnings), 450 &community.MemberCount, &community.SubscriberCount, &community.PostCount, 451 &federatedFrom, &federatedID, 452 &community.CreatedAt, &community.UpdatedAt, 453 &recordURI, &recordCID, 454 &relevance, 455 ) 456 if err != nil { 457 return nil, 0, fmt.Errorf("failed to scan community: %w", err) 458 } 459 460 // Map nullable fields 461 community.DisplayName = displayName.String 462 community.Description = description.String 463 community.AvatarCID = avatarCID.String 464 community.BannerCID = bannerCID.String 465 community.ModerationType = moderationType.String 466 community.ContentWarnings = contentWarnings 467 community.FederatedFrom = federatedFrom.String 468 community.FederatedID = federatedID.String 469 community.RecordURI = recordURI.String 470 community.RecordCID = recordCID.String 471 if descFacets != nil { 472 community.DescriptionFacets = descFacets 473 } 474 475 result = append(result, community) 476 } 477 478 if err = rows.Err(); err != nil { 479 return nil, 0, fmt.Errorf("error iterating search results: %w", err) 480 } 481 482 return result, totalCount, nil 483} 484 485// Helper functions 486func nullString(s string) sql.NullString { 487 return sql.NullString{String: s, Valid: s != ""} 488} 489 490func nullBytes(b []byte) []byte { 491 if b == nil || len(b) == 0 { 492 return nil 493 } 494 return b 495}