Skip to content

Latest commit

 

History

History
59 lines (32 loc) · 17.2 KB

features.md

File metadata and controls

59 lines (32 loc) · 17.2 KB

Features

Per-feature decisions. Shared concepts (groups, privacy/scoping, the non-member groupId 403 contract, resolveMemberIds) live in architecture.md.

Display name (nickname)

  • Every user has ONE global display name. user_profiles (userId PK, displayName notNull, timestamps) is the single source of truth. resolveDisplayName(userId) reads profile → Clerk firstName → "Member" — it NEVER returns an email/username/last name (emails must stay private; the old Clerk-derived addedBy fallback in watchlist.ts that could leak an email was removed). All name snapshots are populated via resolveDisplayName on 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 after groupsRouter, before entriesRouter; has its own router.use(requireAuth)): GET /profile → {displayName: string|null} (null = not set yet); PUT /profile trims, 400 on blank, enforces per-group case-insensitive uniqueness across the caller's ACTIVE groups (conflictingGroupName via lower()), 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 (in App.tsx) runs BEFORE GroupGate for any signed-in user; if GET /profile returns no name it renders the forced, non-dismissible SetDisplayNameScreen (prefilled with Clerk firstName). DisplayNameForm/DisplayNameDialog (components/display-name-dialog.tsx) handle settings edits (from UserMenu, which now shows the profile name, never email) and invite name-conflict resolution (InviteAccepter catches the 409, prompts, retries). Saving calls qc.invalidateQueries() (refetch all) so denormalized names refresh everywhere. InviteAccepter also waits for profile.displayName before 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 legacy group_members rows 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.

Personal watchlist

  • watchlist_items table (per-user saved shows) with unique(userId, titleKey, mediaType). A "show" has no canonical entity — the same show across users is matched by titleKey (normalized lower(trim(title))) + mediaType. GET /watchlist returns the caller's saved items each annotated with alsoEngagedBy: display names of OTHER users who have rated (have an entries row) OR saved that same show, deduped by userId across both sources and caller-excluded. POST /watchlist is an idempotent upsert; DELETE /watchlist/:id is ownership-scoped (id + userId). Save/Saved toggle lives on each library card; /watchlist is the personal page.

Who-should-watch poll

  • show_audiences table (per-user picks per show) with unique(userId, titleKey, mediaType) and an audiences text[] column holding any of the canonical Audience values (girls,guys,couples,solo). It is MULTI-SELECT: a member may pick one or many. Same "same-show" identity as watchlist (titleKey = normalized lower(trim(title)) + mediaType). GET /audiences fetches 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 as myAudiences. PUT /audiences upserts 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 fresh ShowAudience summary (titleKey, mediaType, girls, guys, couples, solo, myAudiences). Interactive AudienceControl ("Who Should Watch?", neutral Eye icon, multi-select toggle buttons The Girls / The Guys / Couples / Solo, tap to add/remove) lives on the view-entry page; read-only AudienceSummary (e.g. "3 Couples / 2 The Girls / 1 Solo", count-desc, non-zero only) shows on library cards and the watchlist page. Hook useAudienceMap/audienceKey.

Spicy flag

  • show_spice table — a single-answer SIBLING of the audience poll, spicy in (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 via resolveMemberIds, non-member groupId → 403). Routes GET/PUT/DELETE /spice (mounted after audiences in routes/index.ts), returning a ShowSpicy summary (titleKey, mediaType, mild, mature, adult, mySpicy). Interactive SpicyControl ("Spicy? 🌶️", 3 pepper buttons, tap-again-to-clear) sits under AudienceControl on the view-entry page; read-only SpicySummary renders the rounded-average pepper level + total rating count (only when total>0) on library + watchlist cards. Hook useSpiceMap/spiceKey mirrors useAudienceMap. Note spice is single-select while audiences is multi-select — they share the scoping contract but not the answer shape. The spicy column is free text (no DB enum); the server validates against the canonical set, so legacy yes/no rows from the old binary version are simply ignored in tallies (users re-rate).

Threaded per-show discussion

  • show_comments table (id, userId, authorName snapshot, titleKey, mediaType, parentId nullable self-FK with onDelete cascade, body, createdAt, index on (titleKey, mediaType)). Same "same-show" identity as audiences/spice (titleKey = normalized lower(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). Routes GET/POST /comments (mounted after spice in routes/index.ts). GET returns the FLAT list of comments authored by the resolved member set for a show, oldest first (clients nest by parentId); without a groupId only the caller's own comments. POST requires membership, resolves authorName via resolveDisplayName (like entry addedBy), and for a reply validates the parentId exists, is on the SAME show, AND is authored by a member of the resolved set (else 400). The created comment is returned with 201 and parsed with ListCommentsResponseItem (Orval emits no zod Response schema for 201s — see memory). Frontend CommentThread (title, mediaType, active groupId) renders under the Thoughts block on the view-entry page: a top-level composer plus per-comment inline Reply, nesting replies recursively (border-l indent); styled like AudienceControl/SpicyControl (rounded-2xl border border-border bg-card). No edit/delete (out of scope).

Activity feed

  • GET /activity/feed (in routes/activity.ts, params groupId+limit default 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 (local resolveMemberIds(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 normalized titleKey, so the handler builds titleByKey+entryIdByKey maps from the members' entries (ordered asc(createdAt) so newest entry/title wins) with watchlist backfilling titles only; each non-entry event inherits that display title + a linkable entryId (null if no entry exists for the show). Audience/spice rows store only userId → actor name resolved via the group_members.displayName map (with resolveDisplayName fallback for the no-group caller). Each ActivityItem has a composite string id ("rating:123" etc.), type, actorName, title, mediaType, createdAt, plus type-specific entryId/rating/audiences/spicy (all nullable; audiences is an Audience[]). Frontend /activity page (pages/activity.tsx, linked from the home header next to My Watchlist) always passes the active groupId, renders title as a /entry/:id Link only when entryId != null, shows StarRating for ratings, renders audience events as "suggested {title} for The Girls, Couples", and uses formatDistanceToNow for relative time.

New-activity badge

  • user_activity table stores per-user lastSeenAt. POST /activity/check-in reads the prior lastSeenAt, counts entries created since then by OTHER users (excludes the caller's own), updates lastSeenAt to now, and returns { newCount, since }. The web client calls it once on library mount and shows a dismissible badge when newCount > 0. First visit returns 0 (no prior timestamp).

Top Four favorites

  • top_four table (id, userId, position int, title, mediaType, updatedAt) with uniqueIndex(userId, position). Per-user, up to 4 ordered all-time-favorite picks. Titles are FREE-TEXT (NOT tied to logged entries — no titleKey/FK); mediaType is movie|tv. Routes GET/PUT /top-four (in routes/topFour.ts, mounted after comments). GET /top-four?userId=X returns the target's picks ordered by position (caller defaults to self; viewing another requires usersShareGroup, else 403 — same pattern as GET /watchlist?userId=). PUT /top-four body { 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 reindexing position 0..n by array order; no userId in body (always the caller). Frontend TopFourSection (userId, isSelf, displayName) renders 4 poster-style cards at the top of pages/member.tsx; when isSelf it shows an Edit button opening a Dialog (4 title inputs + movie/tv toggle each). Self reaches their own profile because group.tsx now links EVERY member row (incl. self, kept "(you)") to /member/:userId. The section hides entirely for other members with no picks.

Lists (group-browsable, owner-edited)

  • lists table (id, ownerId, ownerName snapshot, name, description nullable, createdAt) + list_items (id, listId FK onDelete cascade, title FREE-TEXT, mediaType movie|tv, addedAt, index on listId). Lists are PERSONAL (no groupId column) — visibility is by SHARED GROUP, mirroring entries/watchlist. Routes GET/POST /lists, GET/PATCH/DELETE /lists/{id}, POST /lists/{id}/items, DELETE /lists/{id}/items/{itemId} (in routes/lists.ts, mounted after topFour). GET /lists is group-scoped via local resolveMemberIds(callerId, groupId) (same contract as polls: no group → caller-only, member → all member ids, non-member groupId → 403) and returns ListSummary[] (each with itemCount). GET /lists/{id} returns ListDetail (with items) and is readable by anyone who usersShareGroup(caller, owner) (else 403/404); ALL mutations (PATCH/DELETE list, add/remove item) are OWNER-ONLY (403 otherwise). ownerName is snapshotted via resolveDisplayName on create (like entry addedBy). Orval gotcha: POST /lists (201) has no zod Response export — create parses with ListListsResponseItem; item add returns plain json. Frontend: /lists browse page (pages/lists.tsx, cards show name/desc/itemCount/creator + create dialog) and /lists/:id detail (pages/list-detail.tsx, owner sees add-item row + per-item remove + edit/delete dialogs, gated on user.id === list.ownerId). Both always pass the active groupId. Nav Link in home.tsx header next to Activity/My Watchlist.

Invite links (group-scoped)

  • invites table is ONE reusable invite PER GROUP (unique(groupId), high-entropy random token via randomBytes(16).base64url, plus createdBy/createdByName); there is NO invite_acceptances table — joining writes a group_members row directly. POST /groups/{id}/invite (auth, caller must be a member) is create-or-get for that group's invite; GET /invites/:token is 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 with joined=false). Web flow: InviteDialog (header) generates the active group's link via useCreateOrGetGroupInvite; /invite/:token stores the token in localStorage and shows sign-up/sign-in CTAs; a global InviteAccepter (reactive on useUser + location) calls accept once a pending token + signed-in user coincide, then sets that group as active and invalidates the groups query.

TMDB-backed "Add a Show"

  • The entry title is no longer free-text — users search TMDB and pick a result. entries gained nullable columns tmdbId, posterPath, streamingProvider, streamingLogo, network (all nullable, so old rows are fine). Server-side TMDB proxy keeps TMDB_API_KEY off the client: GET /tmdb/search?query= (search/multi, movies+tv only) → TmdbSearchResult[] and GET /tmdb/details?tmdbId=&mediaType= → TmdbDetails {streamingProvider, streamingLogo, network} (US watch/providers flatrate first by display_priority; for tv also the first networks[] name). Both live in routes/tmdb.ts (own requireAuth, mounted after entries) using lib/tmdb.ts; details is best-effort (TMDB failure → nulls, search failure → 502). Frontend ShowSearchField (debounced search dropdown with poster/title/year; on pick fetches details and setValues title/mediaType/tmdb fields) replaces the title input AND the movie/tv radio in EntryForm (mediaType now derived from the pick). EntryInput/EntryUpdate carry the optional (nullable) tmdb fields; create/update persist them. Read-only StreamingBadge (provider logo+name via image.tmdb.org/t/p/w92, else network name) shows on library cards (home) + the view-entry page.

Emoji reactions (group-scoped)

  • reactions table (id, userId, targetType (entry|comment), targetId, emoji, createdAt) with unique(userId, targetType, targetId, emoji) + index (targetType, targetId). Reactions attach to a numeric row id (an entry's id or a comment's id), NOT the titleKey identity used by polls/comments. Canonical emoji set + display order is EMOJI_ORDER = 👍 👎 (mirrored on the client in reaction-bar.tsx). The set was reduced from the original 👍 ❤️ 😂 😮 🔥; the emoji column is plain text (no DB enum), so legacy rows with the retired emojis are simply filtered out of summaries (summarize keeps only EMOJI_ORDER values) rather than migrated. Routes GET/POST /reactions (routes/reactions.ts, own requireAuth, mounted after lists). Same group-scoping contract as the polls (local resolveMemberIds(callerId, groupId) → [callerId] no group, all member ids if member, null→403; check runs BEFORE any mutation). GET returns one ReactionSummary per target that any member reacted to: {targetType, targetId, emojis: [{emoji,count}] (only non-zero, in EMOJI_ORDER), mine: [emoji]}. POST TOGGLES the caller's (targetType,targetId,emoji) (delete if present, else insert onConflictDoNothing) 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: hook useReactionMap/reactionKey (keyed ${targetType}:${targetId}, mirrors useAudienceMap), ReactionBar (emoji pill buttons w/ counts, tap-to-toggle, mine highlighted, optimistic via queryClient.invalidateQueries(["/api/reactions"])); wired onto home library cards + the view-entry page (targetType:"entry") and each comment in comment-thread.tsx (targetType:"comment").

In-app weekly digest

  • GET /activity/digest (in routes/activity.ts, groupId param, same resolveMemberIds 403 contract) returns a WeeklyDigest for the last 7 days across the resolved member set: {since, newRatings, newComments, newSaves, topShow, mostActive}. Counts come from entries/show_comments/watchlist_items rows with createdAt > 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). Frontend WeeklyDigestCard on home (under the new-activity badge) is dismissible per ISO-week via localStorage key digest-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).