Skip to content

iClosed-style lead capture + intl-tel-input phone fields#136

Open
huntervcx wants to merge 12 commits into
olivierlambert:mainfrom
DYB-Corp:feat/lead-capture
Open

iClosed-style lead capture + intl-tel-input phone fields#136
huntervcx wants to merge 12 commits into
olivierlambert:mainfrom
DYB-Corp:feat/lead-capture

Conversation

@huntervcx

@huntervcx huntervcx commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

What's inside

  • Lead capture (iClosed-style). When a guest types into a public booking form, every keystroke is debounced-POSTed to /api/lead-capture and upserted into partial_bookings. If they never submit, the host sees the abandoned lead on /dashboard/leads (worklist with contacted/archive actions, UTM + referrer attribution, conversion / abandonment / completed counts). Gated by an admin-wide kill switch plus a per-event-type opt-in that requires the host to acknowledge once that bookers are informed. Configurable auto-purge window (default 30 days; RGPD reminder banner above 90).

  • A blurred-calendar gate on the slots page mirrors the iClosed flow — name/email/phone are collected before the available times are revealed.

  • Footer-only RGPD notice. The data-handling notice now lives at the bottom of public booking pages, next to "powered by calrs". Admins can replace it with a single link to their own legal-mentions page via a new admin field.

  • Phone collection — three states + intl-tel-input. collect_phone becomes off / optional / required per event type. Required is enforced server-side. We vendor intl-tel-input@25.3.1 (MIT) into assets/intl-tel-input/ and serve it from same-origin /static/intl-tel-input/ (zero external calls — no geo-IP lookup, region detected from navigator.language with FR fallback). The phone fields on both the booking form and the lead-capture gate now ship with a flag picker, format-as-you-type, and libphonenumber validation on submit. Captured guest_phone is included in the calendar event DESCRIPTION and host notification emails.

Code structure : Lead-capture HTTP layer is in src/web/leads.rs (~580 lines), following the existing captcha.rs pattern.
additionnal library : intl-tel-input 25.3.1 (MIT), vendored in assets/intl-tel-input/ (7 files, ~408KB) — JS, CSS, libphonenumber utils.js, sprites webp flags + globe.

What is still rough and need polishing :

  • Translations: new strings ship in en/main.ftl only. FR / PL / ES will fall back to English at runtime until the i18n branch catches up.
  • RGPD nuance: the upsert COALESCEs name/email/phone/notes so a guest who types then clears a field keeps the captured value. UX-wise this matches iClosed, but it's a defensible choice rather than an obvious one — flagging it so we agree on the trade-off.
  • Booking handlers duplication: the four booking handlers (handle_booking, handle_booking_for_user, handle_group_booking, handle_dynamic_group_booking) duplicate ~1670 lines of near-identical flow. Lead capture made it more visible — the mark_completed call and the phone validation had to be replicated four times. A BookingFlow refactor is out of scope for this branch; tracked separately.
  • Tests at the HTTP layer for capture/required-phone gate: today only handle_booking has explicit coverage of collect_phone=2; the other three share the path but the test suite doesn't pin it.
  • No webhook outbound (lead.created/updated/converted/abandoned) yet — would be the next obvious iClosed parity step for plugging into a CRM.
  • No source-aggregation view: UTM is captured but the dashboard doesn't show per-source conversion

Not perfect, but functionality is there to test and get reviews / ideas

Summary by CodeRabbit

Release Notes

  • New Features
    • Lead capture system to track incomplete booking attempts and abandoned guests
    • Guest phone number collection with international validation on booking pages
    • Host dashboard to manage captured leads with contact and archive actions
    • Admin controls for lead retention policies and legal information URLs
    • Automated email alerts when guests abandon the booking process
    • Per-event lead capture settings with host acknowledgement gates

Arthur Perrot and others added 11 commits June 5, 2026 18:11
Capture name/email/notes that guests type into the public booking form
before they submit, so hosts can follow up on abandoned bookings. The
feature is gated three ways:

  1. Global admin toggle on auth_config (off by default).
  2. Per-event-type opt-in (off by default).
  3. RGPD: a "have you informed bookers?" acknowledgement is required
     to flip the per-event-type toggle on, and a notice is rendered on
     the booking form whenever capture is active.

Captured rows live in a new partial_bookings table, are upserted on
each debounced keystroke from the public booking form, and are
auto-purged after a configurable retention window (default 30 days,
clamped to 1..=365) by a background task in calrs serve. Successful
bookings flip the matching row to completed so the dashboard only
shows actually-abandoned leads.

Backend layout:
  * migrations/056_lead_capture.sql — partial_bookings table plus the
    two gating columns on event_types and auth_config.
  * src/leads/ — config (gating), db (CRUD + purge), shared types.
  * src/web/mod.rs — /api/lead-capture POST endpoint (rate-limited
    per IP), /dashboard/leads page, admin global toggle and per-event
    toggle handlers, render-time context wiring for all four booking
    form variants (personal, team, dynamic-group, reschedule).

Frontend:
  * templates/book.html — debounced fetch() of the form fields to
    /api/lead-capture under a sessionStorage lead_id, plus the RGPD
    notice block.
  * templates/dashboard_leads.html — listing of recent abandoned
    leads scoped to the current host (admin sees all).
  * templates/admin.html — global toggle + retention input.
  * templates/event_type_form.html — per-event-type toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iClosed-style: when lead capture is active for an event type, show a
small name+email form *over* a blurred slot grid and only reveal the
calendar after the guest reveals who they are. Same admin/per-event
gating as the booking-form capture — the global RGPD off-switch on
auth_config disables both at once.

* templates/slots.html — overlay HTML, CSS (filter: blur(8px) +
  pointer-events: none on .slots-main when the gate is active) and
  the small fetch() script that POSTs name/email to /api/lead-capture
  using the existing sessionStorage lead_id. The gate also captures
  on blur so a guest who types and then abandons is still recorded.
  Per-event sessionStorage key avoids re-prompting on reload.
* templates/book.html — read sessionStorage handoff (calrs_lead_name,
  calrs_lead_email) to pre-fill the booking form so the guest doesn't
  retype what they just gave the gate.
* src/web/mod.rs — wire (lc_active, lc_retention) into the four
  slot-page renders (team, dynamic-group, user, legacy). Reschedule
  flow intentionally skipped: the guest already booked once.
* i18n — slots-lead-gate-title/subtitle/button (EN + FR).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The lead-capture fetch() in both the slots gate and the booking form
read the CSRF token from a cookie named `calrs_csrf`, but main renamed
it to `__Host-calrs_csrf` (security hardening). The regex never matched
the `__Host-` prefix, so the token was empty, every POST to
/api/lead-capture was rejected by the CSRF check, and no lead was ever
recorded. Align both reads with the name used everywhere else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… UTM, worklist

Builds on the iClosed-style lead capture with the follow-up features that
make captured leads actionable. Migration 057 adds the backing columns
(guest_phone on bookings, collect_phone on event_types, and utm_*/
referrer/contacted_at/archived_at/notified_at on partial_bookings).

Team visibility (olivierlambert#1): a team event type's leads are now visible to every
member of its team, not just the creator. list_recent_for_user and
stats_for_user resolve scope as "my own leads OR leads on event types in
teams I belong to"; worklist actions gate on the same access check.

Guest phone (olivierlambert#2): calrs had no concept of a guest phone. Event types can
opt in ("Ask for a phone number"), which adds an optional phone field to
the booking form and the lead-capture gate, stores it on the booking, and
shows it on the leads dashboard. Wired through all four public booking
inserts and both event-type edit forms.

Abandonment alerts (olivierlambert#3): a 5-minute background pass emails the host when a
lead has sat untouched for 30 min without completing (once per lead, via
notified_at; capped to leads <48h old to avoid backlog spam). Respects the
global RGPD off-switch and no-ops without SMTP.

Conversion stats (olivierlambert#4): started / completed / open / conversion-% tiles on
the leads dashboard, scoped like the list.

UTM + referrer (olivierlambert#5): the gate and booking-form capture now send utm_*
query params and document.referrer; stored (COALESCE'd so the first value
sticks) and surfaced as a "Source" column.

Worklist (olivierlambert#6): mark-contacted (toggle) and archive actions per lead, with
CSRF + ownership checks; archived leads drop out of the default view.

PartialBooking moves from a 14-field tuple to a FromRow struct now that
the row has 20 columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notice: replace the prominent lead-capture notice paragraph with a
layered (collapsed <details>) notice — a small one-line "How your details
are used" summary that expands to the full text, with an optional "Learn
more" link to the admin's company URL. This is the GDPR-recommended
layered-notice pattern: transparency is preserved (the information is one
click away at the point of data entry) while the page stays uncluttered.
Shared styling lives in base.html; applied to both the booking form and
the slots gate.

Tests: cover the follow-up features end-to-end.
  * leads/db: stats counting, archived/completed exclusion from the list,
    owner-vs-team-vs-stranger access control, contacted toggle, team
    member visibility of a team event type's lead, and the abandonment
    notification window (due / too-fresh / too-old / already-notified).
  * web: worklist archive is 403 for a non-owner and works for an admin,
    plus dashboard-leads and public-slots gate render smoke tests.

Test harness fix: setup_test_db used `sqlite::memory:` with a multi-
connection pool, which gives each connection its own empty database —
migrations land on one connection and later queries flake on another.
Switch to a per-test shared-cache named memory DB so every pooled
connection sees the same schema. Removes latent flakiness for the whole
web test suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	i18n/en/main.ftl
#	i18n/fr/main.ftl
#	src/db.rs
#	src/web/mod.rs
#	templates/admin.html
#	templates/book.html
…one obligation, persist on blank

- Lead capture toggle moves from a standalone POST into the main event-type form's "Save changes". The "I have informed bookers" checkbox is replaced by a confirm() dialog on enable and a persisted lead_capture_acknowledged_at; old toggle route kept as a fallback.
- New legal_mentions_url admin field. When set, the public booking pages replace the verbose data-handling notice with a single link in the footer (next to "powered by calrs"); when empty, the notice itself moves to the footer and is only shown while capture is active.
- collect_phone becomes a 0/1/2 select (off / optional / required). Server-side validation refuses bookings missing a required phone; guest_phone is included in ICS DESCRIPTION and host notification emails.
- Lead-gate email field on slots.html gets the same TLD pattern + custom-validity script as the booking form (fix(book) olivierlambert#129 didn't cover the gate, so "arthur@perrot" passed through).
- partial_bookings upsert COALESCEs name/email/phone/notes so a guest who types then clears a field keeps the captured value (matches the iClosed UX expectation that abandoned data is sticky).
- Admin "Lead capture" panel relabels retention as "Auto-purge captured leads after" and shows the RGPD reminder only when capture is enabled and retention > 90 days.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move all lead-capture HTTP handlers, forms, helpers and the background
  purge/notifier loop from src/web/mod.rs (~580 lines) to src/web/leads.rs.
  Phone validation helpers stay in mod.rs since they cross-cut the booking
  flow, not lead capture. `pub use leads::run_lead_purge_loop` preserves
  the path main.rs imports.
- Promote impersonation_ctx, sidebar_context, CsrfForm to pub(crate) so
  leads.rs can call them.
- Drop the now-dead POST /dashboard/event-types/{slug}/lead-capture route
  + toggle_event_type_lead_capture handler + LeadCaptureToggleForm struct.
  The toggle is part of the main event-type form since phase 2.
- Fix phone validation to count chars instead of bytes so a 64-char phone
  with non-ASCII formatting marks isn't rejected by the server while
  passing the HTML `maxlength=64` attribute on the client.
- Replace the chars().take(10) hack on lead_capture_acknowledged_at with
  a proper NaiveDateTime parse + format, falling back to the raw value if
  the column ever stores a non-SQLite-default format.
- Add Fluent keys for every new English string introduced in phase 2
  (event-type-form-phone-*, event-type-form-lead-capture-*,
  admin-legal-*, admin-lead-*). EN only; other locales fall back to EN at
  runtime per the existing i18n contract.
- Document the BookForm/4-handler duplication as a standalone plan in
  notes/booking-handler-duplication.md, with the test invariants a
  unified BookingFlow refactor would unlock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vendors intl-tel-input 25.3.1 (MIT) into assets/intl-tel-input/ and serves
it from same-origin /static/intl-tel-input/. Zero external calls at
runtime — the JS, CSS, flags sprite and globe icon are baked into the
binary via include_bytes!/include_str!, and the geo-IP lookup is replaced
with a navigator.language hint that defaults to FR.

book.html and the slots.html lead-gate now wrap their phone inputs in
intl-tel-input when collect_phone > 0: a flag dropdown, format-as-you-type,
and isValidNumber() check on submit. The visible input only carries the
national-format number; a sibling hidden input named "phone" is populated
with the E.164 form (iti.getNumber()), preserving the existing server
contract — validate_phone_input and the bookings.guest_phone column still
see a single phone string, just better-shaped.

CSS url(...) references in the vendored file were rewritten to flat paths
so all seven assets live under one route prefix. Adds the en-only Fluent
key book-phone-invalid for the client-side error caption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dfe5ad01-de57-4448-85ac-40018ebc6dde

📥 Commits

Reviewing files that changed from the base of the PR and between 8acda41 and 66f4bb3.

📒 Files selected for processing (6)
  • CLAUDE.md
  • src/leads/db.rs
  • src/web/leads.rs
  • src/web/mod.rs
  • templates/book.html
  • templates/slots.html
🚧 Files skipped from review as they are similar to previous changes (3)
  • templates/book.html
  • src/leads/db.rs
  • src/web/mod.rs

📝 Walkthrough

Walkthrough

This PR implements a complete lead-capture feature for incomplete booking attempts. It adds a partial_bookings database table with incremental guest data capture, optional phone collection with international format support, admin configuration for global enablement and retention policies, host dashboards for viewing abandoned leads, and background jobs that periodically purge expired leads and email hosts about incomplete bookings.

Changes

Lead Capture & Abandoned Booking Notifications

Layer / File(s) Summary
Database Schema: Lead Capture Tables & Configuration
migrations/056_lead_capture.sql, migrations/057_lead_followups.sql, migrations/058_lead_capture_legal_and_phone.sql, src/db.rs
Three migrations add partial_bookings table with lead data, timestamps, and attribution; per-event-type lead_capture flag; global lead_capture_enabled and lead_retention_days in auth_config; guest_phone in bookings; collect_phone in event_types; and legal mentions URL. Migration registration counts updated to 59.
Lead Configuration & Settings Management
src/leads/config.rs
Global lead-capture settings module provides async helpers to read/write enablement and retention from auth_config, evaluate per-event-type capture, and compute combined active state for feature gating.
Lead Data Models & Persistence
src/leads/db.rs, src/leads/mod.rs
PartialBookingInput and PartialBooking types with 13 core operations: upsert (with field preservation and truncation), completion marking, expiry purging, dashboard/stats queries, authorization, state toggles, and notification queueing; comprehensive test suite. Public limits module defines validation caps.
Email Enhancement & Abandoned Alerts
src/email.rs
BookingDetails gains optional guest_phone. Host notifications include phone in both plain and HTML bodies. New send_lead_abandoned_alert() sends email to hosts about incomplete bookings with guest details and optional "View leads" link.
HTTP Handlers: Lead Capture API & Admin Configuration
src/web/leads.rs
Public POST /api/lead-capture endpoint validates CSRF, rate-limits by client IP, resolves event type, applies global/per-event capture gates, and upserts partial bookings. Admin handlers manage legal-mentions URL and lead-capture settings. Dashboard displays leads list with stats and access control. Worklist actions toggle contacted status and archive. Background run_lead_purge_loop periodically purges expired leads and sends abandonment alerts.
Application Startup & Routing
src/main.rs, src/web/mod.rs
Loads initial legal-mentions URL at startup; initializes AppState with lead_capture_limiter and legal URL lock; spawns background purge task. Routes admin legal-mentions endpoint, public lead-capture API, dashboard leads routes, and embedded intl-tel-input asset routes.
Booking Form & Phone Handling
src/web/mod.rs (multiple sections)
Phone normalization helper caps to 64 chars. Booking form structs add optional phone and lead_id fields. Phone validation and normalization integrated across booking submission paths; marks leads completed when linked via lead_id. Event-type configuration adds phone collection level (0/1/2) with validation helpers and applies lead-capture toggle gates. Template context injection loads capture state and phone settings.
Test Infrastructure
src/web/mod.rs (test section)
Test DB setup uses shared-cache named in-memory SQLite with unique per-test names. Adds end-to-end template smoke tests for lead-capture dashboard, public gating, and archive authorization.
Admin Templates: Legal & Lead Capture Configuration
templates/admin.html
New "Legal" card with form to update legal-mentions URL. New "Lead capture" card with enable checkbox, retention-days input, and RGPD warning when enabled and retention exceeds 90 days.
Base Template: Footer & Lead Notice
templates/base.html
Footer conditionally renders lead-capture notice when enabled: direct legal link or expandable details/summary with retention text and company link. Adds CSS styling for expandable notice.
Dashboard Navigation
templates/dashboard_base.html
Adds "Leads" sidebar link under Scheduling section with active styling.
Leads Dashboard Page
templates/dashboard_leads.html
New page displays abandoned booking stats (started/completed/abandoned/conversion %), leads table with guest/contact/source info, inline actions to toggle contacted status and archive, and empty-state messaging.
Booking Form: Phone & Lead Capture
templates/book.html
Form gains id="book-form" and hidden lead_id field when capture is active. Conditional phone input with intl-tel-input when collect_phone enabled. Prefill script restores from sessionStorage. Lead-capture script generates session-based lead ID, debounces POST requests on input/blur with CSRF and attribution, flushes on form submit.
Event Type Form: Phone & Lead Capture Controls
templates/event_type_form.html
Adds "Collect phone" selector in Booking options (0/1/2 levels). Adds "Lead capture" card when editing with enable checkbox, hidden acknowledgement field, and timestamp display. Submission gate requires user confirmation when enabling capture.
Slots Calendar: Lead Gate Overlay
templates/slots.html
Adds CSS for lead-capture overlay (blur, pointer-events lock, responsive positioning). Conditionally renders overlay form collecting name/email with custom validation, optional phone with intl-tel-input. Client-side logic manages sessionStorage state, blur-triggered partial capture, final submit with timezone and UTM/referrer attribution, and prefill restoration.
Frontend Assets
assets/intl-tel-input/intlTelInput.min.css
Minified CSS stylesheet for intl-tel-input widget with layout, dropdown/search styles, country flag sprites, and high-DPI media query.
Documentation
CLAUDE.md
Updates migrations catalog to document three new migrations and their schema additions.

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A rabbit hops through forms today,
Capturing leads along the way,
From slots to bookings, step by step,
No guest info will be left unkept!
With phone and email, safely stored,
Abandoned bookings are explored. 🔔

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "iClosed-style lead capture + intl-tel-input phone fields" clearly and concisely summarizes the two main changes: implementing lead capture functionality and adding international phone input UI support.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ast-grep (0.43.0)
src/web/mod.rs

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🧹 Nitpick comments (1)
src/leads/config.rs (1)

44-71: 💤 Low value

Optional: Eliminate TOCTOU race with SQLite upsert.

The UPDATE-or-INSERT pattern (check existence at lines 51-53, then branch) has a time-of-check to time-of-use race. Two concurrent admin requests could both see "no row exists" and both attempt INSERT, causing the second to fail with a uniqueness violation.

Practical risk: Low. auth_config is a singleton updated only from the admin panel, so concurrent writes are rare.

Cleaner alternative: SQLite supports INSERT ... ON CONFLICT DO UPDATE (same pattern already used in src/leads/db.rs line 94-117 for upsert_partial), which is atomic and removes the extra round-trip.

♻️ Refactor: use SQLite upsert for atomicity
 pub async fn set_global_settings(
     pool: &SqlitePool,
     enabled: bool,
     retention_days: i64,
 ) -> Result<()> {
     let retention_days = retention_days.clamp(1, 365);
-    // auth_config is a singleton — UPDATE if a row exists, otherwise INSERT.
-    let existing: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM auth_config LIMIT 1")
-        .fetch_optional(pool)
-        .await?;
-    if existing.is_some() {
-        sqlx::query("UPDATE auth_config SET lead_capture_enabled = ?, lead_retention_days = ?")
-            .bind(enabled as i64)
-            .bind(retention_days)
-            .execute(pool)
-            .await?;
-    } else {
-        sqlx::query(
-            "INSERT INTO auth_config (lead_capture_enabled, lead_retention_days)
-             VALUES (?, ?)",
-        )
-        .bind(enabled as i64)
-        .bind(retention_days)
-        .execute(pool)
-        .await?;
-    }
+    sqlx::query(
+        "INSERT INTO auth_config (id, lead_capture_enabled, lead_retention_days)
+         VALUES ('singleton', ?, ?)
+         ON CONFLICT(id) DO UPDATE SET
+           lead_capture_enabled = excluded.lead_capture_enabled,
+           lead_retention_days = excluded.lead_retention_days",
+    )
+    .bind(enabled as i64)
+    .bind(retention_days)
+    .execute(pool)
+    .await?;
     Ok(())
 }

(Note: Assumes auth_config.id is the PK. Adjust ON CONFLICT clause to match the actual unique constraint.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/leads/config.rs` around lines 44 - 71, The set_global_settings function
currently does a SELECT then UPDATE/INSERT which risks a TOCTOU race; replace
that pattern with a single atomic SQLite upsert using INSERT ... ON CONFLICT DO
UPDATE (similar to upsert_partial in src/leads/db.rs) so you no longer check
`existing`—construct an INSERT into auth_config (lead_capture_enabled,
lead_retention_days[, id if needed]) with an ON CONFLICT (id or the actual
unique key) DO UPDATE SET lead_capture_enabled = excluded.lead_capture_enabled,
lead_retention_days = excluded.lead_retention_days and execute that via
sqlx::query(...).execute(pool).await?; keep the retention_days.clamp and binding
of values but remove the SELECT and branching logic in set_global_settings.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@assets/intl-tel-input/intlTelInput.min.css`:
- Line 1: The .iti__a11y-text rule uses the deprecated clip property; update the
visually-hidden helper to use modern clip-path-based hiding by removing
clip:rect(...) and replacing it with clip-path: inset(0 0 0 0) (or equivalent
inset values) while keeping
width:1px;height:1px;overflow:hidden;position:absolute; (add white-space:nowrap
if needed for screen-reader consistency); locate and update the .iti__a11y-text
selector in the stylesheet to use clip-path instead of clip so Stylelint
warnings are resolved and the behavior remains the same.

In `@CLAUDE.md`:
- Around line 96-98: Update the migration list in CLAUDE.md to reflect the
renumbering and include the missing migrations referenced in src/db.rs: replace
the current 056/057 entries with the sequential entries 057_lead_capture.sql,
058_lead_followups.sql, and 059_lead_capture_legal_and_phone.sql (with their
short descriptions), and also ensure any mention of 056_meeting_links (if
present in src/db.rs) is added or adjusted so all registered migrations and
filenames in src/db.rs match the documented list.

In `@src/db.rs`:
- Around line 239-254: Two migrations share the same numeric prefix
("056_lead_capture" and "056_meeting_links"), breaking the sequential migration
numbering convention; rename the migration identifiers and corresponding .sql
files so numbers are unique and sequential (e.g., bump "056_lead_capture" →
"057_lead_capture", "057_lead_followups" → "058_lead_followups", and
"058_lead_capture_legal_and_phone" → "059_lead_capture_legal_and_phone" or
follow the correct order per desired sequence), update the strings in src/db.rs
where the tuples ("056_lead_capture", "056_meeting_links", "057_lead_followups",
"058_lead_capture_legal_and_phone") are listed, rename the files in migrations/
to match the new prefixes, and update any tests and CLAUDE.md references to the
old migration numbers to the new ones.

In `@src/leads/config.rs`:
- Around line 19-24: The DB query failures in global_settings and
event_type_capture_enabled are being swallowed by .unwrap_or(None); change these
to capture the Result error, log it with the project's structured logger
(include context like function name and the query), and then either return the
explicit fail-safe defaults (capture disabled, 30-day retention for
GlobalSettings; capture disabled for event type) or propagate the error;
specifically replace the .unwrap_or(None) on the sqlx::query_as(...)
.fetch_optional(...).await call in global_settings and the analogous call in
event_type_capture_enabled with error handling that logs the error and then
returns the appropriate safe default (or returns Err) instead of silently
swallowing it.

In `@src/leads/db.rs`:
- Around line 148-160: The mark_completed function currently swallows any DB
error (let _ = ...execute().await) which hides failures; change it to capture
the Result from sqlx::query(...).execute(pool).await, match on it, and log any
Err with structured context including the function name, lead_id and the error
(e.g., using tracing::error! or the project's logger) so DB update failures are
visible; keep the function signature but ensure success is still ignored and
only errors are logged for operational visibility.
- Around line 345-368: The function due_for_notification currently swallows DB
errors via .unwrap_or_default(), causing silent failures; change
due_for_notification to return
Result<Vec<(String,String,Option<String>,String,String)>, sqlx::Error> (or a
crate-specific error type), remove .unwrap_or_default(), and propagate the query
error by replacing .await.unwrap_or_default() with .await? (or
.await.map_err(...)?), then update any callers to handle the Result (log and
surface the error in the background notification task). This preserves the same
query and row type but ensures DB failures are not silently ignored and can be
logged/handled upstream.
- Around line 294-309: The current user_can_access function swallows DB errors
via .unwrap_or(None) which can incorrectly return false; change user_can_access
to return Result<bool, sqlx::Error> (or a crate-specific error type), remove
.unwrap_or(None) and propagate the error from .fetch_optional(...).await (i.e.
use ? to bubble up the sqlx error) and return Ok(row.is_some()); update all
callers of user_can_access (HTTP handlers) to handle the Result (map DB errors
to 500 and authorization failures to 403/404) so operational DB failures are
visible instead of silently denying access.
- Around line 372-377: The update in function mark_notified silently discards
errors from sqlx::query(...).execute(pool).await which can cause duplicate
emails; change mark_notified to check the Result, log any Err (include the id
and error) via your logger or tracing (or return a Result) and only treat
success as setting notified_at—specifically inspect the outcome of the
sqlx::query/execute call in mark_notified and emit an error log containing id
and the error when it fails so failures are visible and retried behavior is
correct.

In `@src/web/leads.rs`:
- Around line 76-83: The current sqlx::query does a blind UPDATE which may
affect zero rows and ignores SQL errors; change it to perform an upsert (INSERT
... ON CONFLICT(id) DO UPDATE ... or equivalent for your DB) using the same bind
values and await the Result, check for errors and ensure the write succeeded,
and only then update state.legal_mentions_url (the RwLock) and return
Redirect::to("/dashboard/admin").into_response(); if the DB write fails
return/propagate an error response instead of updating the cache.
- Around line 123-150: apply_lead_capture_toggle() currently swallows both the
initial SELECT and subsequent UPDATE errors and always returns Ok(()), causing
the UI to believe a toggle succeeded when SQLite rejected it; change the
function to propagate SQL errors instead of unwrapping/ignoring them: replace
the unwrap_or(None).flatten() on the SELECT with a ?-style error return (or map
the error into the function's Result), check the fetch_optional result and
return Err on failure, and propagate the result of .execute(pool).await
(returning that error) for both the acknowledging_now and non-acknowledging
branches so callers (e.g., set_global_settings()) can surface a failure response
for lead_capture changes. Ensure the function signature and caller code are
updated to accept and handle the propagated Result.
- Around line 155-169: The payload and single-user resolution are ambiguous
because LeadCapturePayload currently only carries event_type_slug and team_slug,
allowing slug collisions across hosts to bind leads to the wrong
event_type_id/host_user_id (which are overwritten on upsert); modify
LeadCapturePayload to include a server-trusted identifier (either username:
Option<String> for public URLs /u/{username}/{slug} or event_type_id:
Option<i64>) and change the lookup logic that resolves event types (the
single-user slug lookup path that writes partial_bookings.event_type_id and
host_user_id) to prefer event_type_id if present or to require and use username
+ event_type_slug when team_slug is None, falling back to team_slug behavior
only for team-scoped lookups.

In `@src/web/mod.rs`:
- Around line 2227-2239: The normalize_phone function currently truncates to 64
bytes which can mismatch validate_phone_input's 64-character limit; change
normalize_phone to cap by characters not bytes so it matches
validate_phone_input: after trimming, iterate over trimmed.chars().take(64) and
collect those chars into a String (preserving UTF-8 safety), return None for
empty as before, and ensure the cap value (64) is the same constant/number used
in validate_phone_input (or extract a shared constant) so validation and
normalization stay consistent.

---

Nitpick comments:
In `@src/leads/config.rs`:
- Around line 44-71: The set_global_settings function currently does a SELECT
then UPDATE/INSERT which risks a TOCTOU race; replace that pattern with a single
atomic SQLite upsert using INSERT ... ON CONFLICT DO UPDATE (similar to
upsert_partial in src/leads/db.rs) so you no longer check `existing`—construct
an INSERT into auth_config (lead_capture_enabled, lead_retention_days[, id if
needed]) with an ON CONFLICT (id or the actual unique key) DO UPDATE SET
lead_capture_enabled = excluded.lead_capture_enabled, lead_retention_days =
excluded.lead_retention_days and execute that via
sqlx::query(...).execute(pool).await?; keep the retention_days.clamp and binding
of values but remove the SELECT and branching logic in set_global_settings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fd02c9ce-95ed-4447-a2f3-8a55bd500d7a

📥 Commits

Reviewing files that changed from the base of the PR and between 27a0e37 and 8acda41.

⛔ Files ignored due to path filters (1)
  • assets/intl-tel-input/intlTelInput.min.js is excluded by !**/*.min.js
📒 Files selected for processing (25)
  • CLAUDE.md
  • assets/intl-tel-input/flags.webp
  • assets/intl-tel-input/flags@2x.webp
  • assets/intl-tel-input/globe.webp
  • assets/intl-tel-input/globe@2x.webp
  • assets/intl-tel-input/intlTelInput.min.css
  • assets/intl-tel-input/utils.js
  • migrations/056_lead_capture.sql
  • migrations/057_lead_followups.sql
  • migrations/058_lead_capture_legal_and_phone.sql
  • src/db.rs
  • src/email.rs
  • src/leads/config.rs
  • src/leads/db.rs
  • src/leads/mod.rs
  • src/main.rs
  • src/web/leads.rs
  • src/web/mod.rs
  • templates/admin.html
  • templates/base.html
  • templates/book.html
  • templates/dashboard_base.html
  • templates/dashboard_leads.html
  • templates/event_type_form.html
  • templates/slots.html
👮 Files not reviewed due to content moderation or server errors (7)
  • templates/book.html
  • templates/slots.html
  • templates/event_type_form.html
  • templates/admin.html
  • templates/base.html
  • templates/dashboard_base.html
  • templates/dashboard_leads.html

Comment thread assets/intl-tel-input/intlTelInput.min.css
Comment thread CLAUDE.md Outdated
Comment thread src/db.rs
Comment thread src/leads/config.rs
Comment thread src/leads/db.rs
Comment thread src/leads/db.rs
Comment thread src/web/leads.rs
Comment thread src/web/leads.rs
Comment thread src/web/leads.rs
Comment thread src/web/mod.rs
@olivierlambert

Copy link
Copy Markdown
Owner

I'm not very fond of tracking tools integration, maybe that would be the right time to have a plugin/extensions mechanism 🤔

@huntervcx

Copy link
Copy Markdown
Contributor Author

I'm not very fond of tracking tools integration, maybe that would be the right time to have a plugin/extensions mechanism 🤔

Yeah me neither at first, but lot of demand from our clients (+a strong trend around it since ~6months), so I'd rather understand how it works than have everyone blindly send their data to US-based services.

That's also why I added an admin switch to completely disable the feature for organizations that don't want it or have stricter compliance requirements (disabled by default org wide, and on new events).
But if you have ideas for the extensions mechanisms, can look into it with pleasure

…visibility

- Lead capture single-user lookup now scopes by username, not just slug.
  Public booking URLs are /u/{username}/{slug} and slugs are only unique per
  account, so a slug shared across hosts could bind a lead to the wrong
  event type/host (and every keystroke upsert reinforced it). LeadCapturePayload
  carries a server-trusted username (sent by book.html and the slots lead-gate);
  the query mirrors the booking handler's WHERE u.username = ? AND et.slug = ?.
  The legacy /{slug} route keeps the global-slug fallback.
- normalize_phone caps by chars (chars().take(64)) instead of bytes so a
  multi-byte number can't pass validate_phone_input then get silently truncated.
- Log the previously-swallowed DB errors in the abandonment-notifier path:
  due_for_notification (silent failure = no alerts) and mark_notified (silent
  failure = duplicate alerts) now emit tracing::warn.
- CLAUDE.md migration list documents 056_meeting_links and
  058_lead_capture_legal_and_phone, and why the 056 prefix is shared.

Co-Authored-By: Claude Opus 4.7 (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.

2 participants