feat: migrate setup visibility from boolean to three-tier system

Replace isPublic boolean with visibility enum (private/link/public) across
the full stack. Add shares table to schema for future share link support.
Update all services, routes, schemas, hooks, components, and tests.

Plan: 32-01 (Setup Sharing System - Schema Migration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 17:55:46 +02:00
parent 727abf1528
commit edc9793c2d
20 changed files with 1556 additions and 81 deletions

View File

@@ -0,0 +1,17 @@
CREATE TABLE "shares" (
"id" serial PRIMARY KEY NOT NULL,
"setup_id" integer NOT NULL,
"token" text NOT NULL,
"permission" text DEFAULT 'read' NOT NULL,
"expires_at" timestamp,
"user_id" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"revoked_at" timestamp,
CONSTRAINT "shares_token_unique" UNIQUE("token")
);
--> statement-breakpoint
ALTER TABLE "setups" ADD COLUMN "visibility" text DEFAULT 'private' NOT NULL;--> statement-breakpoint
UPDATE "setups" SET "visibility" = 'public' WHERE "is_public" = true;--> statement-breakpoint
ALTER TABLE "shares" ADD CONSTRAINT "shares_setup_id_setups_id_fk" FOREIGN KEY ("setup_id") REFERENCES "public"."setups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shares" ADD CONSTRAINT "shares_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "setups" DROP COLUMN "is_public";

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@
"when": 1776016552627, "when": 1776016552627,
"tag": "0004_smiling_night_nurse", "tag": "0004_smiling_night_nurse",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1776095449827,
"tag": "0005_true_green_goblin",
"breakpoints": true
} }
] ]
} }

View File

@@ -4,7 +4,7 @@ import { useFormatters } from "../hooks/useFormatters";
interface SetupCardProps { interface SetupCardProps {
id: number; id: number;
name: string; name: string;
isPublic?: boolean; visibility?: "private" | "link" | "public";
itemCount: number; itemCount: number;
totalWeight: number; totalWeight: number;
totalCost: number; totalCost: number;
@@ -13,7 +13,7 @@ interface SetupCardProps {
export function SetupCard({ export function SetupCard({
id, id,
name, name,
isPublic, visibility,
itemCount, itemCount,
totalWeight, totalWeight,
totalCost, totalCost,
@@ -30,9 +30,15 @@ export function SetupCard({
<h3 className="text-sm font-semibold text-gray-900 truncate"> <h3 className="text-sm font-semibold text-gray-900 truncate">
{name} {name}
</h3> </h3>
{isPublic && ( {visibility && visibility !== "private" && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-green-50 text-green-600 shrink-0"> <span
Public className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium shrink-0 ${
visibility === "public"
? "bg-green-50 text-green-600"
: "bg-blue-50 text-blue-600"
}`}
>
{visibility === "public" ? "Public" : "Link"}
</span> </span>
)} )}
</div> </div>

View File

@@ -100,7 +100,7 @@ export function SetupsView() {
key={setup.id} key={setup.id}
id={setup.id} id={setup.id}
name={setup.name} name={setup.name}
isPublic={setup.isPublic} visibility={setup.visibility}
itemCount={setup.itemCount} itemCount={setup.itemCount}
totalWeight={setup.totalWeight} totalWeight={setup.totalWeight}
totalCost={setup.totalCost} totalCost={setup.totalCost}

View File

@@ -11,7 +11,7 @@ import {
interface SetupListItem { interface SetupListItem {
id: number; id: number;
name: string; name: string;
isPublic: boolean; visibility: "private" | "link" | "public";
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
itemCount: number; itemCount: number;
@@ -39,7 +39,7 @@ interface SetupItemWithCategory {
interface SetupWithItems { interface SetupWithItems {
id: number; id: number;
name: string; name: string;
isPublic: boolean; visibility: "private" | "link" | "public";
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
items: SetupItemWithCategory[]; items: SetupItemWithCategory[];
@@ -88,8 +88,10 @@ export function useCreateSetup() {
export function useUpdateSetup(setupId: number) { export function useUpdateSetup(setupId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: { name?: string; isPublic?: boolean }) => mutationFn: (data: {
apiPut<SetupListItem>(`/api/setups/${setupId}`, data), name?: string;
visibility?: "private" | "link" | "public";
}) => apiPut<SetupListItem>(`/api/setups/${setupId}`, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["setups"] }); queryClient.invalidateQueries({ queryKey: ["setups"] });
}, },

View File

@@ -36,7 +36,7 @@ function SetupDetailPage() {
: publicSetup; : publicSetup;
const deleteSetup = useDeleteSetup(); const deleteSetup = useDeleteSetup();
const updateSetup = useUpdateSetup(numericId); const _updateSetup = useUpdateSetup(numericId);
const removeItem = useRemoveSetupItem(numericId); const removeItem = useRemoveSetupItem(numericId);
const updateClassification = useUpdateItemClassification(numericId); const updateClassification = useUpdateItemClassification(numericId);
@@ -174,33 +174,60 @@ function SetupDetailPage() {
<LucideIcon name="plus" size={16} /> <LucideIcon name="plus" size={16} />
</button> </button>
{/* Public toggle — desktop */} {/* Visibility badge — desktop */}
<button <span
type="button" className={`hidden md:inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg ${
onClick={() => updateSetup.mutate({ isPublic: !setup.isPublic })} setup.visibility === "public"
className={`hidden md:inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${ ? "text-green-700 bg-green-50"
setup.isPublic : setup.visibility === "link"
? "text-green-700 bg-green-50 hover:bg-green-100" ? "text-blue-600 bg-blue-50"
: "text-gray-500 bg-gray-50 hover:bg-gray-100" : "text-gray-500 bg-gray-50"
}`} }`}
> >
<LucideIcon name="globe" size={16} /> <LucideIcon
{setup.isPublic ? "Public" : "Private"} name={
</button> setup.visibility === "public"
{/* Public toggle — mobile */} ? "globe"
<button : setup.visibility === "link"
type="button" ? "link"
onClick={() => updateSetup.mutate({ isPublic: !setup.isPublic })} : "lock"
className={`md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 rounded-lg transition-colors ${ }
setup.isPublic size={16}
? "text-green-700 bg-green-50 hover:bg-green-100" />
: "text-gray-500 bg-gray-50 hover:bg-gray-100" {setup.visibility === "public"
? "Public"
: setup.visibility === "link"
? "Link"
: "Private"}
</span>
{/* Visibility badge — mobile */}
<span
className={`md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 rounded-lg ${
setup.visibility === "public"
? "text-green-700 bg-green-50"
: setup.visibility === "link"
? "text-blue-600 bg-blue-50"
: "text-gray-500 bg-gray-50"
}`} }`}
aria-label={setup.isPublic ? "Public" : "Private"} title={
title={setup.isPublic ? "Public" : "Private"} setup.visibility === "public"
? "Public"
: setup.visibility === "link"
? "Link shared"
: "Private"
}
> >
<LucideIcon name="globe" size={16} /> <LucideIcon
</button> name={
setup.visibility === "public"
? "globe"
: setup.visibility === "link"
? "link"
: "lock"
}
size={16}
/>
</span>
<div className="flex-1" /> <div className="flex-1" />
{/* Delete Setup — desktop */} {/* Delete Setup — desktop */}

View File

@@ -857,7 +857,7 @@ export const DEV_THREADS = [
export const DEV_SETUPS = [ export const DEV_SETUPS = [
{ {
name: "Weekend Overnighter", name: "Weekend Overnighter",
isPublic: true, visibility: "public" as const,
items: [ items: [
{ userItemIndex: 0, classification: "base" }, // Terrapin saddle bag { userItemIndex: 0, classification: "base" }, // Terrapin saddle bag
{ userItemIndex: 3, classification: "base" }, // X-Mid 1 { userItemIndex: 3, classification: "base" }, // X-Mid 1
@@ -871,7 +871,7 @@ export const DEV_SETUPS = [
}, },
{ {
name: "Ultra-Light Day Ride", name: "Ultra-Light Day Ride",
isPublic: false, visibility: "private" as const,
items: [ items: [
{ userItemIndex: 2, classification: "base" }, // Top tube pack { userItemIndex: 2, classification: "base" }, // Top tube pack
{ userItemIndex: 7, classification: "worn" }, // Nitecore NU25 { userItemIndex: 7, classification: "worn" }, // Nitecore NU25

View File

@@ -252,7 +252,7 @@ async function seedDevData(database: Db = db) {
.values({ .values({
name: setupDef.name, name: setupDef.name,
userId, userId,
isPublic: setupDef.isPublic, visibility: setupDef.visibility,
}) })
.returning(); .returning();
if (!setup) throw new Error(`Failed to insert setup: ${setupDef.name}`); if (!setup) throw new Error(`Failed to insert setup: ${setupDef.name}`);

View File

@@ -1,5 +1,4 @@
import { import {
boolean,
doublePrecision, doublePrecision,
integer, integer,
pgTable, pgTable,
@@ -121,7 +120,7 @@ export const setups = pgTable("setups", {
userId: integer("user_id") userId: integer("user_id")
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
isPublic: boolean("is_public").notNull().default(false), visibility: text("visibility").notNull().default("private"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); });
@@ -139,6 +138,21 @@ export const setupItems = pgTable("setup_items", {
classification: text("classification").notNull().default("base"), classification: text("classification").notNull().default("base"),
}); });
// ── Shares ─────────────────────────────────────────────────────────
export const shares = pgTable("shares", {
id: serial("id").primaryKey(),
setupId: integer("setup_id")
.notNull()
.references(() => setups.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
permission: text("permission").notNull().default("read"),
expiresAt: timestamp("expires_at"),
userId: integer("user_id").references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
revokedAt: timestamp("revoked_at"),
});
// ── Global Items ──────────────────────────────────────────────────── // ── Global Items ────────────────────────────────────────────────────
export const globalItems = pgTable( export const globalItems = pgTable(

View File

@@ -114,7 +114,7 @@ app.post("/delete", zValidator("json", deleteAccountSchema), async (c) => {
await tx await tx
.update(setups) .update(setups)
.set({ userId: sentinel.id }) .set({ userId: sentinel.id })
.where(and(eq(setups.userId, userId), eq(setups.isPublic, true))); .where(and(eq(setups.userId, userId), eq(setups.visibility, "public")));
// 3. Get private setup IDs for cleanup // 3. Get private setup IDs for cleanup
const privateSetups = await tx const privateSetups = await tx

View File

@@ -21,7 +21,7 @@ interface CursorPage<T> {
/** /**
* Get popular public setups ordered by item count descending. * Get popular public setups ordered by item count descending.
* Cursor format: "{itemCount}_{id}" for stable composite pagination. * Cursor format: "{itemCount}_{id}" for stable composite pagination.
* Only public setups (isPublic=true) are returned. * Only public setups (visibility='public') are returned.
*/ */
export async function getPopularSetups( export async function getPopularSetups(
db: Db = prodDb, db: Db = prodDb,
@@ -50,7 +50,7 @@ export async function getPopularSetups(
.from(setups) .from(setups)
.leftJoin(setupItems, eq(setupItems.setupId, setups.id)) .leftJoin(setupItems, eq(setupItems.setupId, setups.id))
.leftJoin(users, eq(users.id, setups.userId)) .leftJoin(users, eq(users.id, setups.userId))
.where(eq(setups.isPublic, true)) .where(eq(setups.visibility, "public"))
.groupBy(setups.id, setups.name, setups.createdAt, users.displayName) .groupBy(setups.id, setups.name, setups.createdAt, users.displayName)
.orderBy(desc(sql<number>`COUNT(${setupItems.id})`), desc(setups.id)) .orderBy(desc(sql<number>`COUNT(${setupItems.id})`), desc(setups.id))
.limit(fetchLimit); .limit(fetchLimit);

View File

@@ -79,7 +79,7 @@ export async function getPublicProfile(db: Db, userId: number) {
), 0)`.as("total_cost"), ), 0)`.as("total_cost"),
}) })
.from(setups) .from(setups)
.where(and(eq(setups.userId, userId), eq(setups.isPublic, true))); .where(and(eq(setups.userId, userId), eq(setups.visibility, "public")));
return { ...user, setups: publicSetups }; return { ...user, setups: publicSetups };
} }
@@ -88,7 +88,7 @@ export async function getPublicSetupWithItems(db: Db, setupId: number) {
const [setup] = await db const [setup] = await db
.select() .select()
.from(setups) .from(setups)
.where(and(eq(setups.id, setupId), eq(setups.isPublic, true))); .where(and(eq(setups.id, setupId), eq(setups.visibility, "public")));
if (!setup) return null; if (!setup) return null;

View File

@@ -14,7 +14,11 @@ type Db = typeof prodDb;
export async function createSetup(db: Db, userId: number, data: CreateSetup) { export async function createSetup(db: Db, userId: number, data: CreateSetup) {
const [row] = await db const [row] = await db
.insert(setups) .insert(setups)
.values({ name: data.name, userId, isPublic: data.isPublic ?? false }) .values({
name: data.name,
userId,
visibility: data.visibility ?? "private",
})
.returning(); .returning();
return row; return row;
@@ -25,7 +29,7 @@ export async function getAllSetups(db: Db, userId: number) {
.select({ .select({
id: setups.id, id: setups.id,
name: setups.name, name: setups.name,
isPublic: setups.isPublic, visibility: setups.visibility,
createdAt: setups.createdAt, createdAt: setups.createdAt,
updatedAt: setups.updatedAt, updatedAt: setups.updatedAt,
itemCount: sql<number>`COALESCE(( itemCount: sql<number>`COALESCE((
@@ -129,8 +133,8 @@ export async function updateSetup(
name: data.name, name: data.name,
updatedAt: new Date(), updatedAt: new Date(),
}; };
if (data.isPublic !== undefined) { if (data.visibility !== undefined) {
updateData.isPublic = data.isPublic; updateData.visibility = data.visibility;
} }
const [row] = await db const [row] = await db

View File

@@ -85,12 +85,15 @@ export const reorderCandidatesSchema = z.object({
// Setup schemas // Setup schemas
export const createSetupSchema = z.object({ export const createSetupSchema = z.object({
name: z.string().min(1, "Setup name is required"), name: z.string().min(1, "Setup name is required"),
isPublic: z.boolean().optional().default(false), visibility: z
.enum(["private", "link", "public"])
.optional()
.default("private"),
}); });
export const updateSetupSchema = z.object({ export const updateSetupSchema = z.object({
name: z.string().min(1, "Setup name is required"), name: z.string().min(1, "Setup name is required"),
isPublic: z.boolean().optional(), visibility: z.enum(["private", "link", "public"]).optional(),
}); });
export const syncSetupItemsSchema = z.object({ export const syncSetupItemsSchema = z.object({

View File

@@ -20,6 +20,7 @@ async function getOrCreateDb(): Promise<Db> {
// Truncation order respects foreign keys (children first) // Truncation order respects foreign keys (children first)
const TRUNCATE_TABLES = [ const TRUNCATE_TABLES = [
"shares",
"setup_items", "setup_items",
"setups", "setups",
"thread_candidates", "thread_candidates",

View File

@@ -40,7 +40,7 @@ async function insertPublicSetup(
) { ) {
const [row] = await db const [row] = await db
.insert(setups) .insert(setups)
.values({ name, userId, isPublic: true }) .values({ name, userId, visibility: "public" })
.returning(); .returning();
return row; return row;
} }
@@ -76,7 +76,7 @@ describe("Discovery Routes", () => {
// Insert a private setup // Insert a private setup
await db await db
.insert(setups) .insert(setups)
.values({ name: "Private Setup", userId, isPublic: false }); .values({ name: "Private Setup", userId, visibility: "private" });
const res = await app.request("/api/discovery/setups"); const res = await app.request("/api/discovery/setups");
expect(res.status).toBe(200); expect(res.status).toBe(200);

View File

@@ -111,8 +111,8 @@ describe("Profile Routes", () => {
it("includes only public setups", async () => { it("includes only public setups", async () => {
// Create public and private setups // Create public and private setups
await db.insert(schema.setups).values([ await db.insert(schema.setups).values([
{ name: "Public Setup", userId, isPublic: true }, { name: "Public Setup", userId, visibility: "public" },
{ name: "Private Setup", userId, isPublic: false }, { name: "Private Setup", userId, visibility: "private" },
]); ]);
const res = await app.request(`/api/users/${userId}/profile`); const res = await app.request(`/api/users/${userId}/profile`);
@@ -181,7 +181,7 @@ describe("Public Setup Routes", () => {
it("returns 200 for public setup without auth", async () => { it("returns 200 for public setup without auth", async () => {
const [setup] = await db const [setup] = await db
.insert(schema.setups) .insert(schema.setups)
.values({ name: "My Public Setup", userId, isPublic: true }) .values({ name: "My Public Setup", userId, visibility: "public" })
.returning(); .returning();
const res = await app.request(`/api/setups/${setup.id}/public`); const res = await app.request(`/api/setups/${setup.id}/public`);
@@ -189,14 +189,14 @@ describe("Public Setup Routes", () => {
const body = await res.json(); const body = await res.json();
expect(body.name).toBe("My Public Setup"); expect(body.name).toBe("My Public Setup");
expect(body.isPublic).toBe(true); expect(body.visibility).toBe("public");
expect(body.items).toBeDefined(); expect(body.items).toBeDefined();
}); });
it("returns 200 for public setup with items", async () => { it("returns 200 for public setup with items", async () => {
const [setup] = await db const [setup] = await db
.insert(schema.setups) .insert(schema.setups)
.values({ name: "Loaded Setup", userId, isPublic: true }) .values({ name: "Loaded Setup", userId, visibility: "public" })
.returning(); .returning();
const [cat] = await db const [cat] = await db
@@ -231,7 +231,7 @@ describe("Public Setup Routes", () => {
it("returns 404 for private setup", async () => { it("returns 404 for private setup", async () => {
const [setup] = await db const [setup] = await db
.insert(schema.setups) .insert(schema.setups)
.values({ name: "Private Setup", userId, isPublic: false }) .values({ name: "Private Setup", userId, visibility: "private" })
.returning(); .returning();
const res = await app.request(`/api/setups/${setup.id}/public`); const res = await app.request(`/api/setups/${setup.id}/public`);

View File

@@ -47,7 +47,7 @@ async function insertPublicSetup(
) { ) {
const [setup] = await db const [setup] = await db
.insert(setups) .insert(setups)
.values({ name, userId, isPublic: true }) .values({ name, userId, visibility: "public" })
.returning(); .returning();
for (const itemId of itemIds) { for (const itemId of itemIds) {
await db.insert(setupItems).values({ setupId: setup.id, itemId }); await db.insert(setupItems).values({ setupId: setup.id, itemId });
@@ -62,7 +62,7 @@ async function insertPrivateSetup(
) { ) {
const [setup] = await db const [setup] = await db
.insert(setups) .insert(setups)
.values({ name, userId, isPublic: false }) .values({ name, userId, visibility: "private" })
.returning(); .returning();
return setup; return setup;
} }

View File

@@ -74,7 +74,7 @@ describe("Profile Service", () => {
// Create one public and one private setup // Create one public and one private setup
const _pub = await createSetup(db, userId, { const _pub = await createSetup(db, userId, {
name: "Public Setup", name: "Public Setup",
isPublic: true, visibility: "public",
}); });
const _priv = await createSetup(db, userId, { name: "Private Setup" }); const _priv = await createSetup(db, userId, { name: "Private Setup" });
@@ -91,10 +91,10 @@ describe("Profile Service", () => {
}); });
describe("getPublicSetupWithItems", () => { describe("getPublicSetupWithItems", () => {
it("returns setup with items when isPublic is true", async () => { it("returns setup with items when visibility is public", async () => {
const setup = await createSetup(db, userId, { const setup = await createSetup(db, userId, {
name: "Public Setup", name: "Public Setup",
isPublic: true, visibility: "public",
}); });
// Create an item and add to setup // Create an item and add to setup
@@ -125,7 +125,7 @@ describe("Profile Service", () => {
expect(result!.items[0].name).toBe("Tent"); expect(result!.items[0].name).toBe("Tent");
}); });
it("returns null when isPublic is false", async () => { it("returns null when visibility is private", async () => {
const setup = await createSetup(db, userId, { const setup = await createSetup(db, userId, {
name: "Private Setup", name: "Private Setup",
}); });
@@ -140,7 +140,7 @@ describe("Profile Service", () => {
}); });
}); });
describe("Setup Service - isPublic", () => { describe("Setup Service - visibility", () => {
let db: Db; let db: Db;
let userId: number; let userId: number;
@@ -150,33 +150,33 @@ describe("Setup Service - isPublic", () => {
userId = testData.userId; userId = testData.userId;
}); });
it("createSetup persists isPublic when true", async () => { it("createSetup persists visibility when public", async () => {
const setup = await createSetup(db, userId, { const setup = await createSetup(db, userId, {
name: "Public", name: "Public",
isPublic: true, visibility: "public",
}); });
expect(setup.isPublic).toBe(true); expect(setup.visibility).toBe("public");
}); });
it("createSetup defaults isPublic to false", async () => { it("createSetup defaults visibility to private", async () => {
const setup = await createSetup(db, userId, { name: "Private" }); const setup = await createSetup(db, userId, { name: "Private" });
expect(setup.isPublic).toBe(false); expect(setup.visibility).toBe("private");
}); });
it("updateSetup can toggle isPublic", async () => { it("updateSetup can change visibility", async () => {
const setup = await createSetup(db, userId, { name: "Test" }); const setup = await createSetup(db, userId, { name: "Test" });
expect(setup.isPublic).toBe(false); expect(setup.visibility).toBe("private");
const updated = await updateSetup(db, userId, setup.id, { const updated = await updateSetup(db, userId, setup.id, {
name: "Test", name: "Test",
isPublic: true, visibility: "public",
}); });
expect(updated).not.toBeNull(); expect(updated).not.toBeNull();
expect(updated!.isPublic).toBe(true); expect(updated!.visibility).toBe("public");
}); });
it("getAllSetups includes isPublic in response", async () => { it("getAllSetups includes visibility in response", async () => {
await createSetup(db, userId, { name: "Public", isPublic: true }); await createSetup(db, userId, { name: "Public", visibility: "public" });
await createSetup(db, userId, { name: "Private" }); await createSetup(db, userId, { name: "Private" });
const setups = await getAllSetups(db, userId); const setups = await getAllSetups(db, userId);
@@ -184,17 +184,17 @@ describe("Setup Service - isPublic", () => {
const pub = setups.find((s) => s.name === "Public"); const pub = setups.find((s) => s.name === "Public");
const priv = setups.find((s) => s.name === "Private"); const priv = setups.find((s) => s.name === "Private");
expect(pub!.isPublic).toBe(true); expect(pub!.visibility).toBe("public");
expect(priv!.isPublic).toBe(false); expect(priv!.visibility).toBe("private");
}); });
it("getSetupWithItems includes isPublic", async () => { it("getSetupWithItems includes visibility", async () => {
const setup = await createSetup(db, userId, { const setup = await createSetup(db, userId, {
name: "Test", name: "Test",
isPublic: true, visibility: "public",
}); });
const result = await getSetupWithItems(db, userId, setup.id); const result = await getSetupWithItems(db, userId, setup.id);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!.isPublic).toBe(true); expect(result!.visibility).toBe("public");
}); });
}); });