Compare commits
51 Commits
7c80c391f1
...
461ab9bcd9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
461ab9bcd9 | ||
|
|
ca95dde9b3 | ||
|
|
5c7dbc2eba | ||
|
|
0a3b8523ef | ||
|
|
07712da774 | ||
|
|
149f60dbb0 | ||
|
|
a136c54501 | ||
|
|
05ad3fa5cf | ||
|
|
24cbf35902 | ||
|
|
9e531e32a9 | ||
|
|
8742cabebf | ||
|
|
a790455ae1 | ||
|
|
b8dec6d494 | ||
|
|
5e41e2a9fa | ||
|
|
c4486d9e5e | ||
|
|
554ae4ba4a | ||
|
|
691b5fd09f | ||
|
|
fc66d80f56 | ||
|
|
01f3edcf08 | ||
|
|
f1a9549b01 | ||
|
|
299f7beb63 | ||
|
|
0e8d0f3853 | ||
|
|
cfa302ce9a | ||
|
|
94263c6447 | ||
|
|
a6b35dfe7a | ||
|
|
19dd71bd93 | ||
|
|
57e1d0d0be | ||
|
|
704f472729 | ||
|
|
5d3408fafa | ||
|
|
c7714e3e8a | ||
|
|
27cfa9ac13 | ||
|
|
005af557c2 | ||
|
|
692b514fe9 | ||
|
|
b6006ad1f7 | ||
|
|
1d3b6a3f8f | ||
|
|
eeb4ce25b8 | ||
|
|
274f2a8270 | ||
|
|
a8c7ab539a | ||
|
|
4a6c778e7c | ||
|
|
c52f46468f | ||
|
|
8cec9d2f33 | ||
|
|
16120ed3e0 | ||
|
|
f34cc97327 | ||
|
|
6de2711aa8 | ||
|
|
cfd3680bb4 | ||
|
|
965a92da8f | ||
|
|
86dae64eb4 | ||
|
|
b5b67056aa | ||
|
|
14d0e982e9 | ||
|
|
332bf0bcda | ||
|
|
8d3bab6a89 |
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpm -F api typecheck)",
|
||||
"Bash(pnpm -F @rubis/web typecheck)"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
90
.gitea/workflows/deploy-app.yml
Normal file
90
.gitea/workflows/deploy-app.yml
Normal file
@ -0,0 +1,90 @@
|
||||
name: Build & Deploy App
|
||||
|
||||
# Workflow pour l'app SaaS (apps/api AdonisJS + apps/web React) déployée
|
||||
# sur app.rubis.arthurbarre.fr. Image distincte de la landing.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'packages/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'turbo.json'
|
||||
- 'Dockerfile.app'
|
||||
- 'k3s/app/**'
|
||||
- '.gitea/workflows/deploy-app.yml'
|
||||
|
||||
env:
|
||||
REGISTRY: git.arthurbarre.fr
|
||||
IMAGE: ordinarthur/rubis-app
|
||||
NAMESPACE: rubis
|
||||
DEPLOYMENT: rubis-app
|
||||
CONTAINER: app
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ordinarthur
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push app image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.app
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:cache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:cache,mode=max
|
||||
|
||||
- name: Install kubectl
|
||||
run: |
|
||||
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
mv kubectl /usr/local/bin/
|
||||
|
||||
- name: Deploy to K3s
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
kubectl apply -f k3s/namespace.yml
|
||||
|
||||
# Idempotent : on (re)pose le pull secret du registry Gitea + le
|
||||
# secret applicatif n'est PAS recréé ici (créé manuellement au
|
||||
# premier deploy via kubectl, contient des creds qui ne
|
||||
# transitent jamais par le CI).
|
||||
kubectl -n $NAMESPACE create secret docker-registry gitea-registry \
|
||||
--docker-server=$REGISTRY \
|
||||
--docker-username=ordinarthur \
|
||||
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# Apply Redis + app manifests (idempotent)
|
||||
kubectl apply -f k3s/app/
|
||||
|
||||
# Pin l'image avec le sha du commit pour rolling update propre.
|
||||
# Le init-container migrate utilise la même image et tourne avant
|
||||
# le serveur — migrations idempotentes via ace migration:run.
|
||||
kubectl -n $NAMESPACE set image deployment/$DEPLOYMENT \
|
||||
$CONTAINER=$REGISTRY/$IMAGE:${{ github.sha }}
|
||||
|
||||
# Patch aussi le init container (même image)
|
||||
kubectl -n $NAMESPACE patch deployment $DEPLOYMENT \
|
||||
--type='json' \
|
||||
-p="[{\"op\":\"replace\",\"path\":\"/spec/template/spec/initContainers/0/image\",\"value\":\"$REGISTRY/$IMAGE:${{ github.sha }}\"}]"
|
||||
|
||||
kubectl -n $NAMESPACE rollout status deployment/$DEPLOYMENT --timeout=300s
|
||||
@ -1,8 +1,17 @@
|
||||
name: Build & Deploy
|
||||
name: Build & Deploy Landing
|
||||
|
||||
# Workflow pour la landing static (rubis.arthurbarre.fr).
|
||||
# L'app SaaS (apps/api + apps/web) a son propre workflow : deploy-app.yml.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'landing/**'
|
||||
- 'Dockerfile'
|
||||
- 'k3s/namespace.yml'
|
||||
- 'k3s/deployment.yml'
|
||||
- 'k3s/service.yml'
|
||||
- '.gitea/workflows/deploy.yml'
|
||||
|
||||
env:
|
||||
REGISTRY: git.arthurbarre.fr
|
||||
|
||||
29
.gitignore
vendored
29
.gitignore
vendored
@ -1,4 +1,33 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
assets/test-invoices/
|
||||
|
||||
# Env files (never commit secrets)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build artefacts
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Tooling caches
|
||||
.turbo/
|
||||
.cache/
|
||||
coverage/
|
||||
.eslintcache
|
||||
|
||||
# Adonis generated types (regenerated from API source)
|
||||
apps/api/.adonisjs/
|
||||
|
||||
# Generated by TanStack Router
|
||||
apps/web/src/routeTree.gen.ts
|
||||
|
||||
# Generated by MSW (vendored worker)
|
||||
apps/web/public/mockServiceWorker.js
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
4
.lintstagedrc.json
Normal file
4
.lintstagedrc.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.{ts,tsx,js,jsx}": ["prettier --write", "eslint --fix"],
|
||||
"*.{json,md,css,yml,yaml}": ["prettier --write"]
|
||||
}
|
||||
10
.prettierignore
Normal file
10
.prettierignore
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.turbo
|
||||
.adonisjs
|
||||
coverage
|
||||
pnpm-lock.yaml
|
||||
landing/index.html
|
||||
**/routeTree.gen.ts
|
||||
**/*.gen.ts
|
||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
169
AGENTS.md
Normal file
169
AGENTS.md
Normal file
@ -0,0 +1,169 @@
|
||||
# Rubis Sur l'Ongle
|
||||
|
||||
> **Le SaaS de relance de factures impayées pour TPE-PME françaises.** Drag-and-drop, OCR, plans de relance automatiques. 1 rubis = 10 minutes libérées.
|
||||
|
||||
Ce fichier est le contexte top-level. Il est court, dense, scannable. Pour les détails, voir `/docs/`.
|
||||
|
||||
---
|
||||
|
||||
## En une phrase
|
||||
|
||||
Vos factures se relancent toutes seules pendant que vous travaillez.
|
||||
|
||||
## Cible
|
||||
|
||||
TPE-PME françaises, 5 à 50 salariés, qui émettent 10 à 200 factures par mois, sans crédit manager dédié. Le décideur teste lui-même le produit (pas de cycle de vente long).
|
||||
|
||||
## Promesse de valeur
|
||||
|
||||
- **5 heures par semaine récupérées** (benchmark : 8h → <3h après automatisation).
|
||||
- **Tonalité émotionnelle** : on vend du temps libéré, pas de la trésorerie. Le rubis gagné est la métrique-héros, pas le DSO.
|
||||
- **2 à 3 clics maximum** pour lancer une relance sur une nouvelle facture.
|
||||
|
||||
## Principes produit (toujours valides)
|
||||
|
||||
1. **3 clics maximum** pour lancer une relance sur une facture neuve. Idéalement 2 si bien configuré.
|
||||
2. **Mobile et desktop** — la photo de facture depuis le téléphone est un usage clé.
|
||||
3. **Pure-player relance** — on ne fait pas CRM, pas facturation, pas comptabilité. On fait une chose et on la fait bien.
|
||||
4. **Respectueux du client final** — le ton monte avec le retard, jamais avant. Pas d'agressivité par défaut.
|
||||
5. **Le rubis est une vraie devise produit** — 1 rubis = 10 min libérées. La gamification doit être tangible et défendable.
|
||||
|
||||
## Identité de marque (TLDR)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Logo** | Direction A — gem facetté géométrique. Le ◆ est un symbole produit autant qu'un logo. |
|
||||
| **Couleur primaire** | `#9F1239` — rubis profond légèrement violacé. *Anti-Coca-Cola.* |
|
||||
| **Couleur secondaires** | `#771328` (deep), `#C9415C` (light), `#FBE4EA` (glow) |
|
||||
| **Neutres** | Crème `#FAF7F2`, encre chaude `#1A1410`. Jamais de blanc pur, jamais de noir pur. |
|
||||
| **Typo display** | Bricolage Grotesque (500–800), Google Fonts |
|
||||
| **Typo body** | Inter (400–700), Google Fonts |
|
||||
| **Icônes** | Lucide (regular weight) |
|
||||
| **Pas de** | or, bleu, vert, violet, emojis joaillerie 💎💰, mot "recouvrement" en com publique |
|
||||
|
||||
Voir `/docs/marque.md` pour la référence complète et `/brand-identity.html` pour la présentation visuelle (note : la mention de l'or accent dans ce fichier est obsolète, à ignorer).
|
||||
|
||||
## Voix
|
||||
|
||||
Direct, concret, chaleureux, précis, empathique. *On parle comme un bon associé, pas comme une DAF.*
|
||||
|
||||
- ✓ "Vos factures relancées toutes seules."
|
||||
- ✗ "Optimisez votre processus de recouvrement amiable."
|
||||
|
||||
## Glossaire
|
||||
|
||||
- **Rubis** : unité de gamification. **1 rubis = 10 minutes libérées** = 1 relance qu'on n'a pas eu à faire à la main.
|
||||
- **Plan de relance** : cadence d'emails automatisés (ex. J+3, J+10, J+20). Chaque facture est associée à un plan.
|
||||
- **Étape** : un email programmé dans un plan (ex. "J+10 — relance ferme").
|
||||
- **Check-in** : email envoyé **à l'utilisateur** (pas au client) pour confirmer si une facture a été payée avant l'envoi de la prochaine relance. Remplace l'intégration banking en V1.
|
||||
- **Mise en demeure** : étape ferme du plan. **Toujours sous validation manuelle** via modale de confirmation, jamais auto.
|
||||
- **DSO** : Days Sales Outstanding. Métrique secondaire dans l'app, jamais dans la com publique.
|
||||
- **LME** : loi de modernisation de l'économie (2008). Plafonne les délais de paiement à 60 jours (ou 45 jours fin de mois). Sanctions DGCCRF jusqu'à 2 M€.
|
||||
|
||||
## Périmètre V1
|
||||
|
||||
### IN
|
||||
|
||||
- Auth email/password + Google SSO
|
||||
- Onboarding 3 étapes (compte, entreprise, signature email)
|
||||
- Upload drag-and-drop + OCR factures (PDF, PNG, JPG)
|
||||
- Saisie manuelle (fallback)
|
||||
- Bibliothèque de plans (4 plans fournis par défaut)
|
||||
- Éditeur de plan (cadence + templates email avec variables)
|
||||
- Check-in email à l'utilisateur (cadence configurable) → confirme si payé → relance ou stop
|
||||
- Dashboard avec compteur rubis + KPIs (à relancer, encaissé, DSO)
|
||||
- Liste filtrable des factures
|
||||
- Détail facture avec timeline des relances
|
||||
- App mobile (web responsive)
|
||||
|
||||
### OUT (V2 ou plus tard)
|
||||
|
||||
- **SMS** — uniquement plan le plus cher en V2
|
||||
- **Multi-utilisateurs** — uniquement plans payants en V2
|
||||
- **Intégration banking / réconciliation auto** — l'architecture V1 doit l'anticiper, mais l'implémentation est V2+
|
||||
- Multi-langues, multi-devises (FR/EUR only en V1)
|
||||
- Intégration ERP/comptable (Sage, Pennylane, Quickbooks)
|
||||
|
||||
## Pricing (esquisse, à valider)
|
||||
|
||||
| Plan | Prix | Limite |
|
||||
|---|---|---|
|
||||
| **Free** | 0 € | 5 factures actives en relance, 1 utilisateur |
|
||||
| **Pro** | 19 €/mois | Factures illimitées, OCR illimité, 1 utilisateur |
|
||||
| **Business** | 49 €/mois | + multi-utilisateurs, + branding email, + SMS (V2) |
|
||||
|
||||
Argument de vente : *"moins cher qu'une heure de votre temps mensuel"*.
|
||||
|
||||
## Décisions clés validées (résumé)
|
||||
|
||||
Voir `/docs/decisions.md` pour le log complet avec rationale.
|
||||
|
||||
- 1 rubis = 10 minutes libérées
|
||||
- Logo direction A (gem facetté), wordmark à monter en parallèle plus tard
|
||||
- Palette rubis chaude, sans or, sans bleu
|
||||
- Typo Bricolage Grotesque + Inter
|
||||
- Iconographie Lucide
|
||||
- Mise en demeure : validation manuelle obligatoire (modale)
|
||||
- SMS et multi-users : V2 + plans payants seulement
|
||||
- Banking intégration : pas en V1, remplacée par check-in emails
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Couche | Choix | Source |
|
||||
|---|---|---|
|
||||
| Backend | **AdonisJS v7** (TS, MVC, Lucid ORM, auth & jobs intégrés) | ADR-014 |
|
||||
| Frontend | **React + Vite** | ADR-014 |
|
||||
| Routing client | **TanStack Router** | ADR-014 |
|
||||
| State serveur | **TanStack Query** | ADR-014 |
|
||||
| Base de données | **PostgreSQL** | ADR-014 |
|
||||
| Hosting | **Proxmox + K3s** (perso) | ADR-014 |
|
||||
| OCR provider | à benchmarker | ADR-020 (en attente) |
|
||||
| Email outbound | à benchmarker | ADR-021 (en attente) |
|
||||
|
||||
**Architecture** : monorepo (`apps/api` + `apps/web` + `packages/shared`), API REST AdonisJS Bearer-auth, SPA React/Vite séparé, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`.
|
||||
|
||||
**Décisions cadres** : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG Proxmox existant), ADR-017 (Bearer tokens), ADR-018 (MinIO existant).
|
||||
|
||||
### Conventions techniques (cross-cutting)
|
||||
|
||||
- **Identifiants : UUID partout.** Toutes les PK et FK applicatives sont des UUID v4 (PG `uuid` avec default `gen_random_uuid()`). **Jamais d'`increments`/`serial`**, même pour les tables internes (auth tokens, sessions, refresh tokens, etc.). Les UUID protègent de l'énumération, simplifient la fédération multi-tenant et évitent les fuites de volumes par incrément. Les transformers exposent les UUID directement en string — pas de cast nécessaire.
|
||||
|
||||
## Documents associés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `/AGENTS.md` (ce fichier) | Contexte top-level, toujours en tête |
|
||||
| `/landing/index.html` | Landing page brand-applied, déployée (waitlist V1) |
|
||||
| `/landing/favicon.{svg,ico,png}` | Set complet de favicons + apple-touch-icon |
|
||||
| `/landing/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) |
|
||||
| `/landing/assets/logo.png` | Logo Rubis original (généré, source pour les favicons) |
|
||||
| `/docs/produit.md` | Spec produit détaillée (features, flows, IN/OUT V1) |
|
||||
| `/docs/marque.md` | Référence marque écrite (palette, typo, voix, do/don't) |
|
||||
| `/docs/decisions.md` | Log de décisions avec rationale (format ADR-light) |
|
||||
| `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP |
|
||||
| `/docs/brand-identity.html` | Présentation visuelle de la marque (4 logos, applications) |
|
||||
| `/docs/munitions-marketing.md` | Stats marché, concurrents, positionnement, copy |
|
||||
| `/docs/tech/architecture.md` | Architecture technique : composants, flux, topologie, conventions |
|
||||
| `/Dockerfile` | Build nginx-alpine servant `/landing/` sur port 80 |
|
||||
| `/k3s/` | Manifests Kubernetes (namespace, deployment, service) |
|
||||
| `/.Codex/deploy-memory.md` | Procédure de déploiement (Gitea CI ou manuel) |
|
||||
|
||||
## Déploiement
|
||||
|
||||
- **Image** : `git.arthurbarre.fr/ordinarthur/rubis:latest`
|
||||
- **Domaine actuel** (temporaire) : https://rubis.arthurbarre.fr
|
||||
- **Build** : `COPY landing/` → nginx servi sur port 80
|
||||
- Voir `.Codex/deploy-memory.md` pour la procédure complète.
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
- **Stack technique app produit** à formaliser (la landing tourne en static nginx, mais le SaaS lui-même reste à scoper)
|
||||
- **Conversion 1 rubis = 10 min** validée mais à confirmer en user testing après MVP
|
||||
- **Wordmark "rubis" avec gem-i** (direction C) à monter en complément du logo A à un moment
|
||||
- **Provider OCR** à benchmarker (Mindee, Document AI, Textract, Tesseract)
|
||||
- **Endpoint waitlist** à câbler dans `/landing/index.html` (Resend, Formspree, ou API perso)
|
||||
- **Domaine définitif** à acheter (le sous-domaine actuel est temporaire)
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : 2026-05-05 · Maintenu par Arthur + Codex.*
|
||||
22
CLAUDE.md
22
CLAUDE.md
@ -109,9 +109,24 @@ Voir `/docs/decisions.md` pour le log complet avec rationale.
|
||||
|
||||
## Stack technique
|
||||
|
||||
À confirmer avec Arthur. Stack choisie mais pas encore documentée. *À remplir lors de la prochaine session technique.*
|
||||
| Couche | Choix | Source |
|
||||
|---|---|---|
|
||||
| Backend | **AdonisJS v7** (TS, MVC, Lucid ORM, auth & jobs intégrés) | ADR-014 |
|
||||
| Frontend | **React + Vite** | ADR-014 |
|
||||
| Routing client | **TanStack Router** | ADR-014 |
|
||||
| State serveur | **TanStack Query** | ADR-014 |
|
||||
| Base de données | **PostgreSQL** | ADR-014 |
|
||||
| Hosting | **Proxmox + K3s** (perso) | ADR-014 |
|
||||
| OCR provider | à benchmarker | ADR-020 (en attente) |
|
||||
| Email outbound | à benchmarker | ADR-021 (en attente) |
|
||||
|
||||
Ce qu'on sait : TypeScript, le reste à formaliser (framework, DB, OCR provider, email provider, hosting, jobs).
|
||||
**Architecture** : monorepo (`apps/api` + `apps/web` + `packages/shared`), API REST AdonisJS Bearer-auth, SPA React/Vite séparé, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`.
|
||||
|
||||
**Décisions cadres** : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG Proxmox existant), ADR-017 (Bearer tokens), ADR-018 (MinIO existant).
|
||||
|
||||
### Conventions techniques (cross-cutting)
|
||||
|
||||
- **Identifiants : UUID partout.** Toutes les PK et FK applicatives sont des UUID v4 (PG `uuid` avec default `gen_random_uuid()`). **Jamais d'`increments`/`serial`**, même pour les tables internes (auth tokens, sessions, refresh tokens, etc.). Les UUID protègent de l'énumération, simplifient la fédération multi-tenant et évitent les fuites de volumes par incrément. Les transformers exposent les UUID directement en string — pas de cast nécessaire.
|
||||
|
||||
## Documents associés
|
||||
|
||||
@ -128,6 +143,9 @@ Ce qu'on sait : TypeScript, le reste à formaliser (framework, DB, OCR provider,
|
||||
| `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP |
|
||||
| `/docs/brand-identity.html` | Présentation visuelle de la marque (4 logos, applications) |
|
||||
| `/docs/munitions-marketing.md` | Stats marché, concurrents, positionnement, copy |
|
||||
| `/docs/marketing/playbook.md` | Playbook acquisition premiers clients : ICP, Dream 100, channels, templates outreach |
|
||||
| `/docs/tech/architecture.md` | Architecture technique : composants, flux, topologie, conventions |
|
||||
| `/docs/tech/frontend.md` | Guide d'implémentation frontend (deps, Tailwind, TanStack, Tuyau) |
|
||||
| `/Dockerfile` | Build nginx-alpine servant `/landing/` sur port 80 |
|
||||
| `/k3s/` | Manifests Kubernetes (namespace, deployment, service) |
|
||||
| `/.claude/deploy-memory.md` | Procédure de déploiement (Gitea CI ou manuel) |
|
||||
|
||||
111
Dockerfile.app
Normal file
111
Dockerfile.app
Normal file
@ -0,0 +1,111 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# =============================================================================
|
||||
# Rubis Sur l'Ongle — image production de l'app SaaS (apps/api + apps/web)
|
||||
# Sert app.rubis.arthurbarre.fr. La landing (rubis.arthurbarre.fr) reste sur
|
||||
# une image séparée — Dockerfile à la racine, nginx static.
|
||||
# =============================================================================
|
||||
#
|
||||
# Multi-stage :
|
||||
# - base : node 22 alpine + pnpm + tini
|
||||
# - deps : install workspace deps (cache friendly via manifests d'abord)
|
||||
# - build : build shared, web, api ; copie le SPA dans apps/api/build/public
|
||||
# - runner : copie le repo "pruned" prod, lance node bin/server.js
|
||||
#
|
||||
# Choix architectural : un seul process Node sert l'API ET le SPA static
|
||||
# (via le static middleware AdonisJS + un fallback wildcard pour SPA routing).
|
||||
# Les workers BullMQ tournent dans le même process (cf. start/queue.ts).
|
||||
# =============================================================================
|
||||
|
||||
ARG NODE_VERSION=22.13.1
|
||||
ARG PNPM_VERSION=10.0.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# base — node + pnpm + tini
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION}-alpine AS base
|
||||
|
||||
ARG PNPM_VERSION
|
||||
RUN apk add --no-cache libc6-compat tini && \
|
||||
corepack enable && \
|
||||
corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||
|
||||
WORKDIR /repo
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# deps — install workspace (devDeps inclus, on en a besoin pour les builds)
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS deps
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.base.json ./
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# build — shared → web → api, puis copie du SPA dans le build de l'API
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM deps AS build
|
||||
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY apps/web ./apps/web
|
||||
COPY apps/api ./apps/api
|
||||
|
||||
# Builds :
|
||||
# - @rubis/shared : pas de build (TS source consommé directement via exports).
|
||||
# - Web : on appelle vite build directement (le `tsc -b` du script de prod
|
||||
# fait remonter des erreurs DOM dans @tanstack/router-core sans cache
|
||||
# .tsbuildinfo ; le typecheck est fait en CI séparément).
|
||||
# - API : `node ace build` (canonique AdonisJS V7) — produit apps/api/build
|
||||
# avec compiled JS, package.json runtime, et metaFiles configurés.
|
||||
#
|
||||
# Note : ce build peut planter en cross-compile ARM→amd64 (swc/core), donc
|
||||
# en local sur Mac silicon, builder pour --platform linux/arm64. Le CI
|
||||
# Gitea tourne nativement sur linux/amd64 et n'a pas le problème.
|
||||
RUN pnpm --filter @rubis/web exec vite build && \
|
||||
pnpm --filter @rubis/api build
|
||||
|
||||
# Le SPA static va dans apps/api/build/public/ pour être servi par le static
|
||||
# middleware AdonisJS. AdonisJS ne copie pas public/ par défaut dans build/
|
||||
# (metaFiles vide), on le fait manuellement ici.
|
||||
RUN mkdir -p apps/api/build/public && \
|
||||
cp -r apps/web/dist/. apps/api/build/public/
|
||||
|
||||
# Prune les devDeps. Les symlinks pnpm vers les workspace packages
|
||||
# (@rubis/shared) restent valides car on garde le repo en place.
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --prod --frozen-lockfile=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# runner — runtime minimal, user non-root
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM base AS runner
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S adonis -u 1001
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=3333 \
|
||||
LOG_LEVEL=info
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# On copie tout le repo pruned (node_modules inclus avec les symlinks
|
||||
# workspace). C'est plus gros qu'une image "deploy" pure, mais ça évite
|
||||
# les pièges de résolution workspace pour V1.
|
||||
COPY --from=build --chown=adonis:nodejs /repo /app
|
||||
|
||||
USER adonis
|
||||
|
||||
WORKDIR /app/apps/api
|
||||
|
||||
EXPOSE 3333
|
||||
|
||||
# Healthcheck léger : le serveur HTTP doit répondre 200 sur /api/v1/.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3333/ >/dev/null 2>&1 || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["node", "build/bin/server.js"]
|
||||
22
apps/api/.editorconfig
Normal file
22
apps/api/.editorconfig
Normal file
@ -0,0 +1,22 @@
|
||||
# http://editorconfig.org
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.json]
|
||||
insert_final_newline = unset
|
||||
|
||||
[**.min.js]
|
||||
indent_style = unset
|
||||
insert_final_newline = unset
|
||||
|
||||
[MakeFile]
|
||||
indent_style = space
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
80
apps/api/.env.example
Normal file
80
apps/api/.env.example
Normal file
@ -0,0 +1,80 @@
|
||||
# Node
|
||||
TZ=UTC
|
||||
PORT=3333
|
||||
HOST=0.0.0.0
|
||||
NODE_ENV=development
|
||||
|
||||
# App
|
||||
LOG_LEVEL=info
|
||||
APP_KEY=
|
||||
# APP_URL est l'URL publique (utilisée dans les emails check-in/relance,
|
||||
# les redirects, etc.). Volontairement découplée de HOST : on bind sur
|
||||
# 0.0.0.0 mais on expose `localhost` (en dev) ou le vrai domaine (en prod).
|
||||
APP_URL=http://localhost:3333
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=cookie
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# CORS (configure allowed origins for API access)
|
||||
#--------------------------------------------------------------------
|
||||
# CORS_ORIGIN=http://localhost:5173,http://localhost:3000
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# Database (Postgres via docker-compose.dev.yml)
|
||||
#--------------------------------------------------------------------
|
||||
DB_CONNECTION=postgres
|
||||
PG_HOST=localhost
|
||||
PG_PORT=5433
|
||||
PG_USER=rubis
|
||||
PG_PASSWORD=rubis
|
||||
PG_DB_NAME=rubis_dev
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# Redis (BullMQ + cache)
|
||||
#--------------------------------------------------------------------
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6380
|
||||
REDIS_PASSWORD=
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# Storage (MinIO via S3 driver)
|
||||
#--------------------------------------------------------------------
|
||||
DRIVE_DISK=s3
|
||||
S3_ENDPOINT=http://localhost:9100
|
||||
S3_REGION=fr-par
|
||||
S3_BUCKET=rubis-invoices
|
||||
S3_ACCESS_KEY=rubis
|
||||
S3_SECRET_KEY=rubis-dev-secret
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# Mail (Resend par défaut, Mailpit en fallback dev via MAIL_DRIVER=smtp)
|
||||
#--------------------------------------------------------------------
|
||||
MAIL_FROM_ADDRESS=rubis@arthurbarre.fr
|
||||
MAIL_FROM_NAME=Rubis Sur l'Ongle
|
||||
MAIL_DRIVER=resend
|
||||
RESEND_API_KEY=
|
||||
# Fallback Mailpit (si MAIL_DRIVER=smtp)
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# OCR (Mistral)
|
||||
#--------------------------------------------------------------------
|
||||
OCR_PROVIDER=mistral
|
||||
MISTRAL_API_KEY=
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# Web (URL du SPA, utilisée pour les redirects post-checkin)
|
||||
#--------------------------------------------------------------------
|
||||
WEB_URL=http://localhost:5173
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# Auth (refresh tokens)
|
||||
#--------------------------------------------------------------------
|
||||
ACCESS_TOKEN_TTL_MINUTES=30
|
||||
REFRESH_TOKEN_TTL_DAYS=30
|
||||
COOKIE_DOMAIN=
|
||||
COOKIE_SECURE=false
|
||||
LIMITER_STORE=redis
|
||||
12
apps/api/.env.test
Normal file
12
apps/api/.env.test
Normal file
@ -0,0 +1,12 @@
|
||||
NODE_ENV=test
|
||||
SESSION_DRIVER=memory
|
||||
# Désactive les vraies connexions Redis/MinIO/SMTP pendant les tests.
|
||||
# Les schedulers détectent NODE_ENV=test et skip BullMQ.add.
|
||||
DRIVE_DISK=fs
|
||||
MAIL_DRIVER=smtp
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
OCR_PROVIDER=mock
|
||||
# Utilise la même DB que dev avec global transactions par test (rollback).
|
||||
# Si tu veux une DB séparée : crée `rubis_test` dans Postgres et override
|
||||
# PG_DB_NAME=rubis_test ici.
|
||||
26
apps/api/.gitignore
vendored
Normal file
26
apps/api/.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# Dependencies and AdonisJS build
|
||||
node_modules
|
||||
build
|
||||
tmp/*
|
||||
!tmp/.gitkeep
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
.env.development.local
|
||||
|
||||
# Frontend assets compiled code
|
||||
public/assets
|
||||
|
||||
# Build tools specific
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Editors specific
|
||||
.fleet
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Platform specific
|
||||
.DS_Store
|
||||
3
apps/api/.prettierignore
Normal file
3
apps/api/.prettierignore
Normal file
@ -0,0 +1,3 @@
|
||||
.adonisjs
|
||||
node_modules
|
||||
build
|
||||
27
apps/api/ace.js
Normal file
27
apps/api/ace.js
Normal file
@ -0,0 +1,27 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| JavaScript entrypoint for running ace commands
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD
|
||||
| PROCESS.
|
||||
|
|
||||
| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build
|
||||
|
|
||||
| Since, we cannot run TypeScript source code using "node" binary, we need
|
||||
| a JavaScript entrypoint to run ace commands.
|
||||
|
|
||||
| This file registers the "ts-node/esm" hook with the Node.js module system
|
||||
| and then imports the "bin/console.ts" file.
|
||||
|
|
||||
*/
|
||||
|
||||
/**
|
||||
* Register hook to process TypeScript files using @poppinss/ts-exec
|
||||
*/
|
||||
import '@poppinss/ts-exec'
|
||||
|
||||
/**
|
||||
* Import ace console entrypoint
|
||||
*/
|
||||
await import('./bin/console.js')
|
||||
128
apps/api/adonisrc.ts
Normal file
128
apps/api/adonisrc.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { indexEntities } from '@adonisjs/core'
|
||||
import { defineConfig } from '@adonisjs/core/app'
|
||||
import { generateRegistry } from '@tuyau/core/hooks'
|
||||
import { indexPolicies } from '@adonisjs/bouncer'
|
||||
|
||||
export default defineConfig({
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Experimental flags
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following features will be enabled by default in the next major release
|
||||
| of AdonisJS. You can opt into them today to avoid any breaking changes
|
||||
| during upgrade.
|
||||
|
|
||||
*/
|
||||
experimental: {},
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Commands
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of ace commands to register from packages. The application commands
|
||||
| will be scanned automatically from the "./commands" directory.
|
||||
|
|
||||
*/
|
||||
commands: [
|
||||
() => import('@adonisjs/core/commands'),
|
||||
() => import('@adonisjs/lucid/commands'),
|
||||
() => import('@adonisjs/session/commands'),
|
||||
() => import('@adonisjs/bouncer/commands')
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Service providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of service providers to import and register when booting the
|
||||
| application
|
||||
|
|
||||
*/
|
||||
providers: [
|
||||
() => import('@adonisjs/core/providers/app_provider'),
|
||||
() => import('@adonisjs/core/providers/hash_provider'),
|
||||
{
|
||||
file: () => import('@adonisjs/core/providers/repl_provider'),
|
||||
environment: ['repl', 'test'],
|
||||
},
|
||||
() => import('@adonisjs/core/providers/vinejs_provider'),
|
||||
() => import('@adonisjs/session/session_provider'),
|
||||
() => import('@adonisjs/shield/shield_provider'),
|
||||
() => import('@adonisjs/lucid/database_provider'),
|
||||
() => import('@adonisjs/cors/cors_provider'),
|
||||
() => import('@adonisjs/auth/auth_provider'),
|
||||
() => import('#providers/api_provider'),
|
||||
() => import('@adonisjs/bouncer/bouncer_provider'),
|
||||
() => import('@adonisjs/limiter/limiter_provider'),
|
||||
() => import('@adonisjs/mail/mail_provider'),
|
||||
() => import('@adonisjs/drive/drive_provider'),
|
||||
() => import('@adonisjs/static/static_provider')
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Preloads
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of modules to import before starting the application.
|
||||
|
|
||||
*/
|
||||
preloads: [
|
||||
() => import('#start/routes'),
|
||||
() => import('#start/kernel'),
|
||||
() => import('#start/validator'),
|
||||
() => import('#start/queue'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Tests
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| List of test suites to organize tests by their type. Feel free to remove
|
||||
| and add additional suites.
|
||||
|
|
||||
*/
|
||||
tests: {
|
||||
suites: [
|
||||
{
|
||||
files: ['tests/unit/**/*.spec.{ts,js}'],
|
||||
name: 'unit',
|
||||
timeout: 2000,
|
||||
},
|
||||
{
|
||||
files: ['tests/functional/**/*.spec.{ts,js}'],
|
||||
name: 'functional',
|
||||
timeout: 30000,
|
||||
},
|
||||
],
|
||||
forceExit: false,
|
||||
},
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Metafiles
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| A collection of files you want to copy to the build folder when creating
|
||||
| the production build.
|
||||
|
|
||||
*/
|
||||
metaFiles: [{
|
||||
pattern: 'public/**',
|
||||
reloadServer: false,
|
||||
}],
|
||||
|
||||
hooks: {
|
||||
init: [
|
||||
indexEntities({
|
||||
transformers: { enabled: true },
|
||||
}),
|
||||
generateRegistry(),
|
||||
indexPolicies()
|
||||
],
|
||||
},
|
||||
})
|
||||
23
apps/api/app/abilities/main.ts
Normal file
23
apps/api/app/abilities/main.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bouncer abilities
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may export multiple abilities from this file and pre-register them
|
||||
| when creating the Bouncer instance.
|
||||
|
|
||||
| Pre-registered policies and abilities can be referenced as a string by their
|
||||
| name. Also they are must if want to perform authorization inside Edge
|
||||
| templates.
|
||||
|
|
||||
*/
|
||||
|
||||
import { Bouncer } from '@adonisjs/bouncer'
|
||||
|
||||
/**
|
||||
* Delete the following ability to start from
|
||||
* scratch
|
||||
*/
|
||||
export const editUser = Bouncer.ability(() => {
|
||||
return true
|
||||
})
|
||||
33
apps/api/app/controllers/access_tokens_controller.ts
Normal file
33
apps/api/app/controllers/access_tokens_controller.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import User from '#models/user'
|
||||
import { loginValidator } from '#validators/user'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { emitAuthSession } from '#services/auth_session'
|
||||
import { revokeCurrentRefreshToken } from '#services/refresh_token'
|
||||
|
||||
export default class AccessTokensController {
|
||||
/**
|
||||
* POST /auth/login — vérifie credentials + émet AuthSession.
|
||||
*/
|
||||
async store(ctx: HttpContext) {
|
||||
const { email, password } = await ctx.request.validateUsing(loginValidator)
|
||||
|
||||
const user = await User.verifyCredentials(email, password)
|
||||
const session = await emitAuthSession(user, ctx)
|
||||
return ctx.serialize(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /account/logout — révoque l'access token courant + le refresh
|
||||
* token + clear le cookie.
|
||||
*/
|
||||
async destroy(ctx: HttpContext) {
|
||||
const user = ctx.auth.getUserOrFail()
|
||||
if (user.currentAccessToken) {
|
||||
await User.accessTokens.delete(user, user.currentAccessToken.identifier)
|
||||
}
|
||||
await revokeCurrentRefreshToken(ctx)
|
||||
|
||||
ctx.response.status(204)
|
||||
return null
|
||||
}
|
||||
}
|
||||
53
apps/api/app/controllers/ai_controller.ts
Normal file
53
apps/api/app/controllers/ai_controller.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { generateRelance } from '#services/ai_relance_generator'
|
||||
|
||||
const RELANCE_TONES = ['amical', 'courtois', 'ferme', 'mise_en_demeure'] as const
|
||||
|
||||
const generateRelanceValidator = vine.create({
|
||||
tone: vine.enum(RELANCE_TONES),
|
||||
offsetDays: vine.number().min(-30).max(180),
|
||||
// Brief libre. On accepte vide : Mistral génère alors un message standard
|
||||
// pour la tonalité + timing donnés.
|
||||
prompt: vine.string().maxLength(1000).optional(),
|
||||
// Contexte du plan parent — nom + description, pour cohérence inter-étapes.
|
||||
planName: vine.string().maxLength(80).optional(),
|
||||
planDescription: vine.string().maxLength(500).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Endpoints IA. V1 : uniquement génération de templates de relance pour le
|
||||
* wizard de création de plan custom. Mistral est déjà utilisé pour l'OCR
|
||||
* (cf. mistral_ocr_provider.ts) — on réutilise la même clé API.
|
||||
*/
|
||||
export default class AiController {
|
||||
/**
|
||||
* POST /ai/generate-relance
|
||||
*
|
||||
* Génère subject + body avec des placeholders Mustache prêts à insérer.
|
||||
* L'utilisateur peut régénérer pour avoir une variante.
|
||||
*/
|
||||
async generateRelance({ auth, request, response }: HttpContext) {
|
||||
auth.getUserOrFail() // auth requise
|
||||
const payload = await request.validateUsing(generateRelanceValidator)
|
||||
|
||||
try {
|
||||
const result = await generateRelance({
|
||||
tone: payload.tone,
|
||||
offsetDays: payload.offsetDays,
|
||||
prompt: payload.prompt ?? '',
|
||||
planName: payload.planName,
|
||||
planDescription: payload.planDescription,
|
||||
})
|
||||
return response.json({ data: result })
|
||||
} catch (err) {
|
||||
// On wrap pour passer par le handler global et garder le format
|
||||
// d'erreur uniforme côté front.
|
||||
throw new Exception(
|
||||
err instanceof Error ? err.message : 'Génération IA indisponible',
|
||||
{ status: 502, code: 'ai_generation_failed' }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
142
apps/api/app/controllers/checkin_controller.ts
Normal file
142
apps/api/app/controllers/checkin_controller.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import CheckinTask from '#models/checkin_task'
|
||||
import Invoice from '#models/invoice'
|
||||
import { hashCheckinToken } from '#services/checkin_token'
|
||||
import { recordActivity } from '#services/activity_recorder'
|
||||
import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import env from '#start/env'
|
||||
import { DateTime } from 'luxon'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
const CHECKIN_TTL_HOURS = 24
|
||||
|
||||
/**
|
||||
* Construit l'URL de redirect SPA selon le résultat. Le SPA lit ces
|
||||
* query params pour afficher un toast et router l'utilisateur.
|
||||
*/
|
||||
function spaRedirectUrl(
|
||||
result: 'paid' | 'pending' | 'expired' | 'invalid' | 'already_answered',
|
||||
invoice?: Pick<Invoice, 'id' | 'numero'>
|
||||
): string {
|
||||
const base = env.get('WEB_URL', 'http://localhost:5173')
|
||||
const params = new URLSearchParams({ checkin: result })
|
||||
if (invoice) params.set('invoice', invoice.numero)
|
||||
const path = invoice ? `/factures/${invoice.id}` : '/'
|
||||
return `${base}${path}?${params.toString()}`
|
||||
}
|
||||
|
||||
type ResolvedTask = { task: CheckinTask; invoice: Invoice } | { redirect: string }
|
||||
|
||||
/**
|
||||
* Lookup + validation commune aux deux endpoints (paid / pending).
|
||||
* Retourne soit la task validée soit une URL de redirect d'erreur.
|
||||
*/
|
||||
async function resolveCheckin(token: string): Promise<ResolvedTask> {
|
||||
const hashed = hashCheckinToken(token)
|
||||
const task = await CheckinTask.query().where('token_hash', hashed).first()
|
||||
if (!task) {
|
||||
return { redirect: spaRedirectUrl('invalid') }
|
||||
}
|
||||
|
||||
if (task.status === 'answered') {
|
||||
const inv = await Invoice.find(task.invoiceId)
|
||||
return { redirect: spaRedirectUrl('already_answered', inv ?? undefined) }
|
||||
}
|
||||
|
||||
// Expiration : 24h après l'envoi (sentAt). Tant qu'elle n'a pas été
|
||||
// envoyée, le link n'est pas censé exister côté user — sécurité belt.
|
||||
if (task.sentAt && task.sentAt.plus({ hours: CHECKIN_TTL_HOURS }) < DateTime.now()) {
|
||||
task.status = 'expired'
|
||||
await task.save()
|
||||
return { redirect: spaRedirectUrl('expired') }
|
||||
}
|
||||
|
||||
const invoice = await Invoice.query().where('id', task.invoiceId).preload('client').first()
|
||||
if (!invoice) {
|
||||
return { redirect: spaRedirectUrl('invalid') }
|
||||
}
|
||||
|
||||
return { task, invoice }
|
||||
}
|
||||
|
||||
export default class CheckinController {
|
||||
/**
|
||||
* GET /api/v1/checkin/:token/paid
|
||||
*
|
||||
* L'utilisateur clique "j'ai été payé". On marque la facture payée +
|
||||
* cancel les relances futures + bonus rubis (idempotent avec mark-paid).
|
||||
* Redirect SPA avec `?checkin=paid&invoice=<numero>`.
|
||||
*
|
||||
* Public : pas d'auth Bearer, c'est un lien dans un email.
|
||||
*/
|
||||
async respondPaid({ params, response }: HttpContext) {
|
||||
const result = await resolveCheckin(params.token)
|
||||
if ('redirect' in result) {
|
||||
return response.redirect(result.redirect)
|
||||
}
|
||||
const { task, invoice } = result
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
task.useTransaction(trx)
|
||||
task.status = 'answered'
|
||||
task.answer = 'paid'
|
||||
task.answeredAt = DateTime.now()
|
||||
await task.save()
|
||||
|
||||
// Mark paid (mêmes effets que POST /invoices/:id/mark-paid).
|
||||
if (invoice.status !== 'paid') {
|
||||
invoice.useTransaction(trx)
|
||||
invoice.status = 'paid'
|
||||
invoice.paidAt = DateTime.now()
|
||||
invoice.rubisEarned = invoice.rubisEarned + 1
|
||||
await invoice.save()
|
||||
|
||||
await trx
|
||||
.from('organizations')
|
||||
.where('id', invoice.organizationId)
|
||||
.increment('rubis_count', 1)
|
||||
|
||||
await recordActivity({
|
||||
organizationId: invoice.organizationId,
|
||||
kind: 'invoice_paid',
|
||||
label: `Facture <b>${invoice.numero}</b> marquée encaissée via check-in`,
|
||||
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
|
||||
trx,
|
||||
})
|
||||
|
||||
await cancelFutureRelances(invoice.id, trx)
|
||||
}
|
||||
})
|
||||
|
||||
return response.redirect(spaRedirectUrl('paid', invoice))
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/checkin/:token/pending
|
||||
*
|
||||
* L'utilisateur clique "toujours en attente". On marque la task
|
||||
* answered, puis on programme les relances client.
|
||||
*/
|
||||
async respondPending({ params, response }: HttpContext) {
|
||||
const result = await resolveCheckin(params.token)
|
||||
if ('redirect' in result) {
|
||||
return response.redirect(result.redirect)
|
||||
}
|
||||
const { task, invoice } = result
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (invoice.planId) {
|
||||
invoice.useTransaction(trx)
|
||||
await scheduleRelancesForInvoice(invoice, trx)
|
||||
}
|
||||
|
||||
task.useTransaction(trx)
|
||||
task.status = 'answered'
|
||||
task.answer = 'still_pending'
|
||||
task.answeredAt = DateTime.now()
|
||||
await task.save()
|
||||
})
|
||||
|
||||
return response.redirect(spaRedirectUrl('pending', invoice))
|
||||
}
|
||||
}
|
||||
172
apps/api/app/controllers/clients_controller.ts
Normal file
172
apps/api/app/controllers/clients_controller.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import Client from '#models/client'
|
||||
import ClientTransformer from '#transformers/client_transformer'
|
||||
import { createClientValidator, updateClientValidator } from '#validators/client'
|
||||
import { bulkComputeClientStats } from '#services/client_stats'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
|
||||
/**
|
||||
* Petite cohérence d'identification orgnisation : si l'utilisateur
|
||||
* n'en a pas, on est dans un état illégal V1 — on bloque ferme.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Sérialisation directe (instanciation manuelle du transformer pour
|
||||
* éviter le wrapper Item — utile quand on doit fusionner des stats
|
||||
* computed par-dessus chaque client dans une liste).
|
||||
*/
|
||||
function serializeClient(c: Client) {
|
||||
return new ClientTransformer(c).toObject()
|
||||
}
|
||||
|
||||
export default class ClientsController {
|
||||
/**
|
||||
* GET /clients?withStats=1&q=
|
||||
*
|
||||
* Sans `withStats`, retour à plat (utilisé par le combobox de saisie).
|
||||
* Avec `withStats`, chaque client est enrichi des compteurs de factures
|
||||
* et trié par actionnabilité (retards d'abord, puis activité récente).
|
||||
*/
|
||||
async index({ auth, request, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const withStats = request.input('withStats') === '1'
|
||||
const q = (request.input('q') ?? '').toString().trim().toLowerCase()
|
||||
|
||||
const query = Client.query().where('organization_id', organizationId)
|
||||
|
||||
if (q.length > 0) {
|
||||
query.where((b) => {
|
||||
b.whereILike('name', `%${q}%`).orWhereILike('email', `%${q}%`)
|
||||
})
|
||||
}
|
||||
|
||||
const clients = await query.exec()
|
||||
|
||||
if (!withStats) {
|
||||
// Tri alphabétique par défaut pour le combobox.
|
||||
clients.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
return response.json({ data: clients.map(serializeClient) })
|
||||
}
|
||||
|
||||
const statsMap = await bulkComputeClientStats(
|
||||
organizationId,
|
||||
clients.map((c) => c.id)
|
||||
)
|
||||
|
||||
const enriched = clients.map((c) => ({
|
||||
...serializeClient(c),
|
||||
...statsMap.get(c.id)!,
|
||||
}))
|
||||
|
||||
// Tri actionnable : retards d'abord, puis activité récente.
|
||||
enriched.sort((a, b) => {
|
||||
if (a.lateInvoiceCount !== b.lateInvoiceCount) {
|
||||
return b.lateInvoiceCount - a.lateInvoiceCount
|
||||
}
|
||||
const aLast = a.lastActivityAt ?? ''
|
||||
const bLast = b.lastActivityAt ?? ''
|
||||
return bLast.localeCompare(aLast)
|
||||
})
|
||||
|
||||
return response.json({ data: enriched })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /clients/:id — détail enrichi (stats + invoices à venir).
|
||||
*/
|
||||
async show({ auth, params, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
|
||||
const client = await Client.query()
|
||||
.where('organization_id', organizationId)
|
||||
.where('id', params.id)
|
||||
.first()
|
||||
|
||||
if (!client) {
|
||||
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
|
||||
}
|
||||
|
||||
const statsMap = await bulkComputeClientStats(organizationId, [client.id])
|
||||
const stats = statsMap.get(client.id)!
|
||||
|
||||
return response.json({
|
||||
data: {
|
||||
...serializeClient(client),
|
||||
...stats,
|
||||
invoices: [], // TODO: brancher quand le domaine Invoice arrive
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /clients — création manuelle.
|
||||
* Détecte les doublons de nom (case-insensitive) et renvoie 409 avec
|
||||
* la fiche existante pour permettre au SPA de proposer "voir le client".
|
||||
*/
|
||||
async store({ auth, request, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const payload = await request.validateUsing(createClientValidator)
|
||||
|
||||
// Doublon → 409 (cf. clients.ts MSW pour le contrat exact).
|
||||
const existing = await Client.query()
|
||||
.where('organization_id', organizationId)
|
||||
.whereILike('name', payload.name)
|
||||
.first()
|
||||
|
||||
if (existing) {
|
||||
return response.status(409).json({
|
||||
errors: [
|
||||
{
|
||||
code: 'duplicate_client',
|
||||
message: `Un client nommé « ${existing.name} » existe déjà.`,
|
||||
field: 'name',
|
||||
},
|
||||
],
|
||||
existing: serializeClient(existing),
|
||||
})
|
||||
}
|
||||
|
||||
const created = await Client.create({
|
||||
organizationId,
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
contactFirstName: payload.contactFirstName ?? null,
|
||||
contactLastName: payload.contactLastName ?? null,
|
||||
phone: payload.phone ?? null,
|
||||
address: payload.address ?? null,
|
||||
siret: payload.siret ?? null,
|
||||
notes: payload.notes ?? null,
|
||||
})
|
||||
|
||||
return response.status(201).json({ data: serializeClient(created) })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /clients/:id — édition partielle.
|
||||
*/
|
||||
async update({ auth, request, params, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const payload = await request.validateUsing(updateClientValidator)
|
||||
|
||||
const client = await Client.query()
|
||||
.where('organization_id', organizationId)
|
||||
.where('id', params.id)
|
||||
.first()
|
||||
|
||||
if (!client) {
|
||||
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
|
||||
}
|
||||
|
||||
client.merge(payload)
|
||||
await client.save()
|
||||
|
||||
return response.json({ data: serializeClient(client) })
|
||||
}
|
||||
}
|
||||
65
apps/api/app/controllers/dashboard_controller.ts
Normal file
65
apps/api/app/controllers/dashboard_controller.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import ActivityEvent from '#models/activity_event'
|
||||
import { computeKpis, topLatePayers } from '#services/dashboard'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
|
||||
const ACTIVITY_DEFAULT_LIMIT = 20
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export default class DashboardController {
|
||||
/**
|
||||
* GET /dashboard/kpis
|
||||
*
|
||||
* Cf. service dashboard.ts — quelques metrics V1 sont placeholder
|
||||
* (miseEnDemeurePending=0 tant que RelanceTask pas branché, percentile
|
||||
* undefined tant que cohorte trop petite).
|
||||
*/
|
||||
async kpis({ auth, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const data = await computeKpis(organizationId)
|
||||
return response.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/activity
|
||||
*
|
||||
* Journal append-only. Limit 20 par défaut, plus récent en tête.
|
||||
*/
|
||||
async activity({ auth, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
|
||||
const events = await ActivityEvent.query()
|
||||
.where('organization_id', organizationId)
|
||||
.orderBy('at', 'desc')
|
||||
.limit(ACTIVITY_DEFAULT_LIMIT)
|
||||
|
||||
return response.json({
|
||||
data: events.map((e) => ({
|
||||
id: e.id,
|
||||
kind: e.kind,
|
||||
at: e.at.toISO()!,
|
||||
label: e.label,
|
||||
meta: e.meta,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/top-late
|
||||
*
|
||||
* Top 5 clients avec le plus de factures en retard (status actif +
|
||||
* due_date dépassée).
|
||||
*/
|
||||
async topLate({ auth, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const data = await topLatePayers(organizationId)
|
||||
return response.json({ data })
|
||||
}
|
||||
}
|
||||
267
apps/api/app/controllers/import_batches_controller.ts
Normal file
267
apps/api/app/controllers/import_batches_controller.ts
Normal file
@ -0,0 +1,267 @@
|
||||
import ImportBatch from '#models/import_batch'
|
||||
import Invoice from '#models/invoice'
|
||||
import Plan from '#models/plan'
|
||||
import ImportBatchTransformer, { serializeDraft } from '#transformers/import_batch_transformer'
|
||||
import InvoiceTransformer from '#transformers/invoice_transformer'
|
||||
import { uploadValidator, validateDraftValidator } from '#validators/import_batch'
|
||||
import { resolveClient } from '#services/resolve_client'
|
||||
import {
|
||||
createImportBatch,
|
||||
createImportBatchFromFilenames,
|
||||
type ImportSource,
|
||||
} from '#services/import_batch'
|
||||
import { recordActivity } from '#services/activity_recorder'
|
||||
import { scheduleCheckinForInvoice } from '#services/checkin_scheduler'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import drive from '@adonisjs/drive/services/main'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function serializeBatch(b: ImportBatch) {
|
||||
return new ImportBatchTransformer(b).toObject()
|
||||
}
|
||||
|
||||
async function loadBatchOrFail(organizationId: string, id: string): Promise<ImportBatch> {
|
||||
const batch = await ImportBatch.query()
|
||||
.where('organization_id', organizationId)
|
||||
.where('id', id)
|
||||
.preload('drafts', (q) => q.orderBy('created_at', 'asc'))
|
||||
.first()
|
||||
if (!batch) {
|
||||
throw new Exception('Batch introuvable', { status: 404, code: 'not_found' })
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
export default class ImportBatchesController {
|
||||
/**
|
||||
* POST /invoices/upload — démarre un batch OCR.
|
||||
*
|
||||
* Deux modes selon Content-Type :
|
||||
* - **multipart/form-data** : champ `files[]` avec les vrais PDFs.
|
||||
* Stockage MinIO + OCR (mock OU mistral selon OCR_PROVIDER).
|
||||
* - **application/json** : `{ filenames: string[] }` (V1 démo).
|
||||
* Aucun fichier stocké → ne marche QU'AVEC OCR_PROVIDER=mock.
|
||||
*/
|
||||
async upload(ctx: HttpContext) {
|
||||
const { auth, request, response } = ctx
|
||||
const organizationId = requireOrgId(auth)
|
||||
|
||||
const isMultipart = (request.header('content-type') ?? '').startsWith('multipart/')
|
||||
|
||||
if (isMultipart) {
|
||||
const files = request.files('files', {
|
||||
size: '10mb',
|
||||
extnames: ['pdf', 'png', 'jpg', 'jpeg'],
|
||||
})
|
||||
if (files.length === 0) {
|
||||
return response.status(422).json({
|
||||
errors: [
|
||||
{ code: 'validation_failed', field: 'files', message: 'Au moins un fichier requis' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Upload vers Drive (MinIO) AVANT l'OCR — l'OCR Mistral télécharge
|
||||
// depuis Drive donc il faut que le fichier soit déjà posé.
|
||||
// Clé : import-drafts/<orgId>/<draftId>.<ext> — pas de batchId
|
||||
// dans la clé car le batch est créé après.
|
||||
const sources: ImportSource[] = []
|
||||
for (const f of files) {
|
||||
if (!f.isValid || !f.tmpPath || !f.extname) {
|
||||
return response.status(422).json({
|
||||
errors: [
|
||||
{
|
||||
code: 'validation_failed',
|
||||
field: 'files',
|
||||
message: f.errors?.[0]?.message ?? 'Fichier invalide',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
const draftKey = randomUUID()
|
||||
const storageKey = `import-drafts/${organizationId}/${draftKey}.${f.extname}`
|
||||
await drive.use().putStream(storageKey, createReadStream(f.tmpPath))
|
||||
sources.push({
|
||||
filename: f.clientName ?? `${draftKey}.${f.extname}`,
|
||||
storageKey,
|
||||
})
|
||||
}
|
||||
|
||||
const batch = await createImportBatch(organizationId, sources)
|
||||
return response.status(201).json({ data: serializeBatch(batch) })
|
||||
}
|
||||
|
||||
// Mode JSON — compat V1 démo.
|
||||
const { filenames } = await request.validateUsing(uploadValidator)
|
||||
const batch = await createImportBatchFromFilenames(organizationId, filenames)
|
||||
return response.status(201).json({ data: serializeBatch(batch) })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /invoices/import-batch/:id — état courant d'un batch.
|
||||
* Le SPA poll cet endpoint pendant la review (drafts pending → validated/skipped).
|
||||
*/
|
||||
async show({ auth, params, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const batch = await loadBatchOrFail(organizationId, params.id)
|
||||
return response.json({ data: serializeBatch(batch) })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /invoices/import-batch/:id/drafts/:draftId/validate
|
||||
*
|
||||
* L'utilisateur valide un draft → on crée l'Invoice avec les champs
|
||||
* éventuellement édités. Même logique de résolution client que POST
|
||||
* /invoices (clientId → match nom → création + email requis).
|
||||
*/
|
||||
async validateDraft({ auth, params, request, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const fields = await request.validateUsing(validateDraftValidator)
|
||||
|
||||
const batch = await loadBatchOrFail(organizationId, params.id)
|
||||
const draft = batch.drafts.find((d) => d.id === params.draftId)
|
||||
if (!draft) {
|
||||
throw new Exception('Brouillon introuvable', { status: 404, code: 'not_found' })
|
||||
}
|
||||
if (draft.status !== 'pending') {
|
||||
throw new Exception('Brouillon déjà traité', {
|
||||
status: 409,
|
||||
code: 'draft_already_processed',
|
||||
})
|
||||
}
|
||||
|
||||
const invoice = await db.transaction(async (trx) => {
|
||||
const result = await resolveClient(
|
||||
organizationId,
|
||||
{
|
||||
clientId: fields.clientId,
|
||||
clientName: fields.clientName,
|
||||
clientEmail: fields.clientEmail,
|
||||
},
|
||||
trx
|
||||
)
|
||||
if ('errorCode' in result) {
|
||||
throw new Exception(
|
||||
'Email du client requis — Rubis en a besoin pour envoyer les relances.',
|
||||
{ status: 422, code: result.errorCode }
|
||||
)
|
||||
}
|
||||
const client = result.client
|
||||
|
||||
// Plan : si fourni, doit appartenir à l'org.
|
||||
let planId: string | null = null
|
||||
if (fields.planId) {
|
||||
const plan = await Plan.query({ client: trx })
|
||||
.where('organization_id', organizationId)
|
||||
.where('id', fields.planId)
|
||||
.first()
|
||||
if (plan) planId = plan.id
|
||||
}
|
||||
|
||||
const created = await Invoice.create(
|
||||
{
|
||||
organizationId,
|
||||
clientId: client.id,
|
||||
planId,
|
||||
numero: fields.numero,
|
||||
amountTtcCents: fields.amountTtcCents,
|
||||
issueDate: DateTime.fromISO(fields.issueDate),
|
||||
dueDate: DateTime.fromISO(fields.dueDate),
|
||||
status: 'pending',
|
||||
rubisEarned: 1, // bonus import OCR (cf. CLAUDE.md → glossaire)
|
||||
pdfStorageKey: draft.pdfStorageKey,
|
||||
notes: null,
|
||||
paidAt: null,
|
||||
},
|
||||
{ client: trx }
|
||||
)
|
||||
|
||||
draft.useTransaction(trx)
|
||||
draft.status = 'validated'
|
||||
draft.edited = {
|
||||
clientId: client.id,
|
||||
clientName: client.name,
|
||||
clientEmail: client.email,
|
||||
numero: fields.numero,
|
||||
amountTtcCents: fields.amountTtcCents,
|
||||
issueDate: fields.issueDate,
|
||||
dueDate: fields.dueDate,
|
||||
planId,
|
||||
}
|
||||
draft.invoiceId = created.id
|
||||
await draft.save()
|
||||
|
||||
await recordActivity({
|
||||
organizationId,
|
||||
kind: 'invoice_imported',
|
||||
label: `Facture <b>${created.numero}</b> importée et validée`,
|
||||
meta: { invoiceId: created.id, clientId: client.id },
|
||||
trx,
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
await invoice.load('client')
|
||||
await invoice.load('plan')
|
||||
|
||||
try {
|
||||
await scheduleCheckinForInvoice(invoice)
|
||||
} catch (err) {
|
||||
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
|
||||
}
|
||||
|
||||
return response.status(201).json({ data: new InvoiceTransformer(invoice).toObject() })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /invoices/import-batch/:id/drafts/:draftId/skip
|
||||
* Marque un brouillon comme skippé (l'utilisateur ne veut pas le valider).
|
||||
*/
|
||||
async skipDraft({ auth, params, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const batch = await loadBatchOrFail(organizationId, params.id)
|
||||
const draft = batch.drafts.find((d) => d.id === params.draftId)
|
||||
if (!draft) {
|
||||
throw new Exception('Brouillon introuvable', { status: 404, code: 'not_found' })
|
||||
}
|
||||
if (draft.status === 'validated') {
|
||||
throw new Exception('Brouillon déjà validé', {
|
||||
status: 409,
|
||||
code: 'draft_already_processed',
|
||||
})
|
||||
}
|
||||
|
||||
if (draft.status !== 'skipped') {
|
||||
draft.status = 'skipped'
|
||||
await draft.save()
|
||||
}
|
||||
|
||||
return response.json({ data: serializeDraft(draft) })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /invoices/import-batch/:id — annule le batch entier.
|
||||
* CASCADE supprime les drafts. Les invoices validées (si y'en a déjà)
|
||||
* restent intactes, le FK draft.invoice_id est SET NULL côté ImportDraft.
|
||||
*/
|
||||
async destroy({ auth, params, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const batch = await loadBatchOrFail(organizationId, params.id)
|
||||
await batch.delete()
|
||||
return response.status(204).send('')
|
||||
}
|
||||
}
|
||||
376
apps/api/app/controllers/invoices_controller.ts
Normal file
376
apps/api/app/controllers/invoices_controller.ts
Normal file
@ -0,0 +1,376 @@
|
||||
import Invoice from '#models/invoice'
|
||||
import Plan from '#models/plan'
|
||||
import RelanceTask from '#models/relance_task'
|
||||
import InvoiceTransformer from '#transformers/invoice_transformer'
|
||||
import { createInvoiceValidator, listInvoicesValidator } from '#validators/invoice'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { DateTime } from 'luxon'
|
||||
import { resolveClient } from '#services/resolve_client'
|
||||
import { recordActivity } from '#services/activity_recorder'
|
||||
import { cancelFutureRelances } from '#services/relance_scheduler'
|
||||
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
// Priorité d'affichage côté liste : ce qui est actionnable d'abord.
|
||||
const STATUS_PRIORITY: Record<string, number> = {
|
||||
awaiting_user_confirmation: 0,
|
||||
in_relance: 1,
|
||||
pending: 2,
|
||||
litigation: 3,
|
||||
paid: 4,
|
||||
cancelled: 5,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function serializeInvoice(i: Invoice) {
|
||||
return new InvoiceTransformer(i).toObject()
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la timeline d'une facture en composant les étapes du plan
|
||||
* avec l'état courant (V1 simplifié — les RelanceTask viendront plus tard).
|
||||
*
|
||||
* - étapes dont sendDay <= aujourd'hui : 'past' (envoyées)
|
||||
* - étape actuelle (la prochaine future) : 'current'
|
||||
* - étapes futures : 'future'
|
||||
*/
|
||||
function buildTimeline(
|
||||
invoice: Invoice,
|
||||
relanceTasks: RelanceTask[] = []
|
||||
): Array<{
|
||||
id: string
|
||||
state: 'past' | 'current' | 'future'
|
||||
when: string
|
||||
what: string
|
||||
}> {
|
||||
const events: Array<{
|
||||
id: string
|
||||
state: 'past' | 'current' | 'future'
|
||||
when: string
|
||||
what: string
|
||||
}> = [
|
||||
{
|
||||
id: `${invoice.id}__issued`,
|
||||
state: 'past',
|
||||
when: `${formatShortDate(invoice.issueDate)} · facture émise`,
|
||||
what: 'Importée',
|
||||
},
|
||||
]
|
||||
|
||||
if (invoice.plan?.steps?.length && invoice.status !== 'paid' && invoice.status !== 'cancelled') {
|
||||
const dueMs = invoice.dueDate.toMillis()
|
||||
const nowMs = DateTime.now().toMillis()
|
||||
const taskByStepId = new Map(relanceTasks.map((task) => [task.planStepId, task]))
|
||||
let currentSet = false
|
||||
|
||||
for (const step of invoice.plan.steps.slice().sort((a, b) => a.order - b.order)) {
|
||||
const sendMs = dueMs + step.offsetDays * 24 * 60 * 60 * 1000
|
||||
const task = taskByStepId.get(step.id)
|
||||
const stepDate = task?.sentAt ?? task?.sendAt ?? DateTime.fromMillis(sendMs)
|
||||
const labelStep = `J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} — Étape ${step.order + 1}`
|
||||
|
||||
let state: 'past' | 'current' | 'future'
|
||||
if (task?.status === 'sent') state = 'past'
|
||||
else if (task?.status === 'scheduled' && task.sendAt.toMillis() < nowMs) state = 'current'
|
||||
else if (!task && invoice.status === 'pending' && !currentSet) {
|
||||
state = 'current'
|
||||
currentSet = true
|
||||
} else if (!currentSet) {
|
||||
state = 'current'
|
||||
currentSet = true
|
||||
} else state = 'future'
|
||||
|
||||
const subject = step.subject.replace('{{numero}}', invoice.numero)
|
||||
const what = task
|
||||
? task.status === 'sent'
|
||||
? `Email envoyé · "${subject}"`
|
||||
: `Email programmé · "${subject}"`
|
||||
: invoice.status === 'pending'
|
||||
? `À programmer après check-in · "${subject}"`
|
||||
: `Relance non programmée · "${subject}"`
|
||||
|
||||
events.push({
|
||||
id: `${invoice.id}__step_${step.order}`,
|
||||
state,
|
||||
when: `${formatShortDate(stepDate)} · ${labelStep}`,
|
||||
what,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (invoice.status === 'paid' && invoice.paidAt) {
|
||||
events.push({
|
||||
id: `${invoice.id}__paid`,
|
||||
state: 'past',
|
||||
when: `${formatShortDate(invoice.paidAt)} · facture encaissée`,
|
||||
what: 'Marquée encaissée — relances stoppées',
|
||||
})
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function formatShortDate(d: DateTime): string {
|
||||
return d.toFormat('dd/LL/yyyy')
|
||||
}
|
||||
|
||||
export default class InvoicesController {
|
||||
/**
|
||||
* GET /invoices?status=&q=&clientId=&page=
|
||||
*/
|
||||
async index({ auth, request, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const filters = await request.validateUsing(listInvoicesValidator)
|
||||
|
||||
const query = Invoice.query()
|
||||
.where('organization_id', organizationId)
|
||||
.preload('client')
|
||||
.preload('plan')
|
||||
|
||||
if (filters.status && filters.status !== 'all') {
|
||||
query.where('status', filters.status)
|
||||
}
|
||||
if (filters.clientId) {
|
||||
query.where('client_id', filters.clientId)
|
||||
}
|
||||
if (filters.q) {
|
||||
const q = filters.q.toLowerCase()
|
||||
query.where((b) => {
|
||||
b.whereILike('numero', `%${q}%`).orWhereExists((sub) => {
|
||||
sub
|
||||
.from('clients')
|
||||
.whereColumn('clients.id', 'invoices.client_id')
|
||||
.whereILike('clients.name', `%${q}%`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const invoices = await query.exec()
|
||||
|
||||
// Tri : actionnable d'abord (status priority), puis échéance croissante.
|
||||
invoices.sort((a, b) => {
|
||||
const dp = (STATUS_PRIORITY[a.status] ?? 99) - (STATUS_PRIORITY[b.status] ?? 99)
|
||||
if (dp !== 0) return dp
|
||||
return a.dueDate.toMillis() - b.dueDate.toMillis()
|
||||
})
|
||||
|
||||
// Pagination simple en V1 (cf. backend.md §6 — cursor-based plus tard).
|
||||
const page = filters.page ?? 1
|
||||
const total = invoices.length
|
||||
const sliced = invoices.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
|
||||
return response.json({
|
||||
data: sliced.map(serializeInvoice),
|
||||
meta: { total, page },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /invoices/counts — compteurs par statut pour les chips dashboard.
|
||||
*/
|
||||
async counts({ auth, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
|
||||
const rows = await db
|
||||
.from('invoices')
|
||||
.where('organization_id', organizationId)
|
||||
.select('status')
|
||||
.count('* as count')
|
||||
.groupBy('status')
|
||||
|
||||
const counts = {
|
||||
all: 0,
|
||||
pending: 0,
|
||||
in_relance: 0,
|
||||
awaiting_user_confirmation: 0,
|
||||
paid: 0,
|
||||
litigation: 0,
|
||||
cancelled: 0,
|
||||
}
|
||||
for (const r of rows) {
|
||||
const c = Number(r.count)
|
||||
counts.all += c
|
||||
const s = r.status as keyof typeof counts
|
||||
if (s in counts) counts[s] = c
|
||||
}
|
||||
|
||||
return response.json({ data: counts })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /invoices/:id — détail enrichi (client + plan + timeline).
|
||||
*/
|
||||
async show({ auth, params, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
|
||||
const invoice = await Invoice.query()
|
||||
.where('organization_id', organizationId)
|
||||
.where('id', params.id)
|
||||
.preload('client')
|
||||
.preload('plan', (q) => q.preload('steps'))
|
||||
.first()
|
||||
|
||||
if (!invoice) {
|
||||
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
|
||||
}
|
||||
|
||||
const data = serializeInvoice(invoice)
|
||||
const relanceTasks = await RelanceTask.query()
|
||||
.where('invoice_id', invoice.id)
|
||||
.whereNot('status', 'cancelled')
|
||||
return response.json({
|
||||
data: {
|
||||
...data,
|
||||
client: invoice.client && {
|
||||
id: invoice.client.id,
|
||||
name: invoice.client.name,
|
||||
email: invoice.client.email,
|
||||
phone: invoice.client.phone,
|
||||
address: invoice.client.address,
|
||||
siret: invoice.client.siret,
|
||||
},
|
||||
plan: invoice.plan && {
|
||||
id: invoice.plan.id,
|
||||
slug: invoice.plan.slug,
|
||||
name: invoice.plan.name,
|
||||
steps: (invoice.plan.steps ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
offsetDays: s.offsetDays,
|
||||
tone: s.tone,
|
||||
subject: s.subject,
|
||||
body: s.body,
|
||||
requiresManualValidation: s.requiresManualValidation,
|
||||
})),
|
||||
},
|
||||
timeline: buildTimeline(invoice, relanceTasks),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /invoices — saisie manuelle.
|
||||
*/
|
||||
async store({ auth, request, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const fields = await request.validateUsing(createInvoiceValidator)
|
||||
|
||||
const invoice = await db.transaction(async (trx) => {
|
||||
const result = await resolveClient(organizationId, fields, trx)
|
||||
if ('errorCode' in result) {
|
||||
throw new Exception(
|
||||
'Email du client requis — Rubis en a besoin pour envoyer les relances.',
|
||||
{ status: 422, code: result.errorCode }
|
||||
)
|
||||
}
|
||||
const client = result.client
|
||||
|
||||
// Vérification plan (s'il est fourni, doit appartenir à l'org).
|
||||
let planId: string | null = null
|
||||
if (fields.planId) {
|
||||
const plan = await Plan.query({ client: trx })
|
||||
.where('organization_id', organizationId)
|
||||
.where('id', fields.planId)
|
||||
.first()
|
||||
if (plan) planId = plan.id
|
||||
}
|
||||
|
||||
return Invoice.create(
|
||||
{
|
||||
organizationId,
|
||||
clientId: client.id,
|
||||
planId,
|
||||
numero: fields.numero,
|
||||
amountTtcCents: fields.amountTtcCents,
|
||||
issueDate: DateTime.fromISO(fields.issueDate),
|
||||
dueDate: DateTime.fromISO(fields.dueDate),
|
||||
status: 'pending',
|
||||
rubisEarned: 1, // bonus saisie initiale (cf. CLAUDE.md → glossaire)
|
||||
pdfStorageKey: null,
|
||||
notes: null,
|
||||
paidAt: null,
|
||||
},
|
||||
{ client: trx }
|
||||
)
|
||||
})
|
||||
|
||||
await invoice.load('client')
|
||||
await invoice.load('plan')
|
||||
|
||||
// Programme uniquement le check-in (envoyé à dueDate). Les relances
|
||||
// client ne partent qu'après confirmation "toujours en attente".
|
||||
try {
|
||||
await scheduleCheckinForInvoice(invoice)
|
||||
} catch (err) {
|
||||
logger.warn({ err, invoiceId: invoice.id }, 'failed to schedule checkin')
|
||||
}
|
||||
|
||||
return response.status(201).json({ data: serializeInvoice(invoice) })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /invoices/:id/mark-paid
|
||||
* Marque encaissée + bonus +1 rubis (à la fois sur invoice.rubisEarned
|
||||
* et sur organization.rubisCount). Annule toutes les relances futures.
|
||||
*/
|
||||
async markPaid({ auth, params, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
|
||||
const invoice = await Invoice.query()
|
||||
.where('organization_id', organizationId)
|
||||
.where('id', params.id)
|
||||
.preload('client')
|
||||
.preload('plan')
|
||||
.first()
|
||||
|
||||
if (!invoice) {
|
||||
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
|
||||
}
|
||||
if (invoice.status === 'paid') {
|
||||
// Idempotent : déjà payée, on renvoie l'état courant sans bumper.
|
||||
return response.json({ data: serializeInvoice(invoice) })
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
invoice.useTransaction(trx)
|
||||
invoice.status = 'paid'
|
||||
invoice.paidAt = DateTime.now()
|
||||
invoice.rubisEarned = invoice.rubisEarned + 1
|
||||
await invoice.save()
|
||||
|
||||
// Bump du compteur agrégé sur l'organisation
|
||||
await trx.from('organizations').where('id', organizationId).increment('rubis_count', 1)
|
||||
|
||||
// Journal d'activité (cf. dashboard activity feed).
|
||||
await recordActivity({
|
||||
organizationId,
|
||||
kind: 'invoice_paid',
|
||||
label: `Facture <b>${invoice.numero}</b> marquée encaissée`,
|
||||
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
|
||||
trx,
|
||||
})
|
||||
|
||||
// Annule toutes les relances + le check-in programmés pour cette
|
||||
// facture (idempotent, BullMQ.remove peut échouer silencieusement
|
||||
// si le job a déjà été consommé).
|
||||
await cancelFutureRelances(invoice.id, trx)
|
||||
await cancelCheckinForInvoice(invoice.id, trx)
|
||||
})
|
||||
|
||||
return response.json({ data: serializeInvoice(invoice) })
|
||||
}
|
||||
}
|
||||
31
apps/api/app/controllers/new_account_controller.ts
Normal file
31
apps/api/app/controllers/new_account_controller.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import User from '#models/user'
|
||||
import Organization from '#models/organization'
|
||||
import { signupValidator } from '#validators/user'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { provisionDefaultPlans } from '#services/default_plans'
|
||||
import { emitAuthSession } from '#services/auth_session'
|
||||
|
||||
export default class NewAccountController {
|
||||
/**
|
||||
* POST /auth/signup
|
||||
* Crée organisation + 4 plans pré-fournis + user dans une transaction,
|
||||
* puis émet une AuthSession (access token JSON + refresh cookie httpOnly).
|
||||
*/
|
||||
async store(ctx: HttpContext) {
|
||||
const { fullName, email, password } = await ctx.request.validateUsing(signupValidator)
|
||||
|
||||
const user = await db.transaction(async (trx) => {
|
||||
const org = await Organization.create({ name: '' }, { client: trx })
|
||||
await provisionDefaultPlans(org.id, trx)
|
||||
return User.create(
|
||||
{ email, password, fullName, organizationId: org.id },
|
||||
{ client: trx }
|
||||
)
|
||||
})
|
||||
|
||||
const session = await emitAuthSession(user, ctx)
|
||||
ctx.response.status(201)
|
||||
return ctx.serialize(session)
|
||||
}
|
||||
}
|
||||
47
apps/api/app/controllers/organizations_controller.ts
Normal file
47
apps/api/app/controllers/organizations_controller.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import Organization from '#models/organization'
|
||||
import OrganizationTransformer from '#transformers/organization_transformer'
|
||||
import { updateOrganizationValidator } from '#validators/organization'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export default class OrganizationsController {
|
||||
/**
|
||||
* GET /organizations/me — l'organisation de l'utilisateur courant.
|
||||
*/
|
||||
async show({ auth, serialize }: HttpContext) {
|
||||
const user = auth.getUserOrFail()
|
||||
if (user.organizationId === null) {
|
||||
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
|
||||
}
|
||||
const org = await Organization.findOrFail(user.organizationId)
|
||||
return serialize(OrganizationTransformer.transform(org))
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /organizations/me — onboarding step 2.
|
||||
* Marque `onboardingCompletedAt` dès qu'un nom est posé pour la
|
||||
* première fois (heuristique simple : pour l'instant un nom non vide
|
||||
* suffit à considérer l'organisation comme "configurée").
|
||||
*/
|
||||
async update({ auth, request, serialize }: HttpContext) {
|
||||
const user = auth.getUserOrFail()
|
||||
if (user.organizationId === null) {
|
||||
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
|
||||
}
|
||||
const payload = await request.validateUsing(updateOrganizationValidator)
|
||||
|
||||
const org = await Organization.findOrFail(user.organizationId)
|
||||
const wasUnnamed = org.name.trim().length === 0
|
||||
|
||||
org.merge(payload)
|
||||
|
||||
if (wasUnnamed && (payload.name?.trim().length ?? 0) > 0 && !org.onboardingCompletedAt) {
|
||||
org.onboardingCompletedAt = DateTime.now()
|
||||
}
|
||||
|
||||
await org.save()
|
||||
|
||||
return serialize(OrganizationTransformer.transform(org))
|
||||
}
|
||||
}
|
||||
228
apps/api/app/controllers/plans_controller.ts
Normal file
228
apps/api/app/controllers/plans_controller.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import Plan from '#models/plan'
|
||||
import PlanStep from '#models/plan_step'
|
||||
import PlanTransformer from '#transformers/plan_transformer'
|
||||
import { createPlanValidator, updatePlanValidator } from '#validators/plan'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
/**
|
||||
* Slug à partir d'un nom de plan : minuscules, ASCII safe, tirets.
|
||||
* On garantit l'unicité par org en suffixant un compteur si collision.
|
||||
*/
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60) || 'plan'
|
||||
}
|
||||
|
||||
// Slugs réservés côté front (routes statiques type /plans/nouveau).
|
||||
// Si l'utilisateur nomme son plan "nouveau", on suffixe d'office.
|
||||
const RESERVED_SLUGS = new Set(['nouveau', 'new', 'create'])
|
||||
|
||||
async function nextAvailableSlug(organizationId: string, base: string): Promise<string> {
|
||||
const start = RESERVED_SLUGS.has(base) ? `${base}-1` : base
|
||||
const existing = await Plan.query()
|
||||
.where('organization_id', organizationId)
|
||||
.whereILike('slug', `${base}%`)
|
||||
.select('slug')
|
||||
const taken = new Set(existing.map((p) => p.slug))
|
||||
if (!taken.has(start) && !RESERVED_SLUGS.has(start)) return start
|
||||
for (let i = 2; i < 100; i++) {
|
||||
const candidate = `${base}-${i}`
|
||||
if (!taken.has(candidate) && !RESERVED_SLUGS.has(candidate)) return candidate
|
||||
}
|
||||
return `${base}-${Date.now()}`
|
||||
}
|
||||
|
||||
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function serializePlan(p: Plan) {
|
||||
return new PlanTransformer(p).toObject()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte combien de factures actives (non payées, non annulées) référencent
|
||||
* chaque plan d'une org. Utilisé pour enrichir la liste avec un badge "X
|
||||
* factures utilisent ce plan" — utile avant édition pour signaler l'impact.
|
||||
*/
|
||||
async function bulkComputePlanUsage(
|
||||
organizationId: string,
|
||||
planIds: string[]
|
||||
): Promise<Map<string, number>> {
|
||||
const map = new Map<string, number>()
|
||||
for (const id of planIds) map.set(id, 0)
|
||||
|
||||
if (planIds.length === 0) return map
|
||||
|
||||
const rows = await db
|
||||
.from('invoices')
|
||||
.where('organization_id', organizationId)
|
||||
.whereIn('plan_id', planIds)
|
||||
.whereRaw(`status::text in ${ACTIVE_INVOICE_STATUSES}`)
|
||||
.select('plan_id')
|
||||
.count('* as count')
|
||||
.groupBy('plan_id')
|
||||
|
||||
for (const r of rows) {
|
||||
map.set(r.plan_id, Number(r.count))
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export default class PlansController {
|
||||
/**
|
||||
* GET /plans — liste enrichie avec compteurs d'usage.
|
||||
*/
|
||||
async index({ auth, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
|
||||
const plans = await Plan.query()
|
||||
.where('organization_id', organizationId)
|
||||
.preload('steps')
|
||||
.orderBy('is_default', 'desc')
|
||||
.orderBy('name', 'asc')
|
||||
|
||||
const usage = await bulkComputePlanUsage(
|
||||
organizationId,
|
||||
plans.map((p) => p.id)
|
||||
)
|
||||
|
||||
const data = plans.map((p) => ({
|
||||
...serializePlan(p),
|
||||
usageCount: usage.get(p.id) ?? 0,
|
||||
}))
|
||||
|
||||
return response.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /plans/:slug — détail.
|
||||
* Le SPA lookup par slug pour les plans pré-fournis (URL stable et
|
||||
* lisible : /parametres/plans/standard-30j).
|
||||
*/
|
||||
async show({ auth, params, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
|
||||
const plan = await Plan.query()
|
||||
.where('organization_id', organizationId)
|
||||
.where('slug', params.slug)
|
||||
.preload('steps')
|
||||
.first()
|
||||
|
||||
if (!plan) {
|
||||
throw new Exception('Plan introuvable', { status: 404, code: 'not_found' })
|
||||
}
|
||||
|
||||
const usage = await bulkComputePlanUsage(organizationId, [plan.id])
|
||||
return response.json({
|
||||
data: { ...serializePlan(plan), usageCount: usage.get(plan.id) ?? 0 },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /plans/:slug — édite nom, description et/ou recompose les étapes.
|
||||
*
|
||||
* Recomposition des steps : on ne fait pas de diff fin (id par id), on
|
||||
* remplace tout le set en transaction. Plus simple, plus prévisible, et
|
||||
* idiomatique côté UX (l'utilisateur a édité son plan dans son ensemble).
|
||||
*/
|
||||
async update({ auth, params, request, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const payload = await request.validateUsing(updatePlanValidator)
|
||||
|
||||
const plan = await Plan.query()
|
||||
.where('organization_id', organizationId)
|
||||
.where('slug', params.slug)
|
||||
.first()
|
||||
|
||||
if (!plan) {
|
||||
throw new Exception('Plan introuvable', { status: 404, code: 'not_found' })
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
plan.useTransaction(trx)
|
||||
if (payload.name !== undefined) plan.name = payload.name
|
||||
if (payload.description !== undefined) plan.description = payload.description
|
||||
await plan.save()
|
||||
|
||||
if (payload.steps !== undefined) {
|
||||
// Remplace tout le set
|
||||
await PlanStep.query({ client: trx }).where('plan_id', plan.id).delete()
|
||||
await PlanStep.createMany(
|
||||
payload.steps.map((s) => ({
|
||||
planId: plan.id,
|
||||
order: s.order,
|
||||
offsetDays: s.offsetDays,
|
||||
tone: s.tone,
|
||||
subject: s.subject,
|
||||
body: s.body,
|
||||
requiresManualValidation: s.requiresManualValidation,
|
||||
})),
|
||||
{ client: trx }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
await plan.load('steps')
|
||||
return response.json({ data: serializePlan(plan) })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /plans — création d'un plan custom.
|
||||
*
|
||||
* Slug auto-généré depuis `name`, suffixé en cas de collision dans l'org.
|
||||
* Le plan custom n'est pas marqué `isDefault` — il peut être supprimé
|
||||
* (V2) sans toucher à la bibliothèque.
|
||||
*/
|
||||
async store({ auth, request, response }: HttpContext) {
|
||||
const organizationId = requireOrgId(auth)
|
||||
const payload = await request.validateUsing(createPlanValidator)
|
||||
|
||||
const baseSlug = slugify(payload.name)
|
||||
const slug = await nextAvailableSlug(organizationId, baseSlug)
|
||||
|
||||
const plan = await db.transaction(async (trx) => {
|
||||
const created = await Plan.create(
|
||||
{
|
||||
organizationId,
|
||||
slug,
|
||||
name: payload.name,
|
||||
description: payload.description ?? '',
|
||||
isDefault: false,
|
||||
},
|
||||
{ client: trx }
|
||||
)
|
||||
|
||||
await PlanStep.createMany(
|
||||
payload.steps.map((s) => ({
|
||||
planId: created.id,
|
||||
order: s.order,
|
||||
offsetDays: s.offsetDays,
|
||||
tone: s.tone,
|
||||
subject: s.subject,
|
||||
body: s.body,
|
||||
requiresManualValidation: s.requiresManualValidation,
|
||||
})),
|
||||
{ client: trx }
|
||||
)
|
||||
|
||||
return created
|
||||
})
|
||||
|
||||
await plan.load('steps')
|
||||
return response.status(201).json({ data: serializePlan(plan) })
|
||||
}
|
||||
}
|
||||
25
apps/api/app/controllers/profile_controller.ts
Normal file
25
apps/api/app/controllers/profile_controller.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import UserTransformer from '#transformers/user_transformer'
|
||||
import { updateProfileValidator } from '#validators/user'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
export default class ProfileController {
|
||||
/**
|
||||
* GET /account/profile
|
||||
*/
|
||||
async show({ auth, serialize }: HttpContext) {
|
||||
return serialize(UserTransformer.transform(auth.getUserOrFail()))
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /account/profile
|
||||
*/
|
||||
async update({ auth, request, serialize }: HttpContext) {
|
||||
const user = auth.getUserOrFail()
|
||||
const payload = await request.validateUsing(updateProfileValidator)
|
||||
|
||||
user.merge(payload)
|
||||
await user.save()
|
||||
|
||||
return serialize(UserTransformer.transform(user))
|
||||
}
|
||||
}
|
||||
32
apps/api/app/controllers/refresh_controller.ts
Normal file
32
apps/api/app/controllers/refresh_controller.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { Exception } from '@adonisjs/core/exceptions'
|
||||
import { consumeRefreshToken } from '#services/refresh_token'
|
||||
import { emitAuthSession } from '#services/auth_session'
|
||||
|
||||
export default class RefreshController {
|
||||
/**
|
||||
* POST /auth/refresh
|
||||
*
|
||||
* Lit le cookie `rubis_refresh` (httpOnly), valide son hash en DB,
|
||||
* révoque l'ancien et émet une AuthSession fraîche (nouveau access
|
||||
* token + nouveau refresh cookie posé via emitAuthSession).
|
||||
*
|
||||
* Codes d'erreur :
|
||||
* - 401 no_session : pas de cookie envoyé
|
||||
* - 401 session_expired : cookie inconnu, expiré, ou révoqué
|
||||
* (réutilisation d'un token révoqué = vol présumé → panic mode :
|
||||
* tous les tokens actifs du user sont invalidés)
|
||||
*/
|
||||
async handle(ctx: HttpContext) {
|
||||
const result = await consumeRefreshToken(ctx)
|
||||
if ('errorCode' in result) {
|
||||
throw new Exception(
|
||||
result.errorCode === 'no_session' ? 'Pas de session active' : 'Session expirée',
|
||||
{ status: 401, code: result.errorCode }
|
||||
)
|
||||
}
|
||||
|
||||
const session = await emitAuthSession(result.user, ctx)
|
||||
return ctx.serialize(session)
|
||||
}
|
||||
}
|
||||
96
apps/api/app/exceptions/handler.ts
Normal file
96
apps/api/app/exceptions/handler.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
|
||||
|
||||
/**
|
||||
* Exception handler API JSON-only. Normalise toutes les erreurs vers la
|
||||
* shape `{ errors: [{ code, message, field? }] }` documentée dans
|
||||
* backend.md §6.
|
||||
*
|
||||
* Conversions :
|
||||
* - PG 23505 (unique violation) → 422 `duplicate` avec field extrait
|
||||
* - E_INVALID_CREDENTIALS → 401 `invalid_credentials`
|
||||
* - Vine validation errors → 422 (déjà géré par Adonis, on relaie)
|
||||
* - Exception custom avec code & status → propage tel quel sous shape errors
|
||||
* - Reste → fallback super.handle()
|
||||
*/
|
||||
export default class HttpExceptionHandler extends ExceptionHandler {
|
||||
protected debug = !app.inProduction
|
||||
|
||||
async handle(error: unknown, ctx: HttpContext) {
|
||||
if (!isObject(error)) return super.handle(error, ctx)
|
||||
|
||||
// Postgres unique violation → 422 propre (pas un 500 avec stack pg-protocol).
|
||||
if (error.code === '23505') {
|
||||
const detail = typeof error.detail === 'string' ? error.detail : ''
|
||||
const fieldMatch = detail.match(/Key \(([^)]+)\)=/)
|
||||
const field = fieldMatch?.[1]?.split(',')[0]?.trim()
|
||||
ctx.response.status(422)
|
||||
return ctx.response.json({
|
||||
errors: [
|
||||
{
|
||||
code: 'duplicate',
|
||||
message: 'Cette valeur existe déjà.',
|
||||
field: field ?? undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Adonis auth — mauvais credentials. Le default est 400, on veut 401.
|
||||
if (error.code === 'E_INVALID_CREDENTIALS') {
|
||||
ctx.response.status(401)
|
||||
return ctx.response.json({
|
||||
errors: [
|
||||
{
|
||||
code: 'invalid_credentials',
|
||||
message: 'Email ou mot de passe incorrect',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Vine — validation errors. Adonis sort déjà des messages structurés,
|
||||
// on les relaie en `errors[]`.
|
||||
if (error.code === 'E_VALIDATION_ERROR' && Array.isArray(error.messages)) {
|
||||
ctx.response.status(422)
|
||||
return ctx.response.json({
|
||||
errors: error.messages.map((m) => ({
|
||||
code: 'validation_failed',
|
||||
message: typeof m === 'object' && m && 'message' in m ? String(m.message) : '',
|
||||
field: typeof m === 'object' && m && 'field' in m ? String(m.field) : undefined,
|
||||
rule: typeof m === 'object' && m && 'rule' in m ? String(m.rule) : undefined,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Custom Exception levée par les controllers : on a `status` + `code`
|
||||
// + `message`. On les passe en shape `errors[]`.
|
||||
if (
|
||||
typeof error.status === 'number' &&
|
||||
typeof error.code === 'string' &&
|
||||
typeof error.message === 'string' &&
|
||||
error.status >= 400 &&
|
||||
error.status < 600
|
||||
) {
|
||||
ctx.response.status(error.status)
|
||||
return ctx.response.json({
|
||||
errors: [
|
||||
{
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return super.handle(error, ctx)
|
||||
}
|
||||
|
||||
async report(error: unknown, ctx: HttpContext) {
|
||||
return super.report(error, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return v !== null && typeof v === 'object'
|
||||
}
|
||||
69
apps/api/app/jobs/send_checkin_job.ts
Normal file
69
apps/api/app/jobs/send_checkin_job.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import CheckinTask from '#models/checkin_task'
|
||||
import Invoice from '#models/invoice'
|
||||
import User from '#models/user'
|
||||
import { sendCheckinEmail } from '#services/mail_dispatcher'
|
||||
import env from '#start/env'
|
||||
import { DateTime } from 'luxon'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
/**
|
||||
* Worker BullMQ pour la queue `checkins`.
|
||||
*
|
||||
* Idempotent : si la task n'est plus `scheduled` (déjà envoyée ou
|
||||
* expirée parce que la facture a été marquée payée entre-temps),
|
||||
* no-op.
|
||||
*
|
||||
* Le `plain` token est passé dans le payload du job (pas relu depuis
|
||||
* la DB où on n'a que le hash), pour pouvoir construire les URLs.
|
||||
*/
|
||||
export async function sendCheckinJob(jobData: { taskId: string; plain: string }) {
|
||||
const task = await CheckinTask.find(jobData.taskId)
|
||||
if (!task) {
|
||||
logger.warn({ taskId: jobData.taskId }, 'checkin task not found, skipping')
|
||||
return
|
||||
}
|
||||
if (task.status !== 'scheduled') {
|
||||
return
|
||||
}
|
||||
|
||||
const invoice = await Invoice.query()
|
||||
.where('id', task.invoiceId)
|
||||
.preload('client')
|
||||
.first()
|
||||
if (!invoice) {
|
||||
task.status = 'expired'
|
||||
await task.save()
|
||||
return
|
||||
}
|
||||
|
||||
// Si la facture a été payée/annulée entre la programmation et l'exécution,
|
||||
// on n'envoie pas le check-in (l'utilisateur sait déjà).
|
||||
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
|
||||
task.status = 'expired'
|
||||
await task.save()
|
||||
return
|
||||
}
|
||||
|
||||
const user = await User.query().where('organization_id', invoice.organizationId).first()
|
||||
if (!user) {
|
||||
task.status = 'expired'
|
||||
await task.save()
|
||||
return
|
||||
}
|
||||
|
||||
const apiUrl = env.get('APP_URL', 'http://localhost:3333')
|
||||
const paidUrl = `${apiUrl}/api/v1/checkin/${jobData.plain}/paid`
|
||||
const pendingUrl = `${apiUrl}/api/v1/checkin/${jobData.plain}/pending`
|
||||
|
||||
await sendCheckinEmail({
|
||||
invoice,
|
||||
client: invoice.client,
|
||||
user,
|
||||
paidUrl,
|
||||
pendingUrl,
|
||||
})
|
||||
|
||||
task.status = 'sent'
|
||||
task.sentAt = DateTime.now()
|
||||
await task.save()
|
||||
}
|
||||
122
apps/api/app/jobs/send_relance_job.ts
Normal file
122
apps/api/app/jobs/send_relance_job.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import RelanceTask from '#models/relance_task'
|
||||
import Invoice from '#models/invoice'
|
||||
import User from '#models/user'
|
||||
import { sendRelanceEmail } from '#services/mail_dispatcher'
|
||||
import { recordActivity } from '#services/activity_recorder'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import { DateTime } from 'luxon'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
/**
|
||||
* Worker BullMQ pour la queue `relances`. Idempotent : si la task n'est
|
||||
* plus `scheduled` (déjà envoyée, annulée, ou échouée définitivement),
|
||||
* no-op.
|
||||
*
|
||||
* Cas critiques :
|
||||
* - Invoice payée/annulée entre temps → cancel la task (pas d'envoi)
|
||||
* - Step `requires_manual_validation` (mise en demeure) → on n'envoie
|
||||
* PAS, on log un activity_event 'warning_drafted' que l'utilisateur
|
||||
* devra valider manuellement (cf. CLAUDE.md → Principes produit).
|
||||
* - Sinon : envoi de l'email + bump rubis (1 rubis = 10 min libérées).
|
||||
*/
|
||||
export async function sendRelanceJob(jobData: { taskId: string }) {
|
||||
const task = await RelanceTask.query()
|
||||
.where('id', jobData.taskId)
|
||||
.preload('planStep')
|
||||
.first()
|
||||
if (!task) {
|
||||
logger.warn({ taskId: jobData.taskId }, 'relance task not found, skipping')
|
||||
return
|
||||
}
|
||||
if (task.status !== 'scheduled') {
|
||||
logger.info({ taskId: task.id, status: task.status }, 'relance task not scheduled, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
const invoice = await Invoice.query()
|
||||
.where('id', task.invoiceId)
|
||||
.preload('client')
|
||||
.preload('organization')
|
||||
.first()
|
||||
if (!invoice) {
|
||||
task.status = 'cancelled'
|
||||
await task.save()
|
||||
return
|
||||
}
|
||||
|
||||
// Hook critique : la facture peut avoir été payée entre la programmation
|
||||
// et l'exécution. On vérifie avant d'envoyer.
|
||||
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
|
||||
task.status = 'cancelled'
|
||||
await task.save()
|
||||
return
|
||||
}
|
||||
|
||||
const step = task.planStep
|
||||
const user = await User.query().where('organization_id', invoice.organizationId).first()
|
||||
|
||||
// Mise en demeure : on génère un brouillon, on n'envoie pas (cf. CLAUDE.md).
|
||||
if (step.requiresManualValidation) {
|
||||
await db.transaction(async (trx) => {
|
||||
task.useTransaction(trx)
|
||||
task.status = 'sent' // On considère la task "traitée" — le brouillon est l'output
|
||||
task.sentAt = DateTime.now()
|
||||
await task.save()
|
||||
|
||||
await recordActivity({
|
||||
organizationId: invoice.organizationId,
|
||||
kind: 'warning_drafted',
|
||||
label: `Brouillon mise en demeure prêt — <b>${invoice.client.name}</b> (${invoice.numero})`,
|
||||
meta: {
|
||||
invoiceId: invoice.id,
|
||||
clientId: invoice.clientId,
|
||||
planStepOrder: step.order,
|
||||
},
|
||||
trx,
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Envoi normal
|
||||
await sendRelanceEmail({
|
||||
invoice,
|
||||
client: invoice.client,
|
||||
step,
|
||||
user,
|
||||
organization: invoice.organization,
|
||||
})
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
task.useTransaction(trx)
|
||||
task.status = 'sent'
|
||||
task.sentAt = DateTime.now()
|
||||
await task.save()
|
||||
|
||||
invoice.useTransaction(trx)
|
||||
// Première relance envoyée → status passe en `in_relance` (la facture
|
||||
// sort de l'état "pending" silencieux).
|
||||
if (invoice.status === 'pending') {
|
||||
invoice.status = 'in_relance'
|
||||
}
|
||||
invoice.rubisEarned = invoice.rubisEarned + 1
|
||||
await invoice.save()
|
||||
|
||||
await trx
|
||||
.from('organizations')
|
||||
.where('id', invoice.organizationId)
|
||||
.increment('rubis_count', 1)
|
||||
|
||||
await recordActivity({
|
||||
organizationId: invoice.organizationId,
|
||||
kind: 'relance_sent',
|
||||
label: `Relance J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} envoyée à <b>${invoice.client.name}</b>`,
|
||||
meta: {
|
||||
invoiceId: invoice.id,
|
||||
clientId: invoice.clientId,
|
||||
planStepOrder: step.order,
|
||||
},
|
||||
trx,
|
||||
})
|
||||
})
|
||||
}
|
||||
20
apps/api/app/middleware/auth_middleware.ts
Normal file
20
apps/api/app/middleware/auth_middleware.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
import type { Authenticators } from '@adonisjs/auth/types'
|
||||
|
||||
/**
|
||||
* Auth middleware is used authenticate HTTP requests and deny
|
||||
* access to unauthenticated users.
|
||||
*/
|
||||
export default class AuthMiddleware {
|
||||
async handle(
|
||||
ctx: HttpContext,
|
||||
next: NextFn,
|
||||
options: {
|
||||
guards?: (keyof Authenticators)[]
|
||||
} = {}
|
||||
) {
|
||||
await ctx.auth.authenticateUsing(options.guards)
|
||||
return next()
|
||||
}
|
||||
}
|
||||
19
apps/api/app/middleware/container_bindings_middleware.ts
Normal file
19
apps/api/app/middleware/container_bindings_middleware.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Logger } from '@adonisjs/core/logger'
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import { type NextFn } from '@adonisjs/core/types/http'
|
||||
|
||||
/**
|
||||
* The container bindings middleware binds classes to their request
|
||||
* specific value using the container resolver.
|
||||
*
|
||||
* - We bind "HttpContext" class to the "ctx" object
|
||||
* - And bind "Logger" class to the "ctx.logger" object
|
||||
*/
|
||||
export default class ContainerBindingsMiddleware {
|
||||
handle(ctx: HttpContext, next: NextFn) {
|
||||
ctx.containerResolver.bindValue(HttpContext, ctx)
|
||||
ctx.containerResolver.bindValue(Logger, ctx.logger)
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
|
||||
export default class ForceJsonResponseMiddleware {
|
||||
handle(ctx: HttpContext, next: NextFn) {
|
||||
ctx.request.request.headers.accept = 'application/json'
|
||||
return next()
|
||||
}
|
||||
}
|
||||
37
apps/api/app/middleware/initialize_bouncer_middleware.ts
Normal file
37
apps/api/app/middleware/initialize_bouncer_middleware.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import * as abilities from '#abilities/main'
|
||||
import { policies } from '#generated/policies'
|
||||
|
||||
import { Bouncer } from '@adonisjs/bouncer'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
|
||||
/**
|
||||
* Init bouncer middleware is used to create a bouncer instance
|
||||
* during an HTTP request
|
||||
*/
|
||||
export default class InitializeBouncerMiddleware {
|
||||
async handle(ctx: HttpContext, next: NextFn) {
|
||||
/**
|
||||
* Create bouncer instance for the ongoing HTTP request.
|
||||
* We will pull the user from the HTTP context.
|
||||
*/
|
||||
ctx.bouncer = new Bouncer(
|
||||
() => ctx.auth.user || null,
|
||||
abilities,
|
||||
policies
|
||||
).setContainerResolver(ctx.containerResolver)
|
||||
|
||||
// API JSON-only : pas d'intégration Edge views à partager.
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@adonisjs/core/http' {
|
||||
export interface HttpContext {
|
||||
bouncer: Bouncer<
|
||||
Exclude<HttpContext['auth']['user'], undefined>,
|
||||
typeof abilities,
|
||||
typeof policies
|
||||
>
|
||||
}
|
||||
}
|
||||
16
apps/api/app/middleware/silent_auth_middleware.ts
Normal file
16
apps/api/app/middleware/silent_auth_middleware.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
|
||||
/**
|
||||
* Silent auth middleware can be used as a global middleware to silent check
|
||||
* if the user is logged-in or not.
|
||||
*
|
||||
* The request continues as usual, even when the user is not logged-in.
|
||||
*/
|
||||
export default class SilentAuthMiddleware {
|
||||
async handle(ctx: HttpContext, next: NextFn) {
|
||||
await ctx.auth.check()
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
9
apps/api/app/models/activity_event.ts
Normal file
9
apps/api/app/models/activity_event.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { ActivityEventSchema } from '#database/schema'
|
||||
import { belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import Organization from '#models/organization'
|
||||
|
||||
export default class ActivityEvent extends ActivityEventSchema {
|
||||
@belongsTo(() => Organization)
|
||||
declare organization: BelongsTo<typeof Organization>
|
||||
}
|
||||
9
apps/api/app/models/checkin_task.ts
Normal file
9
apps/api/app/models/checkin_task.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { CheckinTaskSchema } from '#database/schema'
|
||||
import { belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import Invoice from '#models/invoice'
|
||||
|
||||
export default class CheckinTask extends CheckinTaskSchema {
|
||||
@belongsTo(() => Invoice)
|
||||
declare invoice: BelongsTo<typeof Invoice>
|
||||
}
|
||||
13
apps/api/app/models/client.ts
Normal file
13
apps/api/app/models/client.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ClientSchema } from '#database/schema'
|
||||
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
|
||||
import Organization from '#models/organization'
|
||||
import Invoice from '#models/invoice'
|
||||
|
||||
export default class Client extends ClientSchema {
|
||||
@belongsTo(() => Organization)
|
||||
declare organization: BelongsTo<typeof Organization>
|
||||
|
||||
@hasMany(() => Invoice)
|
||||
declare invoices: HasMany<typeof Invoice>
|
||||
}
|
||||
13
apps/api/app/models/import_batch.ts
Normal file
13
apps/api/app/models/import_batch.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ImportBatchSchema } from '#database/schema'
|
||||
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
|
||||
import Organization from '#models/organization'
|
||||
import ImportDraft from '#models/import_draft'
|
||||
|
||||
export default class ImportBatch extends ImportBatchSchema {
|
||||
@belongsTo(() => Organization)
|
||||
declare organization: BelongsTo<typeof Organization>
|
||||
|
||||
@hasMany(() => ImportDraft, { foreignKey: 'batchId' })
|
||||
declare drafts: HasMany<typeof ImportDraft>
|
||||
}
|
||||
13
apps/api/app/models/import_draft.ts
Normal file
13
apps/api/app/models/import_draft.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ImportDraftSchema } from '#database/schema'
|
||||
import { belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import ImportBatch from '#models/import_batch'
|
||||
import Invoice from '#models/invoice'
|
||||
|
||||
export default class ImportDraft extends ImportDraftSchema {
|
||||
@belongsTo(() => ImportBatch, { foreignKey: 'batchId' })
|
||||
declare batch: BelongsTo<typeof ImportBatch>
|
||||
|
||||
@belongsTo(() => Invoice, { foreignKey: 'invoiceId' })
|
||||
declare invoice: BelongsTo<typeof Invoice>
|
||||
}
|
||||
17
apps/api/app/models/invoice.ts
Normal file
17
apps/api/app/models/invoice.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { InvoiceSchema } from '#database/schema'
|
||||
import { belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import Organization from '#models/organization'
|
||||
import Client from '#models/client'
|
||||
import Plan from '#models/plan'
|
||||
|
||||
export default class Invoice extends InvoiceSchema {
|
||||
@belongsTo(() => Organization)
|
||||
declare organization: BelongsTo<typeof Organization>
|
||||
|
||||
@belongsTo(() => Client)
|
||||
declare client: BelongsTo<typeof Client>
|
||||
|
||||
@belongsTo(() => Plan)
|
||||
declare plan: BelongsTo<typeof Plan>
|
||||
}
|
||||
9
apps/api/app/models/organization.ts
Normal file
9
apps/api/app/models/organization.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { OrganizationSchema } from '#database/schema'
|
||||
import { hasMany } from '@adonisjs/lucid/orm'
|
||||
import type { HasMany } from '@adonisjs/lucid/types/relations'
|
||||
import User from '#models/user'
|
||||
|
||||
export default class Organization extends OrganizationSchema {
|
||||
@hasMany(() => User)
|
||||
declare users: HasMany<typeof User>
|
||||
}
|
||||
17
apps/api/app/models/plan.ts
Normal file
17
apps/api/app/models/plan.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { PlanSchema } from '#database/schema'
|
||||
import { belongsTo, hasMany } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
|
||||
import Organization from '#models/organization'
|
||||
import PlanStep from '#models/plan_step'
|
||||
import Invoice from '#models/invoice'
|
||||
|
||||
export default class Plan extends PlanSchema {
|
||||
@belongsTo(() => Organization)
|
||||
declare organization: BelongsTo<typeof Organization>
|
||||
|
||||
@hasMany(() => PlanStep, { foreignKey: 'planId' })
|
||||
declare steps: HasMany<typeof PlanStep>
|
||||
|
||||
@hasMany(() => Invoice)
|
||||
declare invoices: HasMany<typeof Invoice>
|
||||
}
|
||||
9
apps/api/app/models/plan_step.ts
Normal file
9
apps/api/app/models/plan_step.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { PlanStepSchema } from '#database/schema'
|
||||
import { belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import Plan from '#models/plan'
|
||||
|
||||
export default class PlanStep extends PlanStepSchema {
|
||||
@belongsTo(() => Plan)
|
||||
declare plan: BelongsTo<typeof Plan>
|
||||
}
|
||||
9
apps/api/app/models/refresh_token.ts
Normal file
9
apps/api/app/models/refresh_token.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { RefreshTokenSchema } from '#database/schema'
|
||||
import { belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import User from '#models/user'
|
||||
|
||||
export default class RefreshToken extends RefreshTokenSchema {
|
||||
@belongsTo(() => User)
|
||||
declare user: BelongsTo<typeof User>
|
||||
}
|
||||
13
apps/api/app/models/relance_task.ts
Normal file
13
apps/api/app/models/relance_task.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { RelanceTaskSchema } from '#database/schema'
|
||||
import { belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import Invoice from '#models/invoice'
|
||||
import PlanStep from '#models/plan_step'
|
||||
|
||||
export default class RelanceTask extends RelanceTaskSchema {
|
||||
@belongsTo(() => Invoice)
|
||||
declare invoice: BelongsTo<typeof Invoice>
|
||||
|
||||
@belongsTo(() => PlanStep, { foreignKey: 'planStepId' })
|
||||
declare planStep: BelongsTo<typeof PlanStep>
|
||||
}
|
||||
24
apps/api/app/models/user.ts
Normal file
24
apps/api/app/models/user.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { UserSchema } from '#database/schema'
|
||||
import hash from '@adonisjs/core/services/hash'
|
||||
import { compose } from '@adonisjs/core/helpers'
|
||||
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
|
||||
import { type AccessToken, DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
|
||||
import { belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import Organization from '#models/organization'
|
||||
|
||||
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
|
||||
static accessTokens = DbAccessTokensProvider.forModel(User)
|
||||
declare currentAccessToken?: AccessToken
|
||||
|
||||
@belongsTo(() => Organization)
|
||||
declare organization: BelongsTo<typeof Organization>
|
||||
|
||||
get initials() {
|
||||
const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
|
||||
if (first && last) {
|
||||
return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
|
||||
}
|
||||
return `${first.slice(0, 2)}`.toUpperCase()
|
||||
}
|
||||
}
|
||||
40
apps/api/app/services/activity_recorder.ts
Normal file
40
apps/api/app/services/activity_recorder.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { DateTime } from 'luxon'
|
||||
import ActivityEvent from '#models/activity_event'
|
||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||
|
||||
type EventKind = 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
|
||||
|
||||
type RecordOpts = {
|
||||
organizationId: string
|
||||
kind: EventKind
|
||||
label: string
|
||||
meta?: Record<string, unknown>
|
||||
at?: DateTime
|
||||
trx?: TransactionClientContract
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un événement dans le journal d'activité (append-only).
|
||||
* Appelé depuis :
|
||||
* - SendRelanceJob (relance_sent)
|
||||
* - InvoicesController.markPaid (invoice_paid)
|
||||
* - ImportBatchesController.validateDraft (invoice_imported)
|
||||
* - SendRelanceJob quand step.requires_manual_validation (warning_drafted)
|
||||
*
|
||||
* Les labels acceptent un HTML léger (<b>) pour permettre au SPA de
|
||||
* mettre en gras les noms d'entité — toujours composé côté serveur,
|
||||
* jamais d'input utilisateur brut.
|
||||
*/
|
||||
export async function recordActivity(opts: RecordOpts): Promise<ActivityEvent> {
|
||||
const { organizationId, kind, label, meta = {}, at, trx } = opts
|
||||
return ActivityEvent.create(
|
||||
{
|
||||
organizationId,
|
||||
kind,
|
||||
label,
|
||||
meta,
|
||||
at: at ?? DateTime.now(),
|
||||
},
|
||||
trx ? { client: trx } : undefined
|
||||
)
|
||||
}
|
||||
156
apps/api/app/services/ai_relance_generator.ts
Normal file
156
apps/api/app/services/ai_relance_generator.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import env from '#start/env'
|
||||
|
||||
const MISTRAL_API = 'https://api.mistral.ai/v1'
|
||||
// Modèle chat rapide et bon en français pour générer du texte court.
|
||||
// `mistral-small-latest` est ~10x moins cher que `mistral-large` et
|
||||
// largement suffisant pour 3 paragraphes de relance.
|
||||
const GENERATION_MODEL = 'mistral-small-latest'
|
||||
|
||||
export type RelanceTone = 'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'
|
||||
|
||||
export type GenerateRelanceInput = {
|
||||
/** Tonalité ciblée — guide le ton du modèle. */
|
||||
tone: RelanceTone
|
||||
/** Position de l'étape dans le plan (J+3, J+10…). Influence l'urgence. */
|
||||
offsetDays: number
|
||||
/** Brief libre rédigé par l'utilisateur (ex. "rappelle qu'on accepte les virements"). */
|
||||
prompt: string
|
||||
/** Nom du plan parent — donne du contexte au modèle (ex. "Clients fidèles"). */
|
||||
planName?: string
|
||||
/** Description du plan parent — quand utiliser ce plan, ICP visé. */
|
||||
planDescription?: string
|
||||
}
|
||||
|
||||
export type GenerateRelanceOutput = {
|
||||
subject: string
|
||||
body: string
|
||||
}
|
||||
|
||||
const TONE_GUIDANCE: Record<RelanceTone, string> = {
|
||||
amical:
|
||||
"Ton chaleureux et bienveillant, presque comme un message à un partenaire de confiance. Pas de pression. On commence par 'Bonjour' suivi du prénom si dispo, sinon du nom de l'entreprise.",
|
||||
courtois:
|
||||
'Ton professionnel et factuel. Poli, neutre, pas de chaleur excessive ni de menace. Standard B2B.',
|
||||
ferme:
|
||||
"Ton ferme et direct. Rappelle l'engagement contractuel. Reste poli mais sans formule de politesse excessive. Pas d'agressivité.",
|
||||
mise_en_demeure:
|
||||
"Ton formel et juridique. Mentionne explicitement 'mise en demeure', un délai de paiement (8 jours), et les conséquences légales (pénalités de retard, voie judiciaire). Reste factuel, pas émotionnel.",
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Tu rédiges des emails de relance de factures impayées en français pour une TPE-PME française.
|
||||
|
||||
# Règles de rédaction
|
||||
- Toujours en français.
|
||||
- Vouvoie systématiquement le destinataire (B2B France).
|
||||
- Concis : 4 à 8 phrases maximum pour le corps.
|
||||
- Une salutation au début, et termine TOUJOURS le corps par {{signature}} sur sa propre ligne. **Ne jamais réécrire le nom de l'expéditeur ni l'entreprise à la main après {{signature}}** : la variable contient déjà tout (nom + entreprise + formule de politesse choisie par l'utilisateur).
|
||||
|
||||
# Syntaxe des variables — IMPORTANT
|
||||
- Utilise UNIQUEMENT la substitution simple \`{{nom.de.variable}}\`.
|
||||
- N'utilise JAMAIS la syntaxe de sections \`{{#var}}...{{/var}}\`, \`{{^var}}...{{/var}}\`, ni aucune syntaxe conditionnelle. Notre interpréteur ne fait que de la substitution simple — toute syntaxe avancée s'affichera telle quelle dans l'email final.
|
||||
- Tu n'es **PAS obligé** d'utiliser toutes les variables. Choisis celles qui rendent le message naturel et utile. Mieux vaut un message simple et clair qu'un message bourré de variables.
|
||||
|
||||
# Variables disponibles
|
||||
- {{client.name}} : raison sociale du client (toujours rempli)
|
||||
- {{client.contactFirstName}} : prénom du contact (peut être vide à l'envoi — dans ce cas la variable s'efface silencieusement, donc préfère une formule qui marche dans les deux cas, ex. "Bonjour {{client.contactFirstName}}," où l'absence du prénom donne juste "Bonjour ,")
|
||||
- {{client.contactLastName}} : nom du contact (peut être vide)
|
||||
- {{numero}} : numéro de la facture
|
||||
- {{amount}} : montant TTC formaté (ex. "1 240,00 €")
|
||||
- {{dueDate}} : date d'échéance (ex. "15/04/2026")
|
||||
- {{issueDate}} : date d'émission
|
||||
- {{daysLate}} : jours de retard (entier — peut être négatif si la relance est avant échéance)
|
||||
- {{user.fullName}} : nom de l'expéditeur (rarement utile dans le corps si on a déjà {{signature}})
|
||||
- {{user.companyName}} : nom de l'entreprise expéditrice
|
||||
- {{signature}} : bloc signature complet — termine TOUJOURS le corps par cette variable
|
||||
|
||||
# Format de retour
|
||||
JSON strict avec deux clés :
|
||||
- "subject" : sujet (max 100 caractères, naturel, peut contenir {{numero}})
|
||||
- "body" : corps de l'email`
|
||||
|
||||
/**
|
||||
* Génère un email de relance via Mistral. Retourne `{ subject, body }`
|
||||
* avec des placeholders Mustache prêts à être interpolés à l'envoi.
|
||||
*
|
||||
* Coût : ~0.0001 € par appel sur `mistral-small-latest` (négligeable).
|
||||
*/
|
||||
export async function generateRelance(input: GenerateRelanceInput): Promise<GenerateRelanceOutput> {
|
||||
const apiKey = env.get('MISTRAL_API_KEY', '')
|
||||
if (!apiKey) {
|
||||
throw new Error('MISTRAL_API_KEY manquante : génération IA indisponible.')
|
||||
}
|
||||
|
||||
const offsetExplanation =
|
||||
input.offsetDays < 0
|
||||
? `${Math.abs(input.offsetDays)} jours **avant** l'échéance (rappel anticipé)`
|
||||
: input.offsetDays === 0
|
||||
? "le **jour J** de l'échéance"
|
||||
: `${input.offsetDays} jours **après** l'échéance (la facture est en retard)`
|
||||
|
||||
const userMessage = [
|
||||
'# Plan parent',
|
||||
input.planName ? `Nom : ${input.planName}` : 'Nom : (non précisé)',
|
||||
input.planDescription
|
||||
? `Description : ${input.planDescription}`
|
||||
: 'Description : (aucune)',
|
||||
'',
|
||||
'# Cette relance',
|
||||
`Tonalité : ${input.tone} → ${TONE_GUIDANCE[input.tone]}`,
|
||||
`Timing : J${input.offsetDays >= 0 ? '+' : ''}${input.offsetDays} → ${offsetExplanation}.`,
|
||||
'',
|
||||
"# Brief de l'utilisateur",
|
||||
input.prompt.trim() ||
|
||||
'(aucun brief — rédige un message standard pour cette tonalité et ce timing, en restant naturel)',
|
||||
].join('\n')
|
||||
|
||||
const res = await fetch(`${MISTRAL_API}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: GENERATION_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'relance_email',
|
||||
strict: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
subject: { type: 'string' },
|
||||
body: { type: 'string' },
|
||||
},
|
||||
required: ['subject', 'body'],
|
||||
},
|
||||
},
|
||||
},
|
||||
temperature: 0.7,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`Mistral génération relance → HTTP ${res.status}: ${text}`)
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[]
|
||||
}
|
||||
const content = json?.choices?.[0]?.message?.content
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('Mistral chat: pas de content string dans la réponse')
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(content) as GenerateRelanceOutput
|
||||
return {
|
||||
subject: parsed.subject.slice(0, 200),
|
||||
body: parsed.body.slice(0, 5000),
|
||||
}
|
||||
}
|
||||
36
apps/api/app/services/auth_session.ts
Normal file
36
apps/api/app/services/auth_session.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { DateTime } from 'luxon'
|
||||
import User from '#models/user'
|
||||
import UserTransformer from '#transformers/user_transformer'
|
||||
import env from '#start/env'
|
||||
import { issueRefreshToken } from '#services/refresh_token'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
/**
|
||||
* Émet une AuthSession complète : access token en JSON + refresh token
|
||||
* en cookie httpOnly. Utilisé par signup et login.
|
||||
*
|
||||
* Format de réponse aligné sur packages/shared/src/types/auth.ts :
|
||||
* `{ data: { accessToken, expiresAt, user } }`
|
||||
*/
|
||||
export async function emitAuthSession(
|
||||
user: User,
|
||||
ctx: HttpContext
|
||||
): Promise<{
|
||||
accessToken: string
|
||||
expiresAt: string
|
||||
user: ReturnType<UserTransformer['toObject']>
|
||||
}> {
|
||||
const accessToken = await User.accessTokens.create(user)
|
||||
await issueRefreshToken(user, ctx)
|
||||
|
||||
const ttlMin = env.get('ACCESS_TOKEN_TTL_MINUTES', 30)
|
||||
const expiresAt =
|
||||
accessToken.expiresAt?.toISOString() ??
|
||||
DateTime.now().plus({ minutes: ttlMin }).toISO()!
|
||||
|
||||
return {
|
||||
accessToken: accessToken.value!.release(),
|
||||
expiresAt,
|
||||
user: new UserTransformer(user).toObject(),
|
||||
}
|
||||
}
|
||||
106
apps/api/app/services/checkin_scheduler.ts
Normal file
106
apps/api/app/services/checkin_scheduler.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { DateTime } from 'luxon'
|
||||
import CheckinTask from '#models/checkin_task'
|
||||
import Invoice from '#models/invoice'
|
||||
import { getQueue } from '#services/queue'
|
||||
import { generateCheckinToken } from '#services/checkin_token'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||
|
||||
const CHECKIN_QUEUE = 'checkins'
|
||||
|
||||
function shouldEnqueue(): boolean {
|
||||
return app.getEnvironment() !== 'test'
|
||||
}
|
||||
|
||||
/**
|
||||
* Programme un check-in pour une facture.
|
||||
*
|
||||
* V1 : 1 check-in par facture, envoyé à `dueDate` (pile à l'échéance).
|
||||
* Si dueDate est dans le passé → envoie immédiat (à `now + 1min`),
|
||||
* pour que les factures importées en retard reçoivent quand même un
|
||||
* check-in.
|
||||
*
|
||||
* Le token est généré ici (plain) — on retourne le plain pour permettre
|
||||
* au caller de le passer dans des emails de test si besoin, mais en
|
||||
* pratique seul le hash est stocké et lu via SendCheckinJob.
|
||||
*
|
||||
* Idempotent par invoice : si une CheckinTask `scheduled` existe déjà,
|
||||
* on la cancelle d'abord puis on en crée une nouvelle (cas re-scheduling
|
||||
* après changement de dueDate).
|
||||
*
|
||||
* En tests : la task DB est créée mais l'enqueue BullMQ est skippé
|
||||
* (les tx auto-rollback laisseraient des jobs orphelins en Redis sinon).
|
||||
*/
|
||||
export async function scheduleCheckinForInvoice(
|
||||
invoice: Invoice,
|
||||
trx?: TransactionClientContract
|
||||
): Promise<{ task: CheckinTask; plain: string } | null> {
|
||||
// Cancel l'éventuelle CheckinTask scheduled précédente.
|
||||
const existing = await CheckinTask.query(trx ? { client: trx } : undefined)
|
||||
.where('invoice_id', invoice.id)
|
||||
.where('status', 'scheduled')
|
||||
const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null
|
||||
for (const t of existing) {
|
||||
if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {})
|
||||
t.useTransaction(trx ?? (null as never))
|
||||
t.status = 'expired'
|
||||
await t.save()
|
||||
}
|
||||
|
||||
const now = DateTime.now()
|
||||
const sendAtRaw = invoice.dueDate
|
||||
const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw
|
||||
|
||||
const { plain, hashed } = generateCheckinToken()
|
||||
|
||||
const task = await CheckinTask.create(
|
||||
{
|
||||
organizationId: invoice.organizationId,
|
||||
invoiceId: invoice.id,
|
||||
sendAt,
|
||||
tokenHash: hashed,
|
||||
status: 'scheduled',
|
||||
sentAt: null,
|
||||
answeredAt: null,
|
||||
answer: null,
|
||||
},
|
||||
trx ? { client: trx } : undefined
|
||||
)
|
||||
|
||||
if (queue) {
|
||||
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
|
||||
await queue.add(
|
||||
'send-checkin',
|
||||
{ taskId: task.id, plain },
|
||||
{
|
||||
delay,
|
||||
jobId: `checkin-${task.id}`,
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 30_000 },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return { task, plain }
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule le check-in scheduled d'une facture (appelé par mark-paid).
|
||||
*/
|
||||
export async function cancelCheckinForInvoice(
|
||||
invoiceId: string,
|
||||
trx?: TransactionClientContract
|
||||
): Promise<void> {
|
||||
const tasks = await CheckinTask.query(trx ? { client: trx } : undefined)
|
||||
.where('invoice_id', invoiceId)
|
||||
.where('status', 'scheduled')
|
||||
if (tasks.length === 0) return
|
||||
|
||||
const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null
|
||||
for (const t of tasks) {
|
||||
if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {})
|
||||
t.useTransaction(trx ?? (null as never))
|
||||
t.status = 'expired'
|
||||
await t.save()
|
||||
}
|
||||
}
|
||||
16
apps/api/app/services/checkin_token.ts
Normal file
16
apps/api/app/services/checkin_token.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Tokens check-in : 32 bytes random → base64url. On stocke le hash
|
||||
* SHA-256 en DB (CheckinTask.token_hash). Pas de signature HMAC : le
|
||||
* token est purement opaque, sa "signature" c'est sa présence en DB.
|
||||
*/
|
||||
export function generateCheckinToken(): { plain: string; hashed: string } {
|
||||
const plain = crypto.randomBytes(32).toString('base64url')
|
||||
const hashed = crypto.createHash('sha256').update(plain).digest('hex')
|
||||
return { plain, hashed }
|
||||
}
|
||||
|
||||
export function hashCheckinToken(plain: string): string {
|
||||
return crypto.createHash('sha256').update(plain).digest('hex')
|
||||
}
|
||||
91
apps/api/app/services/client_stats.ts
Normal file
91
apps/api/app/services/client_stats.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
|
||||
/**
|
||||
* Stats agrégées d'un client. Calculées on-the-fly à partir des invoices
|
||||
* (V1 : pas de cache, le volume reste raisonnable). Si le perf devient un
|
||||
* sujet, on cachera dans Redis avec invalidation post-mutation invoice.
|
||||
*/
|
||||
export type ClientStats = {
|
||||
invoiceCount: number
|
||||
activeInvoiceCount: number
|
||||
lateInvoiceCount: number
|
||||
paidInvoiceCount: number
|
||||
paidLifetimeCents: number
|
||||
pendingLifetimeCents: number
|
||||
lastActivityAt: string | null
|
||||
}
|
||||
|
||||
export const EMPTY_CLIENT_STATS: ClientStats = {
|
||||
invoiceCount: 0,
|
||||
activeInvoiceCount: 0,
|
||||
lateInvoiceCount: 0,
|
||||
paidInvoiceCount: 0,
|
||||
paidLifetimeCents: 0,
|
||||
pendingLifetimeCents: 0,
|
||||
lastActivityAt: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les stats pour un ensemble de clients d'une org en une seule
|
||||
* requête agrégée par client_id. Les clients sans facture reçoivent EMPTY.
|
||||
*
|
||||
* @returns Map clientId → ClientStats
|
||||
*/
|
||||
export async function bulkComputeClientStats(
|
||||
organizationId: string,
|
||||
clientIds: string[]
|
||||
): Promise<Map<string, ClientStats>> {
|
||||
const map = new Map<string, ClientStats>()
|
||||
for (const id of clientIds) {
|
||||
map.set(id, { ...EMPTY_CLIENT_STATS })
|
||||
}
|
||||
|
||||
if (clientIds.length === 0) return map
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const ACTIVE = "('pending','in_relance','awaiting_user_confirmation')"
|
||||
|
||||
const rows = await db
|
||||
.from('invoices')
|
||||
.where('organization_id', organizationId)
|
||||
.whereIn('client_id', clientIds)
|
||||
.select('client_id')
|
||||
.select(db.raw('count(*)::int as invoice_count'))
|
||||
.select(db.raw(`count(*) filter (where status::text in ${ACTIVE})::int as active_count`))
|
||||
.select(
|
||||
db.raw(
|
||||
`count(*) filter (where status::text in ${ACTIVE} and due_date < ?)::int as late_count`,
|
||||
[today]
|
||||
)
|
||||
)
|
||||
.select(db.raw(`count(*) filter (where status = 'paid')::int as paid_count`))
|
||||
.select(
|
||||
db.raw(`coalesce(sum(amount_ttc_cents) filter (where status = 'paid'), 0)::int as paid_cents`)
|
||||
)
|
||||
.select(
|
||||
db.raw(
|
||||
`coalesce(sum(amount_ttc_cents) filter (where status::text in ${ACTIVE}), 0)::int as pending_cents`
|
||||
)
|
||||
)
|
||||
.select(db.raw('max(updated_at) as last_activity'))
|
||||
.groupBy('client_id')
|
||||
|
||||
for (const r of rows) {
|
||||
map.set(r.client_id, {
|
||||
invoiceCount: r.invoice_count,
|
||||
activeInvoiceCount: r.active_count,
|
||||
lateInvoiceCount: r.late_count,
|
||||
paidInvoiceCount: r.paid_count,
|
||||
paidLifetimeCents: r.paid_cents,
|
||||
pendingLifetimeCents: r.pending_cents,
|
||||
lastActivityAt:
|
||||
r.last_activity instanceof Date
|
||||
? r.last_activity.toISOString()
|
||||
: (r.last_activity as string | null),
|
||||
})
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
150
apps/api/app/services/dashboard.ts
Normal file
150
apps/api/app/services/dashboard.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import Organization from '#models/organization'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
|
||||
|
||||
export type DashboardKpis = {
|
||||
rubisCount: number
|
||||
rubisThisMonth: number
|
||||
// 1 rubis = 10 minutes libérées (cf. CLAUDE.md → glossaire)
|
||||
hoursLiberatedThisMonth: number
|
||||
encaisseCents: number
|
||||
encaisseDeltaCents: number
|
||||
dsoDays: number
|
||||
dsoDeltaDays: number
|
||||
factureToRelance: number
|
||||
factureInRelance: number
|
||||
factureNewToday: number
|
||||
miseEnDemeurePending: number
|
||||
monthlyGoalProgress: number
|
||||
// Rang relatif à la cohorte (placeholder V1, calculé en V2 avec assez de data)
|
||||
percentile?: number
|
||||
}
|
||||
|
||||
function startOfMonth(d: DateTime): Date {
|
||||
return d.startOf('month').toJSDate()
|
||||
}
|
||||
|
||||
function startOfDay(d: DateTime): Date {
|
||||
return d.startOf('day').toJSDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les KPIs dashboard pour une organisation.
|
||||
*
|
||||
* V1 — implémentation simple sans cache. Quelques metrics avancés
|
||||
* (DSO, percentile) sont à 0 ou null tant qu'on a pas assez d'historique.
|
||||
* Le contrat reste stable côté SPA.
|
||||
*/
|
||||
export async function computeKpis(organizationId: string): Promise<DashboardKpis> {
|
||||
const now = DateTime.now()
|
||||
const monthStart = startOfMonth(now)
|
||||
const todayStart = startOfDay(now)
|
||||
const prevMonthStart = startOfMonth(now.minus({ months: 1 }))
|
||||
|
||||
const org = await Organization.findOrFail(organizationId)
|
||||
|
||||
// Counts par statut + factures récentes
|
||||
const counts = (await db
|
||||
.from('invoices')
|
||||
.where('organization_id', organizationId)
|
||||
.select(
|
||||
db.raw(`count(*) filter (where status = 'pending')::int as to_relance`),
|
||||
db.raw(`count(*) filter (where status = 'in_relance')::int as in_relance`),
|
||||
db.raw(`count(*) filter (where created_at >= ?)::int as new_today`, [todayStart])
|
||||
)
|
||||
.first()) as { to_relance: number; in_relance: number; new_today: number } | undefined
|
||||
|
||||
// Sommes d'encaissement (paid_at) ce mois et le précédent
|
||||
const paidStats = (await db
|
||||
.from('invoices')
|
||||
.where('organization_id', organizationId)
|
||||
.where('status', 'paid')
|
||||
.select(
|
||||
db.raw(
|
||||
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ?), 0)::int as this_month`,
|
||||
[monthStart]
|
||||
),
|
||||
db.raw(
|
||||
`coalesce(sum(amount_ttc_cents) filter (where paid_at >= ? and paid_at < ?), 0)::int as prev_month`,
|
||||
[prevMonthStart, monthStart]
|
||||
),
|
||||
db.raw(
|
||||
`coalesce(sum(rubis_earned) filter (where paid_at >= ?), 0)::int as rubis_this_month`,
|
||||
[monthStart]
|
||||
),
|
||||
db.raw(
|
||||
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ?), 0)::int as dso_this_month`,
|
||||
[monthStart]
|
||||
),
|
||||
db.raw(
|
||||
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ? and paid_at < ?), 0)::int as dso_prev_month`,
|
||||
[prevMonthStart, monthStart]
|
||||
)
|
||||
)
|
||||
.first()) as
|
||||
| {
|
||||
this_month: number
|
||||
prev_month: number
|
||||
rubis_this_month: number
|
||||
dso_this_month: number
|
||||
dso_prev_month: number
|
||||
}
|
||||
| undefined
|
||||
|
||||
const encaisseCents = paidStats?.this_month ?? 0
|
||||
const encaisseDeltaCents = encaisseCents - (paidStats?.prev_month ?? 0)
|
||||
const rubisThisMonth = paidStats?.rubis_this_month ?? 0
|
||||
const dsoDays = paidStats?.dso_this_month ?? 0
|
||||
const dsoDeltaDays = dsoDays - (paidStats?.dso_prev_month ?? 0)
|
||||
|
||||
return {
|
||||
rubisCount: org.rubisCount,
|
||||
rubisThisMonth,
|
||||
hoursLiberatedThisMonth: rubisThisMonth * 10,
|
||||
encaisseCents,
|
||||
encaisseDeltaCents,
|
||||
dsoDays,
|
||||
dsoDeltaDays,
|
||||
factureToRelance: counts?.to_relance ?? 0,
|
||||
factureInRelance: counts?.in_relance ?? 0,
|
||||
factureNewToday: counts?.new_today ?? 0,
|
||||
// Mise en demeure pending — sera calculé quand RelanceTask est branché
|
||||
// (count des steps requires_manual_validation programmées). Pour V1 : 0.
|
||||
miseEnDemeurePending: 0,
|
||||
// Goal progress (V1 placeholder) : ratio rubis_count / 250 (objectif
|
||||
// mensuel arbitraire). À paramétrer plus tard.
|
||||
monthlyGoalProgress: Math.min(100, Math.round((rubisThisMonth / 25) * 100)),
|
||||
percentile: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Top des clients en retard (top 5 par défaut).
|
||||
* Compte les factures actives dont due_date est dépassée, agrégées par client.
|
||||
*/
|
||||
export async function topLatePayers(
|
||||
organizationId: string,
|
||||
limit = 5
|
||||
): Promise<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
|
||||
const today = startOfDay(DateTime.now())
|
||||
|
||||
const rows = await db
|
||||
.from('invoices')
|
||||
.innerJoin('clients', 'clients.id', 'invoices.client_id')
|
||||
.where('invoices.organization_id', organizationId)
|
||||
.whereRaw(`invoices.status::text in ${ACTIVE_INVOICE_STATUSES}`)
|
||||
.where('invoices.due_date', '<', today)
|
||||
.groupBy('clients.id', 'clients.name')
|
||||
.select('clients.id as client_id', 'clients.name as name')
|
||||
.select(db.raw('count(*)::int as late_invoices_count'))
|
||||
.orderBy('late_invoices_count', 'desc')
|
||||
.limit(limit)
|
||||
|
||||
return rows.map((r) => ({
|
||||
clientId: r.client_id,
|
||||
name: r.name,
|
||||
lateInvoicesCount: r.late_invoices_count,
|
||||
}))
|
||||
}
|
||||
205
apps/api/app/services/default_plans.ts
Normal file
205
apps/api/app/services/default_plans.ts
Normal file
@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Source de vérité des 4 plans pré-fournis (cf. CLAUDE.md → Périmètre V1).
|
||||
* Dupliqués dans chaque organisation à la création (signup) — V1 mono-tenant
|
||||
* mais l'isolation est totale, on peut éditer le plan d'une org sans toucher
|
||||
* aux autres.
|
||||
*
|
||||
* Les valeurs (cadences, tons, sujets) doivent rester alignées sur le seed
|
||||
* MSW (apps/web/src/mocks/seed.ts → SEED_PLANS) tant que les deux coexistent.
|
||||
*/
|
||||
|
||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||
import Plan from '#models/plan'
|
||||
import PlanStep from '#models/plan_step'
|
||||
|
||||
type DefaultStep = {
|
||||
order: number
|
||||
offsetDays: number
|
||||
tone: 'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'
|
||||
subject: string
|
||||
body: string
|
||||
requiresManualValidation: boolean
|
||||
}
|
||||
|
||||
type DefaultPlan = {
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
steps: DefaultStep[]
|
||||
}
|
||||
|
||||
export const DEFAULT_PLANS: DefaultPlan[] = [
|
||||
{
|
||||
slug: 'standard-30j',
|
||||
name: 'Standard B2B',
|
||||
description:
|
||||
'Cadence sobre, ton qui monte progressivement. Pour la majorité des clients sérieux.',
|
||||
steps: [
|
||||
{
|
||||
order: 0,
|
||||
offsetDays: 3,
|
||||
tone: 'amical',
|
||||
subject: 'Petit rappel — facture {{numero}}',
|
||||
body:
|
||||
"Bonjour {{client.name}},\n\nNous espérons que tout va bien. Un petit rappel concernant la facture {{numero}} d'un montant de {{amount}}, échue le {{dueDate}}.\n\nMerci d'avance,\n{{signature}}",
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
offsetDays: 10,
|
||||
tone: 'courtois',
|
||||
subject: 'Relance — facture {{numero}} en retard',
|
||||
body:
|
||||
"Bonjour {{client.name}},\n\nSauf erreur de notre part, la facture {{numero}} d'un montant de {{amount}} reste impayée.\n\nMerci de procéder au règlement dans les meilleurs délais.\n\n{{signature}}",
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
offsetDays: 25,
|
||||
tone: 'ferme',
|
||||
subject: 'Mise en demeure — facture {{numero}}',
|
||||
body:
|
||||
"Bonjour {{client.name}},\n\nMalgré nos relances, la facture {{numero}} d'un montant de {{amount}} reste impayée. Nous vous mettons en demeure de régler sous 8 jours.\n\n{{signature}}",
|
||||
requiresManualValidation: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'rapide-15j',
|
||||
name: 'Rapide',
|
||||
description: 'Cadence resserrée pour les factures récurrentes ou les délais courts.',
|
||||
steps: [
|
||||
{
|
||||
order: 0,
|
||||
offsetDays: 1,
|
||||
tone: 'amical',
|
||||
subject: 'Facture {{numero}} échue',
|
||||
body: 'Bonjour, petit rappel pour la facture {{numero}}.\n\n{{signature}}',
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
offsetDays: 7,
|
||||
tone: 'courtois',
|
||||
subject: 'Relance facture {{numero}}',
|
||||
body: 'La facture {{numero}} reste impayée à ce jour. Merci de régulariser.\n\n{{signature}}',
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
offsetDays: 15,
|
||||
tone: 'ferme',
|
||||
subject: 'Mise en demeure {{numero}}',
|
||||
body: 'Mise en demeure formelle de payer sous 8 jours.\n\n{{signature}}',
|
||||
requiresManualValidation: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'patient-60j',
|
||||
name: 'Patient',
|
||||
description: 'Pour les clients de longue date. On laisse respirer avant de relancer.',
|
||||
steps: [
|
||||
{
|
||||
order: 0,
|
||||
offsetDays: 15,
|
||||
tone: 'amical',
|
||||
subject: 'Facture {{numero}}',
|
||||
body: 'Bonjour, simple rappel.\n\n{{signature}}',
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
offsetDays: 30,
|
||||
tone: 'courtois',
|
||||
subject: 'Relance facture {{numero}}',
|
||||
body: 'Merci de régulariser dans les meilleurs délais.\n\n{{signature}}',
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'ferme-7j',
|
||||
name: 'Ferme',
|
||||
description: 'Cadence stricte pour les clients à risque ou les retards récurrents.',
|
||||
steps: [
|
||||
{
|
||||
order: 0,
|
||||
offsetDays: 1,
|
||||
tone: 'courtois',
|
||||
subject: 'Facture {{numero}}',
|
||||
body: 'Premier rappel.\n\n{{signature}}',
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
offsetDays: 5,
|
||||
tone: 'ferme',
|
||||
subject: 'Relance ferme {{numero}}',
|
||||
body: 'Le règlement est attendu sous 48h.\n\n{{signature}}',
|
||||
requiresManualValidation: false,
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
offsetDays: 10,
|
||||
tone: 'mise_en_demeure',
|
||||
subject: 'Mise en demeure {{numero}}',
|
||||
body: 'Mise en demeure formelle.\n\n{{signature}}',
|
||||
requiresManualValidation: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Provisionne les 4 plans par défaut pour une organisation fraîchement créée.
|
||||
* Idempotent : si l'org a déjà un plan avec un slug, on n'écrase pas.
|
||||
*
|
||||
* À appeler dans la transaction de signup.
|
||||
*/
|
||||
export async function provisionDefaultPlans(
|
||||
organizationId: string,
|
||||
trx: TransactionClientContract
|
||||
): Promise<Plan[]> {
|
||||
const existing = await Plan.query({ client: trx })
|
||||
.where('organization_id', organizationId)
|
||||
.whereIn(
|
||||
'slug',
|
||||
DEFAULT_PLANS.map((p) => p.slug)
|
||||
)
|
||||
.select('slug')
|
||||
const existingSlugs = new Set(existing.map((p) => p.slug))
|
||||
|
||||
const created: Plan[] = []
|
||||
for (const tpl of DEFAULT_PLANS) {
|
||||
if (existingSlugs.has(tpl.slug)) continue
|
||||
|
||||
const plan = await Plan.create(
|
||||
{
|
||||
organizationId,
|
||||
slug: tpl.slug,
|
||||
name: tpl.name,
|
||||
description: tpl.description,
|
||||
isDefault: true,
|
||||
},
|
||||
{ client: trx }
|
||||
)
|
||||
|
||||
await PlanStep.createMany(
|
||||
tpl.steps.map((s) => ({
|
||||
planId: plan.id,
|
||||
order: s.order,
|
||||
offsetDays: s.offsetDays,
|
||||
tone: s.tone,
|
||||
subject: s.subject,
|
||||
body: s.body,
|
||||
requiresManualValidation: s.requiresManualValidation,
|
||||
})),
|
||||
{ client: trx }
|
||||
)
|
||||
|
||||
created.push(plan)
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
160
apps/api/app/services/import_batch.ts
Normal file
160
apps/api/app/services/import_batch.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import ImportBatch from '#models/import_batch'
|
||||
import ImportDraft from '#models/import_draft'
|
||||
import Client from '#models/client'
|
||||
import Plan from '#models/plan'
|
||||
import { getOcrProvider } from '#services/ocr/index'
|
||||
import type { OcrResult } from '#services/ocr/ocr_provider'
|
||||
|
||||
export type DraftFields = {
|
||||
clientId: string | null
|
||||
clientName: string
|
||||
clientEmail: string | null
|
||||
numero: string
|
||||
amountTtcCents: number
|
||||
issueDate: string
|
||||
dueDate: string
|
||||
planId: string | null
|
||||
}
|
||||
|
||||
export type DraftConfidence = Partial<{
|
||||
clientId: number
|
||||
clientName: number
|
||||
clientEmail: number
|
||||
numero: number
|
||||
amountTtcCents: number
|
||||
issueDate: number
|
||||
dueDate: number
|
||||
planId: number
|
||||
}>
|
||||
|
||||
/**
|
||||
* Une "source" de draft : un filename + (optionnellement) une storageKey
|
||||
* MinIO du PDF stocké. Mock OCR ignore storageKey, Mistral l'exige.
|
||||
*/
|
||||
export type ImportSource = {
|
||||
filename: string
|
||||
storageKey: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose `extracted` + `confidence` à partir du résultat OCR. Tente un
|
||||
* match client immédiat (case-insensitive) pour pré-remplir `clientId`.
|
||||
*/
|
||||
async function buildDraftFromOcr(
|
||||
organizationId: string,
|
||||
ocr: OcrResult,
|
||||
defaultPlanId: string | null
|
||||
): Promise<{ extracted: DraftFields; confidence: DraftConfidence }> {
|
||||
const matchedClient = await Client.query()
|
||||
.where('organization_id', organizationId)
|
||||
.whereILike('name', ocr.fields.clientName.value)
|
||||
.first()
|
||||
|
||||
return {
|
||||
extracted: {
|
||||
clientId: matchedClient?.id ?? null,
|
||||
clientName: matchedClient?.name ?? ocr.fields.clientName.value,
|
||||
clientEmail: matchedClient?.email ?? ocr.fields.clientEmail.value,
|
||||
numero: ocr.fields.numero.value,
|
||||
amountTtcCents: ocr.fields.amountTtcCents.value,
|
||||
issueDate: ocr.fields.issueDate.value,
|
||||
dueDate: ocr.fields.dueDate.value,
|
||||
planId: defaultPlanId,
|
||||
},
|
||||
confidence: {
|
||||
clientName: matchedClient ? 1 : ocr.fields.clientName.confidence,
|
||||
clientEmail: ocr.fields.clientEmail.confidence,
|
||||
numero: ocr.fields.numero.confidence,
|
||||
amountTtcCents: ocr.fields.amountTtcCents.confidence,
|
||||
issueDate: ocr.fields.issueDate.confidence,
|
||||
dueDate: ocr.fields.dueDate.confidence,
|
||||
planId: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un batch + N drafts à partir de N sources (filename + storageKey).
|
||||
* Le provider OCR (mock ou mistral) est résolu à l'intérieur.
|
||||
*
|
||||
* - Mock : storageKey=null OK, extraction depuis filename
|
||||
* - Mistral : storageKey requis, extraction depuis le PDF stocké
|
||||
*/
|
||||
export async function createImportBatch(
|
||||
organizationId: string,
|
||||
sources: ImportSource[]
|
||||
): Promise<ImportBatch> {
|
||||
const ocr = getOcrProvider()
|
||||
|
||||
// Plan par défaut = premier is_default de l'org (provisionné au signup).
|
||||
const defaultPlan = await Plan.query()
|
||||
.where('organization_id', organizationId)
|
||||
.where('is_default', true)
|
||||
.orderBy('name', 'asc')
|
||||
.first()
|
||||
|
||||
// OCR fait HORS transaction (calls réseau lents, on ne tient pas de lock
|
||||
// PG pendant). Si l'OCR échoue, l'erreur remonte avant le INSERT.
|
||||
type DraftPayload = {
|
||||
filename: string
|
||||
storageKey: string | null
|
||||
extracted: DraftFields
|
||||
edited: DraftFields
|
||||
confidence: DraftConfidence
|
||||
}
|
||||
const drafts: DraftPayload[] = []
|
||||
|
||||
for (const src of sources) {
|
||||
const result = await ocr.extract(src)
|
||||
const { extracted, confidence } = await buildDraftFromOcr(
|
||||
organizationId,
|
||||
result,
|
||||
defaultPlan?.id ?? null
|
||||
)
|
||||
drafts.push({
|
||||
filename: src.filename,
|
||||
storageKey: src.storageKey,
|
||||
extracted,
|
||||
edited: { ...extracted },
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
return db.transaction(async (trx) => {
|
||||
const batch = await ImportBatch.create({ organizationId }, { client: trx })
|
||||
|
||||
for (const d of drafts) {
|
||||
await ImportDraft.create(
|
||||
{
|
||||
batchId: batch.id,
|
||||
filename: d.filename,
|
||||
pdfStorageKey: d.storageKey,
|
||||
extracted: d.extracted,
|
||||
edited: d.edited,
|
||||
confidence: d.confidence,
|
||||
status: 'pending',
|
||||
invoiceId: null,
|
||||
},
|
||||
{ client: trx }
|
||||
)
|
||||
}
|
||||
|
||||
await batch.load('drafts')
|
||||
return batch
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper compat : V1 mock JSON `{filenames}` → sources avec storageKey null.
|
||||
* @deprecated Préférer `createImportBatch` avec sources explicites.
|
||||
*/
|
||||
export async function createImportBatchFromFilenames(
|
||||
organizationId: string,
|
||||
filenames: string[]
|
||||
): Promise<ImportBatch> {
|
||||
return createImportBatch(
|
||||
organizationId,
|
||||
filenames.map((filename) => ({ filename, storageKey: null }))
|
||||
)
|
||||
}
|
||||
154
apps/api/app/services/mail_dispatcher.ts
Normal file
154
apps/api/app/services/mail_dispatcher.ts
Normal file
@ -0,0 +1,154 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
20
apps/api/app/services/ocr/index.ts
Normal file
20
apps/api/app/services/ocr/index.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import env from '#start/env'
|
||||
import type { OcrProvider } from '#services/ocr/ocr_provider'
|
||||
import { MockOcrProvider } from '#services/ocr/mock_ocr_provider'
|
||||
import { MistralOcrProvider } from '#services/ocr/mistral_ocr_provider'
|
||||
|
||||
/**
|
||||
* Résout l'implémentation OCR à utiliser selon OCR_PROVIDER.
|
||||
*
|
||||
* - `mock` (default) : MockOcrProvider, données plausibles depuis filename.
|
||||
* Compatible avec /invoices/upload en mode JSON `{filenames}`.
|
||||
* - `mistral` : MistralOcrProvider. Nécessite un PDF stocké (multipart
|
||||
* upload) + MISTRAL_API_KEY. Pas compatible avec le mode JSON.
|
||||
*/
|
||||
export function getOcrProvider(): OcrProvider {
|
||||
const provider = env.get('OCR_PROVIDER', 'mock')
|
||||
if (provider === 'mistral') {
|
||||
return new MistralOcrProvider()
|
||||
}
|
||||
return new MockOcrProvider()
|
||||
}
|
||||
213
apps/api/app/services/ocr/mistral_ocr_provider.ts
Normal file
213
apps/api/app/services/ocr/mistral_ocr_provider.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import drive from '@adonisjs/drive/services/main'
|
||||
import env from '#start/env'
|
||||
import type { OcrProvider, OcrResult } from '#services/ocr/ocr_provider'
|
||||
|
||||
const MISTRAL_API = 'https://api.mistral.ai/v1'
|
||||
// Modèle OCR dédié de Mistral — extrait le texte structuré d'un doc.
|
||||
const OCR_MODEL = 'mistral-ocr-latest'
|
||||
// Modèle chat pour la 2e étape (markdown → JSON typé via json_schema strict).
|
||||
const EXTRACTION_MODEL = 'mistral-large-latest'
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Tu es un extracteur de factures françaises B2B.
|
||||
Tu reçois le markdown d'une facture (issu d'une OCR) et tu retournes un
|
||||
JSON strict avec les champs demandés.
|
||||
|
||||
Règles :
|
||||
- amountTtcCents : montant TTC en centimes (entier). Pas le HT.
|
||||
- issueDate / dueDate : ISO 8601 datetime UTC à 09:00 (ex. "2026-04-15T09:00:00.000Z").
|
||||
- clientEmail : null si absent ou illisible (pas d'invention).
|
||||
- numero : tel qu'imprimé sur la facture.
|
||||
- Si un champ est ambigu, mets une confiance basse (0.3–0.6).`
|
||||
|
||||
/**
|
||||
* Provider OCR Mistral. Pipeline en 2 étapes :
|
||||
* 1. POST /v1/ocr avec le PDF en data URI base64 → markdown structuré
|
||||
* 2. POST /v1/chat/completions avec le markdown + json_schema strict →
|
||||
* extraction typée des champs
|
||||
*
|
||||
* Nécessite un PDF réel (storageKey non null). Pour le dev sans PDF,
|
||||
* utiliser OCR_PROVIDER=mock.
|
||||
*/
|
||||
export class MistralOcrProvider implements OcrProvider {
|
||||
private apiKey: string
|
||||
|
||||
constructor() {
|
||||
const key = env.get('MISTRAL_API_KEY', '')
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
'MISTRAL_API_KEY manquante. Posez la dans .env ou bascule OCR_PROVIDER=mock.'
|
||||
)
|
||||
}
|
||||
this.apiKey = key
|
||||
}
|
||||
|
||||
async extract(input: {
|
||||
storageKey: string | null
|
||||
filename: string
|
||||
}): Promise<OcrResult> {
|
||||
if (!input.storageKey) {
|
||||
throw new Error(
|
||||
`MistralOcrProvider exige un PDF stocké (storageKey). Filename "${input.filename}" reçu sans storageKey — utiliser OCR_PROVIDER=mock pour les uploads sans fichier réel.`
|
||||
)
|
||||
}
|
||||
|
||||
// 1. Télécharge le fichier depuis Drive (MinIO en dev) puis encode en base64.
|
||||
const buffer = await this.downloadAsBuffer(input.storageKey)
|
||||
const mimeType = this.mimeTypeFromFilename(input.filename)
|
||||
const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
|
||||
// 2. OCR → markdown
|
||||
const ocrJson = await this.postJson('/ocr', {
|
||||
model: OCR_MODEL,
|
||||
document: this.documentPayload(dataUri, mimeType),
|
||||
})
|
||||
const markdown = (ocrJson?.pages ?? [])
|
||||
.map((p: { markdown?: string }) => p.markdown ?? '')
|
||||
.join('\n\n')
|
||||
.trim()
|
||||
|
||||
if (!markdown) {
|
||||
throw new Error("Mistral OCR n'a retourné aucun texte exploitable")
|
||||
}
|
||||
|
||||
// 3. Extraction structurée via chat avec json_schema strict.
|
||||
const extracted = await this.extractFields(markdown)
|
||||
|
||||
return {
|
||||
fields: {
|
||||
clientName: { value: extracted.clientName, confidence: extracted._conf.clientName },
|
||||
clientEmail: { value: extracted.clientEmail, confidence: extracted._conf.clientEmail },
|
||||
numero: { value: extracted.numero, confidence: extracted._conf.numero },
|
||||
amountTtcCents: {
|
||||
value: extracted.amountTtcCents,
|
||||
confidence: extracted._conf.amountTtcCents,
|
||||
},
|
||||
issueDate: { value: extracted.issueDate, confidence: extracted._conf.issueDate },
|
||||
dueDate: { value: extracted.dueDate, confidence: extracted._conf.dueDate },
|
||||
},
|
||||
rawProviderResponse: { ocr: ocrJson, extracted },
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadAsBuffer(storageKey: string): Promise<Buffer> {
|
||||
const arr = await drive.use().getArrayBuffer(storageKey)
|
||||
return Buffer.from(arr)
|
||||
}
|
||||
|
||||
private mimeTypeFromFilename(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
const mimeType = MIME_BY_EXT[ext]
|
||||
if (!mimeType) {
|
||||
throw new Error(`Format OCR non supporté pour "${filename}"`)
|
||||
}
|
||||
return mimeType
|
||||
}
|
||||
|
||||
private documentPayload(
|
||||
dataUri: string,
|
||||
mimeType: string
|
||||
):
|
||||
| { type: 'document_url'; document_url: string }
|
||||
| { type: 'image_url'; image_url: string } {
|
||||
if (mimeType === 'application/pdf') {
|
||||
return { type: 'document_url', document_url: dataUri }
|
||||
}
|
||||
return { type: 'image_url', image_url: dataUri }
|
||||
}
|
||||
|
||||
private async postJson(path: string, body: unknown): Promise<any> {
|
||||
const res = await fetch(`${MISTRAL_API}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`Mistral ${path} → HTTP ${res.status}: ${text}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
private async extractFields(markdown: string): Promise<{
|
||||
clientName: string
|
||||
clientEmail: string | null
|
||||
numero: string
|
||||
amountTtcCents: number
|
||||
issueDate: string
|
||||
dueDate: string
|
||||
_conf: Record<string, number>
|
||||
}> {
|
||||
const json = await this.postJson('/chat/completions', {
|
||||
model: EXTRACTION_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: markdown },
|
||||
],
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'invoice_fields',
|
||||
strict: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
clientName: { type: 'string' },
|
||||
clientEmail: { type: ['string', 'null'] },
|
||||
numero: { type: 'string' },
|
||||
amountTtcCents: { type: 'integer' },
|
||||
issueDate: { type: 'string' },
|
||||
dueDate: { type: 'string' },
|
||||
_conf: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
clientName: { type: 'number' },
|
||||
clientEmail: { type: 'number' },
|
||||
numero: { type: 'number' },
|
||||
amountTtcCents: { type: 'number' },
|
||||
issueDate: { type: 'number' },
|
||||
dueDate: { type: 'number' },
|
||||
},
|
||||
required: [
|
||||
'clientName',
|
||||
'clientEmail',
|
||||
'numero',
|
||||
'amountTtcCents',
|
||||
'issueDate',
|
||||
'dueDate',
|
||||
],
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'clientName',
|
||||
'clientEmail',
|
||||
'numero',
|
||||
'amountTtcCents',
|
||||
'issueDate',
|
||||
'dueDate',
|
||||
'_conf',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
temperature: 0,
|
||||
})
|
||||
|
||||
const content = json?.choices?.[0]?.message?.content
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('Mistral chat: pas de content string dans la réponse')
|
||||
}
|
||||
return JSON.parse(content)
|
||||
}
|
||||
}
|
||||
84
apps/api/app/services/ocr/mock_ocr_provider.ts
Normal file
84
apps/api/app/services/ocr/mock_ocr_provider.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import type { OcrProvider, OcrResult } from '#services/ocr/ocr_provider'
|
||||
|
||||
const SAMPLE_CLIENT_NAMES = [
|
||||
'Boulangerie Martin SARL',
|
||||
'Atelier Durand',
|
||||
'Cabinet Rousseau',
|
||||
'Garage Lemoine',
|
||||
'Studio Lefèvre',
|
||||
'Pharmacie Bertrand',
|
||||
'Imprimerie Moreau',
|
||||
] as const
|
||||
|
||||
function rand<T>(arr: readonly T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)]!
|
||||
}
|
||||
|
||||
function randomAmountCents(): number {
|
||||
// entre 80 € et 8 000 €, multiple de 50 cts pour rester crédible
|
||||
return Math.floor(((Math.random() * 7920 + 80) * 100) / 50) * 50
|
||||
}
|
||||
|
||||
function numeroFromFilename(filename: string): string {
|
||||
const match = filename.match(/(\d{2,5})/u)
|
||||
const yr = new Date().getFullYear()
|
||||
const seq = match?.[1] ?? Math.floor(Math.random() * 9000 + 1000).toString()
|
||||
return `F-${yr}-${seq.padStart(4, '0')}`
|
||||
}
|
||||
|
||||
function isoDaysFromNow(days: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + days)
|
||||
d.setHours(9, 0, 0, 0)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function slugify(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/sarl|sa|sas/giu, '')
|
||||
.replace(/[^a-z]+/giu, '-')
|
||||
.replace(/^-+|-+$/gu, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Implémentation OCR mock : génère des champs plausibles depuis le filename
|
||||
* + injecte volontairement quelques confidences basses (~30 %) pour signaler
|
||||
* "champ douteux" dans l'UI de review.
|
||||
*
|
||||
* Aucun appel réseau, aucun PDF téléchargé. Quand Mistral arrive, on swap
|
||||
* cette classe via le container Adonis sans toucher au reste.
|
||||
*/
|
||||
export class MockOcrProvider implements OcrProvider {
|
||||
async extract(input: {
|
||||
storageKey: string | null
|
||||
filename: string
|
||||
}): Promise<OcrResult> {
|
||||
const clientName = rand(SAMPLE_CLIENT_NAMES)
|
||||
// 30 % de chance d'avoir un email douteux (low confidence) — déclenche
|
||||
// la pastille "à vérifier" dans la UI de review.
|
||||
const emailLowConf = Math.random() < 0.3
|
||||
const email = emailLowConf ? null : `compta@${slugify(clientName)}.fr`
|
||||
|
||||
return {
|
||||
fields: {
|
||||
clientName: { value: clientName, confidence: 0.95 },
|
||||
clientEmail: {
|
||||
value: email,
|
||||
confidence: emailLowConf ? 0.42 : 0.88,
|
||||
},
|
||||
numero: { value: numeroFromFilename(input.filename), confidence: 0.97 },
|
||||
amountTtcCents: { value: randomAmountCents(), confidence: 0.93 },
|
||||
issueDate: {
|
||||
value: isoDaysFromNow(-15 - Math.floor(Math.random() * 10)),
|
||||
confidence: 0.9,
|
||||
},
|
||||
dueDate: {
|
||||
value: isoDaysFromNow(15 + Math.floor(Math.random() * 20)),
|
||||
confidence: emailLowConf ? 0.65 : 0.92,
|
||||
},
|
||||
},
|
||||
rawProviderResponse: { provider: 'mock', filename: input.filename },
|
||||
}
|
||||
}
|
||||
}
|
||||
33
apps/api/app/services/ocr/ocr_provider.ts
Normal file
33
apps/api/app/services/ocr/ocr_provider.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Interface OCR — abstraction switchable (cf. backend.md §11.1).
|
||||
*
|
||||
* Implémentations :
|
||||
* - MockOcrProvider : retour plausible depuis le filename, pour les démos
|
||||
* et le dev sans Mistral. C'est le default en V1 (OCR_PROVIDER=mock).
|
||||
* - MistralOcrProvider : à venir (ADR-020), appel API Mistral avec PDF
|
||||
* téléchargé depuis MinIO.
|
||||
*/
|
||||
export interface OcrProvider {
|
||||
extract(input: { storageKey: string | null; filename: string }): Promise<OcrResult>
|
||||
}
|
||||
|
||||
export type OcrFieldName =
|
||||
| 'clientName'
|
||||
| 'clientEmail'
|
||||
| 'numero'
|
||||
| 'amountTtcCents'
|
||||
| 'issueDate'
|
||||
| 'dueDate'
|
||||
|
||||
export type OcrResult = {
|
||||
fields: {
|
||||
clientName: { value: string; confidence: number }
|
||||
clientEmail: { value: string | null; confidence: number }
|
||||
numero: { value: string; confidence: number }
|
||||
amountTtcCents: { value: number; confidence: number }
|
||||
issueDate: { value: string; confidence: number } // ISO 8601
|
||||
dueDate: { value: string; confidence: number }
|
||||
}
|
||||
/** Trace brute du provider — utile pour debug / re-process / audit. */
|
||||
rawProviderResponse?: unknown
|
||||
}
|
||||
60
apps/api/app/services/queue.ts
Normal file
60
apps/api/app/services/queue.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { Queue, Worker, type Processor } from 'bullmq'
|
||||
import { redisConnection, queueNames, type QueueName } from '#config/queue'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
/**
|
||||
* Wrappers BullMQ partagés. Chaque queue a 1 instance Queue (producer)
|
||||
* et N workers (consumers) avec le bon handler.
|
||||
*
|
||||
* V1 : on garde tout en mémoire process — workers et HTTP partagent le
|
||||
* même Node. Quand le volume justifie le coût, on extrait les workers
|
||||
* dans un Deployment K3s séparé (cf. backend.md §13.4).
|
||||
*/
|
||||
|
||||
const queues = new Map<QueueName, Queue>()
|
||||
const workers: Worker[] = []
|
||||
|
||||
export function getQueue(name: QueueName): Queue {
|
||||
let q = queues.get(name)
|
||||
if (!q) {
|
||||
q = new Queue(name, { connection: redisConnection })
|
||||
queues.set(name, q)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
export type JobHandler<T = unknown> = Processor<T>
|
||||
|
||||
/**
|
||||
* Enregistre un Worker BullMQ sur une queue. Démarre tout de suite.
|
||||
* Appelé par start/queue.ts au boot pour câbler les handlers.
|
||||
*/
|
||||
export function registerWorker<T = unknown>(name: QueueName, handler: JobHandler<T>): Worker {
|
||||
const worker = new Worker<T>(name, handler, {
|
||||
connection: redisConnection,
|
||||
concurrency: 5,
|
||||
})
|
||||
worker.on('failed', (job, err) => {
|
||||
logger.error({ err, queue: name, jobId: job?.id }, 'job failed')
|
||||
})
|
||||
worker.on('completed', (job) => {
|
||||
logger.info({ queue: name, jobId: job.id }, 'job completed')
|
||||
})
|
||||
workers.push(worker)
|
||||
return worker
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoppe proprement tous les workers + queues. Appelé au shutdown du
|
||||
* process via Adonis terminating hook.
|
||||
*/
|
||||
export async function shutdownQueue(): Promise<void> {
|
||||
await Promise.all(workers.map((w) => w.close()))
|
||||
await Promise.all(Array.from(queues.values()).map((q) => q.close()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des noms de queue (re-export du config pour ne pas exposer la
|
||||
* connection Redis ailleurs dans l'app).
|
||||
*/
|
||||
export const QUEUES = queueNames
|
||||
151
apps/api/app/services/refresh_token.ts
Normal file
151
apps/api/app/services/refresh_token.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { DateTime } from 'luxon'
|
||||
import RefreshToken from '#models/refresh_token'
|
||||
import User from '#models/user'
|
||||
import env from '#start/env'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
export const REFRESH_COOKIE_NAME = 'rubis_refresh'
|
||||
|
||||
/**
|
||||
* Génère un token plain (32 bytes random → base64url ~43 chars), retourne
|
||||
* { plain, hashed } pour ne stocker que le hashed côté DB.
|
||||
*
|
||||
* SHA-256 suffit : le token est un opaque random non humain, pas un mot
|
||||
* de passe — pas besoin de bcrypt/argon (contrairement aux passwords).
|
||||
*/
|
||||
function generateToken(): { plain: string; hashed: string } {
|
||||
const plain = crypto.randomBytes(32).toString('base64url')
|
||||
const hashed = crypto.createHash('sha256').update(plain).digest('hex')
|
||||
return { plain, hashed }
|
||||
}
|
||||
|
||||
function hashToken(plain: string): string {
|
||||
return crypto.createHash('sha256').update(plain).digest('hex')
|
||||
}
|
||||
|
||||
function ttlDays(): number {
|
||||
return env.get('REFRESH_TOKEN_TTL_DAYS', 30)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose le cookie httpOnly avec le token plain. Le SPA ne peut pas le lire
|
||||
* en JS — c'est ce qui le protège du XSS, contrairement à localStorage.
|
||||
*
|
||||
* `path: /api/v1/auth` : le browser n'envoie le cookie qu'aux endpoints
|
||||
* d'auth, pas sur chaque requête API. Réduit la surface d'attaque CSRF.
|
||||
*/
|
||||
function setRefreshCookie(ctx: HttpContext, plain: string) {
|
||||
const maxAgeSeconds = ttlDays() * 24 * 60 * 60
|
||||
ctx.response.cookie(REFRESH_COOKIE_NAME, plain, {
|
||||
httpOnly: true,
|
||||
secure: env.get('COOKIE_SECURE', false),
|
||||
sameSite: 'strict',
|
||||
path: '/api/v1/auth',
|
||||
domain: env.get('COOKIE_DOMAIN') || undefined,
|
||||
maxAge: maxAgeSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
function clearRefreshCookie(ctx: HttpContext) {
|
||||
ctx.response.clearCookie(REFRESH_COOKIE_NAME, {
|
||||
path: '/api/v1/auth',
|
||||
domain: env.get('COOKIE_DOMAIN') || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un refresh token pour un user et pose le cookie correspondant.
|
||||
* Appelé après signup et login.
|
||||
*/
|
||||
export async function issueRefreshToken(
|
||||
user: User,
|
||||
ctx: HttpContext
|
||||
): Promise<{ token: RefreshToken; plain: string }> {
|
||||
const { plain, hashed } = generateToken()
|
||||
|
||||
const token = await RefreshToken.create({
|
||||
userId: user.id,
|
||||
hashedToken: hashed,
|
||||
expiresAt: DateTime.now().plus({ days: ttlDays() }),
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
ipAddress: ctx.request.ip(),
|
||||
userAgent: ctx.request.header('user-agent') ?? null,
|
||||
})
|
||||
|
||||
setRefreshCookie(ctx, plain)
|
||||
return { token, plain }
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide le cookie reçu et révoque l'ancien token. Retourne le user
|
||||
* authentifié — le contrôleur appelle ensuite `issueRefreshToken` (via
|
||||
* emitAuthSession) pour poser un nouveau cookie. Rotation complète.
|
||||
*
|
||||
* Si le user envoie un token déjà révoqué, on suppose un vol potentiel
|
||||
* et on révoque TOUS les tokens actifs du user (panic mode).
|
||||
*/
|
||||
export async function consumeRefreshToken(
|
||||
ctx: HttpContext
|
||||
): Promise<{ user: User } | { errorCode: 'no_session' | 'session_expired' }> {
|
||||
const cookie = ctx.request.cookie(REFRESH_COOKIE_NAME)
|
||||
if (!cookie) return { errorCode: 'no_session' }
|
||||
|
||||
const hashed = hashToken(cookie)
|
||||
const stored = await RefreshToken.query().where('hashed_token', hashed).first()
|
||||
|
||||
if (!stored) {
|
||||
clearRefreshCookie(ctx)
|
||||
return { errorCode: 'session_expired' }
|
||||
}
|
||||
|
||||
// Token déjà révoqué = signal de vol potentiel : on coupe tout pour
|
||||
// l'user concerné. Le vrai propriétaire devra se re-logger.
|
||||
if (stored.revokedAt) {
|
||||
await revokeAllForUser(stored.userId)
|
||||
clearRefreshCookie(ctx)
|
||||
return { errorCode: 'session_expired' }
|
||||
}
|
||||
|
||||
if (stored.expiresAt < DateTime.now()) {
|
||||
stored.revokedAt = DateTime.now()
|
||||
await stored.save()
|
||||
clearRefreshCookie(ctx)
|
||||
return { errorCode: 'session_expired' }
|
||||
}
|
||||
|
||||
stored.revokedAt = DateTime.now()
|
||||
stored.lastUsedAt = DateTime.now()
|
||||
await stored.save()
|
||||
|
||||
const user = await User.findOrFail(stored.userId)
|
||||
return { user }
|
||||
}
|
||||
|
||||
/**
|
||||
* Révoque le token courant (utilisé par /account/logout).
|
||||
* Pas de panic — l'user demande explicitement la déconnexion.
|
||||
*/
|
||||
export async function revokeCurrentRefreshToken(ctx: HttpContext): Promise<void> {
|
||||
const cookie = ctx.request.cookie(REFRESH_COOKIE_NAME)
|
||||
if (cookie) {
|
||||
const hashed = hashToken(cookie)
|
||||
await RefreshToken.query()
|
||||
.where('hashed_token', hashed)
|
||||
.whereNull('revoked_at')
|
||||
.update({ revoked_at: DateTime.now().toSQL() })
|
||||
}
|
||||
clearRefreshCookie(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Révoque tous les tokens d'un user (panic mode si vol détecté, ou
|
||||
* appelable par "déconnecter toutes mes sessions").
|
||||
*/
|
||||
export async function revokeAllForUser(userId: string): Promise<void> {
|
||||
await RefreshToken.query()
|
||||
.where('user_id', userId)
|
||||
.whereNull('revoked_at')
|
||||
.update({ revoked_at: DateTime.now().toSQL() })
|
||||
}
|
||||
157
apps/api/app/services/relance_scheduler.ts
Normal file
157
apps/api/app/services/relance_scheduler.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { DateTime } from 'luxon'
|
||||
import RelanceTask from '#models/relance_task'
|
||||
import Plan from '#models/plan'
|
||||
import type Invoice from '#models/invoice'
|
||||
import { getQueue } from '#services/queue'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||
|
||||
const RELANCE_QUEUE = 'relances'
|
||||
|
||||
/**
|
||||
* En tests, les RelanceTasks DB sont créées (utile pour assertions) mais
|
||||
* l'enqueue BullMQ est skippé : les tx auto-rollback laisseraient des jobs
|
||||
* orphelins en Redis sinon, et on ne veut pas dépendre d'une instance
|
||||
* Redis live pour tourner les tests.
|
||||
*/
|
||||
function shouldEnqueue(): boolean {
|
||||
return app.getEnvironment() !== 'test'
|
||||
}
|
||||
|
||||
/**
|
||||
* Programme toutes les relances d'une facture selon son plan.
|
||||
*
|
||||
* - Pour chaque step du plan, calcule sendAt = invoice.dueDate + offsetDays
|
||||
* - Crée une RelanceTask `scheduled`
|
||||
* - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now
|
||||
*
|
||||
* Si une facture est déjà en retard quand l'utilisateur confirme "toujours
|
||||
* en attente", on n'envoie pas toutes les étapes passées d'un coup :
|
||||
* la première étape éligible part à `now + 1 min`, puis les suivantes
|
||||
* gardent l'écart du plan à partir de ce nouveau départ.
|
||||
*
|
||||
* Idempotent par invoice.id : si des tasks `scheduled` existent déjà
|
||||
* pour cette facture, on les annule avant de re-programmer (cas où on
|
||||
* change de plan).
|
||||
*/
|
||||
export async function scheduleRelancesForInvoice(
|
||||
invoice: Invoice,
|
||||
trx?: TransactionClientContract
|
||||
): Promise<RelanceTask[]> {
|
||||
if (!invoice.planId) return []
|
||||
|
||||
const plan = await Plan.query(trx ? { client: trx } : undefined)
|
||||
.where('id', invoice.planId)
|
||||
.preload('steps', (q) => q.orderBy('order', 'asc'))
|
||||
.first()
|
||||
if (!plan) return []
|
||||
|
||||
const alreadyActive = await RelanceTask.query(trx ? { client: trx } : undefined)
|
||||
.where('invoice_id', invoice.id)
|
||||
.whereIn('status', ['scheduled', 'sent'])
|
||||
if (alreadyActive.length > 0) {
|
||||
return alreadyActive
|
||||
}
|
||||
|
||||
// Cancel les tasks scheduled existantes (re-scheduling après changement
|
||||
// de plan ou de dueDate).
|
||||
const existing = await RelanceTask.query(trx ? { client: trx } : undefined)
|
||||
.where('invoice_id', invoice.id)
|
||||
.where('status', 'scheduled')
|
||||
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
|
||||
for (const t of existing) {
|
||||
if (t.queueJobId && queue) {
|
||||
await queue.remove(t.queueJobId).catch(() => {
|
||||
// Ignore — le job peut déjà être consommé.
|
||||
})
|
||||
}
|
||||
t.useTransaction(trx ?? (null as never))
|
||||
t.status = 'cancelled'
|
||||
await t.save()
|
||||
}
|
||||
|
||||
const now = DateTime.now()
|
||||
const created: RelanceTask[] = []
|
||||
const steps = plan.steps.slice().sort((a, b) => a.order - b.order)
|
||||
const firstOverdueStep = steps.find(
|
||||
(step) => invoice.dueDate.plus({ days: step.offsetDays }) < now
|
||||
)
|
||||
const catchUpAnchor = firstOverdueStep
|
||||
? {
|
||||
offsetDays: firstOverdueStep.offsetDays,
|
||||
sendAt: now.plus({ minutes: 1 }),
|
||||
}
|
||||
: null
|
||||
|
||||
for (const step of steps) {
|
||||
const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays })
|
||||
const sendAt =
|
||||
catchUpAnchor && step.offsetDays >= catchUpAnchor.offsetDays
|
||||
? catchUpAnchor.sendAt.plus({
|
||||
days: step.offsetDays - catchUpAnchor.offsetDays,
|
||||
})
|
||||
: sendAtRaw
|
||||
|
||||
const task = await RelanceTask.create(
|
||||
{
|
||||
organizationId: invoice.organizationId,
|
||||
invoiceId: invoice.id,
|
||||
planStepId: step.id,
|
||||
sendAt,
|
||||
status: 'scheduled',
|
||||
sentAt: null,
|
||||
queueJobId: null,
|
||||
},
|
||||
trx ? { client: trx } : undefined
|
||||
)
|
||||
|
||||
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
|
||||
const job = queue
|
||||
? await queue.add(
|
||||
'send-relance',
|
||||
{ taskId: task.id },
|
||||
{
|
||||
delay,
|
||||
// Idempotency : un seul job actif par task.
|
||||
// BullMQ 5+ interdit `:` dans les custom jobIds → tiret.
|
||||
jobId: `relance-${task.id}`,
|
||||
// Retry exponentiel — si Mailpit est down, BullMQ retry 5x avec
|
||||
// backoff (cf. backend.md §13.2).
|
||||
attempts: 5,
|
||||
backoff: { type: 'exponential', delay: 30_000 },
|
||||
}
|
||||
)
|
||||
: null
|
||||
|
||||
task.queueJobId = job?.id ?? null
|
||||
await task.save()
|
||||
created.push(task)
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule toutes les relances futures d'une facture (appelé quand on
|
||||
* mark-paid ou cancel une invoice). Les tasks déjà `sent` restent
|
||||
* intactes — c'est de l'historique.
|
||||
*/
|
||||
export async function cancelFutureRelances(
|
||||
invoiceId: string,
|
||||
trx?: TransactionClientContract
|
||||
): Promise<void> {
|
||||
const tasks = await RelanceTask.query(trx ? { client: trx } : undefined)
|
||||
.where('invoice_id', invoiceId)
|
||||
.where('status', 'scheduled')
|
||||
if (tasks.length === 0) return
|
||||
|
||||
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
|
||||
for (const t of tasks) {
|
||||
if (t.queueJobId && queue) {
|
||||
await queue.remove(t.queueJobId).catch(() => {})
|
||||
}
|
||||
t.useTransaction(trx ?? (null as never))
|
||||
t.status = 'cancelled'
|
||||
await t.save()
|
||||
}
|
||||
}
|
||||
61
apps/api/app/services/resolve_client.ts
Normal file
61
apps/api/app/services/resolve_client.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||
import Client from '#models/client'
|
||||
|
||||
export type ResolveClientInput = {
|
||||
clientId?: string | null
|
||||
clientName: string
|
||||
clientEmail?: string | null
|
||||
}
|
||||
|
||||
export type ResolveClientResult =
|
||||
| { client: Client; created: boolean }
|
||||
| { errorCode: 'client_email_required' }
|
||||
|
||||
/**
|
||||
* Résolution client à la création de facture / validation d'import OCR.
|
||||
*
|
||||
* Priorité (mêmes règles côté API que côté MSW) :
|
||||
* 1. `clientId` fourni + existant dans l'org → utilise tel quel.
|
||||
* 2. Match par nom (case-insensitive) sur les clients de l'org.
|
||||
* 3. Création à la volée → `clientEmail` REQUIS, sinon
|
||||
* `{ errorCode: 'client_email_required' }`.
|
||||
*
|
||||
* Le contrôleur appelant transforme l'erreur en HTTP 422 avec le code stable.
|
||||
*/
|
||||
export async function resolveClient(
|
||||
organizationId: string,
|
||||
fields: ResolveClientInput,
|
||||
trx: TransactionClientContract
|
||||
): Promise<ResolveClientResult> {
|
||||
if (fields.clientId) {
|
||||
const c = await Client.query({ client: trx })
|
||||
.where('organization_id', organizationId)
|
||||
.where('id', fields.clientId)
|
||||
.first()
|
||||
if (c) return { client: c, created: false }
|
||||
}
|
||||
|
||||
const matched = await Client.query({ client: trx })
|
||||
.where('organization_id', organizationId)
|
||||
.whereILike('name', fields.clientName)
|
||||
.first()
|
||||
if (matched) return { client: matched, created: false }
|
||||
|
||||
if (!fields.clientEmail) {
|
||||
return { errorCode: 'client_email_required' }
|
||||
}
|
||||
|
||||
const created = await Client.create(
|
||||
{
|
||||
organizationId,
|
||||
name: fields.clientName,
|
||||
email: fields.clientEmail,
|
||||
phone: null,
|
||||
address: null,
|
||||
siret: null,
|
||||
notes: null,
|
||||
},
|
||||
{ client: trx }
|
||||
)
|
||||
return { client: created, created: true }
|
||||
}
|
||||
37
apps/api/app/services/template.ts
Normal file
37
apps/api/app/services/template.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Mini interpolateur Mustache-like utilisé pour les sujets/corps des
|
||||
* emails de relance. Supporte les chemins pointés (`{{client.name}}`).
|
||||
*
|
||||
* Volontairement simple : pas d'expressions, pas de conditions, pas de
|
||||
* boucles. Si un chemin manque, retourne "" (silencieux — l'utilisateur
|
||||
* verra un blanc, pas une exception).
|
||||
*/
|
||||
export function renderTemplate(template: string, vars: Record<string, unknown>): string {
|
||||
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => {
|
||||
const parts = path.split('.')
|
||||
let val: unknown = vars
|
||||
for (const p of parts) {
|
||||
if (val == null || typeof val !== 'object') return ''
|
||||
val = (val as Record<string, unknown>)[p]
|
||||
}
|
||||
return val == null ? '' : String(val)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper d'affichage montant : 12400 → "124,00 €".
|
||||
*/
|
||||
export function formatAmountFr(cents: number): string {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(cents / 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper d'affichage date : ISO/Date → "15/04/2026".
|
||||
*/
|
||||
export function formatDateFr(d: Date | string): string {
|
||||
const date = typeof d === 'string' ? new Date(d) : d
|
||||
return new Intl.DateTimeFormat('fr-FR').format(date)
|
||||
}
|
||||
22
apps/api/app/transformers/client_transformer.ts
Normal file
22
apps/api/app/transformers/client_transformer.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type Client from '#models/client'
|
||||
import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||
|
||||
export default class ClientTransformer extends BaseTransformer<Client> {
|
||||
toObject() {
|
||||
const c = this.resource
|
||||
return {
|
||||
id: c.id,
|
||||
organizationId: c.organizationId,
|
||||
name: c.name,
|
||||
email: c.email,
|
||||
contactFirstName: c.contactFirstName,
|
||||
contactLastName: c.contactLastName,
|
||||
phone: c.phone,
|
||||
address: c.address,
|
||||
siret: c.siret,
|
||||
notes: c.notes,
|
||||
createdAt: c.createdAt.toISO()!,
|
||||
updatedAt: c.updatedAt?.toISO() ?? c.createdAt.toISO()!,
|
||||
}
|
||||
}
|
||||
}
|
||||
33
apps/api/app/transformers/import_batch_transformer.ts
Normal file
33
apps/api/app/transformers/import_batch_transformer.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type ImportBatch from '#models/import_batch'
|
||||
import type ImportDraft from '#models/import_draft'
|
||||
import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||
|
||||
function serializeDraft(d: ImportDraft) {
|
||||
return {
|
||||
id: d.id,
|
||||
filename: d.filename,
|
||||
pdfStorageKey: d.pdfStorageKey,
|
||||
extracted: d.extracted,
|
||||
edited: d.edited,
|
||||
confidence: d.confidence,
|
||||
status: d.status,
|
||||
invoiceId: d.invoiceId,
|
||||
createdAt: d.createdAt.toISO()!,
|
||||
updatedAt: d.updatedAt?.toISO() ?? d.createdAt.toISO()!,
|
||||
}
|
||||
}
|
||||
|
||||
export default class ImportBatchTransformer extends BaseTransformer<ImportBatch> {
|
||||
toObject() {
|
||||
const b = this.resource
|
||||
return {
|
||||
id: b.id,
|
||||
organizationId: b.organizationId,
|
||||
drafts: (b.drafts ?? []).map(serializeDraft),
|
||||
createdAt: b.createdAt.toISO()!,
|
||||
updatedAt: b.updatedAt?.toISO() ?? b.createdAt.toISO()!,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { serializeDraft }
|
||||
30
apps/api/app/transformers/invoice_transformer.ts
Normal file
30
apps/api/app/transformers/invoice_transformer.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type Invoice from '#models/invoice'
|
||||
import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||
|
||||
export default class InvoiceTransformer extends BaseTransformer<Invoice> {
|
||||
toObject() {
|
||||
const i = this.resource
|
||||
return {
|
||||
id: i.id,
|
||||
organizationId: i.organizationId,
|
||||
clientId: i.clientId,
|
||||
// Le SPA affiche `clientName` dans la liste — c'est lu depuis la
|
||||
// relation préchargée, sinon vide. La V1 MSW dénormalisait ce champ
|
||||
// dans la table invoice, on préfère le préchargement côté API.
|
||||
clientName: i.client?.name ?? '',
|
||||
numero: i.numero,
|
||||
amountTtcCents: i.amountTtcCents,
|
||||
issueDate: i.issueDate.toISO()!,
|
||||
dueDate: i.dueDate.toISO()!,
|
||||
status: i.status,
|
||||
planId: i.planId,
|
||||
planName: i.plan?.name ?? null,
|
||||
pdfStorageKey: i.pdfStorageKey,
|
||||
notes: i.notes,
|
||||
rubisEarned: i.rubisEarned,
|
||||
paidAt: i.paidAt?.toISO() ?? null,
|
||||
createdAt: i.createdAt.toISO()!,
|
||||
updatedAt: i.updatedAt?.toISO() ?? i.createdAt.toISO()!,
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apps/api/app/transformers/organization_transformer.ts
Normal file
19
apps/api/app/transformers/organization_transformer.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type Organization from '#models/organization'
|
||||
import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||
|
||||
export default class OrganizationTransformer extends BaseTransformer<Organization> {
|
||||
toObject() {
|
||||
const o = this.resource
|
||||
return {
|
||||
// UUID natif (cf. CLAUDE.md → Conventions techniques).
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
siret: o.siret,
|
||||
monthlyVolumeBucket: o.monthlyVolumeBucket,
|
||||
rubisCount: o.rubisCount,
|
||||
onboardingCompletedAt: o.onboardingCompletedAt?.toISO() ?? null,
|
||||
createdAt: o.createdAt.toISO()!,
|
||||
updatedAt: o.updatedAt?.toISO() ?? o.createdAt.toISO()!,
|
||||
}
|
||||
}
|
||||
}
|
||||
34
apps/api/app/transformers/plan_transformer.ts
Normal file
34
apps/api/app/transformers/plan_transformer.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type Plan from '#models/plan'
|
||||
import type PlanStep from '#models/plan_step'
|
||||
import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||
|
||||
function serializeStep(s: PlanStep) {
|
||||
return {
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
offsetDays: s.offsetDays,
|
||||
tone: s.tone,
|
||||
subject: s.subject,
|
||||
body: s.body,
|
||||
requiresManualValidation: s.requiresManualValidation,
|
||||
}
|
||||
}
|
||||
|
||||
export default class PlanTransformer extends BaseTransformer<Plan> {
|
||||
toObject() {
|
||||
const p = this.resource
|
||||
// p.steps doit être préchargé par le controller (preload('steps'))
|
||||
const steps = (p.steps ?? []).slice().sort((a, b) => a.order - b.order)
|
||||
return {
|
||||
id: p.id,
|
||||
organizationId: p.organizationId,
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
isDefault: p.isDefault,
|
||||
steps: steps.map(serializeStep),
|
||||
createdAt: p.createdAt.toISO()!,
|
||||
updatedAt: p.updatedAt?.toISO() ?? p.createdAt.toISO()!,
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apps/api/app/transformers/user_transformer.ts
Normal file
19
apps/api/app/transformers/user_transformer.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type User from '#models/user'
|
||||
import { BaseTransformer } from '@adonisjs/core/transformers'
|
||||
|
||||
export default class UserTransformer extends BaseTransformer<User> {
|
||||
toObject() {
|
||||
const u = this.resource
|
||||
return {
|
||||
// id et organizationId sont des UUID (cf. CLAUDE.md → Conventions techniques).
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
fullName: u.fullName,
|
||||
organizationId: u.organizationId,
|
||||
signature: u.signature,
|
||||
initials: u.initials,
|
||||
createdAt: u.createdAt.toISO()!,
|
||||
updatedAt: u.updatedAt?.toISO() ?? u.createdAt.toISO()!,
|
||||
}
|
||||
}
|
||||
}
|
||||
41
apps/api/app/validators/client.ts
Normal file
41
apps/api/app/validators/client.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
const name = () => vine.string().minLength(2).maxLength(120)
|
||||
const email = () => vine.string().email().maxLength(254)
|
||||
// SIRET = 14 chiffres exactement (cf. INSEE).
|
||||
const siret = () => vine.string().regex(/^\d{14}$/)
|
||||
const phone = () => vine.string().maxLength(40)
|
||||
const address = () => vine.string().maxLength(500)
|
||||
const notes = () => vine.string().maxLength(2000)
|
||||
// Prénom/nom du contact dédié — utilisés comme variables dans les templates
|
||||
// custom ({{client.contactFirstName}}). Optionnels.
|
||||
const contactName = () => vine.string().minLength(1).maxLength(80)
|
||||
|
||||
/**
|
||||
* Validator pour POST /clients. Email **requis** : sans email, Rubis ne
|
||||
* peut pas relancer (pivot produit, cf. CLAUDE.md → Principes).
|
||||
*/
|
||||
export const createClientValidator = vine.create({
|
||||
name: name(),
|
||||
email: email(),
|
||||
contactFirstName: contactName().nullable().optional(),
|
||||
contactLastName: contactName().nullable().optional(),
|
||||
phone: phone().nullable().optional(),
|
||||
address: address().nullable().optional(),
|
||||
siret: siret().nullable().optional(),
|
||||
notes: notes().nullable().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validator pour PATCH /clients/:id. Tous les champs optionnels.
|
||||
*/
|
||||
export const updateClientValidator = vine.create({
|
||||
name: name().optional(),
|
||||
email: email().optional(),
|
||||
contactFirstName: contactName().nullable().optional(),
|
||||
contactLastName: contactName().nullable().optional(),
|
||||
phone: phone().nullable().optional(),
|
||||
address: address().nullable().optional(),
|
||||
siret: siret().nullable().optional(),
|
||||
notes: notes().nullable().optional(),
|
||||
})
|
||||
29
apps/api/app/validators/import_batch.ts
Normal file
29
apps/api/app/validators/import_batch.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
/**
|
||||
* POST /invoices/upload — V1 mock.
|
||||
*
|
||||
* Accepte un tableau de filenames (pas de fichiers réels). Quand on
|
||||
* branchera Mistral + MinIO, on switchera sur multipart `files[]` avec
|
||||
* upload effectif des PDFs. Le contrat côté SPA reste le même.
|
||||
*/
|
||||
export const uploadValidator = vine.create({
|
||||
filenames: vine.array(vine.string().minLength(1).maxLength(500)).minLength(1).maxLength(20),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /invoices/import-batch/:id/drafts/:draftId/validate.
|
||||
*
|
||||
* Le SPA envoie les `edited` finaux (peut différer de `extracted` si
|
||||
* l'utilisateur a corrigé). On les normalise puis on crée l'invoice.
|
||||
*/
|
||||
export const validateDraftValidator = vine.create({
|
||||
clientId: vine.string().uuid().nullable(),
|
||||
clientName: vine.string().minLength(1).maxLength(120),
|
||||
clientEmail: vine.string().email().nullable(),
|
||||
numero: vine.string().minLength(1).maxLength(50),
|
||||
amountTtcCents: vine.number().min(1),
|
||||
issueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
|
||||
dueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
|
||||
planId: vine.string().uuid().nullable(),
|
||||
})
|
||||
42
apps/api/app/validators/invoice.ts
Normal file
42
apps/api/app/validators/invoice.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
const INVOICE_STATUSES = [
|
||||
'pending',
|
||||
'awaiting_user_confirmation',
|
||||
'in_relance',
|
||||
'paid',
|
||||
'litigation',
|
||||
'cancelled',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Filtres GET /invoices?status=&q=&clientId=&page=
|
||||
*/
|
||||
export const listInvoicesValidator = vine.create({
|
||||
status: vine.enum([...INVOICE_STATUSES, 'all'] as const).optional(),
|
||||
q: vine.string().maxLength(120).optional(),
|
||||
clientId: vine.string().uuid().optional(),
|
||||
page: vine.number().min(1).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /invoices — saisie manuelle.
|
||||
*
|
||||
* Le SPA peut envoyer :
|
||||
* - clientId d'un client existant (combobox a sélectionné une fiche), OU
|
||||
* - clientName seul → on tente de matcher par nom, sinon création à la
|
||||
* volée mais alors clientEmail est REQUIS (pivot produit, cf. Client).
|
||||
*
|
||||
* On ne peut pas exprimer "email requis si pas de match" en Vine pur, donc
|
||||
* c'est le contrôleur qui retourne 422 `client_email_required` si besoin.
|
||||
*/
|
||||
export const createInvoiceValidator = vine.create({
|
||||
clientId: vine.string().uuid().optional(),
|
||||
clientName: vine.string().minLength(2).maxLength(120),
|
||||
clientEmail: vine.string().email().nullable().optional(),
|
||||
numero: vine.string().minLength(1).maxLength(50),
|
||||
amountTtcCents: vine.number().min(1),
|
||||
issueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
|
||||
dueDate: vine.string().regex(/^\d{4}-\d{2}-\d{2}T/),
|
||||
planId: vine.string().uuid().nullable().optional(),
|
||||
})
|
||||
14
apps/api/app/validators/organization.ts
Normal file
14
apps/api/app/validators/organization.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
const MONTHLY_VOLUME_BUCKETS = ['moins-10', '10-50', '50-100', '100-200', 'plus-200'] as const
|
||||
|
||||
/**
|
||||
* Validator pour PATCH /organizations/me. Tous les champs optionnels :
|
||||
* l'utilisateur peut compléter au fil de l'onboarding.
|
||||
*/
|
||||
export const updateOrganizationValidator = vine.create({
|
||||
name: vine.string().minLength(2).maxLength(120).optional(),
|
||||
// SIRET = 14 chiffres exactement, sinon null pour réinitialiser.
|
||||
siret: vine.string().regex(/^\d{14}$/).nullable().optional(),
|
||||
monthlyVolumeBucket: vine.enum(MONTHLY_VOLUME_BUCKETS).nullable().optional(),
|
||||
})
|
||||
36
apps/api/app/validators/plan.ts
Normal file
36
apps/api/app/validators/plan.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
const RELANCE_TONES = ['amical', 'courtois', 'ferme', 'mise_en_demeure'] as const
|
||||
|
||||
const planStep = vine.object({
|
||||
// id optionnel : présent si on édite une étape existante, absent pour
|
||||
// une création (le contrôleur le générera).
|
||||
id: vine.string().optional(),
|
||||
order: vine.number().min(0),
|
||||
// Plage : -30 (rappel avant échéance) à 180 jours (gros retards).
|
||||
offsetDays: vine.number().min(-30).max(180),
|
||||
tone: vine.enum(RELANCE_TONES),
|
||||
subject: vine.string().minLength(1).maxLength(200),
|
||||
body: vine.string().minLength(1).maxLength(5000),
|
||||
requiresManualValidation: vine.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validator pour PATCH /plans/:slug. Tous les champs optionnels — l'éditeur
|
||||
* front peut envoyer juste `name` ou juste `steps` selon ce qu'il modifie.
|
||||
*/
|
||||
export const updatePlanValidator = vine.create({
|
||||
name: vine.string().minLength(1).maxLength(80).optional(),
|
||||
description: vine.string().maxLength(500).optional(),
|
||||
steps: vine.array(planStep).minLength(1).maxLength(10).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validator pour POST /plans — création d'un plan custom.
|
||||
* Le slug est généré côté contrôleur depuis le name.
|
||||
*/
|
||||
export const createPlanValidator = vine.create({
|
||||
name: vine.string().minLength(1).maxLength(80),
|
||||
description: vine.string().maxLength(500).optional(),
|
||||
steps: vine.array(planStep).minLength(1).maxLength(10),
|
||||
})
|
||||
35
apps/api/app/validators/user.ts
Normal file
35
apps/api/app/validators/user.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import vine from '@vinejs/vine'
|
||||
|
||||
/**
|
||||
* Shared rules for email and password.
|
||||
*/
|
||||
const email = () => vine.string().email().maxLength(254)
|
||||
const password = () => vine.string().minLength(8).maxLength(72)
|
||||
|
||||
/**
|
||||
* Validator pour /auth/signup. Contrat aligné sur le SPA (Zod
|
||||
* `registerSchema` dans packages/shared). Pas de passwordConfirmation
|
||||
* côté API : la confirmation visuelle est une affaire de formulaire.
|
||||
*/
|
||||
export const signupValidator = vine.create({
|
||||
email: email().unique({ table: 'users', column: 'email' }),
|
||||
password: password(),
|
||||
fullName: vine.string().minLength(2).maxLength(120),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validator pour /auth/login.
|
||||
*/
|
||||
export const loginValidator = vine.create({
|
||||
email: email(),
|
||||
password: vine.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validator pour /account/profile (PATCH). Tous les champs optionnels.
|
||||
*/
|
||||
export const updateProfileValidator = vine.create({
|
||||
fullName: vine.string().minLength(2).maxLength(120).optional(),
|
||||
email: email().optional(),
|
||||
signature: vine.string().maxLength(500).optional(),
|
||||
})
|
||||
47
apps/api/bin/console.ts
Normal file
47
apps/api/bin/console.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ace entry point
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The "console.ts" file is the entrypoint for booting the AdonisJS
|
||||
| command-line framework and executing commands.
|
||||
|
|
||||
| Commands do not boot the application, unless the currently running command
|
||||
| has "options.startApp" flag set to true.
|
||||
|
|
||||
*/
|
||||
|
||||
await import('reflect-metadata')
|
||||
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
|
||||
|
||||
/**
|
||||
* URL to the application root. AdonisJS need it to resolve
|
||||
* paths to file and directories for scaffolding commands
|
||||
*/
|
||||
const APP_ROOT = new URL('../', import.meta.url)
|
||||
|
||||
/**
|
||||
* The importer is used to import files in context of the
|
||||
* application.
|
||||
*/
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.ace()
|
||||
.handle(process.argv.splice(2))
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
45
apps/api/bin/server.ts
Normal file
45
apps/api/bin/server.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP server entrypoint
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
|
||||
| server. Either you can run this file directly or use the "serve"
|
||||
| command to run this file and monitor file changes
|
||||
|
|
||||
*/
|
||||
|
||||
await import('reflect-metadata')
|
||||
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
|
||||
|
||||
/**
|
||||
* URL to the application root. AdonisJS need it to resolve
|
||||
* paths to file and directories for scaffolding commands
|
||||
*/
|
||||
const APP_ROOT = new URL('../', import.meta.url)
|
||||
|
||||
/**
|
||||
* The importer is used to import files in context of the
|
||||
* application.
|
||||
*/
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.httpServer()
|
||||
.start()
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
62
apps/api/bin/test.ts
Normal file
62
apps/api/bin/test.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test runner entrypoint
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The "test.ts" file is the entrypoint for running tests using Japa.
|
||||
|
|
||||
| Either you can run this file directly or use the "test"
|
||||
| command to run this file and monitor file changes.
|
||||
|
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
import 'reflect-metadata'
|
||||
import { Ignitor, prettyPrintError } from '@adonisjs/core'
|
||||
import { configure, processCLIArgs, run } from '@japa/runner'
|
||||
|
||||
/**
|
||||
* URL to the application root. AdonisJS need it to resolve
|
||||
* paths to file and directories for scaffolding commands
|
||||
*/
|
||||
const APP_ROOT = new URL('../', import.meta.url)
|
||||
|
||||
/**
|
||||
* The importer is used to import files in context of the
|
||||
* application.
|
||||
*/
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.testRunner()
|
||||
.configure(async (app) => {
|
||||
const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
|
||||
|
||||
processCLIArgs(process.argv.splice(2))
|
||||
configure({
|
||||
...app.rcFile.tests,
|
||||
...config,
|
||||
...{
|
||||
setup: runnerHooks.setup,
|
||||
teardown: runnerHooks.teardown.concat([() => app.terminate()]),
|
||||
},
|
||||
})
|
||||
})
|
||||
.run(() => run())
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
61
apps/api/commands/send_test_email.ts
Normal file
61
apps/api/commands/send_test_email.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
import mail from '@adonisjs/mail/services/main'
|
||||
import env from '#start/env'
|
||||
|
||||
/**
|
||||
* Envoie un email de test via le mailer courant (typiquement Resend)
|
||||
* pour valider la conf SPF/DKIM/clé API sans passer par toute la chaîne
|
||||
* facture → job BullMQ.
|
||||
*
|
||||
* node ace send:test-email arthur@example.com
|
||||
* node ace send:test-email arthur@example.com --reply-to=patron@tpe.fr
|
||||
*/
|
||||
export default class SendTestEmail extends BaseCommand {
|
||||
static commandName = 'send:test-email'
|
||||
static description = 'Envoie un email de test via le mailer configuré (Resend en prod)'
|
||||
|
||||
static options: CommandOptions = {
|
||||
startApp: true,
|
||||
}
|
||||
|
||||
@args.string({ description: 'Adresse destinataire' })
|
||||
declare to: string
|
||||
|
||||
@flags.string({ description: 'Adresse de reply-to (optionnelle)' })
|
||||
declare replyTo?: string
|
||||
|
||||
async run() {
|
||||
const driver = env.get('MAIL_DRIVER', 'smtp')
|
||||
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
|
||||
const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
|
||||
|
||||
this.logger.info(`Driver: ${driver}`)
|
||||
this.logger.info(`From: ${fromName} <${fromAddress}>`)
|
||||
this.logger.info(`To: ${this.to}`)
|
||||
if (this.replyTo) this.logger.info(`ReplyTo: ${this.replyTo}`)
|
||||
|
||||
const mailer = mail.use(driver)
|
||||
const response = await mailer.send((m) => {
|
||||
m.from(fromAddress, fromName)
|
||||
.to(this.to)
|
||||
.subject('[Rubis] Test d\'envoi via Resend')
|
||||
.text(
|
||||
`Bonjour,\n\n` +
|
||||
`Ceci est un email de test envoyé depuis Rubis Sur l'Ongle.\n` +
|
||||
`Si vous recevez ce message, la conf Resend (SPF/DKIM/API key) est OK.\n\n` +
|
||||
`Driver utilisé : ${driver}\n` +
|
||||
`Date : ${new Date().toISOString()}\n\n` +
|
||||
`— L'équipe Rubis`
|
||||
)
|
||||
if (this.replyTo) m.replyTo(this.replyTo)
|
||||
})
|
||||
|
||||
this.logger.success('Email envoyé')
|
||||
// Resend renvoie un messageId dans la réponse — utile pour retrouver
|
||||
// le log dans le dashboard.
|
||||
if (response?.messageId) {
|
||||
this.logger.info(`messageId: ${response.messageId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
93
apps/api/config/app.ts
Normal file
93
apps/api/config/app.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import env from '#start/env'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { defineConfig } from '@adonisjs/core/http'
|
||||
|
||||
/**
|
||||
* The app key is used for encrypting cookies, generating signed URLs,
|
||||
* and by the "encryption" module.
|
||||
*
|
||||
* The encryption module will fail to decrypt data if the key is lost or
|
||||
* changed. Therefore it is recommended to keep the app key secure.
|
||||
*/
|
||||
export const appKey = env.get('APP_KEY')
|
||||
|
||||
/**
|
||||
* The app URL can be used in various places where you want to create absolute
|
||||
* URLs to your application. For example, when sending emails, images should
|
||||
* use absolute URLs.
|
||||
*/
|
||||
export const appUrl = env.get('APP_URL')
|
||||
|
||||
/**
|
||||
* The configuration settings used by the HTTP server
|
||||
*/
|
||||
export const http = defineConfig({
|
||||
/**
|
||||
* Generate a unique request id for each incoming request.
|
||||
* Useful to correlate logs and debug a request flow.
|
||||
*/
|
||||
generateRequestId: true,
|
||||
|
||||
/**
|
||||
* Allow HTTP method spoofing via the "_method" form/query parameter.
|
||||
* This lets HTML forms target PUT/PATCH/DELETE routes while still
|
||||
* submitting with POST.
|
||||
*/
|
||||
allowMethodSpoofing: false,
|
||||
|
||||
/**
|
||||
* Enabling async local storage will let you access HTTP context
|
||||
* from anywhere inside your application.
|
||||
*/
|
||||
useAsyncLocalStorage: false,
|
||||
|
||||
/**
|
||||
* Redirect configuration controls the behavior of
|
||||
* response.redirect().back() and query string forwarding.
|
||||
*/
|
||||
redirect: {
|
||||
/**
|
||||
* When enabled, all redirects automatically carry over the current
|
||||
* request's query string parameters to the redirect destination.
|
||||
* Use withQs(false) to opt out for a specific redirect.
|
||||
*/
|
||||
forwardQueryString: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Manage cookies configuration. The settings for the session id cookie are
|
||||
* defined inside the "config/session.ts" file.
|
||||
*/
|
||||
cookie: {
|
||||
/**
|
||||
* Restrict the cookie to a specific domain.
|
||||
* Keep empty to use the current host.
|
||||
*/
|
||||
domain: '',
|
||||
|
||||
/**
|
||||
* Restrict the cookie to a URL path. '/' means all routes.
|
||||
*/
|
||||
path: '/',
|
||||
|
||||
/**
|
||||
* Default lifetime for cookies managed by the HTTP layer.
|
||||
*/
|
||||
maxAge: '2h',
|
||||
|
||||
/**
|
||||
* Prevent JavaScript access to the cookie in the browser.
|
||||
*/
|
||||
httpOnly: true,
|
||||
|
||||
/**
|
||||
* Send cookies only over HTTPS in production.
|
||||
*/
|
||||
secure: app.inProduction,
|
||||
|
||||
/**
|
||||
* Cross-site policy for cookie sending.
|
||||
*/
|
||||
sameSite: 'lax',
|
||||
},
|
||||
})
|
||||
50
apps/api/config/auth.ts
Normal file
50
apps/api/config/auth.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { defineConfig } from '@adonisjs/auth'
|
||||
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
|
||||
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
|
||||
import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
|
||||
|
||||
const authConfig = defineConfig({
|
||||
/**
|
||||
* Default guard used when no guard is explicitly specified.
|
||||
*/
|
||||
default: 'api',
|
||||
|
||||
guards: {
|
||||
/**
|
||||
* Token-based guard for stateless API authentication.
|
||||
*/
|
||||
api: tokensGuard({
|
||||
provider: tokensUserProvider({
|
||||
tokens: 'accessTokens',
|
||||
model: () => import('#models/user'),
|
||||
}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Session-based guard for browser authentication.
|
||||
*/
|
||||
web: sessionGuard({
|
||||
/**
|
||||
* Enable persistent login using remember-me tokens.
|
||||
*/
|
||||
useRememberMeTokens: false,
|
||||
|
||||
provider: sessionUserProvider({
|
||||
model: () => import('#models/user'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default authConfig
|
||||
|
||||
/**
|
||||
* Inferring types from the configured auth
|
||||
* guards.
|
||||
*/
|
||||
declare module '@adonisjs/auth/types' {
|
||||
export interface Authenticators extends InferAuthenticators<typeof authConfig> {}
|
||||
}
|
||||
declare module '@adonisjs/core/types' {
|
||||
interface EventsList extends InferAuthEvents<Authenticators> {}
|
||||
}
|
||||
78
apps/api/config/bodyparser.ts
Normal file
78
apps/api/config/bodyparser.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { defineConfig } from '@adonisjs/core/bodyparser'
|
||||
|
||||
const bodyParserConfig = defineConfig({
|
||||
/**
|
||||
* Parse request bodies for these HTTP methods.
|
||||
* Keep this aligned with methods that receive payloads in your routes.
|
||||
*/
|
||||
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
|
||||
/**
|
||||
* Config for the "application/x-www-form-urlencoded"
|
||||
* content-type parser.
|
||||
*/
|
||||
form: {
|
||||
/**
|
||||
* Normalize empty string values to null.
|
||||
*/
|
||||
convertEmptyStringsToNull: true,
|
||||
|
||||
/**
|
||||
* Content types handled by the form parser.
|
||||
*/
|
||||
types: ['application/x-www-form-urlencoded'],
|
||||
},
|
||||
|
||||
/**
|
||||
* Config for the JSON parser.
|
||||
*/
|
||||
json: {
|
||||
/**
|
||||
* Normalize empty string values to null.
|
||||
*/
|
||||
convertEmptyStringsToNull: true,
|
||||
|
||||
/**
|
||||
* Content types handled by the JSON parser.
|
||||
*/
|
||||
types: [
|
||||
'application/json',
|
||||
'application/json-patch+json',
|
||||
'application/vnd.api+json',
|
||||
'application/csp-report',
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* Config for the "multipart/form-data" content-type parser.
|
||||
* File uploads are handled by the multipart parser.
|
||||
*/
|
||||
multipart: {
|
||||
/**
|
||||
* Automatically process uploaded files into the system tmp directory.
|
||||
*/
|
||||
autoProcess: true,
|
||||
|
||||
/**
|
||||
* Normalize empty string values to null.
|
||||
*/
|
||||
convertEmptyStringsToNull: true,
|
||||
|
||||
/**
|
||||
* Routes where multipart processing is handled manually.
|
||||
*/
|
||||
processManually: [],
|
||||
|
||||
/**
|
||||
* Maximum accepted payload size for multipart requests.
|
||||
*/
|
||||
limit: '20mb',
|
||||
|
||||
/**
|
||||
* Content types handled by the multipart parser.
|
||||
*/
|
||||
types: ['multipart/form-data'],
|
||||
},
|
||||
})
|
||||
|
||||
export default bodyParserConfig
|
||||
50
apps/api/config/cors.ts
Normal file
50
apps/api/config/cors.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { defineConfig } from '@adonisjs/cors'
|
||||
|
||||
/**
|
||||
* Configuration options to tweak the CORS policy. The following
|
||||
* options are documented on the official documentation website.
|
||||
*
|
||||
* https://docs.adonisjs.com/guides/security/cors
|
||||
*/
|
||||
const corsConfig = defineConfig({
|
||||
/**
|
||||
* Enable or disable CORS handling globally.
|
||||
*/
|
||||
enabled: true,
|
||||
|
||||
/**
|
||||
* In development, allow every origin to simplify local front/backend setup.
|
||||
* In production, keep an explicit allowlist (empty by default, so no
|
||||
* cross-origin browser access is allowed until configured).
|
||||
*/
|
||||
origin: app.inDev ? true : [],
|
||||
|
||||
/**
|
||||
* HTTP methods accepted for cross-origin requests.
|
||||
*/
|
||||
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
|
||||
/**
|
||||
* Reflect request headers by default. Use a string array to restrict
|
||||
* allowed headers.
|
||||
*/
|
||||
headers: true,
|
||||
|
||||
/**
|
||||
* Response headers exposed to the browser.
|
||||
*/
|
||||
exposeHeaders: [],
|
||||
|
||||
/**
|
||||
* Allow cookies/authorization headers on cross-origin requests.
|
||||
*/
|
||||
credentials: true,
|
||||
|
||||
/**
|
||||
* Cache CORS preflight response for N seconds.
|
||||
*/
|
||||
maxAge: 90,
|
||||
})
|
||||
|
||||
export default corsConfig
|
||||
51
apps/api/config/database.ts
Normal file
51
apps/api/config/database.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { defineConfig } from '@adonisjs/lucid'
|
||||
import env from '#start/env'
|
||||
|
||||
const dbConfig = defineConfig({
|
||||
/**
|
||||
* Postgres en dev/prod. SQLite reste accessible via DB_CONNECTION=sqlite
|
||||
* pour les tests rapides ou un usage offline.
|
||||
*/
|
||||
connection: env.get('DB_CONNECTION', 'postgres'),
|
||||
|
||||
connections: {
|
||||
postgres: {
|
||||
client: 'pg',
|
||||
connection: {
|
||||
host: env.get('PG_HOST'),
|
||||
port: env.get('PG_PORT'),
|
||||
user: env.get('PG_USER'),
|
||||
password: env.get('PG_PASSWORD'),
|
||||
database: env.get('PG_DB_NAME'),
|
||||
},
|
||||
migrations: {
|
||||
naturalSort: true,
|
||||
paths: ['database/migrations'],
|
||||
},
|
||||
schemaGeneration: {
|
||||
enabled: true,
|
||||
rulesPaths: ['./database/schema_rules.js'],
|
||||
},
|
||||
debug: app.inDev,
|
||||
},
|
||||
|
||||
sqlite: {
|
||||
client: 'better-sqlite3',
|
||||
connection: {
|
||||
filename: app.tmpPath('db.sqlite3'),
|
||||
},
|
||||
useNullAsDefault: true,
|
||||
migrations: {
|
||||
naturalSort: true,
|
||||
paths: ['database/migrations'],
|
||||
},
|
||||
schemaGeneration: {
|
||||
enabled: true,
|
||||
rulesPaths: ['./database/schema_rules.js'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default dbConfig
|
||||
39
apps/api/config/drive.ts
Normal file
39
apps/api/config/drive.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { defineConfig, services } from '@adonisjs/drive'
|
||||
import type { InferDriveDisks } from '@adonisjs/drive/types'
|
||||
import env from '#start/env'
|
||||
|
||||
const driveConfig = defineConfig({
|
||||
default: env.get('DRIVE_DISK', 's3'),
|
||||
|
||||
/**
|
||||
* Stockage local (filesystem) — utilisé en fallback si MinIO indisponible.
|
||||
* Bucket par défaut : storage/uploads (ignoré par git).
|
||||
*/
|
||||
services: {
|
||||
fs: services.fs({
|
||||
location: 'storage/uploads',
|
||||
visibility: 'private',
|
||||
}),
|
||||
|
||||
/**
|
||||
* MinIO via le driver S3 (S3-compatible).
|
||||
*/
|
||||
s3: services.s3({
|
||||
credentials: {
|
||||
accessKeyId: env.get('S3_ACCESS_KEY', ''),
|
||||
secretAccessKey: env.get('S3_SECRET_KEY', ''),
|
||||
},
|
||||
endpoint: env.get('S3_ENDPOINT'),
|
||||
region: env.get('S3_REGION', 'fr-par'),
|
||||
bucket: env.get('S3_BUCKET', 'rubis-invoices'),
|
||||
forcePathStyle: env.get('S3_FORCE_PATH_STYLE', true),
|
||||
visibility: 'private',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default driveConfig
|
||||
|
||||
declare module '@adonisjs/drive/types' {
|
||||
export interface DriveDisks extends InferDriveDisks<typeof driveConfig> {}
|
||||
}
|
||||
34
apps/api/config/encryption.ts
Normal file
34
apps/api/config/encryption.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import env from '#start/env'
|
||||
import { defineConfig, drivers } from '@adonisjs/core/encryption'
|
||||
|
||||
const encryptionConfig = defineConfig({
|
||||
/**
|
||||
* Default encryption driver used by the application.
|
||||
*/
|
||||
default: 'gcm',
|
||||
|
||||
list: {
|
||||
gcm: drivers.aes256gcm({
|
||||
/**
|
||||
* Keys used for encryption/decryption.
|
||||
* First key encrypts, all keys are tried for decryption.
|
||||
*/
|
||||
keys: [env.get('APP_KEY')],
|
||||
|
||||
/**
|
||||
* Stable identifier for this driver.
|
||||
*/
|
||||
id: 'gcm',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default encryptionConfig
|
||||
|
||||
/**
|
||||
* Inferring types for the list of encryptors you have configured
|
||||
* in your application.
|
||||
*/
|
||||
declare module '@adonisjs/core/types' {
|
||||
export interface EncryptorsList extends InferEncryptors<typeof encryptionConfig> {}
|
||||
}
|
||||
75
apps/api/config/hash.ts
Normal file
75
apps/api/config/hash.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { defineConfig, drivers } from '@adonisjs/core/hash'
|
||||
|
||||
/**
|
||||
* Hashing configuration.
|
||||
*
|
||||
* This starter uses Node.js scrypt under the hood.
|
||||
* Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
|
||||
*/
|
||||
const hashConfig = defineConfig({
|
||||
/**
|
||||
* Default hasher used by the application.
|
||||
*/
|
||||
default: 'scrypt',
|
||||
|
||||
list: {
|
||||
/**
|
||||
* Scrypt is memory-hard, which makes brute-force attacks more expensive.
|
||||
*/
|
||||
scrypt: drivers.scrypt({
|
||||
/**
|
||||
* Work factor (Node alias: N / cost).
|
||||
* Higher values increase security and CPU+memory usage.
|
||||
*
|
||||
* Tuning guideline:
|
||||
* - Start with 16384.
|
||||
* - Increase gradually (for example 32768) and benchmark login/signup latency.
|
||||
* - Keep values practical for your slowest production machine.
|
||||
*
|
||||
* Node constraint: value must be a power of two greater than 1.
|
||||
*/
|
||||
cost: 16384,
|
||||
|
||||
/**
|
||||
* Block size (Node alias: r / blockSize).
|
||||
* Increases memory and CPU linearly.
|
||||
*
|
||||
* Tuning guideline:
|
||||
* - Keep 8 unless you have a measured reason to change it.
|
||||
* - Raise only with benchmark data, because memory usage grows quickly.
|
||||
*/
|
||||
blockSize: 8,
|
||||
|
||||
/**
|
||||
* Parallelization (Node alias: p / parallelization).
|
||||
* Controls how many independent computations are performed.
|
||||
*
|
||||
* Tuning guideline:
|
||||
* - Keep 1 for most applications.
|
||||
* - Increase only after load testing if your infrastructure benefits from it.
|
||||
*/
|
||||
parallelization: 1,
|
||||
|
||||
/**
|
||||
* Maximum memory limit in bytes (Node alias: maxmem / maxMemory).
|
||||
* Hashing throws if the estimated memory usage is above this limit.
|
||||
* Node documents the check as approximately: 128 * N * r > maxmem.
|
||||
*
|
||||
* Tuning guideline:
|
||||
* - Keep this aligned with your cost/blockSize choices.
|
||||
* - Increase carefully on memory-constrained environments.
|
||||
*/
|
||||
maxMemory: 33554432,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default hashConfig
|
||||
|
||||
/**
|
||||
* Inferring types for the list of hashers you have configured
|
||||
* in your application.
|
||||
*/
|
||||
declare module '@adonisjs/core/types' {
|
||||
export interface HashersList extends InferHashers<typeof hashConfig> {}
|
||||
}
|
||||
31
apps/api/config/limiter.ts
Normal file
31
apps/api/config/limiter.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import env from '#start/env'
|
||||
import { defineConfig, stores } from '@adonisjs/limiter'
|
||||
import type { InferLimiters } from '@adonisjs/limiter/types'
|
||||
|
||||
const limiterConfig = defineConfig({
|
||||
default: env.get('LIMITER_STORE'),
|
||||
stores: {
|
||||
|
||||
/**
|
||||
* Redis store to save rate limiting data inside a
|
||||
* redis database.
|
||||
*
|
||||
* It is recommended to use a separate database for
|
||||
* the limiter connection.
|
||||
*/
|
||||
redis: stores.redis({}),
|
||||
|
||||
|
||||
/**
|
||||
* Memory store could be used during
|
||||
* testing
|
||||
*/
|
||||
memory: stores.memory({})
|
||||
},
|
||||
})
|
||||
|
||||
export default limiterConfig
|
||||
|
||||
declare module '@adonisjs/limiter/types' {
|
||||
export interface LimitersList extends InferLimiters<typeof limiterConfig> {}
|
||||
}
|
||||
51
apps/api/config/logger.ts
Normal file
51
apps/api/config/logger.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import env from '#start/env'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'
|
||||
|
||||
const loggerConfig = defineConfig({
|
||||
/**
|
||||
* Default logger name used by ctx.logger and app logger calls.
|
||||
*/
|
||||
default: 'app',
|
||||
|
||||
loggers: {
|
||||
app: {
|
||||
/**
|
||||
* Toggle this logger on/off.
|
||||
*/
|
||||
enabled: true,
|
||||
|
||||
/**
|
||||
* Logger name shown in log records.
|
||||
*/
|
||||
name: env.get('APP_NAME'),
|
||||
|
||||
/**
|
||||
* Minimum level to output (trace, debug, info, warn, error, fatal).
|
||||
*/
|
||||
level: env.get('LOG_LEVEL'),
|
||||
|
||||
/**
|
||||
* Use sync destination in non-production for immediate flush.
|
||||
*/
|
||||
destination: !app.inProduction ? await syncDestination() : undefined,
|
||||
|
||||
/**
|
||||
* Configure where logs are written.
|
||||
*/
|
||||
transport: {
|
||||
targets: [targets.file({ destination: 1 })],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default loggerConfig
|
||||
|
||||
/**
|
||||
* Inferring types for the list of loggers you have configured
|
||||
* in your application.
|
||||
*/
|
||||
declare module '@adonisjs/core/types' {
|
||||
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
|
||||
}
|
||||
46
apps/api/config/mail.ts
Normal file
46
apps/api/config/mail.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import env from '#start/env'
|
||||
import { defineConfig, transports } from '@adonisjs/mail'
|
||||
import type { InferMailers } from '@adonisjs/mail/types'
|
||||
|
||||
const mailConfig = defineConfig({
|
||||
default: env.get('MAIL_DRIVER', 'smtp'),
|
||||
|
||||
from: {
|
||||
address: env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'),
|
||||
name: env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"),
|
||||
},
|
||||
|
||||
/**
|
||||
* Variables partagées par tous les templates Edge (logo, URL de base…).
|
||||
*/
|
||||
globals: {
|
||||
brandName: "Rubis Sur l'Ongle",
|
||||
appUrl: env.get('APP_URL'),
|
||||
},
|
||||
|
||||
mailers: {
|
||||
/**
|
||||
* SMTP — Mailpit en dev (catch-all sur localhost:1025), n'importe quel
|
||||
* relais SMTP en prod si on ne veut pas de provider tiers.
|
||||
*/
|
||||
smtp: transports.smtp({
|
||||
host: env.get('SMTP_HOST', 'localhost'),
|
||||
port: env.get('SMTP_PORT', 1025),
|
||||
// Auth optionnelle — pas requise pour Mailpit
|
||||
}),
|
||||
|
||||
/**
|
||||
* Resend — provider transactionnel par défaut en prod (cf. ADR-021).
|
||||
*/
|
||||
resend: transports.resend({
|
||||
key: env.get('RESEND_API_KEY', ''),
|
||||
baseUrl: 'https://api.resend.com',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default mailConfig
|
||||
|
||||
declare module '@adonisjs/mail/types' {
|
||||
export interface MailersList extends InferMailers<typeof mailConfig> {}
|
||||
}
|
||||
29
apps/api/config/queue.ts
Normal file
29
apps/api/config/queue.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import env from '#start/env'
|
||||
import { type RedisOptions } from 'ioredis'
|
||||
|
||||
/**
|
||||
* Connexion Redis partagée pour BullMQ. On garde un objet d'options
|
||||
* (et pas une instance) parce que BullMQ instancie ses propres clients
|
||||
* pour chaque queue/worker.
|
||||
*/
|
||||
export const redisConnection: RedisOptions = {
|
||||
host: env.get('REDIS_HOST', 'localhost'),
|
||||
port: env.get('REDIS_PORT', 6379),
|
||||
password: env.get('REDIS_PASSWORD') || undefined,
|
||||
// Requis par BullMQ pour les blocking commands.
|
||||
maxRetriesPerRequest: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des queues. La concurrence est appliquée côté worker.
|
||||
* Ajouter une queue ici → ajouter un Worker correspondant dans #start/queue.ts.
|
||||
*/
|
||||
export const queueNames = ['ocr', 'relances', 'checkins', 'kpis'] as const
|
||||
export type QueueName = (typeof queueNames)[number]
|
||||
|
||||
export const queueConcurrency: Record<QueueName, number> = {
|
||||
ocr: 2,
|
||||
relances: 5,
|
||||
checkins: 5,
|
||||
kpis: 1,
|
||||
}
|
||||
78
apps/api/config/session.ts
Normal file
78
apps/api/config/session.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import env from '#start/env'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { defineConfig, stores } from '@adonisjs/session'
|
||||
|
||||
const sessionConfig = defineConfig({
|
||||
/**
|
||||
* Enable or disable session support globally.
|
||||
*/
|
||||
enabled: true,
|
||||
|
||||
/**
|
||||
* Cookie name storing the session identifier.
|
||||
*/
|
||||
cookieName: 'adonis-session',
|
||||
|
||||
/**
|
||||
* When set to true, the session id cookie will be deleted
|
||||
* once the user closes the browser.
|
||||
*/
|
||||
clearWithBrowser: false,
|
||||
|
||||
/**
|
||||
* Define how long to keep the session data alive without
|
||||
* any activity.
|
||||
*/
|
||||
age: '2h',
|
||||
|
||||
/**
|
||||
* Configuration for session cookie and the
|
||||
* cookie store.
|
||||
*/
|
||||
cookie: {
|
||||
/**
|
||||
* Restrict the cookie to a URL path. '/' means all routes.
|
||||
*/
|
||||
path: '/',
|
||||
|
||||
/**
|
||||
* Prevent JavaScript access to the cookie in the browser.
|
||||
*/
|
||||
httpOnly: true,
|
||||
|
||||
/**
|
||||
* Send cookies only over HTTPS in production.
|
||||
*/
|
||||
secure: app.inProduction,
|
||||
|
||||
/**
|
||||
* Cross-site policy for cookie sending.
|
||||
*/
|
||||
sameSite: 'lax',
|
||||
},
|
||||
|
||||
/**
|
||||
* The store to use. Make sure to validate the environment
|
||||
* variable in order to infer the store name without any
|
||||
* errors.
|
||||
*/
|
||||
store: env.get('SESSION_DRIVER'),
|
||||
|
||||
/**
|
||||
* List of configured stores. Refer documentation to see
|
||||
* list of available stores and their config.
|
||||
*/
|
||||
stores: {
|
||||
/**
|
||||
* Store session data inside encrypted cookies.
|
||||
*/
|
||||
cookie: stores.cookie(),
|
||||
|
||||
/**
|
||||
* Store session data inside the configured database.
|
||||
*/
|
||||
database: stores.database(),
|
||||
},
|
||||
})
|
||||
|
||||
export default sessionConfig
|
||||
95
apps/api/config/shield.ts
Normal file
95
apps/api/config/shield.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { defineConfig } from '@adonisjs/shield'
|
||||
|
||||
const shieldConfig = defineConfig({
|
||||
/**
|
||||
* Configure CSP policies for your app. Refer documentation
|
||||
* to learn more.
|
||||
*/
|
||||
csp: {
|
||||
/**
|
||||
* Enable the Content-Security-Policy header.
|
||||
*/
|
||||
enabled: false,
|
||||
|
||||
/**
|
||||
* Per-resource CSP directives.
|
||||
*/
|
||||
directives: {},
|
||||
|
||||
/**
|
||||
* Report violations without blocking resources.
|
||||
*/
|
||||
reportOnly: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* Configure CSRF protection options. Refer documentation
|
||||
* to learn more.
|
||||
*/
|
||||
csrf: {
|
||||
/**
|
||||
* Enable CSRF token verification for state-changing requests.
|
||||
*/
|
||||
enabled: false,
|
||||
|
||||
/**
|
||||
* Route patterns to exclude from CSRF checks.
|
||||
* Useful for external webhooks or API endpoints.
|
||||
*/
|
||||
exceptRoutes: [],
|
||||
|
||||
/**
|
||||
* Expose an encrypted XSRF-TOKEN cookie for frontend HTTP clients.
|
||||
*/
|
||||
enableXsrfCookie: true,
|
||||
|
||||
/**
|
||||
* HTTP methods protected by CSRF validation.
|
||||
*/
|
||||
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
},
|
||||
|
||||
/**
|
||||
* Control how your website should be embedded inside
|
||||
* iframes.
|
||||
*/
|
||||
xFrame: {
|
||||
/**
|
||||
* Enable the X-Frame-Options header.
|
||||
*/
|
||||
enabled: true,
|
||||
|
||||
/**
|
||||
* Block all framing attempts. Default value is DENY.
|
||||
*/
|
||||
action: 'DENY',
|
||||
},
|
||||
|
||||
/**
|
||||
* Force browser to always use HTTPS.
|
||||
*/
|
||||
hsts: {
|
||||
/**
|
||||
* Enable the Strict-Transport-Security header.
|
||||
*/
|
||||
enabled: true,
|
||||
|
||||
/**
|
||||
* HSTS policy duration remembered by browsers.
|
||||
*/
|
||||
maxAge: '180 days',
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable browsers from sniffing content types and rely only
|
||||
* on the response content-type header.
|
||||
*/
|
||||
contentTypeSniffing: {
|
||||
/**
|
||||
* Enable X-Content-Type-Options: nosniff.
|
||||
*/
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
export default shieldConfig
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user