PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / app / Modules / MCP / Support / CrmContact.php

CrmContact.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Modules/MCP/Support/CrmContact.php

305 lines 11.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\MCP\Support;
4
5 use FluentCart\App\Models\Customer;
6
7 /**
8 * FluentCRM contact context on the MCP's single-record customer and order tools.
9 *
10 * The admin already shows this: FluentCRM injects a "Contact Info" widget into
11 * FluentCart's single-customer and single-order sidebars (its CustomerWidget
12 * hooks `fluent_cart/widgets/customer` and `fluent_cart/widgets/single_order_page`).
13 * An agent reading the same records over MCP saw none of it and had no tool to
14 * ask for it, so "is this buyer subscribed, and what has marketing sent them?"
15 * was unanswerable — the exact question the widget exists to answer.
16 *
17 * Shape follows that widget: contact status, lists, tags, and the emails/opens/
18 * clicks engagement counters, plus the CRM profile URL.
19 *
20 * Layering:
21 * - The compact identity block (contact_id, status, contact_type, profile_url)
22 * rides along on every get-customer / get-order call — ONE query, and it is
23 * what makes the contact discoverable at all.
24 * - lists, tags and engagement cost five more queries, so they arrive only on
25 * include[] = crm_contact. Same key, more fields — never a second shape.
26 *
27 * Reading CRM models from FluentCart follows the existing precedent in
28 * app/Modules/Integrations/FluentPlugins/FluentCRMDeepIntegration.php, and the
29 * whole surface is gated on FluentCRM's own `fcrm_read_contacts` capability
30 * through its PermissionManager — the same check the widget makes — so MCP can
31 * never expose CRM data to a role the CRM itself would refuse.
32 */
33 class CrmContact
34 {
35 const SECTION = 'crm_contact';
36
37 /**
38 * FluentCart detects FluentCRM by the FLUENTCRM constant everywhere else
39 * (Services/Integration.php, FluentCRMConnect::isConfigured, AddonsController).
40 * The model + PermissionManager checks additionally guard against a partially
41 * booted CRM, since we read both directly.
42 */
43 public static function isAvailable()
44 {
45 return defined('FLUENTCRM')
46 && class_exists('\FluentCrm\App\Models\Subscriber')
47 && class_exists('\FluentCrm\App\Services\PermissionManager');
48 }
49
50 /**
51 * Wire the section into both single-record tools. Called from MCPInit::init().
52 *
53 * The availability check CANNOT run here: MCPInit boots from
54 * app/Hooks/actions.php at plugin-file load time, and WordPress loads plugin
55 * files alphabetically — fluent-cart before fluent-crm — so FLUENTCRM is not
56 * defined yet and every check would report FluentCRM absent on a site that
57 * has it. Defer to plugins_loaded, by which point every plugin file has run.
58 * This is the same reason Services/Integration.php defers its own
59 * defined('FLUENTCRM') gate to a later hook.
60 *
61 * Still early enough: the include[] enum is read when definitions() runs on
62 * wp_abilities_api_init (fired from `init`), and the data filters fire at
63 * request time — both after plugins_loaded.
64 */
65 public static function register()
66 {
67 if (did_action('plugins_loaded')) {
68 self::registerNow();
69 return;
70 }
71
72 add_action('plugins_loaded', [self::class, 'registerNow'], 20);
73 }
74
75 /** Attach the hooks, once FluentCRM is known to be loaded. */
76 public static function registerNow()
77 {
78 if (!self::isAvailable()) {
79 return;
80 }
81
82 add_filter('fluent_cart/mcp_customer_include_sections', [self::class, 'addSection']);
83 add_filter('fluent_cart/mcp_order_include_sections', [self::class, 'addSection']);
84 add_filter('fluent_cart/mcp_customer_data', [self::class, 'attachToCustomer'], 10, 2);
85 add_filter('fluent_cart/mcp_order_data', [self::class, 'attachToOrder'], 10, 2);
86 }
87
88 /**
89 * @param array $sections
90 * @return array
91 */
92 public static function addSection($sections)
93 {
94 $sections = (array) $sections;
95 $sections[] = self::SECTION;
96
97 return $sections;
98 }
99
100 /**
101 * @param array $data the get-customer payload
102 * @param array $context { customer: Customer, include: string[] }
103 * @return array
104 */
105 public static function attachToCustomer($data, $context)
106 {
107 $customer = isset($context['customer']) ? $context['customer'] : null;
108 if (!$customer) {
109 return $data;
110 }
111
112 return self::attach($data, $customer, self::wantsFull($context));
113 }
114
115 /**
116 * The order's buyer is the contact. The customer relation is already loaded
117 * by getOrder(); a guest order with no customer row simply has no contact.
118 *
119 * @param array $data the get-order payload
120 * @param array $context { order: Order, include: string[] }
121 * @return array
122 */
123 public static function attachToOrder($data, $context)
124 {
125 $order = isset($context['order']) ? $context['order'] : null;
126 if (!$order || !$order->customer) {
127 return $data;
128 }
129
130 return self::attach($data, $order->customer, self::wantsFull($context));
131 }
132
133 private static function wantsFull($context)
134 {
135 $include = isset($context['include']) ? (array) $context['include'] : [];
136
137 return in_array(self::SECTION, $include, true);
138 }
139
140 /**
141 * @param array $data
142 * @param Customer $customer
143 * @param bool $full
144 * @return array
145 */
146 private static function attach($data, $customer, $full)
147 {
148 // Silence over a permission error: the contact block is supplementary,
149 // and a role without CRM access asking for an order should still get the
150 // order. Only say something when the section was explicitly requested.
151 if (!\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_read_contacts')) {
152 if ($full) {
153 $data[self::SECTION . '_omitted'] = __('FluentCRM contact data requires the fcrm_read_contacts capability.', 'fluent-cart');
154 }
155 return $data;
156 }
157
158 $contact = self::resolve($customer);
159 if (!$contact) {
160 // Explicit null rather than an absent key: "this buyer is not in the
161 // CRM" is an answer, and an agent that sees nothing cannot tell it
162 // apart from "FluentCRM is not installed".
163 $data[self::SECTION] = null;
164 return $data;
165 }
166
167 $block = [
168 'contact_id' => (int) $contact->id,
169 'status' => $contact->status,
170 'contact_type' => $contact->contact_type,
171 'profile_url' => admin_url('admin.php?page=fluentcrm-admin#/subscribers/' . (int) $contact->id),
172 ];
173
174 if ($full) {
175 $contact->load('lists', 'tags');
176
177 $block['name'] = $contact->full_name;
178 $block['source'] = $contact->source;
179 $block['created_at'] = self::utcDate($contact->created_at);
180 $block['last_activity'] = self::utcDate($contact->last_activity);
181 $block['lists'] = self::terms($contact->lists);
182 $block['tags'] = self::terms($contact->tags);
183 $block['engagement'] = self::engagement($contact);
184 }
185
186 $data[self::SECTION] = $block;
187
188 return $data;
189 }
190
191 /**
192 * Match a FluentCart customer to a CRM contact.
193 *
194 * Email first, so this can never disagree with the admin widget (which
195 * matches on email alone). user_id is a fallback for the case the widget
196 * misses: a buyer whose CRM contact was created under a different email but
197 * the same WordPress account.
198 *
199 * @param Customer $customer
200 * @return object|null
201 */
202 private static function resolve($customer)
203 {
204 $subscriber = '\FluentCrm\App\Models\Subscriber';
205
206 if (!empty($customer->email)) {
207 $contact = $subscriber::where('email', $customer->email)->first();
208 if ($contact) {
209 return $contact;
210 }
211 }
212
213 if (!empty($customer->user_id)) {
214 return $subscriber::where('user_id', (int) $customer->user_id)->first();
215 }
216
217 return null;
218 }
219
220 /**
221 * CRM timestamps, shifted to UTC.
222 *
223 * FluentCRM writes its timestamps with current_time('mysql') — WordPress
224 * SITE time — and its ORM hydrates them carrying the site offset. Every other
225 * date in an MCP payload is UTC (FluentCart stores GMT), and the store
226 * context tells agents dates are ISO-8601 UTC. Passing these through
227 * unconverted would put a contact's last_activity and an order's created_at
228 * on different clocks in the same response — off by the site's offset, with
229 * nothing in the payload to reveal it.
230 *
231 * @param mixed $value
232 * @return string|null
233 */
234 private static function utcDate($value)
235 {
236 if (!$value) {
237 return null;
238 }
239
240 if ($value instanceof \DateTimeInterface) {
241 $dt = new \DateTime($value->format('Y-m-d H:i:s'), $value->getTimezone());
242 return $dt->setTimezone(new \DateTimeZone('UTC'))->format('c');
243 }
244
245 // A plain string has no offset attached, so read it as site time — the
246 // timezone it was written in — before shifting.
247 if (is_string($value) && strpos($value, '0000-00-00') !== 0) {
248 try {
249 $dt = new \DateTime($value, wp_timezone());
250 return $dt->setTimezone(new \DateTimeZone('UTC'))->format('c');
251 } catch (\Exception $e) {
252 return null;
253 }
254 }
255
256 return null;
257 }
258
259 /** Lists/tags as {id, title} — the shape the admin widget renders as chips. */
260 private static function terms($collection)
261 {
262 $out = [];
263 if (!$collection) {
264 return $out;
265 }
266 foreach ($collection as $term) {
267 $out[] = ['id' => (int) $term->id, 'title' => $term->title];
268 }
269
270 return $out;
271 }
272
273 /**
274 * Email engagement, with the two rates the widget computes in the browser
275 * done here instead — an agent comparing "opens" across contacts with
276 * different send volumes needs the rate, not the raw count.
277 *
278 * @param object $contact
279 * @return array|null null when the CRM cannot produce stats for this contact
280 */
281 private static function engagement($contact)
282 {
283 $stats = [];
284 try {
285 $stats = (array) $contact->stats();
286 } catch (\Throwable $e) {
287 return null;
288 }
289
290 $sent = isset($stats['emails']) ? (int) $stats['emails'] : 0;
291 $opens = isset($stats['opens']) ? (int) $stats['opens'] : 0;
292 $clicks = isset($stats['clicks']) ? (int) $stats['clicks'] : 0;
293
294 return [
295 'emails_sent' => $sent,
296 'opens' => $opens,
297 'clicks' => $clicks,
298 // null, not 0, when nothing was sent: a 0% open rate reads as
299 // "never opens our email", which is a different claim.
300 'open_rate_percent' => $sent > 0 ? round($opens / $sent * 100, 2) : null,
301 'click_rate_percent' => $sent > 0 ? round($clicks / $sent * 100, 2) : null,
302 ];
303 }
304 }
305