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..da119ad
--- /dev/null
+++ b/app/(ui)/deck/[id]/propose/page.tsx
@@ -0,0 +1,75 @@
+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";
+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);
+ if (!deck) notFound();
+
+ 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..a72ac03
--- /dev/null
+++ b/app/_actions/deck/__tests__/collaboration.test.ts
@@ -0,0 +1,466 @@
+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", () => {
+ // 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(),
+ },
+ follow: {
+ findUnique: vi.fn(),
+ },
+ deckCard: {
+ findMany: vi.fn(),
+ },
+ card: {
+ findMany: vi.fn(),
+ },
+ deckProposal: {
+ create: vi.fn(),
+ findMany: vi.fn(),
+ updateMany: vi.fn(),
+ findUniqueOrThrow: vi.fn(),
+ },
+ };
+ return { prisma: prismaMock };
+});
+
+import { prisma } from "@/lib/db";
+import { getSession, requireSession } from "@/lib/auth/session";
+import { applyChanges } from "@/lib/deck/mutation";
+import { StructuralViolation } from "@/lib/deck/mutation/errors";
+import {
+ approveDeckProposal,
+ listDeckProposals,
+ 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 mockProposalFindMany = vi.mocked(prisma.deckProposal.findMany);
+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 mockGetSession = vi.mocked(getSession);
+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,
+ };
+}
+
+const addSolRing = [
+ {
+ cardId: 1,
+ cardName: "Sol Ring",
+ zone: Zone.MAINBOARD,
+ category: null,
+ delta: 1,
+ },
+];
+
+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", () => {
+ it("creates a pending proposal for an eligible collaborator", async () => {
+ mockGetSession.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 () => {
+ mockGetSession.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 () => {
+ mockGetSession.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();
+ });
+
+ it("rejects a delta that pairs a category with a non-mainboard zone", async () => {
+ mockGetSession.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 () => {
+ 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);
+ 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", () => {
+ 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);
+ mockProposalUpdateMany.mockResolvedValue({ count: 1 } as never);
+ mockProposalFindUniqueOrThrow.mockResolvedValue({
+ proposerId: PROPOSER_ID,
+ changes: addSolRing,
+ } as never);
+ mockDeckCardFindMany.mockResolvedValue([] as never);
+
+ await approveDeckProposal(DECK_ID, "prop-1");
+
+ expect(mockApplyChanges).toHaveBeenCalledTimes(1);
+ 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);
+ // 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,
+ }),
+ });
+ });
+
+ 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);
+ mockProposalUpdateMany.mockResolvedValue({ count: 1 } as never);
+
+ await rejectDeckProposal(DECK_ID, "prop-1");
+
+ expect(mockApplyChanges).not.toHaveBeenCalled();
+ expect(mockDeckCardFindMany).not.toHaveBeenCalled();
+ expect(mockProposalUpdateMany).toHaveBeenCalledWith({
+ where: { id: "prop-1", deckId: DECK_ID, status: "PENDING" },
+ 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(mockProposalUpdateMany).not.toHaveBeenCalled();
+ });
+});
+
+describe("approveDeckProposal on a stale or already-resolved proposal", () => {
+ 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);
+ mockProposalUpdateMany.mockResolvedValue({ count: 0 } as never);
+
+ await expect(approveDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
+ /already been resolved/,
+ );
+ expect(mockApplyChanges).not.toHaveBeenCalled();
+ });
+
+ 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);
+ mockProposalUpdateMany.mockResolvedValue({ count: 1 } as never);
+ mockProposalFindUniqueOrThrow.mockResolvedValue({
+ proposerId: PROPOSER_ID,
+ 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();
+ });
+
+ it("throws when the proposal has already been resolved", async () => {
+ mockRequireSession.mockResolvedValue({ userId: OWNER_ID } as never);
+ mockDeckFindUnique.mockResolvedValue(deckRow() as never);
+ 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,
+ changes: addSolRing,
+ } as never);
+
+ await approveDeckProposal(DECK_ID, "prop-1");
+ await expect(approveDeckProposal(DECK_ID, "prop-1")).rejects.toThrow(
+ /already been resolved/,
+ );
+
+ 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
new file mode 100644
index 0000000..2f703de
--- /dev/null
+++ b/app/_actions/deck/collaboration.ts
@@ -0,0 +1,243 @@
+"use server";
+
+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 {
+ 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[],
+ tx?: Prisma.TransactionClient,
+): Promise {
+ const client = tx ?? prisma;
+ const rows = await client.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,
+ }));
+ },
+);
+
+export const approveDeckProposal = withActionLogging(
+ "deck.approveProposal",
+ async (deckId: string, proposalId: string): Promise => {
+ const { userId: ownerId } = await requireDeckOwner(deckId);
+
+ 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)]);
+ },
+);
+
+export const rejectDeckProposal = withActionLogging(
+ "deck.rejectProposal",
+ async (deckId: string, proposalId: string): Promise => {
+ const { userId: ownerId } = await requireDeckOwner(deckId);
+
+ 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-action-row.tsx b/app/_components/deck/deck-action-row.tsx
index 7dbc373..f7d522b 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 && (
- router.push(`/decks/compare?a=${deckId}`)}
- >
-
- Compare
-
-
}
>
+ {!!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
@@ -249,3 +278,29 @@ export function DeckActionRow({
);
}
+
+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..53da3a1
--- /dev/null
+++ b/app/_components/deck/deck-collaboration-toggle.tsx
@@ -0,0 +1,58 @@
+"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 [pending, startTransition] = useTransition();
+
+ function handleClick() {
+ if (pending) return;
+ 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..345db12
--- /dev/null
+++ b/app/_components/deck/deck-proposal-review-list.tsx
@@ -0,0 +1,232 @@
+"use client";
+
+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,
+ 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 grouped = groupDeltasByZone(proposal.changes);
+
+ return (
+
+
+
+
+ {proposal.message && (
+
+ “{proposal.message}”
+
+ )}
+ {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})
+
+ )}
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
+
+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/app/_components/deck/deck-propose-draft.tsx b/app/_components/deck/deck-propose-draft.tsx
new file mode 100644
index 0000000..561817b
--- /dev/null
+++ b/app/_components/deck/deck-propose-draft.tsx
@@ -0,0 +1,271 @@
+"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 { deltaKey, type RevisionDelta } from "@/lib/deck/revision";
+
+interface ExistingMainboardCard {
+ cardId: number;
+ cardName: string;
+ category: string | null;
+ quantity: number;
+}
+
+interface DeckProposeDraftProps {
+ deckId: string;
+ existingCards: ExistingMainboardCard[];
+}
+
+function mainboardKey(c: Pick) {
+ return deltaKey({ cardId: c.cardId, zone: "MAINBOARD", category: c.category });
+}
+
+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) => [mainboardKey(c), 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(key: string, next: number) {
+ setQuantities((prev) => {
+ const map = new Map(prev);
+ map.set(key, 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 key = mainboardKey(c);
+ const next = quantities.get(key) ?? 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) => {
+ const key = mainboardKey(c);
+ return (
+
+ setQuantity(key, 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/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 797ff22..3861e5e 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,162 @@ 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 (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(
+ "NEXT_NOT_FOUND",
+ );
+ });
+
+ it("404s when the caller is the deck owner (owner isn't a 'collaborator')", async () => {
+ mockGetSession.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 () => {
+ mockGetSession.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 () => {
+ mockGetSession.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 () => {
+ mockGetSession.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..a2a998a 100644
--- a/lib/auth/deck-access.ts
+++ b/lib/auth/deck-access.ts
@@ -35,3 +35,51 @@ 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 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 getSession();
+ if (!session) notFound();
+ 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/__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",
+ ]);
+ });
+});
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/__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/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/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,
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")