287 lines
12 KiB
Markdown
287 lines
12 KiB
Markdown
# Minnect feed browser
|
|
|
|
A browser client for the Minnect expert home feed. Anyone who receives the app signs in with their own Minnect email and password. The app then reads and writes the feed as that person.
|
|
|
|
This file is the build contract. An implementation is done when the acceptance checks at the end pass. Do not invent a second login, a shared account, or extra Minnect products.
|
|
|
|
## Product
|
|
|
|
Signed out, the only screen is email, password, and a sign-in button.
|
|
|
|
Signed in, the screen is a single column:
|
|
|
|
1. The signed-in person's name and a sign-out control.
|
|
2. A composer for a new public text post, plus an optional image or video.
|
|
3. The home feed, newest first. Each post is a card. A control at the bottom loads the next page.
|
|
|
|
A card shows the author photo, display name, relative time, the full post text, images, video, like state, like count, and comment count. Opening comments lists them and accepts a new text comment. The signed-in person can remove their own like, and can delete a post only when they are its author.
|
|
|
|
## Non-goals
|
|
|
|
Do not build ticketing, wallet, circles chat, paid questions, or any admin screen.
|
|
|
|
Read-only expert and user search is in scope, but only if a public, normal-user API for it is confirmed by the Mobilization spike (task 8kr7vfa5n). If no such API exists, do not build a search box or any search control. Search never calls admin routes. (Amended 2026-10-19: search moved out of non-goals by Cesar.)
|
|
|
|
Do not call admin routes. Do not call a route whose path contains `/admin/`. Do not call `POST /api/v4/auth/external-services/auth-user`. Do not call feed `POST /api/v1/auth/login`. That feed route is an admin login, not the user login.
|
|
|
|
Do not put a token, password, or session in the repository, the frontend bundle, a query string, or a log line.
|
|
|
|
## Shape of the app
|
|
|
|
The browser talks only to this app. This app talks to Minnect.
|
|
|
|
```
|
|
browser -- cookie session --> this app -- Bearer token --> Minnect
|
|
```
|
|
|
|
The Minnect hosts do not allow this app's origin to call them from the browser. A same-origin server is required for that reason, and so the tokens never reach JavaScript.
|
|
|
|
Session storage holds, per signed-in person:
|
|
|
|
- profile access token and its expiry
|
|
- profile refresh token
|
|
- feed access token and its expiry
|
|
|
|
The browser receives one httpOnly, Secure, SameSite=Lax cookie. The cookie is an opaque session id. It is not a Minnect token.
|
|
|
|
## Hosts
|
|
|
|
| Name | Base URL |
|
|
|---|---|
|
|
| Profiles | `https://profiles.minnect.com` |
|
|
| Feed | `https://feed.sharedservices.vt.com` |
|
|
| Image CDN | `https://cdn.feed.sharedservices.vt.com` |
|
|
| Video | `https://stream.mux.com` |
|
|
|
|
Send JSON as `Content-Type: application/json` and `Accept: application/json`. Send the service token as `Authorization: Bearer <token>`.
|
|
|
|
## Login
|
|
|
|
### Sign in
|
|
|
|
`POST https://profiles.minnect.com/api/v4/auth/login`
|
|
|
|
```json
|
|
{ "email": "person@example.com", "password": "their-password" }
|
|
```
|
|
|
|
`email` and `password` are required. Do not send a `device` object.
|
|
|
|
Success is HTTP 200. The saved OpenAPI document does not describe that 200 body. Read the tokens with this order and stop at the first match:
|
|
|
|
1. `data.accessToken` and `data.refreshToken`
|
|
2. `accessToken` and `refreshToken`
|
|
|
|
Also read `data.expiresIn` or `expiresIn` when present. It is seconds.
|
|
|
|
A failed sign-in is HTTP 401 with this shape:
|
|
|
|
```json
|
|
{ "data": null, "errors": [{ "message": "...", "code": "UnauthorizedException" }] }
|
|
```
|
|
|
|
Show one message: "Email or password is incorrect." Do not show the server message. The server message differs for an unknown email and a wrong password. The screen must not.
|
|
|
|
### Who the person is
|
|
|
|
`GET https://profiles.minnect.com/api/v4/auth/me` with the profile access token.
|
|
|
|
Use `data.userId`, `data.email`, `data.firstName`, `data.lastName`, and `data.profilePictureUrl`. Required fields on the user object are `userId`, `email`, and `role`.
|
|
|
|
### Feed token
|
|
|
|
`POST https://profiles.minnect.com/api/v4/auth/external-services/feed`
|
|
|
|
Header: profile access token. Body: `{}`.
|
|
|
|
Success is HTTP 200. Read the feed token in this order:
|
|
|
|
1. `accessToken`
|
|
2. `data.accessToken`
|
|
3. `token`
|
|
4. `data.token`
|
|
|
|
If none of those fields exist, sign-in fails with "Could not open the feed." Do not show the raw body.
|
|
|
|
Confirm the feed token with `GET https://feed.sharedservices.vt.com/api/v1/auth/me`. Keep `data.id`. That id is the feed author id used later to decide whether a post belongs to this person.
|
|
|
|
### Refresh
|
|
|
|
Profile and feed access tokens last about one hour. Refresh when either token expires within two minutes, and once after a feed call returns 401.
|
|
|
|
1. `POST https://profiles.minnect.com/api/v4/auth/refresh-token` with `{ "refreshToken": "<profile refresh token>" }`.
|
|
2. Replace the stored profile access token and refresh token from that response, using the same field order as sign-in.
|
|
3. Exchange a new feed token with the call in "Feed token".
|
|
|
|
If refresh fails, delete the session and return the browser to the sign-in screen.
|
|
|
|
### Sign out
|
|
|
|
`POST https://profiles.minnect.com/api/v4/auth/revoke-token` with `{ "refreshToken": "<profile refresh token>" }`. Then delete the server session and clear the cookie. Sign-out still succeeds if revoke returns an error.
|
|
|
|
## Feed
|
|
|
|
### Read the home feed
|
|
|
|
`GET https://feed.sharedservices.vt.com/api/v1/feeds/home/items?limit=20`
|
|
|
|
Pass `cursor` with the previous page's `metadata.nextCursor`. Omit `cursor` on the first page.
|
|
|
|
The response is:
|
|
|
|
```json
|
|
{
|
|
"data": [],
|
|
"metadata": { "limit": 20, "cursor": null, "nextCursor": "..." }
|
|
}
|
|
```
|
|
|
|
Stop paging when `metadata.nextCursor` is null. Ask for 20 items. Do not ask for more than 50.
|
|
|
|
Render an item only when `type` is `"post"` and `post` is an object. Other types, including `trending-conversations`, are skipped. They must not break the page.
|
|
|
|
### Post card fields
|
|
|
|
From the feed item: `publishedAt`, `likesCount`, `commentsCount`.
|
|
|
|
From `post`:
|
|
|
|
| Field | Use |
|
|
|---|---|
|
|
| `id` | Post id for like, comment, and delete |
|
|
| `content` | Full text. Keep line breaks |
|
|
| `type` | `text`, `image`, or `video` |
|
|
| `isLiked` | Initial like state |
|
|
| `author.id` | Compare with the feed user id from auth/me |
|
|
| `author.displayName` | Name on the card. Fall back to first + last name |
|
|
| `author.profilePictureUrl` | Avatar. It may be a redirect URL. Use it as the image source |
|
|
| `author.slug` | Optional |
|
|
| `mediaItems` | Images and video, in `order` |
|
|
|
|
Image media uses `imageMediumWebpUrl`, then `imageMediumUrl`, then `imageUrl`.
|
|
|
|
Video media has `mediaType: "video"` and `hlsUrl` on `stream.mux.com`. Play it with an HLS player (hls.js or the platform equivalent). Use `thumbnailUrl` as the poster. A missing `hlsUrl` shows the thumbnail only.
|
|
|
|
### Like and unlike
|
|
|
|
`POST https://feed.sharedservices.vt.com/api/v1/posts/{postId}/like`
|
|
|
|
`DELETE https://feed.sharedservices.vt.com/api/v1/posts/{postId}/like`
|
|
|
|
No body. Success is HTTP 200 and `data.success: true`. A second like does not increase the count. Update the card from the response, or flip `isLiked` and adjust the count by one when the response has no count.
|
|
|
|
### Comments
|
|
|
|
`GET https://feed.sharedservices.vt.com/api/v1/posts/{postId}/comments?limit=20&page=1`
|
|
|
|
`POST https://feed.sharedservices.vt.com/api/v1/posts/{postId}/comments`
|
|
|
|
```json
|
|
{ "content": "comment text", "type": "text", "status": "published" }
|
|
```
|
|
|
|
`content` is required. Show `content`, the author name, and `createdAt`. A comment with `deletedAt` set is shown as removed, without the old text.
|
|
|
|
### Create a text post
|
|
|
|
1. `POST https://feed.sharedservices.vt.com/api/v1/posts`
|
|
|
|
```json
|
|
{
|
|
"content": "post text",
|
|
"type": "text",
|
|
"status": "draft",
|
|
"visibility": "public"
|
|
}
|
|
```
|
|
|
|
2. `POST https://feed.sharedservices.vt.com/api/v1/posts/{id}/publish` with `{ "content": "post text" }`.
|
|
|
|
The create call can return `status: "draft"` after the post is already on the home feed. Reload the first page of the home feed and treat the post as published when its id is there.
|
|
|
|
### Create a post with an image or video
|
|
|
|
1. Create a draft as above, with `type` set to `image` or `video`.
|
|
2. `POST https://feed.sharedservices.vt.com/api/v1/attachments/presigned-urls`
|
|
|
|
```json
|
|
{
|
|
"postId": "<draft id>",
|
|
"files": [{ "name": "photo.jpg", "type": "image/jpeg", "size": 12345 }]
|
|
}
|
|
```
|
|
|
|
`files` is 1 to 10 items. `type` must match `image/jpeg`, `image/jpg`, `image/png`, `image/gif`, `image/webp`, `image/heic`, `video/mp4`, `video/quicktime`, or `video/webm`.
|
|
|
|
3. Upload each file with HTTP PUT to the returned presigned URL. The server does this. The browser does not receive the feed token in order to do it.
|
|
4. `POST https://feed.sharedservices.vt.com/api/v1/attachments/confirm-uploads`
|
|
|
|
```json
|
|
{
|
|
"postId": "<draft id>",
|
|
"uploads": [{
|
|
"attachmentId": "<from step 2>",
|
|
"s3Key": "<from step 2>",
|
|
"mediaType": "image",
|
|
"filename": "photo.jpg"
|
|
}]
|
|
}
|
|
```
|
|
|
|
`mediaType` is `image` or `video`. Image confirmation blocks until processing finishes. Video confirmation returns while transcoding continues. Publish anyway. The card shows the thumbnail until `hlsUrl` exists, then the player.
|
|
5. Publish with the same publish call as a text post.
|
|
|
|
### Delete
|
|
|
|
`DELETE https://feed.sharedservices.vt.com/api/v1/posts/{id}`
|
|
|
|
Offer this only when `post.author.id` equals the signed-in feed user id. Remove the card after HTTP 200.
|
|
|
|
## App routes
|
|
|
|
These are this app's routes, not Minnect's.
|
|
|
|
| Method | Path | Behavior |
|
|
|---|---|---|
|
|
| `POST` | `/api/session` | Body `{ email, password }`. Run sign-in, profile me, and the feed-token exchange. Set the cookie. Return `{ name, email, pictureUrl }` |
|
|
| `DELETE` | `/api/session` | Revoke, delete the session, clear the cookie |
|
|
| `GET` | `/api/session` | Return the signed-in person, or 401 |
|
|
| `GET` | `/api/feed?cursor=` | One page of post cards plus `nextCursor` |
|
|
| `POST` | `/api/posts` | JSON text post, or multipart with `content` and one file |
|
|
| `POST` | `/api/posts/:id/like` | Like |
|
|
| `DELETE` | `/api/posts/:id/like` | Unlike |
|
|
| `GET` | `/api/posts/:id/comments` | Comment list |
|
|
| `POST` | `/api/posts/:id/comments` | Body `{ content }` |
|
|
| `DELETE` | `/api/posts/:id` | Delete when the caller is the author |
|
|
|
|
Every route except `POST /api/session` requires the session cookie. A missing or dead session is HTTP 401 with `{ "error": "sign_in" }`. The page then shows the sign-in screen.
|
|
|
|
## Screen behavior
|
|
|
|
- Empty feed: "No posts yet." The composer stays usable.
|
|
- Feed request failed: "The feed did not load." with a retry control. Keep the person signed in.
|
|
- Like, comment, and publish failures name the action and leave the previous card in place.
|
|
- The composer clears only after the new post is on the reloaded first page.
|
|
- Long text is fully visible. Do not truncate a post.
|
|
- The layout is one column, readable on a phone and on a desktop window.
|
|
|
|
## Acceptance
|
|
|
|
Run these with two real Minnect accounts, A and B.
|
|
|
|
1. Signed out, opening the feed route returns 401 and the sign-in screen.
|
|
2. A wrong password shows "Email or password is incorrect." and creates no session.
|
|
3. Account A signs in and sees the home feed, including author, text, and a working image or video on a card that has one.
|
|
4. Account A publishes a short public text post. After reload, that post is the newest card and its author is A.
|
|
5. Account B signs in on the same deployment, sees A's post, likes it, and comments. Account A sees the like and the comment.
|
|
6. Account A cannot see a delete control on B's posts. Account A can delete the post from step 4, and it leaves the feed.
|
|
7. Sign-out clears the cookie. The next feed request returns 401.
|
|
8. A search of the built frontend assets finds no access token, refresh token, or password.
|
|
|
|
## Implementation notes
|
|
|
|
- The home-feed path that is known to work is `/api/v1/feeds/home/items`. The OpenAPI form `/api/v1/feeds/{feedKey}/items` with `feedKey=home` is the same resource. Use the first path.
|
|
- Like is idempotent. Do not retry a like in a loop.
|
|
- Publish can report `draft` while the post is already public. The home feed is the check that matters.
|
|
- Saved feed JSON in this repository is an old sample. Do not use it as the data source. The live calls above are the source.
|