Files
myep/docs/bpn-connect-query-reference.md
T

899 lines
30 KiB
Markdown

# bpn-connect Query Reference
> **Scope: endpoint design and the XML→SQL migration plan for the `bpn-connect` Go proxy.**
> For schema semantics, conventions and confidence marks see
> [`docs/buspro-database-reference.md`](buspro-database-reference.md), which is authoritative
> and wins on any conflict. The table descriptions below are kept for endpoint context.
This document summarizes the BusProNet schema exports in
`docs/buspronet-schema` for future `bpn-connect` endpoint/query development.
Generated from:
- `docs/buspronet-schema/BusPronet_20260601-1517.csv`: table columns
- `docs/buspronet-schema/BusPronet_20260601-1518.csv`: foreign keys
Current limitation: the visible exports do not include primary-key or index
catalog data, even though those were expected. Identity flags are not a reliable
primary-key substitute in this database because many `ID...` columns are not
marked as identity. Add PK/index exports before finalizing performance-sensitive
queries.
## Naming Conventions
- Primary identifiers usually follow `ID<TableName>`, for example
`Buchung.IDBuchung`, `Adresse.IDAdresse`, `AdressePerson.IDAdressePerson`.
- Foreign key columns usually include `_FS`, for example
`BuchungTN.IDBuchung_FS`.
- Address data is split into:
- `Adresse`: address/customer account root.
- `AdressePerson`: person attached to an address.
- `AdresseAnschrift`: postal address.
- `AdresseKommunikation`: communication values such as email/phone.
- Booking data is split into:
- `Buchung`: booking header.
- `BuchungTN`: booking participants.
- `BuchungTNFerienziel`, `BuchungTNLeistung`: participant travel/service rows.
- Public booking number appears to be `Buchung.Vorgang`.
- Internal booking id is `Buchung.IDBuchung`.
- Many tables have `Status` fields. Based on observed data, `Status = 'L'`
appears to be a soft-delete marker and should be excluded from portal lookup
queries. Other status values still need table-specific business validation.
## Core Tables
### `Buchung`
Booking header. Important columns:
| Column | Type | Nullable | Notes |
| --- | --- | --- | --- |
| `IDBuchung` | `bigint` | no | Internal booking id. |
| `Vorgang` | `nvarchar(100)` | no | Public booking number/reference. |
| `IDReise_FS` | `bigint` | yes | Links to `Reise.IDReise`; no FK was present in the export. |
| `IDAdresse_FS_Anmelder` | `bigint` | yes | Applicant address id; no FK was present in the export. |
| `IDAdresseAnschrift_FS_Anmelder` | `bigint` | yes | Applicant postal address id; no FK was present in the export. |
| `IdAdressePerson_FS_Anmelder` | `bigint` | yes | Applicant person id; FK exists. |
| `IDAdresse_FS_Agentur` | `bigint` | no | Agency address; FK exists. |
| `IDAdresse_FS_Mandant` | `bigint` | no | Tenant/client address; FK exists. |
| `LoginWert1` | `nvarchar(100)` | no | Possible login-related value; semantics unknown. |
| `LoginWert2` | `nvarchar(100)` | no | Possible login-related value; semantics unknown. |
| `LoginWert3` | `nvarchar(100)` | no | Possible login-related value; semantics unknown. |
Known exported foreign keys:
- `Buchung.IdAdressePerson_FS_Anmelder -> AdressePerson.IDAdressePerson`
- `Buchung.IDAdresse_FS_Agentur -> Adresse.IDAdresse`
- `Buchung.IDAdresse_FS_ErsterSachb -> Adresse.IDAdresse`
- `Buchung.IDAdresse_FS_LetzterSachb -> Adresse.IDAdresse`
- `Buchung.IDAdresse_FS_Mandant -> Adresse.IDAdresse`
Notable missing FKs in the export:
- `Buchung.IDAdresse_FS_Anmelder`
- `Buchung.IDAdresseAnschrift_FS_Anmelder`
- `Buchung.IDReise_FS`
Treat those as likely relationships but verify against real data.
### `BuchungTN`
Booking participant.
| Column | Type | Nullable | Notes |
| --- | --- | --- | --- |
| `IDBuchungTN` | `bigint` | no | Participant row id. |
| `IDBuchung_FS` | `bigint` | no | Booking header id. |
| `IDAdressePerson_FS` | `bigint` | no | Participant person id. |
| `IDAdresseAnschrift_FS` | `bigint` | yes | Participant postal address id. |
| `Status` | `nvarchar(2)` | no | Participant status; values need business validation. |
| `BuchungsDatum` | `datetime` | no | Booking date. |
| `StornoStatusKV` | `nvarchar(2)` | yes | Cancellation-related status. |
| `StornoDatum` | `datetime` | yes | Cancellation date. |
| `Position` | `smallint` | no | Participant position within booking. |
Known exported foreign keys:
- `BuchungTN.IDBuchung_FS -> Buchung.IDBuchung`
- `BuchungTN.IDAdressePerson_FS -> AdressePerson.IDAdressePerson`
### `Adresse`
Customer/address root.
| Column | Type | Nullable | Notes |
| --- | --- | --- | --- |
| `IDAdresse` | `bigint` | no | Address/customer id needed by the local user record. |
| `SuchName` | `nvarchar(402)` | no | Search/display name. |
| `Status` | `nvarchar(2)` | no | Address status; active values need validation. |
| `Art` | `tinyint` | no | Address type; values need validation. |
| `IDAdresseAnschrift_FS_Versandanschrift` | `bigint` | no | Default shipping/postal address. |
| `IDAdressePerson_FS_ReferenzM` | `bigint` | no | Reference person. |
| `DatumLöschungDSGVO` | `datetime` | yes | GDPR deletion marker. Exclude rows where this is not null for portal access. |
### `AdressePerson`
Person attached to an address.
| Column | Type | Nullable | Notes |
| --- | --- | --- | --- |
| `IDAdressePerson` | `bigint` | no | Person id needed by the local user record. |
| `IDAdresse_FS` | `bigint` | no | Parent address id. |
| `Name` | `nvarchar(200)` | no | Last name. |
| `Vorname` | `nvarchar(200)` | yes | First name. |
| `Geschlecht` | `nvarchar(2)` | no | Gender/salutation code. |
| `Status` | `nvarchar(2)` | no | Person status; active values need validation. |
| `Passwort` | `nvarchar(200)` | no | Legacy BusProNet password field. Do not use from `bpn-connect`; password migration remains on the BusProNet XML API until the algorithm is known. |
| `Registrierung` | `bit` | no | Registration flag; semantics need validation. |
| `IDFremd` | `nvarchar(100)` | no | External id field. |
Known exported foreign key:
- `AdressePerson.IDAdresse_FS -> Adresse.IDAdresse`
### `AdresseKommunikation`
Communication values for an address/person.
| Column | Type | Nullable | Notes |
| --- | --- | --- | --- |
| `IDAdresseKommunikation` | `bigint` | no | Communication row id. |
| `IDAdresse_FS` | `bigint` | no | Address id. |
| `IDAdressePerson_FS` | `bigint` | no | Person id. No FK was present in the export, but the name strongly implies `AdressePerson.IDAdressePerson`. |
| `Wert` | `nvarchar(512)` | no | Communication value, likely email/phone depending on type. |
| `WertTelefonie` | `nvarchar(100)` | yes | Phone-specific normalized value. |
| `IDArtKommunikation_FS` | `bigint` | no | Communication type. |
| `Status` | `nvarchar(2)` | no | Communication status; active values need validation. |
| `Newsletter` | `bit` | no | Newsletter flag. |
| `DoubleOptIn` | `bit` | no | Double opt-in flag. |
Known exported foreign keys:
- `AdresseKommunikation.IDAdresse_FS -> Adresse.IDAdresse`
- `AdresseKommunikation.IDArtKommunikation_FS -> ArtKommunikation.IDArtKommunikation`
Important missing FK:
- `AdresseKommunikation.IDAdressePerson_FS` is not exported as a FK. Join it to
`AdressePerson.IDAdressePerson` only after validating with real data.
### `ArtKommunikation`
Communication type lookup.
| Column | Type | Nullable | Notes |
| --- | --- | --- | --- |
| `IDArtKommunikation` | `bigint` | no | Type id. |
| `Langtext` | `nvarchar(200)` | yes | Long label. |
| `Kurztext` | `nvarchar(40)` | yes | Short label. |
| `Code` | `nvarchar(20)` | yes | Type code. Find the email code/id here. |
| `Status` | `nvarchar(2)` | yes | Type status. |
Before relying on email lookup queries, run:
```sql
SELECT IDArtKommunikation, Langtext, Kurztext, Code, Status
FROM dbo.ArtKommunikation
ORDER BY IDArtKommunikation;
```
Then hard-code or configure the email type ids/codes in `bpn-connect`.
### `AdresseAnschrift`
Postal address.
| Column | Type | Nullable | Notes |
| --- | --- | --- | --- |
| `IDAdresseAnschrift` | `bigint` | no | Postal address id. |
| `IDAdresse_FS` | `bigint` | no | Address id. |
| `IDAdressePerson_FS` | `bigint` | yes | Person id. |
| `Anrede` | `nvarchar(100)` | yes | Salutation. |
| `Name` | `nvarchar(400)` | yes | Postal name. |
| `Strasse` | `nvarchar(200)` | no | Street. |
| `PLZ` | `nvarchar(20)` | no | Postal code. |
| `Ort` | `nvarchar(200)` | no | City. |
| `Status` | `nvarchar(2)` | no | Postal address status. |
No direct FK from `AdresseAnschrift` to `Adresse` or `AdressePerson` was present
in the export subset shown by the current FK file. Treat these as likely
relationships and validate them with data.
### `Reise` and `Produkt`
Travel/product metadata.
`Reise` important columns:
- `IDReise`
- `IDProdukt_FS`
- `Bezeichnung`
- `Code`
- `TerminVon`
- `TerminBis`
- `Status`
- `StornoDatum`
`Produkt` important columns:
- `IDProdukt`
- `Bezeichnung`
- `Code`
- `Suchfeld`
- `Status`
Known exported foreign key:
- `Reise.IDProdukt_FS -> Produkt.IDProdukt`
## Relationship Map
Most relevant joins for portal access:
```text
Buchung.IDBuchung
-> BuchungTN.IDBuchung_FS
-> BuchungTN.IDAdressePerson_FS
-> AdressePerson.IDAdressePerson
-> AdressePerson.IDAdresse_FS
-> Adresse.IDAdresse
```
Applicant joins:
```text
Buchung.IdAdressePerson_FS_Anmelder
-> AdressePerson.IDAdressePerson
-> AdressePerson.IDAdresse_FS
-> Adresse.IDAdresse
```
Email joins to validate:
```text
AdresseKommunikation.IDAdressePerson_FS
-> AdressePerson.IDAdressePerson
AdresseKommunikation.IDAdresse_FS
-> Adresse.IDAdresse
AdresseKommunikation.IDArtKommunikation_FS
-> ArtKommunikation.IDArtKommunikation
```
## Required bpn-connect Endpoints
All endpoints are private and authenticated by API key:
```http
X-API-Key: {apiKey}
Accept: application/json
```
Keep responses narrow. `bpn-connect` should return only fields needed by the
Symfony portal and should never return BusProNet passwords, bank data, credit
card data, or broad customer records by default.
Authentication rule: Symfony owns portal passwords after a user has been
bootstrapped locally. The BusProNet legacy password algorithm is currently
unknown, so `bpn-connect` must not verify passwords from the database. Keep the
existing BusProNet XML password check only for first-time legacy migration. Once
the local Symfony password hash exists, all subsequent login/profile refresh
logic should use local authentication plus `bpn-connect` read endpoints.
### `GET /api/bookings/applicant-lookup`
Purpose: resolve an applicant by email and public booking number. This is the
core endpoint for local user creation after a booking and for the portal access
request flow.
Request:
```http
GET /api/bookings/applicant-lookup?email={email}&bookingNumber={vorgang}
```
Response `200`:
```json
{
"bookingNumber": "111386",
"bookingId": 123456,
"addressId": 98765,
"personId": 54321,
"email": "[email protected]",
"firstName": "Example",
"lastName": "Customer"
}
```
Response rules:
- Return `200` when `email` belongs to `Buchung.IdAdressePerson_FS_Anmelder`
for the given `Buchung.Vorgang`.
- Return `404` when there is no matching non-deleted applicant.
- Return `409` if the query returns more than one applicant row after
deduplication. This should be rare and indicates inconsistent BusProNet data.
- Normalize input email by trimming and lowercasing before querying.
SQL:
```sql
DECLARE @Email nvarchar(512) = LOWER(LTRIM(RTRIM(@email)));
DECLARE @Vorgang nvarchar(100) = LTRIM(RTRIM(@bookingNumber));
SELECT TOP (2)
b.Vorgang AS bookingNumber,
b.IDBuchung AS bookingId,
a.IDAdresse AS addressId,
ap.IDAdressePerson AS personId,
@Email AS email,
ap.Vorname AS firstName,
ap.Name AS lastName
FROM dbo.Buchung b
INNER JOIN dbo.AdressePerson ap
ON ap.IDAdressePerson = b.IdAdressePerson_FS_Anmelder
INNER JOIN dbo.Adresse a
ON a.IDAdresse = ap.IDAdresse_FS
WHERE b.Vorgang = @Vorgang
AND a.DatumLöschungDSGVO IS NULL
AND a.Status <> 'L'
AND ap.Status <> 'L'
AND EXISTS (
SELECT 1
FROM dbo.AdresseKommunikation ak
WHERE ak.IDAdresse_FS = ap.IDAdresse_FS
AND ak.IDAdressePerson_FS = ap.IDAdressePerson
AND LOWER(LTRIM(RTRIM(ak.Wert))) = @Email
AND ak.Status <> 'L'
-- TODO: restrict to validated email communication type.
-- AND ak.IDArtKommunikation_FS IN (...)
)
ORDER BY ap.IDAdressePerson;
```
### `GET /api/customers/{addressId}/persons/{personId}`
Purpose: fetch the personal data for a known BusProNet person. Use this after a
local user exists and already stores `addressId` and `personId`. Do not use email
as the identifier because multiple `Adresse` rows can share an email address.
Request:
```http
GET /api/customers/{addressId}/persons/{personId}
```
Response `200`:
```json
{
"addressId": 98765,
"personId": 54321,
"firstName": "Example",
"lastName": "Customer",
"email": "[email protected]",
"genderCode": "F",
"title": null,
"birthDate": "1980-01-01T00:00:00Z",
"nationality": "deutsch",
"nationalityCountryName": "Deutschland",
"street": "Example Street 1",
"postalCode": "12345",
"city": "Example City",
"addressCountryName": "Deutschland"
}
```
Response rules:
- Return `200` for a non-deleted address/person pair.
- Return `404` if the address/person pair does not exist or has `Status = 'L'`.
- Return only portal-editable or portal-visible fields.
SQL:
```sql
DECLARE @AddressId bigint = @addressId;
DECLARE @PersonId bigint = @personId;
SELECT
a.IDAdresse AS addressId,
ap.IDAdressePerson AS personId,
ap.Name AS lastName,
ap.Vorname AS firstName,
ap.Geschlecht AS genderCode,
ap.Titel AS title,
ap.Geburtsdatum AS birthDate,
ap.Briefanrede AS letterSalutation,
ap.Anrede AS salutation,
ap.Newsletter AS newsletter,
email.Wert AS email,
manifest.IDArtNationalitaet_FS AS nationalityId,
person_nat.Langtext AS nationalityCountryName,
person_nat.Kurztext AS nationality,
person_nat.Code AS nationalityCode,
aa.IDAdresseAnschrift AS postalAddressId,
aa.Anrede AS postalSalutation,
aa.Name AS postalName,
aa.Strasse AS street,
aa.PLZ AS postalCode,
aa.Ort AS city,
aa.Ortsteil AS district,
aa.IDArtNationalitaet_FS_Land AS addressCountryId,
address_country.Langtext AS addressCountryName,
address_country.Kurztext AS addressCountryNationality,
address_country.Code AS addressCountryCode,
ad.TelefonOK AS phoneAllowed,
ad.MailOK AS mailAllowed,
ad.PostOK AS postAllowed
FROM dbo.AdressePerson ap
INNER JOIN dbo.Adresse a
ON a.IDAdresse = ap.IDAdresse_FS
OUTER APPLY (
SELECT TOP (1)
ak.Wert
FROM dbo.AdresseKommunikation ak
WHERE ak.IDAdresse_FS = ap.IDAdresse_FS
AND ak.IDAdressePerson_FS = ap.IDAdressePerson
AND ak.Status <> 'L'
-- TODO: restrict to validated email communication type.
-- AND ak.IDArtKommunikation_FS IN (...)
ORDER BY ak.IDAdresseKommunikation DESC
) email
LEFT JOIN dbo.AdresseAnschrift aa
ON aa.IDAdresseAnschrift = ap.IDAdresseAnschrift_FS_Versandanschrift
AND aa.Status <> 'L'
LEFT JOIN dbo.ArtNationalitaet address_country
ON address_country.IDArtNationalitaet = aa.IDArtNationalitaet_FS_Land
AND COALESCE(address_country.Status, '') <> 'L'
OUTER APPLY (
SELECT TOP (1)
m.IDArtNationalitaet_FS
FROM dbo.AdressePersonManifest m
WHERE m.IDAdressePerson_FS = ap.IDAdressePerson
AND m.IDAdresse_FS = ap.IDAdresse_FS
AND COALESCE(m.Status, '') <> 'L'
ORDER BY m.IDAdressePersonManifest DESC
) manifest
LEFT JOIN dbo.ArtNationalitaet person_nat
ON person_nat.IDArtNationalitaet = manifest.IDArtNationalitaet_FS
AND COALESCE(person_nat.Status, '') <> 'L'
LEFT JOIN dbo.AdresseDatenschutz ad
ON ad.IdAdresse_FS = a.IDAdresse
WHERE a.IDAdresse = @AddressId
AND ap.IDAdressePerson = @PersonId
AND a.DatumLöschungDSGVO IS NULL
AND a.Status <> 'L'
AND ap.Status <> 'L';
```
### Optional Debug Endpoint: `GET /api/customers/by-email`
Purpose: manual investigation only. Email is not unique across `Adresse`, so this
endpoint should return a list and should not be used as a local user identifier.
Request:
```http
GET /api/customers/by-email?email={email}
```
Use the same joins as `GET /api/customers/{addressId}/persons/{personId}`, but
start from `AdresseKommunikation` and return all non-deleted matching persons.
Do not use this for account creation unless the user also provides a booking
number or another disambiguating key.
## Symfony Login And Bootstrap Replacement Plan
Current Symfony login behavior:
- Existing local users authenticate against the local Symfony password hash.
- Existing legacy local users may still have an encrypted legacy password in the
local `User.password` column. Symfony validates it locally once, then replaces
it with a Symfony password hash.
- If no local user exists, `BpnAuthenticator::bootstrapUser()` currently calls
the BusProNet XML API with email and password:
- `getPersonalDataWithPassword(email, password)`
- `getCrmAttributesWithPassword(email, password)`
- After any successful local login, `BpnAuthenticator::refreshBpnSnapshot()`
calls the BusProNet XML API by local `addressId` and `personId`:
- `getPersonalData(email, addressId, personId)`
- `getCrmAttributes(email, addressId, personId)`
Replacement rule:
- Keep login password validation local to Symfony.
- Keep the current XML password-based bootstrap for users that exist in
BusProNet but do not yet have a local Symfony user.
- On successful XML legacy verification, Symfony creates a local user with a
Symfony password hash from the submitted password.
- Future logins use only the local hash and must not call XML for password
verification.
- Replace post-login snapshot refresh with `bpn-connect` id-based read endpoints.
### Login Endpoint Usage
No `bpn-connect` endpoint is needed to authenticate a password.
Existing local user login should do:
1. Find local `User` by normalized email.
2. Validate local Symfony password hash.
3. If the local row still has a legacy encrypted local password, migrate it to a
Symfony hash without contacting BusProNet.
4. Call `GET /api/customers/{addressId}/persons/{personId}` to refresh profile
completeness.
5. Call `GET /api/customers/{addressId}/crm-attributes` to refresh roles and
hotel codes.
Missing local user login should do:
1. Call the existing BusProNet XML password-based bootstrap.
2. If valid, create a local `User` with a Symfony hash of the submitted password.
3. Store returned `addressId`, `personId`, `roles`, `hotelCodes`, and
`profileComplete`.
4. Persist and continue login.
5. If invalid, fail with the generic invalid-login message.
Do not add a `bpn-connect` password verification endpoint until the BusProNet
password algorithm is known and validated against real examples. The observed
database values do not currently match simple MD5/SHA/Base64 derivations.
### `GET /api/customers/{addressId}/crm-attributes`
Purpose: replace XML `SelektionCRM` reads used during login refresh and by the
OAuth CRM attributes endpoint.
Request:
```http
GET /api/customers/{addressId}/crm-attributes
```
Minimal response:
```json
{
"roles": ["ROLE_CUSTOMER"],
"hotelCodes": []
}
```
Detailed response may also include selection groups and CRM actions if the
current `/api/crm-attributes` response must remain compatible.
Known role mapping from the XML parser:
- `Selektionsstamm.IDSelektionsstamm = 1292` selected: `ROLE_ADMIN` and
`ROLE_HOUSE_MANAGER`
- `Selektionsstamm.IDSelektionsstamm = 1293` selected: `ROLE_MANAGER`
- `Selektionsstamm.IDSelektionsstamm = 1070` selected: `ROLE_TEAMER`
- selected `Selektionsstamm.Selektion` matching `Hausleitung {CODE}`:
`ROLE_HOUSE_MANAGER` and hotel code `{CODE}`
- admin users also receive default hotel code `SSL`
- no matched role means `ROLE_CUSTOMER`
Candidate SQL for selected customer selections:
```sql
DECLARE @AddressId bigint = @addressId;
SELECT
sg.IDSelektionsgruppenstamm AS groupId,
sg.Selektionsgruppe AS groupLabel,
s.IDSelektionsstamm AS selectionId,
s.Selektion AS selectionLabel,
CAST(1 AS bit) AS selected
FROM dbo.Selektionen sel
INNER JOIN dbo.Selektionsstamm s
ON s.IDSelektionsstamm = sel.IDSelektionsstamm_FS
LEFT JOIN dbo.Selektionsgruppe sg_map
ON sg_map.IDSelektionsstamm_FS = s.IDSelektionsstamm
LEFT JOIN dbo.Selektionsgruppenstamm sg
ON sg.IDSelektionsgruppenstamm = sg_map.IDSelektionsgruppenstamm_FS
WHERE sel.IDAdresse_FS = @AddressId
AND COALESCE(s.Status, '') <> 'L'
AND COALESCE(sg.Status, '') <> 'L'
ORDER BY sg.Position, sg.Selektionsgruppe, sg_map.Position, s.Selektion;
```
The Go service can derive `roles` and `hotelCodes` from this result with the
mapping above. Before shipping, verify whether XML `SelektionCRM` includes
unselected mutable options; if the portal only needs roles/hotel codes, selected
rows are enough.
Candidate SQL for CRM actions if compatibility is required:
```sql
DECLARE @AddressId bigint = @addressId;
SELECT
ca.IDCRMAktion AS id,
ca.Code AS code,
ca.Bezeichnung AS label,
ca.Aktiv AS selected
FROM dbo.CRMZuweisung cz
INNER JOIN dbo.CRMAktion ca
ON ca.IDCRMAktion = cz.IDCRMAktion_FS
WHERE cz.IDAdresse_FS = @AddressId
AND ca.Status <> 'L'
ORDER BY ca.Bezeichnung;
```
This needs data validation against the XML response before compatibility is
guaranteed.
## XML Calls To Replace For Local User Processes
| Current Symfony call | Current XML purpose | Replacement |
| --- | --- | --- |
| `getPersonalData(email, addressId, personId)` | Load profile by known user ids. | `GET /api/customers/{addressId}/persons/{personId}` |
| `updatePersonalData(email, personalData)` | Persist profile edits. | Future `PATCH /api/customers/{addressId}/persons/{personId}` with transaction-safe updates. |
| `getCrmAttributes(email, addressId, personId)` | Refresh roles/hotel codes and expose CRM attributes. | `GET /api/customers/{addressId}/crm-attributes` |
| `getPersonalDataWithPassword(email, password)` | Missing-local-user bootstrap by BPN password. | Keep XML until the local user has been created/migrated. |
| `getCrmAttributesWithPassword(email, password)` | Roles for missing-local-user bootstrap by BPN password. | Keep XML until the local user has been created/migrated. |
| `register(registrationData)` | Create BusProNet address/person and local user. | Future `POST /api/customers` if direct DB writes are allowed; otherwise keep XML until write rules are known. |
| `createAddress(personalData)` | Create BusProNet address/person without mail. | Future `POST /api/customers` if direct DB writes are allowed; otherwise keep XML until write rules are known. |
Recommended migration sequence:
1. Keep XML for legacy password bootstrap only.
2. Replace booking applicant lookup for guest provisioning and portal access.
3. Replace post-login profile/CRM refresh reads.
4. Replace personal-data page reads.
5. Replace OAuth/userinfo and CRM API reads.
6. Replace booking form prefill reads.
7. Defer personal-data writes until update rules are validated.
8. Defer registration/create-address until the insert graph is known.
### Phase 1: Keep XML For Legacy Password Bootstrap
Keep these XML calls temporarily:
- `getPersonalDataWithPassword(email, password)`
- `getCrmAttributesWithPassword(email, password)`
Scope:
- Only used when no local Symfony user exists yet.
- On success, create the local user with a Symfony password hash from the
submitted password.
- Store `addressId`, `personId`, `roles`, `hotelCodes`, and `profileComplete`.
- Future logins for that user use only the local Symfony password hash.
Do not implement a `bpn-connect` password verification endpoint until the
BusProNet password algorithm is known and verified.
### Phase 2: Booking Applicant Lookup
Replace XML/fragile booking lookup plumbing with:
```http
GET /api/bookings/applicant-lookup?email={email}&bookingNumber={vorgang}
```
Symfony integration points:
- `BookingGuestAccountManager::provisionGuestAccountForBooking()`
- `BookingGuestAccountManager::requestPortalAccess()`
- booking-created welcome account provisioning
This endpoint returns only the fields needed for local user creation:
- `bookingNumber`
- `bookingId`
- `addressId`
- `personId`
- `email`
- `firstName`
- `lastName`
### Phase 3: Post-login Snapshot Refresh
Replace XML reads in `BpnAuthenticator::refreshBpnSnapshot()` with:
```http
GET /api/customers/{addressId}/persons/{personId}
GET /api/customers/{addressId}/crm-attributes
```
Symfony keeps local password validation. `bpn-connect` only refreshes ids,
profile completeness, roles, and hotel codes after the password has already been
validated locally.
### Phase 4: Personal-data Page Reads
Replace XML read in `PersonalDataController::loadPersonalData()` with:
```http
GET /api/customers/{addressId}/persons/{personId}
```
Keep `updatePersonalData()` on XML for now. Reads are low-risk; writes require
validated BusProNet update semantics.
### Phase 5: OAuth/Userinfo And CRM Reads
Replace XML reads in:
- `UserinfoController`
- `CrmAttributeController`
with:
```http
GET /api/customers/{addressId}/persons/{personId}
GET /api/customers/{addressId}/crm-attributes
```
### Phase 6: Booking Form Prefill Reads
Replace XML read in `ParticipantDataPrefiller::prefillApplicantFromUser()` with:
```http
GET /api/customers/{addressId}/persons/{personId}
```
### Phase 7: Defer Personal-data Writes
Keep XML call:
- `updatePersonalData(email, personalData)`
Future replacement:
```http
PATCH /api/customers/{addressId}/persons/{personId}
```
Do this only after write rules are validated for:
- `AdressePerson`
- `AdresseAnschrift`
- `AdresseKommunikation`
- `AdressePersonManifest`
- `AdresseZusatzfeld` or other tables holding height, weight, shoe size, and
remarks if those are not in the core person/address tables.
### Phase 8: Defer Registration And Create-address
Keep XML calls:
- `register(registrationData)`
- `createAddress(personalData)`
Future replacement:
```http
POST /api/customers
```
This is higher risk than read replacement because it requires the full BusProNet
insert graph, required defaults, and side effects.
## Candidate SQL: Find Email Communication Type
Run once during `bpn-connect` development:
```sql
SELECT
IDArtKommunikation,
Langtext,
Kurztext,
Code,
Status
FROM dbo.ArtKommunikation
WHERE
LOWER(COALESCE(Langtext, '')) LIKE '%mail%'
OR LOWER(COALESCE(Kurztext, '')) LIKE '%mail%'
OR LOWER(COALESCE(Code, '')) LIKE '%mail%'
ORDER BY IDArtKommunikation;
```
If the data uses German labels, also inspect all rows manually. The schema alone
cannot prove the canonical email type.
## Candidate SQL: Validate Missing Relationships
Use these checks before shipping lookup logic:
```sql
-- Check whether AdresseKommunikation.IDAdressePerson_FS is a real person link.
SELECT TOP (20)
ak.IDAdresseKommunikation,
ak.IDAdresse_FS,
ak.IDAdressePerson_FS,
ap.IDAdressePerson,
ap.IDAdresse_FS AS PersonAddressId,
ak.Wert
FROM dbo.AdresseKommunikation ak
LEFT JOIN dbo.AdressePerson ap
ON ap.IDAdressePerson = ak.IDAdressePerson_FS
WHERE ak.IDAdressePerson_FS <> 0
AND ap.IDAdressePerson IS NULL;
```
```sql
-- Check whether Buchung.IDAdresse_FS_Anmelder matches the applicant person's address.
SELECT TOP (100)
b.IDBuchung,
b.Vorgang,
b.IDAdresse_FS_Anmelder,
b.IdAdressePerson_FS_Anmelder,
ap.IDAdresse_FS AS ApplicantPersonAddressId
FROM dbo.Buchung b
LEFT JOIN dbo.AdressePerson ap
ON ap.IDAdressePerson = b.IdAdressePerson_FS_Anmelder
WHERE b.IDAdresse_FS_Anmelder IS NOT NULL
AND ap.IDAdresse_FS <> b.IDAdresse_FS_Anmelder;
```
```sql
-- Inspect status values before adding active-only predicates.
SELECT 'Adresse' AS table_name, Status, COUNT(*) AS rows
FROM dbo.Adresse
GROUP BY Status
UNION ALL
SELECT 'AdressePerson', Status, COUNT(*)
FROM dbo.AdressePerson
GROUP BY Status
UNION ALL
SELECT 'AdresseKommunikation', Status, COUNT(*)
FROM dbo.AdresseKommunikation
GROUP BY Status
UNION ALL
SELECT 'BuchungTN', Status, COUNT(*)
FROM dbo.BuchungTN
GROUP BY Status
ORDER BY table_name, Status;
```
## Indexes To Confirm Or Add
The current CSV files do not include index definitions. For the lookup endpoint,
confirm whether these access paths already exist:
- `Buchung(Vorgang)` including `IDBuchung`, `IdAdressePerson_FS_Anmelder`
- `BuchungTN(IDBuchung_FS)` including `IDAdressePerson_FS`, `Status`
- `AdressePerson(IDAdressePerson)` including `IDAdresse_FS`, `Vorname`, `Name`,
`Status`
- `AdresseKommunikation(IDAdressePerson_FS, Wert)` including `IDAdresse_FS`,
`IDArtKommunikation_FS`, `Status`
- `AdresseKommunikation(IDAdresse_FS, Wert)` including `IDAdressePerson_FS`,
`IDArtKommunikation_FS`, `Status`
If the database collation is case-sensitive and email lookup must be
case-insensitive, avoid `LOWER(Wert)` on large tables unless a computed
normalized-email column or suitable index exists.
## Future Endpoint Candidates
The XML plumbing can be replaced incrementally by small read endpoints backed by
targeted SQL:
| Endpoint | Purpose | Core tables |
| --- | --- | --- |
| `GET /api/bookings/lookup` | Resolve email + public booking number to booking/person/address ids. | `Buchung`, `BuchungTN`, `AdressePerson`, `AdresseKommunikation`, `Adresse` |
| `GET /api/bookings/{bookingId}` | Fetch booking header/detail by internal id. | `Buchung`, `Reise`, `Produkt`, `BuchungTN` |
| `GET /api/bookings/{bookingId}/participants` | Fetch participants for a booking. | `BuchungTN`, `AdressePerson`, `AdresseAnschrift` |
| `GET /api/customers/{addressId}/persons/{personId}` | Fetch a minimal customer/person profile. | `Adresse`, `AdressePerson`, `AdresseKommunikation`, `AdresseAnschrift` |
Keep endpoint responses narrow. Return only ids and fields needed by the Symfony
portal so the Go service does not become a broad BusProNet data mirror.
## Regeneration Notes
When new catalog exports are added, regenerate this document with:
1. Columns export from `sys.tables`, `sys.schemas`, `sys.columns`, `sys.types`.
2. Foreign key export from `sys.foreign_keys` and related FK columns.
3. Primary-key export from `sys.key_constraints`.
4. Index export from `sys.indexes`, `sys.index_columns`, and `sys.columns`.
Primary keys and indexes should be added to the table sections and the lookup
query should be revisited once the real access paths are known.