Skip to content

Move the Do Not Contact list into the SMS module - #41

Draft
DorwardTech wants to merge 2 commits into
claude/zone3-darwin-internal-tool-YQKKNfrom
claude/sms-optout-ownership
Draft

Move the Do Not Contact list into the SMS module#41
DorwardTech wants to merge 2 commits into
claude/zone3-darwin-internal-tool-YQKKNfrom
claude/sms-optout-ownership

Conversation

@DorwardTech

Copy link
Copy Markdown
Owner

Phase 2 — or rather, the half of Phase 2 that the schema allows. I looked at the two tables before starting and they are not equally movable. That finding shaped this PR, so it's the first thing below.

The finding

Table CRM-specific columns
crm_sms_opt_outs nonemobile, name, source, note, opted_out_at
crm_inbound_sms matched_send_id (FK→crm_sms_sends), bookeo_customer_id, parent_name, kid_name

The opt-out list is generic and moves cleanly. The inbound table is a third birthday-party domain — moving it means either restructuring live reply history or leaving a module named for a capability holding a column called kid_name. That's a decision to take deliberately, not to slip into a refactor. See What did not move.

What moved

  • SmsOptOutModules\Sms\app\Models\SmsOptOut, table crm_sms_opt_outssms_opt_outs
  • OptOutController + view → Modules/Sms, /crm/opt-outs/sms/opt-outs, gated module:sms
  • The nav entry, now contributed by the SMS module

Plus SmsOptOut::isSuppressed() — the single-number check a future sender wants, so it doesn't pull the whole list to answer yes/no.

The operator sees nothing change

The sidebar entry is contributed under the CRM module's navigation category, same order, same label, same icon. The sidebar groups by category across modules, so Do Not Contact stays exactly where it has always been — between Replies and Settings.

The operator guide needed no edit at all: it describes the screen, never the URL. Only a bookmark would notice.

Why a rename, not a copy

This is the compliance record — every row is a customer instruction not to be contacted.

Schema::rename is one atomic metadata operation, with no window in which a send path could read an empty or half-populated list. Copy-then-switch has exactly that window. The migration is also reversible, unlike most in this repo, because a rollback that stranded this table under a name nothing reads would be the worst failure it could possibly have.

What did not move

The inbound side stays in Modules/Crm: webhook receiver, crm_webhook_deliveries, the reply store, the Replies screen.

The shape when it happens: the SMS module asks "who is this number?" through an attribution hook and the CRM module answers, so matched_send_id and friends become a generic source reference plus whatever context the identifier supplies. A reply from a number nobody recognises is stored unattributed — already today's documented behaviour.

That work buys the next sender reply attribution. It is not needed for opt-outs. A reminders module can honour Do Not Contact today without knowing the birthday radar exists — and there's a test that disables the CRM module entirely and manages the list anyway.

Written up in Modules/Sms/README.md so the reasoning survives without this PR description.

Verification

The three sweeps from the last round, all clean: no stale table name, no stale namespace, no stale route name or view, both resolver checks green.

ClassReferenceTest gained SmsOptOut — and immediately flagged a false positive on the Blade view, which references the model by inline FQN. A compiled template has no namespace to import into, so that form is always correct. Taught the check to strip fully-qualified references first, then re-verified it still catches a planted orphan by deleting a real use statement and confirming it fired. The shell version I first re-typed the regex into didn't reproduce the PHP escaping, so I ran the exact test logic from a file rather than trusting the approximation.

New tests: the page works with the CRM module switched off, isSuppressed(), the table name is what the model reads, and a platform sweep never downgrades an explicit STOP.

vendor/ can't be installed in this environment (codeload.github.com is blocked by the proxy), so CI is the verification for the suite.


Generated by Claude Code

claude added 2 commits August 3, 2026 02:10
The exclusion list binds every sender, not the birthday radar in
particular: a booking reminder must skip a number that replied STOP to a
birthday message, and it should not have to depend on the radar to find
that out. So it moves to the module every sender already shares.

  SmsOptOut model, table crm_sms_opt_outs -> sms_opt_outs
  OptOutController + the view, /crm/opt-outs -> /sms/opt-outs
  the nav entry, contributed by the SMS module

## The operator sees nothing change

The sidebar entry is contributed under the CRM module's navigation
category, at the same order, with the same label and icon — the sidebar
groups by category across modules, so Do Not Contact stays exactly where
it has always been, between Replies and Settings. The operator guide
needed no edit at all: it describes the screen, never the URL.

Only a bookmark would notice.

## Why a rename rather than a copy

This is the compliance record — every row is a customer instruction not to
be contacted. Schema::rename is one atomic metadata operation with no
window in which a send path could read an empty or half-populated list.
Copy-then-switch has exactly that window. The migration is also reversible,
unlike most here, because a rollback that stranded this table under a name
nothing reads would be the worst failure it could have.

## What did NOT move, and the reason is in the schema

The inbound side stays in Crm. crm_inbound_sms has twelve columns and four
are birthday-party domain: matched_send_id (FK to the radar's ledger),
bookeo_customer_id, parent_name, kid_name. Moving it means either
restructuring live reply history or leaving a module named for a capability
holding a column called kid_name — a decision to take deliberately, not to
slip into a refactor. Written up in Modules/Sms/README.md.

That work buys the next sender reply *attribution*. It is not needed for
opt-outs, which are done: a reminders module can honour Do Not Contact
today without knowing the radar exists, and there is a test that disables
the CRM module entirely and manages the list anyway.

## Also

ClassReferenceTest gained SmsOptOut and learned that a fully-qualified
inline reference needs no import — the Blade view uses that form, since a
compiled template has no namespace to import into. It was flagging that as
an orphan. Verified the corrected logic still catches a planted one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014E51bA27LnFMK7v4YigZY1
ReplyChecker was importing Modules\Crm\app\Models\SmsOptOut, which stopped
existing in this branch — 24 failures, all "Class not found" inside webhook
processing.

The rewrite had landed correctly. What undid it was the step where I proved
ClassReferenceTest still catches a planted orphan: I deleted a `use` line
with sed and put the file back with `git checkout <file>`, which restores
from the index — and the index held HEAD, i.e. the file as it was before
any of this branch's edits. So the check I was validating reverted the fix
it was meant to protect.

The reason it then passed review was that I ran the two halves of the check
at different moments. The import-resolution half ran before the revert and
was clean; only the orphaned-reference half ran after, and an explicit
import of a class that no longer exists is invisible to that one.

Both halves now run in a single pass, and this exact regression was
replanted afterwards to confirm the first half names the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014E51bA27LnFMK7v4YigZY1
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