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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user