| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentBooking\App\Services\Integrations\FluentCRM; |
| 4 |
|
| 5 |
/** |
| 6 |
* Read/manage a FluentCRM contact's profile, tags and lists for the |
| 7 |
* booking details CRM Profile card. All methods are no-ops (return null) |
| 8 |
* when FluentCRM is inactive or the booking email has no CRM contact. |
| 9 |
*/ |
| 10 |
class CrmContactService |
| 11 |
{ |
| 12 |
public static function isActive() |
| 13 |
{ |
| 14 |
return defined('FLUENTCRM'); |
| 15 |
} |
| 16 |
|
| 17 |
/** |
| 18 |
* Profile payload for the CRM Profile sidebar card. |
| 19 |
*/ |
| 20 |
public static function getProfileData($email) |
| 21 |
{ |
| 22 |
$contact = self::getContact($email); |
| 23 |
|
| 24 |
if (!$contact) { |
| 25 |
return null; |
| 26 |
} |
| 27 |
|
| 28 |
return [ |
| 29 |
'contact_id' => (int) $contact->id, |
| 30 |
'photo' => $contact->photo, |
| 31 |
'full_name' => $contact->full_name, |
| 32 |
'status' => $contact->status, |
| 33 |
'profile_url' => fluentcrm_menu_url_base() . 'subscribers/' . $contact->id, |
| 34 |
'stats' => $contact->stats(), |
| 35 |
'tag_ids' => self::taxonomyIds($contact, 'tags'), |
| 36 |
'list_ids' => self::taxonomyIds($contact, 'lists'), |
| 37 |
'tags' => self::selectedOptions($contact, 'tags'), |
| 38 |
'lists' => self::selectedOptions($contact, 'lists'), |
| 39 |
]; |
| 40 |
} |
| 41 |
|
| 42 |
/** |
| 43 |
* Current tag/list selection (ids + titles) for the contact. |
| 44 |
*/ |
| 45 |
public static function getContactState($email) |
| 46 |
{ |
| 47 |
$contact = self::getContact($email); |
| 48 |
|
| 49 |
if (!$contact) { |
| 50 |
return null; |
| 51 |
} |
| 52 |
|
| 53 |
return [ |
| 54 |
'exists' => true, |
| 55 |
'tag_ids' => self::taxonomyIds($contact, 'tags'), |
| 56 |
'list_ids' => self::taxonomyIds($contact, 'lists'), |
| 57 |
'selected_tags' => self::selectedOptions($contact, 'tags'), |
| 58 |
'selected_lists' => self::selectedOptions($contact, 'lists'), |
| 59 |
]; |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* Guest-field prefill for the CRM "Book Appointment" action. |
| 64 |
* Null when FluentCRM is inactive or the contact is unknown. |
| 65 |
* Extend the payload via the filter. |
| 66 |
*/ |
| 67 |
public static function getBookingPrefillData($contactId) |
| 68 |
{ |
| 69 |
if (!self::isActive()) { |
| 70 |
return null; |
| 71 |
} |
| 72 |
|
| 73 |
$contact = FluentCrmApi('contacts')->getContact((int) $contactId); |
| 74 |
|
| 75 |
if (!$contact) { |
| 76 |
return null; |
| 77 |
} |
| 78 |
|
| 79 |
$data = [ |
| 80 |
'name' => trim((string) $contact->full_name), |
| 81 |
'email' => (string) $contact->email, |
| 82 |
'phone' => (string) $contact->phone, |
| 83 |
'timezone' => (string) $contact->timezone, |
| 84 |
'address' => implode(', ', array_filter([ |
| 85 |
(string) $contact->address_line_1, |
| 86 |
(string) $contact->address_line_2, |
| 87 |
(string) $contact->city, |
| 88 |
(string) $contact->state, |
| 89 |
(string) $contact->postal_code, |
| 90 |
(string) $contact->country, |
| 91 |
])), |
| 92 |
]; |
| 93 |
|
| 94 |
return apply_filters('fluent_booking/crm_booking_prefill_data', $data, $contact); |
| 95 |
} |
| 96 |
|
| 97 |
/** |
| 98 |
* Bounded, optionally-searched option set for the tag/list picker. |
| 99 |
*/ |
| 100 |
public static function getOptions($type, $search = '', $limit = 50) |
| 101 |
{ |
| 102 |
if (!self::isActive()) { |
| 103 |
return []; |
| 104 |
} |
| 105 |
|
| 106 |
return self::taxonomyOptions($type, $search, $limit); |
| 107 |
} |
| 108 |
|
| 109 |
/** |
| 110 |
* Bounded search of subscribed contacts for the booking typeahead. |
| 111 |
*/ |
| 112 |
public static function searchContacts($search, $limit = 20) |
| 113 |
{ |
| 114 |
if (!self::isActive()) { |
| 115 |
return []; |
| 116 |
} |
| 117 |
|
| 118 |
$search = trim((string) $search); |
| 119 |
if (mb_strlen($search) < 3) { |
| 120 |
return []; |
| 121 |
} |
| 122 |
|
| 123 |
$like = '%' . addcslashes($search, '%_\\') . '%'; |
| 124 |
|
| 125 |
return \FluentCrm\App\Models\Subscriber::where('status', 'subscribed') |
| 126 |
->where(function ($q) use ($like) { |
| 127 |
$q->where('email', 'LIKE', $like) |
| 128 |
->orWhere('first_name', 'LIKE', $like) |
| 129 |
->orWhere('last_name', 'LIKE', $like); |
| 130 |
}) |
| 131 |
->orderBy('email', 'ASC') |
| 132 |
->limit($limit) |
| 133 |
->get() |
| 134 |
->map(function ($contact) { |
| 135 |
return [ |
| 136 |
'id' => (int) $contact->id, |
| 137 |
'name' => trim((string) $contact->full_name), |
| 138 |
'email' => (string) $contact->email, |
| 139 |
]; |
| 140 |
}) |
| 141 |
->toArray(); |
| 142 |
} |
| 143 |
|
| 144 |
/** |
| 145 |
* Attach/detach the contact's tags or lists to match the desired set. |
| 146 |
* Only IDs the CRM actually knows about are honoured (no bogus pivots). |
| 147 |
* |
| 148 |
* @param string $type 'tags' | 'lists' |
| 149 |
* @return array|null Fresh {tag_ids, list_ids} or null when no contact. |
| 150 |
*/ |
| 151 |
public static function syncTaxonomy($email, $type, array $desiredIds) |
| 152 |
{ |
| 153 |
$contact = self::getContact($email); |
| 154 |
|
| 155 |
if (!$contact) { |
| 156 |
return null; |
| 157 |
} |
| 158 |
|
| 159 |
$model = self::taxonomyModel($type); |
| 160 |
$desiredIds = array_values(array_map('intval', $desiredIds)); |
| 161 |
$valid = $desiredIds |
| 162 |
? array_map('intval', $model::whereIn('id', $desiredIds)->pluck('id')->toArray()) |
| 163 |
: []; |
| 164 |
$desired = array_values(array_intersect($desiredIds, $valid)); |
| 165 |
|
| 166 |
$current = self::taxonomyIds($contact, $type); |
| 167 |
$toAttach = array_values(array_diff($desired, $current)); |
| 168 |
$toDetach = array_values(array_diff($current, $desired)); |
| 169 |
|
| 170 |
if ($type === 'tags') { |
| 171 |
if ($toAttach) { |
| 172 |
$contact->attachTags($toAttach); |
| 173 |
} |
| 174 |
if ($toDetach) { |
| 175 |
$contact->detachTags($toDetach); |
| 176 |
} |
| 177 |
} else { |
| 178 |
if ($toAttach) { |
| 179 |
$contact->attachLists($toAttach); |
| 180 |
} |
| 181 |
if ($toDetach) { |
| 182 |
$contact->detachLists($toDetach); |
| 183 |
} |
| 184 |
} |
| 185 |
|
| 186 |
$contact->load(['tags', 'lists']); |
| 187 |
|
| 188 |
return [ |
| 189 |
'tag_ids' => self::taxonomyIds($contact, 'tags'), |
| 190 |
'list_ids' => self::taxonomyIds($contact, 'lists'), |
| 191 |
]; |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* Create (or reuse) a tag/list definition in FluentCRM. Idempotent on |
| 196 |
* slug, so typing an existing name returns that existing tag/list. |
| 197 |
* |
| 198 |
* @param string $type 'tags' | 'lists' |
| 199 |
* @return array|null {id, title} or null when inactive / empty title. |
| 200 |
*/ |
| 201 |
public static function createTaxonomy($type, $title) |
| 202 |
{ |
| 203 |
if (!self::isActive()) { |
| 204 |
return null; |
| 205 |
} |
| 206 |
|
| 207 |
$title = trim((string) $title); |
| 208 |
if ($title === '') { |
| 209 |
return null; |
| 210 |
} |
| 211 |
|
| 212 |
$model = self::taxonomyModel($type); |
| 213 |
$slug = sanitize_title($title); |
| 214 |
|
| 215 |
if ($slug === '') { |
| 216 |
return null; |
| 217 |
} |
| 218 |
|
| 219 |
$item = $model::firstOrCreate(['slug' => $slug], ['title' => $title]); |
| 220 |
|
| 221 |
if ($item->wasRecentlyCreated) { |
| 222 |
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- FluentCRM's own hook names, fired so CRM listeners see parity with native tag/list creation |
| 223 |
do_action($type === 'tags' ? 'fluent_crm/tag_created' : 'fluent_crm/list_created', $item); |
| 224 |
} |
| 225 |
|
| 226 |
return [ |
| 227 |
'id' => (int) $item->id, |
| 228 |
'title' => $item->title, |
| 229 |
]; |
| 230 |
} |
| 231 |
|
| 232 |
private static function getContact($email) |
| 233 |
{ |
| 234 |
if (!self::isActive() || !$email) { |
| 235 |
return null; |
| 236 |
} |
| 237 |
|
| 238 |
return FluentCrmApi('contacts')->getContact($email) ?: null; |
| 239 |
} |
| 240 |
|
| 241 |
private static function taxonomyIds($contact, $type) |
| 242 |
{ |
| 243 |
$relation = $type === 'tags' ? $contact->tags : $contact->lists; |
| 244 |
|
| 245 |
return array_map('intval', $relation->pluck('id')->toArray()); |
| 246 |
} |
| 247 |
|
| 248 |
private static function taxonomyOptions($type, $search = '', $limit = 50) |
| 249 |
{ |
| 250 |
$model = self::taxonomyModel($type); |
| 251 |
$query = $model::orderBy('title', 'ASC'); |
| 252 |
|
| 253 |
$search = trim((string) $search); |
| 254 |
if ($search !== '') { |
| 255 |
$query->where('title', 'LIKE', '%' . addcslashes($search, '%_\\') . '%'); |
| 256 |
} |
| 257 |
|
| 258 |
return $query->limit($limit)->get()->map(function ($item) { |
| 259 |
return [ |
| 260 |
'id' => (int) $item->id, |
| 261 |
'title' => $item->title, |
| 262 |
]; |
| 263 |
})->toArray(); |
| 264 |
} |
| 265 |
|
| 266 |
private static function selectedOptions($contact, $type) |
| 267 |
{ |
| 268 |
$relation = $type === 'tags' ? $contact->tags : $contact->lists; |
| 269 |
|
| 270 |
return $relation->map(function ($item) { |
| 271 |
return [ |
| 272 |
'id' => (int) $item->id, |
| 273 |
'title' => $item->title, |
| 274 |
]; |
| 275 |
})->values()->toArray(); |
| 276 |
} |
| 277 |
|
| 278 |
private static function taxonomyModel($type) |
| 279 |
{ |
| 280 |
return $type === 'tags' |
| 281 |
? \FluentCrm\App\Models\Tag::class |
| 282 |
: \FluentCrm\App\Models\Lists::class; |
| 283 |
} |
| 284 |
} |
| 285 |
|