SSO CRM/upstream data flow
Backend handoff for SSO Profile upstream APIs, auth, and data ownership.
SSO Profile -> CRM / upstream integration data flow
Status: working integration notes created after the initial sprint. Verify exact upstream contracts with the owning backend/CRM teams before treating this as a frozen API specification.
Audience: backend/CRM/integration engineers. This document intentionally skips frontend screen behavior and focuses on upstream dependencies, authentication, and data flow.
Related docs:
- Mobile/Bearer API exposed by this app: Mobile/Bearer API
- Keycloak/session token storage: Keycloak/session token storage
- Deploy/env notes:
deploy/portainer/README.mdin the SSO Profile source repository
Executive summary
profil.slavia.cz is a backend-for-frontend (BFF) over several upstream systems:
- Keycloak SSO identifies the logged-in user and provides the access token used for most user-scoped upstream calls.
- Profile Service is the source of truth for editable profile data, profile avatar, and SSO Profile custom flags.
- CRM API provides communications, marketing consent, loyalty, ticketing history/detail, and ticketing-derived activity.
- Ticketportal fan server provides active tickets/season tickets and favorite-player catalog data.
- Fanshop order API provides fanshop order history/detail and fanshop activity enrichment.
The app generally does not store business records itself. It stores only the encrypted server-side auth session handle/session payload, including the current Keycloak token set. Business data is read from or written to upstream systems on demand.
High-level data flow
Browser or mobile app
|
| 1. User authenticates with Keycloak
v
SSO Profile app / BFF
|
|-- Keycloak discovery/token/userinfo/refresh
|
|-- Profile Service
| GET/POST /v1/profile
| GET/POST/DELETE /v1/avatar
|
|-- CRM API
| GET/PATCH /api/Person
| GET /api/Customer/0/Communications
| GET /api/PushNotification/CampaignPreview/{id}
| GET /api/Transaction/Customer/0
| GET /api/Transaction/CustomerActivity/0
| GET /api/Transaction/CustomerOrders
| GET /api/Transaction/{transactionId}/TicketId/{ticketId}
| GET /api/Transaction/{transactionId}/Tickets
| GET /api/Transaction/{transactionId}/BasicInfo
| GET /api/SeasonTicket/CRM/Products?seasonTicketNum=...
| GET/POST /api/LoyaltySystem/...
|
|-- Ticketportal fan server
| GET /ticketing/myTickets
| GET /ticketing/mySeasonTickets
| GET /ticketing/seasonTicketAttendance?id=...
| GET /enums/seasons/current
| GET /player/list?teamId=...&seasonId=...
|
|-- Fanshop order API
GET /api/order/list?customer_id=...
GET /api/order/detail?id=...Authentication model
End-user identity
- Browser users authenticate through Keycloak Authorization Code + PKCE.
- Mobile/app clients call
/api/app/v1/*withAuthorization: Bearer <keycloak-access-token>; see Mobile/Bearer API. - The app resolves the current user from Keycloak and uses the Keycloak access token for user-scoped upstream calls.
Server-side session/token storage
Browser sessions use a server-side session handle cookie. The session payload contains:
- workspace user id
- email when available
- allowed/default instances
- Keycloak token set (
accessToken,refreshToken, expiry, etc.)
Storage modes:
- Redis-backed session store in production (
WORKSPACE_AUTH_REDIS_URL) - in-memory fallback only outside production
The app refreshes Keycloak tokens server-side when needed and uses a refresh lock to avoid concurrent refresh storms.
Upstream auth patterns
| Upstream | Auth used by SSO Profile app |
|---|---|
| Keycloak | OAuth/OIDC client config (KEYCLOAK_*) |
| Profile Service | Authorization: Bearer <user Keycloak token> |
| CRM API | Authorization: Bearer <user Keycloak token> |
| Ticketportal fan server | Authorization: Bearer <user Keycloak token> for ticketing endpoints; public/no bearer for favorite-player catalog calls |
| Fanshop order API | Global bearer token from ORDER_API_BEARER_TOKEN / FANSHOP_TOKEN; user is selected by email query/filter |
Required environment/config
| Variable | Purpose |
|---|---|
WORKSPACE_AUTH_SESSION_SECRET | Required to seal/unseal server-side auth sessions. AUTH_SESSION_SECRET is a legacy alias. |
WORKSPACE_AUTH_REDIS_URL | Redis session storage. Required in production auth flow. |
WORKSPACE_AUTH_REDIS_KEY_PREFIX | Optional Redis key prefix. |
KEYCLOAK_SSO_BASE_URL | Keycloak issuer/realm URL. Legacy alias: SSO_BASE_URL. Default: https://id.slavia.cz/realms/plg. |
KEYCLOAK_CLIENT_ID | Keycloak client id. Legacy alias: SSO_CLIENT_ID. Default: slavia. |
KEYCLOAK_SCOPE | OAuth scopes. Legacy alias: SSO_SCOPE. Default: openid profile email. |
KEYCLOAK_PUBLIC_URL / SERVER_URL | Public origin used to derive callback URLs when exact overrides are not set. |
KEYCLOAK_REDIRECT_URI | Optional exact login callback URI. |
KEYCLOAK_POST_LOGOUT_REDIRECT_URI | Optional exact post-logout redirect URI. |
KEYCLOAK_ALLOWED_INSTANCES | Optional comma-separated list of workspace instances from auth/session. |
KEYCLOAK_DEFAULT_INSTANCE | Optional default workspace instance. |
PROFILE_SERVICE_URL | Required origin-only Profile Service URL, e.g. https://profile-service.example.com. No path/query/hash. |
CRM_API_URL | Required CRM API base URL. FRM_API_URL remains a legacy alias. |
WORKSPACE_LOYALTY_PROGRAM_ID | Required for CRM loyalty history. |
TP_FAN_SERVER_API_URL | Ticketportal fan server base URL. Required in production for TP-backed features. Non-production fallback: https://stage-slavia.tpapp.cz/. |
ORDER_API_BEARER_TOKEN | Fanshop order API bearer token. Legacy aliases: FANSHOP_API_BEARER_TOKEN, FANSHOP_TOKEN. |
ORDER_API_URL | Optional fanshop order API base URL. Legacy alias: FANSHOP_API_URL. Default: https://fanshop.slavia.cz/. |
Upstream dependencies
1. Keycloak SSO
Code: src/features/auth/keycloak.server.ts, src/features/auth/workspace-keycloak-token.server.ts
Used for:
- login/logout
- token exchange and refresh
userinfoidentity resolution- bearer token forwarded to Profile Service, CRM API, and Ticketportal fan server
Relevant upstream calls:
- OIDC discovery document from configured issuer
- authorization endpoint
- token endpoint
- userinfo endpoint
- end-session endpoint when available
Timeouts/refresh behavior:
- Upstream timeout: 10s.
- Discovery document cache: 5 minutes.
- Token refresh skew: 30s before expiry.
- Auth-flow state max age: 30 minutes.
- Refresh lock window is based on Keycloak timeout and stored in the session store.
2. Profile Service
Code: src/features/workspace/profile/*
Base URL: PROFILE_SERVICE_URL
Auth: Authorization: Bearer <user Keycloak token>
Profile Service owns editable profile data and SSO Profile custom flags.
| Method/path | Used for |
|---|---|
GET /v1/profile | Load current profile. 404 means no persisted profile yet. |
POST /v1/profile | Upsert profile and supported custom fields. |
GET /v1/avatar | Load avatar URL. 404 means no avatar. |
POST /v1/avatar | Upload/set avatar from normalized base64 or URL input. |
DELETE /v1/avatar | Delete avatar. 404 is treated as already deleted. |
Important field mapping:
| SSO Profile field | Profile Service field |
|---|---|
id | id |
email | email |
firstName / lastName | firstName / lastName |
birthDate | birthdate |
gender: "1" | gender: "male" |
gender: "2" | gender: "female" |
gender: "0" or empty | gender: null / "other" read as "0" |
primaryPhone | first item in phone[] |
address | address.street |
city | address.city |
zip | address.zip |
countryCode | converted between ISO alpha-2 in SSO Profile and alpha-3 in Profile Service |
imageUrl | /v1/avatar.avatarUrl |
Supported custom fields on profile.custom:
| Custom field | Meaning |
|---|---|
favoritePlayerId | Selected favorite player id; normalized to numeric string, 1-16 digits. |
onboardingFinished | Whether the user completed onboarding. |
tpAccessGranted | Whether the user granted Ticketportal data access in the SSO Profile flow. |
Notes/limitations:
mailingAllowedmay be read from profile custom data for compatibility, but marketing email preference writes go to CRM, not Profile Service.secondaryPhoneandjerseyNumberare exposed in some app contracts but are not persisted through the current Profile Service mapper.- Avatar failures during profile reads/updates are logged and usually degrade to
imageUrl: nullor previous avatar fallback rather than failing the whole profile read.
3. CRM API
Code: src/features/workspace/*/*crm-client.server.ts, src/features/workspace/ticketing/*
Base URL: CRM_API_URL (FRM_API_URL legacy alias)
Auth: Authorization: Bearer <user Keycloak token>
CRM currently backs these SSO Profile areas:
- communications list/detail
- communication preferences / marketing consent
- loyalty summary/register/leave/history
- ticketing history/detail/purchase history
- ticketing-derived activity feed items
CRM endpoint inventory
| Method/path | SSO Profile use | Notes |
|---|---|---|
GET /api/Person | Current CRM identity and communication preferences | Expected fields include Id, Email, MailingAllowed. Id is used for activity/loyalty identity. |
PATCH /api/Person | Update marketing consent | CRM expects a full PersonModel-shaped payload; app first reads current Person and patches only MailingAllowed. Empty successful PATCH is accepted, then app refetches. |
GET /api/Customer/0/Communications | Communications list | Filters out State = unsubscribed; maps email/SMS/push channels. |
GET /api/PushNotification/CampaignPreview/{id} | Push communication detail | Used for body HTML, image, external URL. |
GET /api/Transaction/CustomerActivity/0 | Activity feed ticketing rows | App derives purchase, attendance, donation, cancellation/return events. Short in-memory per-session cache. |
GET /api/Transaction/Customer/0 | Ticketing history source | App filters historical rows by cancellation/donation/past ShowTime. Short in-memory per-session cache. |
GET /api/Transaction/CustomerOrders | Purchase history | Response may contain transactions under Transactions or t. |
GET /api/Transaction/{transactionId}/TicketId/{ticketId} | Ticket or season-ticket detail | If 400/404, app falls back to transaction tickets list. |
GET /api/Transaction/{transactionId}/Tickets | Detail fallback and activity/detail resolution | Returns ticket rows for one transaction. 400/404 can become empty/null. |
GET /api/Transaction/{transactionId}/BasicInfo | Detail fallback enrichment | Best-effort; failure returns null and detail still maps from ticket row. |
GET /api/SeasonTicket/CRM/Products?seasonTicketNum=... | Season-ticket attendance list by season-ticket number | 404 returns null. |
GET /api/LoyaltySystem/MemberDetail | Loyalty summary | null CRM response means unknown/not registered. |
POST /api/LoyaltySystem/MemberRegister?activate=true | Register/activate loyalty | App refetches summary up to 3 times to confirm active state. |
POST /api/LoyaltySystem/MemberRegister?activate=false | Leave/deactivate loyalty | App refetches summary up to 3 times to confirm inactive state. |
GET /api/LoyaltySystem/MemberHistory?programId=...&personId=... | Loyalty points history | Requires WORKSPACE_LOYALTY_PROGRAM_ID; personId comes from GET /api/Person.Id. |
CRM communication data mapping
GET /api/Customer/0/Communications accepts flexible CRM field names and maps them to a normalized communication item:
| Normalized field | CRM source fields |
|---|---|
id | Id |
channel | Type or Channel; values containing push -> push, sms -> sms, otherwise email |
sentAt | SentAt, Date, Created, CreatedAt |
status | State === read -> read, otherwise sent |
campaignName | Name, InternalName |
title | Title, Subject, or channel fallback |
summary | PreviewText, Summary, Subject, Title |
body | Body or Message, split into paragraphs |
link | first valid HTTP URL from UrlExternal, Url, ExternalUrl |
GET /api/PushNotification/CampaignPreview/{id} maps CampaignName, Title, Body, image fields (ImageId, ImageUrl, Img, Image), and external URL fields.
CRM communication preferences mapping
GET /api/Person:
{
Email: string;
MailingAllowed: boolean;
}maps to:
{
systemEmailsEnabled: true;
marketingEmailsEnabled: MailingAllowed;
}systemEmailsEnabled is currently always true; only marketing consent is writable.
CRM ticketing/activity data mapping
The CRM ticketing rows are normalized from CRM fields such as:
- buyer/owner:
BuyerId,BuyerMail,BuyerName,OwnerId,OwnerMail,OwnerName - transaction/order:
TransactionId,TranItemNr,OriginalTrNo,OrderNumb,SaleChannel,TransactionDate,TranDate - ticket/product:
TicketId,TicketNumber,seasonTicketNumber,Product,ProductName,IsSeasonTicket,IsPackageTicket - event/location:
ShowTime,Venue,VenueAddress,VenueCity,Sector,Row,Seat - price/discount:
Price,Discount,DiscountDescription - activity state:
FirstEntranceCheck,IsCancelled,CancelledDate,DonatedDate,DonatedFrom,DonatedFromID
The app derives activity kinds from those rows:
| Activity kind | CRM condition |
|---|---|
purchase | TransactionDate exists and BuyerId is current user id |
attendance | FirstEntranceCheck exists and OwnerId is current user id |
donation | DonatedDate exists; direction resolved from DonatedFromID or buyer/owner fallback |
ticket_cancellation | IsCancelled === true for normal ticket rows |
season_ticket_return | IsCancelled === true for season-ticket rows |
Ticketing detail fallback behavior:
- Try
GET /api/Transaction/{transactionId}/TicketId/{ticketId}. - If CRM returns
400or404, callGET /api/Transaction/{transactionId}/Tickets. - Optionally enrich with
GET /api/Transaction/{transactionId}/BasicInfo. - Return
nullif the row is not the expected ticket type.
CRM loyalty mapping
Summary accepts several CRM naming variants:
| Normalized field | CRM source fields |
|---|---|
isKnown | ExistsInLoyaltyProgram, IsRegistered, Registered, IsMember, or a member code |
isActive / isRegistered | IsActive, IsRegistered, Registered, IsMember |
memberCode | MemberCode, Code, UniqueCode |
qrCodeValue | QRCodeValue, QRCode, QrCode, or member code fallback |
points | CurrentProgram.CurrentPoints, CurrentProgram.PointBalance, CurrentProgram.Points, CurrentPoints, PointBalance, Points |
program | Program, CurrentProgram, ProgramId, ProgramName |
level | Level, CurrentProgram.LevelId, CurrentProgram.LevelName, LevelId, LevelName |
Loyalty history accepts flexible date/title/description/points fields and sorts newest first.
4. Ticketportal fan server
Code: src/features/workspace/ticketing/ticketing-tp-fan-server-client.server.ts, src/features/workspace/favorite-player/favorite-player-tp-client.server.ts
Base URL: TP_FAN_SERVER_API_URL
Ticketing auth: Authorization: Bearer <user Keycloak token>
Favorite-player catalog auth: no bearer header in current implementation.
Supported workspace instances:
slaviaonly. Other instances return an empty/unavailable ticketing state.
Endpoint inventory:
| Method/path | Use | Notes |
|---|---|---|
GET /ticketing/myTickets | Active tickets snapshot | 404 for fresh users maps to empty list. |
GET /ticketing/mySeasonTickets | Active season tickets snapshot | Season tickets are later enriched with attendance. |
GET /ticketing/seasonTicketAttendance?id=... | Active season ticket attendance | Non-auth failures are logged and become attendance: null; 401/403 are propagated. |
GET /enums/seasons/current | Resolve main current season for favorite-player options | Requires an isMain season. |
GET /player/list?teamId=...&seasonId=... | Favorite-player options | Maps player id/name/surname/position/number/photo. |
Ticketing snapshot behavior:
- Active ticket/season-ticket snapshot is read from Ticketportal fan server.
- CRM is used separately for history/detail/purchase history and for resolving detail refs.
- Snapshot reads use short in-memory per-session cache: 15s success TTL, 5s failure TTL, max 500 entries.
5. Fanshop order API
Code: src/features/order-api/*, src/features/workspace/fanshop/*, src/features/workspace/activity/activity-fanshop.server.ts
Base URL: ORDER_API_URL / FANSHOP_API_URL, default https://fanshop.slavia.cz/
Auth: global API bearer token from ORDER_API_BEARER_TOKEN / FANSHOP_API_BEARER_TOKEN / FANSHOP_TOKEN
User selection: normalized current user email.
Endpoint inventory:
| Method/path | Use | Notes |
|---|---|---|
GET /api/order/getUser?email=... | Order API user lookup | Implemented in service, not central to current workspace screens. |
GET /api/order/list?customer_id=<email>&page=...&limit=... | Fanshop order list and activity enrichment | Activity enrichment reads first page with limit 50. |
GET /api/order/detail?id=... | Fanshop order detail | App verifies returned order customer email matches current user email; mismatch becomes not found. |
Fanshop failures in the combined activity feed are best-effort: CRM activity still returns if fanshop enrichment fails.
End-to-end flows
Login/session bootstrap
- Browser starts
/auth/login. - App creates Keycloak PKCE auth flow and redirects to Keycloak.
- Keycloak redirects to
/auth/callbackwith authorization code. - App exchanges code for token set and loads Keycloak userinfo.
- App creates sealed server-side session record and stores it in Redis/memory.
- Later workspace requests resolve the session cookie and access/refresh Keycloak tokens as needed.
Mobile clients skip the browser session and send a Keycloak bearer token directly to /api/app/v1/*.
Profile load/update
Read:
- Resolve user Keycloak token.
GET Profile Service /v1/profile.- If profile missing, return a fallback identity using session/user email.
- Best-effort
GET /v1/avatar. - Map Profile Service fields/custom flags to SSO Profile contract.
Update:
- Resolve user Keycloak token.
- Optionally read current profile/avatar for custom-field preservation and fallback.
POST /v1/profilewith mapped profile fields and supported custom fields.- Best-effort reload avatar.
- Return normalized profile.
Marketing communication consent
Read:
- Resolve user Keycloak token.
GET CRM /api/Person.- Map
MailingAllowedtomarketingEmailsEnabled;systemEmailsEnabledremainstrue.
Update:
- Resolve user Keycloak token.
GET CRM /api/Personfor current full Person payload.PATCH CRM /api/Personwith the full current payload and changedMailingAllowed.- If PATCH returns empty success, refetch
GET /api/Person.
Activity feed
- In parallel:
- CRM: resolve current CRM person id through
GET /api/Person, then readGET /api/Transaction/CustomerActivity/0. - Fanshop: resolve current user email, then read
GET /api/order/listfirst page.
- CRM: resolve current CRM person id through
- CRM rows are expanded into ticketing activity events.
- Fanshop orders become
fanshop_order_createdevents. - Events are combined, sorted, and paginated.
- Fanshop failure is best-effort; CRM failure fails the activity feed.
Ticketing snapshot/history/detail
Active tickets/season tickets:
- Check instance support (
slaviaonly). - Resolve user Keycloak token.
- Read Ticketportal fan server
myTicketsand/ormySeasonTickets. - Enrich season tickets with Ticketportal
seasonTicketAttendancebest-effort. - Normalize, cache briefly, paginate/sort.
History/purchase/detail:
- Check instance support.
- Resolve user Keycloak token.
- Read CRM endpoints for history, purchase history, detail, and detail fallback.
- Normalize fields and return app contract.
Loyalty
Summary:
- Resolve user Keycloak token.
GET CRM /api/LoyaltySystem/MemberDetail.- Map flexible CRM shape into normalized loyalty summary.
Register/leave:
- Resolve user Keycloak token.
POST CRM /api/LoyaltySystem/MemberRegister?activate=true|false.- Refetch member detail up to 3 times with 150ms delay until
isActivematches expected state.
History:
- Resolve user Keycloak token.
GET CRM /api/Personfor currentId.GET CRM /api/LoyaltySystem/MemberHistory?programId=<WORKSPACE_LOYALTY_PROGRAM_ID>&personId=<Id>.- Normalize and sort newest first.
Favorite player
Options:
- Read Ticketportal current seasons.
- Select
isMainseason. - Read Ticketportal player list for season/team.
- Map id/name/surname/position/number/photo.
Selection:
- Stored in Profile Service custom field
favoritePlayerId. - Valid value is numeric string, 1-16 digits; null/empty means no selection.
Error handling and caching
Upstream request handling
- Default upstream timeout: 10s.
- JSON-like payloads are parsed where possible even if
content-typeis not exactly JSON. - Error messages are extracted from common upstream keys:
error,message,detail,details,title. - Schema validation failures are converted to upstream errors with the upstream status.
User-facing downstream behavior
- Internal/browser oRPC routes rethrow upstream errors through the workspace upstream error mapper.
- Mobile API routes return the documented app error envelope in Mobile/Bearer API.
- Ticketing snapshot failures can be returned as an error state instead of throwing, depending on call mode.
- Some enrichments are intentionally best-effort:
- profile avatar on profile read/update
- Ticketportal season-ticket attendance enrichment except auth failures
- Fanshop enrichment in activity feed
- CRM transaction basic-info fallback enrichment
Short in-memory caches
Several read-heavy per-session upstream results use short process-local caches:
| Data | Success TTL | Failure TTL | Max entries |
|---|---|---|---|
| CRM activity rows | 15s | 5s | 500 |
| CRM ticketing history source | 15s | 5s | 500 |
| Ticketportal active ticketing snapshot | 15s | 5s | 500 |
Caches are keyed by base URL/instance/session handle when a browser session is used. Explicit bearer-token calls bypass these per-session cache keys.
Source of truth summary
| Data area | Source of truth |
|---|---|
| Authentication identity | Keycloak |
| Editable profile fields | Profile Service |
| Avatar | Profile Service |
favoritePlayerId | Profile Service custom field |
onboardingFinished | Profile Service custom field |
tpAccessGranted | Profile Service custom field |
| Marketing email consent | CRM Person.MailingAllowed |
| System emails flag | Hardcoded true in SSO Profile contract |
| Communications | CRM |
| Active tickets/season tickets | Ticketportal fan server |
| Ticketing history/purchase/detail | CRM |
| Season-ticket attendance for active snapshot | Ticketportal fan server |
Season-ticket attendance endpoint (/ticketing/season-tickets/attendance) | CRM |
| Loyalty | CRM |
| Favorite-player options | Ticketportal fan server |
| Fanshop orders | Fanshop order API |