A community based topic aggregation platform built on atproto

feat(domain): add comment entity and repository interface

Define core comment domain model and repository interface for AppView indexing.
Comment entity tracks threading references (root/parent), soft delete state,
and denormalized reply count.

Repository interface provides:
- CRUD operations (Create, GetByURI, Update, Delete)
- Thread queries (ListByRoot, ListByParent, CountByParent)
- User queries (ListByCommenter)

Designed for read-heavy workload with denormalized counts for performance.

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

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

Changed files
+169
internal
+80
internal/core/comments/comment.go
···
+
package comments
+
+
import (
+
"time"
+
)
+
+
// Comment represents a comment in the AppView database
+
// Comments are indexed from the firehose after being written to user repositories
+
type Comment struct {
+
ID int64 `json:"id" db:"id"`
+
URI string `json:"uri" db:"uri"`
+
CID string `json:"cid" db:"cid"`
+
RKey string `json:"rkey" db:"rkey"`
+
CommenterDID string `json:"commenterDid" db:"commenter_did"`
+
+
// Threading (reply references)
+
RootURI string `json:"rootUri" db:"root_uri"`
+
RootCID string `json:"rootCid" db:"root_cid"`
+
ParentURI string `json:"parentUri" db:"parent_uri"`
+
ParentCID string `json:"parentCid" db:"parent_cid"`
+
+
// Content
+
Content string `json:"content" db:"content"`
+
ContentFacets *string `json:"contentFacets,omitempty" db:"content_facets"`
+
Embed *string `json:"embed,omitempty" db:"embed"`
+
ContentLabels *string `json:"labels,omitempty" db:"content_labels"`
+
Langs []string `json:"langs,omitempty" db:"langs"`
+
+
// Timestamps
+
CreatedAt time.Time `json:"createdAt" db:"created_at"`
+
IndexedAt time.Time `json:"indexedAt" db:"indexed_at"`
+
DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"`
+
+
// Stats (denormalized for performance)
+
UpvoteCount int `json:"upvoteCount" db:"upvote_count"`
+
DownvoteCount int `json:"downvoteCount" db:"downvote_count"`
+
Score int `json:"score" db:"score"`
+
ReplyCount int `json:"replyCount" db:"reply_count"`
+
}
+
+
// CommentRecord represents the atProto record structure indexed from Jetstream
+
// This is the data structure that gets stored in the user's repository
+
// Matches social.coves.feed.comment lexicon
+
type CommentRecord struct {
+
Type string `json:"$type"`
+
Reply ReplyRef `json:"reply"`
+
Content string `json:"content"`
+
Facets []interface{} `json:"facets,omitempty"`
+
Embed map[string]interface{} `json:"embed,omitempty"`
+
Langs []string `json:"langs,omitempty"`
+
Labels *SelfLabels `json:"labels,omitempty"`
+
CreatedAt string `json:"createdAt"`
+
}
+
+
// ReplyRef represents the threading structure from the comment lexicon
+
// Root always points to the original post, parent points to the immediate parent
+
type ReplyRef struct {
+
Root StrongRef `json:"root"`
+
Parent StrongRef `json:"parent"`
+
}
+
+
// StrongRef represents a strong reference to a record (URI + CID)
+
// Matches com.atproto.repo.strongRef
+
type StrongRef struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
// SelfLabels represents self-applied content labels per com.atproto.label.defs#selfLabels
+
// This is the structured format used in atProto for content warnings
+
type SelfLabels struct {
+
Values []SelfLabel `json:"values"`
+
}
+
+
// SelfLabel represents a single label value per com.atproto.label.defs#selfLabel
+
// Neg is optional and negates the label when true
+
type SelfLabel struct {
+
Val string `json:"val"` // Required: label value (max 128 chars)
+
Neg *bool `json:"neg,omitempty"` // Optional: negates the label if true
+
}
+44
internal/core/comments/errors.go
···
+
package comments
+
+
import "errors"
+
+
var (
+
// ErrCommentNotFound indicates the requested comment doesn't exist
+
ErrCommentNotFound = errors.New("comment not found")
+
+
// ErrInvalidReply indicates the reply reference is malformed or invalid
+
ErrInvalidReply = errors.New("invalid reply reference")
+
+
// ErrParentNotFound indicates the parent post/comment doesn't exist
+
ErrParentNotFound = errors.New("parent post or comment not found")
+
+
// ErrRootNotFound indicates the root post doesn't exist
+
ErrRootNotFound = errors.New("root post not found")
+
+
// ErrContentTooLong indicates comment content exceeds 3000 graphemes
+
ErrContentTooLong = errors.New("comment content exceeds 3000 graphemes")
+
+
// ErrContentEmpty indicates comment content is empty
+
ErrContentEmpty = errors.New("comment content is required")
+
+
// ErrNotAuthorized indicates the user is not authorized to perform this action
+
ErrNotAuthorized = errors.New("not authorized")
+
+
// ErrBanned indicates the user is banned from the community
+
ErrBanned = errors.New("user is banned from this community")
+
+
// ErrCommentAlreadyExists indicates a comment with this URI already exists
+
ErrCommentAlreadyExists = errors.New("comment already exists")
+
)
+
+
// IsNotFound checks if an error is a "not found" error
+
func IsNotFound(err error) bool {
+
return errors.Is(err, ErrCommentNotFound) ||
+
errors.Is(err, ErrParentNotFound) ||
+
errors.Is(err, ErrRootNotFound)
+
}
+
+
// IsConflict checks if an error is a conflict/already exists error
+
func IsConflict(err error) bool {
+
return errors.Is(err, ErrCommentAlreadyExists)
+
}
+45
internal/core/comments/interfaces.go
···
+
package comments
+
+
import "context"
+
+
// Repository defines the data access interface for comments
+
// Used by Jetstream consumer to index comments from firehose
+
//
+
// Architecture: Comments are written directly by clients to their PDS using
+
// com.atproto.repo.createRecord/updateRecord/deleteRecord. This AppView indexes
+
// comments from Jetstream for aggregation and querying.
+
type Repository interface {
+
// Create inserts a new comment into the AppView database
+
// Called by Jetstream consumer after comment is created on PDS
+
// Idempotent: ON CONFLICT DO NOTHING for duplicate URIs
+
Create(ctx context.Context, comment *Comment) error
+
+
// Update modifies an existing comment's content fields
+
// Called by Jetstream consumer after comment is updated on PDS
+
// Preserves vote counts and created_at timestamp
+
Update(ctx context.Context, comment *Comment) error
+
+
// GetByURI retrieves a comment by its AT-URI
+
// Used for Jetstream UPDATE/DELETE operations and queries
+
GetByURI(ctx context.Context, uri string) (*Comment, error)
+
+
// Delete soft-deletes a comment (sets deleted_at)
+
// Called by Jetstream consumer after comment is deleted from PDS
+
Delete(ctx context.Context, uri string) error
+
+
// ListByRoot retrieves all comments in a thread (flat)
+
// Used for fetching entire comment threads on posts
+
ListByRoot(ctx context.Context, rootURI string, limit, offset int) ([]*Comment, error)
+
+
// ListByParent retrieves direct replies to a post or comment
+
// Used for building nested/threaded comment views
+
ListByParent(ctx context.Context, parentURI string, limit, offset int) ([]*Comment, error)
+
+
// CountByParent counts direct replies to a post or comment
+
// Used for showing reply counts in threading UI
+
CountByParent(ctx context.Context, parentURI string) (int, error)
+
+
// ListByCommenter retrieves all comments by a specific user
+
// Future: Used for user comment history
+
ListByCommenter(ctx context.Context, commenterDID string, limit, offset int) ([]*Comment, error)
+
}