Posts / Timeline Domain — Story Bundles

POST-STORY-001 — Create a post ✅ [CORE]

AS A logged-in user I WANT to publish a short-form post SO THAT my followers can see it in their timeline feed

SCENARIO 1: Successful post creation

Scenario ID: POST-STORY-001-S1

GIVEN

  • The user is authenticated (valid JWT)

  • The post text is non-empty and within the allowed length

WHEN

  • The user submits the post form

THEN

  • A post record is created in the posts schema with author_id, text, and created_at

  • The API returns 201 Created with the new post object

SCENARIO 2: Empty post rejected

Scenario ID: POST-STORY-001-S2

GIVEN

  • The user submits a post with an empty or whitespace-only text body

WHEN

  • The request reaches the Posts Service

THEN

  • The API returns 422 Unprocessable Entity with error code POST_TEXT_REQUIRED

  • No record is created

SCENARIO 3: Post exceeds maximum length

Scenario ID: POST-STORY-001-S3

GIVEN

  • The post text exceeds 280 characters

WHEN

  • The request reaches the Posts Service

THEN

  • The API returns 422 Unprocessable Entity with error code POST_TEXT_TOO_LONG

  • No record is created

SCENARIO 4: Unauthenticated request rejected

Scenario ID: POST-STORY-001-S4

GIVEN

  • The request carries no JWT or an expired JWT

WHEN

  • POST /posts is called

THEN

  • The API returns 401 Unauthorized

Architecture reference: Chapter 1 — Introduction and Goals, Chapter 5 — Building Block View, Chapter 6 — Runtime View


POST-FE-001.1 — Compose post UI

AS A logged-in user I WANT a text input area with a submit button to compose a post SO THAT I can publish content from the timeline page

SCENARIO 1: Post submitted and appears in feed

Scenario ID: POST-FE-001.1-S1

GIVEN

  • The user is on the timeline page and has typed text into the compose area

WHEN

  • The user clicks “Post”

THEN

  • A POST /posts request is sent with the Bearer JWT

  • On 201 Created the new post is prepended to the feed without a full page reload

  • The compose area is cleared

Architecture reference: Chapter 3 — Context and Scope


POST-FE-001.2 — Compose form validation

AS A logged-in user I WANT the compose form to enforce length limits before submission SO THAT I get immediate feedback without a round-trip to the server

SCENARIO 1: Character counter warns near limit

Scenario ID: POST-FE-001.2-S1

GIVEN

  • The user is typing in the compose area

WHEN

  • The text length reaches 260 characters

THEN

  • A character counter shows remaining characters in amber

WHEN

  • The text length exceeds 280 characters

THEN

  • The counter turns red and the submit button is disabled

Architecture reference: Chapter 3 — Context and Scope


POST-BE-001.1 — POST /posts endpoint

AS A Posts Service I WANT to expose POST /posts SO THAT authenticated users can create posts

SCENARIO 1: Valid post is persisted

Scenario ID: POST-BE-001.1-S1

GIVEN

  • The request carries a valid RS256 JWT

  • The body contains {"text": "<non-empty string ≤280 chars>"}

WHEN

  • POST /posts is called

THEN

  • A row is inserted into posts.posts (id, author_id, text, created_at)

  • Response is 201 Created with {id, author_id, text, created_at}

SCENARIO 2: JWT validated locally without calling Users Service

Scenario ID: POST-BE-001.1-S2

GIVEN

  • The Posts Service has the RS256 public key in JWT_PUBLIC_KEY

WHEN

  • Any authenticated request arrives

THEN

  • The token is verified locally; no HTTP call to the Users Service is made for auth

Architecture reference: Chapter 5 — Building Block View, Chapter 6 — Runtime View, Chapter 9 — Architecture Decisions


POST-BE-001.2 — Author profile enrichment

AS A Posts Service I WANT to enrich post responses with the author’s display name and avatar SO THAT the frontend can render posts without a separate profile lookup

SCENARIO 1: Author profile fetched from Users Service

Scenario ID: POST-BE-001.2-S1

GIVEN

  • A post has been created with author_id

WHEN

  • The post is returned in any response

THEN

  • The Posts Service calls GET /users/{author_id} on the Users Service

  • The response includes author: {id, username, display_name, avatar_url}

SCENARIO 2: Users Service unavailable — graceful degradation

Scenario ID: POST-BE-001.2-S2

GIVEN

  • The Users Service is unreachable

WHEN

  • The Posts Service attempts to enrich the author profile

THEN

  • The post is returned with author: null

  • A structured warning is logged with the trace_id

Architecture reference: Chapter 5 — Building Block View, Chapter 8 — Cross-Cutting Concepts


POST-INFRA-001.1 — Posts Service containerised and deployable

AS AN operator I WANT the Posts Service packaged as a Docker image SO THAT it can be deployed independently behind the nginx reverse proxy

SCENARIO 1: Container starts and serves traffic

Scenario ID: POST-INFRA-001.1-S1

GIVEN

  • A Docker image is built from the Posts Service source

  • DATABASE_URL and JWT_PUBLIC_KEY are set as environment variables

WHEN

  • The container starts

THEN

  • The service is reachable on its configured port

  • GET /health returns 200 OK

Architecture reference: Chapter 7 — Deployment View


POST-INFRA-001.2 — Posts schema migration

AS AN operator I WANT an Alembic migration that creates the posts.posts table SO THAT the Posts Service can persist user content

SCENARIO 1: Migration creates posts table with required index

Scenario ID: POST-INFRA-001.2-S1

GIVEN

  • A PostgreSQL instance has no posts schema tables

WHEN

  • alembic upgrade head is run for the Posts Service

THEN

  • Table posts.posts exists with columns (id, author_id, text, created_at)

  • The (author_id, created_at DESC) index is managed by the feed-story migration (POST-INFRA-002.2)

Architecture reference: Chapter 7 — Deployment View, Chapter 9 — Architecture Decisions


POST-INFRA-001.3 — Event handling

Not applicable — post creation is a synchronous operation at this stage. No async fan-out events are triggered (ADR-004 uses fan-out on read).


POST-INFRA-001.4 — Monitoring and alarms for Posts Service

AS AN operator I WANT a health check endpoint and an error-rate alert on the Posts Service SO THAT I am notified when post creation is degraded

SCENARIO 1: Health check returns 200

Scenario ID: POST-INFRA-001.4-S1

GIVEN

  • The Posts Service is running and connected to its database

WHEN

  • GET /health is called

THEN

  • Response is 200 OK with {"status": "ok"}

SCENARIO 2: Alert fires on elevated error rate

Scenario ID: POST-INFRA-001.4-S2

GIVEN

  • Prometheus scrapes /metrics on the Posts Service every 15 seconds

WHEN

  • The HTTP 5xx error rate exceeds 1% for 5 consecutive minutes

THEN

  • An alert fires and is routed to the on-call channel

Architecture reference: Chapter 7 — Deployment View, Chapter 8 — Cross-Cutting Concepts


POST-STORY-002 — Read aggregated timeline feed ✅ [CORE]

AS A logged-in user I WANT to see a paginated feed of posts from users I follow SO THAT I can stay up to date with their content

SCENARIO 1: Timeline returns posts from followees

Scenario ID: POST-STORY-002-S1

GIVEN

  • The authenticated user follows at least one user who has posts

WHEN

  • The user requests their timeline feed

THEN

  • Posts from all followees are returned, sorted by created_at DESC

  • Each post includes the author’s username and avatar_url

  • The response is paginated (default page size: 20)

SCENARIO 2: Empty feed for user with no followees

Scenario ID: POST-STORY-002-S2

GIVEN

  • The authenticated user follows nobody

WHEN

  • The user requests their timeline feed

THEN

  • The API returns 200 OK with an empty items array and pagination metadata (page, page_size, has_next)

SCENARIO 3: Pagination returns correct page

Scenario ID: POST-STORY-002-S3

GIVEN

  • The user’s followees have published more than 20 posts in total

WHEN

  • The user requests page 2 (?page=2&page_size=20)

THEN

  • The response contains posts 21–40 sorted by created_at DESC

  • The response includes page, page_size, and has_next metadata

SCENARIO 4: Feed loads within performance budget

Scenario ID: POST-STORY-002-S4

GIVEN

  • The authenticated user follows up to 1 000 users

WHEN

  • The timeline feed is requested

THEN

  • The response is returned in under 500 ms (p95)

Architecture reference: Chapter 1 — Introduction and Goals, Chapter 5 — Building Block View, Chapter 6 — Runtime View, Chapter 9 — Architecture Decisions


POST-FE-002.1 — Timeline feed page

AS A logged-in user I WANT an infinite-scroll timeline page that loads posts from my followees SO THAT I can browse content without manual pagination

SCENARIO 1: Initial feed loads on page open

Scenario ID: POST-FE-002.1-S1

GIVEN

  • The authenticated user navigates to /timeline

WHEN

  • The page mounts

THEN

  • A GET /feed request is sent with the Bearer JWT

  • Up to 20 posts are rendered, each showing author avatar, username, text, and timestamp

SCENARIO 2: Next page loads on scroll to bottom

Scenario ID: POST-FE-002.1-S2

GIVEN

  • The user has scrolled to the bottom of the currently loaded posts

  • has_next is true in the last response

WHEN

  • The scroll threshold is crossed

THEN

  • A GET /feed?page=<next> request is sent

  • New posts are appended below existing ones without replacing them

Architecture reference: Chapter 3 — Context and Scope


POST-BE-002.1 — GET /feed endpoint

AS A Posts Service I WANT to expose GET /feed SO THAT authenticated users can retrieve their aggregated timeline

SCENARIO 1: Feed query aggregates followee posts (fan-out on read)

Scenario ID: POST-BE-002.1-S1

GIVEN

  • The request carries a valid JWT containing user_id

  • The users.follows table has the caller’s followee IDs (read via internal API call to Users Service)

WHEN

  • GET /feed?page=1&page_size=20 is called

THEN

  • The Posts Service obtains the followee ID list from the Users Service via the follow-graph contract (candidate: GET /users/{user_id}/followees; cross-context interface to be formally defined in architecture docs)

  • A single query selects from posts.posts WHERE author_id = ANY(:followee_ids) ORDER BY created_at DESC LIMIT 20 OFFSET 0

  • Response is 200 OK with {items: [...], page, page_size, has_next}

SCENARIO 2: Each post enriched with author profile

Scenario ID: POST-BE-002.1-S2

GIVEN

  • The feed query returns N posts with distinct author_id values

WHEN

  • The Posts Service builds the response

THEN

  • Author profiles are fetched via a batch Users Service contract for the unique author IDs in the page (candidate: GET /users?ids={id1},{id2},...; cross-context interface to be formally defined in architecture docs)

  • Each post item includes author: {id, username, display_name, avatar_url}

Architecture reference: Chapter 5 — Building Block View, Chapter 6 — Runtime View, Chapter 9 — Architecture Decisions


POST-BE-002.2 — Followees cross-context call (candidate contract)

AS A Posts Service I WANT to retrieve a user’s followee list from the Users Service SO THAT the feed query knows which author_id values to include

Note: GET /users/{user_id}/followees is a candidate cross-context contract pending formal definition in architecture/05-building-block-view.md.

SCENARIO 1: Followee list returned successfully

Scenario ID: POST-BE-002.2-S1

GIVEN

  • The Posts Service calls the Users-domain follow-graph contract (candidate: GET /users/{user_id}/followees) with the Bearer JWT

WHEN

  • The Users Service processes the request

THEN

  • A list of followee_id values is returned

  • The Posts Service uses this list as the author_id filter in the feed query

SCENARIO 2: Users Service unavailable — empty feed returned

Scenario ID: POST-BE-002.2-S2

GIVEN

  • The Users Service is unreachable

WHEN

  • The Posts Service attempts to fetch followees

THEN

  • The feed returns 200 OK with an empty items array

  • A structured warning is logged with the trace_id

Architecture reference: Chapter 5 — Building Block View, Chapter 8 — Cross-Cutting Concepts


POST-INFRA-002.1 — Posts Service containerised and deployable

Covered by POST-INFRA-001.1 — the same container serves /feed.

Architecture reference: Chapter 7 — Deployment View


POST-INFRA-002.2 — Feed query index migration

AS AN operator I WANT an Alembic migration that ensures the (author_id, created_at DESC) index exists on posts.posts SO THAT fan-out-on-read feed queries meet the 500 ms performance budget

SCENARIO 1: Index present after migration

Scenario ID: POST-INFRA-002.2-S1

GIVEN

  • The posts.posts table exists (POST-INFRA-001.2 has run)

WHEN

  • The migration for POST-STORY-002 is applied

THEN

  • EXPLAIN ANALYZE on the feed query shows an index scan on (author_id, created_at DESC)

  • Migration is idempotent (CREATE INDEX IF NOT EXISTS)

Architecture reference: Chapter 7 — Deployment View, Chapter 9 — Architecture Decisions


POST-INFRA-002.3 — Event handling

Not applicable — the timeline feed is a synchronous read; no async events are triggered (ADR-004: fan-out on read).


POST-INFRA-002.4 — Feed latency alert

AS AN operator I WANT an alert when feed response latency exceeds the 500 ms quality goal SO THAT performance degradation is caught before users notice

SCENARIO 1: Latency alert fires on p95 breach

Scenario ID: POST-INFRA-002.4-S1

GIVEN

  • Prometheus scrapes /metrics on the Posts Service and records http_request_duration_seconds

WHEN

  • The p95 latency for GET /feed exceeds 500 ms for 5 consecutive minutes

THEN

  • An alert fires and is routed to the on-call channel

Architecture reference: Chapter 7 — Deployment View, Chapter 8 — Cross-Cutting Concepts


POST-STORY-003 — Like / unlike a post ✅ [Supporting]

AS A logged-in user I WANT to like or unlike a post SO THAT I can express appreciation for content

SCENARIO 1: Successfully like a post

Scenario ID: POST-STORY-003-S1

GIVEN

  • The authenticated user has not already liked the post

WHEN

  • The user clicks the like button on a post

THEN

  • A like record is created in posts.likes

  • The post’s like count increments by 1

  • The API returns 200 OK

SCENARIO 2: Unlike a post

Scenario ID: POST-STORY-003-S2

GIVEN

  • The authenticated user has already liked the post

WHEN

  • The user clicks the like button again

THEN

  • The like record is removed from posts.likes

  • The post’s like count decrements by 1

SCENARIO 3: Like is idempotent

Scenario ID: POST-STORY-003-S3

GIVEN

  • The authenticated user has already liked the post

WHEN

  • POST /posts/{id}/like is called again

THEN

  • Response is 200 OK with no duplicate like record created

Architecture reference: Chapter 5 — Building Block View


POST-FE-003.1 — Like button on post card

AS A logged-in user I WANT a like toggle button on each post card SO THAT I can like or unlike without leaving the feed

SCENARIO 1: Like count updates optimistically

Scenario ID: POST-FE-003.1-S1

GIVEN

  • The user sees a post with a like button

WHEN

  • The user clicks the like button

THEN

  • The like count increments immediately (optimistic update)

  • POST /posts/{id}/like is sent in the background

  • On error the count reverts and a toast error is shown

Architecture reference: Chapter 3 — Context and Scope


POST-BE-003.1 — POST /posts/{id}/like and DELETE /posts/{id}/like endpoints

AS A Posts Service I WANT to expose POST /posts/{id}/like and DELETE /posts/{id}/like SO THAT authenticated users can like and unlike posts

SCENARIO 1: Like created

Scenario ID: POST-BE-003.1-S1

GIVEN

  • The request carries a valid JWT

  • No existing like from this user on this post

WHEN

  • POST /posts/{id}/like is called

THEN

  • A row is inserted into posts.likes (post_id, user_id, created_at)

  • Response is 200 OK with updated like_count

SCENARIO 2: Unlike removes like

Scenario ID: POST-BE-003.1-S2

GIVEN

  • A like record exists for this user and post

WHEN

  • DELETE /posts/{id}/like is called

THEN

  • The row is deleted from posts.likes

  • Response is 200 OK with updated like_count

Architecture reference: Chapter 5 — Building Block View


POST-INFRA-003.1 — Posts Service containerised and deployable

Covered by POST-INFRA-001.1.

Architecture reference: Chapter 7 — Deployment View


POST-INFRA-003.2 — likes table migration

AS AN operator I WANT an Alembic migration that creates the posts.likes table SO THAT like relationships can be persisted

SCENARIO 1: Migration creates likes table

Scenario ID: POST-INFRA-003.2-S1

GIVEN

  • The posts schema exists (POST-INFRA-001.2 has run)

WHEN

  • The migration for POST-STORY-003 is applied

THEN

  • Table posts.likes exists with columns (post_id, user_id, created_at)

  • A composite primary key on (post_id, user_id) prevents duplicate likes

Architecture reference: Chapter 7 — Deployment View


POST-INFRA-003.3 — Event handling

Not applicable — likes are synchronous; no async events required.


POST-INFRA-003.4 — Monitoring

Covered by POST-INFRA-001.4.

Architecture reference: Chapter 7 — Deployment View


POST-STORY-004 — Edit / delete own post ✅ [Supporting]

AS A logged-in user I WANT to edit or delete a post I authored SO THAT I can correct mistakes or remove content

SCENARIO 1: Successfully edit a post

Scenario ID: POST-STORY-004-S1

GIVEN

  • The authenticated user is the author of the post

  • The new text is non-empty and ≤280 characters

WHEN

  • The user submits the edited text

THEN

  • The posts.posts row is updated with the new text and an edited_at timestamp

  • The API returns 200 OK with the updated post

SCENARIO 2: Successfully delete a post

Scenario ID: POST-STORY-004-S2

GIVEN

  • The authenticated user is the author of the post

WHEN

  • The user confirms deletion

THEN

  • The post row is deleted from posts.posts

  • The API returns 204 No Content

SCENARIO 3: Non-author cannot edit or delete

Scenario ID: POST-STORY-004-S3

GIVEN

  • The authenticated user is not the author of the post

WHEN

  • PATCH /posts/{id} or DELETE /posts/{id} is called

THEN

  • The API returns 403 Forbidden with error code NOT_POST_AUTHOR

Architecture reference: Chapter 5 — Building Block View


POST-FE-004.1 — Edit and delete controls on own post card

AS A logged-in user I WANT edit and delete options on posts I authored SO THAT I can manage my content inline

SCENARIO 1: Edit mode replaces post text with input

Scenario ID: POST-FE-004.1-S1

GIVEN

  • The user is viewing their own post

WHEN

  • The user clicks “Edit”

THEN

  • The post text is replaced with an editable input pre-filled with the current text

  • “Save” and “Cancel” buttons appear

SCENARIO 2: Delete requires confirmation

Scenario ID: POST-FE-004.1-S2

GIVEN

  • The user clicks “Delete” on their own post

WHEN

  • A confirmation dialog appears and the user confirms

THEN

  • DELETE /posts/{id} is called and the post is removed from the feed on 204

Architecture reference: Chapter 3 — Context and Scope


POST-BE-004.1 — PATCH /posts/{id} and DELETE /posts/{id} endpoints

AS A Posts Service I WANT to expose PATCH /posts/{id} and DELETE /posts/{id} SO THAT authors can update or remove their posts

SCENARIO 1: Post updated by author

Scenario ID: POST-BE-004.1-S1

GIVEN

  • The JWT user_id matches the post’s author_id

  • The body contains valid {text}

WHEN

  • PATCH /posts/{id} is called

THEN

  • posts.posts row is updated with new text and edited_at = now()

  • Response is 200 OK with the updated post

SCENARIO 2: Post deleted by author

Scenario ID: POST-BE-004.1-S2

GIVEN

  • The JWT user_id matches the post’s author_id

WHEN

  • DELETE /posts/{id} is called

THEN

  • The row is deleted from posts.posts

  • Associated posts.likes rows are cascade-deleted

  • Response is 204 No Content

Architecture reference: Chapter 5 — Building Block View


POST-INFRA-004.1 — Posts Service containerised and deployable

Covered by POST-INFRA-001.1.

Architecture reference: Chapter 7 — Deployment View


POST-INFRA-004.2 — edited_at column migration

AS AN operator I WANT an Alembic migration that adds edited_at to posts.posts SO THAT edited posts can be distinguished from original ones

SCENARIO 1: Migration adds edited_at column

Scenario ID: POST-INFRA-004.2-S1

GIVEN

  • The posts.posts table exists

WHEN

  • The migration for POST-STORY-004 is applied

THEN

  • Column edited_at TIMESTAMPTZ NULL exists on posts.posts

  • Existing rows have edited_at = NULL

Architecture reference: Chapter 7 — Deployment View


POST-INFRA-004.3 — Event handling

Not applicable — edit and delete are synchronous operations.


POST-INFRA-004.4 — Monitoring

Covered by POST-INFRA-001.4.

Architecture reference: Chapter 7 — Deployment View