A community based topic aggregation platform built on atproto

feat(posts): add domain layer for post creation

- Add Post domain model with AppView database representation
- Add CreatePostRequest/Response for XRPC endpoint
- Add PostRecord for PDS write-forward
- Add Service and Repository interfaces
- Add error types (ValidationError, ContentRuleViolation)
- Add service implementation with PDS write-forward
- Add validation for content length, labels, at-identifiers

Part of Alpha post creation feature.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+487
+123
internal/core/posts/errors.go
··· 1 + package posts 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + ) 7 + 8 + // Sentinel errors for common post operations 9 + var ( 10 + // ErrCommunityNotFound is returned when the community doesn't exist in AppView 11 + ErrCommunityNotFound = errors.New("community not found") 12 + 13 + // ErrNotAuthorized is returned when user isn't authorized to post in community 14 + // (e.g., banned, private community without membership - Beta) 15 + ErrNotAuthorized = errors.New("user not authorized to post in this community") 16 + 17 + // ErrBanned is returned when user is banned from community (Beta) 18 + ErrBanned = errors.New("user is banned from this community") 19 + 20 + // ErrInvalidContent is returned for general content violations 21 + ErrInvalidContent = errors.New("invalid post content") 22 + 23 + // ErrNotFound is returned when a post is not found by URI 24 + ErrNotFound = errors.New("post not found") 25 + ) 26 + 27 + // ValidationError represents a validation error with field context 28 + type ValidationError struct { 29 + Field string 30 + Message string 31 + } 32 + 33 + func (e *ValidationError) Error() string { 34 + return fmt.Sprintf("validation error (%s): %s", e.Field, e.Message) 35 + } 36 + 37 + // NewValidationError creates a new validation error 38 + func NewValidationError(field, message string) error { 39 + return &ValidationError{ 40 + Field: field, 41 + Message: message, 42 + } 43 + } 44 + 45 + // IsValidationError checks if error is a validation error 46 + func IsValidationError(err error) bool { 47 + var valErr *ValidationError 48 + return errors.As(err, &valErr) 49 + } 50 + 51 + // ContentRuleViolation represents a violation of community content rules 52 + // (Deferred to Beta - included here for future compatibility) 53 + type ContentRuleViolation struct { 54 + Rule string // e.g., "requireText", "allowedEmbedTypes" 55 + Message string // Human-readable explanation 56 + } 57 + 58 + func (e *ContentRuleViolation) Error() string { 59 + return fmt.Sprintf("content rule violation (%s): %s", e.Rule, e.Message) 60 + } 61 + 62 + // NewContentRuleViolation creates a new content rule violation error 63 + func NewContentRuleViolation(rule, message string) error { 64 + return &ContentRuleViolation{ 65 + Rule: rule, 66 + Message: message, 67 + } 68 + } 69 + 70 + // IsContentRuleViolation checks if error is a content rule violation 71 + func IsContentRuleViolation(err error) bool { 72 + var violation *ContentRuleViolation 73 + return errors.As(err, &violation) 74 + } 75 + 76 + // NotFoundError represents a resource not found error 77 + type NotFoundError struct { 78 + Resource string // e.g., "post", "community" 79 + ID string // Resource identifier 80 + } 81 + 82 + func (e *NotFoundError) Error() string { 83 + return fmt.Sprintf("%s not found: %s", e.Resource, e.ID) 84 + } 85 + 86 + // NewNotFoundError creates a new not found error 87 + func NewNotFoundError(resource, id string) error { 88 + return &NotFoundError{ 89 + Resource: resource, 90 + ID: id, 91 + } 92 + } 93 + 94 + // IsNotFound checks if error is a not found error 95 + func IsNotFound(err error) bool { 96 + var notFoundErr *NotFoundError 97 + return errors.As(err, &notFoundErr) || err == ErrCommunityNotFound || err == ErrNotFound 98 + } 99 + 100 + // IsConflict checks if error is due to duplicate/conflict 101 + func IsConflict(err error) bool { 102 + if err == nil { 103 + return false 104 + } 105 + // Check for common conflict indicators in error message 106 + errStr := err.Error() 107 + return contains(errStr, "already indexed") || 108 + contains(errStr, "duplicate key") || 109 + contains(errStr, "already exists") 110 + } 111 + 112 + func contains(s, substr string) bool { 113 + return len(s) >= len(substr) && anySubstring(s, substr) 114 + } 115 + 116 + func anySubstring(s, substr string) bool { 117 + for i := 0; i <= len(s)-len(substr); i++ { 118 + if s[i:i+len(substr)] == substr { 119 + return true 120 + } 121 + } 122 + return false 123 + }
+35
internal/core/posts/interfaces.go
··· 1 + package posts 2 + 3 + import "context" 4 + 5 + // Service defines the business logic interface for posts 6 + // Coordinates between Repository, community service, and PDS 7 + type Service interface { 8 + // CreatePost creates a new post in a community 9 + // Flow: Validate -> Fetch community -> Ensure fresh token -> Write to PDS -> Return URI/CID 10 + // AppView indexing happens asynchronously via Jetstream consumer 11 + CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) 12 + 13 + // Future methods (Beta): 14 + // GetPost(ctx context.Context, uri string, viewerDID *string) (*Post, error) 15 + // UpdatePost(ctx context.Context, req UpdatePostRequest) (*Post, error) 16 + // DeletePost(ctx context.Context, uri string, userDID string) error 17 + // ListCommunityPosts(ctx context.Context, communityDID string, limit, offset int) ([]*Post, error) 18 + } 19 + 20 + // Repository defines the data access interface for posts 21 + // Used by Jetstream consumer to index posts from firehose 22 + type Repository interface { 23 + // Create inserts a new post into the AppView database 24 + // Called by Jetstream consumer after post is created on PDS 25 + Create(ctx context.Context, post *Post) error 26 + 27 + // GetByURI retrieves a post by its AT-URI 28 + // Used for E2E test verification and future GET endpoint 29 + GetByURI(ctx context.Context, uri string) (*Post, error) 30 + 31 + // Future methods (Beta): 32 + // Update(ctx context.Context, post *Post) error 33 + // Delete(ctx context.Context, uri string) error 34 + // List(ctx context.Context, communityDID string, limit, offset int) ([]*Post, int, error) 35 + }
+68
internal/core/posts/post.go
··· 1 + package posts 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + // Post represents a post in the AppView database 8 + // Posts are indexed from the firehose after being written to community repositories 9 + type Post struct { 10 + CreatedAt time.Time `json:"createdAt" db:"created_at"` 11 + IndexedAt time.Time `json:"indexedAt" db:"indexed_at"` 12 + EditedAt *time.Time `json:"editedAt,omitempty" db:"edited_at"` 13 + Embed *string `json:"embed,omitempty" db:"embed"` 14 + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` 15 + ContentLabels *string `json:"contentLabels,omitempty" db:"content_labels"` 16 + Title *string `json:"title,omitempty" db:"title"` 17 + Content *string `json:"content,omitempty" db:"content"` 18 + ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"` 19 + CID string `json:"cid" db:"cid"` 20 + CommunityDID string `json:"communityDid" db:"community_did"` 21 + RKey string `json:"rkey" db:"rkey"` 22 + URI string `json:"uri" db:"uri"` 23 + AuthorDID string `json:"authorDid" db:"author_did"` 24 + ID int64 `json:"id" db:"id"` 25 + UpvoteCount int `json:"upvoteCount" db:"upvote_count"` 26 + DownvoteCount int `json:"downvoteCount" db:"downvote_count"` 27 + Score int `json:"score" db:"score"` 28 + CommentCount int `json:"commentCount" db:"comment_count"` 29 + } 30 + 31 + // CreatePostRequest represents input for creating a new post 32 + // Matches social.coves.post.create lexicon input schema 33 + type CreatePostRequest struct { 34 + OriginalAuthor interface{} `json:"originalAuthor,omitempty"` 35 + FederatedFrom interface{} `json:"federatedFrom,omitempty"` 36 + Location interface{} `json:"location,omitempty"` 37 + Title *string `json:"title,omitempty"` 38 + Content *string `json:"content,omitempty"` 39 + Embed map[string]interface{} `json:"embed,omitempty"` 40 + Community string `json:"community"` 41 + AuthorDID string `json:"authorDid"` 42 + Facets []interface{} `json:"facets,omitempty"` 43 + ContentLabels []string `json:"contentLabels,omitempty"` 44 + } 45 + 46 + // CreatePostResponse represents the response from creating a post 47 + // Matches social.coves.post.create lexicon output schema 48 + type CreatePostResponse struct { 49 + URI string `json:"uri"` // AT-URI of created post 50 + CID string `json:"cid"` // CID of created post 51 + } 52 + 53 + // PostRecord represents the actual atProto record structure written to PDS 54 + // This is the data structure that gets stored in the community's repository 55 + type PostRecord struct { 56 + OriginalAuthor interface{} `json:"originalAuthor,omitempty"` 57 + FederatedFrom interface{} `json:"federatedFrom,omitempty"` 58 + Location interface{} `json:"location,omitempty"` 59 + Title *string `json:"title,omitempty"` 60 + Content *string `json:"content,omitempty"` 61 + Embed map[string]interface{} `json:"embed,omitempty"` 62 + Type string `json:"$type"` 63 + Community string `json:"community"` 64 + Author string `json:"author"` 65 + CreatedAt string `json:"createdAt"` 66 + Facets []interface{} `json:"facets,omitempty"` 67 + ContentLabels []string `json:"contentLabels,omitempty"` 68 + }
+261
internal/core/posts/service.go
··· 1 + package posts 2 + 3 + import ( 4 + "Coves/internal/core/communities" 5 + "bytes" 6 + "context" 7 + "encoding/json" 8 + "fmt" 9 + "io" 10 + "log" 11 + "net/http" 12 + "time" 13 + ) 14 + 15 + type postService struct { 16 + repo Repository 17 + communityService communities.Service 18 + pdsURL string 19 + } 20 + 21 + // NewPostService creates a new post service 22 + func NewPostService( 23 + repo Repository, 24 + communityService communities.Service, 25 + pdsURL string, 26 + ) Service { 27 + return &postService{ 28 + repo: repo, 29 + communityService: communityService, 30 + pdsURL: pdsURL, 31 + } 32 + } 33 + 34 + // CreatePost creates a new post in a community 35 + // Flow: 36 + // 1. Validate input 37 + // 2. Resolve community at-identifier (handle or DID) to DID 38 + // 3. Fetch community from AppView 39 + // 4. Ensure community has fresh PDS credentials 40 + // 5. Build post record 41 + // 6. Write to community's PDS repository 42 + // 7. Return URI/CID (AppView indexes asynchronously via Jetstream) 43 + func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) { 44 + // 1. Validate basic input 45 + if err := s.validateCreateRequest(req); err != nil { 46 + return nil, err 47 + } 48 + 49 + // 2. Resolve community at-identifier (handle or DID) to DID 50 + // This accepts both formats per atProto best practices: 51 + // - Handles: !gardening.communities.coves.social 52 + // - DIDs: did:plc:abc123 or did:web:coves.social 53 + communityDID, err := s.communityService.ResolveCommunityIdentifier(ctx, req.Community) 54 + if err != nil { 55 + // Handle specific error types appropriately 56 + if communities.IsNotFound(err) { 57 + return nil, ErrCommunityNotFound 58 + } 59 + if communities.IsValidationError(err) { 60 + // Pass through validation errors (invalid format, etc.) 61 + return nil, NewValidationError("community", err.Error()) 62 + } 63 + // Infrastructure failures (DB errors, network issues) should be internal errors 64 + // Don't leak internal details to client (e.g., "pq: connection refused") 65 + return nil, fmt.Errorf("failed to resolve community identifier: %w", err) 66 + } 67 + 68 + // 3. Fetch community from AppView (includes all metadata) 69 + community, err := s.communityService.GetByDID(ctx, communityDID) 70 + if err != nil { 71 + if communities.IsNotFound(err) { 72 + return nil, ErrCommunityNotFound 73 + } 74 + return nil, fmt.Errorf("failed to fetch community: %w", err) 75 + } 76 + 77 + // 4. Check community visibility (Alpha: public/unlisted only) 78 + // Beta will add membership checks for private communities 79 + if community.Visibility == "private" { 80 + return nil, ErrNotAuthorized 81 + } 82 + 83 + // 5. Ensure community has fresh PDS credentials (token refresh if needed) 84 + community, err = s.communityService.EnsureFreshToken(ctx, community) 85 + if err != nil { 86 + return nil, fmt.Errorf("failed to refresh community credentials: %w", err) 87 + } 88 + 89 + // 6. Build post record for PDS 90 + postRecord := PostRecord{ 91 + Type: "social.coves.post.record", 92 + Community: communityDID, 93 + Author: req.AuthorDID, 94 + Title: req.Title, 95 + Content: req.Content, 96 + Facets: req.Facets, 97 + Embed: req.Embed, 98 + ContentLabels: req.ContentLabels, 99 + OriginalAuthor: req.OriginalAuthor, 100 + FederatedFrom: req.FederatedFrom, 101 + Location: req.Location, 102 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 103 + } 104 + 105 + // 7. Write to community's PDS repository 106 + uri, cid, err := s.createPostOnPDS(ctx, community, postRecord) 107 + if err != nil { 108 + return nil, fmt.Errorf("failed to write post to PDS: %w", err) 109 + } 110 + 111 + // 8. Return response (AppView will index via Jetstream consumer) 112 + log.Printf("[POST-CREATE] Author: %s, Community: %s, URI: %s", req.AuthorDID, communityDID, uri) 113 + 114 + return &CreatePostResponse{ 115 + URI: uri, 116 + CID: cid, 117 + }, nil 118 + } 119 + 120 + // validateCreateRequest validates basic input requirements 121 + func (s *postService) validateCreateRequest(req CreatePostRequest) error { 122 + // Global content limits (from lexicon) 123 + const ( 124 + maxContentLength = 50000 // 50k characters 125 + maxTitleLength = 3000 // 3k bytes 126 + maxTitleGraphemes = 300 // 300 graphemes (simplified check) 127 + ) 128 + 129 + // Validate community required 130 + if req.Community == "" { 131 + return NewValidationError("community", "community is required") 132 + } 133 + 134 + // Validate author DID set by handler 135 + if req.AuthorDID == "" { 136 + return NewValidationError("authorDid", "authorDid must be set from authenticated user") 137 + } 138 + 139 + // Validate content length 140 + if req.Content != nil && len(*req.Content) > maxContentLength { 141 + return NewValidationError("content", 142 + fmt.Sprintf("content too long (max %d characters)", maxContentLength)) 143 + } 144 + 145 + // Validate title length 146 + if req.Title != nil { 147 + if len(*req.Title) > maxTitleLength { 148 + return NewValidationError("title", 149 + fmt.Sprintf("title too long (max %d bytes)", maxTitleLength)) 150 + } 151 + // Simplified grapheme check (actual implementation would need unicode library) 152 + // For Alpha, byte length check is sufficient 153 + } 154 + 155 + // Validate content labels are from known values 156 + validLabels := map[string]bool{ 157 + "nsfw": true, 158 + "spoiler": true, 159 + "violence": true, 160 + } 161 + for _, label := range req.ContentLabels { 162 + if !validLabels[label] { 163 + return NewValidationError("contentLabels", 164 + fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label)) 165 + } 166 + } 167 + 168 + return nil 169 + } 170 + 171 + // createPostOnPDS writes a post record to the community's PDS repository 172 + // Uses com.atproto.repo.createRecord endpoint 173 + func (s *postService) createPostOnPDS( 174 + ctx context.Context, 175 + community *communities.Community, 176 + record PostRecord, 177 + ) (uri, cid string, err error) { 178 + // Use community's PDS URL (not service default) for federated communities 179 + // Each community can be hosted on a different PDS instance 180 + pdsURL := community.PDSURL 181 + if pdsURL == "" { 182 + // Fallback to service default if community doesn't have a PDS URL 183 + // (shouldn't happen in practice, but safe default) 184 + pdsURL = s.pdsURL 185 + } 186 + 187 + // Build PDS endpoint URL 188 + endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", pdsURL) 189 + 190 + // Build request payload 191 + // IMPORTANT: repo is set to community DID, not author DID 192 + // This writes the post to the community's repository 193 + payload := map[string]interface{}{ 194 + "repo": community.DID, // Community's repository 195 + "collection": "social.coves.post.record", // Collection type 196 + "record": record, // The post record 197 + // "rkey" omitted - PDS will auto-generate TID 198 + } 199 + 200 + // Marshal payload 201 + jsonData, err := json.Marshal(payload) 202 + if err != nil { 203 + return "", "", fmt.Errorf("failed to marshal post payload: %w", err) 204 + } 205 + 206 + // Create HTTP request 207 + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData)) 208 + if err != nil { 209 + return "", "", fmt.Errorf("failed to create PDS request: %w", err) 210 + } 211 + 212 + // Set headers (auth + content type) 213 + req.Header.Set("Content-Type", "application/json") 214 + req.Header.Set("Authorization", "Bearer "+community.PDSAccessToken) 215 + 216 + // Extended timeout for write operations (30 seconds) 217 + client := &http.Client{ 218 + Timeout: 30 * time.Second, 219 + } 220 + 221 + // Execute request 222 + resp, err := client.Do(req) 223 + if err != nil { 224 + return "", "", fmt.Errorf("PDS request failed: %w", err) 225 + } 226 + defer func() { 227 + if closeErr := resp.Body.Close(); closeErr != nil { 228 + log.Printf("Warning: failed to close response body: %v", closeErr) 229 + } 230 + }() 231 + 232 + // Read response body 233 + body, err := io.ReadAll(resp.Body) 234 + if err != nil { 235 + return "", "", fmt.Errorf("failed to read PDS response: %w", err) 236 + } 237 + 238 + // Check for errors 239 + if resp.StatusCode != http.StatusOK { 240 + // Sanitize error body for logging (prevent sensitive data leakage) 241 + bodyPreview := string(body) 242 + if len(bodyPreview) > 200 { 243 + bodyPreview = bodyPreview[:200] + "... (truncated)" 244 + } 245 + log.Printf("[POST-CREATE-ERROR] PDS Status: %d, Body: %s", resp.StatusCode, bodyPreview) 246 + 247 + // Return truncated error (defense in depth - handler will mask this further) 248 + return "", "", fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, bodyPreview) 249 + } 250 + 251 + // Parse response 252 + var result struct { 253 + URI string `json:"uri"` 254 + CID string `json:"cid"` 255 + } 256 + if err := json.Unmarshal(body, &result); err != nil { 257 + return "", "", fmt.Errorf("failed to parse PDS response: %w", err) 258 + } 259 + 260 + return result.URI, result.CID, nil 261 + }