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>

Changed files
+487
internal
+123
internal/core/posts/errors.go
···
···
+
package posts
+
+
import (
+
"errors"
+
"fmt"
+
)
+
+
// Sentinel errors for common post operations
+
var (
+
// ErrCommunityNotFound is returned when the community doesn't exist in AppView
+
ErrCommunityNotFound = errors.New("community not found")
+
+
// ErrNotAuthorized is returned when user isn't authorized to post in community
+
// (e.g., banned, private community without membership - Beta)
+
ErrNotAuthorized = errors.New("user not authorized to post in this community")
+
+
// ErrBanned is returned when user is banned from community (Beta)
+
ErrBanned = errors.New("user is banned from this community")
+
+
// ErrInvalidContent is returned for general content violations
+
ErrInvalidContent = errors.New("invalid post content")
+
+
// ErrNotFound is returned when a post is not found by URI
+
ErrNotFound = errors.New("post not found")
+
)
+
+
// ValidationError represents a validation error with field context
+
type ValidationError struct {
+
Field string
+
Message string
+
}
+
+
func (e *ValidationError) Error() string {
+
return fmt.Sprintf("validation error (%s): %s", e.Field, e.Message)
+
}
+
+
// NewValidationError creates a new validation error
+
func NewValidationError(field, message string) error {
+
return &ValidationError{
+
Field: field,
+
Message: message,
+
}
+
}
+
+
// IsValidationError checks if error is a validation error
+
func IsValidationError(err error) bool {
+
var valErr *ValidationError
+
return errors.As(err, &valErr)
+
}
+
+
// ContentRuleViolation represents a violation of community content rules
+
// (Deferred to Beta - included here for future compatibility)
+
type ContentRuleViolation struct {
+
Rule string // e.g., "requireText", "allowedEmbedTypes"
+
Message string // Human-readable explanation
+
}
+
+
func (e *ContentRuleViolation) Error() string {
+
return fmt.Sprintf("content rule violation (%s): %s", e.Rule, e.Message)
+
}
+
+
// NewContentRuleViolation creates a new content rule violation error
+
func NewContentRuleViolation(rule, message string) error {
+
return &ContentRuleViolation{
+
Rule: rule,
+
Message: message,
+
}
+
}
+
+
// IsContentRuleViolation checks if error is a content rule violation
+
func IsContentRuleViolation(err error) bool {
+
var violation *ContentRuleViolation
+
return errors.As(err, &violation)
+
}
+
+
// NotFoundError represents a resource not found error
+
type NotFoundError struct {
+
Resource string // e.g., "post", "community"
+
ID string // Resource identifier
+
}
+
+
func (e *NotFoundError) Error() string {
+
return fmt.Sprintf("%s not found: %s", e.Resource, e.ID)
+
}
+
+
// NewNotFoundError creates a new not found error
+
func NewNotFoundError(resource, id string) error {
+
return &NotFoundError{
+
Resource: resource,
+
ID: id,
+
}
+
}
+
+
// IsNotFound checks if error is a not found error
+
func IsNotFound(err error) bool {
+
var notFoundErr *NotFoundError
+
return errors.As(err, &notFoundErr) || err == ErrCommunityNotFound || err == ErrNotFound
+
}
+
+
// IsConflict checks if error is due to duplicate/conflict
+
func IsConflict(err error) bool {
+
if err == nil {
+
return false
+
}
+
// Check for common conflict indicators in error message
+
errStr := err.Error()
+
return contains(errStr, "already indexed") ||
+
contains(errStr, "duplicate key") ||
+
contains(errStr, "already exists")
+
}
+
+
func contains(s, substr string) bool {
+
return len(s) >= len(substr) && anySubstring(s, substr)
+
}
+
+
func anySubstring(s, substr string) bool {
+
for i := 0; i <= len(s)-len(substr); i++ {
+
if s[i:i+len(substr)] == substr {
+
return true
+
}
+
}
+
return false
+
}
+35
internal/core/posts/interfaces.go
···
···
+
package posts
+
+
import "context"
+
+
// Service defines the business logic interface for posts
+
// Coordinates between Repository, community service, and PDS
+
type Service interface {
+
// CreatePost creates a new post in a community
+
// Flow: Validate -> Fetch community -> Ensure fresh token -> Write to PDS -> Return URI/CID
+
// AppView indexing happens asynchronously via Jetstream consumer
+
CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error)
+
+
// Future methods (Beta):
+
// GetPost(ctx context.Context, uri string, viewerDID *string) (*Post, error)
+
// UpdatePost(ctx context.Context, req UpdatePostRequest) (*Post, error)
+
// DeletePost(ctx context.Context, uri string, userDID string) error
+
// ListCommunityPosts(ctx context.Context, communityDID string, limit, offset int) ([]*Post, error)
+
}
+
+
// Repository defines the data access interface for posts
+
// Used by Jetstream consumer to index posts from firehose
+
type Repository interface {
+
// Create inserts a new post into the AppView database
+
// Called by Jetstream consumer after post is created on PDS
+
Create(ctx context.Context, post *Post) error
+
+
// GetByURI retrieves a post by its AT-URI
+
// Used for E2E test verification and future GET endpoint
+
GetByURI(ctx context.Context, uri string) (*Post, error)
+
+
// Future methods (Beta):
+
// Update(ctx context.Context, post *Post) error
+
// Delete(ctx context.Context, uri string) error
+
// List(ctx context.Context, communityDID string, limit, offset int) ([]*Post, int, error)
+
}
+68
internal/core/posts/post.go
···
···
+
package posts
+
+
import (
+
"time"
+
)
+
+
// Post represents a post in the AppView database
+
// Posts are indexed from the firehose after being written to community repositories
+
type Post struct {
+
CreatedAt time.Time `json:"createdAt" db:"created_at"`
+
IndexedAt time.Time `json:"indexedAt" db:"indexed_at"`
+
EditedAt *time.Time `json:"editedAt,omitempty" db:"edited_at"`
+
Embed *string `json:"embed,omitempty" db:"embed"`
+
DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
+
ContentLabels *string `json:"contentLabels,omitempty" db:"content_labels"`
+
Title *string `json:"title,omitempty" db:"title"`
+
Content *string `json:"content,omitempty" db:"content"`
+
ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"`
+
CID string `json:"cid" db:"cid"`
+
CommunityDID string `json:"communityDid" db:"community_did"`
+
RKey string `json:"rkey" db:"rkey"`
+
URI string `json:"uri" db:"uri"`
+
AuthorDID string `json:"authorDid" db:"author_did"`
+
ID int64 `json:"id" db:"id"`
+
UpvoteCount int `json:"upvoteCount" db:"upvote_count"`
+
DownvoteCount int `json:"downvoteCount" db:"downvote_count"`
+
Score int `json:"score" db:"score"`
+
CommentCount int `json:"commentCount" db:"comment_count"`
+
}
+
+
// CreatePostRequest represents input for creating a new post
+
// Matches social.coves.post.create lexicon input schema
+
type CreatePostRequest struct {
+
OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
+
FederatedFrom interface{} `json:"federatedFrom,omitempty"`
+
Location interface{} `json:"location,omitempty"`
+
Title *string `json:"title,omitempty"`
+
Content *string `json:"content,omitempty"`
+
Embed map[string]interface{} `json:"embed,omitempty"`
+
Community string `json:"community"`
+
AuthorDID string `json:"authorDid"`
+
Facets []interface{} `json:"facets,omitempty"`
+
ContentLabels []string `json:"contentLabels,omitempty"`
+
}
+
+
// CreatePostResponse represents the response from creating a post
+
// Matches social.coves.post.create lexicon output schema
+
type CreatePostResponse struct {
+
URI string `json:"uri"` // AT-URI of created post
+
CID string `json:"cid"` // CID of created post
+
}
+
+
// PostRecord represents the actual atProto record structure written to PDS
+
// This is the data structure that gets stored in the community's repository
+
type PostRecord struct {
+
OriginalAuthor interface{} `json:"originalAuthor,omitempty"`
+
FederatedFrom interface{} `json:"federatedFrom,omitempty"`
+
Location interface{} `json:"location,omitempty"`
+
Title *string `json:"title,omitempty"`
+
Content *string `json:"content,omitempty"`
+
Embed map[string]interface{} `json:"embed,omitempty"`
+
Type string `json:"$type"`
+
Community string `json:"community"`
+
Author string `json:"author"`
+
CreatedAt string `json:"createdAt"`
+
Facets []interface{} `json:"facets,omitempty"`
+
ContentLabels []string `json:"contentLabels,omitempty"`
+
}
+261
internal/core/posts/service.go
···
···
+
package posts
+
+
import (
+
"Coves/internal/core/communities"
+
"bytes"
+
"context"
+
"encoding/json"
+
"fmt"
+
"io"
+
"log"
+
"net/http"
+
"time"
+
)
+
+
type postService struct {
+
repo Repository
+
communityService communities.Service
+
pdsURL string
+
}
+
+
// NewPostService creates a new post service
+
func NewPostService(
+
repo Repository,
+
communityService communities.Service,
+
pdsURL string,
+
) Service {
+
return &postService{
+
repo: repo,
+
communityService: communityService,
+
pdsURL: pdsURL,
+
}
+
}
+
+
// CreatePost creates a new post in a community
+
// Flow:
+
// 1. Validate input
+
// 2. Resolve community at-identifier (handle or DID) to DID
+
// 3. Fetch community from AppView
+
// 4. Ensure community has fresh PDS credentials
+
// 5. Build post record
+
// 6. Write to community's PDS repository
+
// 7. Return URI/CID (AppView indexes asynchronously via Jetstream)
+
func (s *postService) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResponse, error) {
+
// 1. Validate basic input
+
if err := s.validateCreateRequest(req); err != nil {
+
return nil, err
+
}
+
+
// 2. Resolve community at-identifier (handle or DID) to DID
+
// This accepts both formats per atProto best practices:
+
// - Handles: !gardening.communities.coves.social
+
// - DIDs: did:plc:abc123 or did:web:coves.social
+
communityDID, err := s.communityService.ResolveCommunityIdentifier(ctx, req.Community)
+
if err != nil {
+
// Handle specific error types appropriately
+
if communities.IsNotFound(err) {
+
return nil, ErrCommunityNotFound
+
}
+
if communities.IsValidationError(err) {
+
// Pass through validation errors (invalid format, etc.)
+
return nil, NewValidationError("community", err.Error())
+
}
+
// Infrastructure failures (DB errors, network issues) should be internal errors
+
// Don't leak internal details to client (e.g., "pq: connection refused")
+
return nil, fmt.Errorf("failed to resolve community identifier: %w", err)
+
}
+
+
// 3. Fetch community from AppView (includes all metadata)
+
community, err := s.communityService.GetByDID(ctx, communityDID)
+
if err != nil {
+
if communities.IsNotFound(err) {
+
return nil, ErrCommunityNotFound
+
}
+
return nil, fmt.Errorf("failed to fetch community: %w", err)
+
}
+
+
// 4. Check community visibility (Alpha: public/unlisted only)
+
// Beta will add membership checks for private communities
+
if community.Visibility == "private" {
+
return nil, ErrNotAuthorized
+
}
+
+
// 5. Ensure community has fresh PDS credentials (token refresh if needed)
+
community, err = s.communityService.EnsureFreshToken(ctx, community)
+
if err != nil {
+
return nil, fmt.Errorf("failed to refresh community credentials: %w", err)
+
}
+
+
// 6. Build post record for PDS
+
postRecord := PostRecord{
+
Type: "social.coves.post.record",
+
Community: communityDID,
+
Author: req.AuthorDID,
+
Title: req.Title,
+
Content: req.Content,
+
Facets: req.Facets,
+
Embed: req.Embed,
+
ContentLabels: req.ContentLabels,
+
OriginalAuthor: req.OriginalAuthor,
+
FederatedFrom: req.FederatedFrom,
+
Location: req.Location,
+
CreatedAt: time.Now().UTC().Format(time.RFC3339),
+
}
+
+
// 7. Write to community's PDS repository
+
uri, cid, err := s.createPostOnPDS(ctx, community, postRecord)
+
if err != nil {
+
return nil, fmt.Errorf("failed to write post to PDS: %w", err)
+
}
+
+
// 8. Return response (AppView will index via Jetstream consumer)
+
log.Printf("[POST-CREATE] Author: %s, Community: %s, URI: %s", req.AuthorDID, communityDID, uri)
+
+
return &CreatePostResponse{
+
URI: uri,
+
CID: cid,
+
}, nil
+
}
+
+
// validateCreateRequest validates basic input requirements
+
func (s *postService) validateCreateRequest(req CreatePostRequest) error {
+
// Global content limits (from lexicon)
+
const (
+
maxContentLength = 50000 // 50k characters
+
maxTitleLength = 3000 // 3k bytes
+
maxTitleGraphemes = 300 // 300 graphemes (simplified check)
+
)
+
+
// Validate community required
+
if req.Community == "" {
+
return NewValidationError("community", "community is required")
+
}
+
+
// Validate author DID set by handler
+
if req.AuthorDID == "" {
+
return NewValidationError("authorDid", "authorDid must be set from authenticated user")
+
}
+
+
// Validate content length
+
if req.Content != nil && len(*req.Content) > maxContentLength {
+
return NewValidationError("content",
+
fmt.Sprintf("content too long (max %d characters)", maxContentLength))
+
}
+
+
// Validate title length
+
if req.Title != nil {
+
if len(*req.Title) > maxTitleLength {
+
return NewValidationError("title",
+
fmt.Sprintf("title too long (max %d bytes)", maxTitleLength))
+
}
+
// Simplified grapheme check (actual implementation would need unicode library)
+
// For Alpha, byte length check is sufficient
+
}
+
+
// Validate content labels are from known values
+
validLabels := map[string]bool{
+
"nsfw": true,
+
"spoiler": true,
+
"violence": true,
+
}
+
for _, label := range req.ContentLabels {
+
if !validLabels[label] {
+
return NewValidationError("contentLabels",
+
fmt.Sprintf("unknown content label: %s (valid: nsfw, spoiler, violence)", label))
+
}
+
}
+
+
return nil
+
}
+
+
// createPostOnPDS writes a post record to the community's PDS repository
+
// Uses com.atproto.repo.createRecord endpoint
+
func (s *postService) createPostOnPDS(
+
ctx context.Context,
+
community *communities.Community,
+
record PostRecord,
+
) (uri, cid string, err error) {
+
// Use community's PDS URL (not service default) for federated communities
+
// Each community can be hosted on a different PDS instance
+
pdsURL := community.PDSURL
+
if pdsURL == "" {
+
// Fallback to service default if community doesn't have a PDS URL
+
// (shouldn't happen in practice, but safe default)
+
pdsURL = s.pdsURL
+
}
+
+
// Build PDS endpoint URL
+
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.createRecord", pdsURL)
+
+
// Build request payload
+
// IMPORTANT: repo is set to community DID, not author DID
+
// This writes the post to the community's repository
+
payload := map[string]interface{}{
+
"repo": community.DID, // Community's repository
+
"collection": "social.coves.post.record", // Collection type
+
"record": record, // The post record
+
// "rkey" omitted - PDS will auto-generate TID
+
}
+
+
// Marshal payload
+
jsonData, err := json.Marshal(payload)
+
if err != nil {
+
return "", "", fmt.Errorf("failed to marshal post payload: %w", err)
+
}
+
+
// Create HTTP request
+
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData))
+
if err != nil {
+
return "", "", fmt.Errorf("failed to create PDS request: %w", err)
+
}
+
+
// Set headers (auth + content type)
+
req.Header.Set("Content-Type", "application/json")
+
req.Header.Set("Authorization", "Bearer "+community.PDSAccessToken)
+
+
// Extended timeout for write operations (30 seconds)
+
client := &http.Client{
+
Timeout: 30 * time.Second,
+
}
+
+
// Execute request
+
resp, err := client.Do(req)
+
if err != nil {
+
return "", "", fmt.Errorf("PDS request failed: %w", err)
+
}
+
defer func() {
+
if closeErr := resp.Body.Close(); closeErr != nil {
+
log.Printf("Warning: failed to close response body: %v", closeErr)
+
}
+
}()
+
+
// Read response body
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
return "", "", fmt.Errorf("failed to read PDS response: %w", err)
+
}
+
+
// Check for errors
+
if resp.StatusCode != http.StatusOK {
+
// Sanitize error body for logging (prevent sensitive data leakage)
+
bodyPreview := string(body)
+
if len(bodyPreview) > 200 {
+
bodyPreview = bodyPreview[:200] + "... (truncated)"
+
}
+
log.Printf("[POST-CREATE-ERROR] PDS Status: %d, Body: %s", resp.StatusCode, bodyPreview)
+
+
// Return truncated error (defense in depth - handler will mask this further)
+
return "", "", fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, bodyPreview)
+
}
+
+
// Parse response
+
var result struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
if err := json.Unmarshal(body, &result); err != nil {
+
return "", "", fmt.Errorf("failed to parse PDS response: %w", err)
+
}
+
+
return result.URI, result.CID, nil
+
}