Skip to content

feat(segments): P1/4 — the segment table, create and read a static list - #1345

Open
manas-narra wants to merge 1 commit into
releasefrom
feat/segments-p1-tables
Open

manas-narra wants to merge 1 commit into
releasefrom
feat/segments-p1-tables

Conversation

@manas-narra

@manas-narra manas-narra commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Phase 2 of the CRM (task map W7) needs audiences: a broadcast admits a segment into a workflow, and crm_broadcast.segment_id is NOT NULL (canon T17 col 6). Nothing exists for segments yet. This is phase 1 of 4: the segment row itself, created and read.

A segment is one of two kinds (canon/05-audiences.md:3-4):

  • Static list — a fixed set of customers stored as member rows (e.g. a merchant's CSV). definition is NULL.
  • Dynamic segment — a stored question, computed when asked. definition holds the rule.

This PR creates static lists only. Members arrive in P2; dynamic segments in P3 (the rule format, its check on save, create) and P4 (the compiler, evaluate and count). Uploading a CSV is a console feature: the console sends the file's rows in batches of 1,000 to P2's add-members route.

What

Migration 085 — crm_segment (canon T15), owned by outreach

Column Notes
id, merchant_id tenant root row
name unique per merchant ((merchant_id, name)); a CHECK refuses a blank or untrimmed name (name <> '' AND name !~ '^\s|\s$'), so the plain unique index is whitespace-insensitive for every writer, not only the ones that trim
definition NULL = static list; when set, always a JSON object (CHECK)
member_count, computed_at the honesty pair: a count always says how old it is. A new list is born 0, stamped now
created_by, created_at, updated_at updated_at via the shared touch trigger

Also a unique (merchant_id, id) — the pin target for the tenant-pinned FK crm_broadcast will use (the 049/058/073 precedent), so a broadcast can never freeze another merchant's segment — and (merchant_id, created_at DESC, id DESC) for the list read. The kind (static / dynamic) is derived from definition on read, never stored (law 10).

Routes (/segments, every route declares merchant_scope)

Route Answers
POST /segments {merchant_id, name} 201 the new empty static list · 409 name taken · 422 name blank once trimmed · 403 merchant not reachable
GET /segments?merchant_id=&limit=&offset= one page, newest first, with total in the body
GET /segments/{id}?merchant_id= the segment · 404 unknown or another merchant's · 422 id not a uuid

Layout — the module skeleton as outreach already has it: outreach/segments.py (logic), db/queries|accessors|decoders/segment.py (one table, one file), Segment / SegmentPage in schemas.py, segment_router in outreach/api.py mounted at /segments, crm_segment in TABLE_OWNERS, ownership note in docs/crm/migrations.md.

Example

curl -X POST /segments -d '{"merchant_id": "flipkart", "name": " Diwali VIPs "}'
# 201
# {"id": "8c1e…", "merchant_id": "flipkart", "name": "Diwali VIPs", "kind": "static",
#  "definition": null, "member_count": 0, "computed_at": "2026-10-13T10:00:00Z",
#  "created_by": "ops@juspay.in", "created_at": "…", "updated_at": "…"}

curl -X POST /segments -d '{"merchant_id": "flipkart", "name": "Diwali VIPs"}'
# 409 {"detail": "a segment named Diwali VIPs already exists"}

curl '/segments?merchant_id=flipkart&limit=10'
# {"items": [ … ], "total": 1}

Not in this PR

  • crm_segment_member (T21) and adding members through resolve() — P2, together with the writer that fills it.
  • Dynamic segments — P3 (rule format, check, create) and P4 (compiler, evaluate, count). CSV upload — the console, on P2's route.
  • outreach/schemas.py is past 1,000 lines; the per-family split the corpus names is owed as its own structure PR so it does not collide with open branches.

Tests

tests/crm/test_segments.py (32): the table's owner and 085's self-defense (name unique per merchant with merchant first, blank name, object-only definition, tenant pin, no stored kind) · every builder puts merchant_id at $1 and parameterizes · a new list is born 0 / stamped / no definition · name trimming and blank refusal (blank never reaches the database) · duplicate name → SegmentNameTaken · the same name across two merchants · another merchant's id reads as None · the kind derived from definition · every /segments route declares the tenancy door · the root router mounts /segments · 201 / 409 / 422 / 403 / 404 / 422 and the paged list. Two mutation checks (drop the trim; drop the refusal mapping) turn the suite red.

Gates

black / isort / autoflake / pyrefly (0 errors) / migration guard (84, no gaps) / CRM boundary guard: pass. tests/crm: 1581 passed, 15 skipped.

After review

  • Tara (MINOR, 085): the unique index was on the raw name, so "Diwali VIPs" and "Diwali VIPs " were one name only for writers that trim first. The name CHECK now refuses a blank or untrimmed name (name <> '' AND name !~ '^\s|\s$' — \s covers tabs and newlines, which btrim alone does not), so the plain unique index holds for every writer. Chosen over an expression index on btrim(name) because it keeps the stored name clean for display and is a format CHECK (CLAUDE.md: CHECKs on format are required). Every name the app stores passes it: segment_name() strips a superset.
  • CodeRabbit's docstring-coverage warning (pre-merge, all four segment PRs): not chased — the repo does not gate on it (feat(telephony): configurable outbound ring timeout for Plivo and Vobiz #1288 merged at 40%, fix(crm): write the token onto a dispatching square before executing it #1281 at 69.67% with the same warning), and it counts every test function and one-line helper; these files follow the surrounding comment density.

Summary by CodeRabbit

  • New Features
    • Added CRM segment management: create static lists, view segment details, and browse paginated segment lists.
    • Segment names are trimmed, must be nonblank, and must be unique within a merchant. Creating a duplicate name returns a conflict; requesting a missing segment returns not found.

@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 65f1f757-1de7-4b05-85f8-c0fd328fe8f2
📥 Commits

Reviewing files that changed from the base of the PR and between 057e5ae and 07301fc.

📒 Files selected for processing (12)
  • app/crm/api.py
  • app/crm/outreach/api.py
  • app/crm/outreach/db/accessors/segment.py
  • app/crm/outreach/db/decoders/segment.py
  • app/crm/outreach/db/queries/segment.py
  • app/crm/outreach/db/queries/tables.py
  • app/crm/outreach/schemas.py
  • app/crm/outreach/segments.py
  • app/database/migrations/084_create_crm_segment.sql
  • docs/crm/migrations.md
  • scripts/check_crm_boundaries.py
  • tests/crm/test_segments.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Walkthrough

This change adds CRM segment storage and service operations, then exposes merchant-scoped endpoints to create static lists, list segments, and retrieve a segment by UUID.

Changes

CRM Segments

Layer / File(s) Summary
Segment schema and persistence
app/crm/outreach/schemas.py, app/database/migrations/084_create_crm_segment.sql, app/crm/outreach/db/queries/*, app/crm/outreach/db/accessors/segment.py, app/crm/outreach/db/decoders/segment.py, app/crm/outreach/db/queries/tables.py, docs/crm/migrations.md, scripts/check_crm_boundaries.py, tests/crm/test_segments.py
Adds Segment and SegmentPage, the crm_segment table and indexes, tenant-scoped queries and accessors, and decoding that derives kind from definition. Tests cover migration constraints, table ownership, queries, and decoding.
Segment service operations
app/crm/outreach/segments.py, tests/crm/test_segments.py
Adds name trimming and blank-name rejection, static-list creation with duplicate-name translation, merchant-scoped lookup, and paginated results. Tests cover these service behaviors.
Segment API routes
app/crm/api.py, app/crm/outreach/api.py, tests/crm/test_segments.py
Mounts the segment router and adds create, list, and detail endpoints. The routes apply merchant scope and return the specified validation, duplicate-name, and not-found responses. Tests cover routing, dependencies, and responses.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant CRMRootRouter
  participant SegmentRouter
  participant SegmentService
  participant SegmentAccessor
  participant crm_connection
  CRMRootRouter->>SegmentRouter: Mount router at /segments
  SegmentRouter->>SegmentService: Create static list for merchant
  SegmentService->>SegmentAccessor: Insert normalized name and caller email
  SegmentAccessor->>crm_connection: Execute insert query
  crm_connection-->>SegmentAccessor: Return inserted row
  SegmentAccessor-->>SegmentRouter: Return decoded Segment
Loading

Suggested reviewers: rahul-2903-juspay

Merge Risk: ⚪ Minimal · up to 07301

This change adds merchant-scoped static segment creation, listing, and detail reads using the existing tenancy conventions. No outstanding issue blocks merging.

Security architecture risk: 🔵 Low · up to 07301

The new endpoints preserve merchant authorization and tenant-scoped database access, and database constraints contain concurrent or repeated creation. No material security defect was demonstrated in the inspected paths. End-to-end authentication, database integration, and production rollout behavior remain unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The new reachable data is segment metadata in the shared database. Ordinary authenticated callers are restricted to merchants named in their verified identity. Existing admin and wildcard identities retain cross-merchant access and now inherit access to segments; this PR does not introduce those privileges. The inspected create path does not confer broadcast, membership, or dynamic-rule execution authority.

Trust Boundaries and Controls

  • observed — Bearer authentication precedes merchant authorization. merchant_scope prefers a nonempty query merchant_id, otherwise reads a string merchant_id from the JSON body for write methods, rejects missing scope with 400, and rejects unauthorized scope with 403. The handlers use only the returned merchant identifier, so conflicting query/body values do not create an authorization-to-storage mismatch. Foreign segment IDs remain constrained by both merchant and segment ID.

Resilience and Maintainability Implications

  • observed — The creation accessor executes one database statement through an async context-managed pooled connection and decodes the returned row afterward. Statement failure does not leave a partially initialized segment, and connection release is managed on exit. An uncertain committed outcome can leave a valid row despite a failed response, but uniqueness prevents a repeated request from creating another row under the same merchant/name.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 18.37% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 49 functions across 10 files. (2 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
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.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly summarizes the main changes: the segment table and create/read support for static lists.
Full details: Docstring Coverage

Explanation

Docstring coverage is 18.37% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 49 functions across 10 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit checks the segment rows,
And trims the names before it goes.
The lists are stored, the pages flow,
Merchant-scoped results now show.
It thumps once softly: “All set!”

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

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary — PR #1345 (segments P1/4)

Files reviewed: 12 · New issues raised: 1 MINOR · 0 MAJOR · 0 CRITICAL

What was verified (not assumed)

  • SQL safety: every builder in db/queries/segment.py parameterizes all values via $1/$2/$3 with merchant_id at $1 on every read and write; the f-strings interpolate only module constants (SEGMENT_TABLE, _SEGMENT_COLUMNS), never request data. Matches the repo-wide builder pattern.
  • Auth & tenancy: all three /segments routes declare merchant_scope(...) (verified against app/crm/auth.py — fail-closed 403/400, request-sourced merchant_id). Every query self-scopes on merchant_id = $1, so another merchant's segment id reads as None → 404. The route-walk test pins the door structurally.
  • Validation: SegmentCreate is a Pydantic model; blank-after-trim names are refused app-side (422) and DB-side (CHECK), and never reach the database.
  • Migration 084: a NEW sequential file (append-only rule satisfied); crm_touch_updated_at() exists since 048; gen_random_uuid() has broad precedent; no GRANT/REVOKE block matches sibling non-append-only tables (064).
  • Layering: queries → accessor → decoder respected; crm_connection() single-statement usage is the documented sanctioned pattern; UniqueViolation is exported from the db door; jsonb_value is the total shared primitive; no Segment name collision.

The one finding

💡 MINOR (084, line 35): the unique index is on raw name, so the whitespace-insensitive uniqueness the module promises is enforced only by the app-level trim. Upcoming writers (P2 member writer, P3 CSV import) could bypass it. Suggested fix: index on (merchant_id, btrim(name)) so the database enforces the invariant for every writer.

Verdict

Approve. No blocking criteria met. The MINOR is worth addressing before P2/P3 land writers into this table, but it does not block this phase.


-- T15 col 3: the name is unique per merchant — merchant_id first (tenancy law).
CREATE UNIQUE INDEX crm_segment_merchant_name_uq
ON crm_segment (merchant_id, name);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💡 MINOR: The whitespace-insensitive uniqueness this module promises is enforced only by the app-level trim — this index is on the raw name, so segments.segment_name()'s claim that "Diwali VIPs" and "Diwali VIPs " are "one name to the unique index" holds only for writers that trim first. The P2 member writer and P3 CSV import are both upcoming writers into this table; any one of them that skips the trim creates two rows a merchant cannot tell apart, and nothing at the DB level refuses it. Enforce the invariant in the database so every writer inherits it:

CREATE UNIQUE INDEX crm_segment_merchant_name_uq
    ON crm_segment (merchant_id, btrim(name));

btrim is IMMUTABLE so the expression index is valid, it pairs naturally with the btrim(name) <> '' CHECK above, and the app path is unchanged: inserts still surface UniqueViolation, which create_static_list already maps to SegmentNameTaken.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6f4e964, a CHECK instead of the expression index: name <> '' AND name !~ '^\s|\s$'. The table now refuses a blank or untrimmed name from any writer, so the plain (merchant_id, name) unique index already treats "Diwali VIPs" and "Diwali VIPs " as one name. Why the CHECK: it keeps the stored name clean for display, it's a format CHECK (CLAUDE.md), and \s also catches tabs and newlines, which btrim(name) alone doesn't. Every name the app writes passes it (segment_name() strips more). Checked in Postgres: "Diwali VIPs ", " Diwali", "Diwali\t", "Diwali\n", "" and " " are refused; "Diwali VIPs" and "Diwali – VIPs" pass. Thanks!

@manas-narra
manas-narra added this pull request to stack #1348 October 13, 2026 07:49

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary — PR #1345 (segments P1/4)

Files reviewed: 12 · New issues this run: 0 🔒 CRITICAL · 0 ⚠️ MAJOR · 1 💡 MINOR · 0 💬 SUGGESTION

Decision: APPROVE — no blocking criteria met.

What was verified

  • SQL safety — every builder in db/queries/segment.py uses $1/$2/$3 positional placeholders with merchant_id at $1 on every read and write (tenancy law); the f-strings interpolate only module constants (SEGMENT_TABLE, _SEGMENT_COLUMNS), never request data. No injection surface.
  • Auth & tenancy — all three /segments routes declare merchant_scope(...), which I confirmed internally performs get_current_user_with_rbac (JWT) plus the fail-closed assert_merchant_access check, so the GET routes without an explicit auth dependency are still authenticated and tenant-scoped. Cross-merchant reads answer 404/None, and the (merchant_id, id) tenant-pin index is in place for the future crm_broadcast FK.
  • Migration 084 — a new sequential file (append-only rule respected; no existing migration touched). crm_touch_updated_at() exists since 048; the table is not append-only, so no REVOKE/refusal trigger is required by the template. The earlier whitespace-uniqueness thread (Tara-ag) was resolved by the author's name <> '' AND name !~ '^\s|\s$' CHECK — not re-raised.
  • Layering — queries → accessor → decoder respected; crm_connection() is the sanctioned single-statement path; jsonb_value is the shared defensive decoder; UniqueViolation is imported through the module's db door.
  • Validation — SegmentCreate is a Pydantic model consistent with the module's WorkflowCreate precedent (merchant_scope reads merchant_id from the raw body on POST); name trimming/blank-refusal is tested and the DB CHECK backstops it for future writers.
  • Tests — 32 tests cover builders, the tenancy-door route walk, cross-merchant isolation, and every documented status code.

The one new comment

  • 💡 MINOR (app/crm/outreach/segments.py): the page and its total come from two separate statements, while the repo's own precedent (enrollment.py) carries count(*) OVER () so a page and its total come from one statement. Non-blocking; a concrete one-query suggestion is in the comment.

Nice work — the canon self-defense tests that read the migration SQL back are a particularly good guard against drift in P2–P4.

Comment on lines +63 to +65
"""One page of the merchant's segments, newest first, and the total."""
return SegmentPage(
items=await segment_accessor.list_segments(merchant_id, limit, offset),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💡 MINOR: The page and its total come from two separate statements here, but this repo's own precedent for paged lists is one statement: app/crm/outreach/db/queries/enrollment.py's list query carries count(*) OVER () on every row precisely so "a page and its '1–10 of 46' come from one statement", with a fallback count read only when the page comes back empty. Two statements cost an extra round trip on every page turn and can disagree under a concurrent write — the page renders rows from one snapshot while total reflects another, so a merchant can see 3 items beside "total: 4".

Suggestion — carry the total on the page query and keep the fallback for the empty page, mirroring the enrollment accessor:

# db/queries/segment.py
def list_segments_query(
    merchant_id: str, limit: int, offset: int
) -> Tuple[str, List[Any]]:
    query = f"""
        SELECT {_SEGMENT_COLUMNS}, (count(*) OVER ())::int AS total
        FROM {SEGMENT_TABLE}
        WHERE merchant_id = $1
        ORDER BY created_at DESC, id DESC
        LIMIT $2 OFFSET $3
    """
    return query, [merchant_id, limit, offset]
# db/accessors/segment.py
async def list_segments(
    merchant_id: str, limit: int, offset: int
) -> Tuple[List[Segment], int]:
    query, values = list_segments_query(merchant_id, limit, offset)
    async with crm_connection() as conn:
        rows = await conn.fetch(query, *values)
    if rows:
        return [decode_segment(r) for r in rows], int(rows[0]["total"])
    # Empty page: count(*) OVER () has no row to ride on — read the total
    # separately, the same fallback the enrollment accessor established.
    return [], await count_segments(merchant_id)

and segments.list_segments passes the pair straight into SegmentPage.

Migration 085 creates crm_segment (canon T15), owned by outreach: name
unique per merchant and stored trimmed (a CHECK refuses a blank or
untrimmed name, so the plain unique index holds for every writer), definition NULL for a static list (always a JSON
object when set), member_count + computed_at stamped 0/now at birth, and
the (merchant_id, id) pin target a broadcast will FK. The kind is derived
from definition on read, never stored.

POST /segments creates an empty static list (409 name taken, 422 blank),
GET /segments pages a merchant's segments with the total in the body,
GET /segments/{id} reads one (404 unknown or foreign). Every route
declares merchant_scope.

Members (T21) arrive in P2, CSV import in P3, dynamic segments in P4.
@manas-narra
manas-narra force-pushed the feat/segments-p1-tables branch from 114c074 to 414221b Compare October 15, 2026 12:15

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary — APPROVED

Scope: 12 changed files (+731/−2): migration 085, the queries/accessor/decoder trio, segments.py logic, Segment/SegmentPage schemas, the /segments routes, router mount, owner-map + docs updates, and 373 lines of tests.

New issues this run: 0 🔒 CRITICAL · 0 ⚠️ MAJOR · 0 💡 MINOR · 0 💬 SUGGESTION.

What was verified

  • SQL safety: all four builders in db/queries/segment.py are fully parameterized ($1–$3); f-strings interpolate only module constants (SEGMENT_TABLE, _SEGMENT_COLUMNS), never request/lead data. merchant_id is $1 on every read and write — tenancy law holds.
  • Auth & tenancy: all three /segments routes declare merchant_scope(...) (checked against app/crm/auth.py — JWT via get_current_user_with_rbac + fail-closed assert_merchant_access, marked for the route-walk test). Cross-tenant reads answer 404/None; the 403 path is tested. No client-supplied ID is trusted without the scope check.
  • Migration hygiene: 085 is a new sequential file — no existing migration touched. The name CHECK was verified at the raw-byte level: '\\s' is a single backslash in the file, so the regex behaves correctly under standard_conforming_strings=on, and the test asserts that exact text. crm_touch_updated_at() exists (048); the three indexes match the query shapes (unique name, tenant pin, newest-first list).
  • Layering & conventions: queries → accessor → decoder respected; assert row is not None matches the workflow/enrollment accessor precedent; jsonb_value decode is total; kind derived from definition, never stored.
  • Validation & errors: Pydantic body with bounded limit/offset; UniqueViolation → 409, blank-name ValueError → 422, unknown/foreign id → 404 all mapped and tested.

Pre-existing threads (not re-raised)

  • 085 whitespace-insensitive uniqueness (Tara-ag) — answered by the author with the format CHECK; verified above.
  • segments.py page+total from two statements vs the count(*) OVER () enrollment precedent — open MINOR for the author to consider; excluded from this decision per duplicate-avoidance.

Next steps

None blocking. P2 (members + writer), P3/P4 (dynamic segments) as planned — the tenant-pin index is already in place for crm_broadcast.segment_id.

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.

3 participants