--- 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: [] --- 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. ### Task 1: Install Sharp dependency Run `bun add sharp` and `bun add -d @types/sharp` to install the Sharp image processing library and its type definitions. grep '"sharp"' package.json && echo "PASS" || echo "FAIL" - package.json contains `"sharp"` in dependencies - @types/sharp in devDependencies - `bun install` completes without errors ### Task 2: Add schema fields to database - src/db/schema.ts 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"), ``` grep -c "dominant_color" src/db/schema.ts | grep -q "3" && echo "PASS" || echo "FAIL" - `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 ### Task 3: [BLOCKING] Push schema changes to database Run `bun run db:generate` to generate the Drizzle migration, then `bun run db:push` to apply it to the database. bun run db:push 2>&1 | tail -5 - 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 ### Task 4: Create dominant color extraction utility - src/server/services/storage.service.ts - src/server/services/image.service.ts 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 { 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. grep "extractDominantColor" src/server/services/image.service.ts && echo "PASS" || echo "FAIL" - `src/server/services/image.service.ts` exports `extractDominantColor` function - Function accepts `Buffer | ArrayBuffer` and returns `Promise` - Uses `sharp(buffer).resize(1, 1).raw().toBuffer()` to extract color - Returns hex string in format `#rrggbb` - Returns null on error (try/catch) ### Task 5: Integrate dominant color extraction into upload endpoints - src/server/routes/images.ts - src/server/services/image.service.ts 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`. grep "dominantColor" src/server/routes/images.ts && grep "dominantColor" src/server/services/image.service.ts && echo "PASS" || echo "FAIL" - `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 ### Task 6: Update Zod schemas for new fields - src/shared/schemas.ts 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. grep -c "dominantColor" src/shared/schemas.ts | grep -q "3" && echo "PASS" || echo "FAIL" - `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 ### Task 7: Update storage service to return dominant color with image URLs - src/server/services/storage.service.ts 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. grep "...record" src/server/services/storage.service.ts && echo "PASS" || echo "FAIL" - `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 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 - 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 | 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 | - [ ] 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