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,
"tag": "0004_smiling_night_nurse",
"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 {
id: number;
name: string;
isPublic?: boolean;
visibility?: "private" | "link" | "public";
itemCount: number;
totalWeight: number;
totalCost: number;
@@ -13,7 +13,7 @@ interface SetupCardProps {
export function SetupCard({
id,
name,
isPublic,
visibility,
itemCount,
totalWeight,
totalCost,
@@ -30,9 +30,15 @@ export function SetupCard({
<h3 className="text-sm font-semibold text-gray-900 truncate">
{name}
</h3>
{isPublic && (
<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">
Public
{visibility && visibility !== "private" && (
<span
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>
)}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import {
boolean,
doublePrecision,
integer,
pgTable,
@@ -121,7 +120,7 @@ export const setups = pgTable("setups", {
userId: integer("user_id")
.notNull()
.references(() => users.id),
isPublic: boolean("is_public").notNull().default(false),
visibility: text("visibility").notNull().default("private"),
createdAt: timestamp("created_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"),
});
// ── 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 ────────────────────────────────────────────────────
export const globalItems = pgTable(

View File

@@ -114,7 +114,7 @@ app.post("/delete", zValidator("json", deleteAccountSchema), async (c) => {
await tx
.update(setups)
.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
const privateSetups = await tx

View File

@@ -21,7 +21,7 @@ interface CursorPage<T> {
/**
* Get popular public setups ordered by item count descending.
* 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(
db: Db = prodDb,
@@ -50,7 +50,7 @@ export async function getPopularSetups(
.from(setups)
.leftJoin(setupItems, eq(setupItems.setupId, setups.id))
.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)
.orderBy(desc(sql<number>`COUNT(${setupItems.id})`), desc(setups.id))
.limit(fetchLimit);

View File

@@ -79,7 +79,7 @@ export async function getPublicProfile(db: Db, userId: number) {
), 0)`.as("total_cost"),
})
.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 };
}
@@ -88,7 +88,7 @@ export async function getPublicSetupWithItems(db: Db, setupId: number) {
const [setup] = await db
.select()
.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;

View File

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

View File

@@ -85,12 +85,15 @@ export const reorderCandidatesSchema = z.object({
// Setup schemas
export const createSetupSchema = z.object({
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({
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({

View File

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

View File

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

View File

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

View File

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

View File

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