Skip to content

Add a new CRM operation

Adding a CRM operation means adding code to crms-middleware (the only service that talks to the CRMs) and a caller on the backend. Before you touch anything, make the one decision that determines the whole shape — read the CRM integration model first if it isn’t fresh. This recipe uses the Lead entity as the worked example (a write and a read), verified against crms-middleware @ develop.

Write op — worked example: submit a broker lead

Section titled “Write op — worked example: submit a broker lead”

The request travels: route → controller → action (map + wrap + call) → client → Logic App, then the response is normalised back.

  1. RoutePOST portals/brokers/{brokerSourceId}/leadsBrokerController@submitNewLead (routes/api.php:86), inside the prefix('portals') + client:portals-api group (:23).
  2. ControllerApi/Portals/BrokerController.php:33-46 injects the request-validation, the concrete LeadClient, the SubmitNewLead action, and CountryName $country (resolved from the header — see below); calls the action, then $this->commonResponse(...).
  3. ActionActions/Crms/Leads/SubmitNewLead.php:19-57: resolve the numeric country id (Util::getCountrySourceId), run the request Mapper, merge ['country' => <id>], wrap as ['Lead' => $mapped], call $client->submitLead(...), and pipe the result through FormatCrmResponse (pulling Lead.leadid / Lead.customerid into a CrmResponseDto).
  4. ClientClients/Crms/LeadClient.php:10-15: submitLead() reads the Logic-App URL from config('services.crms.lead.submit_new_lead_url') and calls postWithAuthentication(url, data). LeadClient extends LinkDevAuthenticatedClient.
  5. Auth + transportLinkDevAuthenticatedClient does the OAuth v1 resource= grant (token cached as linkdev-crm-token); AbstractClient::postWithAuthentication POSTs with a 300 s, single-attempt timeout.

The request Mapper (Mapper/SubmitNewLeadRequestMapper.php:19-40) is a Dict schema translating the portal’s snake_case to Dynamics logical names — first_name→firstname, phone→fullmobilenumber, budget→clientbudget, broker_source_id→salesagentid, destination_source_id→destinationid, interested_in_unit_id→interestedin. Broker/shopper lead writes share one country-agnostic request mapper (only the numeric country differs); per-country request mappers exist only for the Sales-Man surface.

Config + env (config/services.php): the write URL is services.crms.lead.submit_new_lead_urlLEAD_CRM_SUBMIT_NEW_LEAD_URL; the write auth is services.crms.link_dev_authLINK_DEV_AUTH_URL / _CLIENT_ID / _CLIENT_SECRET / _GRANT_TYPE / _RESOURCE.

  1. config/services.php — add services.crms.<group>.<op>_url (+ the *_URL env name).
  2. Clients/Crms/<Thing>Client.php — add the method (on a LinkDevAuthenticatedClient subclass) reading that URL.
  3. Mapper/<...>RequestMapper.php — add the unified→Dynamics Dict (skip if no field translation).
  4. Actions/Crms/<Domain>/<Verb><Thing>.php — map → merge country → wrap → call client → FormatCrmResponse.
  5. Http/Controllers/Api/Portals/<X>Controller.php — inject CountryName + validation + client + action; return commonResponse.
  6. routes/api.php (prefix('portals')) — add the route.
  7. docs/portals-openapi.yml — document the inbound contract.
  8. Backend side — add/extend a CrmsMiddleware* client action + persona route that calls it with the country header (see below).

How the country header picks the right code

Section titled “How the country header picks the right code”

For a write, the numeric country goes into the body: Util::getCountrySourceIdCrmCountryIdConverter::mapToCrmCountryName::getKeyegypt=1, oman=2, montenegro=3 (a plain ordinal, not a GUID). For a read, the container swaps the whole class. Both start from AppServiceProvider::getCountryFromHeadersOrFail() (:295-304), which reads header('country') and CountryName::tryFrom(...). Then scopeCountry() (:347-369) binds — per the resolve-maps — the country-specific concrete class for every interface:

  • countryCrmClientResolveMap() — the read clients (GetBrokerLeadsInterface → Egypt/Oman/Montenegro).
  • countryResponseMapperResolveMap() — the response mappers.
  • countryMapperResolveMap() — per-country request mappers (Sales-Man only).
  • Single attempt, 300 s, no retry. AbstractClient sets ->timeout(300)->connectTimeout(300) and has no ->retry(...) — one shot per call; a slow Logic App / Dynamics query blocks up to 5 minutes then fails hard.
  • Non-200 from the CRM → HTTP 400 (FormatCrmResponse + ApiController::commonResponse), with the CRM body JSON-encoded into messageexcept two exceptions that surface as 500:
    • UnsupportedCountryException — an accidental 500: it passes HTTP_NOT_FOUND as the exception code (not a status) and has no render(), so Laravel emits a generic 500. An unknown/missing country header looks like a 500, not a 404. (Tracked as a bug — see the KT tracker.)
    • UnsupportedClassForCountry — a deliberate 500 (constructs with HTTP_INTERNAL_SERVER_ERROR + has a render()), thrown when the op has no class for the requested country.
  • Auth differs by direction; OData version differs by resource. Writes use the Link-Dev v1 resource= grant; reads use per-country v2 scope=. The OData version is chosen per entity in the reader URL (leads → v9.2, *units → v9.1) — don’t assume one version org-wide. All of it authenticates against the same Azure AD app.
  • Never share mappers across CRMs, never share clients across directions — the Dynamics dialects genuinely differ per country, and the write/read auth surfaces are separate.
  • Local dev uses crm-mock. Point <CRM>_BASE_URL at the crm-mock app; there’s also an Oman broker-CRM mock swap (OMAN_CRM_BROKER_CRM_MOCK_ENABLED).

The backend caller (who invokes the middleware)

Section titled “The backend caller (who invokes the middleware)”

The portal backend reaches the middleware via CrmMiddlewareClient — a Passport client_credentials grant against CRMS_MIDDLEWARE_AUTH_URL (token cached as crms-middleware). Every outbound call sets the country header from the country slug — that header is exactly what the middleware’s scopeCountry bindings read. So the backend contract for any new op: call the matching crms-middleware route through a CrmsMiddleware* client action with client_credentials auth and the country header, and keep the route signature in sync with what the backend sends.

  1. Static + routephpstan analyse, then php artisan route:list to confirm the new portals/… route sits under client:portals-api. (Note: the resolve-maps are plain arrays, so a wrong interface/country is a runtime UnsupportedClassForCountry, not a static error — verify the map entry by eye.)
  2. Local run against crm-mock — point <CRM>_BASE_URL at crm-mock; send the request with a valid client:portals-api token and the country header (omit it → the 500 gotcha), and assert the mapped unified shape (write → {lead_source_id, customer_source_id}; read → the mapper’s fields).
  3. Per-country matrix — exercise each country you registered; a missing map entry 500s only for that country.
  4. Contract — update + lint docs/portals-openapi.yml; keep the backend CrmsMiddleware* client signature in sync.