diff --git a/app/(ui)/deck/[id]/history/page.tsx b/app/(ui)/deck/[id]/history/page.tsx index b90db39..aec0d11 100644 --- a/app/(ui)/deck/[id]/history/page.tsx +++ b/app/(ui)/deck/[id]/history/page.tsx @@ -9,9 +9,16 @@ import { DeckHistoryList } from "@/app/_components/deck/deck-history-list"; interface DeckHistoryPageProps { params: Promise<{ id: string }>; + searchParams: Promise<{ revision?: string }>; } -async function DeckHistoryContent({ id }: { id: string }) { +async function DeckHistoryContent({ + id, + highlightId, +}: { + id: string; + highlightId?: string | undefined; +}) { const [deck, session] = await Promise.all([ prisma.deck.findUnique({ where: { id }, @@ -40,8 +47,8 @@ async function DeckHistoryContent({ id }: { id: string }) { {deck.name} · History

- Changes to this deck, newest first. Edits within 5 minutes of each - other are grouped into a single revision. + Changes to this deck, newest first. Edits by the same player within + 5 minutes are grouped into a single revision.

@@ -49,12 +56,16 @@ async function DeckHistoryContent({ id }: { id: string }) { deckId={deck.id} revisions={revisions} isOwner={isOwner} + highlightId={highlightId} /> ); } -export default function DeckHistoryPage({ params }: DeckHistoryPageProps) { +export default function DeckHistoryPage({ + params, + searchParams, +}: DeckHistoryPageProps) { return (
} > - +
); @@ -73,9 +84,11 @@ export default function DeckHistoryPage({ params }: DeckHistoryPageProps) { async function HistoryLoader({ params, + searchParams, }: { params: Promise<{ id: string }>; + searchParams: Promise<{ revision?: string }>; }) { - const { id } = await params; - return ; + const [{ id }, { revision }] = await Promise.all([params, searchParams]); + return ; } diff --git a/app/_components/deck/deck-history-list.tsx b/app/_components/deck/deck-history-list.tsx index ab297ad..e8abad7 100644 --- a/app/_components/deck/deck-history-list.tsx +++ b/app/_components/deck/deck-history-list.tsx @@ -1,34 +1,26 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Undo2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { revertDeckRevision } from "@/app/_actions/deck/revisions"; import type { RevisionView } from "@/app/_actions/deck/revisions"; -import { deltaKey } from "@/lib/deck/revision"; -import { groupDeltasByZone } from "@/lib/deck/group-deltas"; -import type { Zone } from "@/lib/generated/prisma/enums"; +import { RevisionDiff } from "@/app/_components/deck/revision-diff"; interface DeckHistoryListProps { deckId: string; revisions: RevisionView[]; isOwner: boolean; + highlightId?: string | undefined; } -const ZONE_LABEL: Record = { - MAINBOARD: "Mainboard", - SIDEBOARD: "Sideboard", - CONSIDERING: "Considering", - COMMANDER: "Commander", - COMPANION: "Companion", -}; - export function DeckHistoryList({ deckId, revisions, isOwner, + highlightId, }: DeckHistoryListProps) { if (revisions.length === 0) { return ( @@ -46,6 +38,7 @@ export function DeckHistoryList({ deckId={deckId} revision={r} isOwner={isOwner} + isHighlighted={r.id === highlightId} /> ))} @@ -56,19 +49,24 @@ function RevisionCard({ deckId, revision, isOwner, + isHighlighted, }: { deckId: string; revision: RevisionView; isOwner: boolean; + isHighlighted: boolean; }) { + const cardRef = useRef(null); + + useEffect(() => { + if (!isHighlighted) return; + cardRef.current?.scrollIntoView({ block: "center", behavior: "smooth" }); + }, [isHighlighted]); + const updatedAt = useMemo( () => new Date(revision.updatedAt), [revision.updatedAt], ); - const grouped = useMemo( - () => groupDeltasByZone(revision.changes), - [revision.changes], - ); const [selected, setSelected] = useState>(new Set()); const toggle = (key: string, checked: boolean) => { @@ -83,12 +81,20 @@ function RevisionCard({ const clearSelection = () => setSelected(new Set()); return ( -
  • +
  • -
    - {grouped.map(({ zone, deltas }) => ( -
    - {grouped.length > 1 && ( -
    - {ZONE_LABEL[zone]} -
    - )} -
      - {deltas.map((d) => { - const key = deltaKey(d); - return ( -
    • - {isOwner && ( - toggle(key, checked)} - /> - )} - 0 - ? "text-emerald-600 dark:text-emerald-400 font-medium" - : "text-red-600 dark:text-red-400 font-medium" - } - > - {d.delta > 0 ? `+${d.delta}` : d.delta} - - {d.cardName || `Card #${d.cardId}`} - {d.category && ( - - ({d.category}) - - )} -
    • - ); - })} -
    -
    - ))} +
    + ( + toggle(key, checked)} + /> + ) + : undefined + } + />
  • ); diff --git a/app/_components/deck/revision-diff.tsx b/app/_components/deck/revision-diff.tsx new file mode 100644 index 0000000..998ab7d --- /dev/null +++ b/app/_components/deck/revision-diff.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; +import { deltaKey, type RevisionDelta } from "@/lib/deck/revision"; +import { groupDeltasByZone } from "@/lib/deck/group-deltas"; +import type { Zone } from "@/lib/generated/prisma/enums"; + +const ZONE_LABEL: Record = { + MAINBOARD: "Mainboard", + SIDEBOARD: "Sideboard", + CONSIDERING: "Considering", + COMMANDER: "Commander", + COMPANION: "Companion", +}; + +export function RevisionDiff({ + deltas, + renderRowStart, +}: { + deltas: readonly RevisionDelta[]; + renderRowStart?: ((delta: RevisionDelta, key: string) => ReactNode) | undefined; +}) { + const grouped = groupDeltasByZone(deltas); + + return ( +
    + {grouped.map(({ zone, deltas: zoneDeltas }) => ( +
    + {grouped.length > 1 && ( +
    + {ZONE_LABEL[zone]} +
    + )} +
      + {zoneDeltas.map((d) => { + const key = deltaKey(d); + return ( +
    • + {renderRowStart?.(d, key)} + 0 + ? "text-emerald-600 dark:text-emerald-400 font-medium" + : "text-red-600 dark:text-red-400 font-medium" + } + > + {d.delta > 0 ? `+${d.delta}` : d.delta} + + {d.cardName || `Card #${d.cardId}`} + {d.category && ( + + ({d.category}) + + )} +
    • + ); + })} +
    +
    + ))} +
    + ); +} diff --git a/app/_components/home/deck-strip.tsx b/app/_components/home/deck-strip.tsx index b8713c5..2b93a9f 100644 --- a/app/_components/home/deck-strip.tsx +++ b/app/_components/home/deck-strip.tsx @@ -117,7 +117,7 @@ export function DeckStrip({ . ) : ( -
    +
    {decks.map((d, i) => ( ))} diff --git a/app/_components/home/feed-row-expand.tsx b/app/_components/home/feed-row-expand.tsx new file mode 100644 index 0000000..26fb36e --- /dev/null +++ b/app/_components/home/feed-row-expand.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; +import { + Collapsible, + CollapsiblePanel, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +export function FeedRowExpand({ + deckName, + summary, + children, +}: { + deckName: string; + summary: ReactNode; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + + return ( + +
    + {summary} + + + +
    + +
    {children}
    +
    +
    + ); +} diff --git a/app/_components/home/home-view.tsx b/app/_components/home/home-view.tsx index 1bfe466..4fb270e 100644 --- a/app/_components/home/home-view.tsx +++ b/app/_components/home/home-view.tsx @@ -4,6 +4,7 @@ import { type Format, type Visibility } from "@/lib/generated/prisma/enums"; import { HomeGreeting } from "./home-greeting"; import { DeckStrip, type DeckStripItem } from "./deck-strip"; import { FeaturedDecks, FeaturedDecksSkeleton } from "./featured-decks"; +import { UpdatesFeed, UpdatesFeedSkeleton } from "./updates-feed"; interface HomeViewProps { userId: string; @@ -44,17 +45,11 @@ export async function HomeView({ userId, username }: HomeViewProps) { - {/* Two-column: Trending cards + placeholder activity */} + {/* Two-column: updates feed + reserved slot for trending cards */}
    - {/* Activity feed placeholder — TODO: implement when activity query exists */} -
    -
    - Activity -
    -
    - Activity feed coming soon. -
    -
    + }> + +
    ); diff --git a/app/_components/home/landing-view.tsx b/app/_components/home/landing-view.tsx index 707fcbb..58bbc33 100644 --- a/app/_components/home/landing-view.tsx +++ b/app/_components/home/landing-view.tsx @@ -30,7 +30,7 @@ function RecentDeckStripSkeleton() {
    -
    +
    {Array.from({ length: STRIP_LIMIT }).map((_, i) => (
    + Updates +
    + ); +} + +function EmptyTile({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ); +} + +function FeedRow({ item }: { item: FeedItem }) { + const { added, removed, count } = summarizeDeltas(item.changes); + const editorLabel = item.editor.displayUsername ?? item.editor.username; + + return ( +
    + +
    + + + {editorLabel} + {" "} + updated{" "} + + {item.deck.name} + + + + + +
    + + {formatLabel(item.deck.format)} ·{" "} + {added > 0 && ( + + +{added} + + )} + {added > 0 && removed > 0 && " "} + {removed > 0 && ( + + −{removed} + + )}{" "} + · {count} change{count === 1 ? "" : "s"} + + } + > + + +
    + ); +} + +export async function UpdatesFeed({ userId }: { userId: string }) { + const { followingCount, items } = await getFollowingUpdates(userId); + + return ( +
    +
    + {followingCount === 0 ? ( + + + Follow players + {" "} + to see their deck updates here. + + ) : items.length === 0 ? ( + No recent updates from players you follow. + ) : ( +
    + {items.map((item) => ( + + ))} +
    + )} +
    + ); +} + +export function UpdatesFeedSkeleton() { + return ( +
    +
    +
    + {Array.from({ length: FEED_PAGE_SIZE }).map((_, i) => ( +
    + ))} +
    +
    + ); +} diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx new file mode 100644 index 0000000..ead948a --- /dev/null +++ b/components/ui/collapsible.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" +import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible" + +function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { + return +} + +function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { + return ( + + ) +} + +function CollapsiblePanel({ ...props }: CollapsiblePrimitive.Panel.Props) { + return +} + +export { Collapsible, CollapsiblePanel, CollapsibleTrigger } diff --git a/lib/deck/__tests__/revision.test.ts b/lib/deck/__tests__/revision.test.ts index 3a77a3c..2c6ded6 100644 --- a/lib/deck/__tests__/revision.test.ts +++ b/lib/deck/__tests__/revision.test.ts @@ -5,6 +5,7 @@ import { deltasToBulkChanges, invertDeltas, mergeDeltas, + summarizeDeltas, type RevisionDelta, } from "@/lib/deck/revision"; import type { ExistingDeckCard } from "@/lib/deck/mutation/diff"; @@ -94,6 +95,31 @@ describe("mergeDeltas", () => { }); }); +describe("summarizeDeltas", () => { + it("splits mixed deltas into added/removed totals with the raw entry count", () => { + const result = summarizeDeltas([ + delta(1, "Sol Ring", 2), + delta(2, "Counterspell", -3), + delta(3, "Island", 4), + ]); + + expect(result).toEqual({ added: 6, removed: 3, count: 3 }); + }); + + it("reports zero removed when every delta is positive", () => { + const result = summarizeDeltas([ + delta(1, "Sol Ring", 1), + delta(2, "Counterspell", 2), + ]); + + expect(result).toEqual({ added: 3, removed: 0, count: 2 }); + }); + + it("returns all zeros for an empty list", () => { + expect(summarizeDeltas([])).toEqual({ added: 0, removed: 0, count: 0 }); + }); +}); + describe("invertDeltas", () => { it("flips every sign", () => { const inverted = invertDeltas([ diff --git a/lib/deck/mutation/__tests__/revision.test.ts b/lib/deck/mutation/__tests__/revision.test.ts index 48a4ae1..8e27ab3 100644 --- a/lib/deck/mutation/__tests__/revision.test.ts +++ b/lib/deck/mutation/__tests__/revision.test.ts @@ -60,6 +60,9 @@ describe("recordDeckRevisionTx", () => { await recordDeckRevisionTx(tx, DECK_ID, USER_ID, [delta]); + expect(tx.deckRevision.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { deckId: DECK_ID, userId: USER_ID } }), + ); expect(tx.deckRevision.create).toHaveBeenCalledWith({ data: { deckId: DECK_ID, @@ -69,6 +72,28 @@ describe("recordDeckRevisionTx", () => { }); }); + it("scopes the merge window per editor: a second editor gets their own revision", async () => { + // First editor has a recent revision; the merge lookup is scoped by + // userId, so the second editor's findFirst returns null and a fresh + // revision is created under their own userId. + const tx = makeTx({ findFirst: null }); + + await recordDeckRevisionTx(tx, DECK_ID, "user-2", [delta]); + + expect(tx.deckRevision.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { deckId: DECK_ID, userId: "user-2" } }), + ); + expect(tx.deckRevision.create).toHaveBeenCalledWith({ + data: { + deckId: DECK_ID, + userId: "user-2", + changes: [delta], + }, + }); + expect(tx.deckRevision.update).not.toHaveBeenCalled(); + expect(tx.deckRevision.delete).not.toHaveBeenCalled(); + }); + it("creates a new revision when outside the merge window", async () => { const staleDate = new Date(Date.now() - 10 * 60 * 1000); // 10 min ago const tx = makeTx({ diff --git a/lib/deck/mutation/revision.ts b/lib/deck/mutation/revision.ts index 239c3d1..2049584 100644 --- a/lib/deck/mutation/revision.ts +++ b/lib/deck/mutation/revision.ts @@ -33,7 +33,7 @@ export async function recordDeckRevisionTx( } const latest = await tx.deckRevision.findFirst({ - where: { deckId }, + where: { deckId, userId }, orderBy: { updatedAt: "desc" }, select: { id: true, updatedAt: true, changes: true }, }); diff --git a/lib/deck/revision.ts b/lib/deck/revision.ts index 347c069..b9c970e 100644 --- a/lib/deck/revision.ts +++ b/lib/deck/revision.ts @@ -55,6 +55,24 @@ export function mergeDeltas( return [...byKey.values()].filter((d) => d.delta !== 0); } +export interface DeltaSummary { + added: number; + removed: number; + count: number; +} + +export function summarizeDeltas( + deltas: readonly RevisionDelta[], +): DeltaSummary { + let added = 0; + let removed = 0; + for (const d of deltas) { + if (d.delta > 0) added += d.delta; + else removed -= d.delta; + } + return { added, removed, count: deltas.length }; +} + export function invertDeltas(deltas: readonly RevisionDelta[]): RevisionDelta[] { return deltas.map((d) => ({ ...d, delta: -d.delta })); } diff --git a/lib/user/__tests__/feed.test.ts b/lib/user/__tests__/feed.test.ts new file mode 100644 index 0000000..2f9e086 --- /dev/null +++ b/lib/user/__tests__/feed.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/cache", () => ({ + cacheLife: vi.fn(), + cacheTag: vi.fn(), +})); +vi.mock("@/lib/db", () => ({ + prisma: { + follow: { + findMany: vi.fn(), + }, + deckRevision: { + findMany: vi.fn(), + }, + user: { + findMany: vi.fn(), + }, + }, +})); + +import { cacheTag } from "next/cache"; +import { prisma } from "@/lib/db"; +import { FEED_PAGE_SIZE, getFollowingUpdates } from "../feed"; + +const mockFollowFindMany = vi.mocked(prisma.follow.findMany); +const mockRevisionFindMany = vi.mocked(prisma.deckRevision.findMany); +const mockUserFindMany = vi.mocked(prisma.user.findMany); +const mockCacheTag = vi.mocked(cacheTag); + +const VIEWER_ID = "viewer-1"; +const EDITOR_ID = "editor-1"; + +const EDITOR = { + id: EDITOR_ID, + username: "bob", + displayUsername: "Bob", +}; + +function revision( + id: string, + overrides: Partial<{ + userId: string; + updatedAt: Date; + changes: unknown; + deck: { id: string; name: string; format: string }; + }> = {}, +) { + return { + id, + userId: EDITOR_ID, + updatedAt: new Date("2026-07-01T12:00:00Z"), + changes: [ + { + cardId: 1, + cardName: "Sol Ring", + zone: "MAINBOARD", + category: null, + delta: 1, + }, + ], + deck: { id: "deck-1", name: "My Deck", format: "COMMANDER" }, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getFollowingUpdates", () => { + it("returns the zero-follow state without querying revisions or users", async () => { + mockFollowFindMany.mockResolvedValue([] as never); + + const result = await getFollowingUpdates(VIEWER_ID); + + expect(result).toEqual({ followingCount: 0, items: [] }); + expect(mockRevisionFindMany).not.toHaveBeenCalled(); + expect(mockUserFindMany).not.toHaveBeenCalled(); + }); + + it("tags the viewer's following cache", async () => { + mockFollowFindMany.mockResolvedValue([] as never); + + await getFollowingUpdates(VIEWER_ID); + + expect(mockCacheTag).toHaveBeenCalledWith(`user:${VIEWER_ID}:following`); + }); + + it("queries public DECK revisions by followed editors, newest first, one page", async () => { + mockFollowFindMany.mockResolvedValue([ + { followingId: EDITOR_ID }, + { followingId: "editor-2" }, + ] as never); + mockRevisionFindMany.mockResolvedValue([revision("rev-1")] as never); + mockUserFindMany.mockResolvedValue([EDITOR] as never); + + const result = await getFollowingUpdates(VIEWER_ID); + + expect(mockRevisionFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId: { in: [EDITOR_ID, "editor-2"] }, + deck: { visibility: "PUBLIC", kind: "DECK" }, + }, + orderBy: [{ updatedAt: "desc" }, { id: "desc" }], + take: FEED_PAGE_SIZE, + }), + ); + expect(result.followingCount).toBe(2); + expect(result.items).toEqual([ + { + revisionId: "rev-1", + updatedAt: new Date("2026-07-01T12:00:00Z"), + deck: { id: "deck-1", name: "My Deck", format: "COMMANDER" }, + editor: { + username: "bob", + displayUsername: "Bob", + }, + changes: [ + { + cardId: 1, + cardName: "Sol Ring", + zone: "MAINBOARD", + category: null, + delta: 1, + }, + ], + }, + ]); + }); + + it("dedupes editor ids before the user lookup", async () => { + mockFollowFindMany.mockResolvedValue([ + { followingId: EDITOR_ID }, + ] as never); + mockRevisionFindMany.mockResolvedValue([ + revision("rev-1"), + revision("rev-2"), + ] as never); + mockUserFindMany.mockResolvedValue([EDITOR] as never); + + await getFollowingUpdates(VIEWER_ID); + + expect(mockUserFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: { in: [EDITOR_ID] } } }), + ); + }); + + it("skips revisions whose editor row is missing", async () => { + mockFollowFindMany.mockResolvedValue([ + { followingId: EDITOR_ID }, + ] as never); + mockRevisionFindMany.mockResolvedValue([ + revision("rev-1", { userId: "deleted-user" }), + revision("rev-2"), + ] as never); + mockUserFindMany.mockResolvedValue([EDITOR] as never); + + const { items } = await getFollowingUpdates(VIEWER_ID); + + expect(items).toHaveLength(1); + expect(items[0]!.revisionId).toBe("rev-2"); + }); + + it("skips revisions with malformed changes payloads", async () => { + mockFollowFindMany.mockResolvedValue([ + { followingId: EDITOR_ID }, + ] as never); + mockRevisionFindMany.mockResolvedValue([ + revision("rev-1", { changes: { not: "an array" } }), + revision("rev-2"), + ] as never); + mockUserFindMany.mockResolvedValue([EDITOR] as never); + + const { items } = await getFollowingUpdates(VIEWER_ID); + + expect(items).toHaveLength(1); + expect(items[0]!.revisionId).toBe("rev-2"); + }); + + it("skips the user lookup when no revisions match", async () => { + mockFollowFindMany.mockResolvedValue([ + { followingId: EDITOR_ID }, + ] as never); + mockRevisionFindMany.mockResolvedValue([] as never); + + const result = await getFollowingUpdates(VIEWER_ID); + + expect(result).toEqual({ followingCount: 1, items: [] }); + expect(mockUserFindMany).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/user/feed.ts b/lib/user/feed.ts new file mode 100644 index 0000000..5090f4b --- /dev/null +++ b/lib/user/feed.ts @@ -0,0 +1,96 @@ +import "server-only"; +import { cacheLife, cacheTag } from "next/cache"; +import { prisma } from "@/lib/db"; +import { userFollowingTag } from "@/lib/deck/cache-tags"; +import { parseRevisionDeltas, type RevisionDelta } from "@/lib/deck/revision"; +import type { Format } from "@/lib/generated/prisma/enums"; + +export const FEED_PAGE_SIZE = 10; + +export interface FeedItem { + revisionId: string; + updatedAt: Date; + deck: { id: string; name: string; format: Format }; + editor: { + username: string; + displayUsername: string | null; + }; + changes: RevisionDelta[]; +} + +export interface FollowingUpdates { + followingCount: number; + items: FeedItem[]; +} + +/** + * Recent revisions authored by users the viewer follows, on PUBLIC decks of + * `kind=DECK`. Attribution is by editor (`DeckRevision.userId`), so a + * followed user's collaborator edits on someone else's public deck surface + * too. Cached under the viewer's following tag — follow/unfollow refreshes + * immediately; deck edits ride the minutes-level staleness. + */ +export async function getFollowingUpdates( + viewerId: string, +): Promise { + "use cache"; + cacheLife("minutes"); + cacheTag(userFollowingTag(viewerId)); + + const follows = await prisma.follow.findMany({ + where: { followerId: viewerId }, + select: { followingId: true }, + }); + if (follows.length === 0) return { followingCount: 0, items: [] }; + + const followedIds = follows.map((f) => f.followingId); + const revisions = await prisma.deckRevision.findMany({ + where: { + userId: { in: followedIds }, + deck: { visibility: "PUBLIC", kind: "DECK" }, + }, + orderBy: [{ updatedAt: "desc" }, { id: "desc" }], + take: FEED_PAGE_SIZE, + select: { + id: true, + userId: true, + updatedAt: true, + changes: true, + deck: { select: { id: true, name: true, format: true } }, + }, + }); + + const editorIds = [...new Set(revisions.map((r) => r.userId))]; + const editors = + editorIds.length === 0 + ? [] + : await prisma.user.findMany({ + where: { id: { in: editorIds } }, + select: { + id: true, + username: true, + displayUsername: true, + }, + }); + const editorById = new Map(editors.map((e) => [e.id, e])); + + const items: FeedItem[] = []; + for (const r of revisions) { + const editor = editorById.get(r.userId); + if (!editor) continue; + const changes = parseRevisionDeltas(r.changes); + if (changes.length === 0) continue; + items.push({ + revisionId: r.id, + updatedAt: r.updatedAt, + deck: r.deck, + editor: { + username: editor.username, + displayUsername: editor.displayUsername, + }, + changes, + }); + } + + return { followingCount: follows.length, items }; +} diff --git a/prisma/migrations/20260710212937_deck_revision_user_updated_at_idx/migration.sql b/prisma/migrations/20260710212937_deck_revision_user_updated_at_idx/migration.sql new file mode 100644 index 0000000..3706a81 --- /dev/null +++ b/prisma/migrations/20260710212937_deck_revision_user_updated_at_idx/migration.sql @@ -0,0 +1,17 @@ +-- NOTE ON PRODUCTION ROLLOUT: +-- Prisma Migrate wraps every migration in a transaction, so CONCURRENTLY +-- cannot be used inline here (Postgres rejects it inside a transaction block). +-- For production, apply the CONCURRENTLY variant manually BEFORE running +-- `prisma migrate deploy`, then mark this migration as applied: +-- +-- psql "$DATABASE_URL" <<'SQL' +-- CREATE INDEX CONCURRENTLY IF NOT EXISTS "deck_revision_user_id_updated_at_idx" +-- ON "deck_revision" ("user_id", "updated_at" DESC); +-- SQL +-- pnpm prisma migrate resolve --applied 20260710212937_deck_revision_user_updated_at_idx +-- +-- The non-concurrent statement below applies in dev/shadow databases without issue. + +-- Supports the updates feed: recent revisions by followed editors, newest first. +CREATE INDEX IF NOT EXISTS "deck_revision_user_id_updated_at_idx" + ON "deck_revision" ("user_id", "updated_at" DESC); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a31b375..00a3158 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -338,6 +338,7 @@ model DeckRevision { deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) @@index([deckId, updatedAt(sort: Desc)]) + @@index([userId, updatedAt(sort: Desc)]) @@map("deck_revision") }