MeetyyAPI
Documentation / API Reference / Public Feed

Public Feed API

Endpoints for the public feed — a global, role-agnostic social layer where any authenticated user (mentor or mentee) can post, react, comment, share/repost, run polls, connect with other users, and report content. Unlike the mentor-owned Community feed (which is membership-gated and scoped to a community_id), the public feed is a single cross-platform timeline built on a mutual connection graph.

  • Base URL: /api/v1
  • Auth: every endpoint requires a Sanctum bearer token — Authorization: Bearer {token}. There is no public (unauthenticated) surface.
  • Content type: application/json (image uploads use multipart/form-data; videos are not posted inline — they go through the resumable Chunked Upload API first, and the post then references the returned video_upload_id)

Surface. All endpoints live under a single feed prefix (/api/v1/feed/...) guarded by auth:sanctum, shared by every authenticated user regardless of role. Authorship is keyed by user_id; ownership actions (edit/delete) are enforced in the service layer (→ 403).

Scope note. Reporting is capture-only in this cut — reports are persisted (polymorphic over posts and comments) with a pending status, but there is no admin moderation surface (list/resolve/hide) yet. Community post/comment reports flow into the same feed_reports table (see community-api.md §15a), so one future moderation surface covers both feeds. Reaction and comment notifications write synchronously to the database channel (no queue worker required).

Video note. A post carries either images (attachments[]) or a single video (video_upload_id) — never both, and never a video alongside a poll. Videos are capped at 500 MB (VIDEO_MAX_SIZE) and limited to video/mp4, video/quicktime, video/webm. Thumbnails are generated client-side, not on the server. The frontend extracts a frame from the selected video (e.g. <video> + canvas) and uploads it as the thumbnail field when creating the post; the server just stores it and echoes it back as video.thumbnail. If the client sends no thumbnail, video.thumbnail is null and the player should fall back to the video's own first frame.


Response envelope

Success { "status": "success", "message": "…", "data": { } } Error { "status": "error", "message": "…", "data": { } }

Validation errors (422) return field errors in data. Paginated lists are returned as a Laravel resource-collection payload under a named key (posts, comments, users) with the standard data / links / meta structure.

Status codes

Code Meaning
200 OK (read, reaction, vote, accept/remove connection)
201 Resource created (post, share, comment, report)
401 Missing/invalid token
403 Authenticated but not the author (edit/delete post or comment)
404 Post / comment / user not found
409 Content already reported by this user
422 Validation error / invalid state (self-connect, already connected, poll rules, expired poll)
500 Server error

Enums

Post type: text · poll · share · video

Post visibility: public · connections

Sort order (feed sort query): top (engagement-ranked, default) · recent (chronological, newest first)

Reaction type: like · love · laugh · wow · sad · angry

Connection status (relative to the caller): none · pending_outgoing (caller requested them) · pending_incoming (they requested the caller) · connected

Report reason: spam · harassment · hate_speech · violence · nudity · misinformation · other

Report status: pending · reviewed · dismissed · actioned (capture-only — stays pending)

Notification types: feed.connection_request · feed.connection_accepted · feed.post_commented · feed.post_reacted


Shared objects

Post object

{
  "id": "3c4d…",
  "type": "text",
  "type_label": "Text",
  "visibility": "public",
  "visibility_label": "Public",
  "body": "Hello world",
  "reaction_count": 4,
  "comment_count": 2,
  "share_count": 1,
  "my_reaction": "love",
  "reactions": [
    { "type": "love", "count": 3 },
    { "type": "like", "count": 1 }
  ],
  "image_urls": [],
  "video": {
    "url": "https://…/clip.mp4",
    "thumbnail": "https://…/clip-thumb.jpg",
    "mime": "video/mp4"
  },
  "hashtags": ["laravel", "php"],
  "author": {
    "id": "7d2e…",
    "username": "john_doe",
    "name": "John Doe",
    "avatar_url": null,
    "role": "mentor",
    "connection_status": "connected",
    "is_connected": true,
    "is_me": false
  },
  "poll": null,
  "shared_post": null,
  "comments": [ { "…comment object…" } ],
  "created_at": "…",
  "updated_at": "…"
}

my_reaction is the caller's own reaction type (or null). reactions is the per-type breakdown — an array of { type, count } ordered by count descending, present on every post response (feed, search, show, create/update/share), and [] when there are no reactions; reaction_count remains the total. comments is included only on the single-post (show) response. hashtags is the list of tag names auto-parsed from body (lowercased, deduplicated) — see the Hashtags section. poll is present for type: poll; shared_post (a nested post object) for type: share. On author, role is the author's platform role ("mentor", "mentee", "admin", or null when they have none), connection_status is the caller's relationship to the post's author (see the Connection status enum), is_connected is a shorthand for connection_status == "connected", and is_me is true when the caller is the author.

Poll object (post.poll)

{
  "id": "9a0b…",
  "question": "Best language?",
  "allows_multiple": false,
  "expires_at": null,
  "is_closed": false,
  "total_votes": 12,
  "options": [
    { "id": "…", "label": "PHP", "position": 0, "vote_count": 7, "voted_by_me": true },
    { "id": "…", "label": "JS",  "position": 1, "vote_count": 5, "voted_by_me": false }
  ]
}

is_closed is true once expires_at has passed. voted_by_me reflects the caller's vote(s).

Comment object

{
  "id": "5e6f…",
  "post_id": "3c4d…",
  "parent_id": null,
  "body": "Nice one",
  "reaction_count": 1,
  "my_reaction": "like",
  "my_reaction_exists": true,
  "reactions": [ { "type": "like", "count": 1 } ],
  "author": { "id": "7d2e…", "name": "John Doe", "avatar_url": null },
  "replies": [ { "…comment object…" } ],
  "replies_count": 12,
  "created_at": "…",
  "updated_at": "…"
}

Comments carry the same reaction shape as postsreaction_count, my_reaction, and the per-type reactions breakdown. my_reaction_exists is retained as a convenience boolean (my_reaction !== null).

Comments support one-level threading via parent_id. replies holds at most the first 3 replies (oldest first) as a preview so a thread renders in one round-trip; replies_count is the full total. When replies_count exceeds the nested array, page the remainder via the replies endpoint. Both keys are omitted on reply objects and wherever replies are not loaded.

Connection-user object

{ "id": "7d2e…", "name": "John Doe", "username": "john_doe", "avatar_url": null, "connection_status": "connected", "is_connected": true }

connection_status is the caller's relationship to this listed user (see the Connection status enum); is_connected is a shorthand for connection_status == "connected".

Hashtag object

{ "name": "laravel", "posts_count": 42, "created_at": "…" }

The tag's normalized name (lowercase, no leading #) — also its route key. posts_count is the number of posts carrying the tag, included on the popular hashtags list and the browse-by-tag response (omitted where not counted).

Profile object

{
  "id": "7d2e…",
  "username": "john_doe",
  "name": "John Doe",
  "bio": "Building things.",
  "avatar_url": null,
  "cover_photo_url": null,
  "connections_count": 128,
  "posts_count": 17,
  "connection_status": "connected",
  "is_connected": true,
  "is_me": false
}

connection_status reflects the caller's relationship to this user (see the Connection status enum), is_connected is a shorthand for connection_status == "connected", and is_me is true when the profile belongs to the caller.


Feed

A single unified feed replaces the previously separate home/discover feeds. It defaults to an engagement-ranked order (sort=top) and accepts sort=recent for the classic chronological order.

Query params

Param Type Default Notes
sort enum top top (engagement-ranked) · recent (newest first). Invalid value → 422
per_page int 15 1–50

1. Unified feed

GET /api/v1/feed              → paginated unified feed
GET /api/v1/feed?sort=recent  → same set, newest first

Returns, in one ranked stream:

  • your own posts,
  • every post by your connections (including their connections-only posts), and
  • all public posts across the platform.

Under sort=top, popular public posts naturally surface alongside the connection graph via the per-post hot_score. A stranger's connections-only post is never shown. Each post carries my_reaction, live counters, and an author object with connection_status / is_me.

The separate GET /api/v1/feed/discover endpoint has been removed — its content is now part of GET /api/v1/feed.

Ranking ("hot" score)

sort=top orders by a persisted per-post hot_score — weighted engagement over a time-decay denominator, so posts that draw reactions/comments/shares rise while older posts fade:

hot_score = (reaction_count + 2·comment_count + 3·share_count)
            / POW(hoursSinceCreated + 2, 1.5)

The score is refreshed live on each reaction/comment/share and by a scheduled sweep, and ties break on created_at DESC (stable pagination). Weights, gravity and the recompute window are configurable server-side (config/feed.php) — clients only choose sort.

2. Search

GET /api/v1/feed/search no longer exists. Post search now lives in the global search endpoint, alongside people, communities and courses.

GET /api/v1/search?q=laravel                     → all four tabs, 5 rows each
GET /api/v1/search?q=laravel&tab=post            → posts only, 20 rows
GET /api/v1/search?q=laravel&tab=post&sort=recent
GET /api/v1/search?h=laravel                     → posts carrying #laravel (implies tab=post)
GET /api/v1/search?h=%23laravel                  → same (a leading '#' is ignored)

Two mutually exclusive modes: q matches free text, h matches a hashtag exactly. Sending both is a 422; sending neither is a 422. Results are capped lists — never paginated, and results is always an object keyed by tab.

Param Type Required Notes
q string one of Free-text term; 1–100 chars
h string one of Hashtag, with or without #; 1–100 chars. Post tab only
tab enum no post · user · community · course. Omit in q mode to get all four
limit int no Rows per tab; 5 without tab, 20 with one. Max 50
sort enum no Post tab only: top (engagement-ranked, default) · recent
scope enum no Course & community tabs: all (default) · created · member · public

The post tab returns the same post object as the timeline and enforces the same visibility rules: the caller's own posts and their connections' posts (any visibility) plus all public posts — a stranger's connections-only post never appears. In q mode a post matches on its body only; use h to match on the tag itself.

The course and community tabs cover everything the caller may see — published/public rows plus the ones they authored or joined/enrolled in, drafts included — each flagged with is_creator and is_enrolled / member_role. scope narrows that to a single arm.


Posts

3. Create a post

POST /api/v1/feed/posts          (multipart when sending images) → 201 { "data": { "post": { … } } }

A post is text, a poll when a poll object is present, or a video when a video_upload_id is present.

Field Type Required Notes
body string conditionally Required unless poll or video_upload_id is present; ≤ 10000
visibility enum no public (default) · connections
attachments[] file no ≤ 10 images, ≤ 10 MB each
video_upload_id uuid no A completed feed_video chunked upload (see upload-api.md). Mutually exclusive with attachments and poll.
thumbnail file no Client-generated video thumbnail (image, ≤ 5 MB). Only meaningful with video_upload_id.
poll object no Presence makes the post a poll
poll.question string no ≤ 255
poll.allows_multiple bool no Default false
poll.expires_at date no Must be in the future
poll.options array with poll 2–10 entries
poll.options.* string yes ≤ 255

Video posts. Upload the file first via the Chunked Upload API (purpose feed_video), then create the post with the returned video_upload_id. The response carries a video object (url, thumbnail, mime); the key is omitted on non-video posts.

Thumbnail. Generate it on the client and send it as the thumbnail field in the same multipart/form-data request that creates the post. Typical browser recipe: load the file into a <video>, seek to ~1s, draw the frame to a <canvas>, canvas.toBlob(), append the blob as thumbnail. The server stores it as-is and never derives one itself, so video.thumbnail is null whenever the client omits it.

4. Show / update / delete

GET    /api/v1/feed/posts/{post}     → post incl. comments (per-type `reactions` are on every post response)
PUT    /api/v1/feed/posts/{post}     (author only, else 403) — body / visibility
DELETE /api/v1/feed/posts/{post}     (author only, else 403) — cascades comments, reactions, poll

5. Share / repost

POST /api/v1/feed/posts/{post}/share     → 201 { "data": { "post": { …type: share… } } }

Creates a new share post referencing the original (shared_post) and increments the original's share_count. Sharing a share flattens to the root post (no infinite nesting).

Field Type Required Notes
body string no Optional quote/commentary; ≤ 10000
visibility enum no public (default) · connections

Hashtags

Hashtags are auto-parsed from a post's body on every create / update / share — clients simply write #tags inline, there is no separate field to send. A tag must start with a letter and may contain letters, numbers and underscores (#laravel, #dev_ops, #100DaysOfCode); tokens are lowercased, deduplicated and capped at 100 chars, and purely numeric tokens (#123) are ignored. Each unique tag is stored once and reused across posts; editing a post's body re-syncs its tags. The resolved list is returned as hashtags on the post object.

Finding posts for a tag uses global search in hashtag mode (GET /api/v1/search?h={tag}) — there is no separate posts-by-hashtag endpoint.

6. List / search hashtags

GET /api/v1/feed/hashtags                  → paginated hashtag objects, most-used first
GET /api/v1/feed/hashtags?search=lara      → only tags whose name contains "lara" (autocomplete)
GET /api/v1/feed/hashtags?per_page=20

Lists hashtags that are used by at least one post, ranked by posts_count (descending, newest as tiebreaker). Pass search to filter by name — a leading # is ignored and the match is a case-insensitive substring, so it doubles as a hashtag autocomplete. Returned as a paginated resource-collection under hashtags.

Param Type Default Notes
search string Optional name filter (≤ 100 chars); leading # ignored, substring match
per_page int 20 1–50

Reactions

One reaction per user per target; re-reacting changes the type without double-counting. Works on both posts and comments (reaction_count kept in sync). Reacting to another user's post notifies its author (feed.post_reacted).

POST   /api/v1/feed/posts/{post}/reactions          body: { "type": "love" }  → { "data": { "reaction_count": n } }
DELETE /api/v1/feed/posts/{post}/reactions          → { "data": { "reaction_count": n } }
POST   /api/v1/feed/comments/{comment}/reactions    body: { "type": "like" }  → { "data": { "reaction_count": n } }
DELETE /api/v1/feed/comments/{comment}/reactions    → { "data": { "reaction_count": n } }

Payload: type (required, one of the reaction enum values). Invalid type → 422.


Comments

GET    /api/v1/feed/posts/{post}/comments      → paginated top-level comments (newest first, 3-reply preview)
GET    /api/v1/feed/comments/{comment}/replies → paginated replies (oldest first) under "data": { "replies": … }
POST   /api/v1/feed/posts/{post}/comments      → 201 { "data": { "comment": { … } } }
DELETE /api/v1/feed/comments/{comment}         (author only, else 403)

List query params (both GETs): per_page (optional, 1–50, default 15).

The comments listing returns only top-level comments, each with a 3-reply preview plus the full replies_count. Fetch the rest of a thread from the replies endpoint, which 422s if the target is itself a reply.

Create payload: body (required, ≤ 5000), parent_id (optional; one-level threading — must reference a top-level comment on this post). Commenting increments the post's comment_count and notifies the post author (feed.post_commented) unless they are the commenter.

Deleting a comment cascades its replies, and comment_count is decremented by the whole removed subtree (the parent plus its replies).

Errors: 403 not the author (delete) · 422 missing body, or a parent_id that does not exist, belongs to a different post, or is itself a reply.


Polls

POST   /api/v1/feed/posts/{post}/poll/vote     body: { "option_ids": ["…"] }  → { "data": { "poll": { … } } }
DELETE /api/v1/feed/posts/{post}/poll/vote     → retracts the caller's vote(s)

Voting replaces any previous vote by the caller. Rules:

  • Single-choice polls (allows_multiple: false) reject more than one option → 422.
  • Multi-choice polls accept multiple option_ids.
  • Options must belong to the poll → 422 otherwise.
  • Voting on a closed poll (past expires_at) → 422.

Per-option vote_count and the caller's voted_by_me flags are recomputed and returned.


Profile

GET /api/v1/feed/users/{username}     → { "data": { "user": { …profile object… } } }

Shows a single user's public feed profile, resolved by username (not the UUID used by the connection/reaction routes). Returns connection/post counts, the caller's connection_status, and an is_me flag. An unknown username → 404.

This is the trimmed profile, sized for a feed card. For the complete profile — role, expertise areas, experiences, education, certifications, location, and social links — call GET /api/v1/user/{username} (see the Authentication API docs).

GET /api/v1/feed/users/{username}/posts     → paginated posts by this author
GET /api/v1/feed/users/{username}/posts?sort=recent

Lists the author's posts (same post object and pagination shape as the timeline), also resolved by username. Honours the same sort / per_page params as the timeline. Visibility is enforced: the author sees all their own posts; a connection additionally sees the author's connections-only posts; everyone else sees public posts only. Unknown username → 404.


Connections

A mutual relationship established by a request/accept handshake (LinkedIn-style). One user sends a request; the other accepts; both are then connected. Either side can remove it.

POST   /api/v1/feed/connections/{user}         → 201 { "data": { "connection_status": "pending_outgoing" } }
POST   /api/v1/feed/connections/{user}/accept  → { "data": { "connection_status": "connected" } }
DELETE /api/v1/feed/connections/{user}         → { "data": { "connection_status": "none" } }
GET    /api/v1/feed/connections                → paginated connection-user objects (caller's connections)
GET    /api/v1/feed/connections/pending        → paginated connection-user objects (incoming requests)
GET    /api/v1/feed/connections/sent           → paginated connection-user objects (outgoing requests you sent)
GET    /api/v1/feed/users/{username}/connections → paginated connection-user objects (a user's connections)

Mind the binding. The three connections/{user} write routes resolve the target by UUID. The users/{username}/connections list resolves by username, matching the other profile-shaped routes (users/{username}, users/{username}/posts). Passing a UUID there returns 404.

  • Send a request (POST connections/{user}) — creates a pending edge and notifies the target (feed.connection_request). Idempotent for a request you already sent. If the target had already sent you a request, this auto-accepts it (returns connected). Connecting with yourself → 422; requesting someone you're already connected to → 422.
  • Accept (POST connections/{user}/accept) — accepts an incoming pending request from {user} and notifies the requester (feed.connection_accepted). No pending request → 422.
  • Remove (DELETE connections/{user}) — one idempotent verb that cancels a request you sent, declines an incoming request, or removes an existing connection.
  • Incoming vs outgoingconnections/pending lists requests others sent you (rows carry connection_status: "pending_incoming"); connections/sent lists requests you sent (rows carry pending_outgoing), so a user can review and DELETE (cancel) them.
  • Lists annotate each row with the caller's connection_status and accept per_page.

Suggested profiles

GET /api/v1/feed/suggestions              → paginated profiles to connect with
GET /api/v1/feed/suggestions?per_page=20

A paginated "who to connect with" list, ranked by how well each candidate matches the caller. The caller and anyone they already have an edge with (pending or connected) are excluded; inactive/banned users are omitted.

Ranking blends three signals (higher is better):

score = shared_expertise_areas * 3
      + mutual_connections      * 2
      + LN(connections_count + 1)
  • shared_expertise_areas — overlap between the caller's and the candidate's expertise areas, drawn from both mentor and mentee profiles.
  • mutual_connections — people connected to the caller who are also connected to the candidate (connection-graph proximity).
  • connections_count — overall popularity (log-dampened tiebreaker).

When the caller has no expertise signal, ranking degrades gracefully to the connection-graph and popularity terms. Each row exposes the "why" (and always connection_status: "none", since linked users are excluded):

{
  "id": "7d2e…",
  "username": "john_doe",
  "name": "John Doe",
  "bio": "Building things.",
  "avatar_url": null,
  "connections_count": 128,
  "mutual_connections_count": 3,
  "shared_expertise_count": 2,
  "connection_status": "none"
}
Param Type Default Notes
per_page int 15 1–50

Report

POST /api/v1/feed/posts/{post}/report        → 201
POST /api/v1/feed/comments/{comment}/report  → 201

Payload: reason (required, one of the report-reason enum values), details (optional, ≤ 2000). One report per user per target — a duplicate returns 409. Reports are stored with status: pending; no moderation surface consumes them yet (see below).


Deferred / cross-phase (not in this cut)

Item Present Unblocks with
Admin moderation surface (list/resolve reports, hide/remove content) feed_reports captured (status column) Future moderation cycle
Queued notification fan-out; email / push channels database channel (synchronous) Phase 10 — Notifications / queue worker
Blocking / mute between users Future feed cycle
Mentions / advanced full-text search (relevance-ranked, typo-tolerant) LIKE-based post search over body + hashtag shipped (see Search & Hashtags) Future feed cycle