Skip to main content
Version: Latest

Mobile Posts — Feed & Detail Data Structure

For mobile rendering. This explains every field the API returns on a post — in the feed, in a filtered list, and on a single post-detail screen — so you can decide what to show and which buttons (CTAs / actions) to enable based on who is looking: the post's author, or a third person (any other viewer).

This is a read guide. It does not tell you how to create posts (see mobile-posts-guide.md) or the request/DM flow mechanics (see mobile-post-requests-guide.md and mobile-post-request-dm-guide.md). It tells you what the JSON means.

  • API base URL (local): http://localhost:3001
  • Auth: Authorization: Bearer <accessToken> on every endpoint here.
  • Send X-Client-Type: mobile on every request.

1 — One post shape, everywhere

Every endpoint that returns posts runs them through one serializer, so the post object looks the same whether it came from the feed, a profile, saved bookmarks, or a single-post fetch. Learn the shape once.

Endpoints that return the list shape (array of posts):

GET /api/posts → filtered list { data: { items, nextCursor } }
GET /api/posts/me → my posts
GET /api/posts/user/:username → someone's posts
GET /api/posts/saved → my bookmarks
GET /api/feed → personalized feed

Endpoints that return the single shape (one post):

GET /api/posts/:id → one post { data: <post> }
POST /api/posts → after create { data: <post> }
POST /api/posts/:id/repost → after repost { data: <post> }

Response envelope is always { success: true, data: ... }. List endpoints wrap posts as { items: [<post>, ...], nextCursor: string | null }.

The only difference between the two shapes:

FieldList / feedSingle detail (GET /:id)
comments❌ not sent✅ full comment array sent

Everything else is identical. So the feed gives you a comment count (_count.comments) but not the comments themselves; open the detail screen to get the actual comment list.


2 — The two roles

Several fields change value depending on the viewer. There are exactly two roles, decided by comparing the logged-in user's id to authorId:

  • AuthorauthorId === your user id. This is your own post.
  • Third person — everyone else. Any other logged-in viewer.

The server already resolves role-dependent fields for you (cta.buttonLabel, _count.requests, viewerRequestStatus, …). You do not re-compute them — you read them and render. The role comparison is only useful so you understand why a value looks the way it does, and to pick which action buttons to show.

Quick rule:

const isAuthor = post.authorId === currentUser.id;

3 — Full field reference

Every key on a post object. Fields are grouped for reading; the JSON is flat (except nested objects noted below).

3.1 — Core content (same for everyone)

FieldTypeMeaning
idstringThe post's unique id.
typestring enumPost kind: FIND_TEAMMATES, INVITE_PEOPLE, START_SOMETHING, SHARE_OPPORTUNITY, ANYTHING, REPOST. Drives which optional fields are filled and whether a CTA exists.
titlestring | nullPost title. null on kinds that don't use one (e.g. plain ANYTHING, REPOST).
contentstringThe body text. Can be "" (empty) for media-only ANYTHING posts and always empty for simple reposts.
locationstring | nullFree-text location. null if unset.
locationModestring | nullCITY | SCHOOL | ONLINE | HYBRID. Only Start-Something sets this. null otherwise.
eventAtISO date-time | nullWhen the thing happens. Set on Find-Teammates / Invite / Opportunity. null if none.
deadlineAtISO date-time | nullRequest cut-off. Required on Find-Teammates / Invite-People (pre-existing rows may still be null).
linkstring | nullExternal URL (Share-Opportunity / Start-Something). null if none.
detailsobject | nullFree-form escape-hatch JSON for future per-kind fields. Usually null.
isHiddenbooleanAuthor hid the post. Hidden posts don't reach third-person viewers at all (they 404), so you'll normally see this false.
createdAtISO date-timeWhen posted.
updatedAtISO date-timeLast edit time.
authorIdstringThe author's user id. Compare with the current user to get the role.

3.2 — Author block

author is a nested object describing who posted it. Same for every viewer.

FieldTypeMeaning
author.idstringAuthor user id (same as authorId).
author.usernamestringHandle.
author.gradestring | nullAuthor's current grade (e.g. "5th"). null if unset.
author.schoolstring | nullAuthor's current school name. null if unset.
author.profileobject | nullProfile card. null if the user has no profile row.
author.profile.displayNamestringShown name. Prefer this over username for display.
author.profile.avatarUrlstring | nullAvatar image URL. null → render initials/placeholder.

3.3 — Topics & media (same for everyone)

topics — array of tag objects attached to the post:

FieldTypeMeaning
idstringTopic id.
slugstringURL-safe key.
namestringDisplay label.
iconstring | nullIcon name. null if none.

media — array of image/video rows (empty array if none):

FieldTypeMeaning
idstringMedia id.
typestringimage | video | gif.
urlstringThe full-resolution original. Can be several MB — do not load this in the feed (see 3.3.1).
widthnumber | nullPixel width of the original. Often null — prefer a variant's width for layout (see 3.3.1).
heightnumber | nullPixel height of the original. Same caveat as width.
altTextstring | nullAccessibility text. null if none.
processingStatusstringpending | processing | complete | failed. Governs whether variants is populated (see 3.3.1).
variantsarrayResized renditions (thumbnail/small/medium/large). Empty until processingStatus === "complete".

3.3.1 — Image variants & which URL to render

Rule: never render media.url in the feed. url is the untouched original the user uploaded — routinely 3–5 MB. Loading many of those in a scrolling list causes intermittent decode/memory failures (blank images that "fix themselves" when you open the detail screen, then blank again on the way back). Render a variant instead; keep url for the detail/full-screen view only.

Each processed image exposes up to four variants. variants is ordered alphabetically by variantType (large, medium, small, thumbnail) — look up by variantType, never by array index.

Each variant object:

FieldTypeMeaning
variantTypestringthumbnail | small | medium | large.
urlstringRender this. WebP.
widthnumberActual pixel width of the variant.
heightnumberActual pixel height of the variant.
sizeBytesnumberByte size — always small (KB range).
variantTypeSizeUse for
thumbnail240×240 (square crop)Grid tiles, avatars-of-media, dense multi-image collages.
small≤750px wideFeed card default.
medium≤1080px wideSingle-image feed card on large phones / tablets.
large≤1440px widePost-detail screen.
(original)media.urlFull-screen / pinch-to-zoom only.

Videos (type: "video") get only a thumbnail variant (a 240×240 poster frame). Play media.url when the user taps.

Picking the URL (pseudocode):

function feedImageUrl(m):
v = m.variants.find(x => x.variantType === "small")
?? m.variants.find(x => x.variantType === "medium")
?? m.variants.find(x => x.variantType === "thumbnail")
return v?.url ?? m.url // fall back to original only if no variants yet

Dimensions for aspect-ratio boxing: take width/height from the variant you chose, not from top-level media.width/media.height — those are often null even after processing. This lets you reserve the correct box and avoid layout jump. If you fell all the way back to media.url and its dims are null, use a sensible default ratio (e.g. 4:5) until the image loads.

While processingStatus !== "complete": variants is empty. Two options — (a) show a placeholder/skeleton until a later fetch returns variants, or (b) render media.url directly as a stopgap (works, just heavy). The background processor normally fills variants within seconds of upload; a failed status means it never will (retry/report, don't spin forever).

3.4 — Counts

_count — a small object of totals:

FieldTypeMeaning
likesnumberTotal likes.
commentsnumberTotal comments (top-level + replies).
repostsnumberTotal reposts of this post.
sharesnumberTotal times shared.
requestsnumberRole-dependent. Pending request count. Shows the real number only to the author; a third person always sees 0. Use it to render the author's "N new requests" badge.

3.5 — Viewer state (this logged-in user's private relationship to the post)

These describe your relationship to the post. For a third person they reflect that viewer's own taps; for the author, some don't apply (see notes).

FieldTypeMeaning
isLikedbooleanDid this viewer like it. Drives the filled/empty heart.
isSavedbooleanDid this viewer bookmark it. Private — never shown to others. Drives the filled/empty bookmark.
interestStatusstring | nullThis viewer's recommendation feedback: "interested", "not_interested", or null (none). Not settable on your own post.
viewerRequestStatusstring | nullThis viewer's request state on the post: PENDING, ACCEPTED, REJECTED, WITHDRAWN, or null (never asked). Always null for the author (you don't request your own post).
viewerRequestConversationIdstring | nullThe DM opened when this viewer's request was accepted. This is the navigation target for the "Open chat" button. null unless viewerRequestStatus === "ACCEPTED" and a DM was successfully linked.

3.6 — Request prompt block (only meaningful on CTA kinds)

These come straight off the post. They describe the little questionnaire a third person fills when they tap the CTA. On CTA-disabled kinds they are empty.

FieldTypeMeaning
requestPromptstring | nullThe question shown to a requester, e.g. "Why are you a good fit?".
requestPromptDescriptionstring | nullHelper line under the prompt.
requestOptionsstring[]The multiple-choice chips a requester can pick from. Empty on non-CTA kinds.
requestPromptSourcestring | nullWhere the prompt came from: DEFAULT (catalog default), AUTHOR (author customized), AI, or null. Informational only.
requestStatestringAuthor's intake gate: OPEN (accepting), PAUSED (temporarily off), COMPLETED (permanently closed). Drives the CTA label for fresh viewers — see §4.

3.7 — CTA object (role-resolved — read §4)

cta — the single most important object for buttons. It is pre-resolved for the current viewer. See the next section for the full breakdown.

3.8 — Repost fields

Present on every post; only interesting for reposts or posts that have been reposted.

FieldTypeMeaning
originalPostIdstring | nullOn a type: REPOST row, points at the original post. null + type: REPOST together mean the original was deleted → render "Original removed". null on a normal post.
originalobject | nullEmbedded summary of the reposted original (so you can render the quoted card without a second fetch). null if not a repost, or original deleted. Shape: { id, type, title, content, createdAt, author, media }.
isRepostedbooleanDid this viewer already repost this post (one-tap simple repost). Drives the filled/active repost icon.
viewerRepostIdstring | nullThe id of this viewer's own repost row. Pass it (or the original id) to DELETE /api/posts/:id/repost to undo. null if not reposted.
repostedByobject | nullSocial proof: mutuals of this viewer who reposted it. null if none. Shape: { users: [{ id, username, profile }], mutualCount } — up to 3 named users, newest first, plus the total mutual count. May lag follow changes by up to 5 min.

3.9 — Comments (single-detail only)

Only present on GET /api/posts/:id. Array of comments, each with its author block (id, username, profile), ordered oldest-first. In the feed you get only _count.comments.


4 — The cta object (author vs third person)

This is where role-based rendering lives. The server has already picked the right label and enabled/disabled state for the current viewer. You render what it says — no per-role branching needed for the label itself.

4.1 — Fields

FieldTypeMeaning
enabledbooleanDoes this post kind have a request CTA at all. false for SHARE_OPPORTUNITY, ANYTHING, REPOST. When false, show no request button.
verbstringThe action verb: join / attend / collaborate. Used in copy.
buttonLabelstringThe label to show on the button, already resolved for this viewer. This is the field you render.
actionablebooleanWhether the button should be tappable. false → render it disabled/informational (e.g. "Requested", "Closed", "Approved").
pendingLabelstringRaw label for the pending state (the resolver may have already put it in buttonLabel).
acceptedLabelstringRaw label for accepted state.
rejectedLabelstringRaw label for rejected state.
authorLabelstringRaw label the author sees ("View Responses").
pausedLabelstringRaw label for a fresh viewer when intake is paused.
completedLabelstringRaw label for a fresh viewer when intake is closed.

You mostly only need enabled, buttonLabel, and actionable. The raw per-state labels are there if you want to build your own copy.

4.2 — How buttonLabel + actionable are resolved

The server walks these rules in order and stops at the first match:

Viewer situationbuttonLabel becomesactionableWhat to render
CTA disabled (enabled: false)""falseNo request button at all.
AuthorauthorLabel → "View Responses"trueTap → open the requests inbox for this post.
Third person, viewerRequestStatus = PENDINGpendingLabel → "Requested"trueAlready asked; can re-submit/withdraw.
Third person, viewerRequestStatus = ACCEPTEDacceptedLabel → "Open chat"falseTap → open DM at viewerRequestConversationId.
Third person, viewerRequestStatus = REJECTEDrejectedLabel → "Request Declined"falseDisabled; cannot re-ask.
Third person, fresh, requestState = PAUSEDpausedLabel → "Requests Paused"falseDisabled.
Third person, fresh, requestState = COMPLETEDcompletedLabel → "Closed"falseDisabled.
Third person, fresh, requestState = OPENbuttonLabel → "Request to Join/Attend/Collaborate"trueTap → open the request form.

Note the exception: an ACCEPTED viewer's buttonLabel is "Open chat" but actionable is false — "not actionable" means "cannot submit a request", not "cannot tap". When the label is "Open chat", navigate to viewerRequestConversationId. Treat "Open chat" as the one special tap on an otherwise non-request button.

4.3 — Simplest rendering logic

if (!post.cta.enabled) {
// no request CTA for this kind — skip the button
} else if (isAuthor) {
// button: post.cta.buttonLabel ("View Responses")
// badge: post._count.requests (pending count, author-only)
// tap → GET /api/posts/:id/requests
} else if (post.viewerRequestStatus === "ACCEPTED") {
// button: "Open chat" — tap → open post.viewerRequestConversationId
} else {
// button: post.cta.buttonLabel
// enabled: post.cta.actionable
// tap (if actionable) → open the request form
}

5 — Per-kind cheat sheet

Which fields are populated and whether a CTA exists, by type.

Kind (type)Has CTA?verbTypical filled fields
FIND_TEAMMATES✅ yesjointitle, content, topics, eventAt, deadlineAt, (opt) media/location
INVITE_PEOPLE✅ yesattendtitle, content, topics, location, eventAt, deadlineAt, (opt) media/link
START_SOMETHING✅ yescollaboratetitle, content, topics, (opt) media/location/locationMode/link
SHARE_OPPORTUNITY❌ notitle, content, topics, location, eventAt, (opt) media/link
ANYTHING❌ notopics, then content or media (one required); (opt) location
REPOST❌ noempty title/content; originalPostId + original embed carry the quoted post
  • CTA-enabled kinds (FIND_TEAMMATES, INVITE_PEOPLE, START_SOMETHING) are the only ones with a real cta, requestPrompt*, requestOptions, and a meaningful requestState. Everything else has cta.enabled: false and empty request fields.
  • REPOST: render the reposter (author) as "X reposted", then render the original card underneath. If original is null and type is REPOST, show "Original removed".

6 — Which actions to show (by role)

Data tells you role and state; here's the button set each role gets. All are separate endpoints — this guide only says when to show them.

Author (your own post):

  • Edit → PATCH /api/posts/:id
  • Delete → DELETE /api/posts/:id
  • View responses → GET /api/posts/:id/requests (badge from _count.requests)
  • Pause / Resume / Complete intake → PATCH /api/posts/:id/request-state (only on CTA kinds; reflect current requestState)
  • Accept / Reject / Reopen a request → PATCH /api/posts/:id/requests/:reqId
  • Like / Save / Comment / Share — allowed on your own post.
  • Not available: request-to-join, repost your own post, set interest on your own post (the API rejects these).

Third person (any other viewer):

  • Like → PUT /api/posts/:id/like (state in isLiked)
  • Save → PUT /api/posts/:id/save (state in isSaved)
  • Interested / Not interested → PUT /api/posts/:id/interest (state in interestStatus)
  • Repost → POST /api/posts/:id/repost / undo DELETE …/repost (state in isReposted / viewerRepostId)
  • Share → POST /api/posts/:id/share
  • Comment → POST /api/posts/:id/comments
  • Request CTA — only if cta.enabled and cta.actionable:
    • Submit → POST /api/posts/:id/requests
    • Withdraw → DELETE /api/posts/:id/requests/me (when viewerRequestStatus = PENDING)
    • Open chat → navigate to viewerRequestConversationId (when ACCEPTED)

7 — Saved list: unavailable shells

GET /api/posts/saved is special. If you bookmarked a post that later became unviewable (deleted, hidden, or the author went private), the item comes back as a shell instead of a full post, so you can show "no longer available" with a tap-to-remove. Detect it by the isUnavailable flag:

{ "id": "<postId>", "isUnavailable": true, "unavailableReason": "DELETED" }
unavailableReasonMeaning
DELETEDThe post no longer exists.
HIDDEN_BY_AUTHORThe author hid it.
RESTRICTEDThe author's profile went private; you can't see it.

Shells have only id, isUnavailable, unavailableReason — no author, cta, etc. Always check isUnavailable before reading other fields on saved-list items. Every other list endpoint returns full posts only.


8 — Gotchas

  • _count.requests is 0 for non-authors — never use it to show request counts to a third person. It's author-only by design (no inbox-size leakage).
  • viewerRequestConversationId can be null even when ACCEPTED — if chat was blocked or DM linkage failed. If it's null, fall back to opening the post instead of the chat.
  • cta object is always present even on non-CTA kinds — check cta.enabled first; when false, its labels are empty strings.
  • original / originalPostId both null on a REPOST → the original was deleted; render "Original removed".
  • Feed has no comments array — use _count.comments for the number and fetch GET /api/posts/:id (or the comments endpoint) to show them.
  • Don't recompute role-based labelscta.buttonLabel, actionable, and _count.requests are already resolved server-side for the requesting user.
⤓ Download .md