Changelog

0.10.0 — Dial home + bucket folders, default-From + scheduled send that ships, and phone/palette search that finds what's there

Dial gets a **card landing page with bucket folders** and stops looping the last lead of an exhausted queue. **Default From account** on every compose window — and **scheduled send** finally runs end-to-end (the worker was never deployed). **Phone search** matches by a canonical digit key instead of stored format, so `(910) 620-0001` and `9106200001` are the same lookup. The command palette stops hiding an entire org behind a legacy `workspaces` row. Clients get a real **source** column backfilled from lineage/phone/email, and the `pitchPrfct` lead-source label is renamed to `selfGen` in place — data, options list, saved views, and conversion snapshots all in one migration.

What's new

Dial: card landing page, bucket folders, and no more last-lead loop

Clicking Dial used to drop you straight into a session on whichever bucket sorted first — the only overview was a dropdown carrying one number. And a bucket that ran out of leads looped: dispositioning the last lead wrapped the cursor back to the top and re-served leads still under their daily cap. On a one-lead bucket, that is the same person, forever, with nothing on screen saying the pass had ended.

DialerSurface now routes on ?bucket=<id>: absent → the card landing page, present → the dial session. URL-as-state buys back / forward, refresh-in-place, and makes "back to buckets" a param deletion instead of a mode flag.

Landing page — folder tree + card gallery. Templates-shaped layout: folder-tree rail sidebar with smart scopes (All / Active / Paused / Draft / Completed) that filter the whole tree; folder scopes browse like a file manager. Cards lead with available now, then total leads and calls today · by you. Plus a status pill, a Closed until Mon 9:00 AM chip, and a "can't be dialed" chip whose click goes to the builder instead of into a dead session. Counts that haven't loaded render , never 0 — a card claiming zero available leads when it simply hasn't loaded would send a rep to the wrong bucket.

Bucket folders — dial_buckets gets a tree (migration 171: parent_id / is_folder / icon / sort_order), mirroring message_templates. Containment trigger, cascade FK, and a folder-shape CHECK whose branches can't evaluate to NULL (a CHECK passes on NULL — that trap already bit the templates table once). Sort-order math is lib/treeSortOrder, already shared with Pages and Templates. Folder deletes send an explicit descendant id list rather than leaning on the cascade, so the delete policy runs per row.

End of pass — resolveAdvanceTarget(queue, cursorIndex, hasMore) is pure, in dialBucketRuntime, and returns next or exhausted. The hasMore guard is load-bearing: the JS post-filters routinely empty a loaded page while callable leads sit deeper in the keyset, and calling that "done" would kick an operator out mid-shift. On exhaustion the native mount returns home with a toast; the canvas mount renders a Queue finished state with an explicit Start another pass — the second lap the old code took silently now sits behind a click. passComplete is session state that dies with the component, never a persisted past-end cursor.

Available-now count. Migration 172 adds dial_buckets_overview(p_specs jsonb) — every card's counts in one round trip. p_specs carries each bucket's resolved view snapshot because a view-linked bucket's stored flat filters are the legacy fallback and can be stale. available_now copies dialer_next_leads' CTEs verbatim (cap, cooldown, cadence ladder, teammates' leases) so the card can't promise leads the queue won't serve. It deliberately does not run the dialer's JS post-filters (per-lead timezone windows, eligibility/DNC, live appointments); doing so would mean N lead-page, eligibility and calendar fetches on a landing page — so it's an upper bound, and the closed-hours chip is the visible caveat.

Navigation. DialBreadcrumb replaces the bucket dropdown: Dial › folders › bucket, root always walking back to the landing page, ancestors returning home already scoped to that folder. Switching buckets is now "go back to the list and pick one" — the same gesture as every other app in the shell. BucketSelect survives only on the canvas mount, which has no landing page behind it and would otherwise lose its only bucket switcher.

DRY under the hood. The dialer's seven "nothing to dial" panels were seven copies of one markup block; they're now one DialTerminalState. That's what made adding "Back to buckets" to every dead end a single edit instead of seven.

See Dial → landing page and buckets.

Inbox: default From account, and scheduled send actually works

Two related shifts in the email surface — the compose window now always tells you which account it's going out from, and scheduled send now ships end-to-end for the first time.

Default From account. Configured in Settings → Inbox → Defaults, stored per-user and per-org at profile.preferences.inbox.default_from_by_org[orgId]. Org-keyed because integrations rows are unique per (org_id, user_id, provider), so a user in two orgs has two disjoint connection sets and a single scalar would dangle in whichever org didn't set it.

Every compose window now shows a From row when more than one account is connected — static text on replies, which can only be threaded by the mailbox holding the conversation. The account is pinned to the draft at open time (DRAFT_VERSION 2→3) and always passed at send, so what the row shows is what actually sends, including for a draft restored after a refresh.

Resolution order. The account being viewed in the inbox → the configured default → the first connection. Both stored steps are validated against the live connections list, so a disconnected or other-org id degrades to the first connection instead of failing the send.

Bugs this exposed:

  • Re-opened provider drafts captured draftIntegrationId but ignored it at send — a draft written in account B could go out from account A.
  • Per-account default signatures already existed but never applied to a new compose (auto-apply resolved against replyTo only). They do now, and switching From swaps the signature — unless you picked one by hand, which the new signatureAuto flag protects.
  • activeView is session-wide. EmailProvider wraps the whole authenticated workspace, so "the account you're viewing" kept naming the last account you opened in the inbox long after you'd moved to the dialer or a record sheet — meaning the configured default would never have applied there. Inbox context is now supplied by the inbox surfaces themselves (useInboxCompose) rather than read from global state.

Scheduled send — actually runs now. It had never worked. The compose UI and the create/list/cancel API were complete; everything beneath them was missing:

LayerBefore
DB insertintegration_id is NOT NULL and compose passed null for non-replies — the INSERT was rejected. Only replies could ever be queued.
WorkerNever deployed — absent from all 25 edge functions.
CronNo job existed — a valid row would never dispatch.
UINothing rendered the list; failures were silent.

The empty production scheduled_email_sends table was the tell.

Now live and verified in production: worker deployed (verify_jwt=true, matching every other cron-invoked function), driven by a new per-minute pg_cron job using the Vault service-role key — the process-calendar-reminders pattern.

  • Retry classification. Transient failures (provider 5xx, 429, network) return to pending with backoff up to 3 attempts; permanent ones (disconnected account, missing attachment, rejected request) fail immediately rather than delaying the bad news. Unrecognised errors count as transient — dropping a queued message is worse than sending it late.
  • Failures are no longer silent. Terminal failures write a notification that rides the existing realtime fan-out. This needed its own toast gate: the records gate suppresses created_by === userId, which for your own failed send is exactly backwards.
  • Stuck rows recover. A row claimed as sending whose worker died returns to the queue after 10 minutes; the claim query only looks at pending, so it would otherwise never retry.
  • Scheduled panel in the email rail: what's queued, when, and why anything failed, realtime-subscribed so rows update in place. A Retrying state distinct from Scheduled. Cancel reopens the message as a draft — body, recipients, From account and attachments intact.
  • Send-time validation in both the store and the picker. The picker previously returned silently on a past time, so Schedule just did nothing.

Storage cleanup and retention. deleteAttachment only ever fired when a user removed a chip from a draft. Nothing cleaned up after a successful send — not the compose store, not email-api, not the worker — so every attachment ever sent stayed in the bucket permanently. Both paths now delete on success. Safe because template files are copied into a draft-owned path on insert, so the template's own object (a different bucket) is untouched; scheduling deliberately does not clean up, since the worker needs the files later. A nightly purge trims terminal queue rows — sent/cancelled at 30 days, failed at 90, since failures are the rows you may still need to act on.

Migration 170 adds the cron jobs and the realtime publication, and re-declares the table, enum, RLS, indexes and trigger idempotently — all of it had been MCP-applied with no repo migration, so a fresh environment could not be built from the repo alone. The updated_at function is reproduced verbatim including SECURITY DEFINER and its pinned search_path; a naive re-declare would have silently stripped both.

See Inbox → Default From account and Inbox → Schedule send.

Phone search: canonical digit key, no more format-matching

Phone lookup only worked when the typed format happened to match the stored format. to_tsvector('simple', …) tokenizes on punctuation, so formatting became part of the index:

StoredTokens
(317) 644-9695317, 644, -9695
+13176449695+13176449695 (one token)
31764496953176449695 (one token)

Measured against production before the fix — row stored (910) 620-0001 was invisible to 9106200001 or +19106200001; row stored 8478901614 was invisible to +18478901614 or (847) 890-1614. Client-side, searchMatch tested query-digits ⊂ cell-digits — one-directional, which is exactly why adding +1 broke it.

Both sides normalize to one key. NFKC → strip extension → ASCII digits → drop NANP country code, as JS/SQL twins pinned by shared fixtures:

  • lib/phone.jsapp_hidden.phone_search_key — contract in lib/phoneSearch.fixtures.js, asserted in both runtimes.
  • Migration 173search_phones text[] column + partial GIN index, trigger extension, and a new g8 arm on global_search.

NFKC is required, not cosmetic. JS \D deletes full-width digits ('123'''); Postgres \D keeps them. Two different wrong answers. Prod also has a row with invisible LTR-embedding marks from an iOS paste.

Design choices worth reviewing:

  • Phone keys stay out of search_vector — digit tokens would distort ts_rank_cd and leak digit-soup into the palette's ts_headline snippets.
  • Arm g8 is a separate union all arm, not an OR in arm 7, so text search is byte-for-byte unchanged and phone hits carry their own score (1.0 exact / 0.5 fragment vs ts_rank_cd's ~0.0x). Outer distinct on (kind, id) dedupes rows matching both ways.
  • The backfill does not use the old set data = data trick: handle_user_table_updated_at stamps updated_at unconditionally, and that drives both the grid's default sort and global_search's recency tiebreak. It honours a transaction-local GUC instead — which also makes reindex_user_table_search lock-free.

Bundled in:

  • DataTableViewerWidget's two byte-identical search+filter blocks collapse into one useFilteredRows hook, now debounced 120 ms to match dial search (it previously re-filtered every row on every keystroke).
  • ContactListWidget and ChildRecordsPicker drop their ad-hoc matching in favour of the shared helper.
  • Phone columns are detected by name as well as type, so numbers imported into text columns are searchable (phone/mobile as substrings; tel/cell word-bounded so hotel/excellent don't match).
  • Five scattered copies of digit-stripping now share phoneDigits().
  • The SMS inbox sends PitchPrfct an E.164 term for phone queries — their contacts are stored E.164, so typed formatting matched nothing. This matches what useDialSmsForLead already does successfully.
  • Master search now finds rows by phone in clients, contacts, dependents and carriers — invisible before, since search_text only ever held display keys.

Deliberate behaviour changes:

  1. A formatted query under 4 digits (e.g. (710)) no longer digit-normalizes; it can only match literally.
  2. DataTable search is debounced 120 ms. The input echoes instantly; only the filter pass is throttled.
  3. Phone columns matched by name, widening which columns get digit matching.

Command palette: stop hiding every record behind a legacy workspace

Searching a phone number in the command palette returned "No matches" for a row the table's own search box found instantly. The phone normalization above was not at fault. The two searches differ in one thing: the palette passed p_workspace_id to global_search, while the table search filters already-loaded rows in memory (makeRowMatcher), which has no workspace concept.

Root cause. Workspaces were removed from the product (org → canvas), but older orgs still own a legacy workspaces row and src/store/workspaces.jsx hydrates currentWorkspace to restored || wsList[0] — so it's a real uuid, not null. The palette handed that id to global_search, whose pages / user_tables / user_table_rows arms filter workspace_id = p_workspace_id, while everything those orgs own carries workspace_id IS NULL.

Measured on production before the fix — one org, same query, only the workspace argument differing:

querywith workspacewithout
+1 (626) 488-716701
Karen9 (0 records)12 (3 records)

So it was never phone-specific: all 12 tables, every row in them, and all 21 of that org's pages were invisible to the palette. A name query still returned calendar events and activities — neither arm is workspace-scoped — which is why only the phone lookup looked broken. A stale workspace filter fails closed, so it reads as a search bug rather than a scoping bug.

The fix — both halves.

  • Client. GlobalCommandBar no longer takes or forwards a workspaceId; the prop is dropped at the CanvasWorkspace call site. Palette search is org-wide.

  • SQL (migration 174). One named rule instead of five inlined copies of the predicate:

    app_hidden.workspace_in_scope(p_row_workspace_id, p_filter)
      -- NULL filter            = no filter
      -- NULL row workspace_id  = org-scoped, visible under every filter

    Adopted by the pages, conversations, user_tables, g7 and g8 arms. This is the durable half: it stops any future caller from silently blanking records the same way.

p_workspace_id is kept — dropping it changes the signature, needing a drop/recreate plus a coordinated client deploy, for no gain while workspaces rows still exist.

Clients: source column, backfilled from lineage / phone / email

clients had no lead-origin field, so once a prospect converted, "where did this deal come from?" was unanswerable. Migration 175 adds a source select mirroring the prospects column and attributes 101 of 116 clients.

Three rules, strict precedence, first tier that resolves wins:

TierRuleNew matches
1converted_from_row_id — exact lineage from convert_rows (056)86
2app_hidden.phone_search_key() equality+13
3lowercased email+2

Tiers 2–3 are validated, not assumed: on the 86 rows where lineage and phone/email both fired, they agreed on the source every single time. Zero disagreements, zero ambiguous rows.

Name matching is deliberately excluded. Measured against production it contributed exactly zero additional matches — every name match was already covered by phone or email — while being the one rule that can silently conflate two people sharing a common name. No gain, real risk.

Unmatched clients are left with no source key. Blank honestly means unknown and stays filterable; a default would make unknown data indistinguishable from attributed data.

Also repairs the stale prospects options list, which was missing CABoom despite 37 rows already holding it.

Lead source: rename pitchPrfctselfGen in place

648 rows (613 prospects + 35 clients). The row data was the easy part; migration 176 carries three other things with it:

  • The options list on both tables — renamed in place, so selfGen keeps pitchPrfct's slot and the dropdown order stays stable.
  • A saved view (already named selfGen) filtering source is pitchPrfct. This is the one that fails silently — rename the data without it and the view returns zero rows, no error anywhere.
  • 26 record_conversions.source_snapshot rows. Not just history: undo_conversion re-inserts them as live rows, so a stale snapshot resurrects a prospect holding a dead option.

What is deliberately NOT renamed. pitchprfct names two unrelated things in this database and only one is a lead source:

Left intactWhy
integrations.provider='pitchprfct' ×2pitchprfct-api queries .eq("provider","pitchprfct") — renaming 404s the SMS proxy
canvases.nodes_json "smsProvider":"pitchprfct"SMS inbox widget binding
email_events ×7genuine mail from the company
record_activities.body ×1a human note about the product

app_hidden.filter_tree_rename_value() walks saved filter trees at arbitrary depth and rewrites only rule nodes bound to the target field. A blunt replace(filters::text, ...) would have hit preset names and rules on other fields.

Under the hood

  • Migration 170 — scheduled email sends. Cron jobs + realtime publication + idempotent re-declaration of the table, enum, RLS, indexes and updated_at trigger. Backfills a repo-only baseline for the MCP-applied schema.
  • Migration 171 — bucket folders. dial_buckets tree columns (parent_id / is_folder / icon / sort_order), containment trigger, folder-shape CHECK guarded against NULL branches.
  • Migration 172 — landing overview RPC. dial_buckets_overview(p_specs jsonb) returns every card's counts in one round trip. Reuses dialer_next_leads' CTEs so available_now can't promise leads the queue would refuse.
  • Migration 173 — phone canonical key. search_phones text[] + partial GIN, trigger extension, global_search arm g8. Backfill runs under a transaction-local GUC so updated_at isn't stamped.
  • Migration 174 — app_hidden.workspace_in_scope. One named rule for workspace filter semantics; adopted by five global_search arms. NULL row workspace_id treated as org-scoped under every filter.
  • Migration 175 — clients.source + backfill. Three-tier attribution (lineage / phone / email) with an ambiguity guard; idempotent on source is null.
  • Migration 176 — pitchPrfctselfGen. In-place value rename across data, options list, saved-view filter trees, and record_conversions.source_snapshot; self-contained (creates app_hidden.filter_tree_rename_value before use).
  • DialerBody gained optional controlled props (bucketId / onSelectBucket / onExitToHome / onQueueExhausted / initialSearchRowId). Omitted, it behaves exactly as before — that seam keeps the legacy canvas dialWidget working, auto-select and all.
  • useFilteredRows — one hook replaces two byte-identical search+filter blocks in DataTableViewerWidget, and gains a 120 ms debounce.
  • lib/phone.jsapp_hidden.phone_search_key — twin normalizers with a shared fixture file (lib/phoneSearch.fixtures.js) asserted in both runtimes.
  • dialEmailCompose.js stays the single build site for every dial-Email entry point's recordContext (unchanged from 0.9.0, but relevant to how the new From row plumbs into it).

What's next

  • Retire the workspace column. The client stopped writing it in 0.10.0's palette fix, but CanvasWorkspace still stamps workspace_id: currentWorkspace?.id on newly created pages/canvases and api.js still filters conversations by workspace id. Both are consistent today — the point is to stop writing a dead concept onto new rows before the trap resets.
  • Legacy dial-bucket flat filters. dial_buckets.filters / dial_buckets.sorts are still read as the fallback when a client doesn't pass a view snapshot; safe to drop once the last pre-0.9 client is retired.
  • MMS on the dial / SMS composer. Copy picture (0.9.0) remains the bridge until PitchPrfct's send grows a media field.

See Dial, Inbox, and Quickstart → the command palette.

0.11.0 — MCP server ships (30 tools across the 6 native apps) + per-card loading skeletons on Dial home and Templates

The **MCP server** finally ships — an earlier draft existed in the tree but had never been deployed, had no UI to mint a token against, and covered only 4 of the 6 native apps thinly. This is the working suite — **thirty read-only tools across Tables, Calendar, Dial, Inbox, Pages, and Templates**, tokens that bind to `(org_id, user_id)` and query through the owner's own RLS (so isolation is a database property, not a per-tool discipline), a **Settings → MCP access** tab to mint and revoke, and it's live on production. Plus **per-card loading skeletons** on the Dial landing page and the Templates gallery, killing the "0 available now" false zero and the "No templates yet" flash on first paint.

0.9.0 — Dial queue pacing + freshness mix + email compose from the dialer + disposition rollup columns

The dial queue gets two opt-in gates — an **attempt-pacing ladder** that rests a lead after N calls (and can retire past a hard cap), and a **freshness mix** that interleaves aged leads into the working queue — plus an honest "N calls today" header count and a `12 calls` history chip on the contact panel. The dialer's Email tab picks up a **New Email** button that seeds the floating compose against the active lead (with templates that finally resolve `{{contact.*}}`, and a real Forward), and the Message button opens a **template menu** for SMS with an OS-handoff. Records get **most-recent-disposition rollup columns** — three scopes, all backed by one system-field registry — so buckets, filters, and the grid can address the same signal the eligibility engine already saw. Plus templates copy an SMS picture as a picture (and paste one in), calendar reminders stop firing per-tab and per-mirror-twin, and a signup-breaking `handle_new_user_org` regression is fixed.

On this page