Développeurs et intégrateurs
Parqueo — documentation technique
Référence d'architecture et d'implémentation. Public visé : les personnes qui reprennent, modifient ou auditent le code.
- 1. Vue d'ensemble
- 2. Pile technique
- 3. Organisation du dépôt
- 4. Modèle de données
- 5. Authentification et autorisation
- 6. API HTTP
- 7. Moteur de workflows
- 8. Services de fond
- 9. Pièces jointes
- 10. Paramètres applicatifs
- 11. Front-end
- 12. Tests
- 13. Sécurité
- 14. Limites connues
1. Vue d'ensemble#
Parqueo est un logiciel ITSM auto-hébergé : ticketing, inventaire de parc, catalogue de demandes, base de connaissances et automatisation par workflows visuels. Il se déploie en deux processus :
Navigateur
│
│ HTTP (même origine en production)
▼
Reverse proxy ────────┬──────────────────────────────┐
│ / │ /api
▼ ▼
client/dist (SPA statique) API Express (port 4000)
│
┌────────────────────────────┼───────────────┐
▼ ▼ ▼
PostgreSQL server/uploads/ SMTP / IMAP
(Prisma) (pièces jointes)
Le serveur ne rend aucune page : c'est une API JSON, plus une seule exception
(la page HTML de l'enquête de satisfaction, GET /api/tickets/satisfaction).
Le client est une SPA React qui appelle /api en relatif — ce qui suppose que
le proxy serve les deux sur la même origine (voir la documentation
d'installation).
Trois processus de fond démarrent avec l'API, dans app.listen :
la clôture automatique des tickets résolus, le collecteur IMAP, et — au fil des
requêtes — le moteur de workflows.
2. Pile technique#
| Couche | Choix | Version |
|---|---|---|
| Runtime | Node.js (modules ESM, "type": "module") |
22.x |
| API | Express | ^4.21 |
| ORM | Prisma Client | ^5.20 |
| Base | PostgreSQL | 14+ (testé sur 18) |
| Auth | jsonwebtoken (HS256) + bcrypt | ^9.0 / ^6.0 |
| Sécurité HTTP | helmet, cors, express-rate-limit | ^8.3 / ^2.8 / ^8.6 |
| Email sortant | nodemailer | ^9.0 |
| Email entrant | imapflow + mailparser | ^1.4 / ^3.9 |
| Upload | multer (stockage disque) | ^2.2 |
| Front | React + React Router | ^19.2 / ^7.18 |
| Build front | Vite | ^8.1 |
| Styles | Tailwind CSS (plugin Vite) | ^4.3 |
| Lint front | oxlint | ^1.71 |
Aucune dépendance de framework côté API (pas de NestJS, pas de couche de validation type Zod) : les routeurs Express valident à la main, ce qui garde le volume de code bas au prix d'une validation à relire route par route.
Aucun client OIDC tiers non plus : le SSO Entra ID est implémenté directement
avec fetch + crypto natif + jsonwebtoken (voir §5.3).
3. Organisation du dépôt#
server/
prisma/
schema.prisma modèle de données (source de vérité)
migrations/ 11 migrations SQL versionnées
seed.js jeu minimal : 1 équipe, 1 catégorie, 1 admin
seed-demo.js jeu de démonstration (⚠ voir §14)
migrate-rules-to-workflows.js script ponctuel de migration historique
backfill-workflow-edges.js script ponctuel de migration historique
src/
index.js montage Express, middlewares, démarrage
lib/
prisma.js instance PrismaClient partagée
appSettings.js paramètres globaux + cache 10 s
visibility.js règle de visibilité des tickets (pure)
workflowUtils.js helpers du moteur (purs)
inbound.js parsing des emails entrants (pur)
labels.js libellés FR pour les emails
middleware/
auth.js vérification du JWT → req.user
roles.js requireRole(...)
routes/ auth, tickets, assets, users, workflows,
forms, kb, settings, refs
services/
workflowEngine.js machine à états des workflows
mailer.js notifications sortantes
mailbox.js collecteur IMAP
oidc.js SSO Microsoft Entra ID
autoclose.js clôture automatique horaire
uploads/ pièces jointes (hors Git)
client/
src/
App.jsx routes React Router
api/client.js wrapper fetch + jeton
context/ AuthContext, SettingsContext
components/ Layout, Brand, DataTable, WorkflowCanvas…
pages/ 13 pages
lib/
dashboard.js métriques, catalogue de widgets, layouts
workflowBlocks.js catalogue de blocs du canvas
labels.js libellés et couleurs
csv.js parseur CSV de l'import d'utilisateurs
Les modules lib/ du serveur sont volontairement purs (aucun accès base,
aucun I/O) : ce sont eux que couvrent les tests unitaires.
4. Modèle de données#
14 modèles, PostgreSQL, nommage snake_case en base et camelCase côté Prisma
(via @map).
4.1 Énumérations#
| Enum | Valeurs |
|---|---|
Role |
admin, technician, user |
TicketStatus |
new, in_progress, waiting, resolved, closed |
TicketPriority |
low, medium, high |
CommentType |
comment (message humain), event (entrée de journal) |
AssetType |
pc, printer, server, software |
AssetStatus |
in_service, in_repair, retired |
FieldType |
text, textarea, select, date, checkbox |
L'ordre de déclaration des enums compte : le tri par statut ou par priorité en
base suit l'ordre de l'enum PostgreSQL (new → closed, low → high), pas
l'ordre alphabétique. C'est ce qui rend le tri « priorité haute d'abord »
correct sans colonne numérique dédiée.
4.2 Entités#
User — email unique, passwordHash nullable (les comptes SSO n'en
ont pas), provider (local | entra), role, teamId optionnel.
Team, Category — deux référentiels plats (id, nom). La catégorie
porte les tickets, les formulaires et les articles de la base de connaissances.
Ticket — cœur du modèle. title, description, status, priority,
plus six liens : category (requis), author (requis), assignee, team,
asset, form. satisfaction (1/0/null) et satisfactionAt portent l'enquête
un clic. updatedAt est géré par Prisma (@updatedAt) et sert de base à la
clôture automatique.
TicketComment — messages et journal d'événements dans la même table,
discriminés par type. Les événements sont générés par les routes et par le
moteur de workflows, et sont écrits dans la même transaction que la
modification qu'ils décrivent (comments: { create: … } imbriqué dans le
update). Suppression en cascade avec le ticket.
Attachment — métadonnées seules ; le fichier vit sur le disque
(storedPath). Cascade avec le ticket — attention, le fichier n'est pas
supprimé du disque (voir §14).
Asset — inventaire : nom, type, emplacement, date d'achat, état,
utilisateur assigné. Un actif référencé par des tickets ne peut pas être
supprimé (409) : il faut le passer en retired.
Form / FormField — catalogue de demandes. Un formulaire cible une
catégorie et une priorité ; ses champs sont ordonnés par position, et les
choix d'un champ select sont stockés en JSON sérialisé dans options
(colonne String, pas Json). Les champs sont supprimés en cascade et
remplacés en bloc à chaque édition, jamais diffés.
Setting — table clé/valeur JSON. Trois familles de clés :
app.config (paramètres globaux), dashboard.layout.<role> (layout imposé par
rôle), dashboard.layout.user.<id> (layout personnel d'un compte).
KbArticle — titre, corps, published, catégorie optionnelle, auteur.
Workflow / WorkflowStep / WorkflowRun — voir §7.
4.3 Diagramme relationnel#
Team ──< User ──< Ticket >── Category
│ │ │
│ │ ├──< Form ──< FormField
│ │ └──< KbArticle
│ ├──< TicketComment
│ ├──< Attachment
│ └──< WorkflowRun >── Workflow ──< WorkflowStep
└── Asset
4.4 Migrations#
11 migrations, appliquées dans l'ordre :
| Migration | Apport |
|---|---|
init |
socle : users, teams, categories, tickets, comments, attachments, assets |
add_forms |
catalogue de demandes |
ticket_satisfaction |
enquête un clic |
kb_articles |
base de connaissances |
settings |
table clé/valeur |
workflows |
workflows + étapes |
drop_workflow_rules |
suppression des anciennes « règles » |
workflow_runs |
position d'un ticket dans un workflow |
workflow_canvas_layout |
coordonnées du canvas |
workflow_branches |
blocs condition, graphe edges |
sso_auth |
password_hash nullable + provider |
5. Authentification et autorisation#
5.1 Jeton#
POST /api/auth/login vérifie le couple email/mot de passe avec bcrypt puis
émet un JWT HS256 signé avec JWT_SECRET :
{ "sub": 12, "role": "technician", "teamId": 3, "iat": …, "exp": … }
Durée de vie : 7 jours, sans rafraîchissement ni révocation. Le client le
stocke dans localStorage sous la clé parqueo_token et l'envoie en
Authorization: Bearer ….
authRequired (middleware/auth.js) vérifie le jeton et pose req.user.
requireRole(...roles) (middleware/roles.js) filtre ensuite par rôle.
Conséquence importante : les changements de rôle, d'équipe ou la suppression
d'un compte ne prennent effet qu'à l'expiration du jeton, puisque req.user
provient du jeton et non d'une lecture en base.
5.2 Visibilité des tickets#
lib/visibility.js produit un where Prisma appliqué à toutes les lectures de
tickets :
| Rôle | Tickets visibles |
|---|---|
admin |
tous |
technician |
ceux qui lui sont assignés, ou non assignés, ou portés par son équipe |
user |
ceux dont il est l'auteur |
L'inventaire a sa propre règle, plus simple (routes/assets.js) : un user ne
voit que les actifs qui lui sont assignés, et l'accès complet à l'inventaire
peut lui être coupé par le paramètre assetsVisibleToUsers.
5.3 SSO Microsoft Entra ID#
Implémentation OIDC authorization code flow, client confidentiel, sans
dépendance externe (services/oidc.js). Activé dès que SSO_TENANT_ID,
SSO_CLIENT_ID et SSO_CLIENT_SECRET sont renseignés.
Navigateur API Parqueo Entra ID
│ GET /api/auth/sso/login │
│─────────────────►│ │
│ │ state = JWT{nonce, purpose} │
│ │ (10 min, signé JWT_SECRET) │
│◄─── 302 ─────────│ │
│──────────────── authorize?…&state&nonce ──────────►│
│◄──────────────── 302 code ─────────────────────────│
│ GET /api/auth/sso/callback?code&state │
│─────────────────►│ │
│ │ vérifie state → nonce │
│ │──── POST /token (secret) ──────►│
│ │◄─── id_token ───────────────────│
│ │ vérifie RS256 via JWKS, │
│ │ audience, issuer, nonce │
│ │ garde-fou domaines │
│ │ provisioning JIT (rôle user) │
│◄── 302 ?sso_token=… ────────────────────────────────
Points d'implémentation :
- Pas de session serveur. Le
stateest un JWT signé qui transporte le nonce anti-rejeu ; rien n'est stocké entre les deux requêtes. - La découverte OIDC (
.well-known/openid-configuration) et le JWKS sont mis en cache en mémoire, avec un rafraîchissement unique si unkidinconnu apparaît (rotation de clés). - Le jeton d'identité est vérifié en RS256 avec la clé publique convertie
JWK → PEM par
crypto.createPublicKey, en contrôlantaudience,issueretnonce. - Provisioning JIT : un compte inexistant est créé au premier login avec le
rôle
user,provider: 'entra'etpasswordHash: null. Toute élévation de rôle est donc un geste d'administration explicite. SSO_ALLOWED_DOMAINSrestreint les domaines email acceptés (garde-fou optionnel, en plus du caractère mono-annuaire de l'app registration).- Le JWT applicatif est renvoyé au navigateur dans l'URL
(
?sso_token=…) ;AuthContextle retire immédiatement de la barre d'adresse viahistory.replaceState. Voir §13 pour la portée du risque.
Un compte SSO qui tente le formulaire mot de passe reçoit un message dédié
(« Ce compte utilise la connexion Microsoft. »), puisque passwordHash est nul.
5.4 Limitation de débit#
Seul /api/auth/login est protégé : 20 tentatives par fenêtre de 15 minutes,
avec skipSuccessfulRequests: true — les connexions réussies ne consomment pas
le quota, pour qu'une équipe entière derrière une même IP (NAT) ne se bloque
pas elle-même.
6. API HTTP#
Préfixe commun /api. Toutes les routes exigent un jeton sauf
GET /api/health, GET /api/auth/config, POST /api/auth/login, les deux
routes SSO et GET /api/tickets/satisfaction.
Erreurs : { "error": "message en français" } avec un statut cohérent
(400 validation, 401 auth, 403 rôle, 404 introuvable ou invisible, 409
conflit). Le 404 est volontairement utilisé pour l'invisible : un technicien ne
peut pas distinguer un ticket inexistant d'un ticket qu'il n'a pas le droit de
voir.
6.1 Santé et authentification#
| Méthode | Chemin | Accès | Rôle |
|---|---|---|---|
| GET | /api/health |
public | — |
| GET | /api/auth/config |
public | expose { sso: bool } pour afficher ou non le bouton SSO |
| POST | /api/auth/login |
public | { email, password } → { token, user } |
| GET | /api/auth/sso/login |
public | redirige vers Entra |
| GET | /api/auth/sso/callback |
public | retour Entra → redirection avec sso_token |
| GET | /api/auth/me |
authentifié | profil courant |
6.2 Tickets#
| Méthode | Chemin | Rôle | Notes |
|---|---|---|---|
| GET | /api/tickets/satisfaction |
public (lien signé) | ?token=&value=up|down, répond en HTML |
| GET | /api/tickets |
tous | filtres + pagination, voir ci-dessous |
| GET | /api/tickets/:id |
tous | détail + commentaires + pièces jointes + état des workflows |
| POST | /api/tickets |
tous | crée, déclenche workflows et notification |
| PATCH | /api/tickets/:id |
admin, technicien | statut, priorité, assignation, équipe, actif, titre, description |
| POST | /api/tickets/:id/comments |
tous (si visible) | ajoute un message |
| POST | /api/tickets/:id/attachments |
tous (si visible) | multipart/form-data, champ file |
| GET | /api/tickets/:id/attachments/:attachmentId |
tous (si visible) | téléchargement |
Filtres de liste : status, priority, assigneeId (un id ou none),
categoryId, teamId, q (titre ou description, insensible à la casse),
sort.
Tri : présélections oldest, priority, ou colonne-direction parmi
id, status, priority, category, assignee, updated × asc|desc.
Défaut : updatedAt desc.
Pagination : sans page, la réponse est un tableau brut (utilisé par le
tableau de bord). Avec page, elle devient :
{ "items": [...], "total": 128, "counts": { "new": 12, "in_progress": 30, … } }
pageSize accepte un entier (borné à 500) ou all. counts est calculé
hors filtre de statut, pour que les pastilles de la liste affichent toujours
tous les statuts.
Le détail GET /:id enrichit la réponse d'un tableau workflows :
[{ name, waiting }], où waiting est le libellé lisible du bloc d'attente sur
lequel le ticket est parqué (« en attente de prise en charge », « en attente du
statut « Résolu » »).
6.3 Inventaire#
| Méthode | Chemin | Rôle |
|---|---|---|
| GET | /api/assets |
tous (filtré) — ?type=&status=&q= |
| GET | /api/assets/:id |
tous (filtré) — inclut l'historique des tickets |
| POST | /api/assets |
admin, technicien |
| PATCH | /api/assets/:id |
admin, technicien |
| DELETE | /api/assets/:id |
admin — 409 si des tickets le référencent |
6.4 Utilisateurs#
| Méthode | Chemin | Rôle |
|---|---|---|
| GET | /api/users/assignable |
admin, technicien — techniciens + admins |
| GET | /api/users |
admin |
| POST | /api/users |
admin — mot de passe ≥ 8 caractères |
| POST | /api/users/import |
admin — import en masse, voir ci-dessous |
| PATCH | /api/users/:id |
admin |
| DELETE | /api/users/:id |
admin |
Import en masse : { users: [{ name, email, role?, team? \| teamId?, password? }] },
1000 lignes maximum. Le traitement est ligne à ligne et non transactionnel :
un compte rendu par ligne (created, skipped, error) est renvoyé, et un
mot de passe aléatoire (~12 caractères, randomBytes(9).toString('base64url'))
est généré et retourné en clair dans la réponse quand la ligne n'en fournit
pas. C'est le seul moment où ce mot de passe est lisible.
Garde-fous : on ne peut ni retirer son propre rôle admin, ni supprimer son propre compte ; un compte porteur de tickets ou de commentaires ne peut pas être supprimé (409).
6.5 Workflows, formulaires, base de connaissances, référentiels#
| Méthode | Chemin | Rôle |
|---|---|---|
| GET/POST/PUT/DELETE | /api/workflows[/:id] |
admin uniquement |
| GET | /api/forms |
tous — actifs seulement, ?all=1 pour l'admin |
| GET | /api/forms/:id |
tous |
| POST | /api/forms/:id/submit |
tous — crée le ticket |
| POST/PATCH/DELETE | /api/forms[/:id] |
admin |
| GET | /api/kb, /api/kb/:id |
tous — les user ne voient que le publié |
| POST/PATCH/DELETE | /api/kb[/:id] |
admin, et techniciens si kbTechniciansWrite |
| GET | /api/categories, /api/teams |
tous |
| POST | /api/categories, /api/teams |
admin |
PUT /api/workflows/:id remplace le workflow en entier (métadonnées,
étapes, fils, positions du canvas) dans une transaction Prisma :
update + deleteMany(steps) + createMany(steps). Il n'y a pas d'édition
incrémentale d'une étape.
La recherche de la base de connaissances découpe q en mots d'au moins 3
caractères (8 maximum) et cherche chacun dans le titre ou le corps. C'est la
même route qui alimente les suggestions à la création de ticket (?take=3).
6.6 Paramètres#
| Méthode | Chemin | Rôle |
|---|---|---|
| GET | /api/settings/app |
tous — le client en a besoin pour adapter l'UI |
| PATCH | /api/settings/app |
admin |
| GET | /api/settings/dashboard |
tous — ?role= réservé à l'admin |
| PUT/DELETE | /api/settings/dashboard/personal |
tous, si la permission est accordée |
| PUT/DELETE | /api/settings/dashboard/:role |
admin |
7. Moteur de workflows#
services/workflowEngine.js + lib/workflowUtils.js. C'est la partie la plus
dense du logiciel ; le reste est du CRUD.
7.1 Modèle#
Un workflow est un graphe orienté :
trigger:ticket_createdoustatus_changed.conditions(JSON) : filtre d'entrée —categoryId,formId,priority,toStatus. Toutes les conditions renseignées doivent être vraies.steps: les blocs, chacun avec unekeystable, untype, uneconfigJSON et des coordonnéesx/y(canvas).edges(JSON) : les fils.
Une clé pointe vers une chaîne (sortie unique) ou, pour un bloc{ "trigger": "a", "a": "b", "b": { "yes": "c", "no": "d" } }condition, vers un objet{ yes, no }.layout(JSON) : position du nœud déclencheur sur le canvas.
7.2 Types de blocs#
| Catégorie | Type | Configuration | Effet |
|---|---|---|---|
| Action | assign_team |
teamId |
affecte l'équipe |
| Action | assign_user |
userId |
assigne (refusé si le compte est un user) |
| Action | set_priority |
priority |
change la priorité |
| Action | set_status |
status |
change le statut |
| Action | add_note |
body |
ajoute une entrée de journal (gabarit) |
| Action | send_email |
to (author|assignee|custom), email, subject, body |
envoie un email (gabarit) |
| Action | webhook |
url, secret |
POST JSON |
| Branche | condition |
field, value |
deux sorties oui/non |
| Attente | wait_assigned |
— | parque jusqu'à la prise en charge |
| Attente | wait_status |
status |
parque jusqu'à un statut donné |
Champs testables par condition : priority, status, category, form,
assigned (le ticket a-t-il un assigné).
Les blocs d'attente ne sont proposés dans l'éditeur que sur le déclencheur
ticket_created (catalogFor côté client) : un workflow réactif ne parque pas.
7.3 Exécution#
onTicketCreated(ticket) onTicketUpdated(ticket, ctx)
│ │
│ workflows ticket_created actifs │ si statut changé :
│ dont les conditions matchent │ workflows status_changed
▼ │ → traverse(sans run)
startRun ── crée un WorkflowRun │
│ (unique par ticket × workflow) ▼
▼ resumeRuns ── pour chaque run actif,
traverse(edges.trigger) reprend à run.nodeKey
traverse() parcourt le graphe depuis une clé de départ :
- action → exécutée, puis on suit le fil sortant. Une action qui échoue est
journalisée (
console.error) et sautée ; le parcours continue. - condition →
evaluateConditionchoisit la brancheyesouno. - attente → si
isGateSatisfiedest vrai, on continue ; sinon, en mode « run » le ticket est parqué (nodeKeymémorisé,status: running) et le parcours s'arrête. En mode réactif (pas de run), il s'arrête simplement. - fin de graphe → le run passe à
done,nodeKeyremis à nul.
Un Set de clés visitées empêche toute boucle infinie dans un graphe
cyclique : chaque bloc est exécuté au plus une fois par parcours.
Reprise. PATCH /api/tickets/:id rappelle le moteur dès que le statut ou
l'assignation change — ce sont précisément les deux événements qui peuvent
débloquer wait_assigned et wait_status. Un ticket parqué repart alors du
bloc où il attendait, la condition étant revérifiée au passage.
Anti-boucle. Les modifications faites par une étape passent par
prisma.ticket.update direct et ne repassent jamais par onTicketUpdated :
un set_status dans un workflow ne peut pas déclencher un workflow
status_changed. La propriété est structurelle, pas conditionnelle.
Idempotence d'entrée. WorkflowRun porte une contrainte
@@unique([ticketId, workflowId]) et startRun sort si un run existe déjà :
un ticket n'entre qu'une fois dans un workflow donné, même si l'événement se
répète.
7.4 Gabarits#
renderTemplate remplace {{clé}} dans les notes et les emails de workflow.
Variables disponibles : ticket.id, ticket.title, ticket.description,
ticket.status, ticket.priority, author.name, author.email,
assignee.name, assignee.email, category.name, team.name. Une variable
inconnue ou nulle est remplacée par une chaîne vide.
7.5 Webhook#
POST JSON, corps { event, workflow, ticket } où event est le déclencheur
du workflow. En-têtes : Content-Type: application/json et, si un secret est
configuré, X-Parqueo-Token: <secret>. Timeout 5 secondes via
AbortController. Seules les URL http:// ou https:// sont acceptées ; la
réponse n'est ni lue ni vérifiée, et un échec ne bloque pas le parcours.
7.6 Validation à l'enregistrement#
routes/workflows.js nettoie tout ce qui vient du client :
- 30 étapes maximum, types dans la liste blanche, clés présentes et uniques ;
- conditions restreintes aux 4 clés connues, valeur vide = condition retirée ;
cleanEdgesne conserve que les fils dont la source et la cible existent réellement, et impose la forme{ yes, no }aux seuls blocscondition.
Un canvas incohérent ne peut donc pas produire un graphe d'exécution invalide.
8. Services de fond#
8.1 Notifications sortantes (mailer.js)#
Quatre notifications, chacune débrayable dans les paramètres :
| Fonction | Déclencheur | Destinataires |
|---|---|---|
notifyTicketCreated |
création | demandeur + assigné |
notifyStatusChanged |
changement de statut | demandeur + assigné |
notifyAssigned |
assignation | le technicien qui reçoit (pas soi-même) |
notifyCommentAdded |
nouveau message | l'autre partie, jamais l'auteur du message |
Sans SMTP_HOST, rien n'est envoyé : le message est écrit en console
([mail non envoyé — SMTP non configuré]). C'est le mode de développement par
défaut.
Un échec d'envoi est attrapé et journalisé : il ne fait jamais échouer la
requête HTTP en cours. Les appels sont d'ailleurs lancés sans await depuis les
routes.
Enquête de satisfaction : quand un ticket passe à resolved et que
satisfactionSurvey est actif, l'email contient deux liens signés (JWT
purpose: 'satisfaction', valable 30 jours) vers
/api/tickets/satisfaction. La route est publique — le jeton est
l'authentification — et n'accepte qu'un seul vote par ticket.
8.2 Collecteur email (mailbox.js)#
Activé seulement si IMAP_HOST est renseigné. Boucle de relève :
- connexion ImapFlow, verrou sur
INBOX, recherche des messages non lus ; - chaque message est marqué lu avant traitement — un email qui fait planter le parseur ne sera pas retraité en boucle à chaque cycle ;
- parsing
mailparser, puishandleIncomingEmail.
Règles de traitement :
- l'expéditeur doit être un utilisateur connu (recherche par email) ; sinon le message est ignoré et journalisé — c'est le garde-fou anti-spam ;
- les adresses système (
SMTP_FROM,IMAP_USER) sont ignorées : anti-boucle ; - si le sujet contient
Ticket #net que le ticket existe → commentaire ; - sinon → nouveau ticket dans
IMAP_CATEGORY_ID, ou la première catégorie par id, avec un événement « Ticket créé par email » ; le pipeline complet (workflows + notification) s'applique ; - la citation du message précédent est retirée par
stripQuotedReply(heuristique : coupe au premier marqueur connu — « Le … a écrit : », « On … wrote: », « -----Message d'origine----- », ligne de_____,De:,From:, ou première ligne commençant par>) ; - les pièces jointes suivent la même liste blanche d'extensions et la même limite de 10 Mo que l'upload web.
Intervalle : IMAP_POLL_SECONDS (défaut 60), plancher à 15 secondes. Un
verrou running empêche deux relèves simultanées, et l'intervalle est unref()
pour ne pas retenir le processus.
8.3 Clôture automatique (autoclose.js)#
Au démarrage puis toutes les heures : les tickets resolved dont
updatedAt est antérieur à maintenant − autoCloseDays passent en closed,
avec un événement de journal explicite. autoCloseDays: 0 désactive le
mécanisme. L'auteur de l'événement est l'assigné, à défaut le demandeur.
Le paramètre vient de la base (app.config) ; la variable d'environnement
AUTO_CLOSE_DAYS n'en est que la valeur initiale par défaut.
9. Pièces jointes#
- Stockage disque via multer, dossier
uploads/relatif au répertoire de travail du processus — le serveur doit donc être lancé depuisserver/. - Nom sur disque :
randomUUID()+ extension d'origine tronquée à 10 caractères. Le nom d'origine n'est conservé qu'en base. - Limite : 10 Mo par fichier.
- Liste blanche d'extensions :
png jpg jpeg gif webp pdf txt log csv zip doc docx xls xlsx ppt pptx. Le filtre porte sur l'extension du nom de fichier, pas sur le contenu. - Le téléchargement passe par une route authentifiée qui revérifie la visibilité du ticket ; les fichiers ne sont jamais servis en statique.
10. Paramètres applicatifs#
Une seule ligne settings (clé app.config), fusionnée avec APP_DEFAULTS,
avec un cache mémoire de 10 secondes (les routes et les emails lisent
souvent).
| Clé | Défaut | Effet |
|---|---|---|
dashboardPersonalTechnician |
false |
les techniciens peuvent personnaliser leur tableau de bord |
dashboardPersonalUser |
false |
idem pour les utilisateurs |
ticketDefaultPriority |
medium |
priorité appliquée à défaut |
userCanSetPriority |
true |
un utilisateur peut choisir la priorité |
autoCloseDays |
AUTO_CLOSE_DAYS ou 7 |
délai de clôture automatique (0 = désactivé) |
satisfactionSurvey |
true |
enquête un clic à la résolution |
notifyOnCreate |
true |
email à la création |
notifyOnStatus |
true |
email au changement de statut |
notifyOnAssign |
true |
email à l'assignation |
notifyOnComment |
true |
email au nouveau message |
kbSuggest |
true |
suggestions d'articles à la création de ticket |
kbTechniciansWrite |
true |
les techniciens rédigent dans la base de connaissances |
assetsVisibleToUsers |
true |
l'inventaire est visible des utilisateurs |
Validation à l'écriture : ticketDefaultPriority doit être une priorité connue,
autoCloseDays un entier de 0 à 365, toute autre clé est booléenne. Une clé
inconnue est refusée (400) — le jeu de clés est fermé.
11. Front-end#
11.1 Routage#
SPA React Router. Protected exige une session, AdminOnly filtre
/admin et /parametres. Toute route inconnue redirige vers /.
| Chemin | Page |
|---|---|
/login |
connexion (mot de passe + bouton SSO conditionnel) |
/ |
tableau de bord modulaire |
/tickets |
liste filtrable et paginée |
/tickets/nouveau |
catalogue de demandes |
/tickets/nouveau/libre |
demande libre |
/demandes/:id |
remplissage d'un formulaire |
/tickets/:id |
détail d'un ticket |
/aide, /aide/:id, /aide/nouveau |
base de connaissances |
/inventaire, /inventaire/:id |
parc |
/admin |
utilisateurs, équipes, catégories, formulaires, workflows |
/parametres |
paramètres globaux |
11.2 Accès à l'API#
api/client.js : wrapper fetch minimal qui pose le Bearer, sérialise le
JSON, gère le 204 et transforme toute erreur en ApiError { status, message }.
Il n'y a pas d'intercepteur de 401 : un jeton expiré produit une erreur par
appel, et AuthContext ne purge la session qu'au chargement initial
(/auth/me en échec).
En développement, Vite proxifie /api vers http://localhost:4000
(vite.config.js). En production, c'est le rôle du reverse proxy.
11.3 Tableau de bord modulaire#
lib/dashboard.js définit :
- 12 métriques pour les tuiles de stat (tickets ouverts, non assignés, mes
tickets, priorité haute, créés/clôturés sur 7 jours, actifs…), certaines
marquées
staffOnlyet donc masquées aux utilisateurs ; - 13 types de widgets : tuile, donut, barre de statuts, flux hebdomadaire, barres par catégorie/priorité/âge, charge par technicien/équipe, types d'actifs, état du parc, liste de tickets, liste d'actifs ;
- des layouts par défaut distincts pour
useret pour le staff ; sanitizeLayout, qui écarte silencieusement les widgets inconnus (catalogue qui évolue) ou interdits au rôle.
Grille de 4 colonnes ; chaque widget déclare les tailles qu'il accepte (1, 2 ou
4). Les layouts par rôle sont imposés par l'administration ; un layout personnel
ne s'applique que si la permission correspondante est accordée, et il est
persisté en base (donc synchronisé entre navigateurs), pas en localStorage.
11.4 Éditeur de workflows#
components/WorkflowCanvas.jsx (~770 lignes) : canvas SVG avec blocs
déplaçables, fils tracés entre les points de connexion, sorties oui/non pour les
conditions, et panneau de configuration par bloc. lib/workflowBlocks.js porte
le catalogue partagé (libellés, icônes, couleurs, configuration par défaut) —
il doit rester aligné avec STEP_TYPES côté serveur.
steps + edges décrit au §7.1 : chaque bloc porte sa key, chaque fil est une entrée de edges, et les coordonnées sont persistées dans x/y.11.5 Tableau générique#
components/DataTable.jsx : filtre par colonne (liste des valeurs distinctes ou
recherche texte), tri cyclique au clic (desc → asc → aucun) et pagination
10/25/50/100/500/Tout. Le tri et le filtrage sont côté client — c'est
adapté aux référentiels (utilisateurs, actifs), pas à un volume de tickets
important, raison pour laquelle la liste des tickets a sa propre implémentation
serveur.
12. Tests#
npm test dans server/ → node --test 'src/**/*.test.js' (exécuteur natif
Node, aucun framework).
| Fichier | Couverture |
|---|---|
lib/inbound.test.js |
extraction de l'id de ticket, suppression des citations, normalisation d'adresse |
lib/visibility.test.js |
règle de visibilité pour les trois rôles |
lib/workflowUtils.test.js |
conditions, blocs d'attente, branches, gabarits |
26 tests, tous au vert à la date de rédaction. La couverture est volontairement concentrée sur la logique pure. Il n'y a aucun test d'intégration HTTP ni de test de bout en bout : les routeurs, le moteur en situation réelle et le front ne sont pas couverts.
Attention : node --test src/ (sans motif) importerait src/index.js et
démarrerait un vrai serveur — utilisez le script npm test.
Le front a npm run lint (oxlint) mais pas de tests.
13. Sécurité#
Ce qui est en place :
helmet(en-têtes de sécurité HTTP) etcorsrestreint àCLIENT_ORIGIN;- mots de passe bcrypt (coût 10), minimum 8 caractères ;
- limitation de débit sur le login ;
- autorisation à deux niveaux : rôle (
requireRole) puis visibilité (visibilityWhere) appliquée dans la requête SQL elle-même ; - pas de cookie de session → pas de surface CSRF ;
- validation stricte des paramètres applicatifs et des graphes de workflow ;
- SSO vérifiant signature, audience, issuer et nonce ;
- pièces jointes jamais servies en statique.
Points à connaître avant une mise en production :
- Jeton en
localStorage. Une faille XSS dans le front donnerait accès au jeton. Le choix évite CSRF et simplifie le déploiement ; il faut en contrepartie tenir le front propre. - Jeton non révocable, 7 jours. Changer un rôle, retirer une équipe ou
supprimer un compte ne coupe pas les sessions en cours. Pour une révocation
immédiate, il faut faire tourner
JWT_SECRET(ce qui déconnecte tout le monde). sso_tokentransite dans l'URL. Il est retiré de la barre d'adresse immédiatement, mais il peut apparaître dans l'historique du navigateur et dans les journaux d'accès du reverse proxy. Filtrez les query strings dans vos logs, ou passez à un cookieHttpOnlysi votre politique l'exige.- Collecteur email non authentifié cryptographiquement. L'expéditeur est vérifié par simple correspondance d'adresse : ni SPF, ni DKIM, ni DMARC ne sont contrôlés. Une adresse usurpée d'un utilisateur connu peut créer un ticket ou commenter en son nom. À faire filtrer en amont par le serveur de messagerie.
- Liste blanche d'extensions, pas de reniflage de contenu. Un exécutable
renommé
.pdfest accepté. Les fichiers ne sont jamais exécutés ni servis en statique, mais ils sont téléchargeables par les personnes qui voient le ticket. - Limitation de débit sur le seul login. Le reste de l'API n'a pas de quota ; ajoutez-en un au niveau du reverse proxy si l'API est exposée publiquement.
- Webhook sortant sans filtrage d'adresse. Un administrateur peut faire pointer un bloc webhook vers une IP interne (SSRF). Le rôle admin est de toute façon un rôle de confiance, mais l'API cloud interne d'un hébergeur reste atteignable.
- Mot de passe de seed par défaut (
admin@parqueo.local/admin1234) : à changer impérativement au premier démarrage.
14. Limites connues#
prisma/seed-demo.jsest cassé. Il appelleprisma.workflowRule(lignes 109, 111 et 254), un modèle supprimé par la migration20260719233351_drop_workflow_rules. Le script s'arrête sur unTypeErroravant de créer le moindre ticket. Même remarque pourprisma/migrate-rules-to-workflows.js, script de migration ponctuel devenu inexécutable — il n'a plus d'utilité et peut être supprimé.- Les fichiers ne sont pas supprimés du disque. La suppression d'un ticket
cascade sur la table
attachmentsmais laisse les fichiers dansuploads/. Aucune tâche de purge n'existe. uploads/est relatif au répertoire de travail. Lancer le serveur depuis un autre dossier crée un second dossier d'uploads et rend les anciennes pièces jointes introuvables.- Pas de pagination serveur hors tickets. L'inventaire, les utilisateurs et la base de connaissances renvoient tout (la base de connaissances est plafonnée à 50 articles par requête) et sont paginés côté client.
- Le tableau de bord charge tous les tickets visibles pour calculer ses métriques en JavaScript. À plusieurs dizaines de milliers de tickets, il faudra passer à des agrégats SQL.
client/package.jsons'appelle encoreclient(le serveur, lui, estparqueo-server).client/README.mdest le gabarit Vite par défaut, sans rapport avec le projet.- Pas de conteneurisation ni d'intégration continue : ni
Dockerfile, nidocker-compose.yml, ni workflow GitHub Actions. - Aucun journal d'audit distinct : la traçabilité repose sur les
commentaires de type
event, qui ne couvrent que le cycle de vie des tickets (rien sur les créations de comptes, changements de rôle ou de paramètres).