Proposal v2 + Add-ons par SessionType (Plan + État d'implémentation)
Résumé
Cette fonctionnalité introduit un flux de proposal public par token dédié (/proposal/:token) permettant :
- de proposer des add-ons issus de
user_rates - filtrés par compatibilité
user_rate_session_types/sessions.user_session_type_id - sélectionnables par le client avec preview pricing
- appliqués au devis (
quote_items) sous forme de lignesADDONtraçables - validés ensuite via acceptation de la proposal (et du devis)
État actuel (février 2026)
✅ Implémenté (Phase v3: builder dédié + email système + UX publique premium)
- Builder Proposal dédié (
/quotes/:quoteId/proposal)- stepper back-office (Story & Cover / Add-ons / Public Preview / Share & Send)
- slug éditable (avec suggestion + unicité backend)
- cover upload en dropzone (preview + suppression overlay) avec compression image côté client
- mode read-only complet si devis
ACCEPTED - bouton retour vers le devis
- Email Proposal système
- template système
new-proposal(FR/EN) - endpoints
preview-email/send-emaildédiés proposal - variables
{proposal_link}/{proposalLink}(+ alias FR) - envoi Proposal marque le devis
SENT - migration
0188_add_new_proposal_default_email_template
- template système
- Proposal public premium
/proposal/:token|:slug- expérience immersive full-screen en étapes
- navigation clavier
← / →+ stepper bas de page - slug public (lecture sans login) + fallback token sécurisé
- auto-update sélection + auto-apply au devis (moins de clics client)
- verrouillage lecture seule si devis/proposal déjà confirmé
- étape
Confirmmasquée en navigation quand proposal déjà acceptée - invitation compte client en fin de flow (auto + resend manuel)
- Custom proposal items (hors catalogue tarifs)
- ajoutables/supprimables/réordonnables dans le builder add-ons
- support complet public selection/apply/quote lines
- fix backend JSONB + IDs
custom-*+ pricing TTC-only - option
allowQty(quantité éditable ou forcée à1)
- Proposal panel sur fiche devis
- colonne droite sticky
- statut Proposal dans le bloc (
Draft,Shared,Read-only) - CTA simplifié (
Open proposal builder, lien public slug-first) - blocage si devis non prêt (session type + au moins un item réel requis)
- Proposal defaults (user settings)
- defaults globaux (cover, intro label, templates titre/sous-titre, flow flags)
- préremplissage builder via variables (
{{contactNames}},{{sessionDate}}, etc.) - migration
0189_add_proposal_defaults_to_user_settings - defaults de
Proposal story section(titre + texte) en settings - migration
0190_add_proposal_story_defaults_to_user_settings
✅ Implémenté (socle backend + route publique MVP)
- Migration DB 0187 :
- extensions
user_rates(flags add-on) - extension
quote_items(ADDON,user_rate_id,line_origin,line_metadata) - nouvelles tables
proposal_* quote_revisions
- extensions
- Changelog Liquibase mis à jour (
infra/liquibase/changelog-master.yaml) - Backend
user-rates:- nouveaux champs add-on supportés dans CRUD
- endpoint
GET /api/user-rates/addons-catalog
- Backend
proposals(module Nest) :GET/PUT /api/proposals/:quoteId/upsellsPOST /api/proposals/:quoteId/linkGET /api/public/proposals/:tokenPUT /api/public/proposals/:token/selectionPOST /api/public/proposals/:token/apply-selectionPOST /api/public/proposals/:token/accept
- Quote item patch/recalc helpers :
replaceUpsellAddonItems(...)recalculateQuoteAmountFromDbItems(...)
- OpenAPI :
QuoteItemTypeinclutADDON- schémas
quote_items/user_ratesenrichis - schémas/paths
proposals+user-rates/addons-catalog - types backend/frontend régénérés (
openapi:generate)
- Frontend public MVP :
- route
/proposal/$token - hooks
public-proposals - page MVP (sélection / apply / accept)
- route
- Builder back-office (MVP+) :
- panel Proposal/Add-ons dans
QuoteViewPage - configuration add-ons (offer/recommended/defaultQty/badge)
- ordre persistant des add-ons (drag/drop + move up/down fallback, sauvegardé via ordre JSON des offers)
- génération / copie du lien public proposal
- panel Proposal/Add-ons dans
✅ Correctifs importants livrés (post-implémentation)
- Résolution publique Proposal accessible sans login (
/proposal/*déclaré comme route publique frontend) - Résolution publique par slug ou token côté backend
GET /api/public/proposals/:token|slugreste accessible en lecture même si devis accepté (mutations bloquées seulement)- Validation publique
selectioncompatible IDs custom (custom-*) updatePublicSelection/applySelectionfiltrent les IDs non-UUID avant requêtes SQLuser_ratesproposal_upsell_offers/proposal_selections/presentationssérialisés explicitement enjsonb(fixinvalid input syntax for type json)EmailTemplatesServicefix récursion sur auto-création template système absent (email_new-proposal)- Emails Proposal (preview/send + confirmation après acceptation) utilisent l’URL slug en priorité si configurée
- Expiration des liens Proposal alignée sur
quote.valid_until(fin de journée pour les dates sans heure) Share & Send: étatLink active/expired, régénération lien, édition date de validité du devis (+14/+30 jours)- Bloc URL du builder basé sur le slug sauvegardé (plus de confusion avec le champ local non sauvegardé)
- Total footer public proposal aligné avec le total courant configuré (et non ancien devis)
- Auto-refetch supprimé sur update/apply selection pour éviter flicker/reload du background cover
- Auto-update + auto-apply publics (add-ons) avec blocage temporaire de la navigation et des champs pendant recalcul
- Step
Review: correction des montants (Current totalbase +Optional additions totalaprès apply) - Step
FINALprotégé (non affiché tant que la quote est seulementSENT) - Step
Confirmenrichi + wording simplifié ;Overviewallégée (blocs dashboard retirés) - Guard builder Proposal: devis non prêt => blocage frontend + backend (session type + items requis)
- Fallback
sessionTypeIddepuis lignequote_items.item_type = SESSION_TYPEsi session liée incomplète - Fallback
contract_template_iden édition de devis depuisdefault_contract_template_iddu SessionType (session liée ou ligneSESSION_TYPE) - Upload cover Proposal : normalisation multipart booléens (
usePublicUrl) + couverture stable viacoverImageKey - Emails : footer Aaperture cliquable (wrappers user + système) + signatures texte brut formatées en HTML (retours ligne + liens)
✅ Vérifications effectuées
npm run openapi:generateexécuté après ajout des schemas/paths Proposalnpm run type-check --prefix backend(OK)- lint backend ciblé sur
src/proposals/*et DTOs (OK) - lint frontend ciblé Proposal / Quote panel / Builder / public proposal (OK)
- OpenAPI : le paramètre de path
{token}des endpoints/public/proposals/{token}est documenté comme « token ou slug » (résolution côté API). - Tests backend ciblés Proposal (OK):
backend/src/__tests__/proposals/proposals-pricing.service.spec.tsbackend/src/__tests__/proposals/proposals-apply.service.spec.tsbackend/src/__tests__/proposals/proposals-email.service.spec.ts(preview + send proposal email, planification, contact sans email, quote not found)- couvertures actuelles:
acceptrefuseDIRTYavant transactionacceptidempotent quand proposal déjà acceptée / quote déjàACCEPTEDacceptenvoie l’invitation compte client quandautoInviteAfterAcceptactivé ; skippe quand désactivéapplySelectionorchestre patch add-ons, recalcul,quote_revisions, et passe la sélection enAPPLIEDapplySelectionstable sur double exécution avec la même sélection (idempotence pratique)sendClientPortalInvitation: refus si proposal non acceptée ; resend si invitation déjà en attente ; SKIPPED si contact sans email ; ALREADY_HAS_ACCOUNT si compte existant
- Scénario E2E Playwright ajouté (exécution conditionnelle via
E2E_PROPOSAL_TOKEN):frontend/e2e/public-proposal.smoke.spec.ts
- Scénario E2E Playwright full-flow ajouté (seed via APIs auth, nécessite
E2E_EMAIL/E2E_PASSWORD):frontend/e2e/public-proposal.full-flow.spec.ts
- Scripts npm dédiés Proposal E2E:
npm run test:e2e:proposalnpm run test:e2e:proposal:smokenpm run test:e2e:proposal:full-flow
- GitLab CI:
- job dédié
test:e2e:frontend:proposalajouté (full-flow Proposal) - auto si
E2E_BASE_URL+E2E_EMAIL+E2E_PASSWORD, manuel sinon en MR
- job dédié
- Factorisation
accept quotepartagée livrée:backend/src/quotes-invoices/quote-client-actions.service.ts- utilisée par
client-accessetproposals
QA UX Proposal (checklist)
- Design tokens : écran « builder unavailable » utilise
min-h-[var(--size-empty-state-min-h)](token--size-empty-state-min-h: 40dvhdansdesign-tokens.css). - États read-only / confirmé : page publique proposal en mode confirmé a
aria-readonly="true"etaria-label(cléproposals.public.aria.confirmedReadOnly) sur la section principale pour les lecteurs d’écran. - Copy : statuts Proposal dans le panel devis (Draft / Shared / Read-only) et messages publics (Overview, Options, Review, Confirm, Portal) en EN/FR via i18n.
- Copie du lien : builder et panel Proposal (fiche devis) utilisent un fallback
document.execCommand("copy")lorsquenavigator.clipboard.writeTextéchoue (contexte non sécurisé, permission), pour éviter « Unable to copy link » en HTTP ou environnements restreints. - Mobile : à valider manuellement (touch targets, stepper, boutons Apply/Accept, step FINAL).
⚠️ Hors scope (ou restant partiel)
- UI premium/luxe finale polish (copy/visual QA fine, animations avancées)
🚧 Restant (phase suivante)
Tests backend invitation compte client (auto/retry) en fin de flow proposal— complété :proposals-apply.service.spec.tscouvre auto-invite à l’accept (activé/désactivé), retry (resend when pending), refus avant accept, contact sans email (SKIPPED), contact déjà compte (ALREADY_HAS_ACCOUNT).- E2E builder dédié (
/quotes/:quoteId/proposal) + envoi email Proposal depuis le builder — ajouté : scénario danspublic-proposal.full-flow.spec.ts(step Share & Send + Send Proposal + vérif statut SENT). - QA UX premium (copy, alignment, states confirmed/read-only, mobile) — démarrage : token
--size-empty-state-min-hpour écran builder indisponible, ariaaria-readonly+aria-labelsur la page publique en mode confirmé (lecture seule), clés i18nproposals.public.aria.confirmedReadOnly(EN/FR). - Suivi post-cut du legacy
/client(monitoring, comms, nettoyage résiduel)
Schéma métier (source de vérité)
Tarifs / Add-ons
- Tarifs =
user_rates - Compatibilité
SessionType=user_rate_session_types - Champs add-on:
is_addonis_activeaddon_display_orderaddon_cover_image_url(optionnel)addon_badge(optionnel)
Proposal
proposal_presentationsproposal_links(token dédié, chiffré)proposal_upsell_offersproposal_selectionsquote_revisions
Quote lines add-ons
quote_items.item_type = 'ADDON'quote_items.line_origin = 'UPSELL'quote_items.user_rate_id = <user_rates.id>quote_items.line_metadatapour provenance (proposalLinkId,tariffId, etc.)
Endpoints (implémentés)
Auth user
GET /api/user-rates/addons-catalog?quoteId=...|sessionTypeId=...GET /api/proposals/:quoteId/upsellsPUT /api/proposals/:quoteId/upsellsPOST /api/proposals/:quoteId/linkGET /api/proposals/:quoteId/builderPUT /api/proposals/:quoteId/presentationPOST /api/proposals/:quoteId/preview-emailPOST /api/proposals/:quoteId/send-email
Public token
GET /api/public/proposals/:tokenPUT /api/public/proposals/:token/selectionPOST /api/public/proposals/:token/apply-selectionPOST /api/public/proposals/:token/acceptPOST /api/public/proposals/:token/send-client-portal-invitation
OpenAPI (source de vérité)
Components
openapi/components/proposals.yamlopenapi/components/quotes-invoices.yamlopenapi/components/user-rates.yamlopenapi/components/schemas.yaml
Paths
openapi/paths/proposals.quoteId.upsells.yamlopenapi/paths/proposals.quoteId.link.yamlopenapi/paths/public.proposals.token.yamlopenapi/paths/public.proposals.token.selection.yamlopenapi/paths/public.proposals.token.apply-selection.yamlopenapi/paths/public.proposals.token.accept.yamlopenapi/paths/public.proposals.token.send-client-portal-invitation.yamlopenapi/paths/proposals.quoteId.builder.yamlopenapi/paths/proposals.quoteId.presentation.yamlopenapi/paths/proposals.quoteId.preview-email.yamlopenapi/paths/proposals.quoteId.send-email.yamlopenapi/paths/user-rates.addons-catalog.yaml
Règle de maintenance
- Modifier la spec OpenAPI avant les types générés, puis exécuter
npm run openapi:generate - Ne pas patcher
backend/src/_generated/types.gen.ts/frontend/src/_generated/types.gen.tsà la main
Règles importantes (actuelles)
- Validation server-side des
tariffIdcontre les offres configurées - Preview pricing calculé côté serveur
acceptrefuse l’étatDIRTY(sélection non appliquée)acceptmarque aussi le devisquotes.status = ACCEPTEDapply-selectionremplace uniquement les lignes add-on upsell (ADDON + UPSELL)selection/apply-selectionpublics refusés si devis déjà accepté (proposal en lecture seule / read-only)GET public proposalreste accessible en lecture même après acceptation- Builder Proposal interdit si devis non prêt (session type + item réel requis)
Fichiers clés (implémentation)
Backend
backend/src/proposals/proposals.module.tsbackend/src/proposals/proposals.service.tsbackend/src/proposals/proposals-apply.service.tsbackend/src/proposals/proposals-token.service.tsbackend/src/proposals/proposals-pricing.service.tsbackend/src/proposals/proposals-repository.tsbackend/src/proposals/proposals-email.service.tsbackend/src/user-rates/user-rates.controller.tsbackend/src/user-rates/user-rates-crud.service.tsbackend/src/quotes-invoices/quote-client-actions.service.tsbackend/src/quotes-invoices/quotes-invoices-items.service.tsinfra/liquibase/changes/0187_create_proposals_v2_and_quote_addons/up.sqlinfra/liquibase/changes/0188_add_new_proposal_default_email_template/up.sqlinfra/liquibase/changes/0189_add_proposal_defaults_to_user_settings/up.sqlinfra/liquibase/changes/0190_add_proposal_story_defaults_to_user_settings/up.sql
OpenAPI
openapi/components/proposals.yamlopenapi/components/quotes-invoices.yamlopenapi/components/user-rates.yamlopenapi/paths/proposals.quoteId.upsells.yamlopenapi/paths/public.proposals.token.yamlopenapi/index.yaml
Frontend (MVP → v3)
frontend/src/client/public-proposals/usePublicProposal.tsfrontend/src/client/proposals/useProposals.tsfrontend/src/pages/public-proposal/PublicProposalPage.tsxfrontend/src/pages/proposals/ProposalBuilderPage/ProposalBuilderPage.tsxfrontend/src/pages/proposals/ProposalBuilderPage/components/SendProposalEmailModal.tsxfrontend/src/routes/public.routes.tsxfrontend/src/router.tsx
Plan de rollout (mis à jour)
- ✅ Schéma DB + types + OpenAPI source
- ✅ Backend
user-ratesadd-ons catalog + flags - ✅ Backend
proposals(auth + public token flow) - ✅ Route publique frontend
/proposal/:token(MVP) - ✅ Builder back-office dédié Proposal (v3)
- 🚧 Tests backend + E2E (v3 email/invitation à compléter)
- 🚧 UI premium/luxe (polish final)
- ✅ Migration/suppression legacy
/client(hard cut frontend) - ✅ Suppression exposition
/api/client-access/*(controller legacy retiré)
Backlog immédiat (prochain PR recommandé)
- Tests backend Proposal v3 (
preview-email,send-email,send-client-portal-invitation, paths confirmés/locked) - E2E builder Proposal dédié + envoi email (template
new-proposal) - QA UX premium finale (mobile/desktop, states locked/accepted, alignements/actions)
- Stabiliser l’environnement CI E2E (données/stack) pour réduire les exécutions
allow_failure
Commandes utiles
- Générer types depuis OpenAPI :
npm run openapi:generate
- Type-check backend :
npm run type-check --prefix backend
- Lint ciblé proposal backend :
cd backend && npx eslint src/proposals/*.ts src/proposals/dto/*.ts --max-warnings=0
Notes de maintenance
- Ne pas modifier
backend/src/_generated/types.gen.tsoufrontend/src/_generated/types.gen.tsà la main sans patcher la source OpenAPI correspondante. - Toute évolution des endpoints
proposalsdoit être répercutée dans :openapi/components/proposals.yamlopenapi/paths/public.proposals.*.yamlopenapi/paths/proposals.*.yaml
- Le cut legacy
/client//api/client-access/*est désormais non rétrocompatible; les liens publics historiques peuvent échouer.