docs(32): create phase plans for setup sharing system
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>
This commit is contained in:
298
.planning/phases/32-setup-sharing-system/32-01-PLAN.md
Normal file
298
.planning/phases/32-setup-sharing-system/32-01-PLAN.md
Normal file
@@ -0,0 +1,298 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user