---
phase: 29
plan: 01
type: backend
wave: 1
depends_on: []
files_modified:
  - src/db/schema.ts
  - src/shared/schemas.ts
  - src/shared/types.ts
  - src/server/routes/images.ts
  - src/server/services/image.service.ts
  - src/server/services/storage.service.ts
  - src/server/services/item.service.ts
  - src/server/routes/items.ts
  - src/server/routes/threads.ts
  - src/server/routes/global-items.ts
  - package.json
autonomous: true
requirements: []
---

<objective>
Add dominant color extraction on image upload and extend the database schema with dominantColor and crop fields across items, globalItems, and threadCandidates tables. Install Sharp for server-side image processing. Update API schemas and services to accept/return the new fields.
</objective>

<tasks>

### Task 1: Install Sharp dependency
<task type="command">
<action>
Run `bun add sharp` and `bun add -d @types/sharp` to install the Sharp image processing library and its type definitions.
</action>
<verify>
<automated>grep '"sharp"' package.json && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- package.json contains `"sharp"` in dependencies
- @types/sharp in devDependencies
- `bun install` completes without errors
</acceptance_criteria>
</task>

### Task 2: Add schema fields to database
<task type="code">
<read_first>
- src/db/schema.ts
</read_first>
<action>
Add four new fields to THREE tables in `src/db/schema.ts`:

**items table** — add after `brand: text("brand")`:
```ts
dominantColor: text("dominant_color"),
cropZoom: doublePrecision("crop_zoom"),
cropX: doublePrecision("crop_x"),
cropY: doublePrecision("crop_y"),
```

**globalItems table** — add after `imageSourceUrl: text("image_source_url")`:
```ts
dominantColor: text("dominant_color"),
cropZoom: doublePrecision("crop_zoom"),
cropX: doublePrecision("crop_x"),
cropY: doublePrecision("crop_y"),
```

**threadCandidates table** — add after `imageSourceUrl: text("image_source_url")`:
```ts
dominantColor: text("dominant_color"),
cropZoom: doublePrecision("crop_zoom"),
cropX: doublePrecision("crop_x"),
cropY: doublePrecision("crop_y"),
```
</action>
<verify>
<automated>grep -c "dominant_color" src/db/schema.ts | grep -q "3" && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `src/db/schema.ts` contains `dominantColor: text("dominant_color")` in items, globalItems, and threadCandidates tables (3 occurrences)
- `src/db/schema.ts` contains `cropZoom: doublePrecision("crop_zoom")` in all 3 tables
- `src/db/schema.ts` contains `cropX: doublePrecision("crop_x")` in all 3 tables
- `src/db/schema.ts` contains `cropY: doublePrecision("crop_y")` in all 3 tables
</acceptance_criteria>
</task>

### Task 3: [BLOCKING] Push schema changes to database
<task type="command">
<action>
Run `bun run db:generate` to generate the Drizzle migration, then `bun run db:push` to apply it to the database.
</action>
<verify>
<automated>bun run db:push 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- Migration generated successfully
- `bun run db:push` completes without errors
- Database contains dominant_color, crop_zoom, crop_x, crop_y columns on items, global_items, and thread_candidates tables
</acceptance_criteria>
</task>

### Task 4: Create dominant color extraction utility
<task type="code">
<read_first>
- src/server/services/storage.service.ts
- src/server/services/image.service.ts
</read_first>
<action>
Create a new function `extractDominantColor` in `src/server/services/image.service.ts`:

```ts
import sharp from "sharp";

/**
 * Extract the dominant color from an image buffer.
 * Resizes to 1x1 pixel for a perceptually weighted average.
 * Returns hex string like '#a3b2c1' or null on failure.
 */
export async function extractDominantColor(buffer: Buffer | ArrayBuffer): Promise<string | null> {
  try {
    const { data } = await sharp(Buffer.from(buffer))
      .resize(1, 1)
      .raw()
      .toBuffer({ resolveWithObject: true });
    const r = data[0];
    const g = data[1];
    const b = data[2];
    return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
  } catch {
    return null;
  }
}
```

Keep the existing `fetchImageFromUrl` function. Add the import for sharp at the top.
</action>
<verify>
<automated>grep "extractDominantColor" src/server/services/image.service.ts && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `src/server/services/image.service.ts` exports `extractDominantColor` function
- Function accepts `Buffer | ArrayBuffer` and returns `Promise<string | null>`
- Uses `sharp(buffer).resize(1, 1).raw().toBuffer()` to extract color
- Returns hex string in format `#rrggbb`
- Returns null on error (try/catch)
</acceptance_criteria>
</task>

### Task 5: Integrate dominant color extraction into upload endpoints
<task type="code">
<read_first>
- src/server/routes/images.ts
- src/server/services/image.service.ts
</read_first>
<action>
Update `src/server/routes/images.ts` to extract dominant color during upload:

**POST `/` (direct upload):**
1. After `const buffer = await file.arrayBuffer();`
2. Add: `const dominantColor = await extractDominantColor(buffer);`
3. Change response from `{ filename }` to `{ filename, dominantColor }`

**POST `/from-url`:**
1. In `src/server/services/image.service.ts`, update `fetchImageFromUrl` to also extract dominant color
2. After `await uploadImage(Buffer.from(buffer), filename, contentType);`
3. Add: `const dominantColor = await extractDominantColor(buffer);`
4. Change return from `{ filename, sourceUrl: url }` to `{ filename, sourceUrl: url, dominantColor }`
5. Update `FetchImageResult` interface to include `dominantColor: string | null`

Import `extractDominantColor` in images.ts from `../services/image.service`.
</action>
<verify>
<automated>grep "dominantColor" src/server/routes/images.ts && grep "dominantColor" src/server/services/image.service.ts && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `POST /api/images` response includes `dominantColor` field (string or null)
- `POST /api/images/from-url` response includes `dominantColor` field
- `FetchImageResult` interface has `dominantColor: string | null`
- Dominant color extraction happens before the response is sent
</acceptance_criteria>
</task>

### Task 6: Update Zod schemas for new fields
<task type="code">
<read_first>
- src/shared/schemas.ts
</read_first>
<action>
Update `src/shared/schemas.ts`:

**createItemSchema** — add after `brand: z.string().optional()`:
```ts
dominantColor: z.string().nullable().optional(),
cropZoom: z.number().nullable().optional(),
cropX: z.number().nullable().optional(),
cropY: z.number().nullable().optional(),
```

**createCandidateSchema** — add after `globalItemId: z.number().int().positive().optional()`:
```ts
dominantColor: z.string().nullable().optional(),
cropZoom: z.number().nullable().optional(),
cropX: z.number().nullable().optional(),
cropY: z.number().nullable().optional(),
```

**upsertGlobalItemSchema** — add after `tags`:
```ts
dominantColor: z.string().nullable().optional(),
cropZoom: z.number().nullable().optional(),
cropX: z.number().nullable().optional(),
cropY: z.number().nullable().optional(),
```

updateItemSchema and updateCandidateSchema already use `.partial()` so they inherit the new fields automatically.
</action>
<verify>
<automated>grep -c "dominantColor" src/shared/schemas.ts | grep -q "3" && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `createItemSchema` contains `dominantColor`, `cropZoom`, `cropX`, `cropY` fields
- `createCandidateSchema` contains the same 4 fields
- `upsertGlobalItemSchema` contains the same 4 fields
- All use `z.number().nullable().optional()` for crop fields
- All use `z.string().nullable().optional()` for dominantColor
</acceptance_criteria>
</task>

### Task 7: Update storage service to return dominant color with image URLs
<task type="code">
<read_first>
- src/server/services/storage.service.ts
</read_first>
<action>
The `withImageUrl` and `withImageUrls` functions in `src/server/services/storage.service.ts` currently enrich records with `imageUrl`. They already pass through all record fields via spread operator, so `dominantColor`, `cropZoom`, `cropX`, `cropY` will automatically be included in the response when they exist on the record.

No changes needed to storage.service.ts — the spread operator `{ ...record, imageUrl }` already forwards all fields.

Verify this by confirming the return type `T & { imageUrl: string | null }` preserves all properties of T.
</action>
<verify>
<automated>grep "...record" src/server/services/storage.service.ts && echo "PASS" || echo "FAIL"</automated>
</verify>
<acceptance_criteria>
- `withImageUrl` function uses spread operator `{ ...record, imageUrl }` which preserves dominantColor and crop fields from the source record
- No changes needed — verify existing behavior is sufficient
</acceptance_criteria>
</task>

</tasks>

<verification>
1. `bun run lint` passes
2. `bun test` passes (existing tests not broken)
3. Database has new columns: `SELECT column_name FROM information_schema.columns WHERE table_name = 'items' AND column_name LIKE '%crop%' OR column_name = 'dominant_color';`
4. Upload endpoint returns dominantColor in response body
</verification>

<success_criteria>
- Sharp installed and importable
- 3 tables have dominantColor + crop fields
- Image upload extracts and returns dominant color
- Zod schemas accept new fields
- All existing tests pass
</success_criteria>

<threat_model>
| Threat | Severity | Mitigation |
|--------|----------|------------|
| Sharp buffer overflow via malformed image | Medium | Sharp handles this internally with libvips bounds checking; wrapped in try/catch returning null |
| DoS via large image processing | Low | Existing 5MB file size limit applies before Sharp processing |
| Stored XSS via dominantColor field | Low | Value is a hex color string extracted server-side, not user input; rendered as CSS backgroundColor |
</threat_model>

<must_haves>
- [ ] Sharp dependency installed
- [ ] dominantColor field on items, globalItems, threadCandidates
- [ ] Crop fields (cropZoom, cropX, cropY) on all 3 tables
- [ ] Upload endpoints return dominantColor
- [ ] Schema pushed to database
</must_haves>
