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:
parent
bafa36dd31
commit
2ab1d8e044
4 changed files with 220 additions and 1 deletions
59
apps/api/src/helpers/orgIcons.test.ts
Normal file
59
apps/api/src/helpers/orgIcons.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
142
apps/api/src/helpers/orgIcons.ts
Normal file
142
apps/api/src/helpers/orgIcons.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
18
apps/api/vitest.unit.config.ts
Normal file
18
apps/api/vitest.unit.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue