Internal Dev Docs

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:

Executive summary

profil.slavia.cz is a backend-for-frontend (BFF) over several upstream systems:

  1. Keycloak SSO identifies the logged-in user and provides the access token used for most user-scoped upstream calls.
  2. Profile Service is the source of truth for editable profile data, profile avatar, and SSO Profile custom flags.
  3. CRM API provides communications, marketing consent, loyalty, ticketing history/detail, and ticketing-derived activity.
  4. Ticketportal fan server provides active tickets/season tickets and favorite-player catalog data.
  5. 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/* with Authorization: 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

UpstreamAuth used by SSO Profile app
KeycloakOAuth/OIDC client config (KEYCLOAK_*)
Profile ServiceAuthorization: Bearer <user Keycloak token>
CRM APIAuthorization: Bearer <user Keycloak token>
Ticketportal fan serverAuthorization: Bearer <user Keycloak token> for ticketing endpoints; public/no bearer for favorite-player catalog calls
Fanshop order APIGlobal bearer token from ORDER_API_BEARER_TOKEN / FANSHOP_TOKEN; user is selected by email query/filter

Required environment/config

VariablePurpose
WORKSPACE_AUTH_SESSION_SECRETRequired to seal/unseal server-side auth sessions. AUTH_SESSION_SECRET is a legacy alias.
WORKSPACE_AUTH_REDIS_URLRedis session storage. Required in production auth flow.
WORKSPACE_AUTH_REDIS_KEY_PREFIXOptional Redis key prefix.
KEYCLOAK_SSO_BASE_URLKeycloak issuer/realm URL. Legacy alias: SSO_BASE_URL. Default: https://id.slavia.cz/realms/plg.
KEYCLOAK_CLIENT_IDKeycloak client id. Legacy alias: SSO_CLIENT_ID. Default: slavia.
KEYCLOAK_SCOPEOAuth scopes. Legacy alias: SSO_SCOPE. Default: openid profile email.
KEYCLOAK_PUBLIC_URL / SERVER_URLPublic origin used to derive callback URLs when exact overrides are not set.
KEYCLOAK_REDIRECT_URIOptional exact login callback URI.
KEYCLOAK_POST_LOGOUT_REDIRECT_URIOptional exact post-logout redirect URI.
KEYCLOAK_ALLOWED_INSTANCESOptional comma-separated list of workspace instances from auth/session.
KEYCLOAK_DEFAULT_INSTANCEOptional default workspace instance.
PROFILE_SERVICE_URLRequired origin-only Profile Service URL, e.g. https://profile-service.example.com. No path/query/hash.
CRM_API_URLRequired CRM API base URL. FRM_API_URL remains a legacy alias.
WORKSPACE_LOYALTY_PROGRAM_IDRequired for CRM loyalty history.
TP_FAN_SERVER_API_URLTicketportal fan server base URL. Required in production for TP-backed features. Non-production fallback: https://stage-slavia.tpapp.cz/.
ORDER_API_BEARER_TOKENFanshop order API bearer token. Legacy aliases: FANSHOP_API_BEARER_TOKEN, FANSHOP_TOKEN.
ORDER_API_URLOptional 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
  • userinfo identity 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/pathUsed for
GET /v1/profileLoad current profile. 404 means no persisted profile yet.
POST /v1/profileUpsert profile and supported custom fields.
GET /v1/avatarLoad avatar URL. 404 means no avatar.
POST /v1/avatarUpload/set avatar from normalized base64 or URL input.
DELETE /v1/avatarDelete avatar. 404 is treated as already deleted.

Important field mapping:

SSO Profile fieldProfile Service field
idid
emailemail
firstName / lastNamefirstName / lastName
birthDatebirthdate
gender: "1"gender: "male"
gender: "2"gender: "female"
gender: "0" or emptygender: null / "other" read as "0"
primaryPhonefirst item in phone[]
addressaddress.street
cityaddress.city
zipaddress.zip
countryCodeconverted between ISO alpha-2 in SSO Profile and alpha-3 in Profile Service
imageUrl/v1/avatar.avatarUrl

Supported custom fields on profile.custom:

Custom fieldMeaning
favoritePlayerIdSelected favorite player id; normalized to numeric string, 1-16 digits.
onboardingFinishedWhether the user completed onboarding.
tpAccessGrantedWhether the user granted Ticketportal data access in the SSO Profile flow.

Notes/limitations:

  • mailingAllowed may be read from profile custom data for compatibility, but marketing email preference writes go to CRM, not Profile Service.
  • secondaryPhone and jerseyNumber are 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: null or 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/pathSSO Profile useNotes
GET /api/PersonCurrent CRM identity and communication preferencesExpected fields include Id, Email, MailingAllowed. Id is used for activity/loyalty identity.
PATCH /api/PersonUpdate marketing consentCRM 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/CommunicationsCommunications listFilters out State = unsubscribed; maps email/SMS/push channels.
GET /api/PushNotification/CampaignPreview/{id}Push communication detailUsed for body HTML, image, external URL.
GET /api/Transaction/CustomerActivity/0Activity feed ticketing rowsApp derives purchase, attendance, donation, cancellation/return events. Short in-memory per-session cache.
GET /api/Transaction/Customer/0Ticketing history sourceApp filters historical rows by cancellation/donation/past ShowTime. Short in-memory per-session cache.
GET /api/Transaction/CustomerOrdersPurchase historyResponse may contain transactions under Transactions or t.
GET /api/Transaction/{transactionId}/TicketId/{ticketId}Ticket or season-ticket detailIf 400/404, app falls back to transaction tickets list.
GET /api/Transaction/{transactionId}/TicketsDetail fallback and activity/detail resolutionReturns ticket rows for one transaction. 400/404 can become empty/null.
GET /api/Transaction/{transactionId}/BasicInfoDetail fallback enrichmentBest-effort; failure returns null and detail still maps from ticket row.
GET /api/SeasonTicket/CRM/Products?seasonTicketNum=...Season-ticket attendance list by season-ticket number404 returns null.
GET /api/LoyaltySystem/MemberDetailLoyalty summarynull CRM response means unknown/not registered.
POST /api/LoyaltySystem/MemberRegister?activate=trueRegister/activate loyaltyApp refetches summary up to 3 times to confirm active state.
POST /api/LoyaltySystem/MemberRegister?activate=falseLeave/deactivate loyaltyApp refetches summary up to 3 times to confirm inactive state.
GET /api/LoyaltySystem/MemberHistory?programId=...&personId=...Loyalty points historyRequires 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 fieldCRM source fields
idId
channelType or Channel; values containing push -> push, sms -> sms, otherwise email
sentAtSentAt, Date, Created, CreatedAt
statusState === read -> read, otherwise sent
campaignNameName, InternalName
titleTitle, Subject, or channel fallback
summaryPreviewText, Summary, Subject, Title
bodyBody or Message, split into paragraphs
linkfirst 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 kindCRM condition
purchaseTransactionDate exists and BuyerId is current user id
attendanceFirstEntranceCheck exists and OwnerId is current user id
donationDonatedDate exists; direction resolved from DonatedFromID or buyer/owner fallback
ticket_cancellationIsCancelled === true for normal ticket rows
season_ticket_returnIsCancelled === true for season-ticket rows

Ticketing detail fallback behavior:

  1. Try GET /api/Transaction/{transactionId}/TicketId/{ticketId}.
  2. If CRM returns 400 or 404, call GET /api/Transaction/{transactionId}/Tickets.
  3. Optionally enrich with GET /api/Transaction/{transactionId}/BasicInfo.
  4. Return null if the row is not the expected ticket type.

CRM loyalty mapping

Summary accepts several CRM naming variants:

Normalized fieldCRM source fields
isKnownExistsInLoyaltyProgram, IsRegistered, Registered, IsMember, or a member code
isActive / isRegisteredIsActive, IsRegistered, Registered, IsMember
memberCodeMemberCode, Code, UniqueCode
qrCodeValueQRCodeValue, QRCode, QrCode, or member code fallback
pointsCurrentProgram.CurrentPoints, CurrentProgram.PointBalance, CurrentProgram.Points, CurrentPoints, PointBalance, Points
programProgram, CurrentProgram, ProgramId, ProgramName
levelLevel, 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:

  • slavia only. Other instances return an empty/unavailable ticketing state.

Endpoint inventory:

Method/pathUseNotes
GET /ticketing/myTicketsActive tickets snapshot404 for fresh users maps to empty list.
GET /ticketing/mySeasonTicketsActive season tickets snapshotSeason tickets are later enriched with attendance.
GET /ticketing/seasonTicketAttendance?id=...Active season ticket attendanceNon-auth failures are logged and become attendance: null; 401/403 are propagated.
GET /enums/seasons/currentResolve main current season for favorite-player optionsRequires an isMain season.
GET /player/list?teamId=...&seasonId=...Favorite-player optionsMaps 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/pathUseNotes
GET /api/order/getUser?email=...Order API user lookupImplemented in service, not central to current workspace screens.
GET /api/order/list?customer_id=<email>&page=...&limit=...Fanshop order list and activity enrichmentActivity enrichment reads first page with limit 50.
GET /api/order/detail?id=...Fanshop order detailApp 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

  1. Browser starts /auth/login.
  2. App creates Keycloak PKCE auth flow and redirects to Keycloak.
  3. Keycloak redirects to /auth/callback with authorization code.
  4. App exchanges code for token set and loads Keycloak userinfo.
  5. App creates sealed server-side session record and stores it in Redis/memory.
  6. 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:

  1. Resolve user Keycloak token.
  2. GET Profile Service /v1/profile.
  3. If profile missing, return a fallback identity using session/user email.
  4. Best-effort GET /v1/avatar.
  5. Map Profile Service fields/custom flags to SSO Profile contract.

Update:

  1. Resolve user Keycloak token.
  2. Optionally read current profile/avatar for custom-field preservation and fallback.
  3. POST /v1/profile with mapped profile fields and supported custom fields.
  4. Best-effort reload avatar.
  5. Return normalized profile.

Read:

  1. Resolve user Keycloak token.
  2. GET CRM /api/Person.
  3. Map MailingAllowed to marketingEmailsEnabled; systemEmailsEnabled remains true.

Update:

  1. Resolve user Keycloak token.
  2. GET CRM /api/Person for current full Person payload.
  3. PATCH CRM /api/Person with the full current payload and changed MailingAllowed.
  4. If PATCH returns empty success, refetch GET /api/Person.

Activity feed

  1. In parallel:
    • CRM: resolve current CRM person id through GET /api/Person, then read GET /api/Transaction/CustomerActivity/0.
    • Fanshop: resolve current user email, then read GET /api/order/list first page.
  2. CRM rows are expanded into ticketing activity events.
  3. Fanshop orders become fanshop_order_created events.
  4. Events are combined, sorted, and paginated.
  5. Fanshop failure is best-effort; CRM failure fails the activity feed.

Ticketing snapshot/history/detail

Active tickets/season tickets:

  1. Check instance support (slavia only).
  2. Resolve user Keycloak token.
  3. Read Ticketportal fan server myTickets and/or mySeasonTickets.
  4. Enrich season tickets with Ticketportal seasonTicketAttendance best-effort.
  5. Normalize, cache briefly, paginate/sort.

History/purchase/detail:

  1. Check instance support.
  2. Resolve user Keycloak token.
  3. Read CRM endpoints for history, purchase history, detail, and detail fallback.
  4. Normalize fields and return app contract.

Loyalty

Summary:

  1. Resolve user Keycloak token.
  2. GET CRM /api/LoyaltySystem/MemberDetail.
  3. Map flexible CRM shape into normalized loyalty summary.

Register/leave:

  1. Resolve user Keycloak token.
  2. POST CRM /api/LoyaltySystem/MemberRegister?activate=true|false.
  3. Refetch member detail up to 3 times with 150ms delay until isActive matches expected state.

History:

  1. Resolve user Keycloak token.
  2. GET CRM /api/Person for current Id.
  3. GET CRM /api/LoyaltySystem/MemberHistory?programId=<WORKSPACE_LOYALTY_PROGRAM_ID>&personId=<Id>.
  4. Normalize and sort newest first.

Favorite player

Options:

  1. Read Ticketportal current seasons.
  2. Select isMain season.
  3. Read Ticketportal player list for season/team.
  4. 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-type is 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:

DataSuccess TTLFailure TTLMax entries
CRM activity rows15s5s500
CRM ticketing history source15s5s500
Ticketportal active ticketing snapshot15s5s500

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 areaSource of truth
Authentication identityKeycloak
Editable profile fieldsProfile Service
AvatarProfile Service
favoritePlayerIdProfile Service custom field
onboardingFinishedProfile Service custom field
tpAccessGrantedProfile Service custom field
Marketing email consentCRM Person.MailingAllowed
System emails flagHardcoded true in SSO Profile contract
CommunicationsCRM
Active tickets/season ticketsTicketportal fan server
Ticketing history/purchase/detailCRM
Season-ticket attendance for active snapshotTicketportal fan server
Season-ticket attendance endpoint (/ticketing/season-tickets/attendance)CRM
LoyaltyCRM
Favorite-player optionsTicketportal fan server
Fanshop ordersFanshop order API

On this page