4 plans in 3 waves: - Wave 1: Schema migration (isPublic→visibility) + shares table - Wave 2: Share link service + API routes - Wave 3: Share modal UI + shared setup viewer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
299 lines
14 KiB
Markdown
299 lines
14 KiB
Markdown
---
|
|
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>
|