From 6ec82443f786f02680e3da414edcbec6f3b04cad Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 2 Apr 2026 21:51:18 +0200 Subject: [PATCH] docs: add dynamic PWA manifest implementation plan Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../plans/2026-04-02-dynamic-pwa-manifest.md | 1245 +++++++++++++++++ 1 file changed, 1245 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-02-dynamic-pwa-manifest.md diff --git a/docs/superpowers/plans/2026-04-02-dynamic-pwa-manifest.md b/docs/superpowers/plans/2026-04-02-dynamic-pwa-manifest.md new file mode 100644 index 0000000..b553e85 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-dynamic-pwa-manifest.md @@ -0,0 +1,1245 @@ +# Dynamic PWA Manifest with Organization Logo — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable organizations to upload a custom logo that replaces default XTablo icons in the installed PWA via a dynamic Cloudflare Worker manifest. + +**Architecture:** Supabase migration adds `logo_url` to organizations. API receives logo upload, resizes with `sharp` into 6 PWA icon sizes, stores in R2. Cloudflare Worker intercepts `/manifest.webmanifest`, reads `x-org-id` cookie, returns manifest with org-specific or default icon URLs. A public API endpoint serves org icons from R2 with fallback to defaults. + +**Tech Stack:** sharp (image resizing), Cloudflare Workers, R2 (S3-compatible), Supabase (Postgres), Hono, React, vite-plugin-pwa + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `supabase/migrations/20260402120000_add_organization_logo_url.sql` | Add `logo_url` column to organizations | +| Modify | `apps/api/package.json` | Add `sharp` dependency | +| Create | `apps/api/src/helpers/orgIcons.ts` | Icon resize + R2 upload/delete/fetch logic | +| Create | `apps/api/src/helpers/orgIcons.test.ts` | Tests for icon helper | +| Modify | `apps/api/src/routers/user.ts` | Extend PATCH org + add icon-serving GET endpoint | +| Create | `apps/api/src/routers/user.test.ts` | Tests for new/modified endpoints | +| Modify | `apps/api/src/routers/public.ts` | Add public org-icon serving route | +| Modify | `apps/main/worker/index.ts` | Dynamic manifest generation | +| Create | `apps/main/worker/index.test.ts` | Tests for worker manifest logic | +| Modify | `apps/main/vite.config.ts` | Set `manifest: false`, keep service worker | +| Modify | `apps/main/index.html` | Add `` manually | +| Modify | `apps/main/src/hooks/organization.ts` | Add `useUploadOrgLogo`, `useRemoveOrgLogo`, cookie side-effect | +| Modify | `apps/main/src/hooks/auth.ts` | Clear `x-org-id` cookie on logout | +| Modify | `apps/main/src/pages/settings.tsx` | Add logo upload UI to org section | + +--- + +### Task 1: Supabase Migration — `logo_url` Column + +**Files:** +- Create: `supabase/migrations/20260402120000_add_organization_logo_url.sql` + +- [ ] **Step 1: Write the migration** + +```sql +-- Add logo_url to organizations for storing the R2 base path to org icon variants +ALTER TABLE public.organizations ADD COLUMN logo_url text; + +-- Add comment for clarity +COMMENT ON COLUMN public.organizations.logo_url IS 'R2 base path for org icon variants (e.g. org-icons/42/). NULL means default XTablo icons.'; +``` + +Save to `supabase/migrations/20260402120000_add_organization_logo_url.sql`. + +- [ ] **Step 2: Verify migration syntax** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && npx supabase db lint --schema public` + +Expected: No errors for the new migration. + +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/20260402120000_add_organization_logo_url.sql +git commit -m "feat: add logo_url column to organizations table" +``` + +--- + +### Task 2: Install `sharp` in the API + +**Files:** +- Modify: `apps/api/package.json` + +- [ ] **Step 1: Add sharp dependency** + +```bash +cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/apps/api && pnpm add sharp && pnpm add -D @types/sharp +``` + +- [ ] **Step 2: Verify installation** + +```bash +cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/apps/api && node -e "const sharp = require('sharp'); console.log('sharp version:', sharp.versions.sharp)" +``` + +Expected: Prints sharp version without errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/package.json pnpm-lock.yaml +git commit -m "feat: add sharp for server-side image resizing" +``` + +--- + +### Task 3: Org Icon Helpers — Resize, Upload, Delete, Fetch + +**Files:** +- Create: `apps/api/src/helpers/orgIcons.ts` +- Create: `apps/api/src/helpers/orgIcons.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `apps/api/src/helpers/orgIcons.test.ts`: + +```typescript +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 () => { + // Create a minimal 512x512 red PNG using sharp + 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"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/apps/api && pnpm vitest run src/helpers/orgIcons.test.ts` + +Expected: FAIL — module `./orgIcons.js` not found. + +- [ ] **Step 3: Write the implementation** + +Create `apps/api/src/helpers/orgIcons.ts`: + +```typescript +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) { + let pipeline = sharp(inputBuffer).resize(size, size, { fit: "cover" }).png(); + + // For maskable icons, add 10% padding (safe zone) with transparent 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), // orgId filled by caller + }); + continue; + } + + const buffer = await pipeline.toBuffer(); + results.push({ + size, + maskable, + buffer, + key: buildOrgIconKey(0, size, maskable), // orgId filled by caller + }); + } + + 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; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/apps/api && pnpm vitest run src/helpers/orgIcons.test.ts` + +Expected: All 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/helpers/orgIcons.ts apps/api/src/helpers/orgIcons.test.ts +git commit -m "feat: add org icon resize, upload, delete, and fetch helpers" +``` + +--- + +### Task 4: Extend PATCH Organization Endpoint for Logo Upload + +**Files:** +- Modify: `apps/api/src/routers/user.ts:439-474` (the `updateOrganization` handler) + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/src/routers/user.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// We test the updateOrganization handler indirectly via the router +// Mock dependencies +vi.mock("../helpers/orgIcons.js", () => ({ + uploadOrgIcons: vi.fn().mockResolvedValue("org-icons/1/"), + deleteOrgIcons: vi.fn().mockResolvedValue(undefined), + ORG_ICONS_BUCKET: "web-assets", +})); + +describe("PATCH /organization - logo upload", () => { + it("accepts a logo field and calls uploadOrgIcons", async () => { + const { uploadOrgIcons } = await import("../helpers/orgIcons.js"); + + // Verify the mock is callable + expect(uploadOrgIcons).toBeDefined(); + expect(vi.mocked(uploadOrgIcons)).toHaveBeenCalledTimes(0); + + // The full integration test requires Supabase mocking which is complex. + // This verifies the import/mock wiring works. + // Integration tested via manual testing and the org icon helper tests. + }); + + it("accepts logo: null to clear the logo", async () => { + const { deleteOrgIcons } = await import("../helpers/orgIcons.js"); + expect(deleteOrgIcons).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes (mock wiring check)** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/apps/api && pnpm vitest run src/routers/user.test.ts` + +Expected: PASS (these are wiring checks; the real logic test is integration). + +- [ ] **Step 3: Modify the updateOrganization handler** + +In `apps/api/src/routers/user.ts`, replace the `updateOrganization` handler (lines 439-474) with: + +```typescript +const updateOrganization = factory.createHandlers(async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const s3Client = c.get("s3_client"); + const body = await c.req.json(); + + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("organization_id, is_temporary") + .eq("id", user.id) + .single(); + + if (profileError || !profile?.organization_id) { + return c.json({ error: "Failed to resolve your organization" }, 500); + } + + if (profile.is_temporary) { + return c.json({ error: "Temporary users cannot update organization settings" }, 403); + } + + const organizationId = profile.organization_id; + const updateData: Record = {}; + + // Handle name update + if (body?.name !== undefined) { + const rawName = typeof body.name === "string" ? body.name : ""; + const name = rawName.trim(); + if (name.length < 2 || name.length > 100) { + return c.json({ error: "Organization name must be between 2 and 100 characters" }, 400); + } + updateData.name = name; + } + + // Handle logo upload + if (body?.logo !== undefined) { + if (body.logo === null) { + // Remove logo + await deleteOrgIcons(s3Client, organizationId); + updateData.logo_url = null; + } else if (body.logo?.content && body.logo?.contentType) { + const { content, contentType } = body.logo; + + // Validate content type + const allowedTypes = ["image/png", "image/jpeg", "image/webp"]; + if (!allowedTypes.includes(contentType)) { + return c.json({ error: "Logo must be PNG, JPEG, or WebP" }, 400); + } + + const imageBuffer = Buffer.from(content, "base64"); + try { + const basePath = await uploadOrgIcons(s3Client, organizationId, imageBuffer); + updateData.logo_url = basePath; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to process logo"; + return c.json({ error: message }, 400); + } + } + } + + if (Object.keys(updateData).length > 0) { + const { error: updateError } = await supabase + .from("organizations") + .update(updateData) + .eq("id", organizationId); + + if (updateError) { + return c.json({ error: updateError.message }, 500); + } + } + + return c.json({ message: "Organization updated successfully" }); +}); +``` + +Also add the import at the top of `apps/api/src/routers/user.ts`: + +```typescript +import { uploadOrgIcons, deleteOrgIcons } from "../helpers/orgIcons.js"; +``` + +- [ ] **Step 4: Run existing tests to verify nothing is broken** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/apps/api && pnpm vitest run` + +Expected: All existing tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/routers/user.ts apps/api/src/routers/user.test.ts +git commit -m "feat: extend PATCH /organization to accept logo upload and removal" +``` + +--- + +### Task 5: Public Org Icon Serving Endpoint + +**Files:** +- Modify: `apps/api/src/routers/public.ts` +- Modify: `apps/api/src/routers/index.ts` (if needed for route wiring) + +- [ ] **Step 1: Add the public icon-serving route** + +In `apps/api/src/routers/public.ts`, add at the end of the file before the router export: + +```typescript +import { getOrgIcon } from "../helpers/orgIcons.js"; +``` + +Add the handler and route. The route pattern is `GET /org-icons/:orgId/:filename`. The filename encodes size and maskable variant (e.g., `icon-192.png` or `icon-512-maskable.png`). + +```typescript +const getOrgIconHandler = factory.createHandlers(async (c) => { + const orgIdStr = c.req.param("orgId"); + const filename = c.req.param("filename"); + const orgId = Number.parseInt(orgIdStr, 10); + + if (Number.isNaN(orgId) || !filename) { + return c.json({ error: "Invalid request" }, 400); + } + + // Parse filename: "icon-192.png" or "icon-512-maskable.png" + const match = filename.match(/^icon-(\d+)(-maskable)?\.png$/); + if (!match) { + return c.json({ error: "Invalid icon filename" }, 400); + } + + const size = Number.parseInt(match[1], 10); + const maskable = !!match[2]; + + const s3Client = c.get("s3_client") as S3Client; + const result = await getOrgIcon(s3Client, orgId, size, maskable); + + if (!result) { + // Fallback: redirect to default PWA icon + const defaultPath = maskable + ? "/pwa-icons/pwa-512x512-maskable.png" + : size <= 32 + ? `/pwa-icons/favicon-${size}x${size}.png` + : size === 180 + ? "/pwa-icons/apple-touch-icon-180x180.png" + : `/pwa-icons/pwa-${size}x${size}.png`; + return c.redirect(defaultPath, 302); + } + + return new Response(result.buffer, { + headers: { + "Content-Type": result.contentType, + "Cache-Control": "public, max-age=86400", + }, + }); +}); +``` + +Add the route to the public router (inside `getPublicRouter()`): + +```typescript +publicRouter.get("/org-icons/:orgId/:filename", ...getOrgIconHandler); +``` + +Note: This endpoint needs the R2 middleware. Since public routes currently don't have it, add `r2Middleware` usage. Check how the public router is wired — it goes through `mainRouter.use(middlewareManager.r2)` which is applied globally in `apps/api/src/routers/index.ts:22`, so it's already available. + +Also add the import for S3Client type at the top of `public.ts`: + +```typescript +import type { S3Client } from "@aws-sdk/client-s3"; +``` + +- [ ] **Step 2: Run tests to verify nothing is broken** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/apps/api && pnpm vitest run` + +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/src/routers/public.ts +git commit -m "feat: add public org icon serving endpoint with default fallback" +``` + +--- + +### Task 6: Dynamic Manifest in the Cloudflare Worker + +**Files:** +- Modify: `apps/main/worker/index.ts` +- Create: `apps/main/worker/index.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/main/worker/index.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { buildManifest, parseOrgIdFromCookie } from "./index"; + +describe("parseOrgIdFromCookie", () => { + it("returns null when no cookie header", () => { + expect(parseOrgIdFromCookie(null)).toBeNull(); + }); + + it("returns null when x-org-id cookie is missing", () => { + expect(parseOrgIdFromCookie("other=value; foo=bar")).toBeNull(); + }); + + it("parses x-org-id from cookie string", () => { + expect(parseOrgIdFromCookie("x-org-id=42; other=value")).toBe(42); + }); + + it("returns null for non-numeric x-org-id", () => { + expect(parseOrgIdFromCookie("x-org-id=abc")).toBeNull(); + }); +}); + +describe("buildManifest", () => { + it("returns default icons when orgId is null", () => { + const manifest = buildManifest(null); + expect(manifest.name).toBe("XTablo"); + expect(manifest.icons[0].src).toBe("/pwa-icons/pwa-192x192.png"); + }); + + it("returns org-specific icon URLs when orgId is provided", () => { + const manifest = buildManifest(42); + expect(manifest.name).toBe("XTablo"); + expect(manifest.icons[0].src).toBe("/api/v1/public/org-icons/42/icon-192.png"); + expect(manifest.icons[1].src).toBe("/api/v1/public/org-icons/42/icon-512.png"); + expect(manifest.icons[2].src).toBe("/api/v1/public/org-icons/42/icon-512-maskable.png"); + expect(manifest.icons[2].purpose).toBe("maskable"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/apps/main && pnpm vitest run worker/index.test.ts` + +Expected: FAIL — `parseOrgIdFromCookie` and `buildManifest` not exported. + +- [ ] **Step 3: Write the implementation** + +Replace `apps/main/worker/index.ts` with: + +```typescript +export function parseOrgIdFromCookie(cookieHeader: string | null): number | null { + if (!cookieHeader) return null; + const match = cookieHeader.match(/(?:^|;\s*)x-org-id=(\d+)/); + if (!match) return null; + const id = Number.parseInt(match[1], 10); + return Number.isNaN(id) ? null : id; +} + +interface ManifestIcon { + src: string; + sizes: string; + type: string; + purpose?: string; +} + +interface WebAppManifest { + name: string; + short_name: string; + description: string; + start_url: string; + display: string; + orientation: string; + theme_color: string; + background_color: string; + icons: ManifestIcon[]; +} + +export function buildManifest(orgId: number | null): WebAppManifest { + const base = { + name: "XTablo", + short_name: "XTablo", + description: "Collaborative project management for construction teams", + start_url: "/", + display: "standalone", + orientation: "any", + theme_color: "#1e1b2e", + background_color: "#1e1b2e", + }; + + if (orgId !== null) { + return { + ...base, + icons: [ + { src: `/api/v1/public/org-icons/${orgId}/icon-192.png`, sizes: "192x192", type: "image/png" }, + { src: `/api/v1/public/org-icons/${orgId}/icon-512.png`, sizes: "512x512", type: "image/png" }, + { src: `/api/v1/public/org-icons/${orgId}/icon-512-maskable.png`, sizes: "512x512", type: "image/png", purpose: "maskable" }, + ], + }; + } + + return { + ...base, + icons: [ + { src: "/pwa-icons/pwa-192x192.png", sizes: "192x192", type: "image/png" }, + { src: "/pwa-icons/pwa-512x512.png", sizes: "512x512", type: "image/png" }, + { src: "/pwa-icons/pwa-512x512-maskable.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, + ], + }; +} + +export default { + fetch(request: Request) { + const url = new URL(request.url); + + if (url.pathname === "/manifest.webmanifest") { + const cookieHeader = request.headers.get("cookie"); + const orgId = parseOrgIdFromCookie(cookieHeader); + const manifest = buildManifest(orgId); + + return new Response(JSON.stringify(manifest), { + headers: { + "Content-Type": "application/manifest+json", + "Cache-Control": "no-cache", + }, + }); + } + + if (url.pathname.startsWith("/api/")) { + return Response.json({ name: "Cloudflare" }); + } + + return new Response(null, { status: 404 }); + }, +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/apps/main && pnpm vitest run worker/index.test.ts` + +Expected: All 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/main/worker/index.ts apps/main/worker/index.test.ts +git commit -m "feat: dynamic manifest in Cloudflare Worker with cookie-based org identification" +``` + +--- + +### Task 7: Disable Static Manifest Generation in Vite + +**Files:** +- Modify: `apps/main/vite.config.ts` +- Modify: `apps/main/index.html` + +- [ ] **Step 1: Update vite.config.ts — set manifest to false** + +In `apps/main/vite.config.ts`, replace the `manifest` property in the VitePWA config (lines 29-55) with `manifest: false`: + +```typescript + plugins.push( + VitePWA({ + registerType: "autoUpdate", + injectRegister: false, + includeAssets: [ + "icon.jpg", + "logo_dark.png", + "logo_white.png", + ], + manifest: false, + workbox: { + globPatterns: ["**/*.{js,css,html,ico,png,jpg,svg,woff,woff2}"], + globIgnores: ["**/*.map"], + navigateFallback: "index.html", + maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, + }, + }) + ); +``` + +- [ ] **Step 2: Add manifest link to index.html** + +In `apps/main/index.html`, add the manifest link inside `` after the apple-touch-icon link (line 7): + +```html + +``` + +The full `` should now include: + +```html + + + + + + + + + + + XTablo + +``` + +- [ ] **Step 3: Verify the build still works** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm build:apps` + +Expected: Build succeeds. The dist directory should NOT contain a `manifest.webmanifest` (it's now served dynamically by the worker). + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/vite.config.ts apps/main/index.html +git commit -m "feat: disable static manifest generation, add manual manifest link for dynamic serving" +``` + +--- + +### Task 8: Frontend — Cookie Management & Logo Upload Hook + +**Files:** +- Modify: `apps/main/src/hooks/organization.ts` +- Modify: `apps/main/src/hooks/auth.ts` + +- [ ] **Step 1: Add cookie utility and org logo hooks to organization.ts** + +In `apps/main/src/hooks/organization.ts`, add the following after the existing imports and before `useOrganization`: + +```typescript +function setOrgIdCookie(orgId: number): void { + document.cookie = `x-org-id=${orgId}; path=/; secure; samesite=lax; max-age=31536000`; +} + +function clearOrgIdCookie(): void { + document.cookie = "x-org-id=; path=/; secure; samesite=lax; max-age=0"; +} + +export { clearOrgIdCookie }; +``` + +Then modify `useOrganization` to set the cookie as a side effect. Add `useEffect` import and update the hook: + +```typescript +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "@xtablo/shared"; +import { useEffect } from "react"; +import { useAuthedApi } from "./auth"; +``` + +And inside `useOrganization`, after the `useQuery` call, add the cookie side effect: + +```typescript +export const useOrganization = () => { + const api = useAuthedApi(); + + const query = useQuery({ + queryKey: ["organization"], + queryFn: async () => { + const { data } = await api.get("/api/v1/users/organization"); + return data; + }, + }); + + // Set org ID cookie for dynamic manifest + useEffect(() => { + if (query.data?.organization?.id) { + setOrgIdCookie(query.data.organization.id); + } + }, [query.data?.organization?.id]); + + return query; +}; +``` + +Add `logo_url` to `OrganizationSummary`: + +```typescript +export interface OrganizationSummary { + id: number; + name: string; + plan: string; + member_count: number; + tablo_count: number; + logo_url: string | null; +} +``` + +Add `useUploadOrgLogo` and `useRemoveOrgLogo` hooks at the end of the file: + +```typescript +export const useUploadOrgLogo = () => { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (file: File) => { + const base64Content = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result.split(",")[1]); + } else { + reject(new Error("Failed to read file")); + } + }; + reader.onerror = () => reject(new Error("Error reading file")); + reader.readAsDataURL(file); + }); + + const { data } = await api.patch("/api/v1/users/organization", { + logo: { content: base64Content, contentType: file.type }, + }); + return data; + }, + onSuccess: () => { + toast.add({ + title: "Logo mis à jour", + description: "Le logo de l'organisation a bien été enregistré", + type: "success", + }); + queryClient.invalidateQueries({ queryKey: ["organization"] }); + }, + onError: (error: Error) => { + toast.add({ + title: "Erreur", + description: error.message || "Impossible de mettre à jour le logo", + type: "error", + }); + }, + }); +}; + +export const useRemoveOrgLogo = () => { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const { data } = await api.patch("/api/v1/users/organization", { logo: null }); + return data; + }, + onSuccess: () => { + toast.add({ + title: "Logo supprimé", + description: "Le logo de l'organisation a été supprimé", + type: "success", + }); + queryClient.invalidateQueries({ queryKey: ["organization"] }); + }, + onError: (error: Error) => { + toast.add({ + title: "Erreur", + description: error.message || "Impossible de supprimer le logo", + type: "error", + }); + }, + }); +}; +``` + +- [ ] **Step 2: Clear cookie on logout** + +In `apps/main/src/hooks/auth.ts`, import `clearOrgIdCookie` and call it in the logout mutation: + +Add import: +```typescript +import { clearOrgIdCookie } from "./organization"; +``` + +Modify `useLogout` to clear the cookie: + +```typescript +export function useLogout() { + return useMutation({ + mutationFn: async () => { + const { error } = await supabase.auth.signOut(); + if (error) throw error; + clearOrgIdCookie(); + queryClient.removeQueries(); + }, + onSuccess: () => { + toast.add({ + title: "Déconnexion réussie", + description: "Vous avez été déconnecté avec succès", + type: "success", + }); + }, + onError: (error) => { + toast.add({ + title: "Erreur", + description: error.message, + type: "error", + }); + }, + }); +} +``` + +- [ ] **Step 3: Verify the build** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm typecheck` + +Expected: No type errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/src/hooks/organization.ts apps/main/src/hooks/auth.ts +git commit -m "feat: add org ID cookie management and logo upload/remove hooks" +``` + +--- + +### Task 9: Update getOrganization API to Return logo_url + +**Files:** +- Modify: `apps/api/src/routers/user.ts:280-437` (the `getOrganization` handler) + +- [ ] **Step 1: Update the organization select query** + +In `apps/api/src/routers/user.ts`, in the `getOrganization` handler, change the organization select (line 297) from: + +```typescript + .select("id, name") +``` + +to: + +```typescript + .select("id, name, logo_url") +``` + +- [ ] **Step 2: Update the response to include logo_url** + +In the same handler, change the organization part of the response (around line 419) from: + +```typescript + organization: { + id: organization.id, + name: organization.name, + plan, + member_count: members?.length || 0, + tablo_count: tabloCount || 0, + }, +``` + +to: + +```typescript + organization: { + id: organization.id, + name: organization.name, + logo_url: organization.logo_url ?? null, + plan, + member_count: members?.length || 0, + tablo_count: tabloCount || 0, + }, +``` + +- [ ] **Step 3: Verify** + +Run: `cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source && pnpm typecheck` + +Expected: No type errors (the column exists in the DB types after migration, and the frontend type already includes `logo_url` from Task 8). + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/src/routers/user.ts +git commit -m "feat: include logo_url in organization API response" +``` + +--- + +### Task 10: Settings Page — Logo Upload UI + +**Files:** +- Modify: `apps/main/src/pages/settings.tsx` + +- [ ] **Step 1: Add logo upload UI to the organization card** + +In `apps/main/src/pages/settings.tsx`: + +Add imports at the top: + +```typescript +import { + useInviteOrganizationUser, + useOrganization, + useRemoveOrganizationMember, + useUpdateOrganization, + useUploadOrgLogo, + useRemoveOrgLogo, +} from "../hooks/organization"; +``` + +Add state and hooks inside `SettingsPage()`: + +```typescript + const { mutate: uploadOrgLogo, isPending: uploadOrgLogoPending } = useUploadOrgLogo(); + const { mutate: removeOrgLogo, isPending: removeOrgLogoPending } = useRemoveOrgLogo(); + const orgLogoInputRef = useRef(null); +``` + +Add the logo upload handler: + +```typescript + const handleOrgLogoChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.type.startsWith("image/")) { + toast.add({ + title: t("settings:toasts.error"), + description: t("settings:toasts.invalidImage"), + type: "error", + position: "top-center", + }); + return; + } + + // Validate minimum size client-side + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(img.src); + if (img.width < 512 || img.height < 512) { + toast.add({ + title: t("settings:toasts.error"), + description: "L'image doit faire au moins 512x512 pixels", + type: "error", + position: "top-center", + }); + return; + } + uploadOrgLogo(file); + }; + img.src = URL.createObjectURL(file); + + // Reset input to allow selecting same file again + if (orgLogoInputRef.current) { + orgLogoInputRef.current.value = ""; + } + }; +``` + +Then insert the logo section inside the Organization ``, right before the organization name field (before the `
` containing the `