feat(release): v1.11.0 — marque blanche pour le plan Business (backend)
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:
parent
7a112d3329
commit
919ebfe755
@ -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')
|
||||
}
|
||||
|
||||
250
apps/api/app/controllers/brand_controller.ts
Normal file
250
apps/api/app/controllers/brand_controller.ts
Normal 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 }] })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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]` ré-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'ongle
|
||||
</Link>
|
||||
) : (
|
||||
<strong style={{ color: BRAND.ink2 }}>Rubis sur l'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'ongle
|
||||
</Link>
|
||||
) : (
|
||||
<strong style={{ color: tokens.textMuted }}>
|
||||
Rubis sur l'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',
|
||||
}
|
||||
|
||||
@ -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 été payée AVANT que la 1re
|
||||
* relance ne parte chez son client.
|
||||
*
|
||||
* Structure :
|
||||
* - Header rubis-deep "Rubis · Confirmation requise"
|
||||
* - Eyebrow + question Avez-vous été 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'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',
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
51
apps/api/app/middleware/assert_business_plan_middleware.ts
Normal file
51
apps/api/app/middleware/assert_business_plan_middleware.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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'
|
||||
}
|
||||
}
|
||||
BIN
apps/api/app/services/brand.ts
Normal file
BIN
apps/api/app/services/brand.ts
Normal file
Binary file not shown.
@ -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),
|
||||
|
||||
174
apps/api/app/services/media_storage.ts
Normal file
174
apps/api/app/services/media_storage.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
@ -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é' },
|
||||
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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'),
|
||||
})
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
18
apps/landing/src/content/changelog/1.11.0.md
Normal file
18
apps/landing/src/content/changelog/1.11.0.md
Normal 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.
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user