Files
GearBox/.planning/milestones/v2.2-phases/29-image-presentation/29-01-PLAN.md
Jean-Luc Makiola 2853477a75
All checks were successful
CI / ci (push) Successful in 1m15s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
chore: archive v2.2 User Experience Polish milestone
Phases 28-31 archived to milestones/v2.2-phases/
Requirements and roadmap snapshots archived to milestones/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:00:35 +02:00

10 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements
phase plan type wave depends_on files_modified autonomous requirements
29 01 backend 1
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
true
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"):

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"):

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"):

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`:
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. grep "extractDominantColor" src/server/services/image.service.ts && echo "PASS" || echo "FAIL" <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 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" <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 6: Update Zod schemas for new fields

- src/shared/schemas.ts Update `src/shared/schemas.ts`:

createItemSchema — add after brand: z.string().optional():

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():

dominantColor: z.string().nullable().optional(),
cropZoom: z.number().nullable().optional(),
cropX: z.number().nullable().optional(),
cropY: z.number().nullable().optional(),

upsertGlobalItemSchema — add after tags:

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" <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 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" <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>
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

<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>