Repository navigation
feat(segments): P2/4 — static list members: add, count, read - #1346
manas-narra wants to merge 1 commit into
Conversation
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughThe change adds static CRM segment membership storage, routes for adding and listing members, per-person resolution and duplicate reporting, transactional member-count updates, and cursor-based pagination. ChangesStatic Segment Membership
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Feature Sequence Diagram(s)sequenceDiagram
participant OutreachAPI
participant AddMembers
participant IdentityResolution
participant DbTxn
participant CRMDatabase
OutreachAPI->>AddMembers: Submit segment ID, people, and source
AddMembers->>IdentityResolution: Resolve each person's supplied fields
AddMembers->>DbTxn: Lock and recheck segment
DbTxn->>CRMDatabase: Insert unique member IDs
DbTxn->>CRMDatabase: Recount and stamp member count
Suggested reviewers: Merge Risk: ⚪ Minimal · up to
|
f622fe7 to
edadc84
Compare
edadc84 to
f03bd9f
Compare
Migration 086 creates crm_segment_member (canon T21), owned by outreach:
composite PK (segment_id, customer_id), no merchant_id (a pure child row),
and a trigger that keeps members on static lists only and the customer on
the segment's own merchant.
add_members checks the segment before resolving anyone, sends every person
through resolve() with imported evidence, reports each skipped line with
its reason, drops duplicates within the upload, then in one atom locks the
segment, inserts every member in one statement (ON CONFLICT DO NOTHING) and
recounts the list from its rows. segment_members pages the list by
customer_id.
POST /segments/{id}/members returns the report (received / added /
already_member / duplicates_in_upload / skipped / member_count /
computed_at); GET /segments/{id}/members pages with a next_after cursor.
f03bd9f to
ea460d8
Compare
Stacked on #1345 (P1 — the segment table). Base:
feat/segments-p1-tables.Why
A static list (canon/05-audiences.md:3-4) IS its members: an imported set stored as narrow rows. P1 created the list; this PR gives it members — the thing a merchant's uploaded list becomes, and what a broadcast will freeze. Phase 2 of 4: members in P2; dynamic segments in P3 (rule format, check, create) and P4 (compiler, evaluate, count).
What
Migration 086 —
crm_segment_member(canon T21), owned by outreachsegment_id,customer_idadded_at,source"import diwali-vips.csv","api: ops@…")A pure child row with no
merchant_id(canon/05-audiences.md:49-51) — every read enters through the parent segment. Because the row has no merchant to pin, its trigger checks what a tenant-pinned FK would (law 9: invariants live in the tables):Adding members —
outreach/segments.py::add_membersMAX_MEMBERS_PER_ADD(1,000) people per add, each{phone | email | external_ref}.resolve()with imported evidence (ADR 0021: may attach a handle, never displace one live traffic set) — people are never stored in arrays (canon/05-audiences.md:45-47). A person with no usable handle is a skipped line with its reason.plan_members, pure).FOR UPDATE(two adds to one list run in turn, and the lock re-checks it is still this merchant's static list) → oneINSERT … SELECT unnest($2::uuid[]) ON CONFLICT DO NOTHING RETURNING→ recount from the rows and stampmember_count+computed_at. The count is exact because the transaction that changed the rows stamps it.Reading members —
segment_members:evaluate(id)one chunk at a time, keyset-paged oncustomer_id(the PK carries it), entered through the parent undermerchant_id = $1,next_afteras the cursor.Routes
POST /segments/{id}/members{merchant_id, members: [...], source?}"mobile") · 403 merchant not reachableGET /segments/{id}/members?merchant_id=&after=&limit=next_after· 404 · 409Example
Not in this PR
MAX_MEMBERS_PER_ADD(1,000), sequentially, with a progress bar, and combines the reports (batch offset + line = CSV line). Re-sending a batch is safe — people already added are reported as already members. Consent columns are not accepted (the route takes only phone / email / external_ref); CSV-with-consent becomes a backend import once the consent ledger exists (W10, canon/05-audiences.md:47).Tests
tests/crm/test_segment_members.py(30): the owner and 086's shape and two trigger invariants · the builders (one INSERT with ON CONFLICT + RETURNING, the lock merchant-first FOR UPDATE, the recount from rows stamped now, the page entered through the parent and keyset-paged) ·plan_membersduplicate table · every person sent accounted for exactly once (added / already a member / duplicate / skipped with line + reason) ·resolve()called with imported evidence · the same people twice adds nobody · an unknown or dynamic segment resolves nobody · empty and oversized adds refused before any work · the atom's re-check under the lock (gone, or turned dynamic) writes nothing · an all-skipped upload still stamps the count · the page cursor · the routes (200 report,sourcedefaults to the caller, 422 / 404 / 409 / 403, paging).test_segments.py's route walk now expects 5 routes. Three mutation checks (drop the check before resolving; drop the re-check under the lock; count duplicates as zero) turn the suite red.Gates
black / isort / autoflake / pyrefly (0 errors) / migration guard (85, no gaps) / CRM boundary guard: pass.
tests/crm: 1611 passed, 15 skipped.After review
Summary by CodeRabbit