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 SOAPConnected 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 lesReference.cs.Dtos/: DTOs organisés par sous-domaine (Booking/,Travel/,Api/,Dashboard/,Nav/).Profiles/Profiles.cs: un seul fichier AutoMapper qui contient tous lesCreateMap.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é parWeb/Program.csetWorker/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 avecAuthorizeFolder("/", "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]surAreas/Api. Seuils en config (RateLimiting).Program.cs: Serilog + Sentry + Identity + JWT + limites Kestrel.Areas/:Api/: 13 controllers REST (BookingController,OccurrenceController,TravelController,CustomerController,LoginController, etc.). PolicyApi(JWT).App/: Razor pages métier (espace client final ?).Identity/: pages d'auth.Dev/:DbControllerpour 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 viayarn build→wwwroot/assets/vue.
Worker (src/Worker)
BackgroundWorker.cs:BackgroundServicequi orchestre des jobs cron-like viaRunEvery/RunDaily/RunWeekly.WorkerHostBuilder.cs: construction du host (copie réduite de la DI registration, subset des services nécessaires). Extrait deProgram.csexprès pour être testable —Program.csn'est plus queWorkerHostBuilder.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 enDevelopment—Host.CreateDefaultBuildery activeValidateOnBuild, 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 viaGetRequiredService. Aucun accès base : construire unApplicationDbContextn'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.WhenAlld'ExecuteAsyncn'achève jamais puisque les 4 autres boucles tournent à l'infini, doncBackgroundServiceExceptionBehavior.StopHostne se déclenche pas (conteneur vert), et l'exception reste non observée donc invisible dans Sentry (branché viaILogger). Symptôme terrain : les paiements web ne se validaient plus, sans aucune alerte.Le
HEALTHCHECKduDockerfile-workerne 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. Etrestart: alwaysne réagit pas à un étatunhealthy(seulement à la sortie du process) : sans orchestrateur ni autoheal, un healthcheck rouge n'est qu'un affichage dansdocker ps. L'alerting réel passe par Sentry.Battement de cœur :
RunSafelyécritWorkers.LastSynchronizationaprès chaque exécution réussie, avec une entrée d'enumWorkerspar 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é parGET /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 dansWorkerService.MonitoredJobs— ~3 intervalles pour les jobs courts (tolère 2 tours manqués), intervalle + 2h pour les jobs à heure fixe :PendingPayments10 min,CustomersAndBooking20 min,VisualPlanningAndGuestCleanupetLoyaltyPointsExpiration26 h,MembershipRenewal8 j,ServicesHealth15 min. ⚠GetHealthest en lecture seule (contrairement àGet, qui crée la ligne manquante) et la liste surveillée est explicite :GlobeCustomersAndBookingen 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, seulDoFiveMinuteJobécrivait ce timestamp — et tout son contenu (imports Globe) est commenté, donc l'ancien heartbeat ne prouvait que la survie d'une boucle vide.GlobeCustomersAndBookingest 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é parGET /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?}/.tools→Misc/{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 enReport-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+ policyStartup.AuthRateLimitPolicy("auth", fenêtre fixe 20 req/min par IP) appliquée via[EnableRateLimiting]surLoginModel,Areas/Api/LoginControlleretAreas/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>avecUseSqlServer(...),MigrationsAssembly = Persistence,UseQuerySplittingBehavior(SplitQuery), etAddInterceptors(new AuditableAndSoftDeleteInterceptor(...)). - Comptes SQL séparés (ADR 0009) :
DefaultConnection= compte runtimehorizon_app(datareader/datawriter, aucun droit DDL). Les migrations sont appliquées par l'entrypoint du conteneur web (docker/entrypoint.sh: bundle EF/efbundleavec le comptehorizon_migratorendb_ddladminviaHORIZON_MIGRATION_CONNECTION, secret effacé avant l'execde l'app). Au boot hors Development :VerifySchemaAsync()(log Critical → Sentry si migrations pendantes) puisSeedAsync()— plus aucunMigrateAsync(). LeDesignTimeDbContextFactorylitHORIZON_MIGRATION_CONNECTION(fallbacklocalhost\SQLEXPRESSen dev). - Identity :
AddDefaultIdentity<User>avec password policy stricte (12 chars, digit, lowercase, uppercase, non-alphanumeric). - Auth :
DefaultPolicy(cookie) requis par Razor pages,Apipolicy (JWT) pourAreas/Api. - AutoMapper : un seul
MapperConfigurationavecApplication.Profiles.Profiles. - OutputCache : 2 policies,
travels(60 min) ettravels-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)
| DbContext | DB | État |
|---|---|---|
ApplicationDbContext | SQL Server | Actif (seul utilisé) |
GlobeDbContext | MySQL globe | MORT (legacy migration) |
WebSiteOldDbContext | MySQL legacy site | MORT (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 = trueau lieu de DELETE. - Pose
CreatedAt/UpdatedAt/CreatedBy/UpdatedBy(IHttpContextAccessor). - Filtre global EF exclut
IsDeleted = trueautomatiquement.
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>ouEntityService<T, TModel>pour les CRUD simples (Resource, Vehicle, etc.).
- Service spécifique (
- Toutes les méthodes sont
asyncquand elles touchent au DB.
Logging
- Serilog : config dans
Program.cs. 2 sinks : Console (Information+) + fichierLogs/search-log.txtfiltré par propriétéSearchLog(rolling mensuel, 12 fichiers max). - Sentry : Web et Worker. Sanitize automatique des bodies pour les routes
/api(regexpasswordmasqué).

