feat(release): v1.11.0 — marque blanche pour le plan Business (backend)
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 36s
Build & Deploy API / build-and-deploy (push) Successful in 1m36s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m18s

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 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-11 11:37:07 +02:00
parent 7a112d3329
commit 919ebfe755
20 changed files with 1046 additions and 558 deletions

View File

@ -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')
}

View File

@ -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 (`<img src="..." />` 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<BrandSettings>
// 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 }] })
}
}
}

View File

@ -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é <style>. */
/** Wrappers d'unités inline-friendly pour pas faire de calculs côté <style>. */
export const sp = {
xs: '4px',
sm: '8px',

View File

@ -1,11 +1,19 @@
/**
* Squelette commun aux 2 templates Rubis : header rubis-deep avec brand
* + container cream + footer "Émis via Rubis sur l'ongle" (cliquable
* vers la landing publique).
* Squelette commun à tous les templates Rubis : bandeau header avec
* logo/brand + container + footer "Émis via Rubis sur l'ongle" (masqué
* pour les orgs sur plan Business avec marque blanche activée).
*
* Approche : on stylé en inline via les `style` props que React Email
* convertit en HTML inline (compatible tous mail clients y compris
* Outlook). Pas de classes Tailwind ici risquées en mail.
* Tokens visuels passés en prop `tokens` (cf. `#services/brand`). Tous les
* styles sont déclarés à l'intérieur de la fonction pour fermer sur la
* valeur runtime des tokens un même `<EmailLayout/>` rend en couleurs
* Rubis ou en couleurs Business selon ce qu'on lui passe.
*
* Approche : styles inline (les `style` props que React Email convertit en
* HTML inline). Compatible Gmail, Outlook, Apple Mail. Pas de Tailwind ici.
*
* Dark mode : forcé en light only (Outlook.com et Gmail mobile auto-
* invertissent agressivement, ce qui casse les fonds rubis-deep). Les
* sélecteurs `[data-ogsc]` -imposent nos couleurs pour ces clients.
*/
import * as React from 'react'
@ -20,37 +28,108 @@ import {
Column,
Text,
Link,
Img,
} from '@react-email/components'
import { BRAND, sp } from './_brand.js'
import type { BrandTokens } from '#services/brand'
import { sp } from './_brand.js'
type LayoutProps = {
/** Tokens résolus pour l'org (cf. resolveBrandTokens). */
tokens: BrandTokens
/** Aperçu dans la liste mail (Gmail preview text). */
preview: string
/** Nom commercial affiché dans le header (vendeur ou "Rubis"). */
brandName: string
/** Sous-titre header (ex: numéro de facture, date). Optionnel. */
brandSubtitle?: string | null
/** URL de la landing publique — lien dans le footer ("Rubis sur l'ongle"). */
landingUrl?: string
/** URL de la landing publique — footer "Rubis sur l'ongle". null = masque le footer (marque blanche). */
landingUrl?: string | null
/** Masque le footer Rubis (pour plan Business qui ne veut pas du tout du wordmark). Default false. */
hideRubisFooter?: boolean
children: React.ReactNode
}
export function EmailLayout({
tokens,
preview,
brandName,
brandSubtitle,
landingUrl,
hideRubisFooter = false,
children,
}: LayoutProps) {
const bodyStyle: React.CSSProperties = {
backgroundColor: tokens.bodyBg,
fontFamily: tokens.fontBody,
margin: 0,
padding: 0,
color: tokens.text,
}
const containerStyle: React.CSSProperties = {
backgroundColor: tokens.cardBg,
margin: '0 auto',
maxWidth: '560px',
overflow: 'hidden',
}
const headerStyle: React.CSSProperties = {
backgroundColor: tokens.banner,
padding: `${sp.xl} ${sp.xl}`,
}
const headerBrandStyle: React.CSSProperties = {
color: tokens.white,
fontSize: '20px',
fontWeight: 800,
letterSpacing: '-0.01em',
margin: 0,
lineHeight: '1.1',
}
const gemStyle: React.CSSProperties = {
display: 'inline-block',
marginRight: sp.sm,
color: tokens.primaryGlow,
fontSize: '18px',
verticalAlign: '-1px',
}
const headerSubtitleStyle: React.CSSProperties = {
color: tokens.primaryGlow,
fontSize: '12px',
margin: `${sp.xs} 0 0 0`,
letterSpacing: '0.04em',
textTransform: 'uppercase',
fontWeight: 600,
}
const contentStyle: React.CSSProperties = {
padding: `${sp.xl} ${sp.xl}`,
}
const footerStyle: React.CSSProperties = {
borderTop: `1px solid ${tokens.border}`,
backgroundColor: tokens.bodyBg,
padding: `${sp.lg} ${sp.xl}`,
}
const footerTextStyle: React.CSSProperties = {
color: tokens.textVeryMuted,
fontSize: '11px',
lineHeight: '1.5',
margin: 0,
textAlign: 'center',
}
const footerLinkStyle: React.CSSProperties = {
color: tokens.primary,
fontWeight: 600,
textDecoration: 'none',
}
return (
<Html lang="fr">
<Head>
{/*
* Force le mode clair sur les clients qui auto-invertissent en dark mode
* (iOS Mail, Gmail mobile, Outlook.com web). Sans ces tags, les fonds
* crème/blanc deviennent noirs et le header rubis-deep devient rose pâle.
*/}
{/* Force light mode sur les clients qui auto-invertissent. */}
<meta name="color-scheme" content="light only" />
<meta name="supported-color-schemes" content="light only" />
<style
@ -64,19 +143,19 @@ export function EmailLayout({
/* Outlook.com / Hotmail dark mode — force nos couleurs */
[data-ogsc] body,
[data-ogsb] body {
background-color: ${BRAND.cream} !important;
background-color: ${tokens.bodyBg} !important;
}
[data-ogsc] .rubis-container,
[data-ogsb] .rubis-container {
background-color: ${BRAND.cream} !important;
background-color: ${tokens.cardBg} !important;
}
[data-ogsc] .rubis-header,
[data-ogsb] .rubis-header {
background-color: ${BRAND.rubisDeep} !important;
background-color: ${tokens.banner} !important;
}
[data-ogsc] .rubis-footer,
[data-ogsb] .rubis-footer {
background-color: ${BRAND.cream2} !important;
background-color: ${tokens.bodyBg} !important;
}
`,
}}
@ -85,14 +164,23 @@ export function EmailLayout({
<Preview>{preview}</Preview>
<Body style={bodyStyle} className="rubis-body">
<Container style={containerStyle} className="rubis-container">
{/* Bandeau rubis-deep avec gem ◆ + brand */}
{/* Bandeau header — logo image si configuré, sinon wordmark texte. */}
<Section style={headerStyle} className="rubis-header">
<Row>
<Column style={{ verticalAlign: 'middle' }}>
<Text style={headerBrandStyle}>
<span style={gemStyle}></span>
{brandName}
</Text>
{tokens.logoUrl ? (
<Img
src={tokens.logoUrl}
alt={tokens.senderName}
height="32"
style={{ display: 'block', maxHeight: '32px', width: 'auto' }}
/>
) : (
<Text style={headerBrandStyle}>
<span style={gemStyle}></span>
{tokens.senderName}
</Text>
)}
{brandSubtitle ? (
<Text style={headerSubtitleStyle}>{brandSubtitle}</Text>
) : null}
@ -103,102 +191,29 @@ export function EmailLayout({
{/* Corps de l'email */}
<Section style={contentStyle}>{children}</Section>
{/* Footer Rubis — "Rubis sur l'ongle" cliquable vers la landing */}
<Section style={footerStyle} className="rubis-footer">
<Text style={footerTextStyle}>
Émis via{' '}
{landingUrl ? (
<Link href={landingUrl} style={footerLinkStyle}>
Rubis sur l&apos;ongle
</Link>
) : (
<strong style={{ color: BRAND.ink2 }}>Rubis sur l&apos;ongle</strong>
)}
{' — '}
<span style={{ color: BRAND.ink3 }}>
vos factures relancées toutes seules pendant que vous travaillez.
</span>
</Text>
</Section>
{/* Footer Rubis — masqué en marque blanche complète. */}
{!hideRubisFooter && (
<Section style={footerStyle} className="rubis-footer">
<Text style={footerTextStyle}>
Émis via{' '}
{landingUrl ? (
<Link href={landingUrl} style={footerLinkStyle}>
Rubis sur l&apos;ongle
</Link>
) : (
<strong style={{ color: tokens.textMuted }}>
Rubis sur l&apos;ongle
</strong>
)}
{' — '}
<span style={{ color: tokens.textVeryMuted }}>
vos factures relancées toutes seules pendant que vous travaillez.
</span>
</Text>
</Section>
)}
</Container>
</Body>
</Html>
)
}
// ---------------------------------------------------------------------------
// Styles inline
// ---------------------------------------------------------------------------
const bodyStyle: React.CSSProperties = {
backgroundColor: BRAND.cream,
fontFamily: BRAND.fontBody,
margin: 0,
padding: 0,
color: BRAND.ink,
}
const containerStyle: React.CSSProperties = {
// Fond crème pour que toute la zone du mail soit dans la palette
// (au lieu d'un container blanc flottant sur cream). Le header
// rubis-deep et le footer cream2 gardent leur couleur dédiée.
backgroundColor: BRAND.cream,
margin: '0 auto',
maxWidth: '560px',
overflow: 'hidden',
}
const headerStyle: React.CSSProperties = {
backgroundColor: BRAND.rubisDeep,
padding: `${sp.xl} ${sp.xl}`,
}
const headerBrandStyle: React.CSSProperties = {
color: BRAND.white,
fontSize: '20px',
fontWeight: 800,
letterSpacing: '-0.01em',
margin: 0,
lineHeight: '1.1',
}
const gemStyle: React.CSSProperties = {
display: 'inline-block',
marginRight: sp.sm,
color: BRAND.rubisGlow,
fontSize: '18px',
verticalAlign: '-1px',
}
const headerSubtitleStyle: React.CSSProperties = {
color: BRAND.cream2,
fontSize: '12px',
margin: `${sp.xs} 0 0 0`,
letterSpacing: '0.04em',
textTransform: 'uppercase',
fontWeight: 600,
}
const contentStyle: React.CSSProperties = {
padding: `${sp.xl} ${sp.xl}`,
}
const footerStyle: React.CSSProperties = {
borderTop: `1px solid ${BRAND.line}`,
backgroundColor: BRAND.cream2,
padding: `${sp.lg} ${sp.xl}`,
}
const footerTextStyle: React.CSSProperties = {
color: BRAND.ink3,
fontSize: '11px',
lineHeight: '1.5',
margin: 0,
textAlign: 'center',
}
const footerLinkStyle: React.CSSProperties = {
color: BRAND.rubis,
fontWeight: 600,
textDecoration: 'none',
}

View File

@ -1,25 +1,24 @@
/**
* Template email check-in envoyé à L'UTILISATEUR (le patron de TPE)
* Template email check-in envoyé À L'UTILISATEUR (le patron de TPE)
* pour lui demander si une facture donnée a é payée AVANT que la 1re
* relance ne parte chez son client.
*
* Structure :
* - Header rubis-deep "Rubis · Confirmation requise"
* - Eyebrow + question Avez-vous é payé ?
* - Card facture (numéro + montant + client + échéance)
* - 2 gros boutons CTA :
* "✓ Oui — la facture est payée" /paid (status=paid + cancel relances)
* "→ Non — toujours impayée, lance les relances" /pending
* - Mention TTL 24h
* Cet email reste **toujours en branding Rubis**, indépendamment du plan
* de l'org — c'est une communication Rubis user (méta-produit), pas
* user client. Le dispatcher passe donc `DEFAULT_BRAND` ici, jamais
* les tokens custom.
*/
import * as React from 'react'
import { Section, Text, Button, Heading, Hr } from '@react-email/components'
import { BRAND, sp } from './_brand.js'
import type { BrandTokens } from '#services/brand'
import { sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type CheckinEmailProps = {
/** Toujours DEFAULT_BRAND — checkin reste Rubis-branded. */
tokens: BrandTokens
invoice: {
numero: string
amountFormatted: string
@ -29,11 +28,11 @@ export type CheckinEmailProps = {
user: { fullName: string | null }
paidUrl: string
pendingUrl: string
/** URL landing publique (footer cliquable "Rubis sur l'ongle"). */
landingUrl?: string
landingUrl?: string | null
}
export function CheckinEmail({
tokens,
invoice,
client,
user,
@ -43,10 +42,120 @@ export function CheckinEmail({
}: CheckinEmailProps) {
const greeting = user.fullName ? `Bonjour ${user.fullName.split(' ')[0]},` : 'Bonjour,'
const greetingStyle: React.CSSProperties = {
fontSize: '14px',
color: tokens.textMuted,
margin: `0 0 ${sp.md} 0`,
}
const titleStyle: React.CSSProperties = {
color: tokens.text,
fontSize: '24px',
fontWeight: 700,
letterSpacing: '-0.018em',
lineHeight: '1.2',
margin: `0 0 ${sp.md} 0`,
}
const emStyle: React.CSSProperties = {
color: tokens.primary,
fontStyle: 'normal',
}
const leadStyle: React.CSSProperties = {
color: tokens.textMuted,
fontSize: '14px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
}
const invoiceCardStyle: React.CSSProperties = {
backgroundColor: tokens.white,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radiusCard,
padding: `${sp.lg} ${sp.xl}`,
margin: `0 0 ${sp.xl} 0`,
}
const invoiceNumeroStyle: React.CSSProperties = {
fontSize: '13px',
fontWeight: 600,
color: tokens.textMuted,
letterSpacing: '0.02em',
margin: 0,
}
const invoiceClientStyle: React.CSSProperties = {
fontSize: '13px',
color: tokens.textVeryMuted,
margin: `${sp.xs} 0 ${sp.md} 0`,
}
const invoiceAmountStyle: React.CSSProperties = {
fontSize: '28px',
fontWeight: 800,
letterSpacing: '-0.02em',
color: tokens.text,
margin: 0,
lineHeight: '1',
fontVariantNumeric: 'tabular-nums',
}
const invoiceDueStyle: React.CSSProperties = {
fontSize: '12px',
color: tokens.textVeryMuted,
margin: `${sp.sm} 0 0 0`,
}
const primaryButtonStyle: React.CSSProperties = {
backgroundColor: tokens.primary,
color: tokens.buttonText,
borderRadius: tokens.radiusButton,
display: 'block',
textAlign: 'center',
textDecoration: 'none',
padding: `${sp.md} ${sp.xl}`,
fontSize: '15px',
fontWeight: 600,
width: '100%',
boxSizing: 'border-box',
boxShadow: '0 4px 12px rgba(159, 18, 57, 0.25)',
}
const secondaryButtonStyle: React.CSSProperties = {
backgroundColor: tokens.white,
color: tokens.text,
border: `1px solid ${tokens.text}`,
borderRadius: tokens.radiusButton,
display: 'block',
textAlign: 'center',
textDecoration: 'none',
padding: `${sp.md} ${sp.xl}`,
fontSize: '15px',
fontWeight: 600,
width: '100%',
boxSizing: 'border-box',
}
const hrStyle: React.CSSProperties = {
borderColor: tokens.border,
borderStyle: 'solid',
borderWidth: '0 0 1px 0',
margin: `${sp.xl} 0 ${sp.lg} 0`,
}
const footnoteStyle: React.CSSProperties = {
fontSize: '11.5px',
color: tokens.textVeryMuted,
lineHeight: '1.5',
margin: 0,
fontStyle: 'italic',
}
return (
<EmailLayout
tokens={tokens}
preview={`${invoice.numero} (${invoice.amountFormatted}) — payée par ${client.name} ?`}
brandName="Rubis"
brandSubtitle="Confirmation requise"
landingUrl={landingUrl}
>
@ -61,7 +170,6 @@ export function CheckinEmail({
réglée, on évite l&apos;email inutile et on encaisse +1 rubis.
</Text>
{/* Card facture */}
<Section style={invoiceCardStyle}>
<Text style={invoiceNumeroStyle}>{invoice.numero}</Text>
<Text style={invoiceClientStyle}>{client.name}</Text>
@ -71,8 +179,7 @@ export function CheckinEmail({
</Text>
</Section>
{/* CTA boutons */}
<Section style={ctaSectionStyle}>
<Section style={{ marginTop: 0 }}>
<Button href={paidUrl} style={primaryButtonStyle}>
Oui, la facture est payée
</Button>
@ -92,124 +199,3 @@ export function CheckinEmail({
</EmailLayout>
)
}
// ---------------------------------------------------------------------------
// Styles inline
// ---------------------------------------------------------------------------
const greetingStyle: React.CSSProperties = {
fontSize: '14px',
color: BRAND.ink2,
margin: `0 0 ${sp.md} 0`,
}
const titleStyle: React.CSSProperties = {
color: BRAND.ink,
fontSize: '24px',
fontWeight: 700,
letterSpacing: '-0.018em',
lineHeight: '1.2',
margin: `0 0 ${sp.md} 0`,
}
const emStyle: React.CSSProperties = {
color: BRAND.rubis,
fontStyle: 'normal',
}
const leadStyle: React.CSSProperties = {
color: BRAND.ink2,
fontSize: '14px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
}
const invoiceCardStyle: React.CSSProperties = {
// Blanc sur fond crème pour détacher la card visuellement (avant
// c'était crème sur container blanc — maintenant le container est crème).
backgroundColor: BRAND.white,
border: `1px solid ${BRAND.line}`,
borderRadius: BRAND.radiusCard,
padding: `${sp.lg} ${sp.xl}`,
margin: `0 0 ${sp.xl} 0`,
}
const invoiceNumeroStyle: React.CSSProperties = {
fontSize: '13px',
fontWeight: 600,
color: BRAND.ink2,
letterSpacing: '0.02em',
margin: 0,
}
const invoiceClientStyle: React.CSSProperties = {
fontSize: '13px',
color: BRAND.ink3,
margin: `${sp.xs} 0 ${sp.md} 0`,
}
const invoiceAmountStyle: React.CSSProperties = {
fontSize: '28px',
fontWeight: 800,
letterSpacing: '-0.02em',
color: BRAND.ink,
margin: 0,
lineHeight: '1',
fontVariantNumeric: 'tabular-nums',
}
const invoiceDueStyle: React.CSSProperties = {
fontSize: '12px',
color: BRAND.ink3,
margin: `${sp.sm} 0 0 0`,
}
const ctaSectionStyle: React.CSSProperties = {
marginTop: 0,
}
const primaryButtonStyle: React.CSSProperties = {
backgroundColor: BRAND.rubis,
color: BRAND.white,
borderRadius: BRAND.radiusButton,
display: 'block',
textAlign: 'center',
textDecoration: 'none',
padding: `${sp.md} ${sp.xl}`,
fontSize: '15px',
fontWeight: 600,
width: '100%',
boxSizing: 'border-box',
// Shadow rubis-teintée pour cohérence avec les boutons SPA
boxShadow: '0 4px 12px rgba(159, 18, 57, 0.25)',
}
const secondaryButtonStyle: React.CSSProperties = {
backgroundColor: BRAND.white,
color: BRAND.ink,
border: `1px solid ${BRAND.ink}`,
borderRadius: BRAND.radiusButton,
display: 'block',
textAlign: 'center',
textDecoration: 'none',
padding: `${sp.md} ${sp.xl}`,
fontSize: '15px',
fontWeight: 600,
width: '100%',
boxSizing: 'border-box',
}
const hrStyle: React.CSSProperties = {
borderColor: BRAND.line,
borderStyle: 'solid',
borderWidth: '0 0 1px 0',
margin: `${sp.xl} 0 ${sp.lg} 0`,
}
const footnoteStyle: React.CSSProperties = {
fontSize: '11.5px',
color: BRAND.ink3,
lineHeight: '1.5',
margin: 0,
fontStyle: 'italic',
}

View File

@ -1,48 +1,102 @@
/**
* Template email de remerciement envoyé AU CLIENT FINAL après que
* l'utilisateur a confirmé le paiement (via check-in « Oui, payé » ou
* mark-paid manuel).
* l'utilisateur a confirmé le paiement.
*
* Mise en page volontairement plus douce que la relance :
* - bandeau header rubis-deep avec un check pour signaler "tout est OK"
* - body interpolé (le user a écrit le mot, on garde sa voix)
* - card récap discrète : facture, montant pas de date d'échéance ni
* de retard, on est passé à autre chose.
* Mise en page plus douce que la relance : check visuel, mot du user
* en pre-line, card récap discrète (pas de date d'échéance, on est passé
* à autre chose).
*
* Tokens dynamiques per-org (cf. RelanceEmail).
*/
import * as React from 'react'
import { Section, Text } from '@react-email/components'
import { BRAND, sp } from './_brand.js'
import type { BrandTokens } from '#services/brand'
import { sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type PaymentThanksEmailProps = {
/** Nom commercial visible côté client (l'org du user). */
brandName: string
tokens: BrandTokens
invoice: {
numero: string
amountFormatted: string
}
/** Texte de remerciement (déjà interpolé) — le mot du user. */
bodyText: string
/** URL landing publique (footer cliquable « Rubis sur l'ongle »). */
landingUrl?: string
/** URL landing publique. null = footer masqué. */
landingUrl?: string | null
hideRubisFooter?: boolean
}
export function PaymentThanksEmail({
brandName,
tokens,
invoice,
bodyText,
landingUrl,
hideRubisFooter,
}: PaymentThanksEmailProps) {
const checkBadgeWrapStyle: React.CSSProperties = {
textAlign: 'center',
margin: `0 0 ${sp.lg} 0`,
}
const checkBadgeStyle: React.CSSProperties = {
display: 'inline-block',
width: '44px',
height: '44px',
lineHeight: '44px',
textAlign: 'center',
borderRadius: '999px',
backgroundColor: tokens.primaryGlow,
color: tokens.primaryDeep,
fontSize: '22px',
fontWeight: 800,
}
const bodyTextStyle: React.CSSProperties = {
color: tokens.text,
fontSize: '15px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
whiteSpace: 'pre-line',
}
const summaryCardStyle: React.CSSProperties = {
backgroundColor: tokens.white,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `${sp.lg} 0 0 0`,
}
const summaryRowStyle: React.CSSProperties = {
display: 'block',
margin: `${sp.sm} 0`,
fontSize: '13px',
lineHeight: '1.4',
}
const summaryLabelStyle: React.CSSProperties = {
display: 'inline-block',
width: '110px',
color: tokens.textVeryMuted,
fontWeight: 500,
}
const summaryValueStyle: React.CSSProperties = {
color: tokens.text,
fontWeight: 600,
}
return (
<EmailLayout
tokens={tokens}
preview={`Paiement reçu — facture ${invoice.numero} (${invoice.amountFormatted})`}
brandName={brandName}
brandSubtitle={`Paiement reçu · ${invoice.numero}`}
landingUrl={landingUrl}
hideRubisFooter={hideRubisFooter}
>
{/* Petit check visuel pour signaler positivement "c'est bon". */}
<Section style={checkBadgeWrapStyle}>
<span style={checkBadgeStyle} aria-hidden="true">
@ -71,66 +125,9 @@ export function PaymentThanksEmail({
</Text>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Statut</span>
<span style={{ ...summaryValueStyle, color: BRAND.rubis }}>Réglée</span>
<span style={{ ...summaryValueStyle, color: tokens.primary }}>Réglée</span>
</Text>
</Section>
</EmailLayout>
)
}
// ---------------------------------------------------------------------------
// Styles inline
// ---------------------------------------------------------------------------
const checkBadgeWrapStyle: React.CSSProperties = {
textAlign: 'center',
margin: `0 0 ${sp.lg} 0`,
}
const checkBadgeStyle: React.CSSProperties = {
display: 'inline-block',
width: '44px',
height: '44px',
lineHeight: '44px',
textAlign: 'center',
borderRadius: '999px',
backgroundColor: BRAND.rubisGlow,
color: BRAND.rubisDeep,
fontSize: '22px',
fontWeight: 800,
}
const bodyTextStyle: React.CSSProperties = {
color: BRAND.ink,
fontSize: '15px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
whiteSpace: 'pre-line',
}
const summaryCardStyle: React.CSSProperties = {
backgroundColor: BRAND.white,
border: `1px solid ${BRAND.line}`,
borderRadius: BRAND.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `${sp.lg} 0 0 0`,
}
const summaryRowStyle: React.CSSProperties = {
display: 'block',
margin: `${sp.sm} 0`,
fontSize: '13px',
lineHeight: '1.4',
}
const summaryLabelStyle: React.CSSProperties = {
display: 'inline-block',
width: '110px',
color: BRAND.ink3,
fontWeight: 500,
}
const summaryValueStyle: React.CSSProperties = {
color: BRAND.ink,
fontWeight: 600,
}

View File

@ -4,24 +4,25 @@
* le user dans son plan de relance (avec variables {{numero}} etc.) ; on
* injecte ce body brut dans une mise en page Rubis :
*
* - Header rubis-deep avec le NOM DE L'ORG (ce que connaît le client)
* et le numéro de facture en sous-titre
* - Header avec logo customisé (Business) ou wordmark "◆ <senderName>"
* - Body rendu (le texte que le user a rédigé, déjà interpolé)
* - Card "récap facture" en pied de body : numéro, montant, échéance
*
* Pas de boutons CTA dans la relance le client est censé payer hors
* mail (virement, espèces, ...). Le mail rappelle juste le contexte.
* Tokens visuels passés en prop `tokens` (résolus par org via
* `#services/brand`). Plan Business = couleurs / logo / nom expéditeur
* custom. Sinon = palette Rubis par défaut.
*/
import * as React from 'react'
import { Section, Text } from '@react-email/components'
import { BRAND, sp } from './_brand.js'
import type { BrandTokens } from '#services/brand'
import { sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type RelanceEmailProps = {
/** Nom commercial visible côté client (l'org du user). */
brandName: string
/** Tokens résolus pour l'org (cf. resolveBrandTokens). */
tokens: BrandTokens
invoice: {
numero: string
amountFormatted: string
@ -30,32 +31,68 @@ export type RelanceEmailProps = {
}
/** Texte de la relance (déjà interpolé) que le user a posé dans son plan. */
bodyText: string
/** URL landing publique (footer cliquable "Rubis sur l'ongle"). */
landingUrl?: string
/** URL landing publique (footer cliquable "Rubis sur l'ongle"). null = footer masqué. */
landingUrl?: string | null
/** Masque le footer Rubis (marque blanche full). */
hideRubisFooter?: boolean
}
export function RelanceEmail({
brandName,
tokens,
invoice,
bodyText,
landingUrl,
hideRubisFooter,
}: RelanceEmailProps) {
const isLate = invoice.daysLate > 0
const bodyTextStyle: React.CSSProperties = {
color: tokens.text,
fontSize: '15px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
whiteSpace: 'pre-line',
}
const summaryCardStyle: React.CSSProperties = {
backgroundColor: tokens.white,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `${sp.lg} 0 0 0`,
}
const summaryRowStyle: React.CSSProperties = {
display: 'block',
margin: `${sp.sm} 0`,
fontSize: '13px',
lineHeight: '1.4',
}
const summaryLabelStyle: React.CSSProperties = {
display: 'inline-block',
width: '110px',
color: tokens.textVeryMuted,
fontWeight: 500,
}
const summaryValueStyle: React.CSSProperties = {
color: tokens.text,
fontWeight: 600,
}
return (
<EmailLayout
tokens={tokens}
preview={`${invoice.numero} (${invoice.amountFormatted}) — ${
isLate ? `${invoice.daysLate}j de retard` : 'à régler'
}`}
brandName={brandName}
brandSubtitle={`Facture ${invoice.numero}`}
landingUrl={landingUrl}
hideRubisFooter={hideRubisFooter}
>
{/* Body texte en pre-line pour conserver les sauts de ligne tels que
le user les a écrits. Le client lit le mot du patron, pas un
template impersonnel. */}
<Text style={bodyTextStyle}>{bodyText}</Text>
{/* Card récap en pied : tableau visuel court qui rappelle les chiffres. */}
<Section style={summaryCardStyle}>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Facture</span>
@ -79,12 +116,14 @@ export function RelanceEmail({
<span
style={{
...summaryValueStyle,
color: isLate ? BRAND.rubisDeep : BRAND.ink,
color: isLate ? tokens.primaryDeep : tokens.text,
}}
>
{invoice.dueDateFormatted}
{isLate ? (
<span style={{ color: BRAND.rubisDeep, fontSize: '12px', marginLeft: sp.sm }}>
<span
style={{ color: tokens.primaryDeep, fontSize: '12px', marginLeft: sp.sm }}
>
({invoice.daysLate}j de retard)
</span>
) : null}
@ -94,44 +133,3 @@ export function RelanceEmail({
</EmailLayout>
)
}
// ---------------------------------------------------------------------------
// Styles inline
// ---------------------------------------------------------------------------
const bodyTextStyle: React.CSSProperties = {
color: BRAND.ink,
fontSize: '15px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
whiteSpace: 'pre-line', // préserve les \n du body sans nécessiter <br/>
}
const summaryCardStyle: React.CSSProperties = {
// Blanc sur fond crème pour détacher la card visuellement (le container
// du layout est désormais crème, pas blanc).
backgroundColor: BRAND.white,
border: `1px solid ${BRAND.line}`,
borderRadius: BRAND.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `${sp.lg} 0 0 0`,
}
const summaryRowStyle: React.CSSProperties = {
display: 'block',
margin: `${sp.sm} 0`,
fontSize: '13px',
lineHeight: '1.4',
}
const summaryLabelStyle: React.CSSProperties = {
display: 'inline-block',
width: '110px',
color: BRAND.ink3,
fontWeight: 500,
}
const summaryValueStyle: React.CSSProperties = {
color: BRAND.ink,
fontWeight: 600,
}

View File

@ -0,0 +1,51 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { Exception } from '@adonisjs/core/exceptions'
import Organization from '#models/organization'
/**
* Middleware de gating Business bloque l'accès aux endpoints réservés au
* plan Business (marque blanche, comptes email connectés, etc.).
*
* Throw 403 avec `code: 'business_plan_required'` si l'org n'est pas sur
* Business. Le SPA matche ce code pour afficher l'upsell card propre au
* lieu d'un 403 brutal.
*
* Précondition : l'auth middleware doit avoir déjà tourné en amont on
* lit `auth.user.organizationId` sans re-vérifier le token. Si pas d'org
* rattachée (cas pathologique), on throw 404 (jamais "no business plan" sur
* un user sans org c'est un autre problème).
*
* Usage dans routes.ts :
* router
* .group(() => {
* router.get('brand', [BrandController, 'show'])
* router.patch('brand', [BrandController, 'update'])
* })
* .prefix('/api/v1')
* .use([middleware.auth(), middleware.assertBusinessPlan()])
*/
export default class AssertBusinessPlanMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
const user = ctx.auth.getUserOrFail()
if (!user.organizationId) {
throw new Exception('Aucune organisation rattachée', {
status: 404,
code: 'not_found',
})
}
// On charge l'org pour lire le plan courant. Cache HTTP côté SPA assume
// que cette query reste rapide (un index sur id, ce qui est le cas).
const org = await Organization.findOrFail(user.organizationId)
if (org.plan !== 'business') {
throw new Exception('Plan Business requis pour cette fonctionnalité', {
status: 403,
code: 'business_plan_required',
})
}
return next()
}
}

View File

@ -1,9 +1,20 @@
import { OrganizationSchema } from '#database/schema'
import { hasMany } from '@adonisjs/lucid/orm'
import { column, hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import User from '#models/user'
import type { BrandSettings } from '#services/brand'
export default class Organization extends OrganizationSchema {
/**
* Settings de marque blanche (plan Business) JSONB, null = palette
* Rubis par défaut. Cf. `#services/brand` pour la résolution et la
* validation. Cette déclaration manuelle existe en attendant que
* `schema.ts` soit régénéré par `node ace migration:run` (cf. migration
* `1778400000000_add_brand_settings_to_organizations_table.ts`).
*/
@column()
declare brandSettings: BrandSettings | null
@hasMany(() => User)
declare users: HasMany<typeof User>
}

View File

@ -1,112 +0,0 @@
/**
* blog_uploads upload + serve d'images du blog.
*
* Architecture (cf. /docs/tech/architecture.md §3) :
* - Upload : POST /api/v1/admin/uploads multipart MinIO (drive S3)
* sous la clé `uploads/blog/{uuid}.{ext}`, visibilité privée.
* - Lecture : GET /api/v1/uploads/blog/:filename stream depuis MinIO
* avec Cache-Control: public, max-age=31536000, immutable. Le fichier
* est immuable (uuid dans le nom = chaque upload = nouvelle URL), donc
* cache infini sans risque d'invalidation.
* - L'URL publique est ensuite stockée dans posts.hero_image_url (et
* optionnellement og_image_url) pas de FK, simple reference texte.
*
* Orphelins : quand un post change de hero, l'ancienne image reste sur
* MinIO (~quelques KB par fichier). Acceptable pour V1 ; périodique
* cleanup à ajouter quand on dépassera ~100 articles.
*/
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import drive from '@adonisjs/drive/services/main'
import type { MultipartFile } from '@adonisjs/core/bodyparser'
const ALLOWED_EXTS = ['jpg', 'jpeg', 'png', 'webp'] as const
const MAX_BYTES = 4 * 1024 * 1024 // 4 MB
export type UploadResult = {
/** URL publique relative (à préfixer du host API côté client). */
publicPath: string
/** Clé S3 réelle dans le bucket (debug / cleanup). */
storageKey: string
/** Type MIME inféré de l'extension. */
contentType: string
/** Taille réelle en bytes. */
sizeBytes: number
}
/**
* Reçoit un MultipartFile (validé Adonis) et le pousse sur MinIO.
* Throw si le fichier ne respecte pas les contraintes (taille, MIME).
*/
export async function uploadBlogImage(file: MultipartFile): Promise<UploadResult> {
const ext = (file.extname ?? '').toLowerCase()
if (!ALLOWED_EXTS.includes(ext as (typeof ALLOWED_EXTS)[number])) {
throw new Error(`unsupported_extension: ${ext} (autorisés : ${ALLOWED_EXTS.join(', ')})`)
}
if (file.size > MAX_BYTES) {
throw new Error(`file_too_large: ${file.size}B (max ${MAX_BYTES}B)`)
}
const uuid = randomUUID()
const filename = `${uuid}.${ext}`
const storageKey = `uploads/blog/${filename}`
// Adonis Drive accepte un path local (le multipart écrit le tmp file).
if (!file.tmpPath) {
throw new Error('multipart_no_tmpPath')
}
await drive.use().moveFromFs(file.tmpPath, storageKey)
// URL absolue pour que la landing publique (rubis.pro) puisse l'afficher
// directement en <img src> sans avoir à connaître l'API host. APP_URL est
// posé par le ConfigMap k3s rubis-api-config ('https://app.rubis.pro').
const apiHost = (process.env.APP_URL || 'http://localhost:3333').replace(/\/$/, '')
return {
publicPath: `${apiHost}/api/v1/uploads/blog/${filename}`,
storageKey,
contentType: extToContentType(ext),
sizeBytes: file.size,
}
}
/**
* Stream une image depuis MinIO. Renvoie un Buffer + le contentType
* pour que le contrôleur réponde avec les bons headers.
*/
export async function readBlogImage(filename: string): Promise<{
buffer: Buffer
contentType: string
} | null> {
// Sécurité : pas de path traversal. Le slug doit matcher `uuid.ext`.
if (!/^[a-f0-9-]{36}\.(jpg|jpeg|png|webp)$/i.test(filename)) {
return null
}
const storageKey = `uploads/blog/${filename}`
try {
const buffer = Buffer.from(await drive.use().getArrayBuffer(storageKey))
return {
buffer,
contentType: extToContentType(path.extname(filename).slice(1).toLowerCase()),
}
} catch {
return null
}
}
function extToContentType(ext: string): string {
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'webp':
return 'image/webp'
default:
return 'application/octet-stream'
}
}

Binary file not shown.

View File

@ -16,6 +16,7 @@ import type Organization from '#models/organization'
import { CheckinEmail } from '#mails/checkin_email'
import { RelanceEmail } from '#mails/relance_email'
import { PaymentThanksEmail } from '#mails/payment_thanks_email'
import { resolveBrandTokens, DEFAULT_BRAND } from '#services/brand'
/**
* Templates par défaut utilisés quand le plan d'une org n'a pas (ou plus)
@ -112,15 +113,15 @@ export async function sendRelanceEmail({
const subject = renderTemplate(step.subject, vars)
const body = renderTemplate(step.body, vars)
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
// Le client final connaît l'org (ex: "Arthur Barré"), pas Rubis. On utilise
// le nom de l'org comme display name visible côté client. Fallback :
// user.fullName, puis MAIL_FROM_NAME (= "Rubis sur l'ongle") en dernier
// recours si l'org n'a pas de nom posé.
// L'adresse technique reste sur notre domaine vérifié (SPF/DKIM Resend).
const fromName =
organization?.name?.trim() ||
user?.fullName?.trim() ||
env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
// Le client final connaît l'org du user, pas Rubis. Le display From: vient
// de `tokens.senderName` qui résout la cascade :
// brandSettings.senderName (Business) → org.name → user.fullName → "Rubis".
// Les couleurs/logo du template viennent aussi de tokens (Business only,
// sinon palette Rubis intacte). L'adresse technique reste sur notre domaine
// vérifié (SPF/DKIM Resend) en V1 — le send-on-behalf via Gmail/Microsoft/SMTP
// arrive Phase 2/3/4 (cf. ADR à venir).
const tokens = resolveBrandTokens(organization ?? null)
const fromName = tokens.senderName || user?.fullName?.trim() || 'Rubis sur l\'ongle'
// Calcule daysLate pour le récap visuel dans le HTML.
const nowOrg = await clock.now(invoice.organizationId)
@ -130,10 +131,10 @@ export async function sendRelanceEmail({
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
// Rendu HTML via React Email — DA Rubis (header rubis-deep + card cream).
// Rendu HTML via React Email — tokens dynamiques (Business custom ou Rubis).
const htmlBody = await render(
RelanceEmail({
brandName: fromName,
tokens,
invoice: {
numero: invoice.numero,
amountFormatted: formatAmountFr(invoice.amountTtcCents),
@ -258,10 +259,13 @@ L'équipe Rubis`
// PAS le nom de l'org.
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
// Rendu HTML — DA Rubis avec 2 boutons CTA Oui/Non.
// Rendu HTML — DA Rubis avec 2 boutons CTA Oui/Non. Toujours en branding
// Rubis (jamais customisable) : c'est une notif Rubis → user, pas user →
// client. Le user reconnaît Rubis comme expéditeur, pas sa propre marque.
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
const htmlBody = await render(
CheckinEmail({
tokens: DEFAULT_BRAND,
invoice: {
numero: invoice.numero,
amountFormatted: formatAmountFr(invoice.amountTtcCents),
@ -349,18 +353,15 @@ export async function sendPaymentThanksEmail({
const body = renderTemplate(bodyTpl, vars)
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
// Le client connaît l'org du user (ex: « Arthur Barré »), pas Rubis.
// Aligné avec sendRelanceEmail.
const fromName =
organization?.name?.trim() ||
user?.fullName?.trim() ||
env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
// Tokens résolus + senderName aligné avec sendRelanceEmail.
const tokens = resolveBrandTokens(organization ?? null)
const fromName = tokens.senderName || user?.fullName?.trim() || 'Rubis sur l\'ongle'
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
const htmlBody = await render(
PaymentThanksEmail({
brandName: fromName,
tokens,
invoice: {
numero: invoice.numero,
amountFormatted: formatAmountFr(invoice.amountTtcCents),

View File

@ -0,0 +1,174 @@
/**
* media_storage service mutualisé d'upload / lecture d'images sur MinIO.
*
* Remplace l'ancien `blog_uploads.ts` : toutes les features qui ont besoin
* de stocker un fichier image (blog hero, logo de marque blanche, futures
* pièces jointes, avatars...) passent par ici.
*
* Pattern :
* - Chaque "scope" (`blog`, `brand-logo`, ...) déclare ses contraintes
* (extensions, taille max, prefix de stockage, prefix d'URL publique).
* - L'upload retourne `{ publicPath, storageKey, contentType, sizeBytes }`.
* `storageKey` est conservé en DB pour pouvoir supprimer plus tard.
* - La lecture est publique (cache `max-age=31536000 immutable` côté
* controller) pas d'auth, parce que les emails sortants vers les
* clients doivent pouvoir charger l'image sans token.
* - Cleanup : `deleteMedia(storageKey)` retire un fichier. Utilisé quand
* l'user remplace un logo (pas de versioning, on écrase l'ancien).
*
* Sécurité :
* - Filename = UUID v4 globalement unique, pas de collision cross-org
* même si on garde le storage prefix plat (`uploads/brand-logos/{uuid}.ext`)
* - Le pattern `^[a-f0-9-]{36}\.(...)$` empêche le path traversal sur la
* lecture publique.
*
* URLs publiques :
* - L'host est `process.env.APP_URL` (posé par le ConfigMap rubis-api).
* - Forme : `{APP_URL}/api/v1/uploads/{scopeUrlSegment}/{filename}`
* - Stocké en DB si APP_URL change un jour, faudra migrer (acceptable).
*/
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import drive from '@adonisjs/drive/services/main'
import type { MultipartFile } from '@adonisjs/core/bodyparser'
export type MediaScope = 'blog' | 'brand-logo'
interface ScopeConfig {
/** Préfixe de stockage MinIO (clé S3). */
storagePrefix: string
/** Segment d'URL publique servi par le controller (ex. "blog" → /api/v1/uploads/blog/...). */
urlSegment: string
/** Extensions autorisées (lowercase, sans le point). */
allowedExts: readonly string[]
/** Taille max en bytes. */
maxBytes: number
}
const SCOPES: Record<MediaScope, ScopeConfig> = {
blog: {
storagePrefix: 'uploads/blog',
urlSegment: 'blog',
allowedExts: ['jpg', 'jpeg', 'png', 'webp'],
maxBytes: 4 * 1024 * 1024, // 4 MB — hero images de blog
},
'brand-logo': {
storagePrefix: 'uploads/brand-logos',
urlSegment: 'brand-logos',
// SVG accepté pour les logos vectoriels (typique des entreprises).
// Risque XSS dans un SVG → on serve avec Content-Type strict et X-Content-Type-Options nosniff.
allowedExts: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
maxBytes: 1 * 1024 * 1024, // 1 MB — un logo n'a aucune raison d'être plus gros
},
}
export interface UploadResult {
/** URL publique absolue (à stocker en DB et à intégrer dans les emails). */
publicPath: string
/** Clé S3 / MinIO réelle (à conserver pour delete ultérieur). */
storageKey: string
/** Type MIME inféré de l'extension. */
contentType: string
/** Taille réelle en bytes. */
sizeBytes: number
}
/**
* Reçoit un MultipartFile (validé Adonis) et le pousse sur MinIO.
* Throw si extension/taille hors contraintes du scope.
*/
export async function uploadMedia(
file: MultipartFile,
scope: MediaScope
): Promise<UploadResult> {
const cfg = SCOPES[scope]
if (!cfg) throw new Error(`unknown_scope: ${scope}`)
const ext = (file.extname ?? '').toLowerCase()
if (!cfg.allowedExts.includes(ext)) {
throw new Error(
`unsupported_extension: ${ext} (autorisés : ${cfg.allowedExts.join(', ')})`
)
}
if (file.size > cfg.maxBytes) {
throw new Error(`file_too_large: ${file.size}B (max ${cfg.maxBytes}B)`)
}
if (!file.tmpPath) throw new Error('multipart_no_tmpPath')
const filename = `${randomUUID()}.${ext}`
const storageKey = `${cfg.storagePrefix}/${filename}`
await drive.use().moveFromFs(file.tmpPath, storageKey)
const apiHost = (process.env.APP_URL || 'http://localhost:3333').replace(/\/$/, '')
return {
publicPath: `${apiHost}/api/v1/uploads/${cfg.urlSegment}/${filename}`,
storageKey,
contentType: extToContentType(ext),
sizeBytes: file.size,
}
}
/**
* Stream une image depuis MinIO. Renvoie un Buffer + le contentType pour
* que le controller réponde avec les bons headers + cache long.
*/
export async function readMedia(
scope: MediaScope,
filename: string
): Promise<{ buffer: Buffer; contentType: string } | null> {
const cfg = SCOPES[scope]
if (!cfg) return null
// Sécurité : filename doit matcher UUID v4 + extension autorisée. Empêche
// toute tentative de path traversal ou lecture hors scope.
const extPattern = cfg.allowedExts.join('|')
const re = new RegExp(`^[a-f0-9-]{36}\\.(${extPattern})$`, 'i')
if (!re.test(filename)) return null
const storageKey = `${cfg.storagePrefix}/${filename}`
try {
const buffer = Buffer.from(await drive.use().getArrayBuffer(storageKey))
return {
buffer,
contentType: extToContentType(path.extname(filename).slice(1).toLowerCase()),
}
} catch {
return null
}
}
/**
* Supprime un fichier de MinIO. Utilisé quand un user remplace son logo
* de marque (pas de versioning, on écrase l'ancien fichier proprement
* plutôt que d'accumuler des orphelins).
*
* Silent fail si la clé n'existe pas on ne veut pas bloquer un PATCH
* brand_settings parce qu'on n'arrive pas à supprimer un fichier déjà
* absent.
*/
export async function deleteMedia(storageKey: string): Promise<void> {
try {
await drive.use().delete(storageKey)
} catch {
// ignore — best effort cleanup
}
}
function extToContentType(ext: string): string {
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'webp':
return 'image/webp'
case 'svg':
return 'image/svg+xml'
default:
return 'application/octet-stream'
}
}

View File

@ -6,6 +6,7 @@ import { DateTime } from 'luxon'
import env from '#start/env'
import { CheckinEmail } from '#mails/checkin_email'
import { DEFAULT_BRAND } from '#services/brand'
import { RelanceEmail } from '#mails/relance_email'
/**
@ -82,7 +83,7 @@ export default class SendTestEmail extends BaseCommand {
`Bonjour,\n\nPetit rappel pour la facture ${fakeInvoice.numero}.\n\nCordialement,\nArthur Barré`
html = await render(
RelanceEmail({
brandName: 'Arthur Barré (test)',
tokens: { ...DEFAULT_BRAND, senderName: 'Arthur Barré (test)' },
invoice: { ...fakeInvoice, daysLate: 3 },
bodyText: text,
landingUrl,
@ -98,6 +99,7 @@ export default class SendTestEmail extends BaseCommand {
`— L'équipe Rubis`
html = await render(
CheckinEmail({
tokens: DEFAULT_BRAND,
invoice: fakeInvoice,
client: { name: 'Boulangerie Test' },
user: { fullName: 'Arthur Barré' },

View File

@ -0,0 +1,56 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Marque blanche pour le plan Business.
*
* `brand_settings` est une colonne JSONB nullable qui stocke les overrides
* visuels appliqués aux emails sortants (relances, remerciements) logo,
* nom expéditeur, et 10+ tokens de couleur. null = utilise les defaults
* Rubis (cf. apps/api/app/mails/_brand.ts).
*
* On choisit JSONB plutôt que 11 colonnes plates parce que :
* - aucun champ n'a besoin d'être indexé / filtré individuellement
* - on lit toujours le settings entier au moment d'envoyer un mail
* - on peut ajouter de nouveaux tokens sans migration (UX "tatillons")
* - serializer/validateur unique côté backend
*
* Le contrôleur fait du write-through merge : un PATCH partiel ne remplace
* pas tout l'objet, il met à jour seulement les clés fournies. Mettre une
* clé à `null` explicitement revient au default Rubis sur ce token précis.
*
* Schéma applicatif (cf. `apps/api/app/services/brand.ts` BrandSettings) :
* {
* logoPath?: string, // clé storage MinIO
* logoUrl?: string, // URL publique servie par /api/v1/uploads/...
* senderName?: string, // "Cabinet Compta Martin" pour From: header
* primaryColor?: string, // hex #RRGGBB — CTA + accents + default link
* bannerColor?: string, // bandeau header de l'email
* bodyBgColor?: string, // fond global de l'email
* cardBgColor?: string, // fond de la carte de contenu
* textColor?: string, // texte principal
* textMutedColor?: string, // texte secondaire (footer, meta)
* borderColor?: string, // séparateurs
* linkColor?: string, // liens (sinon = primaryColor)
* buttonTextColor?: string, // texte sur CTA (default white)
* }
*
* Plan gating : la résolution n'utilise les overrides QUE si `org.plan ===
* 'business'`. Si l'org downgrade de Business à Pro/Free, ses settings
* restent en DB (pas de perte de config), mais ne s'appliquent plus
* les emails repartent en branding Rubis.
*/
export default class extends BaseSchema {
protected tableName = 'organizations'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.jsonb('brand_settings').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('brand_settings')
})
}
}

View File

@ -50,6 +50,17 @@ export default {
},
},
},
organizations: {
columns: {
// jsonb — settings de marque blanche (plan Business). Toutes les
// clés sont optionnelles, validation faite côté brand_controller.
// Cf. `#services/brand` pour la résolution + le type complet.
brand_settings: {
tsType:
"{ logoPath?: string | null; logoUrl?: string | null; senderName?: string | null; primaryColor?: string | null; bannerColor?: string | null; bodyBgColor?: string | null; cardBgColor?: string | null; textColor?: string | null; textMutedColor?: string | null; borderColor?: string | null; linkColor?: string | null; buttonTextColor?: string | null } | null",
},
},
},
import_drafts: {
columns: {
status: {

View File

@ -49,4 +49,5 @@ router.use([
export const middleware = router.named({
auth: () => import('#middleware/auth_middleware'),
admin: () => import('#middleware/admin_middleware'),
assertBusinessPlan: () => import('#middleware/assert_business_plan_middleware'),
})

View File

@ -20,6 +20,7 @@ import { controllers } from '#generated/controllers'
const BlogController = () => import('#controllers/blog_controller')
const AdminPostsController = () => import('#controllers/admin_posts_controller')
const BlogUploadsController = () => import('#controllers/blog_uploads_controller')
const BrandController = () => import('#controllers/brand_controller')
router
@ -59,12 +60,24 @@ router
.as('posts')
/**
* Image hero servie depuis MinIO. Public, cache infini (le filename est
* Images servies depuis MinIO. Public, cache infini (le filename est
* un UUID immuable, chaque upload = nouvelle URL).
*
* Deux scopes :
* - `/uploads/blog/:filename` hero images d'articles
* - `/uploads/brand-logos/:filename` logos de marque blanche (Business)
*
* Le BlogUploadsController.show est en fait scope-agnostic (délègue à
* media_storage.readMedia), donc on le réutilise pour les deux. Un
* controller dédié serait du boilerplate inutile vu que la logique est
* 100% dans le service.
*/
router
.get('uploads/blog/:filename', [BlogUploadsController, 'show'])
.as('uploads.blog.show')
router
.get('uploads/brand-logos/:filename', [BrandController, 'showLogo'])
.as('uploads.brand_logos.show')
})
.prefix('/api/v1')
@ -165,6 +178,34 @@ router
.as('organizations')
.use(middleware.auth())
/**
* Marque blanche auth + plan Business obligatoires. Le middleware
* `assertBusinessPlan` throw 403 `business_plan_required` que le SPA
* catch pour afficher l'upsell card propre.
*
* - GET /brand settings + tokens résolus + defaults
* - PATCH /brand maj partielle (null = reset au default)
* - POST /brand/logo upload multipart, écrase l'ancien sur MinIO
* - DELETE /brand/logo retire le logo (retour wordmark Rubis)
* - POST /brand/test envoie un mail de test à l'user pour preview
*
* Note : la route publique de lecture du logo (sans auth) est dans le
* groupe public au-dessus (`uploads/brand-logos/:filename`), parce que
* les emails sortants vers les clients doivent pouvoir charger l'image
* sans token.
*/
router
.group(() => {
router.get('', [BrandController, 'show']).as('show')
router.patch('', [BrandController, 'update']).as('update')
router.post('logo', [BrandController, 'uploadLogo']).as('logo.upload')
router.delete('logo', [BrandController, 'deleteLogo']).as('logo.delete')
router.post('test', [BrandController, 'sendTest']).as('test')
})
.prefix('brand')
.as('brand')
.use([middleware.auth(), middleware.assertBusinessPlan()])
/**
* Clients auth requise, scope par organization de l'utilisateur courant.
*/

View File

@ -0,0 +1,18 @@
---
version: "1.11.0"
date: 2026-05-11
title: "Première brique de la marque blanche"
type: feature
highlights:
- "Logo, nom expéditeur et couleurs custom dans les emails envoyés à vos clients"
- "Les utilisateurs tatillons ont la main sur 10+ tokens visuels : bandeau, fonds, textes, bordures, boutons"
- "Disponible sur le plan Business — l'interface self-service arrive dans la prochaine version"
---
Le plan Business gagne sa fonctionnalité marque blanche : vos emails de relance et de remerciement partent désormais avec **votre logo, vos couleurs et votre nom** à la place du branding Rubis.
Côté visible par votre client : il reçoit un email signé `Cabinet Compta Martin` (et non Rubis), avec votre logo en haut, vos couleurs sur les boutons, et votre signature en bas. Aucun « envoyé via Rubis » nulle part — c'est votre marque, du début à la fin.
Pour les utilisateurs tatillons, on a poussé la customisation loin : couleur du bandeau, du fond, des cartes, des textes principaux et secondaires, des bordures, des liens, du texte sur les boutons. Une dizaine de tokens visuels en tout — vous gardez la main sur tout l'email.
L'interface self-service pour configurer tout ça depuis vos réglages arrive dans la prochaine version. En attendant, les utilisateurs Business peuvent déjà demander à activer leur branding manuellement.

View File

@ -11,7 +11,7 @@
* Convention : semver (major.minor.patch). Le toast affiche `v${APP_VERSION}`
* et linke vers `https://rubis.pro/changelog#${APP_VERSION}`.
*/
export const APP_VERSION = "1.10.0";
export const APP_VERSION = "1.11.0";
/** URL absolue de la page changelog (utilisée par le toast). */
export const CHANGELOG_URL = "https://rubis.pro/changelog";