---
phase: 32-setup-sharing-system
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/db/schema.ts
  - src/server/services/setup.service.ts
  - src/server/services/discovery.service.ts
  - src/server/services/profile.service.ts
  - src/server/routes/setups.ts
  - src/shared/schemas.ts
  - src/shared/types.ts
  - src/client/hooks/useSetups.ts
  - src/client/components/SetupCard.tsx
  - src/client/components/SetupsView.tsx
  - src/client/routes/setups/$setupId.tsx
  - tests/services/setup.service.test.ts
  - tests/services/discovery.service.test.ts
  - tests/services/profile.service.test.ts
autonomous: true
requirements:
  - TBD

must_haves:
  truths:
    - "setups table has visibility text column with values private/link/public instead of isPublic boolean"
    - "shares table exists with id, setupId, token, permission, expiresAt, userId, createdAt, revokedAt columns"
    - "Discovery feed returns only setups with visibility='public'"
    - "Public profile returns only setups with visibility='public'"
    - "All existing isPublic=true setups migrated to visibility='public'"
    - "All existing isPublic=false setups migrated to visibility='private'"
  artifacts:
    - path: "src/db/schema.ts"
      provides: "Updated setups table with visibility column, new shares table"
      contains: "visibility.*text.*notNull.*default.*private"
    - path: "drizzle/"
      provides: "Migration SQL for visibility column and shares table"
  key_links:
    - from: "src/server/services/discovery.service.ts"
      to: "src/db/schema.ts"
      via: "visibility column filter"
      pattern: "visibility.*public"
    - from: "src/server/services/profile.service.ts"
      to: "src/db/schema.ts"
      via: "visibility column filter"
      pattern: "visibility.*public"
---

<objective>
Migrate the setups visibility model from boolean isPublic to three-tier visibility (private/link/public), add shares table to schema, and update all services, routes, schemas, and client code that reference isPublic.

Purpose: This is the foundational schema change required by all other plans. Every service, route, and component that references isPublic must be updated atomically to prevent broken queries.

Output: Updated schema with visibility column and shares table, migrated data, updated services/routes/schemas/hooks/components.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/32-setup-sharing-system/32-CONTEXT.md
@.planning/phases/32-setup-sharing-system/32-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs. -->

From src/db/schema.ts (current setups table, line 118-127):
```typescript
export const setups = pgTable("setups", {
  id: serial("id").primaryKey(),
  name: text("name").notNull(),
  userId: integer("user_id").notNull().references(() => users.id),
  isPublic: boolean("is_public").notNull().default(false),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
```

From src/shared/schemas.ts (setup schemas, lines 86-98):
```typescript
export const createSetupSchema = z.object({
  name: z.string().min(1, "Setup name is required"),
  isPublic: z.boolean().optional().default(false),
});
export const updateSetupSchema = z.object({
  name: z.string().min(1, "Setup name is required"),
  isPublic: z.boolean().optional(),
});
```

From src/server/services/setup.service.ts (createSetup uses isPublic):
```typescript
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 })
    .returning();
  return row;
}
```

From src/server/services/discovery.service.ts (line 53):
```typescript
.where(eq(setups.isPublic, true))
```

From src/server/services/profile.service.ts (lines 82, 91):
```typescript
.where(and(eq(setups.userId, userId), eq(setups.isPublic, true)));
// and:
.where(and(eq(setups.id, setupId), eq(setups.isPublic, true)));
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Update schema — add visibility column, shares table, generate migration</name>
  <files>src/db/schema.ts</files>
  <read_first>src/db/schema.ts</read_first>
  <action>
1. In `src/db/schema.ts`, modify the `setups` table:
   - Remove `isPublic: boolean("is_public").notNull().default(false)` (per D-02)
   - Add `visibility: text("visibility").notNull().default("private")` (per D-01, D-02)

2. Add new `shares` table after `setupItems` (per D-10, D-11, D-12):
```typescript
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"),
});
```

3. Run `bun run db:generate` to generate the Drizzle migration.

4. The generated migration will likely create a new column and drop the old one. Edit the migration SQL to include data migration:
   - After `ALTER TABLE setups ADD COLUMN visibility text NOT NULL DEFAULT 'private'`, add:
   - `UPDATE setups SET visibility = 'public' WHERE is_public = true;`
   - Then the `ALTER TABLE setups DROP COLUMN is_public` statement.

5. Run `bun run db:push` to apply the migration.
  </action>
  <verify>
    <automated>grep -q "visibility" src/db/schema.ts && grep -q "shares" src/db/schema.ts && echo "PASS" || echo "FAIL"</automated>
  </verify>
  <acceptance_criteria>
    - `src/db/schema.ts` contains `visibility: text("visibility").notNull().default("private")` in setups table
    - `src/db/schema.ts` contains `shares` table with columns: id, setupId, token, permission, expiresAt, userId, createdAt, revokedAt
    - `src/db/schema.ts` does NOT contain `isPublic` or `is_public`
    - A new migration file exists in `drizzle/` directory
    - `bun run db:push` succeeds without error
  </acceptance_criteria>
  <done>Schema updated, migration generated and applied, isPublic replaced with visibility, shares table created</done>
</task>

<task type="auto">
  <name>Task 2: Update all services, routes, schemas, and client code from isPublic to visibility</name>
  <files>src/server/services/setup.service.ts, src/server/services/discovery.service.ts, src/server/services/profile.service.ts, src/server/routes/setups.ts, src/shared/schemas.ts, src/shared/types.ts, src/client/hooks/useSetups.ts, src/client/components/SetupCard.tsx, src/client/components/SetupsView.tsx, src/client/routes/setups/$setupId.tsx</files>
  <read_first>src/server/services/setup.service.ts, src/server/services/discovery.service.ts, src/server/services/profile.service.ts, src/shared/schemas.ts, src/client/hooks/useSetups.ts, src/client/routes/setups/$setupId.tsx, src/client/components/SetupCard.tsx, src/client/components/SetupsView.tsx</read_first>
  <action>
**Shared schemas (`src/shared/schemas.ts`):**
- Replace `createSetupSchema`: change `isPublic: z.boolean().optional().default(false)` to `visibility: z.enum(["private", "link", "public"]).optional().default("private")`
- Replace `updateSetupSchema`: change `isPublic: z.boolean().optional()` to `visibility: z.enum(["private", "link", "public"]).optional()`

**Setup service (`src/server/services/setup.service.ts`):**
- `createSetup`: change `isPublic: data.isPublic ?? false` to `visibility: data.visibility ?? "private"` (per D-01)
- `getAllSetups`: change `isPublic: setups.isPublic` in select to `visibility: setups.visibility`
- `updateSetup`: change `data.isPublic` handling to `data.visibility` — set `updateData.visibility = data.visibility` when defined

**Discovery service (`src/server/services/discovery.service.ts`):**
- `getPopularSetups`: change `.where(eq(setups.isPublic, true))` to `.where(eq(setups.visibility, "public"))` (per D-19)

**Profile service (`src/server/services/profile.service.ts`):**
- `getPublicProfile`: change `eq(setups.isPublic, true)` to `eq(setups.visibility, "public")` (per D-19)
- `getPublicSetupWithItems`: change `eq(setups.isPublic, true)` to `eq(setups.visibility, "public")` (per D-19)

**Setup routes (`src/server/routes/setups.ts`):**
- No route changes needed — routes use service functions and Zod schemas

**Client hooks (`src/client/hooks/useSetups.ts`):**
- `useUpdateSetup` mutation body: replace any `isPublic` references with `visibility`
- All query return types will auto-update via TypeScript inference

**Client components:**
- `SetupCard.tsx`: replace any `isPublic` references with `visibility` checks (e.g., `setup.visibility === "public"` instead of `setup.isPublic`)
- `SetupsView.tsx`: replace any `isPublic` references with `visibility`
- `setups/$setupId.tsx`: Replace the globe toggle button (lines 177-203) with a temporary visibility indicator. For now, just show the current visibility state as a read-only badge (the full share modal comes in Plan 03). Replace:
  - `onClick={() => updateSetup.mutate({ isPublic: !setup.isPublic })}`
  - With a static badge showing visibility icon per 32-UI-SPEC.md color table:
    - private: lock icon, gray-500/gray-50
    - link: link icon, blue-600/blue-50
    - public: globe icon, green-700/green-50
  - This button will be upgraded to open the share modal in Plan 03.

**Also check and update:**
- `src/server/routes/account.ts` if it references isPublic
- `src/db/dev-seed.ts` and `src/db/dev-seed-data.ts` — update seed data to use `visibility` instead of `isPublic`
- `src/client/routes/__root.tsx` if it references isPublic
- Any MCP tool definitions that reference isPublic
  </action>
  <verify>
    <automated>grep -r "isPublic" src/ --include="*.ts" --include="*.tsx" | grep -v node_modules | grep -v ".gen.ts" | wc -l | xargs -I{} test {} -eq 0 && echo "PASS" || echo "FAIL: isPublic references remain"</automated>
  </verify>
  <acceptance_criteria>
    - Zero occurrences of `isPublic` or `is_public` in `src/` directory (excluding node_modules and generated files)
    - `src/shared/schemas.ts` contains `visibility: z.enum(["private", "link", "public"])`
    - `src/server/services/discovery.service.ts` contains `eq(setups.visibility, "public")`
    - `src/server/services/profile.service.ts` contains `eq(setups.visibility, "public")` (two occurrences)
    - `src/server/services/setup.service.ts` contains `visibility: data.visibility`
    - `src/client/routes/setups/$setupId.tsx` shows visibility badge with lock/link/globe icons
    - `bun run lint` passes
    - `bun test` passes (existing tests may need updating in Task 3)
  </acceptance_criteria>
  <done>All isPublic references replaced with visibility across the full stack</done>
</task>

<task type="auto">
  <name>Task 3: Update existing tests for visibility column</name>
  <files>tests/services/setup.service.test.ts, tests/services/discovery.service.test.ts, tests/services/profile.service.test.ts, tests/routes/discovery.test.ts, tests/routes/profiles.test.ts</files>
  <read_first>tests/services/setup.service.test.ts, tests/services/discovery.service.test.ts, tests/services/profile.service.test.ts</read_first>
  <action>
Update all existing tests that reference `isPublic` to use `visibility` instead:

1. **`tests/services/setup.service.test.ts`**: Replace `isPublic: true` with `visibility: "public"`, `isPublic: false` with `visibility: "private"` in all test fixtures and assertions.

2. **`tests/services/discovery.service.test.ts`**: Replace `isPublic: true` with `visibility: "public"` in setup creation for discovery feed tests.

3. **`tests/services/profile.service.test.ts`**: Replace `isPublic: true` with `visibility: "public"` in setup creation for public profile tests.

4. **`tests/routes/discovery.test.ts`**: Update route test fixtures.

5. **`tests/routes/profiles.test.ts`**: Update route test fixtures.

6. **`tests/helpers/db.ts`**: If createTestDb seeds any setup data with isPublic, update to visibility.

Run `bun test` to verify all tests pass after changes.
  </action>
  <verify>
    <automated>bun test</automated>
  </verify>
  <acceptance_criteria>
    - Zero occurrences of `isPublic` in `tests/` directory
    - `bun test` exits with code 0 (all tests pass)
    - Discovery feed tests verify `visibility: "public"` setups appear
    - Profile tests verify only `visibility: "public"` setups are returned
  </acceptance_criteria>
  <done>All existing tests pass with the visibility column changes</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| client->API | Visibility enum value from untrusted client input |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-32-01 | Tampering | updateSetup endpoint | mitigate | Zod enum validation ensures only "private"/"link"/"public" accepted — `z.enum(["private", "link", "public"])` at route entry |
| T-32-02 | Information Disclosure | getAllSetups | accept | getAllSetups is already scoped to authenticated userId — no cross-user visibility leak |
</threat_model>

<verification>
1. `bun run lint` passes
2. `bun test` passes — all existing tests updated for visibility
3. No `isPublic` references remain in `src/` or `tests/`
4. Schema migration applied successfully
</verification>

<success_criteria>
- isPublic column fully replaced by visibility column across entire codebase
- shares table exists in schema (ready for Plan 02)
- Discovery feed shows only visibility='public' setups (identical behavior to before)
- All existing tests pass with visibility column
</success_criteria>

<output>
After completion, create `.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md`
</output>
