Skip to content

feat(latepoint): add LatePoint booking notifications - #145

Closed
sadmansakibnadvi wants to merge 21 commits into
WPDevelopers:masterfrom
sadmansakibnadvi:nadvi-dev/latepoint-integration
Closed

feat(latepoint): add LatePoint booking notifications#145
sadmansakibnadvi wants to merge 21 commits into
WPDevelopers:masterfrom
sadmansakibnadvi:nadvi-dev/latepoint-integration

Conversation

@sadmansakibnadvi

Copy link
Copy Markdown

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:

Decision The defect it defends
Buffered, order-anchored capture latepoint_booking_created fires 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 on latepoint_order_created and latepoint_order_updated (LatePoint fires the latter when a booking is added to an existing order), plus a shutdown fallback because the bundle-scheduling branch never reaches an order hook at all.
Delete-then-reinsert on every write nx_entries writes are INSERT-only with no unique constraint, so re-saving an entry_key duplicates rather than updates.
latepoint_booking_updated + local status diff latepoint_booking_change_status fires from exactly one admin form and is skipped by cabinet cancellations, email-link cancellations, bulk actions and change_booking_status().
Retract on will_be_deleted, not deleted Deleting an order fires the former for each booking but never the latter.
Daily reconciliation cron Deleting a customer cascades to their bookings with no booking-specific hook, and the abilities/MCP-AI layer creates, changes and deletes bookings firing nothing at all. Also retracts popups for a service hidden after capture.
Capture-time status allowlist LatePoint lets admins define arbitrary custom statuses, so anything unrecognised must not display. Defaults to Approved + Completed.
Hand-written DTO $booking->get_data_vars() serializes customer email, phone, free-text customer_comment, and manage_booking_* URLs — which are bearer credentials granting no-login view/reschedule/cancel. Never called anywhere in this integration.
entry_key namespaced latepoint_{id}, delete_notification() always given both args Works around #142 (the source predicate 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 on check_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
PASS | single booking -> exactly one popup
PASS | name masked to first + last initial
PASS | entry_key namespaced
PASS | service name captured
PASS | cart of 3 -> ONE popup not three
PASS | booking_count removed (unrenderable by design)
PASS | recurring x52 -> ONE popup not 52
PASS | pending booking is NOT shown
PASS | pending -> approved now appears
PASS | approved -> cancelled is retracted
PASS | double-fire of booking_updated -> still one row
PASS | order delete path (will_be_deleted) retracts
PASS | GDPR: silent customer-delete cascade cleaned by reconcile()
PASS | blank-name customer skipped, not blank popup
PASS | hidden service is NEVER advertised
PASS | midnight (00:00) booking renders
PASS | dedupe_hash removed (was never compared)
PASS | hide-service-name masks the title
PASS | toggle off leaves the real name
PASS | backfill respects display_last=5
PASS | PII: no manage_booking bearer URL stored
PASS | PII: no phone stored
PASS | PII: no customer_comment stored
PASS | FIX4 display_last=1 backfills exactly one (was zero)
PASS | FIX5 buffer not yet written before shutdown
PASS | FIX5 shutdown fallback flushes orphaned buffer
PASS | FIX9 buffered + updated + flushed in one request => ONE row
PASS | FIX3 hide-service-name suppresses the service image
PASS | FIX1 image filter preserves alt (a11y)
PASS | FIX8 link_type=none is respected

Live testing found a real bug that review had not: booking_link and mask_service_name were registered in init_fields(), which initialize() hooks to nx_before_metabox_load — fired only by GlobalFields::tabs() in the admin builder. Both are display-time filters, so on the public site the Hide Service Name toggle silently did nothing. Moved to public_actions(), matching WooCommerce.

Heads-up for a separate fix: SureCart has this same latent bugSureCart.php:55 registers nx_notification_link_surecart in init_fields(), so its product link never applies on the frontend.

Scope

~1,450 lines across 10 files. No React, no webpack build, no new notification Type, no DB migration.

GlobalFields.php is purely additive (three fields, all gated with Rules::includes('source', ['latepoint'])) — no existing line touched, so there is no regression path to other sources. composer.json and vendor/autoload.php are deliberately untouched; only the two classmap files change.

Related issues

Found while building this, all filed separately and not fixed here:

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

  • The two is_pro fields adjacent to the new ones in GlobalFields.php (surecart_order_status, fluentcart_order_status) are Pro-gated; the LatePoint fields deliberately are not.
  • Thumbnails are resolved at capture time and stored on the entry (as SureCart/FluentCart do). The tradeoff is that changing a service image needs a Regenerate to refresh existing entries — resolving at display time cost hundreds of uncached queries per pageview.
  • Existing campaigns default to link_type = none, so they will not link until an owner selects Booking Page in the Link Type dropdown (this PR registers that option via nx_link_types).

🤖 Generated with Claude Code

sadmansakibnadvi and others added 21 commits July 26, 2026 13:21
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant