feat: add org icon resize, upload, delete, and fetch helpers

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-02 21:55:56 +02:00
parent bafa36dd31
commit 2ab1d8e044
No known key found for this signature in database
4 changed files with 220 additions and 1 deletions

View file

@ -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");
});
});

View file

@ -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<ResizedIcon[]> {
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<string> {
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<void> {
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;
}
}

View file

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

View file

@ -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",
},
},
});