Skip to content

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).

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 scope portals-api. The backend authenticates as the portals-backend client. Tokens are minted by Portals\AuthController@login (POST portals/login, routes/api.php:22).
  • CRM → middleware (webhooks) — the /api/crms/* routes require scope crm-webhook. Each CRM authenticates with its own OAuth client. Tokens are minted by Crms\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-style resource= token claim (LinkDevAuthenticatedClient.php:19-24), cached under linkdev-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-scoped CrmCredentials DTO using the v2-style scope= 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).

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:

  1. 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.
  2. Request mapper — a declarative Dict/Arr + primitive (Str/Decimal/Enum/…) schema in app/Mapper/DataTypes/ that maps the unified snake_case request into the CRM/Logic-App field names.
  3. Client — a LinkDevAuthenticatedClient (write) or CrmClient (read) that resolves the operation’s URL from config(...) and performs the authenticated HTTP call.
  4. 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).
  5. Actionapp/Actions/** orchestration: call mapper, call client, format the response, return a CrmResponseDto.

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:

  • Routeroutes/api.php:86 maps it to BrokerController@submitNewLead.
  • ControllerBrokerController::submitNewLead() (app/Http/Controllers/Api/Portals/BrokerController.php:33) validates a SubmitNewLeadRequest, folds the {brokerSourceId} route param into the request data, and injects LeadClient $client, SubmitNewLead $submitNewLead, and CountryName $country (resolved from the country header). It calls $submitNewLead->handle(...) then commonResponse(...).
  • ActionSubmitNewLead::handle() (app/Actions/Crms/Leads/SubmitNewLead.php:19) maps the body, merges in the numeric country source id, wraps it as { "Lead": ... }, calls $client->submitLead(...), then runs the CRM response through FormatCrmResponse with a callback that lifts lead_source_id ← Lead.leadid.
  • Request mapperSubmitNewLeadRequestMapper (app/Mapper/SubmitNewLeadRequestMapper.php:19) is a Dict schema: firstname ← first_name, salesagentid ← broker_source_id, clientbudget ← budget, and so on.
  • ClientLeadClient::submitLead() (app/Clients/Crms/LeadClient.php:10) reads config('services.crms.lead.submit_new_lead_url') and calls postWithAuthentication(...), so the request carries a linkdev-crm-token Azure 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).

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.

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 ConditionalFieldsControllerambassadors, 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

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.

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.