Imported from PostCredits/postcredits-web (
AGENTS.md). Install upstream withnpx skills add PostCredits/postcredits-web. Copyright stays with the author.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ before writing any code. Heed deprecation notices.
Product Concept
PostCredits is a social app for movie "book clubs": a group picks a movie, everyone watches it within a set window, then rates/reviews it and reacts/comments on each other's reviews.
Terminology mapping — the concept doc below uses product language; the API uses different nouns:
- "Group" →
Studio - "Session" →
Feature - Everything else (reviews, comments, reactions) maps 1:1.
Auth
- Sign up with email + unique username + password; log in with username/password.
- Forgot-password flow sends a new password to the account's email. Not yet built — no endpoint exists for this (see API gotchas below).
Joining a Group
- Public groups: join freely.
- Private groups: request to join (approval flow — not yet built, no join-requests endpoint).
- Search public groups by name, member, or description keywords.
- Private/unlisted groups: join via invite code from an existing member.
Group Creation / Admin Privileges
Creator becomes admin. Admins can, at any time:
- Set name and description.
- Toggle privacy: public / private / unlisted.
- Invite members via code.
- Set selection method: rotation vs. voting.
- Set session length and movie-selection-phase length.
- Kick members. Not yet built — no endpoint exists for this yet (see MVP punch list below).
Movie Selection — General
Search movies by title, director, or starring actor. Before submitting/nominating, users can preview title, poster, IMDB rating, genre, director, starring actors, trailer link.
Movie Selection — Rotation
- Fixed member order, defaulting to join order; admin can reorder.
- At the end of a session, the next member in line submits the movie for the following session.
Movie Selection — Voting
- Two phases after a session ends: nomination (each member submits exactly one movie, including themselves) then voting (ranked-choice; top 3 get 5/3/1 points).
- During voting, members can view each nominee's details but not who submitted it.
- Highest point total wins; results and per-movie point values are viewable after the vote closes.
Submitting a Review
- Rating: 0–5 stars in 0.5 increments. A guiding phrase appears above the star picker to anchor the scale:
- 0.5 "I had to turn it off" · 1.0 "Genuinely awful" · 1.5 "It had a few redeeming qualities… I guess" · 2.0 "Not for me" · 2.5 "Just plain okay" · 3.0 "I liked it, but wouldn't rewatch" · 3.5 "It was pretty good" · 4.0 "I loved it!" · 4.5 "Near perfection" · 5.0 "One of my favorite movies ever"
- Optionally pick 0–5 liked tags and 0–5 disliked tags (no overlap) from the fixed
reviewTagenum (plot, beginning, middle, end, writing, characters, performances, cinematography, costume_design, set_design, score, soundtrack, sound_design, editing, visual_effects). - Optional free-form written review.
- Reviews can only be submitted/edited while the session window is open.
Interacting with Reviews
- During the session, members view each other's reviews, comment, react with emoji, and can @-tag users in comments.
- Comments remain possible after the session ends, even though reviews themselves lock.
Viewing Groups
- Group page shows name + description; clicking a group opens its current session by default.
- Past sessions are browsable by date range + movie selected, and their reviews remain viewable/interactive.
- Each group also has a standing group chat, independent of session state, always available. Not yet built — no chat endpoint exists.
MVP Punch List
What's left before opening to production. Enhancements (mentions, voting-method studios, rotation reordering, editing studio settings post-creation, forgot-password, private-studio join-requests, chat, notifications) are explicitly deferred — not blockers.
- Leave studio — backend endpoint
POST /studios/:studioId/leavealready exists; no frontend UI calls it yet. In progress (backend work). - Kick members — no backend endpoint yet. Plan: unify with leave under a single
DELETE /studios/:studioId/members/:userIdroute — ifuserIdmatches the caller it's a self-leave (no special permission needed), otherwise the caller must be admin. Same "can't remove the last admin" guard applies either way. Once built, add frontend UI (admin-only kick action on the members list) and retire/redirect the oldPOST /studios/:studioId/leaveroute if replaced.
Design / Styling
Color scheme is dark mode: blacks and greys as the base palette, white text, red as the accent color. The CSS is already set up to accommodate this — match new components/styles to it rather than introducing other palettes.
Backend API Reference
Express app. All routes mounted at /studios except authRouter (/auth). Auth is a JWT Bearer token in the Authorization header, populating req.user. All routes require auth except: GET /health, POST /auth/register, POST /auth/login.
Error shape: { message: string } for 400/403/404/409/500, or { message: "Invalid request", errors: ZodIssue[] } for validation failures (400). 401 { message: "Unauthorized" } for missing/bad auth.
Gotchas:
ratingcomes back as a string (Postgres numeric), e.g."3.5", not a JS number — parse before doing math/comparisons.- Private studios return 404 (not 403) to non-members on
GET /studios/:studioId— deliberate, avoids leaking existence. POST /studios/:studioId/joinonly works forprivacy: "public"studios; private/unlisted studios are joined viaPOST /studios/joinwith an invite code instead.- No endpoints exist yet for: nominations, voting, vote-choices, chat, join-requests, notifications, kick-member — even though the DB tables exist for some of these. Don't build frontend against these until they ship.
- Movie search now exists (
GET /movies?q=) and is wired up in the select-movie flow — the note above only applies to the remaining list.
Misc
| Method/Path | Notes |
|---|---|
GET /health |
no auth, returns { status: "ok" } |
Auth (/auth)
| Method/Path | Body | Response |
|---|---|---|
POST /auth/register |
{ username, email, password (min 8), firstName?, lastName? } |
201: { token, user: PublicUser } |
POST /auth/login |
{ identifier (username or email), password } |
200: { token, user: PublicUser } |
GET /auth/me |
— | { user: PublicUser } |
PublicUser = { id, username, email, firstName, lastName, createdAt }
Studios (/studios)
| Method/Path | Body/Params | Response |
|---|---|---|
GET /studios?q= |
optional search query | { studios: Studio[] } (public only) |
GET /studios/me |
— | Studio[] with isAdmin, joinedAt |
GET /studios/:studioId |
— | Studio & { memberCount, isMember, isAdmin } (404 if private + not member) |
POST /studios |
{ name (3-50), description? (≤300), privacy: public/private/unlisted, selectionMethod: rotation/voting, featureDurationDays: int>0, nominationDurationDays?, votingDurationDays? } |
201: created Studio |
GET /studios/:studioId/members |
— | { members: { userId, username, firstName, lastName, isAdmin, joinedAt }[] } (members only, ordered by join date; 403 if not a member) |
POST /studios/:studioId/join |
— | { success, message } (only works for public studios) |
POST /studios/join |
{ code } (invite code, case-insensitive) |
{ success, message, studioId } (private/unlisted studios only; 404 unknown code, 403 if studio is public) |
POST /studios/:studioId/leave |
— | { success, message } (409 if last admin) |
Features
| Method/Path | Body | Response |
|---|---|---|
POST /studios/:studioId/features |
— (admin only) | 201: new feature |
GET /studios/:studioId/current-feature |
— | active feature or null |
GET /studios/:studioId/features/history |
— | { features: [...] } |
POST /studios/:studioId/features/select-movie |
{ movieId: int } |
updated feature (status → reviewing) |
POST /studios/:studioId/features/close |
— | { closedFeature, nextFeature } (only works once the review window has actually ended) |
POST /studios/:studioId/features/end-early |
— (admin only) | { closedFeature, nextFeature } (force-closes a feature in reviewing status before its window ends) |
POST /studios/:studioId/features/skip-selector |
— (admin only) | { success, message } (rotation only; only while status is selecting — advances the rotation position, passing the pick to the next member, without waiting on the current selector) |
GET /studios/:studioId/features/:featureId |
— | { feature, movie, reviews, reviewCount, hasReviewed } (feature.currentSelectorId set only while status is selecting on a rotation studio) |
Status enum: nomination \| voting \| selecting \| reviewing \| completed
Reviews
| Method/Path | Body | Response |
|---|---|---|
POST /studios/:studioId/reviews |
{ rating: 0-5 in 0.5 steps, reviewContent? (1-5000), likedTags?: tag[] ≤5, dislikedTags?: tag[] ≤5 no overlap } |
201: review |
GET /studios/:studioId/features/:featureId/reviews |
— | { reviews: [...] } |
PATCH /studios/:studioId/reviews/:reviewId |
any of the above fields, own review only, only while window open | updated review |
reviewTag enum: plot, beginning, middle, end, writing, characters, performances, cinematography, costume_design, set_design, score, soundtrack, sound_design, editing, visual_effects
Comments
| Method/Path | Body | Response |
|---|---|---|
POST /studios/:studioId/reviews/:reviewId/comments |
{ content (1-2000) } |
201: comment |
GET /studios/:studioId/reviews/:reviewId/comments |
— | { comments: [...] } (oldest first) |
PATCH /studios/:studioId/comments/:commentId |
{ content } |
updated comment (own only) |
DELETE /studios/:studioId/comments/:commentId |
— | { success, message } (own only) |
Reactions
| Method/Path | Body | Response |
|---|---|---|
PUT /studios/:studioId/reviews/:reviewId/reaction |
{ reactionType } |
upsert, 200: reaction |
DELETE /studios/:studioId/reviews/:reviewId/reaction |
— | { success, message } |
GET /studios/:studioId/reviews/:reviewId/reactions |
— | { reactions: [...] } |
reactionType enum: thumbs_up, thumbs_down, heart, fire, shocked, hundred, laugh, cry, clap, heartbreak