The crms-middleware service
crms-middleware is a standalone Laravel 9 / PHP 8.1 service that sits between the backend and the
three Microsoft Dynamics 365 CRM orgs (Egypt, Oman, Montenegro). It is the only component in the
platform allowed to talk to a CRM. The backend never calls Dynamics directly — it calls this service over
HTTP at /api/portals/*, and the CRMs call back into it at /api/crms/*. The service owns per-CRM OAuth,
request shaping, and response mapping back to one unified snake_case contract; it holds no domain state of its
own (Passport’s OAuth tables aside).
Auth: two directions, two Passport scopes
Section titled “Auth: two directions, two Passport scopes”Every route is guarded by Laravel Passport client_credentials (the client:<scope> middleware, aliased
in app/Http/Kernel.php). There are two independent directions, each with its own scope and its own token
issuer, and the two must never share a client:
- Backend → middleware — the
/api/portals/*routes require scopeportals-api. The backend authenticates as theportals-backendclient. Tokens are minted byPortals\AuthController@login(POST portals/login,routes/api.php:22). - CRM → middleware (webhooks) — the
/api/crms/*routes require scopecrm-webhook. Each CRM authenticates with its own OAuth client. Tokens are minted byCrms\AuthController@login(POST crms/login,routes/api.php:263).
The two token-issuing login routes and the default GET /api/user (Sanctum scaffold) are the only routes
outside the two client scopes.
Writes go to Azure Logic Apps; reads go to Dynamics OData
Section titled “Writes go to Azure Logic Apps; reads go to Dynamics OData”Outbound traffic to the CRMs splits cleanly in two, and the split is visible in the two OAuth base classes
both descend from AbstractClient (app/Clients/AbstractClient.php:10 — HTTP verbs plus a Cache-keyed
bearer token):
- Writes → Azure Logic Apps. Every mutation (submit lead, create draft sale, reserve, attach, submit EOI,
broker signup, …) is a POST/PATCH to a per-operation Azure Logic App URL. These clients extend
LinkDevAuthenticatedClient(app/Clients/LinkDevAuthenticatedClient.php:7), which authenticates against a single shared Azure AD app (services.crms.link_dev_auth) using the v1-styleresource=token claim (LinkDevAuthenticatedClient.php:19-24), cached underlinkdev-crm-token. The target country is a field in the payload, not a separate URL — one Logic App fans out to the right org internally. - Reads → Dynamics Web API (OData). Every fetch (units, leads, meetings, currencies, installments, …)
hits the country’s Dynamics org directly via its OData endpoint. These clients extend
CrmClient(app/Clients/Crms/CrmClient.php:9), which authenticates per country from the request-scopedCrmCredentialsDTO using the v2-stylescope=token claim (CrmClient.php:25-33), cached under the country name. Each method builds<baseUrl>/api/data/v9.X/<entity>and issues OData$select/$filter/$expand/$orderby.
A third client, PortalClient (app/Clients/PortalClient.php:7), is not a CRM client at all — it is the
outbound path back to the backend (services.cms.*) that the inbound /api/crms/* webhook handlers use
to forward CRM push events (broker registered, unit upserted, sale updated, EOI inserted, lead/meeting/EOI
status notifications).
The request pipeline
Section titled “The request pipeline”Every inbound /api/portals/* call flows through five layers. The counts across the service:
| Layer | Count | Location |
|---|---|---|
| Controllers | 38 | app/Http/Controllers/** |
| Request mappers | 57 | app/Mapper/** |
| Client classes (+ interfaces) | 122 (+ 22) | app/Clients/** |
| Response mappers | 174 | app/ResponseMapper/** |
| Actions | 102 | app/Actions/** |
The flow is controller → request-mapper → client → response-mapper → action, with the action orchestrating the middle three:
- Controller — thin. Validates a
FormRequest, pulls route params, and lets the container inject the right per-country client (via an interface) plus the action. It calls the action and wraps the result in a standard JSON envelope. It never sees raw CRM JSON. - Request mapper — a declarative
Dict/Arr+ primitive (Str/Decimal/Enum/…) schema inapp/Mapper/DataTypes/that maps the unified snake_case request into the CRM/Logic-App field names. - Client — a
LinkDevAuthenticatedClient(write) orCrmClient(read) that resolves the operation’s URL fromconfig(...)and performs the authenticated HTTP call. - Response mapper — a per-country
app/ResponseMapper/**class that maps Dynamics field names (_ldv_salesagent_value,emailaddress1, …) back into unified snake_case. Bound per country (see Country scoping below). - Action —
app/Actions/**orchestration: call mapper, call client, format the response, return aCrmResponseDto.
A concrete write trace: broker submits a lead
Section titled “A concrete write trace: broker submits a lead”Following POST portals/brokers/{brokerSourceId}/leads end to end:
- Route —
routes/api.php:86maps it toBrokerController@submitNewLead. - Controller —
BrokerController::submitNewLead()(app/Http/Controllers/Api/Portals/BrokerController.php:33) validates aSubmitNewLeadRequest, folds the{brokerSourceId}route param into the request data, and injectsLeadClient $client,SubmitNewLead $submitNewLead, andCountryName $country(resolved from thecountryheader). It calls$submitNewLead->handle(...)thencommonResponse(...). - Action —
SubmitNewLead::handle()(app/Actions/Crms/Leads/SubmitNewLead.php:19) maps the body, merges in the numericcountrysource id, wraps it as{ "Lead": ... }, calls$client->submitLead(...), then runs the CRM response throughFormatCrmResponsewith a callback that liftslead_source_id ← Lead.leadid. - Request mapper —
SubmitNewLeadRequestMapper(app/Mapper/SubmitNewLeadRequestMapper.php:19) is aDictschema:firstname ← first_name,salesagentid ← broker_source_id,clientbudget ← budget, and so on. - Client —
LeadClient::submitLead()(app/Clients/Crms/LeadClient.php:10) readsconfig('services.crms.lead.submit_new_lead_url')and callspostWithAuthentication(...), so the request carries alinkdev-crm-tokenAzure bearer to the Logic App.
The read side is symmetric: e.g. BrokerController::listLeads() injects the GetBrokerLeadsInterface client,
which the container binds to the country-specific concrete (EgyptCrmClient\GetBrokerLeads, …). That reader
issues an OData query against the org and its result is shaped by a per-country response mapper such as
Egypt\LeadsMapper (app/ResponseMapper/Egypt/LeadsMapper.php:16, e.g. broker_source_id ← _ldv_salesagent_value).
Endpoints
Section titled “Endpoints”121 routes, all in routes/api.php: 1 Sanctum scaffold, 2 bare login routes, 109 under
client:portals-api, and 9 under client:crm-webhook. Every route param below ({...}) is a CRM source id.
The tables group by domain; highly repetitive groups (salesman lookups, EOI product CRUD) show representative
rows plus a count rather than every line — routes/api.php is the exhaustive source.
Token issuance and scaffold (3)
Section titled “Token issuance and scaffold (3)”| Method | Path | Purpose | Notes |
|---|---|---|---|
| GET | /api/user |
Return the Sanctum-authenticated user | Default Laravel scaffold; auth:sanctum |
| POST | portals/login |
Issue a portals-api token to the backend |
Bare (no client guard) |
| POST | crms/login |
Issue a crm-webhook token to a CRM |
Bare (no client guard) |
Sync and reference lookups — client:portals-api (12)
Section titled “Sync and reference lookups — client:portals-api (12)”| Method | Path | Purpose | Notes |
|---|---|---|---|
| GET | portals/units/sync |
Incremental unit pull (created + updated since) | |
| GET | portals/units/resale/sync |
Incremental resale-unit pull | |
| GET | portals/leads/sync |
Incremental lead pull | |
| GET | portals/leads/count |
Lead count | Egypt only |
| GET | portals/meetings/sync |
Incremental meeting pull | |
| GET | portals/cities |
Cities lookup | Egypt only |
| GET | portals/countries |
Countries lookup | |
| GET | portals/nationalities |
Nationalities lookup | |
| GET | portals/occupations |
Occupations lookup | Egypt + Montenegro |
| GET | portals/connections |
Connection-roles lookup | Montenegro only |
| GET | portals/currencies |
Currencies lookup | |
| GET | portals/egp-exchange-rate |
Current EGP FX rate |
Units, add-ons, sale and reservation — client:portals-api (11)
Section titled “Units, add-ons, sale and reservation — client:portals-api (11)”| Method | Path | Purpose | Notes |
|---|---|---|---|
| GET | portals/units |
Units with filter params | |
| GET | portals/units/{unitSourceId}/sales-offer |
Sales offer for a unit | |
| GET | portals/units/{unitSourceId}/payment-terms |
Payment terms for a unit | |
| GET | portals/units/{unitSourceId}/addons |
Available add-ons | |
| POST | portals/units/{unitSourceId}/addons |
Submit chosen add-ons | |
| GET | portals/units/{unitSourceId}/submitted-addons |
Previously submitted add-ons | Egypt only |
| POST | portals/units/{unitSourceId}/sale |
Create draft sale | |
| POST | portals/sales/{saleSourceId}/reserve |
Reserve a sale | |
| POST | portals/sales/{saleSourceId}/attach |
Attach files to a sale | Multipart |
| POST | portals/sales/{saleSourceId}/update-info |
Update sale info | |
| POST | portals/sales/{saleSourceId}/reservation-form |
Generate reservation form |
Plus GET portals/sales/{saleSourceId}/installments (installment schedule).
Customer requests and shopper — client:portals-api (5)
Section titled “Customer requests and shopper — client:portals-api (5)”| Method | Path | Purpose | Notes |
|---|---|---|---|
| GET | portals/customer-requests |
List customer requests (incidents) | |
| POST | portals/customer-requests |
Submit a unit-service request | |
| POST | portals/resell-assistance |
Submit a resell-assistance request | |
| GET | portals/shopper/details |
Shopper details | |
| POST | portals/shopper/leads |
Shopper lead submission |
Broker signup and broker leads/meetings — client:portals-api (12)
Section titled “Broker signup and broker leads/meetings — client:portals-api (12)”| Method | Path | Purpose | Notes |
|---|---|---|---|
| POST | portals/brokers |
Broker self-signup | Forwards to CMS |
| POST | portals/oman-pre-registration/brokers |
Oman broker pre-registration (register) | Oman only |
| PATCH | portals/oman-pre-registration/brokers |
Oman broker pre-registration (update) | Oman only |
| GET | portals/brokers/{brokerSourceId}/leads |
List a broker’s leads | |
| POST | portals/brokers/{brokerSourceId}/leads |
Submit a broker lead | |
| GET | portals/brokers/{brokerSourceId}/leads/{leadSourceId} |
Broker lead details | |
| GET | portals/brokers/{brokerSourceId}/leads/{leadSourceId}/meetings |
Meetings for a broker’s lead | |
| POST | portals/brokers/{brokerSourceId}/leads/{leadSourceId}/meetings |
Submit a meeting on a lead | |
| GET | portals/brokers/{brokerSourceId}/leads-duplicate |
List duplicate leads | |
| GET | portals/brokers/{brokerSourceId}/leads-duplicate/{dupLeadSourceId} |
Duplicate-lead details | |
| GET | portals/brokers/{brokerSourceId}/meetings |
List a broker’s meetings | |
| GET | portals/brokers/{brokerSourceId}/commissions/{saleSourceId} |
Sales-commission details | Egypt + Oman |
Launches, EOIs and taskeen — client:portals-api (20)
Section titled “Launches, EOIs and taskeen — client:portals-api (20)”| Method | Path | Purpose | Notes |
|---|---|---|---|
| GET | portals/launches |
List launches | Egypt only |
| GET | portals/launches/{launchSourceId}/projects |
Launch projects | |
| GET | portals/launches/{launchSourceId}/unit_types/{priceSourceId}/preferences |
Launch unit-type preferences | |
| GET | portals/eois |
List EOIs | |
| GET | portals/eois/search |
Search EOIs | |
| GET | portals/taskeen |
List taskeen | Egypt only |
| GET | portals/launches/{launchSourceId}/eois/{eoiSourceId} |
EOI details | |
| GET | portals/launches/{launchSourceId}/eois/{eoiSourceId}/attachments |
EOI attachments | |
| POST | portals/launches/{launchSourceId}/eois |
Submit an EOI | |
| POST | portals/launches/{launchSourceId}/eois/{eoiSourceId}/attach |
Attach a file to an EOI | |
| PATCH | portals/launches/{launchSourceId}/eois/{eoiSourceId} |
Update an EOI | |
| PATCH | portals/launches/{launchSourceId}/eois/{eoiSourceId}/cancel |
Cancel an EOI | |
| PATCH | .../eois/{eoiSourceId}/attachments/{attachSourceId}/delete |
Delete an EOI attachment |
The EOI product / unit-type / preference sub-tree adds 7 more nested CRUD routes on
EOIProductController — POST/PATCH/DELETE on
portals/launches/{launchSourceId}/eois/{eoiSourceId}/products/{productId} and its
/unit-types/{unitTypeId} and /preferences/{preferenceId} descendants.
salesman/* subgroup — client:portals-api (48)
Section titled “salesman/* subgroup — client:portals-api (48)”Prefixed portals/salesman, controllers under Portals\SalesMan\. {salesManSourceId} is the CRM
system-user GUID. This is the largest group; it breaks down as:
| Sub-area | Count | Representative routes |
|---|---|---|
| Profile / user | 2 | GET portals/salesman/user-info, GET portals/salesman/system-users/sync |
| Leads | 8 | GET .../{salesManSourceId}/leads, POST .../{salesManSourceId}/leads, POST .../{leadSourceId}/disqualify, GET .../{leadSourceId}/feedback |
| Pipelines | 8 | GET/POST .../{salesManSourceId}/pipelines, PATCH .../{pipelineSourceId}/lost, GET/POST .../{pipelineSourceId}/unit-types |
| Customers | 5 | GET/POST .../{salesManSourceId}/customers, GET .../{customerSourceId}, POST .../{customerSourceId}/attach |
| Sales | 4 | GET/POST .../{salesManSourceId}/sales, POST .../{saleSourceId}/attach, PATCH .../{saleSourceId} |
| Meetings | 2 | GET .../{salesManSourceId}/meetings, GET .../{pipelineSourceId}/meetings |
| Conditional-field lookups | 13 | one ConditionalFieldsController — ambassadors, events, offices, system-users, exhibitions, units, brokers, competitions, corporates, sales-agents, lead-sources (+ /subsources), lead-countries |
| Incremental sync | 5 | leads/sync, sales/sync, sales/sync-split, meetings/sync, system-users/sync |
| Eligibility | 1 | GET portals/salesman/check-lead-eligible |
Inbound CRM webhooks — client:crm-webhook (9)
Section titled “Inbound CRM webhooks — client:crm-webhook (9)”Controllers under Crms\. These receive CRM push events and forward them to the backend via PortalClient.
| Method | Path | Purpose |
|---|---|---|
| POST | crms/brokers/register |
CRM notifies: broker registered |
| PATCH | crms/brokers/update |
CRM notifies: broker updated |
| POST | crms/units/{sourceId} |
CRM upserts a unit |
| PATCH | crms/units/{sourceId} |
CRM upserts a unit (patch alias) |
| PATCH | crms/sales/{saleSourceId} |
CRM notifies: sale updated |
| POST | crms/leads/{leadSourceId} |
CRM notifies: lead status → notify broker |
| POST | crms/meetings/{meetingSourceId} |
CRM notifies: meeting status → notify broker |
| POST | crms/eoi/{eoiSourceId} |
CRM notifies: EOI status → notify user |
| POST | crms/eoi |
CRM upserts an EOI |
Country scoping
Section titled “Country scoping”There is no country in the URL. Every per-country binding is resolved per request from the country HTTP
header. AppServiceProvider::getCountryFromHeadersOrFail()
(app/Providers/AppServiceProvider.php:295) reads request()->header('country') and maps it to the
CountryName enum (app/Enums/CountryName.php): egypt = 1, oman = 2, montenegro = 3. A missing or
unknown header throws UnsupportedCountryException.
The read credentials are likewise built per request: scopeCrmCredentials()
(AppServiceProvider.php:313) reads config("services.crms.<country>") and constructs the CrmCredentials
DTO (auth_url / client_id / client_secret / grant_type / scope / base_url) — where base_url is
that country’s Dynamics org.
Three container resolve-maps then bind each interface to its per-country concrete via a single generic helper
scopeCountry() (AppServiceProvider.php:347), which throws UnsupportedClassForCountry when a country has
no binding for a requested operation (that is how a country-limited endpoint 500s for an unsupported country):
| Resolve-map | Location | Binds | Count |
|---|---|---|---|
countryCrmClientResolveMap() |
AppServiceProvider.php:408-523 |
CRM reader/writer client interfaces → per-country client | 22 interfaces |
countryResponseMapperResolveMap() |
AppServiceProvider.php:525-756 |
response-mapper interfaces → per-country mapper | 57 interfaces |
countryMapperResolveMap() |
AppServiceProvider.php:758-776 |
request-mapper interfaces → per-country mapper | 3 interfaces |
Each country resolves to its own read namespace and its own Dynamics org; writes go to the same shared Logic Apps regardless of country (the country id rides in the payload):
country header |
Enum / id | Read client namespace | Read target (Dynamics org) | Write target |
|---|---|---|---|---|
egypt |
EGYPT / 1 | EgyptCrmClient\* |
EGYPT_CRM_BASE_URL org (OData v9.1/v9.2) |
shared Logic Apps, country = 1 in body |
oman |
OMAN / 2 | OmanCrmClient\* |
OMAN_CRM_BASE_URL org |
shared Logic Apps, country = 2 in body |
montenegro |
MONTENEGRO / 3 | MontenegroCrmClient\* |
MONTENEGRO_CRM_BASE_URL org |
shared Logic Apps, country = 3 in body |
Not every operation is bound for every country. Country-limited examples (calling them for a missing country
throws UnsupportedClassForCountry): Egypt only — leads count, cities, unit add-ons, all SalesMan *Sync
operations, launches/EOIs/taskeen; Montenegro only — connections, lead sources/sub-sources/countries;
Oman only — broker pre-registration; no Montenegro — broker sales-commission details; no Oman —
occupations.
Known issues
Section titled “Known issues”UnitClient::createResale() reads an undeclared config key → null URL
Section titled “UnitClient::createResale() reads an undeclared config key → null URL”UnitClient::createResale() resolves its Logic-App URL from config('services.crms.unit.create_resale')
(app/Clients/Crms/UnitClient.php:61):
public function createResale(array $data): Response{ $url = config('services.crms.unit.create_resale'); // UnitClient.php:61
return $this->postWithAuthentication(url: $url, data: $data);}But the services.crms.unit block in config/services.php (config/services.php:177-186) declares only
payment_terms, create_draft_sale, reserve_sale, attach_sale, update_info_sale,
get_reservation_form, sales_offer, and submit_addon — there is no create_resale key. Every sibling
method (e.g. createDraftSale() at UnitClient.php:19) reads a key that is declared; createResale() is the
one that is not. So config('services.crms.unit.create_resale') resolves to null, and if the method is
ever invoked it POSTs to a null URL.
This is config drift — the code path exists but its env wiring was never added. It is likely either dead or
awaiting an env key. Fixing it means adding a create_resale entry (backed by a new env(...) key) to the
unit block in config/services.php, matching the pattern of the other unit operations.
