From 2ab1d8e0446bd34c595a0f312e1b7b09aa2799d6 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 2 Apr 2026 21:55:56 +0200 Subject: [PATCH] feat: add org icon resize, upload, delete, and fetch helpers Co-Authored-By: Claude Sonnet 4.6 (1M context) --- apps/api/src/helpers/orgIcons.test.ts | 59 +++++++++++ apps/api/src/helpers/orgIcons.ts | 142 ++++++++++++++++++++++++++ apps/api/vitest.config.ts | 2 +- apps/api/vitest.unit.config.ts | 18 ++++ 4 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/helpers/orgIcons.test.ts create mode 100644 apps/api/src/helpers/orgIcons.ts create mode 100644 apps/api/vitest.unit.config.ts diff --git a/apps/api/src/helpers/orgIcons.test.ts b/apps/api/src/helpers/orgIcons.test.ts new file mode 100644 index 0000000..6493d46 --- /dev/null +++ b/apps/api/src/helpers/orgIcons.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resizeOrgIcon, buildOrgIconKey, ICON_SIZES, ORG_ICONS_BUCKET } from "./orgIcons.js"; + +describe("buildOrgIconKey", () => { + it("builds the correct R2 key for a given org and size", () => { + expect(buildOrgIconKey(42, 192)).toBe("org-icons/42/icon-192.png"); + }); + + it("builds the correct key for maskable variant", () => { + expect(buildOrgIconKey(42, 512, true)).toBe("org-icons/42/icon-512-maskable.png"); + }); +}); + +describe("ICON_SIZES", () => { + it("includes all required PWA icon sizes", () => { + const sizes = ICON_SIZES.map((s) => s.size); + expect(sizes).toContain(16); + expect(sizes).toContain(32); + expect(sizes).toContain(180); + expect(sizes).toContain(192); + expect(sizes).toContain(512); + }); + + it("includes a maskable 512 variant", () => { + const maskable = ICON_SIZES.find((s) => s.maskable); + expect(maskable).toBeDefined(); + expect(maskable!.size).toBe(512); + }); +}); + +describe("resizeOrgIcon", () => { + it("returns buffers for all required sizes from a valid PNG input", async () => { + const sharp = (await import("sharp")).default; + const input = await sharp({ + create: { width: 512, height: 512, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } }, + }) + .png() + .toBuffer(); + + const results = await resizeOrgIcon(input); + + expect(results).toHaveLength(ICON_SIZES.length); + for (const result of results) { + expect(result.buffer).toBeInstanceOf(Buffer); + expect(result.buffer.length).toBeGreaterThan(0); + } + }); + + it("rejects images smaller than 512x512", async () => { + const sharp = (await import("sharp")).default; + const tooSmall = await sharp({ + create: { width: 256, height: 256, channels: 4, background: { r: 0, g: 0, b: 255, alpha: 1 } }, + }) + .png() + .toBuffer(); + + await expect(resizeOrgIcon(tooSmall)).rejects.toThrow("minimum 512x512"); + }); +}); diff --git a/apps/api/src/helpers/orgIcons.ts b/apps/api/src/helpers/orgIcons.ts new file mode 100644 index 0000000..5c13e9c --- /dev/null +++ b/apps/api/src/helpers/orgIcons.ts @@ -0,0 +1,142 @@ +import sharp from "sharp"; +import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"; + +export const ORG_ICONS_BUCKET = "web-assets"; + +export const ICON_SIZES = [ + { size: 16, maskable: false }, + { size: 32, maskable: false }, + { size: 180, maskable: false }, + { size: 192, maskable: false }, + { size: 512, maskable: false }, + { size: 512, maskable: true }, +] as const; + +export function buildOrgIconKey(orgId: number, size: number, maskable = false): string { + const suffix = maskable ? `-${size}-maskable` : `-${size}`; + return `org-icons/${orgId}/icon${suffix}.png`; +} + +export function buildOrgIconBasePath(orgId: number): string { + return `org-icons/${orgId}/`; +} + +export interface ResizedIcon { + size: number; + maskable: boolean; + buffer: Buffer; + key: string; +} + +export async function resizeOrgIcon(inputBuffer: Buffer): Promise { + const metadata = await sharp(inputBuffer).metadata(); + if (!metadata.width || !metadata.height || metadata.width < 512 || metadata.height < 512) { + throw new Error("Image must be minimum 512x512 pixels"); + } + + const results: ResizedIcon[] = []; + + for (const { size, maskable } of ICON_SIZES) { + // For maskable icons, add 10% padding (safe zone) with white background + if (maskable) { + const innerSize = Math.round(size * 0.8); + const resized = await sharp(inputBuffer) + .resize(innerSize, innerSize, { fit: "cover" }) + .png() + .toBuffer(); + + const padded = await sharp({ + create: { + width: size, + height: size, + channels: 4, + background: { r: 255, g: 255, b: 255, alpha: 1 }, + }, + }) + .composite([{ input: resized, gravity: "center" }]) + .png() + .toBuffer(); + + results.push({ + size, + maskable, + buffer: padded, + key: buildOrgIconKey(0, size, maskable), + }); + continue; + } + + const buffer = await sharp(inputBuffer).resize(size, size, { fit: "cover" }).png().toBuffer(); + results.push({ + size, + maskable, + buffer, + key: buildOrgIconKey(0, size, maskable), + }); + } + + return results; +} + +export async function uploadOrgIcons( + s3Client: S3Client, + orgId: number, + inputBuffer: Buffer +): Promise { + const resized = await resizeOrgIcon(inputBuffer); + + for (const icon of resized) { + const key = buildOrgIconKey(orgId, icon.size, icon.maskable); + await s3Client.send( + new PutObjectCommand({ + Bucket: ORG_ICONS_BUCKET, + Key: key, + Body: icon.buffer, + ContentType: "image/png", + }) + ); + } + + return buildOrgIconBasePath(orgId); +} + +export async function deleteOrgIcons(s3Client: S3Client, orgId: number): Promise { + const prefix = buildOrgIconBasePath(orgId); + const listed = await s3Client.send( + new ListObjectsV2Command({ + Bucket: ORG_ICONS_BUCKET, + Prefix: prefix, + }) + ); + + if (!listed.Contents || listed.Contents.length === 0) return; + + await s3Client.send( + new DeleteObjectsCommand({ + Bucket: ORG_ICONS_BUCKET, + Delete: { Objects: listed.Contents.map(({ Key }) => ({ Key })) }, + }) + ); +} + +export async function getOrgIcon( + s3Client: S3Client, + orgId: number, + size: number, + maskable: boolean +): Promise<{ buffer: Buffer; contentType: string } | null> { + const key = buildOrgIconKey(orgId, size, maskable); + try { + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: ORG_ICONS_BUCKET, + Key: key, + }) + ); + const body = await response.Body?.transformToByteArray(); + if (!body) return null; + return { buffer: Buffer.from(body), contentType: "image/png" }; + } catch { + return null; + } +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 0b31346..e2c7fcf 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ globalSetup: ["./src/__tests__/globalSetup.ts"], testTimeout: 30000, hookTimeout: 60000, - include: ["src/__tests__/**/*.test.ts"], + include: ["src/__tests__/**/*.test.ts", "src/**/*.test.ts"], exclude: ["node_modules", "dist"], reporters: ["verbose"], pool: "forks", diff --git a/apps/api/vitest.unit.config.ts b/apps/api/vitest.unit.config.ts new file mode 100644 index 0000000..41fd3aa --- /dev/null +++ b/apps/api/vitest.unit.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + testTimeout: 30000, + include: ["src/helpers/**/*.test.ts"], + exclude: ["node_modules", "dist"], + reporters: ["verbose"], + pool: "forks", + }, + resolve: { + alias: { + "@": "/src", + }, + }, +});