Skip to content

Posts Module ​

Location: apps/api/src/modules/posts/

Aggregate roots: Post, PostReply, PostReaction, PostBookmark, PostInterest, PostCoAuthor, PostSeries, PostBoost


Post Formats ​

FormatDescription
noteShort-form text (Twitter-style), default
essayLong-form article with title
photoImage with optional caption
videoVideo with optional caption
quoteQuote from another source with commentary
threadMulti-part connected note
repostBare share of another post (empty body)

Quotes and reposts reference another post via Post.quotedPostId.

Self-reference is rejected. Both quote() and repost() throw BadRequestException('Cannot repost your own post') when source.authorId === authorId — you cannot quote or repost your own post. A repost additionally dedupes inside a transaction (pessimistic lock on the source) so concurrent reposts of the same post can't double-count; a quote always creates a fresh Post (the same source can be quoted many times with different commentary).


Content Depth ​

Authors self-rate their post's technical depth on a 0–5 scale:

LevelLabelMeaning
0SurfaceCasual, conversational
1IntroIntroductory coverage
2DeepSubstantive exploration
3ExpertExpert-level analysis
4ResearchResearch-grade content
5ThesisOriginal thesis / academic

Readers can filter feed by minimum depth.


Visibility ​

ValueWho Can See
publicEveryone (including unauthenticated in future)
circlesUsers who are in at least one of the author's circles
innerUsers in the author's inner circle specifically

Multi-Interest Tagging ​

Posts have one primary interest (Post.interestId) plus up to 4 additional interests via PostInterest junction table.

  • PostInterest.isPrimary = true mirrors the primary tag
  • Feed and trending queries use Post.interestId for performance
  • Cross-interest discovery uses PostInterest

Polls ​

A post can carry a single-choice poll: 2–6 trimmed, non-empty options (extra options past 6 are dropped server-side), with an optional closesAt.

  • POST /posts accepts a poll: { options[], closesAt? } body.
  • POST /posts/:id/poll/vote { optionId } casts or changes a vote (one vote per voter; re-voting moves the tally).
  • Results render inline on the post card with per-option counts.

GET /posts/link-preview?url=… fetches OpenGraph metadata for an external URL. The fetcher is SSRF-hardened: it resolves the host and refuses private / loopback / link-local ranges so a crafted URL can't probe internal services. Previews are best-effort — a blocked or unparseable URL returns null, not an error.


#Journalism Tag ​

Posts can be tagged as journalism (original reporting, factual news, investigative work).

Gate: Author must have reputation ≥ 20 in any single interest (canPostJournalism capability from AccessMatrixService). Admin override available.

Effects:

  • Post.isJournalism = true
  • Journalism badge shown on post card in mobile and admin
  • Filterable via GET /feed?journalism=true

Co-Authorship ​

A post's primary author can invite collaborators. The PostCoAuthor table manages the invitation lifecycle.

Flow ​

mermaid
sequenceDiagram
    Author->>API: POST /posts/:id/co-authors {userId, splitShare}
    API-->>Collaborator: notification (co-author.invited)
    Collaborator->>API: POST /posts/:id/co-authors/:inviteId/respond {accept: true}
    Note over API: Validate total splitShare ≤ 90
    API-->>Author: notification (co-author.accepted)

Split Rules ​

  • splitShare is 0–100 (percentage)
  • Server enforces: sum of all accepted splitShare values ≤ 90
  • Primary author always receives at least 10 % of each EmojiPay payment
  • Only accepted rows participate in mana splits

Replies ​

Each reply has a kind that enables richer threading:

KindPurpose
replyPlain reply (default)
reactionShort emotional response
criticismCounter-argument (surfaced separately in UI)
contributionSubstantive addition (accrues more reputation for replier)

Series ​

Authors can group posts into named series (e.g. "Introduction to Stoicism — 5 parts").

  • PostSeries: title, description, authored by one user
  • PostSeriesItem: join table with explicit position (1-based)
  • SeriesFollow: users subscribe to series and get notified on new parts
  • Max one position per series (unique constraint (series_id, position))

Scheduled Publish ​

Set publishAt to a future timestamp. The scheduled-publish worker checks every minute and:

  1. Emits the post to the feed
  2. Sets scheduledEmittedAt to prevent double-emission on worker restart

Counters ​

Materialised counters on Post are updated synchronously on each action:

CounterIncremented By
reactionsPostReaction with kind = 'heart'
repliesPostReply create
sharesformat = 'repost' create
bookmarksPostBookmark create
viewsFire-and-forget view increment (non-author opens)

Moderation ​

  • Post.hiddenAt (non-null) = moderator hide. Post is invisible in feed but kept for audit.
  • Post.deletedAt (non-null) = author soft-delete. Also hidden from feed.
  • Both are independent — a moderated-hidden post can still be author-deleted.

API (key endpoints) ​

MethodPathDescription
GET/feedHome feed
GET/feed?tab=followingFollowing tab
GET/feed?tab=trendingTrending tab
GET/feed?tab=circlesCircles-only tab
GET/feed?journalism=trueJournalism filter
POST/postsCreate post
GET/posts/:idPost detail with replies + emojiBreakdown
PATCH/posts/:idEdit (author only)
DELETE/posts/:idSoft-delete
POST/posts/:id/repliesAdd reply
GET/posts/:id/repliesPaginated replies
POST/posts/:id/reactReact
POST/posts/:id/bookmarkBookmark
POST/posts/:id/repostRepost or quote
POST/posts/:id/pinPin to profile (max 3)
POST/posts/:id/boostBoost post
GET/posts/by/:usernameUser's profile posts
GET/posts/seriesList my series
POST/posts/seriesCreate series
POST/posts/series/:id/itemsAdd post to series
GET/posts/bookmarksMy bookmarked posts

Regulus — invite-only social-knowledge platform