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é