lh-crm.php holds the main class, LH_Crm_plugin: identity methods, form meta accessors, shared helpers, the Contact Form block, frontend script and admin notices. Each area of behaviour is a separate class in includes/ that extends LH_Crm_plugin and is its own singleton: contact form post type, enquiry post type, form submission, notifications, bbPress adjustments and MCP abilities. None of the plugin’s own classes is wrapped in a class_exists() guard: on this platform a guard like that can report true before the class is declared and silently skip the class’s setup. libraries/ holds four self-contained drop-in classes (form response post type, IP address taxonomy, user agent meta, script registration); these do keep a guard, because other plugins may ship the same files, and whichever copy loads first is the one the network uses. LH CRM’s copies log through LH CRM’s own write_log(). Consolidating every bundled copy is tracked as task 147743. View templates live in partials/.
The libraries and component classes are loaded by load_components(), called from the main class constructor, which runs as soon as the plugin file is read. Only the main class’s own hooks (block, frontend script, admin notices) are registered later, on bp_loaded.
Do not move component loading to bp_loaded or any later hook. The enquiry post type registers its five statuses on init at priority 1000. The wp-statuses library bundled in LH Agora also runs wp_statuses_register on init at priority 1000, and converts every status registered before it into its own status object. Callbacks at the same priority run in the order they were added, so LH CRM’s callback must be added before wp-statuses adds its own. If it is added later, the enquiry statuses stay plain objects and wp-statuses throws a fatal error (a call to is_builtin()) during REST server setup, which breaks the REST API, and with it MCP, across the whole network. This happened in 1.5.6. The same rule applies to any LH plugin that registers post statuses at init priority 1000.
LH CRM depends on BuddyPress. The main class’s hooks are registered on bp_loaded, so without BuddyPress the block and frontend script never load. The component classes are loaded when the plugin file is read (see Load order) and call BuddyPress functions directly (groups, xProfile, notifications), so the plugin should not be activated on a network without BuddyPress.
wp, a POST containing the form’s submitted flag and form ID is picked up and its nonce checked.lh_user_provisioning_ensure_user() when LH User Provisioning is active, otherwise a built-in fallback.lh_crm_http_post_after_user fires. Plugins whose profile fields save themselves (for example LH User Experience Taxonomy) save here.save_data(), after the nonce check, and passed to handle_bp_edit(). It only fills fields that are empty for the enquirer: a stored value is never changed from the enquiry form, whoever submits it. Values are passed to BuddyPress as submitted, so the field type validates them and BuddyPress (or the plugin that owns the field) sanitises them on save.lh_crm_enquiry_created fires for any other plugin listening.Because the nonce is printed into the page for the frontend script, a page cached for longer than the nonce lifetime will make submissions fail. Pages carrying a form should not be cached for long.
For a logged-in user the form shows their email, their name and every filled profile field as greyed, disabled inputs, and only offers inputs for missing details. Disabled inputs are never submitted, and the server side enforces the same rule regardless of what is posted.
Some profile fields are xProfile field types from other plugins that keep their data outside BuddyPress’s own profile data table (Gender and Phone in LH Profile Page are user meta; User Experience in LH User Experience Taxonomy is a user taxonomy term), so bp_field_has_data() reports them empty. The form renders each field with the suppress_if_populated property those field types honour, and treats a field as filled if BuddyPress has data for it, if its displayed value is not empty, or if the field type printed nothing. The greyed value comes from bp_get_the_profile_field_value() (which LH Profile Page filters), then lh_crm_frontend_field_display_value, and otherwise reads On your profile. Field types listed in lh_crm_frontend_always_editable_field_types skip all this and stay editable.
promote_users got blank name and email fields on the frontend, and a submission there could create or match a different user.lh_crm_frontend_always_editable_field_types; ideally LH User Experience Taxonomy would add itself through that filter.bp_loaded rather than the standard plugins_loaded pattern, because BuddyPress is a hard dependency on every LocalHero network.Client-IP and X-Forwarded-For headers over the connection address, and a visitor can set both. The recorded value is validated as an IP address but is only as trustworthy as the proxy in front of the site. Tracked in task 147743.sanitize_text_field(), which removes line breaks.lh_crm_message_title is applied but its result is never used, and the response template’s user summary token defaults to a placeholder value.bp-email post type to LH Email, which owns that integration.[lh_crm_form] shortcode after every use of it on the network had been converted to the Contact Form block.class_exists() guards around the plugin’s own classes, escaped the remaining admin and notification output, and made Hide Message and Notification Recipients clearable on a form.bp_loaded. That broke the REST API network-wide (see Load order), and the plugin was deactivated within minutes.current_time() expects a true or false GMT flag and so released scheduled enquiries 10 to 11 hours late on sites ahead of UTC. It now compares post_date_gmt with the current UTC time, and the lh_crm_gmt_offset filter was removed.lh-crm/query-enquiries MCP ability: behaviour annotations, additionalProperties: false, the valid values in its schema, and an error for a site ID that does not exist.read_private_posts capability (it was mapped to a misspelt read_private_postss), replaced the scheduled-enquiry direct query with get_posts(), added translators comments to the status labels, escaped the Group Action select, and sanitised the current-URL helper.save_data(), after the nonce check, so handle_bp_edit() no longer touches the request.write_log().show_in_menu key in the enquiry post type, the dashboard widget receiving a form ID instead of the whole post, and escaping of the enquiry page’s profile and vCard links. No intended change in behaviour.check_in and log_entry from the stream.return_vcard_link(), which the template checked for, so both links had silently disappeared. The vCard link now uses LH Vcard’s signed download URL and only shows to users with LH Vcard’s view capability, since anyone holding the link can redeem it for 24 hours. The Text link no longer depends on LH Vcard.handle_bp_edit() now only fills empty fields for every submission, and the staff Do not send email option was removed.suppress_if_populated, and added lh_crm_frontend_field_display_value.lh_crm_frontend_always_editable_field_types filter.bp-email editor support, which LH CRM’s auto-reply templates rely on.wp-statuses library that LH CRM’s statuses must register ahead of.lh_crm_http_post_after_user to save it, and adds notification tokens.LH_Vcard_plugin::return_signed_vcard_download_url().lh_crm written before then have since been removed from its store.lh-users-insights-crm-extender is a separate Users Insights add-on. The enquiry fields shown in Users Insights are registered by LH CRM itself, not by that plugin.