···11+package community
22+33+import (
44+ "Coves/internal/api/middleware"
55+ "Coves/internal/core/communities"
66+ "encoding/json"
77+ "log"
88+ "net/http"
99+ "regexp"
1010+ "strings"
1111+)
1212+1313+// Package-level compiled regex for DID validation (compiled once at startup)
1414+var (
1515+ didRegex = regexp.MustCompile(`^did:(plc|web):[a-zA-Z0-9._:%-]+$`)
1616+)
1717+1818+// BlockHandler handles community blocking operations
1919+type BlockHandler struct {
2020+ service communities.Service
2121+}
2222+2323+// NewBlockHandler creates a new block handler
2424+func NewBlockHandler(service communities.Service) *BlockHandler {
2525+ return &BlockHandler{
2626+ service: service,
2727+ }
2828+}
2929+3030+// HandleBlock blocks a community
3131+// POST /xrpc/social.coves.community.blockCommunity
3232+//
3333+// Request body: { "community": "did:plc:xxx" }
3434+// Note: Per lexicon spec, only DIDs are accepted (not handles).
3535+// The block record's "subject" field requires format: "did".
3636+func (h *BlockHandler) HandleBlock(w http.ResponseWriter, r *http.Request) {
3737+ if r.Method != http.MethodPost {
3838+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
3939+ return
4040+ }
4141+4242+ // Parse request body
4343+ var req struct {
4444+ Community string `json:"community"` // DID only (per lexicon)
4545+ }
4646+4747+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
4848+ writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
4949+ return
5050+ }
5151+5252+ if req.Community == "" {
5353+ writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
5454+ return
5555+ }
5656+5757+ // Validate DID format (per lexicon: format must be "did")
5858+ if !strings.HasPrefix(req.Community, "did:") {
5959+ writeError(w, http.StatusBadRequest, "InvalidRequest",
6060+ "community must be a DID (did:plc:... or did:web:...)")
6161+ return
6262+ }
6363+6464+ // Validate DID format with regex: did:method:identifier
6565+ if !didRegex.MatchString(req.Community) {
6666+ writeError(w, http.StatusBadRequest, "InvalidRequest", "invalid DID format")
6767+ return
6868+ }
6969+7070+ // Extract authenticated user DID and access token from request context (injected by auth middleware)
7171+ userDID := middleware.GetUserDID(r)
7272+ if userDID == "" {
7373+ writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
7474+ return
7575+ }
7676+7777+ userAccessToken := middleware.GetUserAccessToken(r)
7878+ if userAccessToken == "" {
7979+ writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token")
8080+ return
8181+ }
8282+8383+ // Block via service (write-forward to PDS)
8484+ block, err := h.service.BlockCommunity(r.Context(), userDID, userAccessToken, req.Community)
8585+ if err != nil {
8686+ handleServiceError(w, err)
8787+ return
8888+ }
8989+9090+ // Return success response (following atProto conventions for block responses)
9191+ response := map[string]interface{}{
9292+ "block": map[string]interface{}{
9393+ "recordUri": block.RecordURI,
9494+ "recordCid": block.RecordCID,
9595+ },
9696+ }
9797+9898+ w.Header().Set("Content-Type", "application/json")
9999+ w.WriteHeader(http.StatusOK)
100100+ if err := json.NewEncoder(w).Encode(response); err != nil {
101101+ log.Printf("Failed to encode response: %v", err)
102102+ }
103103+}
104104+105105+// HandleUnblock unblocks a community
106106+// POST /xrpc/social.coves.community.unblockCommunity
107107+//
108108+// Request body: { "community": "did:plc:xxx" }
109109+// Note: Per lexicon spec, only DIDs are accepted (not handles).
110110+func (h *BlockHandler) HandleUnblock(w http.ResponseWriter, r *http.Request) {
111111+ if r.Method != http.MethodPost {
112112+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
113113+ return
114114+ }
115115+116116+ // Parse request body
117117+ var req struct {
118118+ Community string `json:"community"` // DID only (per lexicon)
119119+ }
120120+121121+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
122122+ writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body")
123123+ return
124124+ }
125125+126126+ if req.Community == "" {
127127+ writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
128128+ return
129129+ }
130130+131131+ // Validate DID format (per lexicon: format must be "did")
132132+ if !strings.HasPrefix(req.Community, "did:") {
133133+ writeError(w, http.StatusBadRequest, "InvalidRequest",
134134+ "community must be a DID (did:plc:... or did:web:...)")
135135+ return
136136+ }
137137+138138+ // Validate DID format with regex: did:method:identifier
139139+ if !didRegex.MatchString(req.Community) {
140140+ writeError(w, http.StatusBadRequest, "InvalidRequest", "invalid DID format")
141141+ return
142142+ }
143143+144144+ // Extract authenticated user DID and access token from request context (injected by auth middleware)
145145+ userDID := middleware.GetUserDID(r)
146146+ if userDID == "" {
147147+ writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required")
148148+ return
149149+ }
150150+151151+ userAccessToken := middleware.GetUserAccessToken(r)
152152+ if userAccessToken == "" {
153153+ writeError(w, http.StatusUnauthorized, "AuthRequired", "Missing access token")
154154+ return
155155+ }
156156+157157+ // Unblock via service (delete record on PDS)
158158+ err := h.service.UnblockCommunity(r.Context(), userDID, userAccessToken, req.Community)
159159+ if err != nil {
160160+ handleServiceError(w, err)
161161+ return
162162+ }
163163+164164+ // Return success response
165165+ w.Header().Set("Content-Type", "application/json")
166166+ w.WriteHeader(http.StatusOK)
167167+ if err := json.NewEncoder(w).Encode(map[string]interface{}{
168168+ "success": true,
169169+ }); err != nil {
170170+ log.Printf("Failed to encode response: %v", err)
171171+ }
172172+}
+23-4
internal/api/handlers/community/subscribe.go
···66 "encoding/json"
77 "log"
88 "net/http"
99+ "strings"
910)
10111112// SubscribeHandler handles community subscriptions
···22232324// HandleSubscribe subscribes a user to a community
2425// POST /xrpc/social.coves.community.subscribe
2525-// Body: { "community": "did:plc:xxx" or "!gaming@coves.social" }
2626+//
2727+// Request body: { "community": "did:plc:xxx", "contentVisibility": 3 }
2828+// Note: Per lexicon spec, only DIDs are accepted for the "subject" field (not handles).
2629func (h *SubscribeHandler) HandleSubscribe(w http.ResponseWriter, r *http.Request) {
2730 if r.Method != http.MethodPost {
2831 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
···31343235 // Parse request body
3336 var req struct {
3434- Community string `json:"community"`
3737+ Community string `json:"community"` // DID only (per lexicon)
3538 ContentVisibility int `json:"contentVisibility"` // Optional: 1-5 scale, defaults to 3
3639 }
3740···42454346 if req.Community == "" {
4447 writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
4848+ return
4949+ }
5050+5151+ // Validate DID format (per lexicon: subject field requires format "did")
5252+ if !strings.HasPrefix(req.Community, "did:") {
5353+ writeError(w, http.StatusBadRequest, "InvalidRequest",
5454+ "community must be a DID (did:plc:... or did:web:...)")
4555 return
4656 }
4757···82928393// HandleUnsubscribe unsubscribes a user from a community
8494// POST /xrpc/social.coves.community.unsubscribe
8585-// Body: { "community": "did:plc:xxx" or "!gaming@coves.social" }
9595+//
9696+// Request body: { "community": "did:plc:xxx" }
9797+// Note: Per lexicon spec, only DIDs are accepted (not handles).
8698func (h *SubscribeHandler) HandleUnsubscribe(w http.ResponseWriter, r *http.Request) {
8799 if r.Method != http.MethodPost {
88100 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
···9110392104 // Parse request body
93105 var req struct {
9494- Community string `json:"community"`
106106+ Community string `json:"community"` // DID only (per lexicon)
95107 }
9610897109 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
···101113102114 if req.Community == "" {
103115 writeError(w, http.StatusBadRequest, "InvalidRequest", "community is required")
116116+ return
117117+ }
118118+119119+ // Validate DID format (per lexicon: subject field requires format "did")
120120+ if !strings.HasPrefix(req.Community, "did:") {
121121+ writeError(w, http.StatusBadRequest, "InvalidRequest",
122122+ "community must be a DID (did:plc:... or did:web:...)")
104123 return
105124 }
106125
+7
internal/api/routes/community.go
···1818 listHandler := community.NewListHandler(service)
1919 searchHandler := community.NewSearchHandler(service)
2020 subscribeHandler := community.NewSubscribeHandler(service)
2121+ blockHandler := community.NewBlockHandler(service)
21222223 // Query endpoints (GET) - public access
2324 // social.coves.community.get - get a single community by identifier
···41424243 // social.coves.community.unsubscribe - unsubscribe from a community
4344 r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.unsubscribe", subscribeHandler.HandleUnsubscribe)
4545+4646+ // social.coves.community.blockCommunity - block a community
4747+ r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.blockCommunity", blockHandler.HandleBlock)
4848+4949+ // social.coves.community.unblockCommunity - unblock a community
5050+ r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.unblockCommunity", blockHandler.HandleUnblock)
44514552 // TODO: Add delete handler when implemented
4653 // r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.community.delete", deleteHandler.HandleDelete)
+99-1
internal/atproto/jetstream/community_consumer.go
···11package jetstream
2233import (
44+ "Coves/internal/atproto/utils"
45 "Coves/internal/core/communities"
56 "context"
67 "encoding/json"
···3536 // IMPORTANT: Collection names refer to RECORD TYPES in repositories, not XRPC procedures
3637 // - social.coves.community.profile: Community profile records (in community's own repo)
3738 // - social.coves.community.subscription: Subscription records (in user's repo)
3939+ // - social.coves.community.block: Block records (in user's repo)
3840 //
3941 // XRPC procedures (social.coves.community.subscribe/unsubscribe) are just HTTP endpoints
4042 // that CREATE or DELETE records in these collections
···4446 case "social.coves.community.subscription":
4547 // Handle both create (subscribe) and delete (unsubscribe) operations
4648 return c.handleSubscription(ctx, event.Did, commit)
4949+ case "social.coves.community.block":
5050+ // Handle both create (block) and delete (unblock) operations
5151+ return c.handleBlock(ctx, event.Did, commit)
4752 default:
4853 // Not a community-related collection
4954 return nil
···267272 uri := fmt.Sprintf("at://%s/social.coves.community.subscription/%s", userDID, commit.RKey)
268273269274 // Create subscription entity
275275+ // Parse createdAt from record to preserve chronological ordering during replays
270276 subscription := &communities.Subscription{
271277 UserDID: userDID,
272278 CommunityDID: communityDID,
273279 ContentVisibility: contentVisibility,
274274- SubscribedAt: time.Now(),
280280+ SubscribedAt: utils.ParseCreatedAt(commit.Record),
275281 RecordURI: uri,
276282 RecordCID: commit.CID,
277283 }
···325331 }
326332327333 log.Printf("✓ Removed subscription: %s -> %s", userDID, subscription.CommunityDID)
334334+ return nil
335335+}
336336+337337+// handleBlock processes block create/delete events
338338+// CREATE operation = user blocked a community
339339+// DELETE operation = user unblocked a community
340340+func (c *CommunityEventConsumer) handleBlock(ctx context.Context, userDID string, commit *CommitEvent) error {
341341+ switch commit.Operation {
342342+ case "create":
343343+ return c.createBlock(ctx, userDID, commit)
344344+ case "delete":
345345+ return c.deleteBlock(ctx, userDID, commit)
346346+ default:
347347+ // Update operations shouldn't happen on blocks, but ignore gracefully
348348+ log.Printf("Ignoring unexpected operation on block: %s (userDID=%s, rkey=%s)",
349349+ commit.Operation, userDID, commit.RKey)
350350+ return nil
351351+ }
352352+}
353353+354354+// createBlock indexes a new block
355355+func (c *CommunityEventConsumer) createBlock(ctx context.Context, userDID string, commit *CommitEvent) error {
356356+ if commit.Record == nil {
357357+ return fmt.Errorf("block create event missing record data")
358358+ }
359359+360360+ // Extract community DID from record's subject field (following atProto conventions)
361361+ communityDID, ok := commit.Record["subject"].(string)
362362+ if !ok {
363363+ return fmt.Errorf("block record missing subject field")
364364+ }
365365+366366+ // Build AT-URI for block record
367367+ // The record lives in the USER's repository
368368+ uri := fmt.Sprintf("at://%s/social.coves.community.block/%s", userDID, commit.RKey)
369369+370370+ // Create block entity
371371+ // Parse createdAt from record to preserve chronological ordering during replays
372372+ block := &communities.CommunityBlock{
373373+ UserDID: userDID,
374374+ CommunityDID: communityDID,
375375+ BlockedAt: utils.ParseCreatedAt(commit.Record),
376376+ RecordURI: uri,
377377+ RecordCID: commit.CID,
378378+ }
379379+380380+ // Index the block
381381+ // This is idempotent - safe for Jetstream replays
382382+ _, err := c.repo.BlockCommunity(ctx, block)
383383+ if err != nil {
384384+ // If already exists, that's fine (idempotency)
385385+ if communities.IsConflict(err) {
386386+ log.Printf("Block already indexed: %s -> %s", userDID, communityDID)
387387+ return nil
388388+ }
389389+ return fmt.Errorf("failed to index block: %w", err)
390390+ }
391391+392392+ log.Printf("✓ Indexed block: %s -> %s", userDID, communityDID)
393393+ return nil
394394+}
395395+396396+// deleteBlock removes a block from the index
397397+// DELETE operations don't include record data, so we need to look up the block
398398+// by its URI to find which community the user unblocked
399399+func (c *CommunityEventConsumer) deleteBlock(ctx context.Context, userDID string, commit *CommitEvent) error {
400400+ // Build AT-URI from the rkey
401401+ uri := fmt.Sprintf("at://%s/social.coves.community.block/%s", userDID, commit.RKey)
402402+403403+ // Look up the block to get the community DID
404404+ // (DELETE operations don't include record data in Jetstream)
405405+ block, err := c.repo.GetBlockByURI(ctx, uri)
406406+ if err != nil {
407407+ if communities.IsNotFound(err) {
408408+ // Already deleted - this is fine (idempotency)
409409+ log.Printf("Block already deleted: %s", uri)
410410+ return nil
411411+ }
412412+ return fmt.Errorf("failed to find block for deletion: %w", err)
413413+ }
414414+415415+ // Remove the block from the index
416416+ err = c.repo.UnblockCommunity(ctx, userDID, block.CommunityDID)
417417+ if err != nil {
418418+ if communities.IsNotFound(err) {
419419+ log.Printf("Block already removed: %s -> %s", userDID, block.CommunityDID)
420420+ return nil
421421+ }
422422+ return fmt.Errorf("failed to remove block: %w", err)
423423+ }
424424+425425+ log.Printf("✓ Removed block: %s -> %s", userDID, block.CommunityDID)
328426 return nil
329427}
330428
···1212 "properties": {
1313 "subject": {
1414 "type": "string",
1515- "format": "at-identifier",
1616- "description": "DID or handle of the community being subscribed to"
1515+ "format": "did",
1616+ "description": "DID of the community being subscribed to"
1717 },
1818 "createdAt": {
1919 "type": "string",
+49
internal/atproto/utils/record_utils.go
···11+package utils
22+33+import (
44+ "database/sql"
55+ "strings"
66+ "time"
77+)
88+99+// ExtractRKeyFromURI extracts the record key from an AT-URI
1010+// Format: at://did/collection/rkey -> rkey
1111+func ExtractRKeyFromURI(uri string) string {
1212+ parts := strings.Split(uri, "/")
1313+ if len(parts) >= 4 {
1414+ return parts[len(parts)-1]
1515+ }
1616+ return ""
1717+}
1818+1919+// StringFromNull converts sql.NullString to string
2020+// Returns empty string if the NullString is not valid
2121+func StringFromNull(ns sql.NullString) string {
2222+ if ns.Valid {
2323+ return ns.String
2424+ }
2525+ return ""
2626+}
2727+2828+// ParseCreatedAt extracts and parses the createdAt timestamp from an atProto record
2929+// Falls back to time.Now() if the field is missing or invalid
3030+// This preserves chronological ordering during Jetstream replays and backfills
3131+func ParseCreatedAt(record map[string]interface{}) time.Time {
3232+ if record == nil {
3333+ return time.Now()
3434+ }
3535+3636+ createdAtStr, ok := record["createdAt"].(string)
3737+ if !ok || createdAtStr == "" {
3838+ return time.Now()
3939+ }
4040+4141+ // atProto uses RFC3339 format for datetime fields
4242+ createdAt, err := time.Parse(time.RFC3339, createdAtStr)
4343+ if err != nil {
4444+ // Fallback to now if parsing fails
4545+ return time.Now()
4646+ }
4747+4848+ return createdAt
4949+}
+11
internal/core/communities/community.go
···5252 ID int `json:"id" db:"id"`
5353}
54545555+// CommunityBlock represents a user blocking a community
5656+// Block records live in the user's repository (at://user_did/social.coves.community.block/{rkey})
5757+type CommunityBlock struct {
5858+ BlockedAt time.Time `json:"blockedAt" db:"blocked_at"`
5959+ UserDID string `json:"userDid" db:"user_did"`
6060+ CommunityDID string `json:"communityDid" db:"community_did"`
6161+ RecordURI string `json:"recordUri,omitempty" db:"record_uri"`
6262+ RecordCID string `json:"recordCid,omitempty" db:"record_cid"`
6363+ ID int `json:"id" db:"id"`
6464+}
6565+5566// Membership represents active participation with reputation tracking
5667type Membership struct {
5768 JoinedAt time.Time `json:"joinedAt" db:"joined_at"`
+9-1
internal/core/communities/errors.go
···3131 // ErrSubscriptionNotFound is returned when subscription doesn't exist
3232 ErrSubscriptionNotFound = errors.New("subscription not found")
33333434+ // ErrBlockNotFound is returned when block doesn't exist
3535+ ErrBlockNotFound = errors.New("block not found")
3636+3737+ // ErrBlockAlreadyExists is returned when user has already blocked the community
3838+ ErrBlockAlreadyExists = errors.New("community already blocked")
3939+3440 // ErrMembershipNotFound is returned when membership doesn't exist
3541 ErrMembershipNotFound = errors.New("membership not found")
3642···6369func IsNotFound(err error) bool {
6470 return errors.Is(err, ErrCommunityNotFound) ||
6571 errors.Is(err, ErrSubscriptionNotFound) ||
7272+ errors.Is(err, ErrBlockNotFound) ||
6673 errors.Is(err, ErrMembershipNotFound)
6774}
6875···7077func IsConflict(err error) bool {
7178 return errors.Is(err, ErrCommunityAlreadyExists) ||
7279 errors.Is(err, ErrHandleTaken) ||
7373- errors.Is(err, ErrSubscriptionAlreadyExists)
8080+ errors.Is(err, ErrSubscriptionAlreadyExists) ||
8181+ errors.Is(err, ErrBlockAlreadyExists)
7482}
75837684// IsValidationError checks if error is a validation error
···11+-- +goose Up
22+CREATE TABLE community_blocks (
33+ id SERIAL PRIMARY KEY,
44+ user_did TEXT NOT NULL CHECK (user_did ~ '^did:(plc|web):[a-zA-Z0-9._:%-]+$'),
55+ community_did TEXT NOT NULL CHECK (community_did ~ '^did:(plc|web):[a-zA-Z0-9._:%-]+$'),
66+ blocked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
77+88+ -- AT-Proto metadata (block record lives in user's repo)
99+ -- These are required for atProto record verification and federation
1010+ record_uri TEXT NOT NULL, -- atProto record identifier (at://user_did/social.coves.community.block/rkey)
1111+ record_cid TEXT NOT NULL, -- Content address (critical for verification)
1212+1313+ UNIQUE(user_did, community_did)
1414+);
1515+1616+-- Indexes for efficient queries
1717+-- Note: UNIQUE constraint on (user_did, community_did) already creates an index for those columns
1818+CREATE INDEX idx_blocks_user ON community_blocks(user_did);
1919+CREATE INDEX idx_blocks_community ON community_blocks(community_did);
2020+CREATE INDEX idx_blocks_record_uri ON community_blocks(record_uri); -- For GetBlockByURI (Jetstream DELETE operations)
2121+CREATE INDEX idx_blocks_blocked_at ON community_blocks(blocked_at);
2222+2323+-- +goose Down
2424+DROP INDEX IF EXISTS idx_blocks_blocked_at;
2525+DROP INDEX IF EXISTS idx_blocks_record_uri;
2626+DROP INDEX IF EXISTS idx_blocks_community;
2727+DROP INDEX IF EXISTS idx_blocks_user;
2828+DROP TABLE IF EXISTS community_blocks;
+173
internal/db/postgres/community_repo_blocks.go
···11+package postgres
22+33+import (
44+ "Coves/internal/core/communities"
55+ "context"
66+ "database/sql"
77+ "fmt"
88+ "log"
99+)
1010+1111+// BlockCommunity creates a new block record (idempotent)
1212+func (r *postgresCommunityRepo) BlockCommunity(ctx context.Context, block *communities.CommunityBlock) (*communities.CommunityBlock, error) {
1313+ query := `
1414+ INSERT INTO community_blocks (user_did, community_did, blocked_at, record_uri, record_cid)
1515+ VALUES ($1, $2, $3, $4, $5)
1616+ ON CONFLICT (user_did, community_did) DO UPDATE SET
1717+ record_uri = EXCLUDED.record_uri,
1818+ record_cid = EXCLUDED.record_cid,
1919+ blocked_at = EXCLUDED.blocked_at
2020+ RETURNING id, blocked_at`
2121+2222+ err := r.db.QueryRowContext(ctx, query,
2323+ block.UserDID,
2424+ block.CommunityDID,
2525+ block.BlockedAt,
2626+ block.RecordURI,
2727+ block.RecordCID,
2828+ ).Scan(&block.ID, &block.BlockedAt)
2929+ if err != nil {
3030+ return nil, fmt.Errorf("failed to create block: %w", err)
3131+ }
3232+3333+ return block, nil
3434+}
3535+3636+// UnblockCommunity removes a block record
3737+func (r *postgresCommunityRepo) UnblockCommunity(ctx context.Context, userDID, communityDID string) error {
3838+ query := `DELETE FROM community_blocks WHERE user_did = $1 AND community_did = $2`
3939+4040+ result, err := r.db.ExecContext(ctx, query, userDID, communityDID)
4141+ if err != nil {
4242+ return fmt.Errorf("failed to unblock community: %w", err)
4343+ }
4444+4545+ rowsAffected, err := result.RowsAffected()
4646+ if err != nil {
4747+ return fmt.Errorf("failed to check unblock result: %w", err)
4848+ }
4949+5050+ if rowsAffected == 0 {
5151+ return communities.ErrBlockNotFound
5252+ }
5353+5454+ return nil
5555+}
5656+5757+// GetBlock retrieves a block record by user DID and community DID
5858+func (r *postgresCommunityRepo) GetBlock(ctx context.Context, userDID, communityDID string) (*communities.CommunityBlock, error) {
5959+ query := `
6060+ SELECT id, user_did, community_did, blocked_at, record_uri, record_cid
6161+ FROM community_blocks
6262+ WHERE user_did = $1 AND community_did = $2`
6363+6464+ var block communities.CommunityBlock
6565+6666+ err := r.db.QueryRowContext(ctx, query, userDID, communityDID).Scan(
6767+ &block.ID,
6868+ &block.UserDID,
6969+ &block.CommunityDID,
7070+ &block.BlockedAt,
7171+ &block.RecordURI,
7272+ &block.RecordCID,
7373+ )
7474+ if err != nil {
7575+ if err == sql.ErrNoRows {
7676+ return nil, communities.ErrBlockNotFound
7777+ }
7878+ return nil, fmt.Errorf("failed to get block: %w", err)
7979+ }
8080+8181+ return &block, nil
8282+}
8383+8484+// GetBlockByURI retrieves a block record by its AT-URI (for Jetstream DELETE operations)
8585+func (r *postgresCommunityRepo) GetBlockByURI(ctx context.Context, recordURI string) (*communities.CommunityBlock, error) {
8686+ query := `
8787+ SELECT id, user_did, community_did, blocked_at, record_uri, record_cid
8888+ FROM community_blocks
8989+ WHERE record_uri = $1`
9090+9191+ var block communities.CommunityBlock
9292+9393+ err := r.db.QueryRowContext(ctx, query, recordURI).Scan(
9494+ &block.ID,
9595+ &block.UserDID,
9696+ &block.CommunityDID,
9797+ &block.BlockedAt,
9898+ &block.RecordURI,
9999+ &block.RecordCID,
100100+ )
101101+ if err != nil {
102102+ if err == sql.ErrNoRows {
103103+ return nil, communities.ErrBlockNotFound
104104+ }
105105+ return nil, fmt.Errorf("failed to get block by URI: %w", err)
106106+ }
107107+108108+ return &block, nil
109109+}
110110+111111+// ListBlockedCommunities retrieves all communities blocked by a user
112112+func (r *postgresCommunityRepo) ListBlockedCommunities(ctx context.Context, userDID string, limit, offset int) ([]*communities.CommunityBlock, error) {
113113+ query := `
114114+ SELECT id, user_did, community_did, blocked_at, record_uri, record_cid
115115+ FROM community_blocks
116116+ WHERE user_did = $1
117117+ ORDER BY blocked_at DESC
118118+ LIMIT $2 OFFSET $3`
119119+120120+ rows, err := r.db.QueryContext(ctx, query, userDID, limit, offset)
121121+ if err != nil {
122122+ return nil, fmt.Errorf("failed to list blocked communities: %w", err)
123123+ }
124124+ defer func() {
125125+ if closeErr := rows.Close(); closeErr != nil {
126126+ // Log error but don't override the main error
127127+ log.Printf("Failed to close rows: %v", closeErr)
128128+ }
129129+ }()
130130+131131+ var blocks []*communities.CommunityBlock
132132+ for rows.Next() {
133133+ // Allocate a new block for each iteration to avoid pointer reuse bug
134134+ block := &communities.CommunityBlock{}
135135+136136+ err = rows.Scan(
137137+ &block.ID,
138138+ &block.UserDID,
139139+ &block.CommunityDID,
140140+ &block.BlockedAt,
141141+ &block.RecordURI,
142142+ &block.RecordCID,
143143+ )
144144+ if err != nil {
145145+ return nil, fmt.Errorf("failed to scan block: %w", err)
146146+ }
147147+148148+ blocks = append(blocks, block)
149149+ }
150150+151151+ if err = rows.Err(); err != nil {
152152+ return nil, fmt.Errorf("error iterating blocks: %w", err)
153153+ }
154154+155155+ return blocks, nil
156156+}
157157+158158+// IsBlocked checks if a user has blocked a specific community (fast EXISTS check)
159159+func (r *postgresCommunityRepo) IsBlocked(ctx context.Context, userDID, communityDID string) (bool, error) {
160160+ query := `
161161+ SELECT EXISTS(
162162+ SELECT 1 FROM community_blocks
163163+ WHERE user_did = $1 AND community_did = $2
164164+ )`
165165+166166+ var exists bool
167167+ err := r.db.QueryRowContext(ctx, query, userDID, communityDID).Scan(&exists)
168168+ if err != nil {
169169+ return false, fmt.Errorf("failed to check if blocked: %w", err)
170170+ }
171171+172172+ return exists, nil
173173+}