Backend
- migration : champs contact_first_name / contact_last_name (nullable)
sur clients pour personnaliser les variables de relance
- POST /api/v1/plans : création de plan custom avec slug auto-généré
(suffixé en cas de collision, "nouveau"/"new"/"create" réservés)
- POST /api/v1/ai/generate-relance : génération de subject+body via
mistral-small-latest, avec brief utilisateur et tonalité ciblée
- mail_dispatcher : nouvelles variables {{daysLate}}, {{issueDate}},
{{user.fullName}}, {{user.companyName}}, {{client.contactFirstName}},
{{client.contactLastName}} (helper buildRelanceVars exposé pour preview)
- send_relance_job preload désormais l'organization pour exposer son name
Frontend
- /plans/nouveau : wizard 4 étapes (Identité → Cadence → Messages → Récap)
- Stepper en haut, navigation guidée, validation par étape
- Étape 1 : nom + tonalité globale (4 cards Doux/Standard/Ferme/Strict)
avec aperçu de la cadence par défaut associée
- Étape 2 : timeline horizontale (rail rubis-glow + nœuds ◆ teintés
selon la tonalité), édition décalage/ton de l'étape sélectionnée
- Étape 3 : édition par étape avec preview live à droite, chips de
variables cliquables, bouton "Générer avec l'IA" qui ouvre une modale
Mistral (brief + résultat + régénérer)
- Étape 4 : récap avec preview de chaque email rendu sur un client fictif
- Détection des variables sensibles → warning si X clients existants n'ont
pas le champ contactFirstName/contactLastName rempli (UX informative,
fallback vide à l'envoi)
- "Dupliquer" sur chaque card de plan → /plans/nouveau?from=<slug>
pour pré-remplir le wizard à partir d'un plan existant
- ClientCreateDialog : ajout des champs prénom/nom du contact dédié
- TEMPLATE_VARIABLES étendu, helper renderTemplate côté front en miroir
exact de l'implémentation API
- MSW handlers ai/plans/clients alignés sur le nouveau contrat
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
import mail from '@adonisjs/mail/services/main'
|
|
import env from '#start/env'
|
|
import { DateTime } from 'luxon'
|
|
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
|
|
import type Invoice from '#models/invoice'
|
|
import type Client from '#models/client'
|
|
import type PlanStep from '#models/plan_step'
|
|
import type User from '#models/user'
|
|
import type Organization from '#models/organization'
|
|
|
|
type RelancePayload = {
|
|
invoice: Invoice
|
|
client: Client
|
|
step: PlanStep
|
|
user: User | null
|
|
organization?: Organization | null
|
|
}
|
|
|
|
/**
|
|
* Construit l'objet `vars` interpolé dans subject/body. Exposé pour
|
|
* permettre la preview côté contrôleur (wizard de création de plan)
|
|
* avec les mêmes variables que ce qui sera réellement envoyé.
|
|
*
|
|
* Variables disponibles :
|
|
* - {{client.name}}, {{client.email}}
|
|
* - {{client.contactFirstName}}, {{client.contactLastName}} (peuvent être vides)
|
|
* - {{numero}}, {{amount}}, {{dueDate}}, {{issueDate}}
|
|
* - {{daysLate}} (jours de retard depuis dueDate, négatif = avant échéance)
|
|
* - {{user.fullName}}, {{user.companyName}}
|
|
* - {{signature}}
|
|
*/
|
|
export function buildRelanceVars({
|
|
invoice,
|
|
client,
|
|
user,
|
|
organization,
|
|
}: {
|
|
invoice: Pick<Invoice, 'numero' | 'amountTtcCents' | 'dueDate' | 'issueDate'>
|
|
client: Pick<Client, 'name' | 'email' | 'contactFirstName' | 'contactLastName'>
|
|
user: Pick<User, 'fullName' | 'signature' | 'email'> | null
|
|
organization?: Pick<Organization, 'name'> | null
|
|
}) {
|
|
const dueDate = invoice.dueDate.toJSDate()
|
|
// Jours de retard arrondis à l'entier (UTC pour cohérence).
|
|
const daysLate = Math.floor(
|
|
DateTime.utc().startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
|
|
)
|
|
return {
|
|
client: {
|
|
name: client.name,
|
|
email: client.email,
|
|
contactFirstName: client.contactFirstName ?? '',
|
|
contactLastName: client.contactLastName ?? '',
|
|
},
|
|
user: {
|
|
fullName: user?.fullName ?? '',
|
|
companyName: organization?.name ?? '',
|
|
},
|
|
numero: invoice.numero,
|
|
amount: formatAmountFr(invoice.amountTtcCents),
|
|
dueDate: formatDateFr(dueDate),
|
|
issueDate: formatDateFr(invoice.issueDate.toJSDate()),
|
|
daysLate: String(daysLate),
|
|
signature: user?.signature ?? user?.fullName ?? '',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Envoie un email de relance à un client à partir d'un step.
|
|
* Le subject/body du step contiennent des placeholders Mustache-like
|
|
* qu'on interpole avant l'envoi (cf. `buildRelanceVars`).
|
|
*
|
|
* Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev,
|
|
* `resend` en prod).
|
|
*/
|
|
export async function sendRelanceEmail({
|
|
invoice,
|
|
client,
|
|
step,
|
|
user,
|
|
organization,
|
|
}: RelancePayload) {
|
|
const vars = buildRelanceVars({ invoice, client, user, organization })
|
|
|
|
const subject = renderTemplate(step.subject, vars)
|
|
const body = renderTemplate(step.body, vars)
|
|
|
|
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
|
|
await mailer.send((m) => {
|
|
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
|
|
.to(client.email, client.name)
|
|
.subject(subject)
|
|
// Texte brut pour V1 — on ajoutera un template HTML quand on aura
|
|
// décidé d'un look graphique pour les relances.
|
|
.text(body)
|
|
// Reply-To pointe sur l'utilisateur Rubis : si le client final répond
|
|
// à la relance, sa réponse arrive chez le patron de la TPE, pas dans
|
|
// notre boîte transactionnelle.
|
|
if (user?.email) {
|
|
m.replyTo(user.email, user.fullName ?? user.email)
|
|
}
|
|
})
|
|
}
|
|
|
|
type CheckinPayload = {
|
|
invoice: Invoice
|
|
client: Client
|
|
user: User
|
|
paidUrl: string
|
|
pendingUrl: string
|
|
}
|
|
|
|
/**
|
|
* Envoie le check-in à l'**utilisateur** (pas au client). Lui demande
|
|
* si la facture a été payée, avec 2 liens publics qui modifient l'état
|
|
* côté API et redirigent ensuite vers le SPA.
|
|
*
|
|
* Texte brut V1. Un template HTML viendra quand on aura figé le look
|
|
* graphique (cf. ADR-021).
|
|
*/
|
|
export async function sendCheckinEmail({
|
|
invoice,
|
|
client,
|
|
user,
|
|
paidUrl,
|
|
pendingUrl,
|
|
}: CheckinPayload) {
|
|
const subject = `Facture ${invoice.numero} — payée par ${client.name} ?`
|
|
const body = `Bonjour ${user.fullName ?? ''},
|
|
|
|
La facture ${invoice.numero} (${formatAmountFr(invoice.amountTtcCents)}) émise pour ${client.name}
|
|
arrive à échéance aujourd'hui (${formatDateFr(invoice.dueDate.toJSDate())}).
|
|
|
|
Avant que Rubis n'envoie la première relance, dites-nous où vous en êtes :
|
|
|
|
✓ J'ai été payé(e), pas besoin de relancer :
|
|
${paidUrl}
|
|
|
|
→ Toujours en attente, lance la relance comme prévu :
|
|
${pendingUrl}
|
|
|
|
Ces liens expirent dans 24h.
|
|
|
|
Merci,
|
|
L'équipe Rubis`
|
|
|
|
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
|
|
await mailer.send((m) => {
|
|
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
|
|
.to(user.email, user.fullName ?? user.email)
|
|
.subject(subject)
|
|
.text(body)
|
|
})
|
|
}
|