Per-feature decisions. Shared concepts (groups, privacy/scoping, the non-member groupId 403 contract, resolveMemberIds) live in architecture.md.
- Every user has ONE global display name.
user_profiles(userIdPK,displayNamenotNull, timestamps) is the single source of truth.resolveDisplayName(userId)reads profile → ClerkfirstName→"Member"— it NEVER returns an email/username/last name (emails must stay private; the old Clerk-derivedaddedByfallback inwatchlist.tsthat could leak an email was removed). All name snapshots are populated viaresolveDisplayNameon write. - The name is denormalized into many tables for read-path-free attribution (
group_members.displayName,entries.addedBy,watchlist_items.addedBy,show_comments.authorName,lists.ownerName,invites.createdByName).setDisplayNameAndPropagate(userId, name)upserts the profile AND fans the new name out to ALL of those snapshots in ONE transaction, so a rename shows everywhere with no read changes. ANY future table that snapshots a user's name MUST be added to this fan-out. - Routes (
routes/profile.ts, mounted aftergroupsRouter, beforeentriesRouter; has its ownrouter.use(requireAuth)):GET /profile→{displayName: string|null}(null = not set yet);PUT /profiletrims, 400 on blank, enforces per-group case-insensitive uniqueness across the caller's ACTIVE groups (conflictingGroupNamevialower()), 409{code:"name_taken"}on conflict (no write — side-effect-free), else propagates. Invite-accept enforces the SAME uniqueness in the TARGET group and 409s{code:"name_taken", groupName}. - Frontend:
DisplayNameGate(inApp.tsx) runs BEFOREGroupGatefor any signed-in user; ifGET /profilereturns no name it renders the forced, non-dismissibleSetDisplayNameScreen(prefilled with ClerkfirstName).DisplayNameForm/DisplayNameDialog(components/display-name-dialog.tsx) handle settings edits (fromUserMenu, which now shows the profile name, never email) and invite name-conflict resolution (InviteAcceptercatches the 409, prompts, retries). Saving callsqc.invalidateQueries()(refetch all) so denormalized names refresh everywhere.InviteAccepteralso waits forprofile.displayNamebefore auto-accepting, preserving name-first ordering. - Uniqueness is enforced at the app layer (check-then-write), NOT a DB constraint — a partial unique index on
(groupId, lower(displayName))would break legacygroup_membersrows that share fallback names (e.g. multiple"Member"). Concurrent same-name writes in one group are theoretically possible but unlikely; matches the codebase's existing check-then-write pattern.
watchlist_itemstable (per-user saved shows) withunique(userId, titleKey, mediaType). A "show" has no canonical entity — the same show across users is matched bytitleKey(normalizedlower(trim(title))) +mediaType.GET /watchlistreturns the caller's saved items each annotated withalsoEngagedBy: display names of OTHER users who have rated (have anentriesrow) OR saved that same show, deduped by userId across both sources and caller-excluded.POST /watchlistis an idempotent upsert;DELETE /watchlist/:idis ownership-scoped (id + userId). Save/Saved toggle lives on each library card;/watchlistis the personal page.
show_audiencestable (per-user picks per show) withunique(userId, titleKey, mediaType)and anaudiencestext[]column holding any of the canonicalAudiencevalues (girls,guys,couples,solo). It is MULTI-SELECT: a member may pick one or many. Same "same-show" identity as watchlist (titleKey= normalizedlower(trim(title))+mediaType).GET /audiencesfetches the resolved member set's rows and aggregates in JS into per-option counts (count = number of members who picked that option, so the four counts can sum past the member count), overlaying the caller's own picks asmyAudiences.PUT /audiencesupserts the caller's full picks array (rejects an empty array with 400);DELETE /audiences?title=&mediaType=clears it (ownership-scoped on all three columns). Both write endpoints return the freshShowAudiencesummary (titleKey, mediaType, girls, guys, couples, solo, myAudiences). InteractiveAudienceControl("Who Should Watch?", neutralEyeicon, multi-select toggle buttons The Girls / The Guys / Couples / Solo, tap to add/remove) lives on the view-entry page; read-onlyAudienceSummary(e.g. "3 Couples / 2 The Girls / 1 Solo", count-desc, non-zero only) shows on library cards and the watchlist page. HookuseAudienceMap/audienceKey.
show_spicetable — a single-answer SIBLING of the audience poll,spicyin (mild,mature,adult) = a 3-level maturity rating (1 pepper Mild, 2 peppers 17+, 3 peppers Adults Only). Same identity/scoping/contract as audiences (unique(userId, titleKey, mediaType), group-scoped tallies viaresolveMemberIds, non-membergroupId→ 403). RoutesGET/PUT/DELETE /spice(mounted after audiences inroutes/index.ts), returning aShowSpicysummary (titleKey, mediaType, mild, mature, adult, mySpicy). InteractiveSpicyControl("Spicy? 🌶️", 3 pepper buttons, tap-again-to-clear) sits underAudienceControlon the view-entry page; read-onlySpicySummaryrenders the rounded-average pepper level + total rating count (only when total>0) on library + watchlist cards. HookuseSpiceMap/spiceKeymirrorsuseAudienceMap. Note spice is single-select while audiences is multi-select — they share the scoping contract but not the answer shape. Thespicycolumn is freetext(no DB enum); the server validates against the canonical set, so legacyyes/norows from the old binary version are simply ignored in tallies (users re-rate).
show_commentstable (id, userId, authorNamesnapshot,titleKey, mediaType, parentIdnullable self-FK withonDelete cascade,body, createdAt, index on(titleKey, mediaType)). Same "same-show" identity as audiences/spice (titleKey= normalizedlower(trim(title))+mediaType) and the SAME group-scoping contract (resolveMemberIds(callerId, groupId)→[callerId]with no group, all member ids if member,null→ 403; check runs BEFORE any insert). RoutesGET/POST /comments(mounted after spice inroutes/index.ts).GETreturns the FLAT list of comments authored by the resolved member set for a show, oldest first (clients nest byparentId); without agroupIdonly the caller's own comments.POSTrequires membership, resolvesauthorNameviaresolveDisplayName(like entryaddedBy), and for a reply validates theparentIdexists, is on the SAME show, AND is authored by a member of the resolved set (else 400). The created comment is returned with201and parsed withListCommentsResponseItem(Orval emits no zodResponseschema for 201s — see memory). FrontendCommentThread(title,mediaType, activegroupId) renders under the Thoughts block on the view-entry page: a top-level composer plus per-comment inline Reply, nesting replies recursively (border-lindent); styled likeAudienceControl/SpicyControl(rounded-2xl border border-border bg-card). No edit/delete (out of scope).
GET /activity/feed(inroutes/activity.ts, paramsgroupId+limitdefault 50/max 100) returns a reverse-chronological combined feed of member actions across FIVE existing sources (no new table): entries→rating, watchlist_items→watchlist, show_comments→comment, show_audiences→audience, show_spice→spice. Same group-scoping contract as the polls (localresolveMemberIds(callerId, groupId)→[callerId]with no group, all member ids if member,null→403; 403 runs before any source query). Comment/audience/spice rows store only normalizedtitleKey, so the handler buildstitleByKey+entryIdByKeymaps from the members' entries (orderedasc(createdAt)so newest entry/title wins) with watchlist backfilling titles only; each non-entry event inherits that display title + a linkableentryId(null if no entry exists for the show). Audience/spice rows store onlyuserId→ actor name resolved via thegroup_members.displayNamemap (withresolveDisplayNamefallback for the no-group caller). EachActivityItemhas a composite stringid("rating:123"etc.),type,actorName,title,mediaType,createdAt, plus type-specificentryId/rating/audiences/spicy(all nullable;audiencesis anAudience[]). Frontend/activitypage (pages/activity.tsx, linked from the home header next to My Watchlist) always passes the activegroupId, renders title as a/entry/:idLink only whenentryId != null, showsStarRatingfor ratings, renders audience events as "suggested {title} for The Girls, Couples", and usesformatDistanceToNowfor relative time.
user_activitytable stores per-userlastSeenAt.POST /activity/check-inreads the priorlastSeenAt, counts entries created since then by OTHER users (excludes the caller's own), updateslastSeenAtto now, and returns{ newCount, since }. The web client calls it once on library mount and shows a dismissible badge whennewCount > 0. First visit returns 0 (no prior timestamp).
top_fourtable (id, userId, position int, title, mediaType, updatedAt) withuniqueIndex(userId, position). Per-user, up to 4 ordered all-time-favorite picks. Titles are FREE-TEXT (NOT tied to loggedentries— notitleKey/FK);mediaTypeis movie|tv. RoutesGET/PUT /top-four(inroutes/topFour.ts, mounted after comments).GET /top-four?userId=Xreturns the target's picks ordered byposition(caller defaults to self; viewing another requiresusersShareGroup, else 403 — same pattern asGET /watchlist?userId=).PUT /top-fourbody{ picks: [{title, mediaType}…] }(maxItems 4) REPLACES the caller's whole set atomically (delete-all + insert in a transaction), trimming titles, dropping blanks, capping at 4, and reindexingposition0..n by array order; nouserIdin body (always the caller). FrontendTopFourSection(userId,isSelf,displayName) renders 4 poster-style cards at the top ofpages/member.tsx; whenisSelfit shows an Edit button opening a Dialog (4 title inputs + movie/tv toggle each). Self reaches their own profile becausegroup.tsxnow links EVERY member row (incl. self, kept "(you)") to/member/:userId. The section hides entirely for other members with no picks.
liststable (id, ownerId, ownerNamesnapshot,name, descriptionnullable,createdAt) +list_items(id, listIdFKonDelete cascade,titleFREE-TEXT,mediaTypemovie|tv,addedAt, index onlistId). Lists are PERSONAL (nogroupIdcolumn) — visibility is by SHARED GROUP, mirroring entries/watchlist. RoutesGET/POST /lists,GET/PATCH/DELETE /lists/{id},POST /lists/{id}/items,DELETE /lists/{id}/items/{itemId}(inroutes/lists.ts, mounted after topFour).GET /listsis group-scoped via localresolveMemberIds(callerId, groupId)(same contract as polls: no group → caller-only, member → all member ids, non-membergroupId→ 403) and returnsListSummary[](each withitemCount).GET /lists/{id}returnsListDetail(withitems) and is readable by anyone whousersShareGroup(caller, owner)(else 403/404); ALL mutations (PATCH/DELETE list, add/remove item) are OWNER-ONLY (403 otherwise).ownerNameis snapshotted viaresolveDisplayNameon create (like entryaddedBy). Orval gotcha:POST /lists(201) has no zodResponseexport — create parses withListListsResponseItem; item add returns plain json. Frontend:/listsbrowse page (pages/lists.tsx, cards show name/desc/itemCount/creator + create dialog) and/lists/:iddetail (pages/list-detail.tsx, owner sees add-item row + per-item remove + edit/delete dialogs, gated onuser.id === list.ownerId). Both always pass the activegroupId. Nav Link inhome.tsxheader next to Activity/My Watchlist.
invitestable is ONE reusable invite PER GROUP (unique(groupId), high-entropy randomtokenviarandomBytes(16).base64url, pluscreatedBy/createdByName); there is NOinvite_acceptancestable — joining writes agroup_membersrow directly.POST /groups/{id}/invite(auth, caller must be a member) is create-or-get for that group's invite;GET /invites/:tokenis PUBLIC (landing page preview) and returns{ token, groupName, inviterName, valid }(valid=false for unknown tokens);POST /invites/:token/accept(auth) idempotently adds the caller to the group and returns{ joined, groupId, groupName }(already-a-member is a no-op withjoined=false). Web flow:InviteDialog(header) generates the active group's link viauseCreateOrGetGroupInvite;/invite/:tokenstores the token inlocalStorageand shows sign-up/sign-in CTAs; a globalInviteAccepter(reactive onuseUser+ location) calls accept once a pending token + signed-in user coincide, then sets that group as active and invalidates the groups query.
- The entry title is no longer free-text — users search TMDB and pick a result.
entriesgained nullable columnstmdbId,posterPath,streamingProvider,streamingLogo,network(all nullable, so old rows are fine). Server-side TMDB proxy keepsTMDB_API_KEYoff the client:GET /tmdb/search?query=(search/multi, movies+tv only) →TmdbSearchResult[]andGET /tmdb/details?tmdbId=&mediaType=→TmdbDetails {streamingProvider, streamingLogo, network}(USwatch/providersflatrate first by display_priority; for tv also the firstnetworks[]name). Both live inroutes/tmdb.ts(ownrequireAuth, mounted after entries) usinglib/tmdb.ts; details is best-effort (TMDB failure → nulls, search failure → 502). FrontendShowSearchField(debounced search dropdown with poster/title/year; on pick fetches details andsetValues title/mediaType/tmdb fields) replaces the title input AND the movie/tv radio inEntryForm(mediaType now derived from the pick). EntryInput/EntryUpdate carry the optional (nullable) tmdb fields; create/update persist them. Read-onlyStreamingBadge(provider logo+name viaimage.tmdb.org/t/p/w92, else network name) shows on library cards (home) + the view-entry page.
reactionstable (id, userId, targetType(entry|comment),targetId, emoji, createdAt) withunique(userId, targetType, targetId, emoji)+ index(targetType, targetId). Reactions attach to a numeric row id (an entry'sidor a comment'sid), NOT thetitleKeyidentity used by polls/comments. Canonical emoji set + display order isEMOJI_ORDER= 👍 👎 (mirrored on the client inreaction-bar.tsx). The set was reduced from the original 👍 ❤️ 😂 😮 🔥; theemojicolumn is plaintext(no DB enum), so legacy rows with the retired emojis are simply filtered out of summaries (summarizekeeps onlyEMOJI_ORDERvalues) rather than migrated. RoutesGET/POST /reactions(routes/reactions.ts, ownrequireAuth, mounted after lists). Same group-scoping contract as the polls (localresolveMemberIds(callerId, groupId)→[callerId]no group, all member ids if member,null→403; check runs BEFORE any mutation).GETreturns oneReactionSummaryper target that any member reacted to:{targetType, targetId, emojis: [{emoji,count}] (only non-zero, in EMOJI_ORDER), mine: [emoji]}.POSTTOGGLES the caller's(targetType,targetId,emoji)(delete if present, else insertonConflictDoNothing) and returns the fresh summary; it first validates the target EXISTS and is authored by a member of the resolved set (else 400 "Invalid target") so you can only react to content you can see. Frontend: hookuseReactionMap/reactionKey(keyed${targetType}:${targetId}, mirrorsuseAudienceMap),ReactionBar(emoji pill buttons w/ counts, tap-to-toggle,minehighlighted, optimistic viaqueryClient.invalidateQueries(["/api/reactions"])); wired onto home library cards + the view-entry page (targetType:"entry") and each comment incomment-thread.tsx(targetType:"comment").
GET /activity/digest(inroutes/activity.ts,groupIdparam, sameresolveMemberIds403 contract) returns aWeeklyDigestfor the last 7 days across the resolved member set:{since, newRatings, newComments, newSaves, topShow, mostActive}. Counts come fromentries/show_comments/watchlist_itemsrows withcreatedAt > since.topShow= the week's highest-rated new entry (order by rating desc, createdAt desc limit 1) →{title, mediaType, rating, addedBy, entryId}(nullable).mostActive= the member with the most total actions (ratings+comments+saves), tallied by the snapshot names already on those rows (addedBy/authorName, no display-name resolution) →{name, count}(nullable). FrontendWeeklyDigestCardon home (under the new-activity badge) is dismissible per ISO-week vialocalStoragekeydigest-dismissed-{groupId|self}-{isoWeek}, and hides entirely when all three counts are 0. NOTE: email/push delivery of this digest is a deferred follow-up (needs an email integration + scheduler).