Aller au contenu

Architecture

Principes directeurs

L'architecture de WonderWork repose sur quelques décisions fondamentales :

  • API centralisée — toute la logique métier vit dans l'API Symfony. Les frontends Next.js sont des clients comme les autres — ils ne contiennent aucune logique métier.
  • Async pour les opérations lourdes — le calcul des scores de matching, l'envoi d'emails et le parsing de CV passent par une queue Symfony Messenger. Les requêtes HTTP restent rapides.
  • DTOs à la frontière API — les entités Doctrine ne sont jamais exposées directement. Des Output DTOs contrôlent exactement ce qui sort de l'API ; des Input DTOs valident ce qui entre.
  • Types TypeScript générés — les types côté frontend sont générés automatiquement depuis le spec OpenAPI du backend. Ils ne sont jamais écrits à la main.

Stack technique

Outil Version Rôle
PHP 8.4 Runtime
Symfony 7.4 LTS Framework — routing, DI, Messenger, Mailer, Console
API Platform 4.3.x Génération REST + OpenAPI depuis les DTOs
Doctrine ORM 3.x Mapping objet-relationnel, migrations
PostgreSQL 17 Base de données principale
Redis 7.x Cache applicatif, sessions, rate limiting
Symfony Messenger bundled Queue async (calcul matching, emails)
LexikJWT latest Authentification JWT — access token 15 min
gesdinet/jwt-refresh-token latest Refresh token rotatif — révocation en cas de vol
Symfony Mailer + Brevo latest Emails transactionnels via SMTP Brevo
PHPUnit 13 Tests unitaires (logique isolée)
Behat 3.x Tests fonctionnels BDD — scénarios Gherkin
Zenstruck Foundry 2.6 Factories et stories pour les seeds
ECS 13.x Code style PHP CS Fixer @Symfony
PHPStan 2.1.x Analyse statique niveau 8

Pourquoi API Platform ? API Platform génère automatiquement les routes REST, la pagination, les filtres, la documentation OpenAPI et le schéma JSON-LD à partir des classes PHP annotées. Cela évite d'écrire des controllers répétitifs pour chaque ressource. Les cas complexes sont gérés par des State Providers/Processors custom.

Pourquoi PostgreSQL ? Les index GIN trigram (pg_trgm) permettent l'autocomplete de villes sur ~140k entrées sans Elasticsearch. Les contraintes FK et d'unicité sont respectées au niveau DB. Le JSON natif est utilisé pour les champs flexibles (ex: preferred_sectors).

Pourquoi Redis ? Le cache applicatif Symfony est configuré sur Redis — les résultats de requêtes fréquentes (liste de compétences, villes populaires) sont mis en cache. Redis gère aussi le rate limiting des endpoints publics.

Outil Version Rôle
Next.js 15 Framework App Router
React 19 UI
TypeScript 5.x Typage strict
Tailwind CSS 4.x CSS-first, utility classes
shadcn/ui latest Composants accessibles (Radix UI + Tailwind)
TanStack Query 5.x Appels API + cache client
Zustand 5.x État global UI (auth, etc.)
React Hook Form + Zod 7.x / 3.x Formulaires guidés + validation
Serwist latest PWA — service worker, manifest

Pourquoi Next.js App Router ? Les layouts imbriqués, le routing basé sur le filesystem et les Server Components permettent de structurer l'app par domaine métier (onboarding, passport, jobs, applications). L'App Router gère nativement la mise en cache et le streaming.

Pourquoi une PWA ? L'app candidat est mobile-first — les candidats consultent leurs matches et postulent depuis leur téléphone. La PWA permet l'installation sur l'écran d'accueil et un comportement natif.

Pourquoi TanStack Query ? La gestion du cache client, des états de chargement, des erreurs et de l'invalidation est complexe à écrire manuellement. TanStack Query gère tout ça — les composants déclarent ce dont ils ont besoin, pas comment le fetcher.

Outil Version Rôle
Next.js 15 Framework App Router
React 19 UI
TypeScript 5.x Typage strict
Tailwind CSS 4.x CSS-first
shadcn/ui latest Composants UI
TanStack Query 5.x Appels API + cache
TanStack Table 8.x Tables de données complexes (tri, filtres, pagination)
dnd-kit latest Drag & drop — board Kanban du pipeline ATS
Recharts latest Graphiques du dashboard (candidatures, conversions)
Zustand 5.x État global (auth, company)

Pourquoi TanStack Table ? Le pipeline ATS affiche des tables de candidats avec tri multi-colonnes, filtres combinés et pagination. TanStack Table est headless — il gère la logique sans imposer de styles, ce qui s'intègre parfaitement avec Tailwind.

Pourquoi dnd-kit ? Le pipeline Kanban (board de candidatures par étape) nécessite du drag-and-drop accessible. dnd-kit est la bibliothèque de référence pour React avec support clavier et screen readers.


Flux de données

flowchart TD
    subgraph Frontend["Frontend (candidate / recruiter)"]
        Browser["Browser\n(fetch /v1/*)"]
        NextServer["Next.js Server\n(App Router)"]
        Browser -->|"relative /v1/..."| NextServer
    end

    subgraph API["API (Symfony)"]
        Controller["API Platform\n(State Providers/Processors)"]
        Service["Services\n(logique métier)"]
        Messenger["Symfony Messenger\n(queue async)"]
        ORM["Doctrine ORM"]
    end

    NextServer -->|"rewrite /v1/* → API_INTERNAL_URL"| Controller
    Controller --> Service
    Service --> ORM
    Service --> Messenger
    ORM --> DB[(PostgreSQL)]
    Messenger -->|async| Worker["Worker\n(matching, emails)"]
    Worker --> DB
    Worker -->|SMTP| Brevo["Brevo"]

Pourquoi les requêtes passent-elles par Next.js ? Les apps Next.js ne sont pas des SPA classiques — elles incluent un serveur Node.js. Toutes les requêtes fetch /v1/* passent par ce serveur qui les redirige vers l'API Symfony via une réécriture interne (API_INTERNAL_URL). Cela permet :

  • Les cookies httpOnly (JWT) de traverser naturellement sans être accessibles en JavaScript côté browser
  • L'API Symfony de ne pas être exposée directement à internet (URL interne non routée)
  • De centraliser les headers d'authentification

Authentification

sequenceDiagram
    participant Browser
    participant NextServer as Next.js Server
    participant API as Symfony API

    Browser->>NextServer: POST /v1/auth/login
    NextServer->>API: POST /v1/auth/login
    API-->>NextServer: Set-Cookie: jwt (httpOnly) + refresh_token (httpOnly)
    NextServer-->>Browser: cookies transmis

    Browser->>NextServer: GET /v1/me (cookie auto)
    NextServer->>API: GET /v1/me (cookie forwarded)
    API-->>NextServer: User payload
    NextServer-->>Browser: données utilisateur

    Note over Browser,API: JWT expire après 15 min
    Browser->>NextServer: POST /v1/auth/refresh
    NextServer->>API: POST /v1/auth/refresh (refresh token)
    API-->>NextServer: nouveau JWT (rotation du refresh token)

JWT en cookie httpOnly — le token n'est jamais accessible en JavaScript (document.cookie). Il ne peut pas être volé via XSS.

Refresh token rotatif — à chaque renouvellement, l'ancien refresh token est invalidé et un nouveau est émis. Si un token déjà utilisé est soumis (signe de vol), tous les tokens de l'utilisateur sont révoqués immédiatement.

Durées différenciées — JWT 15 min (candidate + recruiter). Refresh token 30 jours pour le candidat (usage mobile, sessions longues) vs session pour le recruteur (usage desktop professionnel, sécurité accrue).


Conventions API

JSON-LD partout — toutes les requêtes et réponses utilisent application/ld+json. Aucun application/json plain. JSON-LD ajoute des métadonnées sémantiques (@context, @type, @id) qui permettent à API Platform de générer une documentation OpenAPI riche et un hypermedia navigable.

Content-Type: application/ld+json
Accept: application/ld+json

Préfixe : /v1/ — versionnage explicite de l'API.

Pattern Exemple Quand l'utiliser
Collection GET /v1/job-offers Ressource publique ou multi-tenant
Ressource de l'utilisateur connecté GET /v1/me/matches Données propres à l'utilisateur authentifié
Accès tiers GET /v1/candidates/{id}/matches Admin ou recruteur accédant à une ressource d'un autre utilisateur
Action non-CRUD POST /v1/applications/{id}/withdraw Transitions d'état ou commandes métier

Pas de verbes dans les URLs — le verbe HTTP est le verbe.


Conventions Symfony

Injection de dépendances : constructeur uniquement. Jamais de service locator ($container->get(...)). Toutes les dépendances sont déclarées explicitement — le code est testable et les dépendances circulaires sont détectées au démarrage.

Repositories : seul endroit où les requêtes Doctrine sont écrites. Les services orchestrent ; les repositories lisent et écrivent la base.

DTOs : jamais d'entités Doctrine exposées directement via l'API.

  • Input DTOs (src/DTO/Input/) — valident et typent les données entrantes. API Platform les valide avec les contraintes Symfony avant d'appeler le Processor.
  • Output DTOs (src/DTO/Output/) — contrôlent exactement quels champs sont sérialisés dans la réponse. Un champ ajouté à l'entité n'apparaît pas automatiquement dans l'API.

State Providers/Processors (src/State/) : toute logique custom d'API Platform. Pas de controllers Symfony classiques — API Platform route tout vers ces classes.


Génération des types TypeScript

Les types TypeScript des frontends sont générés depuis le spec OpenAPI du backend — jamais écrits à la main pour les shapes API.

# Depuis candidate/ ou recruiter/
curl -s http://localhost:8000/v1/docs \
  -H "Accept: application/vnd.openapi+json" > /tmp/openapi.json
npx openapi-typescript /tmp/openapi.json -o src/types/api.ts

src/types/api.ts est auto-généré — ne jamais éditer manuellement. Après tout changement de DTO backend, régénérer dans tous les frontends concernés.

Pourquoi cette contrainte ? Sans génération automatique, les types frontend et les réponses API dérivent progressivement. Un champ renommé côté backend provoque des erreurs silencieuses côté frontend — le TypeScript compile mais les données sont undefined. La génération depuis OpenAPI rend ces divergences visibles au moment du build.


Structure des dossiers

src/
├── ApiResource/     ← Définitions API Platform (attributs #[ApiResource])
├── Command/         ← Commandes console Symfony (seed, import, setup)
├── DTO/
│   ├── Input/       ← LoginInput, PassportInput, JobOfferInput…
│   └── Output/      ← UserOutput, MatchItemOutput, ApplicationOutput…
├── Entity/
│   ├── Application/ ← Application, ApplicationStageHistory
│   ├── Auth/        ← EmailVerificationToken
│   ├── Candidate/   ← Candidate, Passport, PassportSkill…
│   ├── Job/         ← JobOffer, JobOfferSkill, PipelineStage…
│   ├── Matching/    ← MatchScore
│   ├── Recruiter/   ← Recruiter, Company, CompanyCultureValue
│   ├── Reference/   ← City, Skill, Nationality, CultureValue
│   ├── Rome/        ← RomeJob, RomeJobAppellation, RomeJobSkill
│   ├── RefreshToken.php
│   └── User.php     ← Base CTI (auth + identité)
├── Enum/            ← Sector, EducationLevel, ContractType, ApplicationStatus…
├── Message/Email/   ← Messages async pour les emails
├── MessageHandler/  ← Handlers Symfony Messenger
├── Repository/      ← Requêtes Doctrine par domaine métier
├── Service/
│   └── Matching/    ← MatchingBloc1-6, PreFilter, ScoreCalculator
├── State/           ← Providers + Processors API Platform
├── Story/           ← Foundry stories (seed dev/test)
└── Validator/       ← Contraintes de validation custom
src/
├── app/
│   ├── (auth)/      ← login, register (routes publiques)
│   └── (app)/       ← dashboard, passport, jobs, applications (protégé)
├── components/
│   ├── ui/          ← shadcn/ui (ne pas éditer manuellement)
│   ├── jobs/
│   ├── passport/
│   └── layout/
├── hooks/
├── stores/          ← Zustand (authStore, etc.)
├── lib/
│   ├── api/         ← TanStack Query hooks + fetch functions
│   └── schemas/     ← Zod schemas (validation formulaires uniquement)
└── types/           ← api.ts (auto-généré) + types locaux
src/
├── app/
│   ├── (auth)/      ← login
│   └── (app)/       ← dashboard, job-offers, pipeline, candidates, settings
├── components/
│   ├── ui/          ← shadcn/ui
│   ├── job-offers/
│   ├── pipeline/    ← Board Kanban, drawer candidature
│   ├── candidates/
│   └── layout/
├── hooks/
├── stores/          ← authStore, companyStore
├── lib/api/
└── types/