From a86536562a22373c2ae64b5026156a1482cee584 Mon Sep 17 00:00:00 2001
From: Jarrod Servilla
Date: Wed, 1 Jul 2026 11:26:16 -0400
Subject: [PATCH 1/5] feat(deck): add deck collaboration (issue #27)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Owners can now let people they follow propose card add/remove/quantity
changes to a deck, which the owner approves or rejects as a whole
proposal — PR-style, no per-line review. Disabled by default per deck.
Key changes:
- Deck.collaborationEnabled toggle + DeckProposal model (PENDING/
APPROVED/REJECTED), reusing the existing RevisionDelta shape for
proposal payloads
- canCollaborateOnDeck/requireDeckCollaborator: eligibility is "owner
follows the candidate", 404s on both missing-deck and ineligible so
callers can't probe deck state
- Server actions to toggle collaboration, submit/list/approve/reject
proposals; approval replays changes through the existing
applyChanges pipeline and attributes the resulting DeckRevision to
the proposer, not the approving owner
- Approving a stale proposal (deck drifted since submission) throws
cleanly instead of partially applying
- New /deck/[id]/collaborate (owner review list, collaborator entry
point) and /deck/[id]/propose (mainboard draft builder) pages, plus
a Collaborate entry in the deck action row with a pending-count
badge for owners
- Extracted groupDeltasByZone shared by the history list and the new
proposal review list
---
app/(ui)/deck/[id]/collaborate/page.tsx | 105 ++++++
app/(ui)/deck/[id]/page.tsx | 13 +
app/(ui)/deck/[id]/propose/page.tsx | 73 +++++
.../deck/__tests__/collaboration.test.ts | 309 ++++++++++++++++++
app/_actions/deck/collaboration.ts | 234 +++++++++++++
app/_components/deck/deck-action-row.tsx | 44 +++
.../deck/deck-collaboration-toggle.tsx | 56 ++++
app/_components/deck/deck-header.tsx | 16 +-
app/_components/deck/deck-history-list.tsx | 38 +--
.../deck/deck-proposal-review-list.tsx | 210 ++++++++++++
app/_components/deck/deck-propose-draft.tsx | 263 +++++++++++++++
lib/auth/__tests__/deck-access.test.ts | 160 ++++++++-
lib/auth/deck-access.ts | 43 +++
lib/deck/cache-tags.ts | 5 +
lib/deck/group-deltas.ts | 37 +++
lib/deck/io/__tests__/serialize.test.ts | 1 +
lib/deck/mutation/index.ts | 1 +
lib/deck/queries.ts | 1 +
lib/deck/revision.ts | 2 +-
.../migration.sql | 33 ++
prisma/schema.prisma | 113 ++++---
21 files changed, 1678 insertions(+), 79 deletions(-)
create mode 100644 app/(ui)/deck/[id]/collaborate/page.tsx
create mode 100644 app/(ui)/deck/[id]/propose/page.tsx
create mode 100644 app/_actions/deck/__tests__/collaboration.test.ts
create mode 100644 app/_actions/deck/collaboration.ts
create mode 100644 app/_components/deck/deck-collaboration-toggle.tsx
create mode 100644 app/_components/deck/deck-proposal-review-list.tsx
create mode 100644 app/_components/deck/deck-propose-draft.tsx
create mode 100644 lib/deck/group-deltas.ts
create mode 100644 prisma/migrations/20260701150501_deck_collaboration/migration.sql
diff --git a/app/(ui)/deck/[id]/collaborate/page.tsx b/app/(ui)/deck/[id]/collaborate/page.tsx
new file mode 100644
index 0000000..5e4e4b6
--- /dev/null
+++ b/app/(ui)/deck/[id]/collaborate/page.tsx
@@ -0,0 +1,105 @@
+import { Suspense } from "react";
+import { notFound } from "next/navigation";
+import { ChevronLeft, PencilLine } from "lucide-react";
+import Link from "@/app/_components/link";
+import { Button } from "@/components/ui/button";
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+import { canCollaborateOnDeck } from "@/lib/auth/deck-access";
+import { listDeckProposals } from "@/app/_actions/deck/collaboration";
+import { DeckProposalReviewList } from "@/app/_components/deck/deck-proposal-review-list";
+
+interface DeckCollaboratePageProps {
+ params: Promise<{ id: string }>;
+}
+
+async function DeckCollaborateContent({ id }: { id: string }) {
+ const [deck, session] = await Promise.all([
+ prisma.deck.findUnique({
+ where: { id },
+ select: {
+ id: true,
+ name: true,
+ userId: true,
+ collaborationEnabled: true,
+ },
+ }),
+ getSession(),
+ ]);
+ if (!deck) notFound();
+
+ const isOwner = session?.userId === deck.userId;
+ const isCollaborator =
+ !isOwner && (await canCollaborateOnDeck(deck, session?.userId));
+ if (!isOwner && !isCollaborator) notFound();
+
+ return (
+
+
+
+
+ Back to deck
+
+
+ {deck.name} · Collaborate
+
+
+ {isOwner
+ ? "Review proposed changes from people you follow. Approving applies the whole proposal at once."
+ : "Propose card changes for the owner to review. They can approve or reject the whole proposal."}
+
+
+
+ {isOwner ? (
+
+ ) : (
+
+
+ Build a set of add/remove/quantity changes, then submit it for the
+ owner to approve or reject.
+
+
}>
+
+ Propose changes
+
+
+ )}
+
+ );
+}
+
+async function DeckOwnerReview({ deckId }: { deckId: string }) {
+ const proposals = await listDeckProposals(deckId);
+ return ;
+}
+
+export default function DeckCollaboratePage({
+ params,
+}: DeckCollaboratePageProps) {
+ return (
+
+ }
+ >
+
+
+
+ );
+}
+
+async function CollaborateLoader({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ return ;
+}
diff --git a/app/(ui)/deck/[id]/page.tsx b/app/(ui)/deck/[id]/page.tsx
index 1083947..694be1b 100644
--- a/app/(ui)/deck/[id]/page.tsx
+++ b/app/(ui)/deck/[id]/page.tsx
@@ -9,6 +9,9 @@ import { getTokensForDeck } from "@/lib/deck/token-queries";
import { isDeckSavedByUser } from "@/lib/deck/saved-queries";
import { getViewerHoldingsForDeck } from "@/lib/inventory/queries";
import { getSession } from "@/lib/auth/session";
+import { canCollaborateOnDeck } from "@/lib/auth/deck-access";
+import { prisma } from "@/lib/db";
+import { ProposalStatus } from "@/lib/generated/prisma/enums";
import {
NOT_FOUND_METADATA,
buildDeckJsonLd,
@@ -151,6 +154,14 @@ async function DeckContent({
? await getViewerHoldingsForDeck(deck.id, session.userId)
: [];
+ const pendingProposalCount = isOwner
+ ? await prisma.deckProposal.count({
+ where: { deckId: deck.id, status: ProposalStatus.PENDING },
+ })
+ : 0;
+ const canCollaborate =
+ !isOwner && (await canCollaborateOnDeck(deck, session?.userId));
+
return (
@@ -174,6 +185,8 @@ async function DeckContent({
isPrivate={deck.visibility === "PRIVATE"}
viewerLoggedIn={session !== null}
initialSaved={initialSaved}
+ showCollaborate={isOwner || canCollaborate}
+ pendingProposalCount={pendingProposalCount}
{...(canLike && {
like: { likeCount: deck.likeCount, liked: viewerLiked },
})}
diff --git a/app/(ui)/deck/[id]/propose/page.tsx b/app/(ui)/deck/[id]/propose/page.tsx
new file mode 100644
index 0000000..f719cd4
--- /dev/null
+++ b/app/(ui)/deck/[id]/propose/page.tsx
@@ -0,0 +1,73 @@
+import { Suspense } from "react";
+import { ChevronLeft } from "lucide-react";
+import Link from "@/app/_components/link";
+import { requireDeckCollaborator } from "@/lib/auth/deck-access";
+import { getDeckById } from "@/lib/deck/queries";
+import { DeckProposeDraft } from "@/app/_components/deck/deck-propose-draft";
+
+interface DeckProposePageProps {
+ params: Promise<{ id: string }>;
+}
+
+async function DeckProposeContent({ id }: { id: string }) {
+ await requireDeckCollaborator(id);
+ const deck = await getDeckById(id);
+
+ const mainboard = (deck?.cards ?? [])
+ .filter((c) => c.zone === "MAINBOARD")
+ .map((c) => ({
+ cardId: c.cardId,
+ cardName: c.card.name,
+ category: c.category,
+ quantity: c.quantity,
+ }));
+
+ return (
+
+
+
+
+ Back to collaborate
+
+
+ Propose changes to {deck?.name}
+
+
+ Adjust mainboard quantities or add new cards, then submit for the
+ owner to review.
+
+
+
+
+
+ );
+}
+
+export default function DeckProposePage({ params }: DeckProposePageProps) {
+ return (
+
+ }
+ >
+
+
+
+ );
+}
+
+async function ProposeLoader({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ return ;
+}
diff --git a/app/_actions/deck/__tests__/collaboration.test.ts b/app/_actions/deck/__tests__/collaboration.test.ts
new file mode 100644
index 0000000..cce664a
--- /dev/null
+++ b/app/_actions/deck/__tests__/collaboration.test.ts
@@ -0,0 +1,309 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { Zone } from "@/lib/generated/prisma/enums";
+
+vi.mock("next/cache", () => ({ updateTag: vi.fn() }));
+vi.mock("next/navigation", () => ({
+ notFound: vi.fn(() => {
+ throw new Error("NEXT_NOT_FOUND");
+ }),
+}));
+vi.mock("@/lib/auth/session", () => ({
+ requireSession: vi.fn(),
+ getSession: vi.fn(),
+}));
+vi.mock("@/lib/deck/mutation", async () => {
+ const actual = await vi.importActual(
+ "@/lib/deck/mutation",
+ );
+ return {
+ ...actual,
+ applyChanges: vi.fn(async () => undefined),
+ };
+});
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ deck: {
+ findUnique: vi.fn(),
+ update: vi.fn(),
+ },
+ follow: {
+ findUnique: vi.fn(),
+ },
+ deckCard: {
+ findMany: vi.fn(),
+ },
+ card: {
+ findMany: vi.fn(),
+ },
+ deckProposal: {
+ create: vi.fn(),
+ findUnique: vi.fn(),
+ update: vi.fn(),
+ },
+ },
+}));
+
+import { prisma } from "@/lib/db";
+import { requireSession } from "@/lib/auth/session";
+import { applyChanges } from "@/lib/deck/mutation";
+import {
+ approveDeckProposal,
+ rejectDeckProposal,
+ submitDeckProposal,
+ toggleDeckCollaboration,
+} from "../collaboration";
+
+const mockDeckFindUnique = vi.mocked(prisma.deck.findUnique);
+const mockDeckUpdate = vi.mocked(prisma.deck.update);
+const mockFollowFindUnique = vi.mocked(prisma.follow.findUnique);
+const mockDeckCardFindMany = vi.mocked(prisma.deckCard.findMany);
+const mockCardFindMany = vi.mocked(prisma.card.findMany);
+const mockProposalCreate = vi.mocked(prisma.deckProposal.create);
+const mockProposalFindUnique = vi.mocked(prisma.deckProposal.findUnique);
+const mockProposalUpdate = vi.mocked(prisma.deckProposal.update);
+const mockRequireSession = vi.mocked(requireSession);
+const mockApplyChanges = vi.mocked(applyChanges);
+
+const OWNER_ID = "owner-1";
+const PROPOSER_ID = "proposer-1";
+const DECK_ID = "deck-1";
+
+// Superset fixture satisfying both requireDeckOwner/requireDeckCollaborator's
+// select and loadSnapshotForDeck's select — mocks ignore `select`, so one
+// object can stand in for every prisma.deck.findUnique call in a test.
+function deckRow(overrides?: Partial>) {
+ return {
+ id: DECK_ID,
+ userId: OWNER_ID,
+ collaborationEnabled: true,
+ format: "COMMANDER",
+ cards: [],
+ categories: [],
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockCardFindMany.mockResolvedValue([
+ {
+ id: 1,
+ name: "Sol Ring",
+ typeLine: "Artifact",
+ colorIdentity: [],
+ legalities: {},
+ },
+ ] as never);
+});
+
+describe("toggleDeckCollaboration", () => {
+ it("updates collaborationEnabled when the caller owns the deck", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+
+ await toggleDeckCollaboration(DECK_ID, true);
+
+ expect(mockDeckUpdate).toHaveBeenCalledWith({
+ where: { id: DECK_ID },
+ data: { collaborationEnabled: true },
+ });
+ });
+
+ it("404s for a non-owner", async () => {
+ mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+
+ await expect(toggleDeckCollaboration(DECK_ID, true)).rejects.toThrow(
+ "NEXT_NOT_FOUND",
+ );
+ expect(mockDeckUpdate).not.toHaveBeenCalled();
+ });
+});
+
+describe("submitDeckProposal", () => {
+ const addSolRing = [
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ category: null,
+ delta: 1,
+ },
+ ];
+
+ it("creates a pending proposal for an eligible collaborator", async () => {
+ mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockFollowFindUnique.mockResolvedValue({ followerId: OWNER_ID } as never);
+ mockDeckCardFindMany.mockResolvedValue([] as never);
+ mockProposalCreate.mockResolvedValue({ id: "prop-1" } as never);
+
+ const id = await submitDeckProposal(DECK_ID, addSolRing, "please add this");
+
+ expect(id).toBe("prop-1");
+ expect(mockProposalCreate).toHaveBeenCalledWith({
+ data: {
+ deckId: DECK_ID,
+ proposerId: PROPOSER_ID,
+ status: "PENDING",
+ changes: addSolRing,
+ message: "please add this",
+ },
+ select: { id: true },
+ });
+ });
+
+ it("404s a submission from a viewer collaboration is disabled for", async () => {
+ mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(
+ deckRow({ collaborationEnabled: false }) as never,
+ );
+
+ await expect(submitDeckProposal(DECK_ID, addSolRing)).rejects.toThrow(
+ "NEXT_NOT_FOUND",
+ );
+ expect(mockProposalCreate).not.toHaveBeenCalled();
+ });
+
+ it("rejects an empty changes array before touching the deck", async () => {
+ mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockFollowFindUnique.mockResolvedValue({ followerId: OWNER_ID } as never);
+
+ await expect(submitDeckProposal(DECK_ID, [])).rejects.toThrow();
+ expect(mockProposalCreate).not.toHaveBeenCalled();
+ });
+});
+
+describe("submit → approve happy path", () => {
+ const addSolRing = [
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ category: null,
+ delta: 1,
+ },
+ ];
+
+ it("approves a pending proposal, applies it, and attributes the resulting revision to the proposer", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockProposalFindUnique.mockResolvedValue({
+ id: "prop-1",
+ deckId: DECK_ID,
+ proposerId: PROPOSER_ID,
+ status: "PENDING",
+ changes: addSolRing,
+ } as never);
+ mockDeckCardFindMany.mockResolvedValue([] as never);
+
+ await approveDeckProposal(DECK_ID, "prop-1");
+
+ expect(mockApplyChanges).toHaveBeenCalledTimes(1);
+ const [appliedDeckId, actorId] = mockApplyChanges.mock.calls[0]!;
+ expect(appliedDeckId).toBe(DECK_ID);
+ // Attributed to the proposer, not the approving owner.
+ expect(actorId).toBe(PROPOSER_ID);
+ expect(mockProposalUpdate).toHaveBeenCalledWith({
+ where: { id: "prop-1" },
+ data: expect.objectContaining({
+ status: "APPROVED",
+ resolvedById: OWNER_ID,
+ }),
+ });
+ });
+
+ it("404s when a non-owner tries to approve", async () => {
+ mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+
+ await expect(approveDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
+ "NEXT_NOT_FOUND",
+ );
+ expect(mockApplyChanges).not.toHaveBeenCalled();
+ });
+});
+
+describe("submit → reject", () => {
+ it("marks a pending proposal rejected and never touches the deck", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockProposalFindUnique.mockResolvedValue({
+ id: "prop-1",
+ deckId: DECK_ID,
+ proposerId: PROPOSER_ID,
+ status: "PENDING",
+ changes: [],
+ } as never);
+
+ await rejectDeckProposal(DECK_ID, "prop-1");
+
+ expect(mockApplyChanges).not.toHaveBeenCalled();
+ expect(mockDeckCardFindMany).not.toHaveBeenCalled();
+ expect(mockProposalUpdate).toHaveBeenCalledWith({
+ where: { id: "prop-1" },
+ data: expect.objectContaining({
+ status: "REJECTED",
+ resolvedById: OWNER_ID,
+ }),
+ });
+ });
+
+ it("404s when a non-owner tries to reject", async () => {
+ mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+
+ await expect(rejectDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
+ "NEXT_NOT_FOUND",
+ );
+ expect(mockProposalUpdate).not.toHaveBeenCalled();
+ });
+});
+
+describe("approveDeckProposal on a stale or already-resolved proposal", () => {
+ it("throws without applying changes or resolving the proposal, when the targeted card is no longer on the deck", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockProposalFindUnique.mockResolvedValue({
+ id: "prop-1",
+ deckId: DECK_ID,
+ proposerId: PROPOSER_ID,
+ status: "PENDING",
+ changes: [
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ category: null,
+ delta: -1,
+ },
+ ],
+ } as never);
+ // Deck no longer has this card — the proposal has gone stale.
+ mockDeckCardFindMany.mockResolvedValue([] as never);
+
+ await expect(approveDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
+ /no longer on the deck/,
+ );
+ expect(mockApplyChanges).not.toHaveBeenCalled();
+ expect(mockProposalUpdate).not.toHaveBeenCalled();
+ });
+
+ it("throws when the proposal has already been resolved", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockProposalFindUnique.mockResolvedValue({
+ id: "prop-1",
+ deckId: DECK_ID,
+ proposerId: PROPOSER_ID,
+ status: "APPROVED",
+ changes: [],
+ } as never);
+
+ await expect(approveDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
+ /already been resolved/,
+ );
+ expect(mockApplyChanges).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/_actions/deck/collaboration.ts b/app/_actions/deck/collaboration.ts
new file mode 100644
index 0000000..a24adfa
--- /dev/null
+++ b/app/_actions/deck/collaboration.ts
@@ -0,0 +1,234 @@
+"use server";
+
+import "server-only";
+import { z } from "zod";
+import { prisma } from "@/lib/db";
+import { requireDeckOwner, requireDeckCollaborator } from "@/lib/auth/deck-access";
+import { withActionLogging } from "@/lib/telemetry";
+import {
+ applyChanges,
+ loadSnapshotForDeck,
+ planMutation,
+ type ExistingDeckCard,
+ type PlannedChange,
+} from "@/lib/deck/mutation";
+import { StructuralViolation } from "@/lib/deck/mutation/errors";
+import {
+ deltaKey,
+ deltasToBulkChanges,
+ revisionDeltaSchema,
+ type RevisionDelta,
+} from "@/lib/deck/revision";
+import { ProposalStatus } from "@/lib/generated/prisma/enums";
+import {
+ deckProposalsTag,
+ deckTag,
+ invalidateTags,
+} from "@/lib/deck/cache-tags";
+
+const proposalChangesSchema = z.array(revisionDeltaSchema).min(1);
+
+export const toggleDeckCollaboration = withActionLogging(
+ "deck.toggleCollaboration",
+ async (deckId: string, enabled: boolean): Promise => {
+ await requireDeckOwner(deckId);
+
+ await prisma.deck.update({
+ where: { id: deckId },
+ data: { collaborationEnabled: enabled },
+ });
+
+ invalidateTags([deckTag(deckId)]);
+ },
+);
+
+/**
+ * Builds the write plan for a set of revision deltas against the deck's
+ * current state. Shared by submit (dry run, nothing committed) and approve
+ * (the real application), so both see the exact same "is this still valid"
+ * answer relative to whatever the deck looks like right now.
+ */
+async function planProposalChanges(
+ deckId: string,
+ deltas: readonly RevisionDelta[],
+): Promise {
+ const rows = await prisma.deckCard.findMany({
+ where: { deckId },
+ select: {
+ id: true,
+ cardId: true,
+ zone: true,
+ category: true,
+ quantity: true,
+ },
+ });
+
+ const existing: ExistingDeckCard[] = rows.map((r) => ({
+ deckCardId: r.id,
+ cardId: r.cardId,
+ zone: r.zone,
+ category: r.category,
+ quantity: r.quantity,
+ }));
+
+ const existingByKey = new Map(existing.map((e) => [deltaKey(e), e]));
+ for (const d of deltas) {
+ if (d.delta < 0 && !existingByKey.has(deltaKey(d))) {
+ throw new Error(
+ `Proposal is stale — "${d.cardName}" is no longer on the deck.`,
+ );
+ }
+ }
+
+ return deltasToBulkChanges(deltas, existing);
+}
+
+export const submitDeckProposal = withActionLogging(
+ "deck.submitProposal",
+ async (
+ deckId: string,
+ changes: RevisionDelta[],
+ message?: string,
+ ): Promise => {
+ const { userId } = await requireDeckCollaborator(deckId);
+ const deltas = proposalChangesSchema.parse(changes);
+
+ const plannedChanges = await planProposalChanges(deckId, deltas);
+ const before = await loadSnapshotForDeck(deckId, plannedChanges);
+ const { structural, missingDeckCardId } = planMutation(
+ before,
+ plannedChanges,
+ );
+ if (structural.length > 0) {
+ throw new StructuralViolation(structural);
+ }
+ if (missingDeckCardId !== null) {
+ throw new Error("Proposal references a card that is no longer on the deck.");
+ }
+
+ const proposal = await prisma.deckProposal.create({
+ data: {
+ deckId,
+ proposerId: userId,
+ status: ProposalStatus.PENDING,
+ changes: deltas,
+ message: message?.trim() || null,
+ },
+ select: { id: true },
+ });
+
+ invalidateTags([deckProposalsTag(deckId)]);
+ return proposal.id;
+ },
+);
+
+export type DeckProposalView = {
+ id: string;
+ status: ProposalStatus;
+ changes: RevisionDelta[];
+ message: string | null;
+ createdAt: Date;
+ resolvedAt: Date | null;
+ proposer: { id: string; username: string; image: string | null };
+};
+
+export const listDeckProposals = withActionLogging(
+ "deck.listProposals",
+ async (deckId: string): Promise => {
+ await requireDeckOwner(deckId);
+
+ const rows = await prisma.deckProposal.findMany({
+ where: { deckId },
+ orderBy: [{ status: "asc" }, { createdAt: "desc" }],
+ select: {
+ id: true,
+ status: true,
+ changes: true,
+ message: true,
+ createdAt: true,
+ resolvedAt: true,
+ proposer: { select: { id: true, username: true, image: true } },
+ },
+ });
+
+ // PENDING sorts first: enum declaration order (PENDING, APPROVED,
+ // REJECTED) already matches `status: "asc"` on the Postgres enum type.
+ return rows.map((r) => ({
+ id: r.id,
+ status: r.status,
+ changes: revisionDeltaSchema.array().parse(r.changes),
+ message: r.message,
+ createdAt: r.createdAt,
+ resolvedAt: r.resolvedAt,
+ proposer: r.proposer,
+ }));
+ },
+);
+
+async function loadPendingProposal(deckId: string, proposalId: string) {
+ const proposal = await prisma.deckProposal.findUnique({
+ where: { id: proposalId },
+ select: {
+ id: true,
+ deckId: true,
+ proposerId: true,
+ status: true,
+ changes: true,
+ },
+ });
+ if (!proposal || proposal.deckId !== deckId) {
+ throw new Error("Proposal not found");
+ }
+ if (proposal.status !== ProposalStatus.PENDING) {
+ throw new Error("Proposal has already been resolved");
+ }
+ return proposal;
+}
+
+export const approveDeckProposal = withActionLogging(
+ "deck.approveProposal",
+ async (deckId: string, proposalId: string): Promise => {
+ const { userId: ownerId } = await requireDeckOwner(deckId);
+ const proposal = await loadPendingProposal(deckId, proposalId);
+ const deltas = revisionDeltaSchema.array().parse(proposal.changes);
+
+ // Re-validated against current deck state (may have drifted since
+ // submission); a stale proposal throws here instead of partially
+ // applying, so the owner can reject it.
+ const plannedChanges = await planProposalChanges(deckId, deltas);
+
+ // Attributed to the proposer, not the owner, so the resulting
+ // DeckRevision credits the contributor who made the change.
+ await applyChanges(deckId, proposal.proposerId, plannedChanges);
+
+ await prisma.deckProposal.update({
+ where: { id: proposalId },
+ data: {
+ status: ProposalStatus.APPROVED,
+ resolvedById: ownerId,
+ resolvedAt: new Date(),
+ },
+ });
+
+ invalidateTags([deckProposalsTag(deckId)]);
+ },
+);
+
+export const rejectDeckProposal = withActionLogging(
+ "deck.rejectProposal",
+ async (deckId: string, proposalId: string): Promise => {
+ const { userId: ownerId } = await requireDeckOwner(deckId);
+ await loadPendingProposal(deckId, proposalId);
+
+ await prisma.deckProposal.update({
+ where: { id: proposalId },
+ data: {
+ status: ProposalStatus.REJECTED,
+ resolvedById: ownerId,
+ resolvedAt: new Date(),
+ },
+ });
+
+ invalidateTags([deckProposalsTag(deckId)]);
+ },
+);
diff --git a/app/_components/deck/deck-action-row.tsx b/app/_components/deck/deck-action-row.tsx
index 7dbc373..ec1555a 100644
--- a/app/_components/deck/deck-action-row.tsx
+++ b/app/_components/deck/deck-action-row.tsx
@@ -11,6 +11,7 @@ import {
Swords,
Trash2,
Upload,
+ Users,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -47,6 +48,13 @@ interface DeckActionRowProps {
likeCount: number;
liked: boolean;
};
+ /**
+ * True when this viewer should see the "Collaborate" entry — the owner
+ * (reviews proposals) or an eligible collaborator (proposes changes).
+ */
+ showCollaborate?: boolean;
+ /** Owner-only: count of PENDING proposals awaiting review. */
+ pendingProposalCount?: number;
}
export function DeckActionRow({
@@ -57,6 +65,8 @@ export function DeckActionRow({
viewerLoggedIn,
initialSaved,
like,
+ showCollaborate,
+ pendingProposalCount,
}: DeckActionRowProps) {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState(false);
@@ -138,6 +148,7 @@ export function DeckActionRow({
Compare
+ {showCollaborate && }
{like && (
+ {showCollaborate && (
+
+ )}
+
);
}
+
+function CollaborateButton({
+ deckId,
+ pendingProposalCount,
+}: {
+ deckId: string;
+ pendingProposalCount?: number | undefined;
+}) {
+ const router = useRouter();
+ return (
+ router.push(`/deck/${deckId}/collaborate`)}
+ >
+
+ Collaborate
+ {!!pendingProposalCount && pendingProposalCount > 0 && (
+
+ {pendingProposalCount}
+
+ )}
+
+ );
+}
diff --git a/app/_components/deck/deck-collaboration-toggle.tsx b/app/_components/deck/deck-collaboration-toggle.tsx
new file mode 100644
index 0000000..0b62fec
--- /dev/null
+++ b/app/_components/deck/deck-collaboration-toggle.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { useOptimistic, useTransition } from "react";
+import { Users } from "lucide-react";
+import { toggleDeckCollaboration } from "@/app/_actions/deck/collaboration";
+import { cn } from "@/lib/utils";
+
+interface DeckCollaborationToggleProps {
+ deckId: string;
+ enabled: boolean;
+}
+
+export function DeckCollaborationToggle({
+ deckId,
+ enabled,
+}: DeckCollaborationToggleProps) {
+ const [optimistic, setOptimistic] = useOptimistic(
+ enabled,
+ (_, next) => next,
+ );
+ const [, startTransition] = useTransition();
+
+ function handleClick() {
+ const next = !optimistic;
+ startTransition(async () => {
+ setOptimistic(next);
+ try {
+ await toggleDeckCollaboration(deckId, next);
+ } catch {
+ // Server state is authoritative — on failure the optimistic value
+ // unwinds and the stored value shows through on refresh.
+ }
+ });
+ }
+
+ return (
+
+
+ {optimistic ? "Collaboration on" : "Collaboration off"}
+
+ );
+}
diff --git a/app/_components/deck/deck-header.tsx b/app/_components/deck/deck-header.tsx
index dc954a8..60fbfbc 100644
--- a/app/_components/deck/deck-header.tsx
+++ b/app/_components/deck/deck-header.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { Globe, Link2, Lock } from "lucide-react";
import { DeckVisibilityPicker } from "@/app/_components/deck/deck-visibility-picker";
+import { DeckCollaborationToggle } from "@/app/_components/deck/deck-collaboration-toggle";
import { computeDeckPrice } from "@/lib/deck/price";
import {
computeAverageMV,
@@ -153,10 +154,17 @@ export function DeckHeader({
·
{isOwner ? (
-
+ <>
+
+ ·
+
+ >
) : (
diff --git a/app/_components/deck/deck-history-list.tsx b/app/_components/deck/deck-history-list.tsx
index 46f945d..ab297ad 100644
--- a/app/_components/deck/deck-history-list.tsx
+++ b/app/_components/deck/deck-history-list.tsx
@@ -7,7 +7,8 @@ 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, type RevisionDelta } from "@/lib/deck/revision";
+import { deltaKey } from "@/lib/deck/revision";
+import { groupDeltasByZone } from "@/lib/deck/group-deltas";
import type { Zone } from "@/lib/generated/prisma/enums";
interface DeckHistoryListProps {
@@ -64,7 +65,10 @@ function RevisionCard({
() => new Date(revision.updatedAt),
[revision.updatedAt],
);
- const grouped = useMemo(() => groupByZone(revision.changes), [revision.changes]);
+ const grouped = useMemo(
+ () => groupDeltasByZone(revision.changes),
+ [revision.changes],
+ );
const [selected, setSelected] = useState>(new Set());
const toggle = (key: string, checked: boolean) => {
@@ -202,36 +206,6 @@ function RevertButton({
);
}
-function groupByZone(
- deltas: RevisionDelta[],
-): Array<{ zone: Zone; deltas: RevisionDelta[] }> {
- const byZone = new Map();
- for (const d of deltas) {
- const list = byZone.get(d.zone) ?? [];
- list.push(d);
- byZone.set(d.zone, list);
- }
- const zones: Zone[] = [
- "COMMANDER",
- "COMPANION",
- "MAINBOARD",
- "SIDEBOARD",
- "CONSIDERING",
- ];
- return zones
- .filter((z) => byZone.has(z))
- .map((zone) => ({
- zone,
- deltas: (byZone.get(zone) ?? [])
- .slice()
- .sort((a, b) => {
- const signDiff = Math.sign(b.delta) - Math.sign(a.delta);
- if (signDiff !== 0) return signDiff;
- return a.cardName.localeCompare(b.cardName);
- }),
- }));
-}
-
function relativeTime(date: Date): string {
const diffMs = Date.now() - date.getTime();
const abs = Math.abs(diffMs);
diff --git a/app/_components/deck/deck-proposal-review-list.tsx b/app/_components/deck/deck-proposal-review-list.tsx
new file mode 100644
index 0000000..1b9fd22
--- /dev/null
+++ b/app/_components/deck/deck-proposal-review-list.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { Check, X } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ approveDeckProposal,
+ rejectDeckProposal,
+ type DeckProposalView,
+} from "@/app/_actions/deck/collaboration";
+import { groupDeltasByZone } from "@/lib/deck/group-deltas";
+import type { Zone } from "@/lib/generated/prisma/enums";
+
+interface DeckProposalReviewListProps {
+ deckId: string;
+ proposals: DeckProposalView[];
+}
+
+const ZONE_LABEL: Record = {
+ MAINBOARD: "Mainboard",
+ SIDEBOARD: "Sideboard",
+ CONSIDERING: "Considering",
+ COMMANDER: "Commander",
+ COMPANION: "Companion",
+};
+
+const STATUS_LABEL: Record = {
+ PENDING: "Pending",
+ APPROVED: "Approved",
+ REJECTED: "Rejected",
+};
+
+export function DeckProposalReviewList({
+ deckId,
+ proposals,
+}: DeckProposalReviewListProps) {
+ const pending = proposals.filter((p) => p.status === "PENDING");
+ const resolved = proposals.filter((p) => p.status !== "PENDING");
+
+ if (proposals.length === 0) {
+ return (
+
+ No proposals yet
+
+ );
+ }
+
+ return (
+
+
+
+ Pending ({pending.length})
+
+ {pending.length === 0 ? (
+
Nothing to review.
+ ) : (
+
+ {pending.map((p) => (
+
+ ))}
+
+ )}
+
+
+ {resolved.length > 0 && (
+
+
+ Resolved
+
+
+ {resolved.map((p) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function ProposalCard({
+ deckId,
+ proposal,
+}: {
+ deckId: string;
+ proposal: DeckProposalView;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const grouped = groupDeltasByZone(proposal.changes);
+
+ function handleApprove() {
+ setError(null);
+ startTransition(async () => {
+ try {
+ await approveDeckProposal(deckId, proposal.id);
+ router.refresh();
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Could not approve this proposal.",
+ );
+ }
+ });
+ }
+
+ function handleReject() {
+ setError(null);
+ startTransition(async () => {
+ try {
+ await rejectDeckProposal(deckId, proposal.id);
+ router.refresh();
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Could not reject this proposal.",
+ );
+ }
+ });
+ }
+
+ return (
+
+
+
+
+ {proposal.proposer.username}
+
+
+ {new Date(proposal.createdAt).toLocaleString()}
+
+
+ {proposal.status === "PENDING" ? (
+
+
+
+ Reject
+
+
+
+ Approve
+
+
+ ) : (
+
+ {STATUS_LABEL[proposal.status]}
+
+ )}
+
+
+
+ {proposal.message && (
+
+ “{proposal.message}”
+
+ )}
+ {error && (
+
{error}
+ )}
+ {grouped.map(({ zone, deltas }) => (
+
+ {grouped.length > 1 && (
+
+ {ZONE_LABEL[zone]}
+
+ )}
+
+ {deltas.map((d) => (
+
+ 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/deck/deck-propose-draft.tsx b/app/_components/deck/deck-propose-draft.tsx
new file mode 100644
index 0000000..32a6774
--- /dev/null
+++ b/app/_components/deck/deck-propose-draft.tsx
@@ -0,0 +1,263 @@
+"use client";
+
+import { useMemo, useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { Minus, Plus } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { submitDeckProposal } from "@/app/_actions/deck/collaboration";
+import { useCardBrowser } from "@/app/_components/builder/card-browser/use-card-browser";
+import type { RevisionDelta } from "@/lib/deck/revision";
+
+interface ExistingMainboardCard {
+ cardId: number;
+ cardName: string;
+ category: string | null;
+ quantity: number;
+}
+
+interface DeckProposeDraftProps {
+ deckId: string;
+ existingCards: ExistingMainboardCard[];
+}
+
+export function DeckProposeDraft({
+ deckId,
+ existingCards,
+}: DeckProposeDraftProps) {
+ const router = useRouter();
+ const [query, setQuery] = useState("");
+ const { results, loading } = useCardBrowser(query);
+
+ const [quantities, setQuantities] = useState>(
+ () => new Map(existingCards.map((c) => [c.cardId, c.quantity])),
+ );
+ const [added, setAdded] = useState<
+ Map
+ >(new Map());
+ const [message, setMessage] = useState("");
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+
+ const existingByCardId = useMemo(
+ () => new Map(existingCards.map((c) => [c.cardId, c])),
+ [existingCards],
+ );
+
+ function setQuantity(cardId: number, next: number) {
+ setQuantities((prev) => {
+ const map = new Map(prev);
+ map.set(cardId, Math.max(0, next));
+ return map;
+ });
+ }
+
+ function addCard(cardId: number, cardName: string) {
+ if (existingByCardId.has(cardId)) return;
+ setAdded((prev) => {
+ const map = new Map(prev);
+ const current = map.get(cardId);
+ map.set(cardId, { cardName, quantity: (current?.quantity ?? 0) + 1 });
+ return map;
+ });
+ }
+
+ function setAddedQuantity(cardId: number, next: number) {
+ setAdded((prev) => {
+ const map = new Map(prev);
+ const current = map.get(cardId);
+ if (!current) return prev;
+ if (next <= 0) {
+ map.delete(cardId);
+ } else {
+ map.set(cardId, { ...current, quantity: next });
+ }
+ return map;
+ });
+ }
+
+ const deltas: RevisionDelta[] = useMemo(() => {
+ const out: RevisionDelta[] = [];
+ for (const c of existingCards) {
+ const next = quantities.get(c.cardId) ?? c.quantity;
+ const delta = next - c.quantity;
+ if (delta !== 0) {
+ out.push({
+ cardId: c.cardId,
+ cardName: c.cardName,
+ zone: "MAINBOARD",
+ category: c.category,
+ delta,
+ });
+ }
+ }
+ for (const [cardId, entry] of added) {
+ out.push({
+ cardId,
+ cardName: entry.cardName,
+ zone: "MAINBOARD",
+ category: null,
+ delta: entry.quantity,
+ });
+ }
+ return out;
+ }, [existingCards, quantities, added]);
+
+ function handleSubmit() {
+ if (deltas.length === 0) return;
+ setError(null);
+ startTransition(async () => {
+ try {
+ await submitDeckProposal(deckId, deltas, message.trim() || undefined);
+ router.push(`/deck/${deckId}/collaborate`);
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Could not submit proposal.",
+ );
+ }
+ });
+ }
+
+ return (
+
+
+
+ Mainboard
+
+ {existingCards.length === 0 ? (
+
+ No mainboard cards yet.
+
+ ) : (
+
+ {existingCards.map((c) => (
+
+ setQuantity(c.cardId, n)}
+ />
+ {c.cardName}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Message (optional)
+
+
+
+ {error && (
+
{error}
+ )}
+
+
+
+ {pending
+ ? "Submitting…"
+ : `Submit proposal (${deltas.length} change${deltas.length === 1 ? "" : "s"})`}
+
+
+
+ );
+}
+
+function QuantityStepper({
+ value,
+ onChange,
+}: {
+ value: number;
+ onChange: (n: number) => void;
+}) {
+ return (
+
+
onChange(value - 1)}
+ aria-label="Decrease quantity"
+ >
+
+
+
{value}
+
onChange(value + 1)}
+ aria-label="Increase quantity"
+ >
+
+
+
+ );
+}
diff --git a/lib/auth/__tests__/deck-access.test.ts b/lib/auth/__tests__/deck-access.test.ts
index 797ff22..6bb1e2d 100644
--- a/lib/auth/__tests__/deck-access.test.ts
+++ b/lib/auth/__tests__/deck-access.test.ts
@@ -13,6 +13,9 @@ vi.mock("@/lib/db", () => ({
deck: {
findUnique: vi.fn(),
},
+ follow: {
+ findUnique: vi.fn(),
+ },
},
}));
vi.mock("../session", () => ({
@@ -21,10 +24,16 @@ vi.mock("../session", () => ({
}));
import { prisma } from "@/lib/db";
-import { requireDeckOwner, requireDeckViewable } from "../deck-access";
+import {
+ canCollaborateOnDeck,
+ requireDeckCollaborator,
+ requireDeckOwner,
+ requireDeckViewable,
+} from "../deck-access";
import { getSession, requireSession } from "../session";
const mockDeckFindUnique = vi.mocked(prisma.deck.findUnique);
+const mockFollowFindUnique = vi.mocked(prisma.follow.findUnique);
const mockRequireSession = vi.mocked(requireSession);
const mockGetSession = vi.mocked(getSession);
@@ -149,3 +158,152 @@ describe("requireDeckViewable", () => {
);
});
});
+
+describe("canCollaborateOnDeck", () => {
+ it("returns false when collaboration is disabled, even if the owner follows the candidate", async () => {
+ mockFollowFindUnique.mockResolvedValue({
+ followerId: USER_ID,
+ } as never);
+
+ const eligible = await canCollaborateOnDeck(
+ { userId: USER_ID, collaborationEnabled: false },
+ OTHER_USER_ID,
+ );
+
+ expect(eligible).toBe(false);
+ expect(mockFollowFindUnique).not.toHaveBeenCalled();
+ });
+
+ it("returns false for the deck owner themselves (self-exclusion)", async () => {
+ const eligible = await canCollaborateOnDeck(
+ { userId: USER_ID, collaborationEnabled: true },
+ USER_ID,
+ );
+
+ expect(eligible).toBe(false);
+ expect(mockFollowFindUnique).not.toHaveBeenCalled();
+ });
+
+ it("returns false for an unauthenticated viewer", async () => {
+ const eligible = await canCollaborateOnDeck(
+ { userId: USER_ID, collaborationEnabled: true },
+ undefined,
+ );
+
+ expect(eligible).toBe(false);
+ expect(mockFollowFindUnique).not.toHaveBeenCalled();
+ });
+
+ it("returns false when the owner does not follow the candidate", async () => {
+ mockFollowFindUnique.mockResolvedValue(null);
+
+ const eligible = await canCollaborateOnDeck(
+ { userId: USER_ID, collaborationEnabled: true },
+ OTHER_USER_ID,
+ );
+
+ expect(eligible).toBe(false);
+ expect(mockFollowFindUnique).toHaveBeenCalledWith({
+ where: {
+ followerId_followingId: {
+ followerId: USER_ID,
+ followingId: OTHER_USER_ID,
+ },
+ },
+ select: { followerId: true },
+ });
+ });
+
+ it("checks follow direction from owner to candidate, not the reverse", async () => {
+ mockFollowFindUnique.mockResolvedValue(null);
+
+ await canCollaborateOnDeck(
+ { userId: USER_ID, collaborationEnabled: true },
+ OTHER_USER_ID,
+ );
+
+ const call = mockFollowFindUnique.mock.calls[0]![0] as {
+ where: { followerId_followingId: { followerId: string; followingId: string } };
+ };
+ expect(call.where.followerId_followingId.followerId).toBe(USER_ID);
+ expect(call.where.followerId_followingId.followingId).toBe(
+ OTHER_USER_ID,
+ );
+ });
+
+ it("returns true when collaboration is enabled and the owner follows the candidate", async () => {
+ mockFollowFindUnique.mockResolvedValue({
+ followerId: USER_ID,
+ } as never);
+
+ const eligible = await canCollaborateOnDeck(
+ { userId: USER_ID, collaborationEnabled: true },
+ OTHER_USER_ID,
+ );
+
+ expect(eligible).toBe(true);
+ });
+});
+
+describe("requireDeckCollaborator", () => {
+ it("404s when the deck does not exist", async () => {
+ mockDeckFindUnique.mockResolvedValue(null);
+
+ await expect(requireDeckCollaborator(DECK_ID)).rejects.toThrow(
+ "NEXT_NOT_FOUND",
+ );
+ });
+
+ it("404s when the caller is the deck owner (owner isn't a 'collaborator')", async () => {
+ mockRequireSession.mockResolvedValue({ userId: USER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue({
+ userId: USER_ID,
+ collaborationEnabled: true,
+ } as never);
+
+ await expect(requireDeckCollaborator(DECK_ID)).rejects.toThrow(
+ "NEXT_NOT_FOUND",
+ );
+ expect(mockFollowFindUnique).not.toHaveBeenCalled();
+ });
+
+ it("404s when collaboration is disabled on the deck", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OTHER_USER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue({
+ userId: USER_ID,
+ collaborationEnabled: false,
+ } as never);
+
+ await expect(requireDeckCollaborator(DECK_ID)).rejects.toThrow(
+ "NEXT_NOT_FOUND",
+ );
+ });
+
+ it("404s when the owner does not follow the candidate", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OTHER_USER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue({
+ userId: USER_ID,
+ collaborationEnabled: true,
+ } as never);
+ mockFollowFindUnique.mockResolvedValue(null);
+
+ await expect(requireDeckCollaborator(DECK_ID)).rejects.toThrow(
+ "NEXT_NOT_FOUND",
+ );
+ });
+
+ it("returns deckId/userId/deck when the candidate is an eligible collaborator", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OTHER_USER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue({
+ userId: USER_ID,
+ collaborationEnabled: true,
+ } as never);
+ mockFollowFindUnique.mockResolvedValue({ followerId: USER_ID } as never);
+
+ await expect(requireDeckCollaborator(DECK_ID)).resolves.toEqual({
+ deckId: DECK_ID,
+ userId: OTHER_USER_ID,
+ deck: { userId: USER_ID, collaborationEnabled: true },
+ });
+ });
+});
diff --git a/lib/auth/deck-access.ts b/lib/auth/deck-access.ts
index 10217dd..ca3a26f 100644
--- a/lib/auth/deck-access.ts
+++ b/lib/auth/deck-access.ts
@@ -35,3 +35,46 @@ export async function requireDeckViewable(
if (deck.visibility === "PRIVATE" && !isOwner) notFound();
return { isOwner };
}
+
+/**
+ * A candidate can propose changes to a deck when collaboration is enabled,
+ * they aren't the owner, and the *owner* follows them — eligibility is a
+ * direction check on `Follow`, not a separate invite/allowlist.
+ */
+export async function canCollaborateOnDeck(
+ deck: { userId: string; collaborationEnabled: boolean },
+ sessionUserId: string | undefined,
+): Promise {
+ if (!deck.collaborationEnabled) return false;
+ if (!sessionUserId || sessionUserId === deck.userId) return false;
+ const follow = await prisma.follow.findUnique({
+ where: {
+ followerId_followingId: {
+ followerId: deck.userId,
+ followingId: sessionUserId,
+ },
+ },
+ select: { followerId: true },
+ });
+ return follow !== null;
+}
+
+/**
+ * 404s on missing deck *and* on ineligible collaborator so callers can't
+ * probe deck existence or collaboration state.
+ */
+export async function requireDeckCollaborator(deckId: string): Promise<{
+ deckId: string;
+ userId: string;
+ deck: { userId: string; collaborationEnabled: boolean };
+}> {
+ const session = await requireSession();
+ const deck = await prisma.deck.findUnique({
+ where: { id: deckId },
+ select: { userId: true, collaborationEnabled: true },
+ });
+ if (!deck) notFound();
+ const eligible = await canCollaborateOnDeck(deck, session.userId);
+ if (!eligible) notFound();
+ return { deckId, userId: session.userId, deck };
+}
diff --git a/lib/deck/cache-tags.ts b/lib/deck/cache-tags.ts
index 110a8ee..5341fb3 100644
--- a/lib/deck/cache-tags.ts
+++ b/lib/deck/cache-tags.ts
@@ -54,6 +54,11 @@ export function deckRevisionsTag(deckId: string): string {
return `deck:${deckId}:revisions`;
}
+/** Per-deck proposal-list tag (drives the collaboration review view). */
+export function deckProposalsTag(deckId: string): string {
+ return `deck:${deckId}:proposals`;
+}
+
/**
* Per-deck like-count tag.
*
diff --git a/lib/deck/group-deltas.ts b/lib/deck/group-deltas.ts
new file mode 100644
index 0000000..a3f8e4a
--- /dev/null
+++ b/lib/deck/group-deltas.ts
@@ -0,0 +1,37 @@
+import type { Zone } from "@/lib/generated/prisma/enums";
+import type { RevisionDelta } from "@/lib/deck/revision";
+
+const ZONE_ORDER: Zone[] = [
+ "COMMANDER",
+ "COMPANION",
+ "MAINBOARD",
+ "SIDEBOARD",
+ "CONSIDERING",
+];
+
+/**
+ * Groups revision-shaped deltas by zone in a fixed display order (commander
+ * first, then companion/mainboard/sideboard/considering), each zone's deltas
+ * sorted additions-first then alphabetically. Shared by the revision history
+ * list and the proposal review list, which render the same delta shape.
+ */
+export function groupDeltasByZone(
+ deltas: readonly RevisionDelta[],
+): Array<{ zone: Zone; deltas: RevisionDelta[] }> {
+ const byZone = new Map();
+ for (const d of deltas) {
+ const list = byZone.get(d.zone) ?? [];
+ list.push(d);
+ byZone.set(d.zone, list);
+ }
+ return ZONE_ORDER.filter((z) => byZone.has(z)).map((zone) => ({
+ zone,
+ deltas: (byZone.get(zone) ?? [])
+ .slice()
+ .sort((a, b) => {
+ const signDiff = Math.sign(b.delta) - Math.sign(a.delta);
+ if (signDiff !== 0) return signDiff;
+ return a.cardName.localeCompare(b.cardName);
+ }),
+ }));
+}
diff --git a/lib/deck/io/__tests__/serialize.test.ts b/lib/deck/io/__tests__/serialize.test.ts
index 300c4b4..19b4614 100644
--- a/lib/deck/io/__tests__/serialize.test.ts
+++ b/lib/deck/io/__tests__/serialize.test.ts
@@ -95,6 +95,7 @@ function makeDeck(
format: "COMMANDER" as Deck["format"],
visibility: "PRIVATE" as Deck["visibility"],
kind: "DECK" as Deck["kind"],
+ collaborationEnabled: false,
manualBracket: null,
forkedFromId: null,
externalSource: null,
diff --git a/lib/deck/mutation/index.ts b/lib/deck/mutation/index.ts
index 0fb2ada..e20f63c 100644
--- a/lib/deck/mutation/index.ts
+++ b/lib/deck/mutation/index.ts
@@ -3,4 +3,5 @@ export { planMutation } from "./plan";
export { runOwnerDeckMutation } from "./runner";
export { InvariantViolation } from "./errors";
export { diffDeck, type ExistingDeckCard } from "./diff";
+export { loadSnapshotForDeck } from "./snapshot";
export type { PlannedChange } from "./types";
diff --git a/lib/deck/queries.ts b/lib/deck/queries.ts
index 26b82a9..5517417 100644
--- a/lib/deck/queries.ts
+++ b/lib/deck/queries.ts
@@ -545,6 +545,7 @@ export async function getDeckById(id: string) {
format: true,
visibility: true,
kind: true,
+ collaborationEnabled: true,
manualBracket: true,
updatedAt: true,
externalSource: true,
diff --git a/lib/deck/revision.ts b/lib/deck/revision.ts
index 39c345b..347c069 100644
--- a/lib/deck/revision.ts
+++ b/lib/deck/revision.ts
@@ -4,7 +4,7 @@ import type { BulkChange } from "@/lib/deck/editor-actions";
import type { ExistingDeckCard } from "@/lib/deck/mutation/diff";
import { logWarn } from "@/lib/telemetry";
-const revisionDeltaSchema = z.object({
+export const revisionDeltaSchema = z.object({
cardId: z.number().int(),
cardName: z.string(),
zone: z.enum(Zone),
diff --git a/prisma/migrations/20260701150501_deck_collaboration/migration.sql b/prisma/migrations/20260701150501_deck_collaboration/migration.sql
new file mode 100644
index 0000000..b80dc93
--- /dev/null
+++ b/prisma/migrations/20260701150501_deck_collaboration/migration.sql
@@ -0,0 +1,33 @@
+-- CreateEnum
+CREATE TYPE "proposal_status" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
+
+-- AlterTable
+ALTER TABLE "deck" ADD COLUMN "collaboration_enabled" BOOLEAN NOT NULL DEFAULT false;
+
+-- CreateTable
+CREATE TABLE "deck_proposal" (
+ "id" TEXT NOT NULL,
+ "deck_id" TEXT NOT NULL,
+ "proposer_id" TEXT NOT NULL,
+ "status" "proposal_status" NOT NULL DEFAULT 'PENDING',
+ "changes" JSONB NOT NULL,
+ "message" TEXT,
+ "resolved_by_id" TEXT,
+ "resolved_at" TIMESTAMP(3),
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "deck_proposal_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "deck_proposal_deck_id_status_idx" ON "deck_proposal"("deck_id", "status");
+
+-- AddForeignKey
+ALTER TABLE "deck_proposal" ADD CONSTRAINT "deck_proposal_deck_id_fkey" FOREIGN KEY ("deck_id") REFERENCES "deck"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "deck_proposal" ADD CONSTRAINT "deck_proposal_proposer_id_fkey" FOREIGN KEY ("proposer_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "deck_proposal" ADD CONSTRAINT "deck_proposal_resolved_by_id_fkey" FOREIGN KEY ("resolved_by_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 741f783..a31b375 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -64,6 +64,14 @@ enum Zone {
@@map("zone")
}
+enum ProposalStatus {
+ PENDING
+ APPROVED
+ REJECTED
+
+ @@map("proposal_status")
+}
+
enum DeckKind {
DECK
WISHLIST
@@ -178,24 +186,26 @@ model IngestLock {
}
model User {
- id String @id @default(cuid())
- email String @unique
- emailVerified Boolean @default(false) @map("email_verified")
- image String?
- name String
- username String @unique
- displayUsername String? @map("display_username")
- dateOfBirth DateTime @map("date_of_birth")
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
- decks Deck[]
- sessions Session[]
- accounts Account[]
- savedDecks SavedDeck[]
- deckLikes DeckLike[]
- holdings Holding[]
- following Follow[] @relation("UserFollowing")
- followers Follow[] @relation("UserFollowers")
+ id String @id @default(cuid())
+ email String @unique
+ emailVerified Boolean @default(false) @map("email_verified")
+ image String?
+ name String
+ username String @unique
+ displayUsername String? @map("display_username")
+ dateOfBirth DateTime @map("date_of_birth")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ decks Deck[]
+ sessions Session[]
+ accounts Account[]
+ savedDecks SavedDeck[]
+ deckLikes DeckLike[]
+ holdings Holding[]
+ following Follow[] @relation("UserFollowing")
+ followers Follow[] @relation("UserFollowers")
+ proposals DeckProposal[] @relation("DeckProposalProposer")
+ resolvedProposals DeckProposal[] @relation("DeckProposalResolver")
@@map("user")
}
@@ -249,29 +259,31 @@ model Verification {
}
model Deck {
- id String @id @default(cuid())
- userId String @map("user_id")
- name String
- description String?
- format Format @default(COMMANDER)
- visibility Visibility @default(PRIVATE)
- kind DeckKind @default(DECK)
- manualBracket Int? @map("manual_bracket")
- forkedFromId String? @map("forked_from_id")
- externalSource String? @map("external_source")
- externalId String? @map("external_id")
- externalVersion String? @map("external_version")
- releasedAt DateTime? @map("released_at") @db.Date
- createdAt DateTime @default(now()) @map("created_at")
- updatedAt DateTime @updatedAt @map("updated_at")
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- forkedFrom Deck? @relation("DeckForks", fields: [forkedFromId], references: [id], onDelete: SetNull)
- forks Deck[] @relation("DeckForks")
- cards DeckCard[]
- categories DeckCategory[]
- revisions DeckRevision[]
- savedBy SavedDeck[]
- likes DeckLike[]
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ name String
+ description String?
+ format Format @default(COMMANDER)
+ visibility Visibility @default(PRIVATE)
+ kind DeckKind @default(DECK)
+ collaborationEnabled Boolean @default(false) @map("collaboration_enabled")
+ manualBracket Int? @map("manual_bracket")
+ forkedFromId String? @map("forked_from_id")
+ externalSource String? @map("external_source")
+ externalId String? @map("external_id")
+ externalVersion String? @map("external_version")
+ releasedAt DateTime? @map("released_at") @db.Date
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ forkedFrom Deck? @relation("DeckForks", fields: [forkedFromId], references: [id], onDelete: SetNull)
+ forks Deck[] @relation("DeckForks")
+ cards DeckCard[]
+ categories DeckCategory[]
+ revisions DeckRevision[]
+ savedBy SavedDeck[]
+ likes DeckLike[]
+ proposals DeckProposal[]
@@unique([externalSource, externalId], map: "deck_external_key")
@@index([userId, kind])
@@ -329,6 +341,25 @@ model DeckRevision {
@@map("deck_revision")
}
+model DeckProposal {
+ id String @id @default(cuid())
+ deckId String @map("deck_id")
+ proposerId String @map("proposer_id")
+ status ProposalStatus @default(PENDING)
+ changes Json
+ message String?
+ resolvedById String? @map("resolved_by_id")
+ resolvedAt DateTime? @map("resolved_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
+ proposer User @relation("DeckProposalProposer", fields: [proposerId], references: [id], onDelete: Cascade)
+ resolvedBy User? @relation("DeckProposalResolver", fields: [resolvedById], references: [id], onDelete: SetNull)
+
+ @@index([deckId, status])
+ @@map("deck_proposal")
+}
+
model SavedDeck {
userId String @map("user_id")
deckId String @map("deck_id")
From 776d3b0e8114f4b7633e6b72b53665905a7d1d2e Mon Sep 17 00:00:00 2001
From: Jarrod Servilla
Date: Wed, 1 Jul 2026 11:38:54 -0400
Subject: [PATCH 2/5] test(deck): close coverage gaps left by collaboration
feature
collaboration.ts and group-deltas.ts fell under the 100% line/function
threshold after issue #27 landed. Add tests for submitDeckProposal's
structural-violation and drifted-deckCardId branches, listDeckProposals,
the not-found path in loadPendingProposal, and groupDeltasByZone (which
had no test file at all).
---
.../deck/__tests__/collaboration.test.ts | 103 ++++++++++++++++++
lib/deck/__tests__/group-deltas.test.ts | 80 ++++++++++++++
2 files changed, 183 insertions(+)
create mode 100644 lib/deck/__tests__/group-deltas.test.ts
diff --git a/app/_actions/deck/__tests__/collaboration.test.ts b/app/_actions/deck/__tests__/collaboration.test.ts
index cce664a..23022af 100644
--- a/app/_actions/deck/__tests__/collaboration.test.ts
+++ b/app/_actions/deck/__tests__/collaboration.test.ts
@@ -37,6 +37,7 @@ vi.mock("@/lib/db", () => ({
},
deckProposal: {
create: vi.fn(),
+ findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
@@ -46,8 +47,10 @@ vi.mock("@/lib/db", () => ({
import { prisma } from "@/lib/db";
import { requireSession } from "@/lib/auth/session";
import { applyChanges } from "@/lib/deck/mutation";
+import { StructuralViolation } from "@/lib/deck/mutation/errors";
import {
approveDeckProposal,
+ listDeckProposals,
rejectDeckProposal,
submitDeckProposal,
toggleDeckCollaboration,
@@ -59,6 +62,7 @@ const mockFollowFindUnique = vi.mocked(prisma.follow.findUnique);
const mockDeckCardFindMany = vi.mocked(prisma.deckCard.findMany);
const mockCardFindMany = vi.mocked(prisma.card.findMany);
const mockProposalCreate = vi.mocked(prisma.deckProposal.create);
+const mockProposalFindMany = vi.mocked(prisma.deckProposal.findMany);
const mockProposalFindUnique = vi.mocked(prisma.deckProposal.findUnique);
const mockProposalUpdate = vi.mocked(prisma.deckProposal.update);
const mockRequireSession = vi.mocked(requireSession);
@@ -173,6 +177,94 @@ describe("submitDeckProposal", () => {
await expect(submitDeckProposal(DECK_ID, [])).rejects.toThrow();
expect(mockProposalCreate).not.toHaveBeenCalled();
});
+
+ it("rejects a delta that pairs a category with a non-mainboard zone", async () => {
+ mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockFollowFindUnique.mockResolvedValue({ followerId: OWNER_ID } as never);
+ mockDeckCardFindMany.mockResolvedValue([] as never);
+
+ const badDelta = [
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.SIDEBOARD,
+ category: "Ramp",
+ delta: 1,
+ },
+ ];
+
+ await expect(submitDeckProposal(DECK_ID, badDelta)).rejects.toThrow(
+ StructuralViolation,
+ );
+ expect(mockProposalCreate).not.toHaveBeenCalled();
+ });
+
+ it("rejects a proposal whose resolved deckCardId has drifted off the current snapshot", async () => {
+ mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ // The deck's current snapshot no longer has this row (raced with another edit).
+ mockDeckFindUnique.mockResolvedValue(deckRow({ cards: [] }) as never);
+ mockFollowFindUnique.mockResolvedValue({ followerId: OWNER_ID } as never);
+ mockDeckCardFindMany.mockResolvedValue([
+ {
+ id: "dc-stale",
+ cardId: 1,
+ zone: Zone.MAINBOARD,
+ category: null,
+ quantity: 1,
+ },
+ ] as never);
+
+ const removeDelta = [
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ category: null,
+ delta: -1,
+ },
+ ];
+
+ await expect(submitDeckProposal(DECK_ID, removeDelta)).rejects.toThrow(
+ "Proposal references a card that is no longer on the deck.",
+ );
+ expect(mockProposalCreate).not.toHaveBeenCalled();
+ });
+});
+
+describe("listDeckProposals", () => {
+ it("returns proposals for the deck owner", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockProposalFindMany.mockResolvedValue([
+ {
+ id: "prop-1",
+ status: "PENDING",
+ changes: [],
+ message: null,
+ createdAt: new Date(),
+ resolvedAt: null,
+ proposer: { id: PROPOSER_ID, username: "proposer", image: null },
+ },
+ ] as never);
+
+ const proposals = await listDeckProposals(DECK_ID);
+
+ expect(proposals).toHaveLength(1);
+ expect(mockProposalFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { deckId: DECK_ID } }),
+ );
+ });
+
+ it("404s for a non-owner", async () => {
+ mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+
+ await expect(listDeckProposals(DECK_ID)).rejects.toThrow(
+ "NEXT_NOT_FOUND",
+ );
+ expect(mockProposalFindMany).not.toHaveBeenCalled();
+ });
});
describe("submit → approve happy path", () => {
@@ -262,6 +354,17 @@ describe("submit → reject", () => {
});
describe("approveDeckProposal on a stale or already-resolved proposal", () => {
+ it("throws when the proposal doesn't exist", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockProposalFindUnique.mockResolvedValue(null);
+
+ await expect(approveDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
+ "Proposal not found",
+ );
+ expect(mockApplyChanges).not.toHaveBeenCalled();
+ });
+
it("throws without applying changes or resolving the proposal, when the targeted card is no longer on the deck", async () => {
mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
mockDeckFindUnique.mockResolvedValue(deckRow() as never);
diff --git a/lib/deck/__tests__/group-deltas.test.ts b/lib/deck/__tests__/group-deltas.test.ts
new file mode 100644
index 0000000..47bd43b
--- /dev/null
+++ b/lib/deck/__tests__/group-deltas.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest";
+import { Zone } from "@/lib/generated/prisma/enums";
+import { groupDeltasByZone } from "@/lib/deck/group-deltas";
+import type { RevisionDelta } from "@/lib/deck/revision";
+
+function delta(
+ cardId: number,
+ cardName: string,
+ d: number,
+ opts: { zone?: Zone; category?: string | null } = {},
+): RevisionDelta {
+ return {
+ cardId,
+ cardName,
+ zone: opts.zone ?? Zone.MAINBOARD,
+ category: opts.category ?? null,
+ delta: d,
+ };
+}
+
+describe("groupDeltasByZone", () => {
+ it("returns an empty array for no deltas", () => {
+ expect(groupDeltasByZone([])).toEqual([]);
+ });
+
+ it("orders zones commander, companion, mainboard, sideboard, considering", () => {
+ const deltas = [
+ delta(1, "Sol Ring", 1, { zone: Zone.CONSIDERING }),
+ delta(2, "Lurrus", 1, { zone: Zone.COMPANION }),
+ delta(3, "Arahbo", 1, { zone: Zone.COMMANDER }),
+ delta(4, "Swords to Plowshares", 1, { zone: Zone.SIDEBOARD }),
+ delta(5, "Wrath of God", 1, { zone: Zone.MAINBOARD }),
+ ];
+
+ expect(groupDeltasByZone(deltas).map((g) => g.zone)).toEqual([
+ Zone.COMMANDER,
+ Zone.COMPANION,
+ Zone.MAINBOARD,
+ Zone.SIDEBOARD,
+ Zone.CONSIDERING,
+ ]);
+ });
+
+ it("omits zones with no deltas", () => {
+ const deltas = [delta(1, "Sol Ring", 1, { zone: Zone.MAINBOARD })];
+
+ const groups = groupDeltasByZone(deltas);
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0]!.zone).toBe(Zone.MAINBOARD);
+ });
+
+ it("sorts additions before removals within a zone", () => {
+ const deltas = [
+ delta(1, "Zzz Removal", -1),
+ delta(2, "Aaa Addition", 1),
+ ];
+
+ const [group] = groupDeltasByZone(deltas);
+
+ expect(group!.deltas.map((d) => d.cardName)).toEqual([
+ "Aaa Addition",
+ "Zzz Removal",
+ ]);
+ });
+
+ it("sorts same-sign deltas alphabetically by card name", () => {
+ const deltas = [
+ delta(1, "Zendikar Resurgent", 1),
+ delta(2, "Arcane Signet", 1),
+ ];
+
+ const [group] = groupDeltasByZone(deltas);
+
+ expect(group!.deltas.map((d) => d.cardName)).toEqual([
+ "Arcane Signet",
+ "Zendikar Resurgent",
+ ]);
+ });
+});
From 0c054d8851ab9f4c3a26e5720eac99e25440d04e Mon Sep 17 00:00:00 2001
From: Jarrod Servilla
Date: Wed, 1 Jul 2026 12:10:12 -0400
Subject: [PATCH 3/5] fix(deck): close TOCTOU race in proposal approve/reject
Confirmed review findings for PR #82: approveDeckProposal did
read-check-status -> plan -> applyChanges -> deckProposal.update as
four non-atomic steps with no WHERE status='PENDING' guard on the
final write, so two concurrent approvals (double-click, retry, two
tabs) could both apply the same proposal's deltas. Two proposals
adding the same new card, approved back-to-back, could also each see
"no existing row" and create duplicate DeckCard rows.
The fix:
- Thread an optional Prisma.TransactionClient through
loadSnapshotForDeck, applyChanges, and planProposalChanges so the
proposal flow can share one transaction end to end.
- Rewrite approveDeckProposal/rejectDeckProposal as a single
prisma.$transaction: a per-deck pg_advisory_xact_lock serializes
concurrent approvals, then an atomic updateMany CAS
(id + deckId + status='PENDING') guards the status flip. On approve,
applyChanges now runs inside that same transaction, so a stale
proposal rolls back the CAS flip instead of leaving it stuck
"approved but not applied". Delete loadPendingProposal, whose
read-then-check-status shape was the pattern being removed.
- Add a ConfirmDialog to Approve/Reject in
deck-proposal-review-list.tsx, matching the existing RevertButton
convention instead of a bare button + manual error state.
- Add CAS/lock regression tests and tx-passthrough coverage in
apply.test.ts.
Known follow-up (not bundled here): the advisory lock only serializes
approval-driven writes against each other, not a concurrent manual
deck edit touching the same new card. Full closure needs a DB unique
constraint on (deckId, cardId, zone, category).
---
.../deck/__tests__/collaboration.test.ts | 173 ++++++++++++------
app/_actions/deck/collaboration.ts | 103 ++++++-----
.../deck/deck-proposal-review-list.tsx | 128 +++++++------
lib/deck/mutation/__tests__/apply.test.ts | 96 ++++++++++
lib/deck/mutation/apply.ts | 19 +-
lib/deck/mutation/snapshot.ts | 7 +-
6 files changed, 361 insertions(+), 165 deletions(-)
diff --git a/app/_actions/deck/__tests__/collaboration.test.ts b/app/_actions/deck/__tests__/collaboration.test.ts
index 23022af..2f3041d 100644
--- a/app/_actions/deck/__tests__/collaboration.test.ts
+++ b/app/_actions/deck/__tests__/collaboration.test.ts
@@ -20,8 +20,13 @@ vi.mock("@/lib/deck/mutation", async () => {
applyChanges: vi.fn(async () => undefined),
};
});
-vi.mock("@/lib/db", () => ({
- prisma: {
+vi.mock("@/lib/db", () => {
+ // Declared as a local so `$transaction` can close over the finished
+ // object and hand callers the same mock as `tx` — fine for asserting
+ // call shape; it doesn't exercise real transaction semantics.
+ const prismaMock = {
+ $transaction: vi.fn((cb: (tx: unknown) => unknown) => cb(prismaMock)),
+ $executeRaw: vi.fn(),
deck: {
findUnique: vi.fn(),
update: vi.fn(),
@@ -38,11 +43,12 @@ vi.mock("@/lib/db", () => ({
deckProposal: {
create: vi.fn(),
findMany: vi.fn(),
- findUnique: vi.fn(),
- update: vi.fn(),
+ updateMany: vi.fn(),
+ findUniqueOrThrow: vi.fn(),
},
- },
-}));
+ };
+ return { prisma: prismaMock };
+});
import { prisma } from "@/lib/db";
import { requireSession } from "@/lib/auth/session";
@@ -63,8 +69,11 @@ const mockDeckCardFindMany = vi.mocked(prisma.deckCard.findMany);
const mockCardFindMany = vi.mocked(prisma.card.findMany);
const mockProposalCreate = vi.mocked(prisma.deckProposal.create);
const mockProposalFindMany = vi.mocked(prisma.deckProposal.findMany);
-const mockProposalFindUnique = vi.mocked(prisma.deckProposal.findUnique);
-const mockProposalUpdate = vi.mocked(prisma.deckProposal.update);
+const mockProposalUpdateMany = vi.mocked(prisma.deckProposal.updateMany);
+const mockProposalFindUniqueOrThrow = vi.mocked(
+ prisma.deckProposal.findUniqueOrThrow,
+);
+const mockExecuteRaw = vi.mocked(prisma.$executeRaw);
const mockRequireSession = vi.mocked(requireSession);
const mockApplyChanges = vi.mocked(applyChanges);
@@ -87,6 +96,16 @@ function deckRow(overrides?: Partial>) {
};
}
+const addSolRing = [
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ category: null,
+ delta: 1,
+ },
+];
+
beforeEach(() => {
vi.clearAllMocks();
mockCardFindMany.mockResolvedValue([
@@ -125,16 +144,6 @@ describe("toggleDeckCollaboration", () => {
});
describe("submitDeckProposal", () => {
- const addSolRing = [
- {
- cardId: 1,
- cardName: "Sol Ring",
- zone: Zone.MAINBOARD,
- category: null,
- delta: 1,
- },
- ];
-
it("creates a pending proposal for an eligible collaborator", async () => {
mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
mockDeckFindUnique.mockResolvedValue(deckRow() as never);
@@ -268,24 +277,12 @@ describe("listDeckProposals", () => {
});
describe("submit → approve happy path", () => {
- const addSolRing = [
- {
- cardId: 1,
- cardName: "Sol Ring",
- zone: Zone.MAINBOARD,
- category: null,
- delta: 1,
- },
- ];
-
it("approves a pending proposal, applies it, and attributes the resulting revision to the proposer", async () => {
mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
mockDeckFindUnique.mockResolvedValue(deckRow() as never);
- mockProposalFindUnique.mockResolvedValue({
- id: "prop-1",
- deckId: DECK_ID,
+ mockProposalUpdateMany.mockResolvedValue({ count: 1 } as never);
+ mockProposalFindUniqueOrThrow.mockResolvedValue({
proposerId: PROPOSER_ID,
- status: "PENDING",
changes: addSolRing,
} as never);
mockDeckCardFindMany.mockResolvedValue([] as never);
@@ -293,12 +290,16 @@ describe("submit → approve happy path", () => {
await approveDeckProposal(DECK_ID, "prop-1");
expect(mockApplyChanges).toHaveBeenCalledTimes(1);
- const [appliedDeckId, actorId] = mockApplyChanges.mock.calls[0]!;
+ const [appliedDeckId, actorId, , opts] = mockApplyChanges.mock.calls[0]!;
expect(appliedDeckId).toBe(DECK_ID);
// Attributed to the proposer, not the approving owner.
expect(actorId).toBe(PROPOSER_ID);
- expect(mockProposalUpdate).toHaveBeenCalledWith({
- where: { id: "prop-1" },
+ // applyChanges must run inside the same transaction as the CAS flip.
+ expect(opts).toEqual(
+ expect.objectContaining({ tx: expect.anything() }),
+ );
+ expect(mockProposalUpdateMany).toHaveBeenCalledWith({
+ where: { id: "prop-1", deckId: DECK_ID, status: "PENDING" },
data: expect.objectContaining({
status: "APPROVED",
resolvedById: OWNER_ID,
@@ -321,20 +322,14 @@ describe("submit → reject", () => {
it("marks a pending proposal rejected and never touches the deck", async () => {
mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
mockDeckFindUnique.mockResolvedValue(deckRow() as never);
- mockProposalFindUnique.mockResolvedValue({
- id: "prop-1",
- deckId: DECK_ID,
- proposerId: PROPOSER_ID,
- status: "PENDING",
- changes: [],
- } as never);
+ mockProposalUpdateMany.mockResolvedValue({ count: 1 } as never);
await rejectDeckProposal(DECK_ID, "prop-1");
expect(mockApplyChanges).not.toHaveBeenCalled();
expect(mockDeckCardFindMany).not.toHaveBeenCalled();
- expect(mockProposalUpdate).toHaveBeenCalledWith({
- where: { id: "prop-1" },
+ expect(mockProposalUpdateMany).toHaveBeenCalledWith({
+ where: { id: "prop-1", deckId: DECK_ID, status: "PENDING" },
data: expect.objectContaining({
status: "REJECTED",
resolvedById: OWNER_ID,
@@ -349,30 +344,37 @@ describe("submit → reject", () => {
await expect(rejectDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
"NEXT_NOT_FOUND",
);
- expect(mockProposalUpdate).not.toHaveBeenCalled();
+ expect(mockProposalUpdateMany).not.toHaveBeenCalled();
});
});
describe("approveDeckProposal on a stale or already-resolved proposal", () => {
- it("throws when the proposal doesn't exist", async () => {
+ it("throws (the collapsed 'already resolved' message) when the proposal doesn't exist", async () => {
+ // The CAS `updateMany` matches on id+deckId+status=PENDING, so a
+ // missing row and an already-resolved row are indistinguishable from
+ // its `count`; both now surface as the single "already resolved"
+ // message instead of the old two-message split. This is intentional,
+ // not a regression — see plan notes.
mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
mockDeckFindUnique.mockResolvedValue(deckRow() as never);
- mockProposalFindUnique.mockResolvedValue(null);
+ mockProposalUpdateMany.mockResolvedValue({ count: 0 } as never);
await expect(approveDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
- "Proposal not found",
+ /already been resolved/,
);
expect(mockApplyChanges).not.toHaveBeenCalled();
});
- it("throws without applying changes or resolving the proposal, when the targeted card is no longer on the deck", async () => {
+ it("throws without applying changes, when the targeted card is no longer on the deck", async () => {
+ // In real Postgres, throwing here rolls back the entire transaction —
+ // including the CAS flip above — leaving the proposal cleanly PENDING
+ // rather than stuck "approved but not applied". The mock can't exercise
+ // that rollback; it only proves `applyChanges` is never reached.
mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
mockDeckFindUnique.mockResolvedValue(deckRow() as never);
- mockProposalFindUnique.mockResolvedValue({
- id: "prop-1",
- deckId: DECK_ID,
+ mockProposalUpdateMany.mockResolvedValue({ count: 1 } as never);
+ mockProposalFindUniqueOrThrow.mockResolvedValue({
proposerId: PROPOSER_ID,
- status: "PENDING",
changes: [
{
cardId: 1,
@@ -390,23 +392,74 @@ describe("approveDeckProposal on a stale or already-resolved proposal", () => {
/no longer on the deck/,
);
expect(mockApplyChanges).not.toHaveBeenCalled();
- expect(mockProposalUpdate).not.toHaveBeenCalled();
});
it("throws when the proposal has already been resolved", async () => {
mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
mockDeckFindUnique.mockResolvedValue(deckRow() as never);
- mockProposalFindUnique.mockResolvedValue({
- id: "prop-1",
- deckId: DECK_ID,
+ mockProposalUpdateMany.mockResolvedValue({ count: 0 } as never);
+
+ await expect(approveDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
+ /already been resolved/,
+ );
+ expect(mockApplyChanges).not.toHaveBeenCalled();
+ });
+});
+
+describe("approveDeckProposal — CAS guard against double-apply", () => {
+ it("the second of two concurrent approvals is rejected by the atomic status flip, and applyChanges runs only once", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockDeckCardFindMany.mockResolvedValue([] as never);
+ mockProposalUpdateMany
+ .mockResolvedValueOnce({ count: 1 } as never)
+ .mockResolvedValueOnce({ count: 0 } as never);
+ mockProposalFindUniqueOrThrow.mockResolvedValue({
proposerId: PROPOSER_ID,
- status: "APPROVED",
- changes: [],
+ changes: addSolRing,
} as never);
+ await approveDeckProposal(DECK_ID, "prop-1");
await expect(approveDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
/already been resolved/,
);
- expect(mockApplyChanges).not.toHaveBeenCalled();
+
+ expect(mockProposalUpdateMany).toHaveBeenCalledTimes(2);
+ expect(mockApplyChanges).toHaveBeenCalledTimes(1);
+ });
+
+ it("issues the per-deck advisory lock before the atomic status flip", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockDeckCardFindMany.mockResolvedValue([] as never);
+ mockProposalUpdateMany.mockResolvedValue({ count: 1 } as never);
+ mockProposalFindUniqueOrThrow.mockResolvedValue({
+ proposerId: PROPOSER_ID,
+ changes: addSolRing,
+ } as never);
+
+ await approveDeckProposal(DECK_ID, "prop-1");
+
+ expect(mockExecuteRaw).toHaveBeenCalledTimes(1);
+ expect(mockExecuteRaw.mock.invocationCallOrder[0]).toBeLessThan(
+ mockProposalUpdateMany.mock.invocationCallOrder[0]!,
+ );
+ });
+});
+
+describe("rejectDeckProposal — CAS guard against double-resolve", () => {
+ it("the second of two concurrent rejections is rejected by the atomic status flip", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ mockProposalUpdateMany
+ .mockResolvedValueOnce({ count: 1 } as never)
+ .mockResolvedValueOnce({ count: 0 } as never);
+
+ await rejectDeckProposal(DECK_ID, "prop-1");
+ await expect(rejectDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
+ /already been resolved/,
+ );
+
+ expect(mockProposalUpdateMany).toHaveBeenCalledTimes(2);
});
});
diff --git a/app/_actions/deck/collaboration.ts b/app/_actions/deck/collaboration.ts
index a24adfa..2f703de 100644
--- a/app/_actions/deck/collaboration.ts
+++ b/app/_actions/deck/collaboration.ts
@@ -3,6 +3,7 @@
import "server-only";
import { z } from "zod";
import { prisma } from "@/lib/db";
+import type { Prisma } from "@/lib/generated/prisma/client";
import { requireDeckOwner, requireDeckCollaborator } from "@/lib/auth/deck-access";
import { withActionLogging } from "@/lib/telemetry";
import {
@@ -51,8 +52,10 @@ export const toggleDeckCollaboration = withActionLogging(
async function planProposalChanges(
deckId: string,
deltas: readonly RevisionDelta[],
+ tx?: Prisma.TransactionClient,
): Promise {
- const rows = await prisma.deckCard.findMany({
+ const client = tx ?? prisma;
+ const rows = await client.deckCard.findMany({
where: { deckId },
select: {
id: true,
@@ -165,49 +168,49 @@ export const listDeckProposals = withActionLogging(
},
);
-async function loadPendingProposal(deckId: string, proposalId: string) {
- const proposal = await prisma.deckProposal.findUnique({
- where: { id: proposalId },
- select: {
- id: true,
- deckId: true,
- proposerId: true,
- status: true,
- changes: true,
- },
- });
- if (!proposal || proposal.deckId !== deckId) {
- throw new Error("Proposal not found");
- }
- if (proposal.status !== ProposalStatus.PENDING) {
- throw new Error("Proposal has already been resolved");
- }
- return proposal;
-}
-
export const approveDeckProposal = withActionLogging(
"deck.approveProposal",
async (deckId: string, proposalId: string): Promise => {
const { userId: ownerId } = await requireDeckOwner(deckId);
- const proposal = await loadPendingProposal(deckId, proposalId);
- const deltas = revisionDeltaSchema.array().parse(proposal.changes);
- // Re-validated against current deck state (may have drifted since
- // submission); a stale proposal throws here instead of partially
- // applying, so the owner can reject it.
- const plannedChanges = await planProposalChanges(deckId, deltas);
-
- // Attributed to the proposer, not the owner, so the resulting
- // DeckRevision credits the contributor who made the change.
- await applyChanges(deckId, proposal.proposerId, plannedChanges);
-
- await prisma.deckProposal.update({
- where: { id: proposalId },
- data: {
- status: ProposalStatus.APPROVED,
- resolvedById: ownerId,
- resolvedAt: new Date(),
- },
+ await prisma.$transaction(async (tx) => {
+ // Serializes all approvals for this deck so two different PENDING
+ // proposals can't both plan against "no existing row" for the same
+ // new card and each emit a duplicate `create`. Auto-released on
+ // commit/rollback.
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${deckId}))`;
+
+ // Atomic compare-and-swap: only one concurrent caller can flip a
+ // given proposal off PENDING. The `count === 0` branch collapses
+ // "not found" and "already resolved" into one message — safe because
+ // production error messages are redacted by Next.js regardless.
+ const { count } = await tx.deckProposal.updateMany({
+ where: { id: proposalId, deckId, status: ProposalStatus.PENDING },
+ data: {
+ status: ProposalStatus.APPROVED,
+ resolvedById: ownerId,
+ resolvedAt: new Date(),
+ },
+ });
+ if (count === 0) {
+ throw new Error("Proposal has already been resolved");
+ }
+
+ const proposal = await tx.deckProposal.findUniqueOrThrow({
+ where: { id: proposalId },
+ select: { proposerId: true, changes: true },
+ });
+ const deltas = revisionDeltaSchema.array().parse(proposal.changes);
+
+ // Re-validated against current deck state (may have drifted since
+ // submission); a stale proposal throws here, rolling back the CAS
+ // flip above too, so the proposal is left cleanly PENDING rather
+ // than stuck "approved but not applied".
+ const plannedChanges = await planProposalChanges(deckId, deltas, tx);
+
+ // Attributed to the proposer, not the owner, so the resulting
+ // DeckRevision credits the contributor who made the change.
+ await applyChanges(deckId, proposal.proposerId, plannedChanges, { tx });
});
invalidateTags([deckProposalsTag(deckId)]);
@@ -218,15 +221,21 @@ export const rejectDeckProposal = withActionLogging(
"deck.rejectProposal",
async (deckId: string, proposalId: string): Promise => {
const { userId: ownerId } = await requireDeckOwner(deckId);
- await loadPendingProposal(deckId, proposalId);
- await prisma.deckProposal.update({
- where: { id: proposalId },
- data: {
- status: ProposalStatus.REJECTED,
- resolvedById: ownerId,
- resolvedAt: new Date(),
- },
+ await prisma.$transaction(async (tx) => {
+ // Same CAS guard as approve; no advisory lock needed since reject
+ // never touches `DeckCard`, so there's no write to serialize against.
+ const { count } = await tx.deckProposal.updateMany({
+ where: { id: proposalId, deckId, status: ProposalStatus.PENDING },
+ data: {
+ status: ProposalStatus.REJECTED,
+ resolvedById: ownerId,
+ resolvedAt: new Date(),
+ },
+ });
+ if (count === 0) {
+ throw new Error("Proposal has already been resolved");
+ }
});
invalidateTags([deckProposalsTag(deckId)]);
diff --git a/app/_components/deck/deck-proposal-review-list.tsx b/app/_components/deck/deck-proposal-review-list.tsx
index 1b9fd22..345db12 100644
--- a/app/_components/deck/deck-proposal-review-list.tsx
+++ b/app/_components/deck/deck-proposal-review-list.tsx
@@ -1,9 +1,9 @@
"use client";
-import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import {
approveDeckProposal,
rejectDeckProposal,
@@ -87,38 +87,8 @@ function ProposalCard({
proposal: DeckProposalView;
}) {
const router = useRouter();
- const [pending, startTransition] = useTransition();
- const [error, setError] = useState(null);
const grouped = groupDeltasByZone(proposal.changes);
- function handleApprove() {
- setError(null);
- startTransition(async () => {
- try {
- await approveDeckProposal(deckId, proposal.id);
- router.refresh();
- } catch (e) {
- setError(
- e instanceof Error ? e.message : "Could not approve this proposal.",
- );
- }
- });
- }
-
- function handleReject() {
- setError(null);
- startTransition(async () => {
- try {
- await rejectDeckProposal(deckId, proposal.id);
- router.refresh();
- } catch (e) {
- setError(
- e instanceof Error ? e.message : "Could not reject this proposal.",
- );
- }
- });
- }
-
return (
@@ -135,25 +105,16 @@ function ProposalCard({
{proposal.status === "PENDING" ? (
-
-
- Reject
-
-
-
- Approve
-
+
router.refresh()}
+ />
+ router.refresh()}
+ />
) : (
@@ -168,9 +129,6 @@ function ProposalCard({
“{proposal.message}”
)}
- {error && (
- {error}
- )}
{grouped.map(({ zone, deltas }) => (
{grouped.length > 1 && (
@@ -208,3 +166,67 @@ function ProposalCard({
);
}
+
+function ApproveProposalButton({
+ deckId,
+ proposalId,
+ onResolved,
+}: {
+ deckId: string;
+ proposalId: string;
+ onResolved: () => void;
+}) {
+ async function handleApprove() {
+ await approveDeckProposal(deckId, proposalId);
+ onResolved();
+ }
+
+ return (
+
+
+ Approve
+
+ }
+ onConfirm={handleApprove}
+ />
+ );
+}
+
+function RejectProposalButton({
+ deckId,
+ proposalId,
+ onResolved,
+}: {
+ deckId: string;
+ proposalId: string;
+ onResolved: () => void;
+}) {
+ async function handleReject() {
+ await rejectDeckProposal(deckId, proposalId);
+ onResolved();
+ }
+
+ return (
+
+
+ Reject
+
+ }
+ onConfirm={handleReject}
+ />
+ );
+}
diff --git a/lib/deck/mutation/__tests__/apply.test.ts b/lib/deck/mutation/__tests__/apply.test.ts
index b7c10a8..723ac14 100644
--- a/lib/deck/mutation/__tests__/apply.test.ts
+++ b/lib/deck/mutation/__tests__/apply.test.ts
@@ -423,3 +423,99 @@ describe("applyChanges — basic ops", () => {
expect(mockUpdateTag).not.toHaveBeenCalled();
});
});
+
+describe("applyChanges — external tx passthrough", () => {
+ // Prisma/Postgres don't support nesting real transactions, so when a
+ // caller (e.g. proposal approval) already holds a `tx`, `applyChanges`
+ // must run everything — including the pre-transaction snapshot read —
+ // against that same client instead of opening its own.
+ function externalTx(cards: Array<{ id: string; cardId: number; name: string; quantity: number }>) {
+ return {
+ deck: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: DECK,
+ format: Format.COMMANDER,
+ cards: cards.map((c) => ({
+ id: c.id,
+ cardId: c.cardId,
+ quantity: c.quantity,
+ zone: Zone.MAINBOARD,
+ category: null,
+ printingId: null,
+ isFoil: false,
+ card: {
+ name: c.name,
+ typeLine: "Artifact",
+ colorIdentity: [],
+ legalities: { commander: "legal" },
+ },
+ })),
+ categories: [],
+ }),
+ },
+ card: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: 1,
+ name: "Sol Ring",
+ typeLine: "Artifact",
+ colorIdentity: [],
+ legalities: { commander: "legal" },
+ },
+ ]),
+ },
+ deckCard: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ create: vi.fn().mockResolvedValue({}),
+ update: vi.fn().mockResolvedValue({}),
+ delete: vi.fn().mockResolvedValue({}),
+ },
+ deckRevision: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ create: vi.fn().mockResolvedValue({}),
+ update: vi.fn().mockResolvedValue({}),
+ delete: vi.fn().mockResolvedValue({}),
+ },
+ };
+ }
+
+ it("runs the snapshot read and the writes against the caller's tx, and never opens its own transaction", async () => {
+ const tx = externalTx([]);
+
+ await applyChanges(
+ DECK,
+ USER,
+ [{ op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null }],
+ { tx: tx as never },
+ );
+
+ expect(mockTransaction).not.toHaveBeenCalled();
+ expect(tx.deck.findUnique).toHaveBeenCalled();
+ expect(tx.deckCard.create).toHaveBeenCalled();
+ expect(tx.deckRevision.create).toHaveBeenCalled();
+ // The global client (what prisma.$transaction would have handed back
+ // as its own `tx`) must never see these calls.
+ expect(mockDeckFindUnique).not.toHaveBeenCalled();
+ expect(mockDeckCardCreate).not.toHaveBeenCalled();
+ expect(mockRevisionCreate).not.toHaveBeenCalled();
+ });
+
+ it("still opens its own transaction when opts.tx is omitted", async () => {
+ commanderDeck([]);
+ mockCardFindMany.mockResolvedValue([
+ {
+ id: 1,
+ name: "Sol Ring",
+ typeLine: "Artifact",
+ colorIdentity: [],
+ legalities: { commander: "legal" },
+ },
+ ] as never);
+
+ await applyChanges(DECK, USER, [
+ { op: "add", cardId: 1, quantity: 1, zone: Zone.MAINBOARD, category: null },
+ ]);
+
+ expect(mockTransaction).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/deck/mutation/apply.ts b/lib/deck/mutation/apply.ts
index aed29eb..deb9e9b 100644
--- a/lib/deck/mutation/apply.ts
+++ b/lib/deck/mutation/apply.ts
@@ -59,6 +59,13 @@ export async function applyChanges(
* the inverse, even when it would cancel a recent edit to net zero.
*/
skipMerge?: boolean;
+ /**
+ * Run against the caller's own transaction instead of opening a new one.
+ * Postgres/Prisma don't support nesting real transactions, so callers that
+ * already hold a `tx` (e.g. proposal approval, which needs the plan and
+ * the apply to commit or roll back together) must pass it through here.
+ */
+ tx?: Prisma.TransactionClient;
},
): Promise {
if (changes.length === 0) return;
@@ -66,7 +73,7 @@ export async function applyChanges(
// The write plan is built from this pre-transaction snapshot rather than from
// in-tx re-reads. Single-owner decks make the staleness window negligible; we
// trade the old per-op `findFirst` requery for one consistent projection.
- const before = await loadSnapshotForDeck(deckId, changes);
+ const before = await loadSnapshotForDeck(deckId, changes, opts?.tx);
const { ops, deltas, structural, missingDeckCardId } = planMutation(
before,
changes,
@@ -80,7 +87,7 @@ export async function applyChanges(
throw new Error("Not found or unauthorized");
}
- await prisma.$transaction(async (tx) => {
+ const run = async (tx: Prisma.TransactionClient) => {
await applyOps(tx, deckId, ops);
if (deltas.length > 0) {
await recordDeckRevisionTx(
@@ -91,7 +98,13 @@ export async function applyChanges(
opts?.skipMerge ? { skipMerge: true } : undefined,
);
}
- });
+ };
+
+ if (opts?.tx) {
+ await run(opts.tx);
+ } else {
+ await prisma.$transaction(run);
+ }
// Bulk callers (workflows) skip per-deck invalidation and fan out
// a single `revalidateTag` at the end. `updateTag` is also unsafe
diff --git a/lib/deck/mutation/snapshot.ts b/lib/deck/mutation/snapshot.ts
index 04a9ec0..dadaf31 100644
--- a/lib/deck/mutation/snapshot.ts
+++ b/lib/deck/mutation/snapshot.ts
@@ -1,5 +1,6 @@
import "server-only";
import { prisma } from "@/lib/db";
+import type { Prisma } from "@/lib/generated/prisma/client";
import type { Legalities } from "@/lib/card/types-meta";
import type {
DeckSnapshot,
@@ -22,8 +23,10 @@ type CardMetaRow = {
export async function loadSnapshotForDeck(
deckId: string,
changes: readonly PlannedChange[] = [],
+ tx?: Prisma.TransactionClient,
): Promise {
- const deck = await prisma.deck.findUnique({
+ const client = tx ?? prisma;
+ const deck = await client.deck.findUnique({
where: { id: deckId },
select: {
id: true,
@@ -63,7 +66,7 @@ export async function loadSnapshotForDeck(
let extraMeta: CardMetaRow[] = [];
if (newCardIds.size > 0) {
- extraMeta = (await prisma.card.findMany({
+ extraMeta = (await client.card.findMany({
where: { id: { in: [...newCardIds] } },
select: {
id: true,
From 36f1f8c4a2bff3d04e826cf7b9a0e0b74c0d7d85 Mon Sep 17 00:00:00 2001
From: Jarrod Servilla
Date: Wed, 1 Jul 2026 12:27:38 -0400
Subject: [PATCH 4/5] fix(deck): address remaining collaboration review
findings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- guard toggle/like/visibility controls against overlapping mutations
(capture pending from useTransition, block re-entry + disable control)
- 404 /deck/[id]/propose when the deck no longer exists instead of
rendering "Propose changes to undefined"
- requireDeckCollaborator: use getSession()+notFound() instead of
requireSession()'s sign-in redirect, so anonymous visitors 404 like
every other collaboration entry point instead of leaking that
collaboration is enabled for a deck they can't see
- key deck-propose-draft's quantities/list state by the same
(cardId, zone, category) identity as RevisionDelta/deltaKey, so a
card that appears in mainboard under two categories no longer
collapses into one row
Other flagged findings (TOCTOU/atomicity, groupDeltasByZone sort,
mainboard-only proposal scope) were already resolved by prior commits
or are out of scope for this pass — no change needed.
---
app/(ui)/deck/[id]/propose/page.tsx | 6 ++-
.../deck/__tests__/collaboration.test.ts | 13 ++++---
.../deck/deck-collaboration-toggle.tsx | 4 +-
app/_components/deck/deck-propose-draft.tsx | 38 +++++++++++--------
.../deck/deck-visibility-picker.tsx | 4 +-
app/_components/deck/like-button.tsx | 4 +-
lib/auth/__tests__/deck-access.test.ts | 18 +++++++--
lib/auth/deck-access.ts | 11 ++++--
8 files changed, 65 insertions(+), 33 deletions(-)
diff --git a/app/(ui)/deck/[id]/propose/page.tsx b/app/(ui)/deck/[id]/propose/page.tsx
index f719cd4..da119ad 100644
--- a/app/(ui)/deck/[id]/propose/page.tsx
+++ b/app/(ui)/deck/[id]/propose/page.tsx
@@ -1,4 +1,5 @@
import { Suspense } from "react";
+import { notFound } from "next/navigation";
import { ChevronLeft } from "lucide-react";
import Link from "@/app/_components/link";
import { requireDeckCollaborator } from "@/lib/auth/deck-access";
@@ -12,8 +13,9 @@ interface DeckProposePageProps {
async function DeckProposeContent({ id }: { id: string }) {
await requireDeckCollaborator(id);
const deck = await getDeckById(id);
+ if (!deck) notFound();
- const mainboard = (deck?.cards ?? [])
+ const mainboard = deck.cards
.filter((c) => c.zone === "MAINBOARD")
.map((c) => ({
cardId: c.cardId,
@@ -33,7 +35,7 @@ async function DeckProposeContent({ id }: { id: string }) {
Back to collaborate
- Propose changes to {deck?.name}
+ Propose changes to {deck.name}
Adjust mainboard quantities or add new cards, then submit for the
diff --git a/app/_actions/deck/__tests__/collaboration.test.ts b/app/_actions/deck/__tests__/collaboration.test.ts
index 2f3041d..a72ac03 100644
--- a/app/_actions/deck/__tests__/collaboration.test.ts
+++ b/app/_actions/deck/__tests__/collaboration.test.ts
@@ -51,7 +51,7 @@ vi.mock("@/lib/db", () => {
});
import { prisma } from "@/lib/db";
-import { requireSession } from "@/lib/auth/session";
+import { getSession, requireSession } from "@/lib/auth/session";
import { applyChanges } from "@/lib/deck/mutation";
import { StructuralViolation } from "@/lib/deck/mutation/errors";
import {
@@ -75,6 +75,7 @@ const mockProposalFindUniqueOrThrow = vi.mocked(
);
const mockExecuteRaw = vi.mocked(prisma.$executeRaw);
const mockRequireSession = vi.mocked(requireSession);
+const mockGetSession = vi.mocked(getSession);
const mockApplyChanges = vi.mocked(applyChanges);
const OWNER_ID = "owner-1";
@@ -145,7 +146,7 @@ describe("toggleDeckCollaboration", () => {
describe("submitDeckProposal", () => {
it("creates a pending proposal for an eligible collaborator", async () => {
- mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockGetSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
mockDeckFindUnique.mockResolvedValue(deckRow() as never);
mockFollowFindUnique.mockResolvedValue({ followerId: OWNER_ID } as never);
mockDeckCardFindMany.mockResolvedValue([] as never);
@@ -167,7 +168,7 @@ describe("submitDeckProposal", () => {
});
it("404s a submission from a viewer collaboration is disabled for", async () => {
- mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockGetSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
mockDeckFindUnique.mockResolvedValue(
deckRow({ collaborationEnabled: false }) as never,
);
@@ -179,7 +180,7 @@ describe("submitDeckProposal", () => {
});
it("rejects an empty changes array before touching the deck", async () => {
- mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockGetSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
mockDeckFindUnique.mockResolvedValue(deckRow() as never);
mockFollowFindUnique.mockResolvedValue({ followerId: OWNER_ID } as never);
@@ -188,7 +189,7 @@ describe("submitDeckProposal", () => {
});
it("rejects a delta that pairs a category with a non-mainboard zone", async () => {
- mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockGetSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
mockDeckFindUnique.mockResolvedValue(deckRow() as never);
mockFollowFindUnique.mockResolvedValue({ followerId: OWNER_ID } as never);
mockDeckCardFindMany.mockResolvedValue([] as never);
@@ -210,7 +211,7 @@ describe("submitDeckProposal", () => {
});
it("rejects a proposal whose resolved deckCardId has drifted off the current snapshot", async () => {
- mockRequireSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
+ mockGetSession.mockResolvedValue({ userId: PROPOSER_ID } as never);
// The deck's current snapshot no longer has this row (raced with another edit).
mockDeckFindUnique.mockResolvedValue(deckRow({ cards: [] }) as never);
mockFollowFindUnique.mockResolvedValue({ followerId: OWNER_ID } as never);
diff --git a/app/_components/deck/deck-collaboration-toggle.tsx b/app/_components/deck/deck-collaboration-toggle.tsx
index 0b62fec..53da3a1 100644
--- a/app/_components/deck/deck-collaboration-toggle.tsx
+++ b/app/_components/deck/deck-collaboration-toggle.tsx
@@ -18,9 +18,10 @@ export function DeckCollaborationToggle({
enabled,
(_, next) => next,
);
- const [, startTransition] = useTransition();
+ const [pending, startTransition] = useTransition();
function handleClick() {
+ if (pending) return;
const next = !optimistic;
startTransition(async () => {
setOptimistic(next);
@@ -37,6 +38,7 @@ export function DeckCollaborationToggle({
) {
+ return deltaKey({ cardId: c.cardId, zone: "MAINBOARD", category: c.category });
+}
+
export function DeckProposeDraft({
deckId,
existingCards,
@@ -30,8 +34,8 @@ export function DeckProposeDraft({
const [query, setQuery] = useState("");
const { results, loading } = useCardBrowser(query);
- const [quantities, setQuantities] = useState>(
- () => new Map(existingCards.map((c) => [c.cardId, c.quantity])),
+ const [quantities, setQuantities] = useState>(
+ () => new Map(existingCards.map((c) => [mainboardKey(c), c.quantity])),
);
const [added, setAdded] = useState<
Map
@@ -45,10 +49,10 @@ export function DeckProposeDraft({
[existingCards],
);
- function setQuantity(cardId: number, next: number) {
+ function setQuantity(key: string, next: number) {
setQuantities((prev) => {
const map = new Map(prev);
- map.set(cardId, Math.max(0, next));
+ map.set(key, Math.max(0, next));
return map;
});
}
@@ -80,7 +84,8 @@ export function DeckProposeDraft({
const deltas: RevisionDelta[] = useMemo(() => {
const out: RevisionDelta[] = [];
for (const c of existingCards) {
- const next = quantities.get(c.cardId) ?? c.quantity;
+ const key = mainboardKey(c);
+ const next = quantities.get(key) ?? c.quantity;
const delta = next - c.quantity;
if (delta !== 0) {
out.push({
@@ -131,15 +136,18 @@ export function DeckProposeDraft({
) : (
- {existingCards.map((c) => (
-
- setQuantity(c.cardId, n)}
- />
- {c.cardName}
-
- ))}
+ {existingCards.map((c) => {
+ const key = mainboardKey(c);
+ return (
+
+ setQuantity(key, n)}
+ />
+ {c.cardName}
+
+ );
+ })}
)}
diff --git a/app/_components/deck/deck-visibility-picker.tsx b/app/_components/deck/deck-visibility-picker.tsx
index 86fac73..a22cbfd 100644
--- a/app/_components/deck/deck-visibility-picker.tsx
+++ b/app/_components/deck/deck-visibility-picker.tsx
@@ -51,13 +51,14 @@ export function DeckVisibilityPicker({
visibility,
(_, next) => next,
);
- const [, startTransition] = useTransition();
+ const [pending, startTransition] = useTransition();
const [open, setOpen] = useState(false);
const current = OPTIONS[optimistic];
const { Icon } = current;
function handleChange(next: string) {
+ if (pending) return;
const value = next as Visibility;
if (value === optimistic) return;
startTransition(async () => {
@@ -74,6 +75,7 @@ export function DeckVisibilityPicker({
return (
(
{ liked, count: likeCount },
(state, next) => {
@@ -35,6 +35,7 @@ export function LikeButton({ deckId, likeCount, liked }: LikeButtonProps) {
);
function handleClick() {
+ if (pending) return;
const next = !optimistic.liked;
startTransition(async () => {
setOptimistic(next);
@@ -59,6 +60,7 @@ export function LikeButton({ deckId, likeCount, liked }: LikeButtonProps) {
variant="outline"
size="sm"
onClick={handleClick}
+ disabled={pending}
aria-label={label}
aria-pressed={optimistic.liked}
>
diff --git a/lib/auth/__tests__/deck-access.test.ts b/lib/auth/__tests__/deck-access.test.ts
index 6bb1e2d..3861e5e 100644
--- a/lib/auth/__tests__/deck-access.test.ts
+++ b/lib/auth/__tests__/deck-access.test.ts
@@ -246,7 +246,17 @@ describe("canCollaborateOnDeck", () => {
});
describe("requireDeckCollaborator", () => {
+ it("404s (not redirects) when there is no session", async () => {
+ mockGetSession.mockResolvedValue(null);
+
+ await expect(requireDeckCollaborator(DECK_ID)).rejects.toThrow(
+ "NEXT_NOT_FOUND",
+ );
+ expect(mockDeckFindUnique).not.toHaveBeenCalled();
+ });
+
it("404s when the deck does not exist", async () => {
+ mockGetSession.mockResolvedValue({ userId: OTHER_USER_ID } as never);
mockDeckFindUnique.mockResolvedValue(null);
await expect(requireDeckCollaborator(DECK_ID)).rejects.toThrow(
@@ -255,7 +265,7 @@ describe("requireDeckCollaborator", () => {
});
it("404s when the caller is the deck owner (owner isn't a 'collaborator')", async () => {
- mockRequireSession.mockResolvedValue({ userId: USER_ID } as never);
+ mockGetSession.mockResolvedValue({ userId: USER_ID } as never);
mockDeckFindUnique.mockResolvedValue({
userId: USER_ID,
collaborationEnabled: true,
@@ -268,7 +278,7 @@ describe("requireDeckCollaborator", () => {
});
it("404s when collaboration is disabled on the deck", async () => {
- mockRequireSession.mockResolvedValue({ userId: OTHER_USER_ID } as never);
+ mockGetSession.mockResolvedValue({ userId: OTHER_USER_ID } as never);
mockDeckFindUnique.mockResolvedValue({
userId: USER_ID,
collaborationEnabled: false,
@@ -280,7 +290,7 @@ describe("requireDeckCollaborator", () => {
});
it("404s when the owner does not follow the candidate", async () => {
- mockRequireSession.mockResolvedValue({ userId: OTHER_USER_ID } as never);
+ mockGetSession.mockResolvedValue({ userId: OTHER_USER_ID } as never);
mockDeckFindUnique.mockResolvedValue({
userId: USER_ID,
collaborationEnabled: true,
@@ -293,7 +303,7 @@ describe("requireDeckCollaborator", () => {
});
it("returns deckId/userId/deck when the candidate is an eligible collaborator", async () => {
- mockRequireSession.mockResolvedValue({ userId: OTHER_USER_ID } as never);
+ mockGetSession.mockResolvedValue({ userId: OTHER_USER_ID } as never);
mockDeckFindUnique.mockResolvedValue({
userId: USER_ID,
collaborationEnabled: true,
diff --git a/lib/auth/deck-access.ts b/lib/auth/deck-access.ts
index ca3a26f..a2a998a 100644
--- a/lib/auth/deck-access.ts
+++ b/lib/auth/deck-access.ts
@@ -60,15 +60,20 @@ export async function canCollaborateOnDeck(
}
/**
- * 404s on missing deck *and* on ineligible collaborator so callers can't
- * probe deck existence or collaboration state.
+ * 404s on missing session, missing deck, *and* on ineligible collaborator so
+ * callers can't probe deck existence or collaboration state. Uses
+ * `getSession` (not `requireSession`) so anonymous visitors 404 instead of
+ * being redirected to sign-in — a redirect would itself leak that
+ * collaboration is possibly enabled for a deck they can't otherwise see.
+ * Matches `collaborate/page.tsx`'s inline eligibility check.
*/
export async function requireDeckCollaborator(deckId: string): Promise<{
deckId: string;
userId: string;
deck: { userId: string; collaborationEnabled: boolean };
}> {
- const session = await requireSession();
+ const session = await getSession();
+ if (!session) notFound();
const deck = await prisma.deck.findUnique({
where: { id: deckId },
select: { userId: true, collaborationEnabled: true },
From 8bd8e12997816a9c4c19c4cad2712a629f68a0b9 Mon Sep 17 00:00:00 2001
From: Jarrod Servilla
Date: Wed, 1 Jul 2026 12:34:39 -0400
Subject: [PATCH 5/5] fix(deck): fold compare and collaborate into more menu
Move Compare and Collaborate from standalone toolbar buttons into
the existing "more actions" dropdown on the deck owner toolbar,
reducing button clutter. Pending proposal count now surfaces as a
badge on both the dropdown trigger and the Collaborate menu item.
---
app/_components/deck/deck-action-row.tsx | 45 +++++++++++++++---------
1 file changed, 28 insertions(+), 17 deletions(-)
diff --git a/app/_components/deck/deck-action-row.tsx b/app/_components/deck/deck-action-row.tsx
index ec1555a..f7d522b 100644
--- a/app/_components/deck/deck-action-row.tsx
+++ b/app/_components/deck/deck-action-row.tsx
@@ -184,23 +184,6 @@ export function DeckActionRow({
History
- router.push(`/decks/compare?a=${deckId}`)}
- >
-
- Compare
-
-
- {showCollaborate && (
-
- )}
-
}
>
+ {!!pendingProposalCount && pendingProposalCount > 0 && (
+
+ {pendingProposalCount}
+
+ )}
+ router.push(`/decks/compare?a=${deckId}`)}
+ className="gap-2"
+ >
+
+ Compare
+
+ {showCollaborate && (
+ router.push(`/deck/${deckId}/collaborate`)}
+ className="gap-2"
+ >
+
+ Collaborate
+ {!!pendingProposalCount && pendingProposalCount > 0 && (
+
+ {pendingProposalCount}
+
+ )}
+
+ )}
+
Playtest