Files
spsays-bff/internal/model/social.go
T
2026-08-29 03:49:30 +05:30

280 lines
12 KiB
Go

package model
import "time"
// Post is a public quote on a user's feed. Caption is the verbatim selected
// text from the knowledgebase; SourceURL links back to the page the user
// was reading. Images are loaded separately via PostImage rows.
//
// Reshares are real Post rows owned by the resharer, with
// ResharedFromPostID pointing to the canonical original. Likes, comments,
// and images all hang off the post's own id, so they diverge naturally
// across reshare boundaries — no parallel "per-resharer" tables.
type Post struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Caption string `json:"caption"`
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
Images []PostImage `json:"images"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// IsHidden — owner has hidden this post from timelines (their profile as
// seen by others, the home feed) and from public collections. It still
// shows to the owner on their own profile and in private collections.
IsHidden bool `json:"is_hidden"`
// Author info — populated by repo joins for feed listings.
AuthorName string `json:"author_name,omitempty"`
AuthorHandle string `json:"author_handle,omitempty"`
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
// Reshare attribution. ResharedFromPostID is non-nil when this post
// is a reshare; the four `Original*` fields carry the canonical
// post's author info so the frontend can render a
// "Reshared from @handle" link without an extra round-trip.
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
OriginalAuthorName *string `json:"original_author_name,omitempty"`
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
// ViewerHasReshared is true when the calling user has reshared the
// canonical version of this post. Hydrated by the service layer
// after the repo returns posts; always false for anonymous viewers.
// Drives the post-card "Reshare ↔ Send" swap on the frontend.
ViewerHasReshared bool `json:"viewer_has_reshared"`
// Likes. LikesCount is the effective total = the denormalized
// posts.likes_count plus any still-pending Redis delta (hydrated by the
// like layer). ViewerHasLiked reflects the calling user's membership row;
// false for anonymous viewers.
LikesCount int `json:"likes_count"`
ViewerHasLiked bool `json:"viewer_has_liked"`
// MentionsCount is how many users are tagged on this post (drives the
// post-card mentions icon). Hydrated by the mention layer.
MentionsCount int `json:"mentions_count"`
// PinnedComment is the post's pinned comment (if any), hydrated by the
// comment layer so the card footer can show it without a per-card fetch.
// Nil when nothing is pinned.
PinnedComment *Comment `json:"pinned_comment,omitempty"`
// CommentsCount is the total number of comments + replies on the post
// (drives the footer's comment count). Hydrated by the comment layer.
CommentsCount int `json:"comments_count"`
// Viewer↔author relationship, hydrated for the home feed so the card can
// render a Follow / Follow Back affordance. ViewerFollowsAuthor: the
// caller follows (accepted) this post's author. AuthorFollowsViewer: the
// author follows the caller. Both false for anonymous viewers / own posts.
ViewerFollowsAuthor bool `json:"viewer_follows_author"`
AuthorFollowsViewer bool `json:"author_follows_viewer"`
// ViewerFollowRequested: the caller has a pending (unaccepted) follow
// request to this post's author. Drives the "Requested" feed affordance.
ViewerFollowRequested bool `json:"viewer_follow_requested"`
}
// Comment is one comment (or reply) on a post. Threading is one level deep:
// ParentID is nil for a top-level comment and otherwise points at the thread
// root. ReplyToHandle, when set, is the @handle the reply was addressed to
// (replies stay flat under the root; this just preserves who was answered).
type Comment struct {
ID string `json:"id"`
PostID string `json:"post_id"`
UserID string `json:"user_id"`
ParentID *string `json:"parent_id,omitempty"`
Body string `json:"body"`
LikesCount int `json:"likes_count"`
PinnedAt *time.Time `json:"pinned_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Author info — populated by repo joins.
AuthorName string `json:"author_name"`
AuthorHandle string `json:"author_handle"`
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
// ReplyToHandle is the handle this reply was addressed to (nil on
// top-level comments), for the "Replying to @handle" prefix.
ReplyToHandle *string `json:"reply_to_handle,omitempty"`
// ViewerHasLiked reflects the calling user's like membership; false for
// anonymous viewers. ReplyCount is the number of replies under a
// top-level comment (0 on replies themselves).
ViewerHasLiked bool `json:"viewer_has_liked"`
ReplyCount int `json:"reply_count"`
}
// PostImage represents one attached image on a post.
type PostImage struct {
ID string `json:"id"`
URL string `json:"url"`
Position int `json:"position"`
}
// Bookmark is a private "save my place" marker.
type Bookmark struct {
ID string `json:"id"`
UserID string `json:"user_id"`
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
Excerpt *string `json:"excerpt,omitempty"`
Note *string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CollectionRecord is a user-curated group of posts.
//
// Renamed to avoid colliding with the legacy `Collection` model in user.go
// from when bookmarks/collections were sketched out earlier. Once that old
// model is unused we can drop it; for now this keeps both compiling.
type CollectionRecord struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
CoverURL *string `json:"cover_url,omitempty"`
IsPublic bool `json:"is_public"`
ItemCount int `json:"item_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PublicProfile is what /u/{handle} returns — the parts of a User safe to
// expose to anyone, plus aggregate counts and the user's posts.
type PublicProfile struct {
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
// Bio is plain text; `@handle` mentions are linkified at render time.
Bio *string `json:"bio"`
JoinedAt time.Time `json:"joined_at"`
PostCount int `json:"post_count"`
BookmarkCount int `json:"-"` // never serialized — private
// IsDirectDisciple marks an account an admin has linked to the disciples
// directory — one of Śrīla Prabhupāda's own initiated disciples.
IsDirectDisciple bool `json:"is_direct_disciple"`
}
// RelationshipState is the viewer's relationship to a target profile,
// computed fresh per request from the follows + blocks tables. Every
// field is "from the viewer looking at the target":
//
// - Following: viewer → target follow edge is accepted.
// - FollowRequested: viewer → target follow edge is pending (awaiting
// the target's approval). The "Requested" button state.
// - FollowsYou: target → viewer follow edge is accepted (they
// already follow the viewer). Drives the "Follow back" label and
// the "Remove from followers" menu item.
// - RequestsYou: target → viewer follow edge is pending (the target
// asked to follow the viewer). Drives the Accept / Reject buttons.
// - Blocked: viewer has blocked the target.
// - BlockedBy: target has blocked the viewer.
//
// nil (not this struct's zero value) is returned for anonymous viewers
// and for a user viewing their own profile — callers special-case those.
type RelationshipState struct {
Following bool `json:"following"`
FollowRequested bool `json:"follow_requested"`
FollowsYou bool `json:"follows_you"`
RequestsYou bool `json:"requests_you"`
Blocked bool `json:"blocked"`
BlockedBy bool `json:"blocked_by"`
// CanMessage reports whether the viewer may START a conversation with this
// profile, given the target's "who can message you" setting and any block.
// It lets the UI decide whether to offer a Message button rather than
// offering one that fails on tap.
CanMessage bool `json:"can_message"`
// MessageRestriction names the target's rule when CanMessage is false, so
// the UI can explain instead of silently hiding the button. Empty when
// messaging is allowed, or "blocked" when a block is the reason.
MessageRestriction string `json:"message_restriction,omitempty"`
}
// Follow is one row of the follows table — a directed, request-based
// edge from Follower to Following. Status is "pending" until the target
// accepts.
type Follow struct {
ID string `json:"id"`
FollowerID string `json:"follower_id"`
FollowingID string `json:"following_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
RespondedAt *time.Time `json:"responded_at,omitempty"`
}
// UserBlock is one row of user_blocks — Blocker has blocked Blocked.
type UserBlock struct {
ID string `json:"id"`
BlockerID string `json:"blocker_id"`
BlockedID string `json:"blocked_id"`
CreatedAt time.Time `json:"created_at"`
}
// UserReport is one row of user_reports — Reporter flagged Reported.
type UserReport struct {
ID string `json:"id"`
ReporterID string `json:"reporter_id"`
ReportedID string `json:"reported_id"`
Category string `json:"category"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}
// PostReport is one row of post_reports — Reporter flagged a specific
// post. Reason is optional ("" when none given).
type PostReport struct {
ID string `json:"id"`
ReporterID string `json:"reporter_id"`
PostID string `json:"post_id"`
Category string `json:"category"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}
// Notification is one delivered/queued notification for a recipient
// (UserID). ActorID is who triggered it; Kind is a short machine string.
// Hydrated Actor* fields are filled by repo joins for list rendering.
type Notification struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ActorID *string `json:"actor_id,omitempty"`
Kind string `json:"kind"`
CreatedAt time.Time `json:"created_at"`
ReadAt *time.Time `json:"read_at,omitempty"`
// Aggregation: for post_like, PostID is the liked post and ActorCount is
// how many likers have aggregated into this single row (the frontend
// renders "X and N-1 others liked your post"). Other kinds: PostID nil,
// ActorCount 1.
PostID *string `json:"post_id,omitempty"`
ActorCount int `json:"actor_count"`
ActorName *string `json:"actor_name,omitempty"`
ActorHandle *string `json:"actor_handle,omitempty"`
ActorAvatar *string `json:"actor_avatar_url,omitempty"`
// PostAuthorHandle is the handle of the author of PostID (when set), so
// the client can deep-link to the post for kinds where the actor isn't
// the post owner (e.g. comment_reply). Nil when there's no post.
PostAuthorHandle *string `json:"post_author_handle,omitempty"`
// Body carries free text for the 'admin_message' kind (an admin → user
// direct message). Nil for every event-driven kind.
Body *string `json:"body,omitempty"`
}
// PushSubscription is one browser/device Web Push endpoint a user granted.
type PushSubscription struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Endpoint string `json:"endpoint"`
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
}