Skip to content

Architecture — Vue d'ensemble

Pattern : Clean Architecture allégée / N-tier

Le projet suit une séparation par projets .NET stricte, sans CQRS ni DDD strict. Pas de Repository pattern : les services métier consomment EF Core directement.

┌───────────────────────────────────────────────────────────────┐
│  Web (host HTTP)        Worker (BackgroundService Docker)     │
│  ─ Razor Pages          ─ jobs périodiques (paiements, VP)    │
│  ─ Web API (Areas/Api)                                        │
│  ─ SignalR Hubs                                               │
└───────────────────┬─────────────────────────┬─────────────────┘
                    │                         │
            ┌───────▼─────────────────────────▼────────┐
            │   Application                            │
            │   ─ Services/ (cross-cutting)            │
            │   ─ Services/Entities/ (1 par entité)    │
            │   ─ Services/ERP/Nav/ (NAV wrappers)     │
            │   ─ DTOs / AutoMapper Profile            │
            │   ─ TemplatesPdf/*.liquid                │
            │   ─ Library/ (helpers, Mapper, Excel…)   │
            │   ─ Connected Services/ (SOAP NAV refs)  │
            └───────┬──────────────────────────────────┘

            ┌───────▼──────────┐    ┌──────────────────────┐
            │   Persistence    │    │   Domain             │
            │   ─ Migrations   │◀───│   ─ Entities (124)   │
            │   ─ Design-time  │    │   ─ Enums (33)       │
            │     factory      │    │   ─ DbContexts (3)   │
            └──────────────────┘    │   ─ Fakers           │
                                    └──────────────────────┘

Note : les DbContext vivent dans Domain (et non dans Persistence ou Infrastructure comme le voudrait Clean Archi pure). Voir adr/0002-dbcontext-in-domain.md.

Couches & responsabilités

Domain (src/Domain)

  • Entities (124) : POCO EF, avec quelques propriétés [NotMapped] calculées (ex: Booking.StatusText, Travel.DistinctTravelOccurrences).
  • Enums (33) : tous les enums métier.
  • DbContexts :
    • ApplicationDbContext (SQL Server, seul actif)
    • GlobeDbContext (MySQL legacy, MORT)
    • WebSiteOldDbContext (MySQL legacy, MORT)
  • Stores : UserStore, RoleStore (Identity custom).
  • Fakers : factories Bogus utilisées pour les seeds + tests.
  • Localization / Resources : ressources de traduction fr.

Persistence (src/Persistence)

Uniquement les migrations EF + le DesignTimeDbContextFactory. Aucune logique. La MigrationsAssembly pointe vers ce projet.

Application (src/Application)

  • Services/Entities/ : un service par entité métier (BookingService, OccurrenceService, LoadingTableService, …). 45 fichiers.
  • Services/ : services cross-cutting (AuthService, BillService, OnlinePaymentService, PdfGeneratorService, VisualPlanningService, ReportService, DashboardService, RazorRendererService, SendGridService, etc.).
  • Services/ERP/Nav/ : services wrapper qui appellent les SOAP Connected Services.
  • Services/Entities/Interfaces/ : interfaces d'abstraction (IEntityService<T>, IEntityService<T, TModel> génériques).
  • Connected Services/Services.Nav.* : 25 références SOAP auto-générées vers Microsoft Dynamics NAV/BC + 1 vers SwissBilling. Ne pas modifier les Reference.cs.
  • Dtos/ : DTOs organisés par sous-domaine (Booking/, Travel/, Api/, Dashboard/, Nav/).
  • Profiles/Profiles.cs : un seul fichier AutoMapper qui contient tous les CreateMap.
  • Library/ : helpers réutilisables. Notable : Mapper.cs (mapper reflection custom, cf. patterns), ExcelExporter, Interceptors/AuditableAndSoftDeleteInterceptor.cs, LevenshteinDistance.
  • TemplatesPdf/*.liquid : templates Liquid pour Confirmation, Balance, Cancellation, CancellationInsurance, Catalog, Estimate, Gift, GiftCustomer, ClubMembershipSubscription/Renewal, SeasideVoucher, TransportAndAccommodation, Header, Footer, QrBill.
  • Templates/ : Razor templates email (FluentEmail.Razor).
  • Hubs/ : 2 SignalR hubs (BookingTrackingHub, EditFormActivityTrackingHub).
  • VisualPlanning/ : client HTTP pour Visual Planning + DTOs (Entity, ApiClient).
  • Mailing/, Gateways/Bc/, Middlewares/, Options/, ImageProcessor/, Helpers/, CembraPay/ : modules transverses.
  • ApplicationModule.ConfigureServices : DI registration centrale (~270 lignes). Importé par Web/Program.cs et Worker/Program.cs.

Web (src/Web)

  • App_Startup/Startup.cs : config DI + middleware pipeline (forwarded headers, en-têtes de sécurité, auth, rate limiting, CORS, output cache, Swagger, SignalR, Razor pages avec AuthorizeFolder("/", "DefaultPolicy")).
  • App_Startup/ForwardedHeadersConfiguration.cs : résolution de l'IP client réelle derrière Nginx Proxy Manager. Prérequis du rate limiting — voir ADR 0008.
  • App_Startup/RateLimiting.cs : les 4 politiques (auth, guess, write-anon, read-public) appliquées par [EnableRateLimiting] sur Areas/Api. Seuils en config (RateLimiting).
  • Program.cs : Serilog + Sentry + Identity + JWT + limites Kestrel.
  • Areas/ :
    • Api/ : 13 controllers REST (BookingController, OccurrenceController, TravelController, CustomerController, LoginController, etc.). Policy Api (JWT).
    • App/ : Razor pages métier (espace client final ?).
    • Identity/ : pages d'auth.
    • Dev/ : DbController pour seeding (/.dev/db/seed).
    • Tools/ : MiscController, GlobeController (legacy), SlugifyController (/.tools/Slugify/Travels, /.tools/nav/sync).
  • Pages/ : 40+ dossiers métier. Pattern doublon *.cshtml + *.cshtml.cs.
  • Frontend/ :
    • Custom/ : SCSS perso buildé via gulp.
    • Metronic/ : thème Bootstrap commercial buildé via gulp.
    • Vue/src/ : 14 mini-SPAs Vue 3 + Vuex (booking, travel-design, deck-configurator, occurrence-assign-resources, passenger-seats, accommodation-occupancy, accommodation-costs, activity-costs, cancel-booking, cancel-occurrence, drive, notes, deck-preview). Build via yarn buildwwwroot/assets/vue.

Worker (src/Worker)

  • BackgroundWorker.cs : BackgroundService qui orchestre des jobs cron-like via RunEvery/RunDaily/RunWeekly.

  • WorkerHostBuilder.cs : construction du host (copie réduite de la DI registration, subset des services nécessaires). Extrait de Program.cs exprès pour être testableProgram.cs n'est plus que WorkerHostBuilder.Create(args).Build().RunAsync(). Toute nouvelle dépendance de job doit être enregistrée ici.

  • tests/Worker : smoke test de démarrage. Le worker n'expose aucun endpoint, donc un service oublié dans la DI ne se voit qu'en prod, au premier tick du job concerné (vécu : passé sous silence à un déploiement). Le test construit le host réel en DevelopmentHost.CreateDefaultBuilder y active ValidateOnBuild, donc un seul enregistrement manquant fait échouer la construction, vérifié — et résout en plus explicitement, dans un scope, les services demandés par les jobs via GetRequiredService. Aucun accès base : construire un ApplicationDbContext n'ouvre pas de connexion.

  • Tourne en container Docker en prod.

  • Chaque exécution de job doit passer par RunSafely (try/catch + LogError → Sentry, la boucle repart au tick suivant). Sans ça — c'était le cas jusqu'au 10.08.2026 — une exception (typiquement SQL) faisait passer la tâche de la boucle en faulted et tuait ce job définitivement, en silence : Task.WhenAll d'ExecuteAsync n'achève jamais puisque les 4 autres boucles tournent à l'infini, donc BackgroundServiceExceptionBehavior.StopHost ne se déclenche pas (conteneur vert), et l'exception reste non observée donc invisible dans Sentry (branché via ILogger). Symptôme terrain : les paiements web ne se validaient plus, sans aucune alerte.

  • Le HEALTHCHECK du Dockerfile-worker ne détecte pas ce cas : il ne vérifie que la présence du process (grep Horizon.Worker /proc/1/cmdline), le worker n'exposant aucun endpoint HTTP. Et restart: always ne réagit pas à un état unhealthy (seulement à la sortie du process) : sans orchestrateur ni autoheal, un healthcheck rouge n'est qu'un affichage dans docker ps. L'alerting réel passe par Sentry.

  • Battement de cœur : RunSafely écrit Workers.LastSynchronization après chaque exécution réussie, avec une entrée d'enum Workers par job (PendingPayments, CustomersAndBooking, VisualPlanningAndGuestCleanup, LoyaltyPointsExpiration, MembershipRenewal, ServicesHealth). C'est le seul détecteur possible pour un job bloqué sans lever d'exception (requête SQL qui pend, appel NAV sans timeout) : le try/catch ne voit rien, Sentry non plus. Exposé par GET /api/health/workers (HealthController, [AllowAnonymous], non caché) : 200 si tous les jobs surveillés sont dans les clous, 503 dès qu'un seul est en retard ou n'a jamais tourné, avec le détail par job. Les seuils vivent dans WorkerService.MonitoredJobs — ~3 intervalles pour les jobs courts (tolère 2 tours manqués), intervalle + 2h pour les jobs à heure fixe : PendingPayments 10 min, CustomersAndBooking 20 min, VisualPlanningAndGuestCleanup et LoyaltyPointsExpiration 26 h, MembershipRenewal 8 j, ServicesHealth 15 min. ⚠ GetHealth est en lecture seule (contrairement à Get, qui crée la ligne manquante) et la liste surveillée est explicite : GlobeCustomersAndBooking en est exclu, sinon ce résidu legacy plus jamais alimenté mettrait l'endpoint en 503 en permanence. Requête équivalente en SQL : SELECT Type, LastSynchronization, DATEDIFF(minute, LastSynchronization, GETDATE()) FROM Workers. ⚠ Avant août 2026, seul DoFiveMinuteJob écrivait ce timestamp — et tout son contenu (imports Globe) est commenté, donc l'ancien heartbeat ne prouvait que la survie d'une boucle vide. GlobeCustomersAndBooking est un résidu legacy, à supprimer avec le reste du code Globe.

  • État des services externes : DoServicesHealthJob (toutes les 5 min) sonde VP, Business Central, Saferpay, CembraPay, SendGrid… et historise le résultat. Visible dans Système > État des services, exposé par GET /api/health/services (200 / 503). Détail des sondes, des plafonds de temps et de l'alerting : modules/monitoring.md.

WorkerGlobe (src/WorkerGlobe)

MORT. Conservé pour archive.

Flux d'une requête HTTP typique

Razor Page (back-office)

1. Browser GET /Bookings/CreateUpdate?id={guid}
2. Pipeline ASP.NET → Cookie auth (Identity) → DefaultPolicy
3. Pages/Bookings/CreateUpdate.cshtml.cs > OnGet
4. Injecte les services nécessaires (IBookingService, IOccurrenceService, …)
5. Charge les données → renvoie Page() avec les BindProperty / ViewBag
6. Razor render le .cshtml avec le PageModel
7. Le HTML inclut le bundle Vue (via main.js), qui monte la SPA correspondante sur un sélecteur DOM
8. La SPA appelle ensuite les endpoints internes (XHR vers /Bookings/CreateUpdate?handler=…)

API REST (web/mobile)

1. Client POST /api/Booking avec JWT en header
2. Pipeline ASP.NET → forwarded headers (IP réelle) → JwtBearer → rate limiter (429 + Retry-After si dépassement) → Api policy
3. Areas/Api/BookingController > Create([FromBody] BookingDto)
4. Newtonsoft.Json désérialise (case-insensitive)
5. Controller appelle IBookingService.SaveAsync ou similaire
6. Service utilise IMapper (AutoMapper) ou Library/Mapper (reflection) pour DTO ↔ Entity
7. Service modifie l'entité, appelle _db.SaveChangesAsync (interceptor pose IsDeleted/timestamps)
8. Retour DTO sérialisé en JSON camelCase (Newtonsoft default)

Routage MVC : patterns explicites uniquement — modifié juillet 2026

Les contrôleurs MVC (hors Areas/Api, qui portent tous un [Route]) sont exposés par des patterns explicites dans Startup.cs et ToolsPipeline.cs :

  • Notification/{action=Index} + Notification/Acknowledge/{id?}
  • /.toolsMisc/{action=Index} seulement

Ne jamais réintroduire un pattern générique {controller}/{action}. Il rendait adressable tout contrôleur de l'assembly, y compris DbController (/Db/Seed → drop de toutes les tables) accessible en GET par n'importe quel utilisateur authentifié, quel que soit son rôle. Les 3 seuls contrôleurs sans [Route] sont Notification, Db et Misc — un nouveau contrôleur MVC doit avoir soit un [Route], soit un pattern explicite.

Corollaire : Globe/Slugify étaient encore routés par ToolsPipeline alors que les contrôleurs n'existent plus (supprimés en e98e8abd) — routes retirées.

Branches app.Map : /.dev et /.tools — modifié juillet 2026

app.Map court-circuite le pipeline principal : une requête sous /.tools ne traverse ni UseHttpsRedirection, ni SignOutInactiveUserMiddleware, ni RedirectPendingUserMiddleware, ni Enforce2faMiddleware. Les utilisateurs désactivés, PENDING ou hors délai 2FA y gardaient donc accès.

Les deux branches sont maintenant conditionnées à IsDevelopment() || IsIntegration() et ToolsPipeline n'appelle plus UseDeveloperExceptionPage() (il renvoyait des stack traces en production). Les actions de MiscController sont en [HttpPost] + [ValidateAntiForgeryToken] (migrations de données irréversibles, précédemment déclenchables par un simple <img src>).

Si une vérification de sécurité doit valoir partout, la mettre dans OnValidatePrincipal du cookie, pas dans un middleware du pipeline principal — le branchement le contournerait.

/health — ajouté août 2026

Troisième branche app.Map, celle-ci active dans tous les environnements, déclarée juste après UseForwardedHeaders() : elle renvoie 200 Healthy en text/plain, sans auth, sans redirection HTTPS, sans accès base. C'est la cible du HEALTHCHECK du Dockerfile.

Seule chose qu'elle ne peut pas court-circuiter : le HostFilteringMiddleware, injecté par le host avant tout Configure(). Si un appsettings serveur restreint AllowedHosts à un domaine, la sonde (qui appelle Host: 127.0.0.1:5000) reçoit un 400 — il faut alors ajouter 127.0.0.1 à la liste.

Le court-circuit est ici l'objectif, pas un effet de bord. Sonder / ne peut pas fonctionner hors Development/Integration : Pages/Index est en [Authorize], et ConfigureApplicationCookie.OnRedirectToLogin (Startup.cs, actif seulement hors Dev/Integration) réécrit le challenge en URL absolue https://{host}/Identity/Account/Login. La sonde, qui tourne dans le conteneur où rien n'écoute sur 443 (TLS terminé par Nginx), suivait la redirection et échouait → conteneur unhealthy en permanence en staging et production, alors que l'image passait healthy en local (compose racine = ASPNETCORE_ENVIRONMENT=Integration, où l'override est désactivé).

La sonde ne teste que la vivacité du process (Kestrel répond). Elle ne vérifie ni la base ni NAV : un readiness check touchant la base ferait redémarrer/marquer unhealthy l'app à chaque hoquet SQL, sans bénéfice — ni Nginx ni Watchtower ne consultent le health status aujourd'hui.

En-têtes de sécurité & rate limiting — ajouté juillet 2026

  • SecurityHeadersMiddleware (Application/Middlewares) : X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, et CSP en Report-Only. La CSP n'est volontairement pas appliquée : le thème Metronic dépend massivement de scripts et styles inline. Pour la passer en mode bloquant il faut d'abord nonce-ifier ces inlines, sinon le back-office casse.
  • AddRateLimiter + policy Startup.AuthRateLimitPolicy ("auth", fenêtre fixe 20 req/min par IP) appliquée via [EnableRateLimiting] sur LoginModel, Areas/Api/LoginController et Areas/Api/MailController. Le lockout Identity (6 essais / 15 min) ne couvre pas le guessing distribué sur plusieurs comptes, ni l'API client qui n'a pas de lockout propre.

DI : DbContexts & services

  • DbContext : AddDbContext<ApplicationDbContext> avec UseSqlServer(...), MigrationsAssembly = Persistence, UseQuerySplittingBehavior(SplitQuery), et AddInterceptors(new AuditableAndSoftDeleteInterceptor(...)).
  • Comptes SQL séparés (ADR 0009) : DefaultConnection = compte runtime horizon_app (datareader/datawriter, aucun droit DDL). Les migrations sont appliquées par l'entrypoint du conteneur web (docker/entrypoint.sh : bundle EF /efbundle avec le compte horizon_migrator en db_ddladmin via HORIZON_MIGRATION_CONNECTION, secret effacé avant l'exec de l'app). Au boot hors Development : VerifySchemaAsync() (log Critical → Sentry si migrations pendantes) puis SeedAsync() — plus aucun MigrateAsync(). Le DesignTimeDbContextFactory lit HORIZON_MIGRATION_CONNECTION (fallback localhost\SQLEXPRESS en dev).
  • Identity : AddDefaultIdentity<User> avec password policy stricte (12 chars, digit, lowercase, uppercase, non-alphanumeric).
  • Auth : DefaultPolicy (cookie) requis par Razor pages, Api policy (JWT) pour Areas/Api.
  • AutoMapper : un seul MapperConfiguration avec Application.Profiles.Profiles.
  • OutputCache : 2 policies, travels (60 min) et travels-no-clear (12h, pour la home page). Désactivé en Dev/Integration.
  • Localization : forcée à fr (cf. business-rules sur la locale).

3 DbContexts (1 actif + 2 morts)

DbContextDBÉtat
ApplicationDbContextSQL ServerActif (seul utilisé)
GlobeDbContextMySQL globeMORT (legacy migration)
WebSiteOldDbContextMySQL legacy siteMORT (legacy migration)

Les 2 contexts MySQL sont enregistrés dans Startup.cs mais leurs services consommateurs (GlobeLegacyService, WebSiteOldService) sont commentés ou inactifs.

Soft delete via interceptor

AuditableAndSoftDeleteInterceptor :

  • Pose IsDeleted = true au lieu de DELETE.
  • Pose CreatedAt/UpdatedAt/CreatedBy/UpdatedBy (IHttpContextAccessor).
  • Filtre global EF exclut IsDeleted = true automatiquement.

Pour requêter les soft-deleted : IgnoreQueryFilters() explicite.

Patterns de service

  • Pas de Repository : les services manipulent _db.<Entities> directement.
  • 2 styles de service :
    • Service spécifique (BookingService, OccurrenceService) avec API riche et règles métier.
    • Service générique EntityService<T> ou EntityService<T, TModel> pour les CRUD simples (Resource, Vehicle, etc.).
  • Toutes les méthodes sont async quand elles touchent au DB.

Logging

  • Serilog : config dans Program.cs. 2 sinks : Console (Information+) + fichier Logs/search-log.txt filtré par propriété SearchLog (rolling mensuel, 12 fichiers max).
  • Sentry : Web et Worker. Sanitize automatique des bodies pour les routes /api (regex password masqué).

Contributors

No contributors

Changelog

No recent changes