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. +

+ +
+ )} +
+ ); +} + +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 && ( - - } > + {!!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 ( + + ); +} 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 ( + + ); +} 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.proposer.username} + + +
    + {proposal.status === "PENDING" ? ( +
    + router.refresh()} + /> + router.refresh()} + /> +
    + ) : ( + + {STATUS_LABEL[proposal.status]} + + )} +
    + +
    + {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} +
    • + ); + })} +
    + )} +
    + +
    +

    + Add cards +

    + setQuery(e.target.value)} + placeholder="Search cards to add…" + /> + {loading && ( +

    Searching…

    + )} + {results.length > 0 && ( +
      + {results.map((r) => ( +
    • + {r.name} + {existingByCardId.has(r.id) ? ( + + Already in deck + + ) : ( + + )} +
    • + ))} +
    + )} + {added.size > 0 && ( +
      + {[...added.entries()].map(([cardId, entry]) => ( +
    • + setAddedQuantity(cardId, n)} + /> + {entry.cardName} +
    • + ))} +
    + )} +
    + +
    + +