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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion app/(ui)/u/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import {
getPublicProfile,
getUserPublicDecks,
getUserUnlistedDecks,
getFollowStats,
PROFILE_DECKS_PAGE_SIZE,
type ProfileDeck,
} from "@/lib/user/queries";
import { FollowButton } from "@/app/_components/user/follow-button";

interface ProfilePageProps {
params: Promise<{ username: string }>;
Expand Down Expand Up @@ -111,7 +113,10 @@ async function ProfileContent({
if (!profile) notFound();

const isOwner = session?.userId === profile.id;
const publicPage = await getUserPublicDecks(profile.id, page);
const [publicPage, followStats] = await Promise.all([
getUserPublicDecks(profile.id, page),
getFollowStats(profile.id, session?.userId),
]);

// Non-owner with no public decks → 404. Owner with no public decks still
// sees their profile (with the Unlisted section).
Expand All @@ -128,6 +133,27 @@ async function ProfileContent({
<h1 className="text-5xl font-medium leading-none tracking-tight">
@{profile.username}
</h1>
<div className="mt-2 flex items-center gap-4 text-sm text-muted-foreground">
{(isOwner || !session) && (
<span>
<strong className="text-foreground">{followStats.followerCount}</strong>{" "}
{followStats.followerCount === 1 ? "follower" : "followers"}
</span>
)}
<span>
<strong className="text-foreground">{followStats.followingCount}</strong>{" "}
following
</span>
</div>
{!isOwner && session && (
<div className="mt-3">
<FollowButton
targetUserId={profile.id}
initialIsFollowing={followStats.isFollowing}
followerCount={followStats.followerCount}
/>
</div>
)}
Comment thread
jcserv marked this conversation as resolved.
</header>

<section className="mb-12">
Expand Down
149 changes: 149 additions & 0 deletions app/_actions/__tests__/user-follows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("next/cache", () => ({ updateTag: vi.fn() }));
vi.mock("@/lib/auth/session", () => ({
requireSession: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
follow: {
upsert: vi.fn(),
deleteMany: vi.fn(),
},
},
}));

import { updateTag } from "next/cache";
import { requireSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { followUser, unfollowUser } from "../user-follows";

const mockSession = vi.mocked(requireSession);
const mockUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockUpsert = vi.mocked(prisma.follow.upsert);
const mockDeleteMany = vi.mocked(prisma.follow.deleteMany);
const mockUpdateTag = vi.mocked(updateTag);

const FOLLOWER_ID = "user-follower";
const PROFILE_ID = "user-profile";
const FOLLOWERS_TAG = `user:${PROFILE_ID}:followers`;
const FOLLOWING_TAG = `user:${FOLLOWER_ID}:following`;

function session(userId: string) {
return {
userId,
email: `${userId}@test.com`,
username: userId,
dateOfBirth: new Date("1990-01-01"),
};
}

beforeEach(() => {
vi.clearAllMocks();
});

describe("followUser", () => {
it("upserts a follow row keyed by (followerId, followingId) for a valid target", async () => {
mockSession.mockResolvedValue(session(FOLLOWER_ID));
mockUserFindUnique.mockResolvedValue({ id: PROFILE_ID } as never);
mockUpsert.mockResolvedValue({} as never);

await followUser(PROFILE_ID);

expect(mockUpsert).toHaveBeenCalledWith({
where: {
followerId_followingId: {
followerId: FOLLOWER_ID,
followingId: PROFILE_ID,
},
},
create: { followerId: FOLLOWER_ID, followingId: PROFILE_ID },
update: {},
});
});

it("invalidates the target's followers tag and the viewer's following tag", async () => {
mockSession.mockResolvedValue(session(FOLLOWER_ID));
mockUserFindUnique.mockResolvedValue({ id: PROFILE_ID } as never);
mockUpsert.mockResolvedValue({} as never);

await followUser(PROFILE_ID);

expect(mockUpdateTag).toHaveBeenCalledWith(FOLLOWERS_TAG);
expect(mockUpdateTag).toHaveBeenCalledWith(FOLLOWING_TAG);
});

it("is idempotent — a second call upserts again without error", async () => {
mockSession.mockResolvedValue(session(FOLLOWER_ID));
mockUserFindUnique.mockResolvedValue({ id: PROFILE_ID } as never);
mockUpsert.mockResolvedValue({} as never);

await followUser(PROFILE_ID);
await followUser(PROFILE_ID);

expect(mockUpsert).toHaveBeenCalledTimes(2);
});

it("rejects self-follow before any DB call", async () => {
mockSession.mockResolvedValue(session(FOLLOWER_ID));

await expect(followUser(FOLLOWER_ID)).rejects.toThrow(
"Cannot follow yourself",
);
expect(mockUserFindUnique).not.toHaveBeenCalled();
expect(mockUpsert).not.toHaveBeenCalled();
expect(mockUpdateTag).not.toHaveBeenCalled();
});

it("rejects when the target user does not exist", async () => {
mockSession.mockResolvedValue(session(FOLLOWER_ID));
mockUserFindUnique.mockResolvedValue(null);

await expect(followUser(PROFILE_ID)).rejects.toThrow("User not found");
expect(mockUpsert).not.toHaveBeenCalled();
expect(mockUpdateTag).not.toHaveBeenCalled();
});
});

describe("unfollowUser", () => {
it("calls deleteMany with the correct (followerId, followingId)", async () => {
mockSession.mockResolvedValue(session(FOLLOWER_ID));
mockDeleteMany.mockResolvedValue({ count: 1 } as never);

await unfollowUser(PROFILE_ID);

expect(mockDeleteMany).toHaveBeenCalledWith({
where: { followerId: FOLLOWER_ID, followingId: PROFILE_ID },
});
});

it("invalidates both tags", async () => {
mockSession.mockResolvedValue(session(FOLLOWER_ID));
mockDeleteMany.mockResolvedValue({ count: 1 } as never);

await unfollowUser(PROFILE_ID);

expect(mockUpdateTag).toHaveBeenCalledWith(FOLLOWERS_TAG);
expect(mockUpdateTag).toHaveBeenCalledWith(FOLLOWING_TAG);
});

it("is idempotent — deleteMany returning count: 0 does not throw", async () => {
mockSession.mockResolvedValue(session(FOLLOWER_ID));
mockDeleteMany.mockResolvedValue({ count: 0 } as never);

await expect(unfollowUser(PROFILE_ID)).resolves.toBeUndefined();
expect(mockUpdateTag).toHaveBeenCalledWith(FOLLOWERS_TAG);
expect(mockUpdateTag).toHaveBeenCalledWith(FOLLOWING_TAG);
});

it("rejects self-unfollow before any DB call", async () => {
mockSession.mockResolvedValue(session(FOLLOWER_ID));

await expect(unfollowUser(FOLLOWER_ID)).rejects.toThrow(
"Cannot unfollow yourself",
);
expect(mockDeleteMany).not.toHaveBeenCalled();
expect(mockUpdateTag).not.toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions app/_actions/deck/bulk-printings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const bulkReselectPrintings = runOwnerDeckMutation(

const next = row.card.printings.find((p) => p.id === nextId);
// Drop the foil pin if the chosen printing can't be foil.
/* v8 ignore next -- nextId always comes from this same printings list */
const isFoil = row.isFoil && (next?.finishes.includes("foil") ?? false);

updates.push({ id: row.id, printingId: nextId, isFoil });
Expand Down
62 changes: 62 additions & 0 deletions app/_actions/user-follows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use server";

import { prisma } from "@/lib/db";
import { requireSession } from "@/lib/auth/session";
import { withActionLogging } from "@/lib/telemetry";
import { userFollowersTag, userFollowingTag, invalidateTags } from "@/lib/deck/cache-tags";

export const followUser = withActionLogging(
"user.follow",
async (targetUserId: string): Promise<void> => {
const session = await requireSession();

if (session.userId === targetUserId) {
throw new Error("Cannot follow yourself");
}

const target = await prisma.user.findUnique({
where: { id: targetUserId },
select: { id: true },
});

if (!target) {
throw new Error("User not found");
}

await prisma.follow.upsert({
where: {
followerId_followingId: {
followerId: session.userId,
followingId: targetUserId,
},
},
create: { followerId: session.userId, followingId: targetUserId },
update: {},
});

invalidateTags([
userFollowersTag(targetUserId),
userFollowingTag(session.userId),
]);
},
);

export const unfollowUser = withActionLogging(
"user.unfollow",
async (targetUserId: string): Promise<void> => {
const session = await requireSession();

if (session.userId === targetUserId) {
throw new Error("Cannot unfollow yourself");
}

await prisma.follow.deleteMany({
where: { followerId: session.userId, followingId: targetUserId },
});

invalidateTags([
userFollowersTag(targetUserId),
userFollowingTag(session.userId),
]);
},
);
75 changes: 75 additions & 0 deletions app/_components/user/follow-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use client";

import { useOptimistic, useTransition } from "react";
import { UserCheck, UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { followUser, unfollowUser } from "@/app/_actions/user-follows";

interface FollowButtonProps {
targetUserId: string;
initialIsFollowing: boolean;
followerCount: number;
}

interface FollowState {
isFollowing: boolean;
count: number;
}

export function FollowButton({
targetUserId,
initialIsFollowing,
followerCount,
}: FollowButtonProps) {
const [isPending, startTransition] = useTransition();
const [optimistic, setOptimistic] = useOptimistic<FollowState, boolean>(
{ isFollowing: initialIsFollowing, count: followerCount },
(state, next) => {
if (state.isFollowing === next) return state;
return {
isFollowing: next,
count: state.count + (next ? 1 : -1),
};
},
);

function handleClick() {
const next = !optimistic.isFollowing;
startTransition(async () => {
setOptimistic(next);
try {
if (next) {
await followUser(targetUserId);
} else {
await unfollowUser(targetUserId);
}
} catch {
// useOptimistic rewinds on transition rejection; server state wins.
}
});
}

const Icon = optimistic.isFollowing ? UserCheck : UserPlus;
const label = optimistic.isFollowing ? "Unfollow" : "Follow";

return (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
<strong className="text-foreground">{optimistic.count}</strong>{" "}
{optimistic.count === 1 ? "follower" : "followers"}
</span>
<Button
type="button"
variant={optimistic.isFollowing ? "secondary" : "default"}
size="sm"
onClick={handleClick}
disabled={isPending}
aria-pressed={optimistic.isFollowing}
aria-label={optimistic.isFollowing ? "Unfollow user" : "Follow user"}
>
<Icon className="size-3.5" aria-hidden />
{label}
</Button>
</div>
);
}
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const eslintConfig = defineConfig([
"next-env.d.ts",
"coverage/**",
"docs/hifi-poc/**",
"docs/backlog/**",
"app/.well-known",
".claude/**",
]),
Expand Down
16 changes: 16 additions & 0 deletions lib/card/__tests__/printing-heuristics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,22 @@ describe("selectPrintingId — no-universes-beyond", () => {
expect(selectPrintingId(ps, "no-universes-beyond", 999, false)).toBeNull();
});

it("returns null for an unpinned card with no printings at all", () => {
expect(selectPrintingId([], "no-universes-beyond", null, false)).toBeNull();
});

it("picks the canonical printing and cheapest non-UB fallback out of id order", () => {
// ids deliberately not ascending so the lowest-id reduces in both
// canonicalFirstPrinting and the unpriced-fallback path actually replace
// their running minimum instead of always keeping the first element.
const ps = [
printing({ id: 3, setCode: "dom" }),
printing({ id: 1, setCode: "ltr" }),
printing({ id: 2, setCode: "war" }),
];
expect(selectPrintingId(ps, "no-universes-beyond", null, false)).toBe(2);
});

it("ranks non-UB candidates by the nonfoil basis, ignoring cheaper foil/etched", () => {
const ps = [
printing({ id: 1, setCode: "ltr", priceUsd: 5 }),
Expand Down
Loading
Loading