Przejdź do głównej zawartości

Wyszukiwanie jest dostępne tylko w buildach produkcyjnych. Spróbuj zbudować i uruchomić aplikację, aby przetestować lokalnie.

Multi-tenancy: Aplikacje (Applications)

CRM jest multi-tenant — jedna instancja serwuje dziesiątki dealerów (Gezet, Tandem, …) i importerów (Renault, Volkswagen, …). Każdy z nich może być wystawiony pod jednym lub wieloma hostami (gezet.localhost, gezet-renault.localhost, renault.localhost, …). Każdy taki host to osobna Application — instancja aplikacji CRM osiągalna pod konkretnym hostem, z własnym brandingiem i własną logiką filtrowania danych.

Ten model jest specyficzny dla CRM (i będzie wzorcem dla BMS / Service), ale partnerska apka nie musi go używać. Jeśli jednak chcesz brand-scoped subdomeny + staff impersonation — to jest jak my to robimy.

Uwaga na nazewnictwo: “Application” w tym dokumencie oznacza naszą wewnętrzną instancję CRM pod konkretnym hostem (np. gezet-renault.localhost → Gezet w sieci Renault). To NIE jest to samo co “aplikacja partnerska” / “klient OAuth2” w sensie rejestracji w DealerID — patrz Rejestracja aplikacji.

TerminDefinicjaPrzykład
ApplicationInstancja aplikacji CRM osiągalna pod konkretnym hostem. Rekord w tabeli application.gezet.localhost, renault.localhost, staff.localhost
Application kindENUM określający charakter Application: dealer_main, importer_main, importer_network, staff.(patrz niżej)
Dealer mainGłówna aplikacja dealera (jego “domowy” CRM).gezet.localhost → Gezet
Importer mainGłówna aplikacja importera (CRM importera).renault.localhost → Renault Polska
Importer networkAplikacja dealera w sieci konkretnego importera. Dziedziczy branding z importer’s organization. Pokazuje tylko salony dealera w tej sieci.gezet-renault.localhost → Gezet w sieci Renault
StaffAplikacja staff DealerCRM (administracja platformy). Bypass filtra tenantu.staff.localhost
BrandMarka samochodowa (NIE to samo co importer — Stellantis to importer obejmujący marki Peugeot+Opel+Citroën).Renault, Dacia, Honda
ImporterImporter/dystrybutor samochodów (= organization typu importer).Renault Polska, Honda Motor Europe
DealerDealer / grupa dealerska (= organization typu dealer).Gezet Auto S.A., Tandem
KolumnaTypOpis
iduuidPK (UUID v7).
hostvarcharUnique. Pełny host (z portem dla dev). Np. gezet.localhost:3001, gezet-renault.localhost:3001, staff.localhost:3001.
organization_iduuidFK do organization — właściciel aplikacji. Dla dealer_main to organization dealera, dla importer_main to organization importera, dla staff to platform org.
kindENUMJedna z: dealer_main, importer_main, importer_network, staff. Decyduje o logice brandingu i filtra SQL.
importer_iduuid?FK do organization (organization typu importer). Nullable — wypełnione tylko dla kind = 'importer_network'. Wskazuje, w sieci którego importera Application działa.
activebooleanFALSE ⇒ host nie resolve’uje się (kill switch).
created_at / updated_attimestampAudit.

Zarządzanie: CRUD po stronie staff panelu DealerCRM (/configuration → staff-only). Patrz memorka project_organizations_staff_only.

Wszystkie hosty CRM siedzą w jednym, płaskim levelu subdomeny pod env zone (<env>.dealercrm.app). Konkretnie:

WzorzecApplication kindPrzykład (QA)
{dealer-slug}.<env>.dealercrm.appdealer_maingezet.qa.dealercrm.app
{importer-slug}.<env>.dealercrm.appimporter_mainrenault.qa.dealercrm.app
{dealer-slug}-{importer-slug}.<env>.dealercrm.appimporter_networkgezet-renault.qa.dealercrm.app
staff.<env>.dealercrm.appstaffstaff.qa.dealercrm.app

Kluczowe: dealer-w-sieci-importera używa myślnika jako separatora, nie kropki. To trzyma wszystkie tenanty pod jednym wildcard certem *.<env>.dealercrm.app i jedną regułą ingress. Konsekwencja: org slugi nie mogą zawierać myślników (eliminuje ambiguity gezet-renault vs hyphenated single slug). Walidator UI wymusza [a-z0-9]{2,32}.

Uzasadnienie i rozważone alternatywy: ADR-0005 w docs/architecture/0005-tenant-subdomain-namespace.md (repo root, nie Starlight — ADR-y to wewnętrzny log decyzji architektonicznych).

Branding skin mieszka na organization.branding_skin_slug (jedno źródło prawdy per firma). Application dziedziczy branding od organization:

Application kindBranding pochodzi z
dealer_mainorganization wskazanej przez application.organization_id
importer_mainorganization wskazanej przez application.organization_id
importer_networkorganization wskazanej przez application.importer_id (importer!)
stafforganization wskazanej przez application.organization_id

Dlaczego importer_network używa importera, nie dealera? Bo gezet-renault.localhost ma być brandingowany jak Renault (yellow / black), nie jak Gezet. To jest punkt styku dealera z marką importera — UI musi wyglądać “jak Renault”.

flowchart TB
    REQ([HTTP request<br/>gezet-renault.localhost:3001])

    subgraph BFF["crm-frontend (Next.js BFF)"]
        Middleware["middleware.ts<br/>(forward Host as X-Forwarded-Host)"]
        SessionFetch["/api/tenant/init<br/>(server-side)"]
    end

    subgraph API["crm-api"]
        Resolve["GET /api/public/tenant/resolve<br/>(reads Host / X-Forwarded-Host)"]
        DB[("application<br/>+ organization")]
        Validator["ApplicationAccessValidator<br/>(priority 7)"]
        Init["ApplicationContextInitializer<br/>(priority 5)"]
        Filter["TenantScopeFilter<br/>(Doctrine SQL filter)"]
    end

    REQ --> Middleware
    Middleware --> SessionFetch
    SessionFetch -->|"X-Forwarded-Host: gezet-renault.localhost:3001"| Resolve
    Resolve --> DB
    DB -->|"Application{<br/>id, host, kind,<br/>organization_id,<br/>importer_id,<br/>brandingSkinSlug<br/>(inherited)}"| Resolve
    Resolve -->|"applicationId<br/>brandingSkinSlug"| Middleware
    Middleware -->|"forward Host w fetch do API"| API
    API --> Validator
    Validator -->|"sprawdź user.memberships<br/>vs application.organization_id"| Init
    Init --> Filter
    Filter -->|"WHERE per kind (patrz niżej)"| DB

Zmiana vs poprzedni model: BFF już nie wysyła headerów X-Organization-Id ani X-Brand-Scope-Id. Backend wyłącznie resolve’uje aplikację z Host header (forward przez X-Forwarded-Host). Cały context aplikacji (organization, importer scope, kind, branding) jest jednoznacznie wyznaczony przez host.

CRM API rozpoznaje Application wyłącznie z Host header (lub X-Forwarded-Host jeśli za reverse proxy). Nie ma już żadnego klienckiego headera, który decyduje o tenant context — to zapobiega class of bugs, w którym BFF i API mogły “rozjechać się” co do tego, której org dotyczy request.

ApplicationAccessValidator:

  1. Z Host header → SELECT z application WHERE host = ? AND active = TRUE.
  2. Jeśli brak rekordu → 404 (host nie jest zarejestrowany).
  3. Walidacja membership: user musi mieć membership w application.organization_id. Wyjątki:
    • Staff (is_internal_staff = TRUE) → bypass.
    • Application kind staff → wymaga is_internal_staff = TRUE (inny user dostanie 403).
  4. Jeśli OK → ApplicationContextInitializer hyduje RequestScopedApplicationContext z polami aplikacji i aktywuje Doctrine SQL filter.

Doctrine SQL filter (encje oznaczone atrybutem #[TenantScoped]) dostają WHERE w zależności od application.kind:

KindSQL filter dorzucany do queryWyjaśnienie
dealer_mainWHERE dealer_id = $org_idDealer widzi tylko swoje dane (lead, klient, szansa…).
importer_mainWHERE importer_id = $org_idImporter widzi tylko dane swojej sieci dealerskiej.
importer_networkWHERE dealer_id = $org_id AND importer_id = $importer_idDealer w sieci importera widzi tylko swoje dane przefiltrowane przez sieć importera.
staff(bypass — brak filtra)Staff widzi cross-tenant queries. UI musi go ostrzec, że nie ma filtrowania.

Gdzie:

  • $org_id = application.organization_id
  • $importer_id = application.importer_id (tylko dla importer_network)

Encje denormalizują FK organization na konkretne role:

  • {role}_id (np. dealer_id, importer_id) — FK do organization gdzie rola jest znana z kontekstu domeny. Np. lead.dealer_id, lead.importer_id.
  • organization_id — FK do organization bez specyficznej roli (rzadkie, np. dla encji cross-org typu shared lookup).

To pozwala filtrowi SQL pisać precyzyjne WHERE bez JOIN’a do tabeli organization.

Gdy application.kind = 'staff', bypassFilter = true — Doctrine filter się nie aktywuje. Staff widzi cross-tenant queries. UI musi:

  1. Pokazać czerwony banner “Tryb staff — widzisz wszystkie tenanty”.
  2. (TODO) Logować każdą operację write w audit logu.
gezet.localhost:3001
→ Application(kind=dealer_main, organization_id=Gezet)
→ branding z Gezet.branding_skin_slug (np. "default")
gezet-renault.localhost:3001
→ Application(kind=importer_network, organization_id=Gezet, importer_id=Renault)
→ branding z Renault.branding_skin_slug ("renault" — yellow/black)
→ SQL filter: WHERE dealer_id = Gezet.id AND importer_id = Renault.id
renault.localhost:3001
→ Application(kind=importer_main, organization_id=Renault)
→ branding z Renault.branding_skin_slug ("renault")
→ SQL filter: WHERE importer_id = Renault.id
staff.localhost:3001
→ Application(kind=staff, organization_id=PlatformOrg)
→ branding z PlatformOrg.branding_skin_slug (np. "staff")
→ bypass filtra

User Łukasz, który ma membership w Gezet i Tandem (oraz is_internal_staff = TRUE):

  • Na DealerID /account/apps zobaczy karty: Gezet (gezet.localhost), Gezet — Renault (gezet-renault.localhost), Tandem (tandem.localhost), DealerCRM Staff (staff.localhost — bo kind=staff i Łukasz ma is_internal_staff).
  • Klika kartę → trafia na odpowiedni host → BFF forward’uje Host → CRM API rozpoznaje Application i aktywuje odpowiedni filtr.

Gdy staff klika kartę dealera (np. żeby zdebuggować problem klienta), wchodzi przez “normalny” host typu gezet.localhost. Frontend wykrywa isStaff && currentApplication.kind !== 'staff' i pokazuje czerwony banner „Pracujesz jako staff w Gezet — wszystkie zmiany zostaną zalogowane”. (Status: zaimplementowane w fazie 1, audit logging jeszcze TODO.)

Alternatywnie staff zostaje na staff.localhost i operuje cross-tenant — wtedy kind = 'staff' i bypass filtra jest aktywny od początku.

  • Host → dokładnie jedna aktywna Application (UNIQUE (host) przy active = TRUE).
  • application.organization_id zawsze wskazuje org “własną” Application (dealer dla dealer_main / importer_network, importer dla importer_main, platform dla staff).
  • application.importer_id jest NOT NULL wyłącznie dla kind = 'importer_network'.
  • Branding skin dziedziczony z organization — application nie ma własnego branding_* pola.
  • Frontend BFF nigdy nie wysyła ani nie ufa headerom X-Organization-Id / X-Brand-Scope-Id (te headery zostały usunięte z architektury). Application context wyznacza się wyłącznie z Host.
  • API nie ufa sam hostowi do autoryzacji — sprawdza, że user ma membership w application.organization_id (lub jest staff).

Jeśli BMS ma być multi-tenant z hostami:

  1. Skopiuj wzorzec application (lub uprość — może wystarczy bez importer_network + bez staff kindu).
  2. Zaimplementuj /api/public/application/resolve (no-auth) — czyta Host header.
  3. Twój BFF forward’uje Host / X-Forwarded-Host do API. Nie wymyślaj własnych headerów.
  4. Twój ApplicationAccessValidator waliduje membership w application.organization_id.
  5. Pamiętaj o exempt-list /api/dealerid (server-to-server discovery, bez application context).
  6. (Opcjonalnie) Doctrine TenantScoped + SQL filter dla cross-tenant safety, z logiką per-kind jak wyżej.

Dla mniejszych apek (np. Portal Klienta jest single-tenant per user — klient widzi tylko swoje dane, organization ID ≡ jego dealerstwo) wystarczy uproszczona wersja: 1 user → 1 organization, host obowiązkowy, ale resolve trywialny i bez per-kind logiki.