---
phase: 18-global-items-public-profiles
plan: 03
type: execute
wave: 2
depends_on: ["18-01"]
files_modified:
  - src/server/services/profile.service.ts
  - src/server/routes/profiles.ts
  - src/server/routes/auth.ts
  - src/server/services/setup.service.ts
  - src/server/routes/setups.ts
  - src/server/index.ts
  - tests/services/profile.service.test.ts
  - tests/routes/profiles.test.ts
autonomous: true
requirements: [PROF-01, PROF-02, PROF-03, PROF-04, PROF-05]

must_haves:
  truths:
    - "PUT /api/auth/profile updates display name, avatar URL, and bio for the authenticated user"
    - "GET /api/users/:id/profile returns public profile data (name, avatar, bio, public setups) without auth"
    - "PATCH or PUT to setup with isPublic=true makes the setup public"
    - "GET /api/setups/:id/public returns setup details without auth (only if isPublic is true)"
    - "GET /api/setups/:id/public returns 404 for private setups"
    - "Public profile lists only public setups, not private ones"
  artifacts:
    - path: "src/server/services/profile.service.ts"
      provides: "updateProfile, getPublicProfile"
      exports: ["updateProfile", "getPublicProfile"]
    - path: "src/server/routes/profiles.ts"
      provides: "GET /api/users/:id/profile route"
      min_lines: 20
    - path: "tests/services/profile.service.test.ts"
      provides: "Profile service tests"
      min_lines: 40
    - path: "tests/routes/profiles.test.ts"
      provides: "Profile and public setup route tests"
      min_lines: 50
  key_links:
    - from: "src/server/routes/profiles.ts"
      to: "src/server/services/profile.service.ts"
      via: "import and call"
      pattern: "getPublicProfile"
    - from: "src/server/routes/auth.ts"
      to: "src/server/services/profile.service.ts"
      via: "import updateProfile"
      pattern: "updateProfile"
    - from: "src/server/index.ts"
      to: "src/server/routes/profiles.ts"
      via: "app.route registration"
      pattern: "app\\.route.*profiles"
---

<objective>
Build the user profiles and public sharing backend: profile service for CRUD and public profile data, profile update endpoint on auth routes, public profile route, setup isPublic toggle, and public setup view endpoint.

Purpose: Delivers PROF-01 through PROF-05 server-side. Users can edit their profile, toggle setup visibility, and anyone can view public profiles and setups without auth.
Output: profile.service.ts, profiles.ts routes, updated auth.ts + setup service/routes + index.ts, service + route tests
</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/phases/18-global-items-public-profiles/18-CONTEXT.md
@.planning/phases/18-global-items-public-profiles/18-RESEARCH.md
@.planning/phases/18-global-items-public-profiles/18-01-SUMMARY.md

@src/server/services/setup.service.ts
@src/server/routes/setups.ts
@src/server/routes/auth.ts
@src/server/index.ts
@src/server/middleware/auth.ts
@tests/helpers/db.ts

<interfaces>
<!-- From Plan 01 (users table additions): -->
users table now has:
  displayName: text("display_name"),  // nullable
  avatarUrl: text("avatar_url"),      // nullable
  bio: text("bio"),                   // nullable

<!-- From Plan 01 (setups table addition): -->
setups table now has:
  isPublic: boolean("is_public").notNull().default(false),

<!-- From schemas.ts: -->
export const updateProfileSchema = z.object({
  displayName: z.string().max(100).optional(),
  avatarUrl: z.string().optional(),
  bio: z.string().max(500).optional(),
});
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Profile service + setup visibility + tests</name>
  <files>src/server/services/profile.service.ts, src/server/services/setup.service.ts, tests/services/profile.service.test.ts</files>
  <read_first>src/server/services/setup.service.ts, src/db/schema.ts, tests/helpers/db.ts, src/shared/schemas.ts</read_first>
  <behavior>
    - updateProfile(db, userId, { displayName: "Alice" }) updates user and returns updated row
    - updateProfile(db, userId, { bio: "Bikepacker" }) updates bio only, leaves other fields untouched
    - updateProfile(db, userId, {}) does nothing harmful, returns user
    - getPublicProfile(db, userId) returns { id, displayName, avatarUrl, bio, setups: [] } when user has no public setups
    - getPublicProfile(db, userId) returns only public setups in the setups array (not private ones)
    - getPublicProfile(db, nonExistentId) returns null
    - getPublicSetupWithItems(db, setupId) returns setup with items when isPublic is true
    - getPublicSetupWithItems(db, setupId) returns null when isPublic is false
    - Updated setup service: createSetup and updateSetup handle isPublic field
  </behavior>
  <action>
**profile.service.ts**: Create at `src/server/services/profile.service.ts`. Follow service pattern.

1. `updateProfile(db: Db, userId: number, data: UpdateProfile)` — Use `db.update(users).set(data).where(eq(users.id, userId)).returning()`. Return updated user or null if not found. Only set fields that are present in data (Drizzle handles undefined correctly).

2. `getPublicProfile(db: Db, userId: number)` — Select id, displayName, avatarUrl, bio from users. Then select id, name, createdAt from setups where userId matches AND isPublic is true. Return `{ ...user, setups: publicSetups }` or null.

3. `getPublicSetupWithItems(db: Db, setupId: number)` — Similar to existing `getSetupWithItems` but: no userId param, adds `eq(setups.isPublic, true)` to where clause. Returns null if setup doesn't exist or is private. Include items via setupItems join (same pattern as existing function). Include weight/cost aggregates.

**setup.service.ts updates**:
- Update `createSetup` to accept and persist `isPublic` from data (default false if not provided).
- Update `updateSetup` to accept and persist `isPublic` if provided.
- Update `getAllSetups` return fields to include `isPublic`.
- Update `getSetupWithItems` return to include `isPublic`.

**Tests**: Write tests FIRST. Use `createTestDb()`. Create user profile data via direct db.update. Create setups with isPublic true/false to test filtering.
  </action>
  <verify>
    <automated>bun test tests/services/profile.service.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "updateProfile" src/server/services/profile.service.ts
    - grep -q "getPublicProfile" src/server/services/profile.service.ts
    - grep -q "getPublicSetupWithItems" src/server/services/profile.service.ts
    - grep -q "isPublic" src/server/services/setup.service.ts
    - test -f tests/services/profile.service.test.ts
  </acceptance_criteria>
  <done>Profile service and public setup service functions pass all tests. Setup service handles isPublic in create/update/list/detail.</done>
</task>

<task type="auto">
  <name>Task 2: Profile routes + public setup route + auth middleware + route tests</name>
  <files>src/server/routes/profiles.ts, src/server/routes/auth.ts, src/server/routes/setups.ts, src/server/index.ts, tests/routes/profiles.test.ts</files>
  <read_first>src/server/routes/auth.ts, src/server/routes/setups.ts, src/server/index.ts, tests/routes/setups.test.ts</read_first>
  <action>
**profiles.ts route**: Create at `src/server/routes/profiles.ts`.

1. `GET /:id/profile` (maps to `/api/users/:id/profile`) — per D-20. Parse id with parseId. Call `getPublicProfile(db, id)`. Return 404 if null, otherwise JSON. No auth needed.

**auth.ts updates** — per D-21:

2. `PUT /profile` (maps to `/api/auth/profile`) — Validate body with `updateProfileSchema` via zValidator. Get userId from context. Call `updateProfile(db, userId, body)`. Return updated profile JSON.

**setups.ts updates** — per D-22:

3. Add `GET /:id/public` endpoint — Parse id with parseId. Call `getPublicSetupWithItems(db, id)`. Return 404 if null (setup not found or is private). Return JSON with setup details and items. This route exists within the existing setup routes file, but the auth middleware skip handles making it public.

4. Ensure existing PUT /:id passes isPublic from body through to updateSetup service function.

**index.ts updates**:
1. Import `profileRoutes` from routes/profiles.ts
2. Register: `app.route("/api/users", profileRoutes)`
3. Update auth middleware skip: Add conditions for:
   - `c.req.path.match(/^\/api\/users\/\d+\/profile$/) && c.req.method === "GET"` — skip auth
   - `c.req.path.match(/^\/api\/setups\/\d+\/public$/) && c.req.method === "GET"` — skip auth

**Route tests**: Test:
- GET /api/users/:id/profile returns 200 without auth, includes public setups only
- GET /api/users/999/profile returns 404
- PUT /api/auth/profile returns 200 with updated fields (requires auth)
- PUT /api/auth/profile without auth returns 401
- GET /api/setups/:id/public returns 200 for public setup without auth
- GET /api/setups/:id/public returns 404 for private setup
  </action>
  <verify>
    <automated>bun test tests/routes/profiles.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "profileRoutes\|profile" src/server/routes/profiles.ts
    - grep -q "profile" src/server/routes/auth.ts
    - grep -q "public" src/server/routes/setups.ts
    - grep -q "api/users" src/server/index.ts
    - grep -q "api/setups.*public\|api/users.*profile" src/server/index.ts
    - test -f tests/routes/profiles.test.ts
  </acceptance_criteria>
  <done>Public profile endpoint returns user info + public setups. Profile update requires auth. Public setup view works without auth and returns 404 for private setups. Auth middleware correctly skips public routes. All route tests pass.</done>
</task>

</tasks>

<verification>
- `bun test tests/services/profile.service.test.ts` — all service tests pass
- `bun test tests/routes/profiles.test.ts` — all route tests pass
- `bun test` — full suite passes (no regressions from setup service changes)
</verification>

<success_criteria>
Profile CRUD works server-side. Public profile shows user info and public setups only. Setup visibility toggle persists. Public setup endpoint serves setup details without auth. Auth middleware correctly routes public/private access. All tests pass.
</success_criteria>

<output>
After completion, create `.planning/phases/18-global-items-public-profiles/18-03-SUMMARY.md`
</output>
