Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
-- THE FEED REMEMBERS WHERE YOU STOPPED.
--
-- The owner's report was about the rail: "when someone recieves messaes it is
-- notifies in notification bell but no no.of messages appear next to messages
-- in the sidepanel. fix this also applies to community feed, approvals, etc".
--
-- Messages and approvals were answerable from tables that already existed —
-- `Delivery.readAt` for one, the approval window for the other. The community
-- feed was not, and that is the whole reason it shipped without a count while
-- its two neighbours got theirs. `FeedPost` records who WROTE a post and when;
-- nothing anywhere recorded who had READ one.
--
-- A count can be invented without this table. "Posted in the last seven days"
-- is one line of code and looks identical on screen — and it is a guess: it
-- keeps counting things you read an hour ago, and it stops counting the post
-- you have never seen the moment it turns eight days old. A badge that is
-- sometimes wrong in both directions is worse than no badge, because people
-- act on it.
--
-- ── WHY ONE INSTANT, NOT ONE ROW PER POST ──────────────────────────────────
--
-- A feed is read by SCROLLING. Nobody opens each item, so "everything up to
-- here" is the only thing a reader actually did, and it is exactly what a
-- watermark records. The per-post alternative grows by (people x posts) to
-- answer a question — "did this specific person read this specific post?" —
-- that no surface in Tenure asks.
--
-- Keyed [userId, institutionId] rather than by user alone because a person can
-- hold seats in more than one institution's workspace, and the feeds are
-- separate. One watermark per feed they can see.
--
-- ── WHAT A MISSING ROW MEANS ───────────────────────────────────────────────
--
-- That the person has never opened the feed. It is NOT read as "everything is
-- unread": the count falls back to the reader's own `User.createdAt`, so
-- somebody who joins today is not met with four years of a club's history in a
-- red badge. Slack and Teams both do this, and for the same reason — a badge
-- that says 900 on first login is a badge people learn to ignore, which costs
-- the two that actually matter.
--
-- ── DEPLOY SHAPE ───────────────────────────────────────────────────────────
--
-- A new table with no backfill and no column added to an existing one. Nothing
-- reads it until the code that writes it ships, and the fallback above means
-- the feature is correct on an empty table from the first request. There is no
-- ordering hazard between this migration and the deploy in either direction.

-- CreateTable
CREATE TABLE "FeedVisit" (
"userId" TEXT NOT NULL,
"institutionId" TEXT NOT NULL,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "FeedVisit_pkey" PRIMARY KEY ("userId","institutionId")
);

-- The referencing side of a foreign key is not indexed for you, and deleting
-- an institution cascades here. The primary key already covers `userId` as its
-- leading column, so only this one is needed.
-- CreateIndex
CREATE INDEX "FeedVisit_institutionId_idx" ON "FeedVisit"("institutionId");

-- AddForeignKey
ALTER TABLE "FeedVisit" ADD CONSTRAINT "FeedVisit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "FeedVisit" ADD CONSTRAINT "FeedVisit_institutionId_fkey" FOREIGN KEY ("institutionId") REFERENCES "Institution"("id") ON DELETE CASCADE ON UPDATE CASCADE;
31 changes: 31 additions & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ model Institution {
serviceNotices ServiceNotice[]
signatureMarks SignatureMark[]
analyticsEvents ProductAnalyticsEvent[]
feedVisits FeedVisit[]
}

enum InstitutionRole {
Expand Down Expand Up @@ -191,6 +192,7 @@ model User {
/// Reminders this person has been sent about approvals still waiting on
/// them. One row per (approval, person, day) — see `ApprovalReminder`.
approvalReminders ApprovalReminder[]
feedVisits FeedVisit[]
}

// ─── Organizations & Role Seats ───────────────────────────────────────────────
Expand Down Expand Up @@ -1403,6 +1405,35 @@ enum NotificationChannel {
// Clubs post collaboration calls; other clubs raise interest; the OSE
// Director sits in the middle and approves each collaboration.

/// WHEN A PERSON LAST LOOKED AT THE COMMUNITY FEED.
///
/// The feed had no per-user read state at all, which is why its nav entry was
/// the one with no count while messages and approvals had theirs. A number
/// derived from "posted this week" would have been a guess dressed as a fact.
///
/// One row per person per institution, holding one instant. Deliberately NOT a
/// per-POST read table: a feed is read by scrolling, not by opening each item,
/// so "everything up to here" is what a reader actually did — and a per-post
/// table would grow by (people x posts) to answer a question nobody asks about
/// an individual post.
///
/// A person with no row has never opened the feed. That is NOT treated as
/// "everything is unread": the count falls back to their own `createdAt`, so
/// somebody who joins today is not greeted by four years of a club's history.
/// It is the same thing Slack and Teams do, and for the same reason — a badge
/// showing 900 is a badge people learn to ignore.
model FeedVisit {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
institutionId String
institution Institution @relation(fields: [institutionId], references: [id], onDelete: Cascade)
/// The moment the feed was last rendered for this person.
lastSeenAt DateTime @default(now())

@@id([userId, institutionId])
@@index([institutionId])
}

model FeedPost {
id String @id @default(cuid())
institutionId String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ jest.mock("@/lib/db", () => ({
? [{ institutionId: INSTITUTION }]
: [{ id: "org_swib", name: "Simon Women in Business" }],
},
// The read-marker behind the rail's "new" badge. Present in the fake
// because the page writes it on every render — and because leaving it out
// is how this suite caught the write being able to take the page down.
feedVisit: { upsert: async () => ({}) },
feedPost: {
findMany: async () => [
{
Expand Down
83 changes: 66 additions & 17 deletions apps/web/src/app/(app)/feed/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from "next/link"
import { feedInstitutionIds } from "@/lib/feed/audience"
import { redirect } from "next/navigation"
import { CalendarDays, Handshake, MessageCircle, Newspaper } from "@/components/ui/icons"
import { auth } from "@/lib/auth"
Expand Down Expand Up @@ -45,23 +46,10 @@ export default async function FeedPage() {
.filter((r) => r.status === "ACTIVE")
.map((r) => r.organizationId)

// The whole institution sees the community feed
const institutionIds = oseInstitutionIds.length
? oseInstitutionIds
: [
...new Set(
(
await db.organization.findMany({
where: {
id: {
in: ctx.orgRoles.map((r) => r.organizationId),
},
},
select: { institutionId: true },
})
).map((o) => o.institutionId)
),
]
// The whole institution sees the community feed. The rule lives in
// `lib/feed/audience.ts` because the sidebar's unread badge has to count
// exactly what this page will show — see the note there.
const institutionIds = await feedInstitutionIds(ctx)

/*
* The three reads this page can start at once.
Expand All @@ -79,6 +67,23 @@ export default async function FeedPage() {
* statement is `await getUserContext(userId)`, and that call is `cache()`d,
* so it already awaits the very promise it would be racing.
*/
/*
* THE CUTOFF IS TAKEN BEFORE THE READ, AND SAVED AFTER IT.
*
* It used to be stamped after `findMany` returned, which opens a window: a
* post created between the query and the stamp is not in the response — so
* it is never rendered — and its `createdAt` is before `lastSeenAt`, so the
* badge excludes it too. That post becomes invisible in both places, for
* good, and nothing anywhere reports it.
*
* Taking the instant first closes the window in the safe direction. A post
* arriving during the read is now AFTER the watermark, so it stays unread
* and shows up in the next badge — the reader sees it one visit late rather
* than never. The write still happens after the read succeeds, so a failed
* read still marks nothing as seen.
*/
const seenAt = new Date()

const [posts, myClubs, myEvents] = await Promise.all([
db.feedPost.findMany({
where: { institutionId: { in: institutionIds }, isArchived: false },
Expand Down Expand Up @@ -117,6 +122,50 @@ export default async function FeedPage() {
: [],
])

/*
* OPENING THE FEED IS READING IT.
*
* The watermark behind the rail's "new" badge. Written AFTER the posts are
* loaded, so a read that failed does not mark anything seen — and written
* per institution, because the feeds are separate and so are the counts.
*
* `updatedAt`-style semantics on purpose: the row moves forward to now on
* every visit, so a second look at the same feed clears what arrived
* between the two.
*
* NOT awaited into the render path's critical section on its own — it is
* fire-and-forget against the reader's own row, and a failure here must not
* fail the page. The worst case is a badge that stays up until the next
* visit, which is exactly the failure a reader can recover from themselves.
*
* `seenAt` was captured BEFORE the posts were read — see the note there.
*/
try {
await Promise.all(
institutionIds.map((institutionId) =>
db.feedVisit.upsert({
where: { userId_institutionId: { userId, institutionId } },
create: { userId, institutionId, lastSeenAt: seenAt },
update: { lastSeenAt: seenAt },
})
)
)
} catch {
/*
* A TRY/CATCH, not `.catch()` on the promise.
*
* The first version chained `.catch(() => null)`, which covers a REJECTED
* upsert and not a synchronous throw on the way to calling it — and the
* property access is the part most likely to fail, as the page's own test
* demonstrated by reaching `db.feedVisit` before it existed.
*
* A page whose entire job is to show a feed must not fail to render
* because a read-marker could not be written. The worst case here is a
* badge that stays up until the next visit, which is a thing the reader
* clears themselves by doing what they were already doing.
*/
}

// Resolve author names in one pass
const authorIds = [
...new Set([
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/shell/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
module: "feed",
capability: null,
workspaces: ALL_WORKSPACES,
attention: { key: "feed", noun: "new" },
},
{
label: "Messages",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,18 @@ describe("which entries carry a badge", () => {
})
})

it("does NOT badge the community feed", () => {
// `FeedPost` has no per-user read state — no seenAt, no visit marker. A
// count derived from "posted recently" would look identical to a real one
// and be a guess. It needs a migration and it gets its own change.
expect(ENTRY("Community feed").attention).toBeUndefined()
it("badges the community feed with what has arrived since you last looked", () => {
/*
* This assertion used to be `toBeUndefined()`, and its comment said why:
* "`FeedPost` has no per-user read state — no seenAt, no visit marker. A
* count derived from 'posted recently' would look identical to a real one
* and be a guess. It needs a migration and it gets its own change."
*
* That migration is `20260826120000_the_feed_remembers_where_you_stopped`,
* and `FeedVisit` is the visit marker. The reason for the absence is gone,
* so the absence goes with it.
*/
expect(ENTRY("Community feed").attention).toEqual({ key: "feed", noun: "new" })
})

it("gives every badge a figure that exists", () => {
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/lib/feed/audience.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import "server-only"
import { db } from "@/lib/db"
import type { UserContext } from "@/lib/rbac"

/**
* WHOSE FEED THIS PERSON SEES.
*
* ── Why this is a function and not two copies of a rule ────────────────────
*
* The community feed is institution-wide: an OSE role names its institutions
* directly, and everybody else reaches theirs through the clubs they hold a
* seat in. That is four lines, and four lines is exactly the size of thing that
* gets written twice and then drifts.
*
* It now has two callers — the page that RENDERS the feed and the badge that
* COUNTS what is new on it. A badge counting rows the page would not show is a
* badge for a page that looks empty, and the two disagreeing is not a failure
* anything reports: both numbers are plausible.
*
* The same reason `rosterStanding` was lifted out of a route handler earlier
* today. A rule with one call site is a rule; a rule with two call sites and no
* name is a coincidence.
*/
export async function feedInstitutionIds(ctx: UserContext): Promise<string[]> {
const oseInstitutionIds = ctx.institutionRoles.map((m) => m.institutionId)
if (oseInstitutionIds.length) return oseInstitutionIds

const orgs = await db.organization.findMany({
where: { id: { in: ctx.orgRoles.map((r) => r.organizationId) } },
select: { institutionId: true },
})
return [...new Set(orgs.map((o) => o.institutionId))]
}
Loading
Loading