feat(latepoint): add LatePoint booking notifications - #145
Closed
sadmansakibnadvi wants to merge 21 commits into
Closed
feat(latepoint): add LatePoint booking notifications#145sadmansakibnadvi wants to merge 21 commits into
sadmansakibnadvi wants to merge 21 commits into
Conversation
Skeleton only: identity, dependency probe, source error message, doc.
Uses class_exists('LatePoint') rather than LATEPOINT_VERSION, which is
only defined during init.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wlist Status is an allowlist, not a denylist: LatePoint lets admins define arbitrary custom statuses, so anything unrecognised must not display. Also rejects blank customer names and unparseable timestamps. Fields deliberately omit is_pro — surecart_order_status and fluentcart_order_status are Pro-gated and must not be copied verbatim into a free integration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Explicitly avoids get_data_vars(), which serializes customer email, phone, free-text comments, and manage_booking URLs that are bearer credentials granting no-login cancel access. Also guards the cases LatePoint gets wrong: related models come back empty rather than null, get_nice_created_at() fatals on PHP 8 for null created_at, and LATEPOINT_ANY_AGENT yields a literal placeholder name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
latepoint_booking_created fires once per booking with no cap, so a recurring or multi-item checkout would emit N popups for one customer. Buffer instead and flush a single notification on latepoint_order_created, which fires once per checkout after all booking events. Callbacks register at priority 20 so LatePoint core (10/12/15) has finished settling the booking row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eligible bookings Register flush_order() on latepoint_order_updated (priority 20, 1 arg) in addition to latepoint_order_created. LatePoint fires order_updated instead of order_created when an admin adds a booking to an EXISTING order, so without this hook the booking was buffered by buffer_booking() and then silently discarded at end of request — no notification was ever produced. Also fix booking_count in flush_order(): it previously counted every buffered booking, including ones build_entry_data() later rejected (hidden service, blank customer name, unparseable timestamp). A hidden service could therefore inflate a public "booked N sessions" claim and leak, in aggregate, that a deliberately hidden service was booked. The loop now counts only bookings that yield a valid DTO and uses the first eligible one as the announced entry; the buffer is still cleared before any work starts so a mid-flush failure can't replay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uses latepoint_booking_updated, not latepoint_booking_change_status — the latter fires from a single admin form and is skipped by cabinet cancellations, email-link cancellations, bulk actions and change_booking_status(). Retracts on will_be_deleted rather than deleted, because deleting an order fires the former for each booking but never the latter. Updates delete-then-reinsert: nx_entries writes are INSERT-only with no unique constraint, so re-saving an entry_key duplicates it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LatePoint deletes bookings through several paths that fire no usable hook: deleting a customer cascades to their bookings silently, deleting an order never fires booking_deleted, and the abilities/MCP layer fires nothing at all. Without this job an erased customer's name would keep appearing in public social-proof popups indefinitely, so this is a correctness and compliance requirement rather than a safety net. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ect docblock
reconcile() was only ever hooked to nx_latepoint_reconcile from
admin_actions()/public_actions(), which initialize() only calls when
is_active(false) is true — requiring class_exists('LatePoint'). Once
LatePoint is deactivated, nothing re-hooks the action, so the fired cron
event invokes nothing and the recurring event lingers in the cron option
forever; the "! class_exists()" self-unschedule guard was unreachable
dead code.
Move registration to __construct(), gated on Modules::is_enabled($this->module)
instead of is_active() — this mirrors initialize()'s own module-enabled
gate without depending on LatePoint being active, so reconcile() (and its
class_exists() guard) stays reachable for as long as the module is on. The
three previous registration call-sites collapse into this one; the
nx_can_entry_latepoint filter in admin_actions() is untouched.
Also corrects reconcile()'s docblock: order deletion does fire
latepoint_booking_will_be_deleted per booking (already handled in
realtime by handle_booking_deleted()), so it was never actually one of
the gaps reconciliation closes. Replaced with the genuine gaps: customer
deletion cascading with no hooks, the abilities/MCP-AI layer deleting
directly with no hooks, and a general status-drift backstop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only uses the customer avatar when it is a genuine upload: get_avatar_url() never returns empty, so an unguarded call would render LatePoint's bundled stock silhouette on every notification. Links to an owner-configured booking page because LatePoint services are custom-table rows with no permalink. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bounded by display_last, following SureCart and FluentCart rather than Tutor's unbounded -1, which would pull every booking row only for Limiter to truncate it non-deterministically at cache_limit. Defining get_notification_ready() also enables the builder's regenerate button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…arity
notification_image() documented a customer-avatar fallback tier that was
never implemented (docblock overstated the code). Added it: derive the
customer from $data['booking_id'] via OsBookingModel (DTO carries no
customer id), guard against LatePoint's empty-model pattern, and reject
its bundled public/images/default-avatar.jpg stock avatar so a real
default->gravatar chain still runs downstream in FrontEnd::get_image_url().
init_fields() registered nx_filtered_entry_{id} with arg count 3, but
FrontEnd.php only ever dispatches 2 (`$entry, $settings`) - every sibling
extension registers with 10, 2. Fixed the registration and simplified
mask_service_name()'s signature to match, without changing its masking
behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…min-only init_fields() is hooked to nx_before_metabox_load (see Extension::initialize() line 98), which is fired solely by GlobalFields::tabs() while building the admin builder's field schema — it never fires on the public site. Registering nx_notification_link_latepoint and nx_filtered_entry_latepoint there left both dead on the frontend. Confirmed by live testing against a real WordPress + LatePoint install: a booking for "Deep Tissue Massage" still showed the real service name in the public popup with "Hide Service Name" enabled, and the configured booking-page link was never applied. Moved both filters into a new public_actions() (calling parent::public_actions() first, per WooCommerce's sibling pattern, so notification_image()/fallback_data() auto-wiring is preserved), which runs on every request once the module is active. nx_latepoint_booking_status stays in init_fields() — it only populates an admin dropdown, so admin-only is correct there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds docs/extensions/latepoint.md from _TEMPLATE.md, a row in the extensions README, and the new cron action + builder filter in docs/api/hooks-filters.md. The doc records why the design is defensive rather than leaving it to be rediscovered: buffered order-anchored capture (booking_created is uncapped and fires per occurrence for recurring bookings), the delete-then-reinsert update path (nx_entries writes are INSERT-only), retraction on will_be_deleted rather than deleted, and the daily reconciliation cron that covers LatePoint's hookless customer-delete cascade and abilities/MCP-AI layer. It also warns against get_data_vars(), which serializes customer email, phone, free-text comments and manage_booking bearer URLs, and records the lifecycle gotcha that display-time filters must be registered in public_actions() rather than the admin-only init_fields(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…den services
schedule_reconciliation() both hooked the handler and (re)created the event,
unconditionally, on every pageload where the module is enabled. With LatePoint
uninstalled that made reconcile()'s wp_clear_scheduled_hook() pointless: the
cron fired, cleared itself, and the next pageload scheduled it again, forever.
Register the handler unconditionally as before, but only schedule the event
while class_exists('LatePoint').
reconcile() also re-checked existence, status and customer but never service
visibility, so an admin hiding a service to stop advertising it had no effect
on the popups already advertising it. Apply the same is_hidden() test the
capture path in build_entry_data() uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OsModel::get_results_as_models() un-arrays its own return when the limit is 1
(lib/models/model.php: `if ( $this->limit == 1 && isset( $models[0] ) ) {
$models = $models[0]; }`), returning a bare model rather than a one-element
array. get_bookings()'s `is_array( $results ) ? $results : []` therefore threw
the row away, and a campaign configured to show a single notification
backfilled nothing at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rites idempotent LatePoint's bundle-scheduling branch in steps_helper::prepare_step_confirmation() fires latepoint_booking_created (lines 2379 and 2400) and then returns at 2409 without ever reaching convert_to_order(), so neither latepoint_order_created nor latepoint_order_updated fires. The request buffer filled and was thrown away. Register flush_order() on shutdown as a fallback; the existing empty-buffer guard keeps it a no-op on every normal request, and flush_order() empties the buffer before writing, so it cannot double-write after an order flush has already run. That fallback needs the write path to be genuinely idempotent, so move the delete-then-reinsert out of handle_booking_updated() and into store_entry() itself. The $written same-request guard it replaces was worse than useless: retract_booking() unset the key immediately before store_entry() checked it, and when booking_updated fired before order_created in a cart checkout it suppressed the buffered write outright. Deleting before inserting is what actually prevents duplicates, since nx_entries writes are INSERT-only with no unique constraint. flush_order() also stops at the first eligible booking rather than walking the whole buffer to compute booking_count, which cost ~3 queries per buffered booking for a value FrontEnd::filtered_data() can never forward to the browser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r image settings notification_image() returned a freshly built array, which threw away the 'alt' (the customer name) and 'classes' that FrontEnd::get_image_url() had already populated before firing the filter — so the rendered <img> had no alt attribute at all (see nxdev/notificationx/frontend/themes/helpers/Image.js). Mutate and return $image_data the way FluentCart, SureCart and WooCommerce do, and drop the meaningless 'id' => 0. It also ignored "Show Default Image": a site owner who picked one image for every popup had it silently overridden by the service image on every entry. Worse, it did the lookup on the read path. The filter runs once per entry (FrontEnd.php:344) over up to cache_limit (100) rows and BEFORE display_last slicing, and each call instantiated \OsServiceModel plus, often, \OsBookingModel and ->customer — LatePoint's models have no caching, so a single frontend pageview could issue ~300 uncached queries. Resolve both candidate URLs once in build_entry_data(), where the service and customer models are already loaded, and store them on the entry; notification_image() now only reads them. SureCart and FluentCart store the URL on the entry for exactly this reason. Finally, "Hide Service Name" now suppresses the service IMAGE too. It fires on nx_filtered_entry_latepoint (FrontEnd.php:352), after get_image_url() has already run, and notification_image() never consulted it — so a clinic that enabled the toggle still published the service's own artwork, which identifies it just as plainly as its name. The toggle now falls back to the customer avatar instead. The payload drops 'attendees', 'agent_name' and 'dedupe_hash' while it is being rebuilt: FrontEnd::filtered_data() (FrontEnd.php:874-886) only forwards keys named by the campaign's notification template, and no Conversions template offers any of them, so none could ever render. agent_name additionally cost a query per booking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FrontEnd::link_url() blanks $link when link_type is 'none' (FrontEnd.php:769-771) and only then applies nx_notification_link_latepoint. booking_link() restored the booking-page URL unconditionally, so the popup linked regardless of what the campaign had chosen. Gate it on link_type the way SureCart::product_link() does. That alone would make the URL unreachable, because LatePoint never registered an nx_link_types option: the builder's Link Type dropdown offered only "None" while the popup linked anyway. Register a 'booking_page' option, following SureCart::link_types(), so the setting is actually selectable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the default status list dedupe_hash() claimed to dedupe the payment-gateway/webhook race by appointment identity, but its value was never compared against anything — the feature it documented did not exist. FrontEnd::filtered_data() could not forward it to the browser either, and its raw-column fallback was unreachable because OsBookingModel::get_start_datetime() is typed `: OsWpDateTime` and never returns null. Delete it rather than ship a comment describing a feature that isn't there. Deduplication is now genuinely provided by store_entry()'s delete-then-reinsert. The $written property has no remaining users after that change, so remove it. The ['approved','completed'] fallback was duplicated in four places. Extract LatePointConversions::DEFAULT_STATUSES for the three PHP spots; the GlobalFields field 'default' stays a literal (it is builder schema, not runtime logic) with a comment pointing at the constant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the code This is a public repo, so comments that assert things about upstream behaviour have to be right. - $class: the claim that LATEPOINT_VERSION is unsafe as a probe is false — it is defined by define_constants(), called from LatePoint::__construct() at latepoint.php:38. State the real reason instead: 'LatePoint' is the main class and class_exists() is the convention every other extension here uses. - handle_booking_updated(): the docblock had WP hook semantics backwards. add_action(..., 2) means this callback receives exactly 2 args no matter what other listeners request, and all five fire sites pass 2 — so drop the unreachable $initiated_by parameter along with the wrong explanation. - parse_created_at(): get_nice_created_at() fatals because it calls setTimezone() on an unchecked date_create_from_format() return, on any PHP version, not specifically PHP 8. Name the actual mechanism, and note that returning false is what lets the caller reject the row. - check_booking_eligibility(): the "LatePoint substitutes now" rationale did not match the code below it — a substituted "now" passes a future-timestamp check. Describe what the two tests actually reject. - docs/extensions/latepoint.md: bring the data-flow, cron, fields, privacy, detection and gotchas sections in line with the fixes in this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
check_booking_eligibility() ships as the gate that stops cancelled bookings and hidden services from reaching the public site with zero repeatable coverage. Add tests/test-latepoint.php covering it plus the other pure/near-pure logic in LatePointConversions (mask_service_name, booking_link, parse_created_at, build_entry_data via stdClass stubs, class_exists() guard clauses, and registration invariants including the free/non-Pro flag) — none of it needs the LatePoint plugin installed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
LatePoint (wordpress.org, 10k+ installs) as a free realtime notification source feeding the existing
conversions(Sales) Type. Confirmed appointments become social-proof popups — "Sarah C. just booked a Deep Tissue Massage".This is NotificationX's first booking-plugin integration — there is currently no Amelia, Bookly or LatePoint source. Requested by a client using both plugins.
Free rather than Pro-gated: LatePoint is self-hosted with local hooks, so there are no API keys or SaaS account to gate. Same shape as Tutor LMS, GiveWP, SureCart and FluentCart.
Why it looks more defensive than a typical integration
LatePoint's hook coverage is incomplete and its creation path is racy, so correctness has to be defended rather than assumed. Every decision below exists for a verified upstream defect, not on principle:
latepoint_booking_createdfires once per booking with no cap — a weekly-for-a-year recurrence is ~52 events in one request, a cart one per line item. Writing there would emit N popups for one customer. Flushed once onlatepoint_order_createdandlatepoint_order_updated(LatePoint fires the latter when a booking is added to an existing order), plus ashutdownfallback because the bundle-scheduling branch never reaches an order hook at all.nx_entrieswrites are INSERT-only with no unique constraint, so re-saving anentry_keyduplicates rather than updates.latepoint_booking_updated+ local status difflatepoint_booking_change_statusfires from exactly one admin form and is skipped by cabinet cancellations, email-link cancellations, bulk actions andchange_booking_status().will_be_deleted, notdeleted$booking->get_data_vars()serializes customer email, phone, free-textcustomer_comment, andmanage_booking_*URLs — which are bearer credentials granting no-login view/reschedule/cancel. Never called anywhere in this integration.entry_keynamespacedlatepoint_{id},delete_notification()always given both argssourcepredicate is commented out, so a bare entry_key deletes other sources' rows).Privacy
Appointments are materially more sensitive than purchases — clinics, legal, counselling. LatePoint never exposes customer names publicly, so this popup is the first place that data becomes public. Names are masked to first name + last initial; a Hide Service Name toggle degrades the popup to "just booked an appointment" and suppresses the service image; hidden/inactive services and blank-name bookings are excluded outright; and the reconciliation cron honours erasure.
Testing
Automated:
tests/test-latepoint.php— the suite goes 25 → 64 tests (843 → 891 assertions), all passing. Focused oncheck_booking_eligibility(), the gate that stops cancelled bookings and hidden services reaching a public page, including an explicit proof that it is an allowlist rather than a denylist. Two tests are skipped with stated reasons (they genuinely need the LatePoint plugin).Live: 30 scenarios run against a real WordPress + LatePoint 5.6.9 install, 30/30 passing:
Scenario results
Live testing found a real bug that review had not:
booking_linkandmask_service_namewere registered ininit_fields(), whichinitialize()hooks tonx_before_metabox_load— fired only byGlobalFields::tabs()in the admin builder. Both are display-time filters, so on the public site the Hide Service Name toggle silently did nothing. Moved topublic_actions(), matching WooCommerce.Scope
~1,450 lines across 10 files. No React, no webpack build, no new notification Type, no DB migration.
GlobalFields.phpis purely additive (three fields, all gated withRules::includes('source', ['latepoint'])) — no existing line touched, so there is no regression path to other sources.composer.jsonandvendor/autoload.phpare deliberately untouched; only the two classmap files change.Related issues
Found while building this, all filed separately and not fixed here:
Extension::delete_notification()deletes across all sources (thesourcepredicate is commented out). This PR works around it; the workaround can be simplified once Bug: Extension::delete_notification() deletes entries across all sources (source predicate commented out) #142 lands.nx_entries. Affects every source; one exporter/eraser pair keyed ondata.emailwould cover them all.Entries::count()memo is not keyed by$source/$col, soLimiter'scache_limitsilently fails to apply.Four defects in LatePoint 5.6.9 itself are being reported to that vendor privately rather than linked here, as one is an unauthenticated write path.
Reviewer notes
is_profields adjacent to the new ones inGlobalFields.php(surecart_order_status,fluentcart_order_status) are Pro-gated; the LatePoint fields deliberately are not.link_type = none, so they will not link until an owner selects Booking Page in the Link Type dropdown (this PR registers that option vianx_link_types).🤖 Generated with Claude Code