From 919ebfe755db2a0f5c62ffeb89c89deaeb7b0db1 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 11 May 2026 11:37:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(release):=20v1.11.0=20=E2=80=94=20marque?= =?UTF-8?q?=20blanche=20pour=20le=20plan=20Business=20(backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Première moitié de la feature marque blanche : la machinerie complète qui permet à un compte Business d'envoyer ses emails de relance avec son propre logo, ses propres couleurs et son nom comme expéditeur, à la place du branding Rubis. Architecture : - Nouvelle colonne JSONB `organizations.brand_settings` (12 tokens customisables : logo, senderName, et 10 couleurs — primary, banner, body bg, card bg, text, text muted, border, link, button text). Null = palette Rubis intacte. Validation hex stricte (#RRGGBB). - Service `#services/brand` avec `resolveBrandTokens(org)` qui merge defaults + overrides en respectant le plan (couleurs/logo = Business only ; senderName = cascade pour tous les plans). Mergeurs avec sémantique "null = reset au default sur ce champ précis" pour les patches partiels. - Service mutualisé `#services/media_storage` qui remplace l'ancien `blog_uploads.ts`. Scopes `blog` (4 MB, jpg/png/webp) et `brand-logo` (1 MB, + svg accepté). Cleanup automatique du logo précédent lors d'un remplacement (pas de versioning — la conv produit est "on écrase"). - Controller `BrandController` (5 endpoints) + middleware `AssertBusinessPlanMiddleware` qui throw 403 `business_plan_required` (code matché par le SPA pour l'upsell card). - Refactor des 3 templates mail (relance, payment thanks, checkin) + layout commun pour accepter `tokens: BrandTokens` en prop. Le dispatcher résout les tokens per-org pour relance + remerciement (= user → client, branded), et passe `DEFAULT_BRAND` au checkin (= Rubis → user, toujours Rubis-branded). - Routes publiques pour le logo : `/api/v1/uploads/brand-logos/:filename` (sans auth, cache immutable, X-Content-Type-Options: nosniff pour les SVG). UI self-service arrive dans la prochaine version (v1.12.0). En attendant, un compte Business peut être configuré via Bruno / API directe. Co-Authored-By: Claude Opus 4.7 --- .../controllers/blog_uploads_controller.ts | 18 +- apps/api/app/controllers/brand_controller.ts | 250 +++++++++++++++++ apps/api/app/mails/_brand.ts | 40 +-- apps/api/app/mails/_layout.tsx | 255 +++++++++-------- apps/api/app/mails/checkin_email.tsx | 260 +++++++++--------- apps/api/app/mails/payment_thanks_email.tsx | 143 +++++----- apps/api/app/mails/relance_email.tsx | 114 ++++---- .../assert_business_plan_middleware.ts | 51 ++++ apps/api/app/models/organization.ts | 13 +- apps/api/app/services/blog_uploads.ts | 112 -------- apps/api/app/services/brand.ts | Bin 0 -> 8672 bytes apps/api/app/services/mail_dispatcher.ts | 39 +-- apps/api/app/services/media_storage.ts | 174 ++++++++++++ apps/api/commands/send_test_email.ts | 4 +- ...d_brand_settings_to_organizations_table.ts | 56 ++++ apps/api/database/schema_rules.ts | 11 + apps/api/start/kernel.ts | 1 + apps/api/start/routes.ts | 43 ++- apps/landing/src/content/changelog/1.11.0.md | 18 ++ apps/web/src/version.ts | 2 +- 20 files changed, 1046 insertions(+), 558 deletions(-) create mode 100644 apps/api/app/controllers/brand_controller.ts create mode 100644 apps/api/app/middleware/assert_business_plan_middleware.ts delete mode 100644 apps/api/app/services/blog_uploads.ts create mode 100644 apps/api/app/services/brand.ts create mode 100644 apps/api/app/services/media_storage.ts create mode 100644 apps/api/database/migrations/1778400000000_add_brand_settings_to_organizations_table.ts create mode 100644 apps/landing/src/content/changelog/1.11.0.md diff --git a/apps/api/app/controllers/blog_uploads_controller.ts b/apps/api/app/controllers/blog_uploads_controller.ts index 7f41841..32f90ff 100644 --- a/apps/api/app/controllers/blog_uploads_controller.ts +++ b/apps/api/app/controllers/blog_uploads_controller.ts @@ -1,14 +1,18 @@ import type { HttpContext } from '@adonisjs/core/http' -import { uploadBlogImage, readBlogImage } from '#services/blog_uploads' +import { uploadMedia, readMedia } from '#services/media_storage' /** * BlogUploadsController — gère les images du blog. * - * - POST /api/v1/admin/uploads (auth + admin) - * multipart 'file' → upload MinIO → renvoie { url, contentType, sizeBytes } - * - GET /api/v1/uploads/blog/:filename (public) - * stream depuis MinIO avec Cache-Control immutable. + * Délègue tout au service mutualisé `#services/media_storage` (scope 'blog'). + * Le même service sert aussi pour les logos de marque blanche (scope + * 'brand-logo') via `BrandController`. + * + * - POST /api/v1/admin/uploads (auth + admin) + * multipart 'file' → upload MinIO → { url, contentType, sizeBytes } + * - GET /api/v1/uploads/blog/:filename (public) + * stream depuis MinIO avec Cache-Control immutable. */ export default class BlogUploadsController { /** @@ -32,7 +36,7 @@ export default class BlogUploadsController { } try { - const result = await uploadBlogImage(file) + const result = await uploadMedia(file, 'blog') return response.status(201).json({ data: { url: result.publicPath, @@ -51,7 +55,7 @@ export default class BlogUploadsController { */ async show({ params, response }: HttpContext) { const filename = String(params.filename ?? '') - const result = await readBlogImage(filename) + const result = await readMedia('blog', filename) if (!result) { return response.status(404).send('not_found') } diff --git a/apps/api/app/controllers/brand_controller.ts b/apps/api/app/controllers/brand_controller.ts new file mode 100644 index 0000000..4590db7 --- /dev/null +++ b/apps/api/app/controllers/brand_controller.ts @@ -0,0 +1,250 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { Exception } from '@adonisjs/core/exceptions' +import logger from '@adonisjs/core/services/logger' + +import Organization from '#models/organization' +import { + type BrandSettings, + DEFAULT_BRAND, + resolveBrandTokens, + mergeBrandSettings, + validateBrandSettings, +} from '#services/brand' +import { uploadMedia, deleteMedia, readMedia } from '#services/media_storage' + +function requireOrgId(auth: HttpContext['auth']): string { + const user = auth.getUserOrFail() + if (!user.organizationId) { + throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' }) + } + return user.organizationId +} + +/** + * BrandController — gestion de la marque blanche (plan Business). + * + * Routes (toutes en `/api/v1`, auth + assertBusinessPlan en middleware) : + * - GET /brand → settings courants + tokens résolus (preview SPA) + * - PATCH /brand → maj partielle des settings + * - POST /brand/logo → upload multipart (remplace l'ancien si présent) + * - DELETE /brand/logo → supprime le logo (retour au wordmark Rubis) + * - POST /brand/test → envoie un email de test à l'user pour preview réelle + * + * Le PATCH supporte la sémantique "null = reset au default" : envoyer + * `{ primaryColor: null }` retire l'override sur ce champ précis (le mail + * repart en rubis #9F1239). Envoyer `{ primaryColor: undefined }` ou ne pas + * inclure la clé du tout = laisse intact. + */ +export default class BrandController { + /** + * GET /api/v1/uploads/brand-logos/:filename — public, cache long. + * + * Servi sans auth parce que les emails sortants vers les clients de + * l'org doivent pouvoir charger l'image (`` côté + * Gmail/Outlook). UUID dans le filename → globalement unique, pas de + * fuite d'info utile à un attaquant qui devinerait l'URL. + * + * Cette méthode est routée par le GROUPE PUBLIC du fichier routes.ts, + * pas par le groupe auth+business — c'est le seul point d'entrée du + * controller qui ne demande pas d'auth. + */ + async showLogo({ params, response }: HttpContext) { + const filename = String(params.filename ?? '') + const result = await readMedia('brand-logo', filename) + if (!result) { + return response.status(404).send('not_found') + } + response.header('Content-Type', result.contentType) + // Filename = uuid → contenu immuable. Cache infini. + response.header('Cache-Control', 'public, max-age=31536000, immutable') + // Pour les SVG (potentiellement scriptables), force le browser à NE PAS + // re-deviner le type et bloque l'exécution de scripts inline. + response.header('X-Content-Type-Options', 'nosniff') + return response.send(result.buffer) + } + + /** + * GET /api/v1/brand — auth + Business. + * Retourne le raw settings + les tokens résolus prêts à preview côté SPA. + */ + async show({ auth, response }: HttpContext) { + const orgId = requireOrgId(auth) + const org = await Organization.findOrFail(orgId) + const settings = (org.brandSettings ?? null) as BrandSettings | null + return response.json({ + data: { + settings: settings ?? {}, + tokens: resolveBrandTokens(org), + defaults: DEFAULT_BRAND, + }, + }) + } + + /** + * PATCH /api/v1/brand — auth + Business. + * Body : partial BrandSettings. Champs absents = laissés tels quels. + * Champ à `null` explicite = reset au default Rubis. + */ + async update({ auth, request, response }: HttpContext) { + const orgId = requireOrgId(auth) + const org = await Organization.findOrFail(orgId) + + const body = request.body() as Partial + // logoPath et logoUrl ne sont JAMAIS modifiables via PATCH — ils passent + // par les endpoints /brand/logo dédiés (upload multipart + cleanup). + delete body.logoPath + delete body.logoUrl + + const err = validateBrandSettings(body) + if (err) { + return response.status(422).json({ errors: [{ message: err }] }) + } + + const current = (org.brandSettings ?? null) as BrandSettings | null + const merged = mergeBrandSettings(current, body) + org.brandSettings = Object.keys(merged).length === 0 ? null : merged + await org.save() + + return response.json({ + data: { + settings: org.brandSettings ?? {}, + tokens: resolveBrandTokens(org), + }, + }) + } + + /** + * POST /api/v1/brand/logo — auth + Business. + * Multipart 'file' → upload MinIO via media_storage scope 'brand-logo'. + * Remplace l'ancien logo s'il existe (delete MinIO + écrase les champs). + */ + async uploadLogo({ auth, request, response }: HttpContext) { + const orgId = requireOrgId(auth) + const org = await Organization.findOrFail(orgId) + + const file = request.file('file', { + size: '1mb', + extnames: ['jpg', 'jpeg', 'png', 'webp', 'svg'], + }) + if (!file) { + return response.status(422).json({ + errors: [{ field: 'file', message: 'Fichier manquant (champ multipart `file`).' }], + }) + } + if (!file.isValid) { + return response.status(422).json({ + errors: file.errors.map((e) => ({ field: 'file', message: e.message })), + }) + } + + try { + const result = await uploadMedia(file, 'brand-logo') + + // Cleanup de l'ancien logo s'il existe — pas de versioning, + // on écrase pour éviter d'accumuler des orphelins sur MinIO. + const current = (org.brandSettings ?? null) as BrandSettings | null + if (current?.logoPath) { + await deleteMedia(current.logoPath) + } + + const next: BrandSettings = { + ...(current ?? {}), + logoPath: result.storageKey, + logoUrl: result.publicPath, + } + org.brandSettings = next + await org.save() + + return response.status(201).json({ + data: { + settings: org.brandSettings, + tokens: resolveBrandTokens(org), + }, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'upload_failed' + logger.warn({ err, orgId }, 'brand_logo_upload_failed') + return response.status(422).json({ errors: [{ field: 'file', message: msg }] }) + } + } + + /** + * DELETE /api/v1/brand/logo — auth + Business. + * Retire le logo (retour au wordmark Rubis dans les emails). + */ + async deleteLogo({ auth, response }: HttpContext) { + const orgId = requireOrgId(auth) + const org = await Organization.findOrFail(orgId) + + const current = (org.brandSettings ?? null) as BrandSettings | null + if (current?.logoPath) { + await deleteMedia(current.logoPath) + } + + const next: BrandSettings = { ...(current ?? {}) } + delete next.logoPath + delete next.logoUrl + org.brandSettings = Object.keys(next).length === 0 ? null : next + await org.save() + + return response.json({ + data: { + settings: org.brandSettings ?? {}, + tokens: resolveBrandTokens(org), + }, + }) + } + + /** + * POST /api/v1/brand/test — auth + Business. + * Envoie un email de test (relance fictive avec données mock) à l'user + * connecté pour qu'il voie le rendu réel des tokens courants dans + * son Gmail/Outlook. Pas envoyé à un client réel — toujours à `user.email`. + * + * Stocke le résultat dans `activity_events` ? Non — c'est un test + * volontaire, on ne pollue pas la timeline produit. + */ + async sendTest({ auth, response }: HttpContext) { + const orgId = requireOrgId(auth) + const user = auth.getUserOrFail() + const org = await Organization.findOrFail(orgId) + + const tokens = resolveBrandTokens(org) + + // Import lazy pour éviter le coût React Email au boot. + const { render } = await import('@react-email/components') + const { RelanceEmail } = await import('#mails/relance_email') + const mail = (await import('@adonisjs/mail/services/main')).default + + // Données mock — facture fictive, juste pour montrer le rendu réel des + // tokens (couleurs, logo, nom expéditeur) dans Gmail/Outlook. + const html = await render( + RelanceEmail({ + tokens, + invoice: { + numero: 'F-2026-0042', + amountFormatted: '1 240,00 €', + dueDateFormatted: '12 avril 2026', + daysLate: 8, + }, + bodyText: `Bonjour Christophe,\n\nUn petit rappel concernant la facture F-2026-0042 d'un montant de 1 240,00 €, échue depuis 8 jours.\n\nMerci pour votre attention,\n\n${tokens.senderName}`, + landingUrl: 'https://rubis.pro', + }) + ) + + const fromAddress = process.env.MAIL_FROM_ADDRESS || 'relances@rubis.pro' + try { + await mail.send((m) => { + m.from(fromAddress, `${tokens.senderName} (test)`) + .to(user.email) + .subject('[Test marque blanche] Aperçu de relance') + .html(html) + }) + return response.json({ data: { ok: true, sentTo: user.email } }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'send_failed' + logger.error({ err, orgId, userEmail: user.email }, 'brand_test_email_failed') + return response.status(500).json({ errors: [{ message: msg }] }) + } + } +} diff --git a/apps/api/app/mails/_brand.ts b/apps/api/app/mails/_brand.ts index 8f9c9fd..6243f85 100644 --- a/apps/api/app/mails/_brand.ts +++ b/apps/api/app/mails/_brand.ts @@ -1,35 +1,19 @@ /** - * Tokens de marque partagés par tous les templates email. + * Spacing tokens partagés par les templates email. * - * On reste sur des hex en dur (pas de CSS vars) parce que les clients mail - * (Gmail, Outlook) ne supportent pas les `--var` dans les `style="..."`. - * Tous les styles sont inline pour la même raison. + * Les TOKENS DE COULEUR sont désormais résolus dynamiquement par org via + * `#services/brand` (résolution dépendante du plan Business + des overrides + * `brand_settings` JSONB). Ce fichier ne contient plus que les espacements, + * qui ne sont pas customisables côté user (déterministe pour la délivrabilité + * mail — Outlook supporte mal les recalculs dynamiques de padding). + * + * Pour les couleurs / logo / nom expéditeur : + * import { resolveBrandTokens } from '#services/brand' + * const tokens = resolveBrandTokens(organization) + * // tokens.primary, tokens.bodyBg, tokens.text, tokens.logoUrl, ... */ -export const BRAND = { - // Palette Rubis (cf. CLAUDE.md → marque) - rubis: '#9F1239', - rubisDeep: '#771328', - rubisLight: '#C9415C', - rubisGlow: '#FBE4EA', - cream: '#FAF7F2', - cream2: '#F5EFE7', - ink: '#1A1410', - ink2: '#4F4640', - ink3: '#8A7F76', - line: '#E8E0D6', - white: '#FFFFFF', - - // Typo — on s'appuie sur les fallbacks system-ui pour la portabilité mail. - fontBody: - "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif", - - // Radius cohérents avec l'app - radiusButton: '6px', - radiusCard: '14px', -} as const - -/** Wrappers d'unités inline-friendly pour pas faire de gros calculs côté