Shurem commited on
Commit
ac8697d
·
1 Parent(s): bf0bce6

multiple structure changes

Browse files
alembic/versions/0013_desk_and_call_picked_by.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """leads.sent_to_desk_at, leads.call_picked_by
2
+
3
+ structure-plan.md Phase 1 -- Contacts becomes the intake hub; a lead only appears on the Cold Call Desk
4
+ once a rep explicitly sends it there (sent_to_desk_at). "Call picked by" (Receptionist / Decision Maker /
5
+ Team Member) is a tag-only mark on the card, never a stage change (call_picked_by).
6
+
7
+ Both nullable, no default: existing leads get NULL for both -- NOT auto-populated onto the desk. The next
8
+ phase (Cold Call Desk query change) decides how NULL is treated for leads that predate this column; until
9
+ then this migration only adds the columns.
10
+
11
+ Revision ID: 0013_desk_and_call_picked_by
12
+ Revises: 0012_user_password
13
+ Create Date: 2026-09-22
14
+ """
15
+
16
+ from typing import Sequence, Union
17
+
18
+ import sqlalchemy as sa
19
+ from alembic import op
20
+
21
+ revision: str = "0013_desk_and_call_picked_by"
22
+ down_revision: Union[str, None] = "0012_user_password"
23
+ branch_labels: Union[str, Sequence[str], None] = None
24
+ depends_on: Union[str, Sequence[str], None] = None
25
+
26
+
27
+ def upgrade() -> None:
28
+ op.add_column("leads", sa.Column("sent_to_desk_at", sa.DateTime(), nullable=True))
29
+ op.add_column("leads", sa.Column("call_picked_by", sa.String(length=16), nullable=True))
30
+ op.create_index("idx_leads_sent_to_desk", "leads", ["sent_to_desk_at"])
31
+
32
+
33
+ def downgrade() -> None:
34
+ op.drop_index("idx_leads_sent_to_desk", table_name="leads")
35
+ op.drop_column("leads", "call_picked_by")
36
+ op.drop_column("leads", "sent_to_desk_at")
alembic/versions/0014_hidden_and_callback.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """leads.hidden_at, leads.callback_at
2
+
3
+ structure-plan.md Phase 3 -- Cold Call Desk card redesign.
4
+
5
+ hidden_at Dead Line / Not Interested (cold call) mark a lead invisible everywhere -- recorded in the
6
+ database, shown on no screen. crud.list_leads() (Phase 3) excludes hidden_at IS NOT NULL
7
+ unconditionally, so every list consumer (Contacts, Pipeline, Cold Call Desk) is covered by
8
+ one change, with nothing to remember per page. A future phase's audit view will need its
9
+ own explicit override; none exists yet.
10
+ callback_at Callback Scheduled's date/time, asked the same way Meeting Booked asks for meeting_at --
11
+ deliberately a SEPARATE column, so a callback can never be confused with, or accidentally
12
+ show on, the Meetings calendar (which reads meeting_at).
13
+
14
+ Both nullable, no default, no backfill: every existing lead is unaffected (not hidden, no callback set).
15
+
16
+ Revision ID: 0014_hidden_and_callback
17
+ Revises: 0013_desk_and_call_picked_by
18
+ Create Date: 2026-09-22
19
+ """
20
+
21
+ from typing import Sequence, Union
22
+
23
+ import sqlalchemy as sa
24
+ from alembic import op
25
+
26
+ revision: str = "0014_hidden_and_callback"
27
+ down_revision: Union[str, None] = "0013_desk_and_call_picked_by"
28
+ branch_labels: Union[str, Sequence[str], None] = None
29
+ depends_on: Union[str, Sequence[str], None] = None
30
+
31
+
32
+ def upgrade() -> None:
33
+ op.add_column("leads", sa.Column("hidden_at", sa.DateTime(), nullable=True))
34
+ op.add_column("leads", sa.Column("callback_at", sa.DateTime(), nullable=True))
35
+ op.create_index("idx_leads_hidden", "leads", ["hidden_at"])
36
+ op.create_index("idx_leads_callback", "leads", ["callback_at"])
37
+
38
+
39
+ def downgrade() -> None:
40
+ op.drop_index("idx_leads_callback", table_name="leads")
41
+ op.drop_index("idx_leads_hidden", table_name="leads")
42
+ op.drop_column("leads", "callback_at")
43
+ op.drop_column("leads", "hidden_at")
app/api/leads.py CHANGED
@@ -24,10 +24,12 @@ from app.models import _utcnow as _now
24
  from app.schemas.lead import (
25
  AddNoteInput,
26
  CallOutcomeInput,
 
27
  CreateLeadInput,
28
  FindPhoneInput,
29
  LeadContactOut,
30
  LeadOut,
 
31
  RevealInput,
32
  UpdateLeadInput,
33
  )
@@ -158,13 +160,48 @@ def record_outcome(
158
  user: User | None = Depends(get_current_user),
159
  ):
160
  lead = _require(db, lead_id)
161
- updated = crud.apply_call_outcome(db, lead, payload.outcome, payload.rep_notes, payload.meeting_at, user)
 
 
162
  crud_notify.notify_mentions(
163
  db, user, payload.mentions, payload.mention_team, text=payload.rep_notes, lead_id=lead_id
164
  )
165
  return LeadOut.from_model(updated)
166
 
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  @router.post("/leads/{lead_id}/notes", response_model=LeadOut)
169
  def add_note(
170
  lead_id: int,
@@ -181,6 +218,15 @@ def add_note(
181
  return LeadOut.from_model(updated)
182
 
183
 
 
 
 
 
 
 
 
 
 
184
  @router.post("/leads/{lead_id}/email-sent", status_code=status.HTTP_204_NO_CONTENT)
185
  def mark_email_sent(
186
  lead_id: int, db: Session = Depends(get_db), user: User | None = Depends(get_current_user)
 
24
  from app.schemas.lead import (
25
  AddNoteInput,
26
  CallOutcomeInput,
27
+ CallPickedByInput,
28
  CreateLeadInput,
29
  FindPhoneInput,
30
  LeadContactOut,
31
  LeadOut,
32
+ MeetingOutcomeInput,
33
  RevealInput,
34
  UpdateLeadInput,
35
  )
 
160
  user: User | None = Depends(get_current_user),
161
  ):
162
  lead = _require(db, lead_id)
163
+ updated = crud.apply_call_outcome(
164
+ db, lead, payload.outcome, payload.rep_notes, payload.meeting_at, user, payload.callback_at
165
+ )
166
  crud_notify.notify_mentions(
167
  db, user, payload.mentions, payload.mention_team, text=payload.rep_notes, lead_id=lead_id
168
  )
169
  return LeadOut.from_model(updated)
170
 
171
 
172
+ @router.post("/leads/{lead_id}/call-picked-by", response_model=LeadOut)
173
+ def call_picked_by(
174
+ lead_id: int,
175
+ payload: CallPickedByInput,
176
+ db: Session = Depends(get_db),
177
+ user: User | None = Depends(get_current_user),
178
+ ):
179
+ """"Call picked by" row -- tag only, see crud.set_call_picked_by(). structure-plan.md Phase 3."""
180
+ lead = _require(db, lead_id)
181
+ return LeadOut.from_model(crud.set_call_picked_by(db, lead, payload.value, user))
182
+
183
+
184
+ @router.post("/leads/{lead_id}/needs-email", response_model=LeadOut)
185
+ def needs_email(
186
+ lead_id: int, db: Session = Depends(get_db), user: User | None = Depends(get_current_user)
187
+ ):
188
+ """"Email Send" button -- see crud.mark_needs_email(). structure-plan.md Phase 3/4."""
189
+ lead = _require(db, lead_id)
190
+ return LeadOut.from_model(crud.mark_needs_email(db, lead, user))
191
+
192
+
193
+ @router.post("/leads/{lead_id}/meeting-outcome", response_model=LeadOut)
194
+ def meeting_outcome(
195
+ lead_id: int,
196
+ payload: MeetingOutcomeInput,
197
+ db: Session = Depends(get_db),
198
+ user: User | None = Depends(get_current_user),
199
+ ):
200
+ """Meetings detail popup's 4-way picker -- see crud.set_meeting_outcome(). structure-plan.md Phase 5."""
201
+ lead = _require(db, lead_id)
202
+ return LeadOut.from_model(crud.set_meeting_outcome(db, lead, payload.stage, user))
203
+
204
+
205
  @router.post("/leads/{lead_id}/notes", response_model=LeadOut)
206
  def add_note(
207
  lead_id: int,
 
218
  return LeadOut.from_model(updated)
219
 
220
 
221
+ @router.post("/leads/{lead_id}/send-to-desk", response_model=LeadOut)
222
+ def send_to_desk(
223
+ lead_id: int, db: Session = Depends(get_db), user: User | None = Depends(get_current_user)
224
+ ):
225
+ """"Send to Cold Call Desk" on Contacts -- see crud.send_to_desk(). structure-plan.md Phase 2."""
226
+ lead = _require(db, lead_id)
227
+ return LeadOut.from_model(crud.send_to_desk(db, lead, user))
228
+
229
+
230
  @router.post("/leads/{lead_id}/email-sent", status_code=status.HTTP_204_NO_CONTENT)
231
  def mark_email_sent(
232
  lead_id: int, db: Session = Depends(get_db), user: User | None = Depends(get_current_user)
app/constants.py CHANGED
@@ -25,7 +25,8 @@ ACTIVE_STAGES = {
25
  "proposal_sent",
26
  }
27
 
28
- CALL_READY_STAGES = {"draft_ready", "followup_due"}
 
29
 
30
  PHONE_STATUSES = [
31
  "missing",
 
25
  "proposal_sent",
26
  }
27
 
28
+ # Retired by structure-plan.md Phase 3: Cold Call Desk membership is now sent_to_desk_at, not stage.
29
+ # Kept only as a comment for anyone grepping history -- CALL_READY_STAGES = {"draft_ready", "followup_due"}
30
 
31
  PHONE_STATUSES = [
32
  "missing",
app/crud/leads.py CHANGED
@@ -19,7 +19,7 @@ from typing import Any, Iterable, Sequence
19
  from sqlalchemy import Select, and_, case, func, or_, select
20
  from sqlalchemy.orm import Session
21
 
22
- from app.constants import ACTIVE_STAGES, CALL_READY_STAGES, DEFAULT_CATEGORY, STAGE_IDS
23
  from app.models import Lead, SalesProblem
24
  from app.models import _utcnow as _now
25
  from app.schemas.lead import CreateLeadInput, UpdateLeadInput, to_decimal
@@ -38,6 +38,11 @@ def list_leads(
38
  ) -> Sequence[Lead]:
39
  stmt: Select = select(Lead)
40
 
 
 
 
 
 
41
  if stage and stage != "all":
42
  stmt = stmt.where(Lead.pipeline_stage == stage)
43
 
@@ -176,6 +181,12 @@ def create_lead(db: Session, data: CreateLeadInput, user=None) -> Lead:
176
  screenshot_path="",
177
  enrichment_source="",
178
  enriched_at=None,
 
 
 
 
 
 
179
  )
180
  db.add(lead)
181
  db.commit()
@@ -200,7 +211,9 @@ def lead_from_dict(payload: dict) -> Lead:
200
  contact_linkedin=(payload.get("contact_linkedin") or "").strip(),
201
  country=(payload.get("country") or "").strip().upper()[:2],
202
  industry_tag=(payload.get("industry_tag") or DEFAULT_CATEGORY).strip(),
203
- deal_value=to_decimal(payload.get("deal_value") or 15000.0),
 
 
204
  pipeline_stage=payload.get("pipeline_stage") or "draft_ready",
205
  status="new",
206
  reason=(payload.get("reason") or "").strip(),
@@ -240,6 +253,11 @@ def lead_from_dict(payload: dict) -> Lead:
240
  phone_country=(payload.get("phone_country") or "").strip(),
241
  signalhire_uid=(payload.get("signalhire_uid") or "").strip(),
242
  discovery_citations=payload.get("discovery_citations") or None,
 
 
 
 
 
243
  )
244
 
245
 
@@ -370,8 +388,23 @@ def _author(user) -> str:
370
  return f"{name} · " if name else ""
371
 
372
 
 
 
 
 
 
 
 
 
 
373
  def apply_call_outcome(
374
- db: Session, lead: Lead, outcome: str, rep_notes: str = "", meeting_at: datetime | None = None, user=None
 
 
 
 
 
 
375
  ) -> Lead:
376
  """Port of db.update_lead_call_outcome, including the appended note format."""
377
  stamp = datetime.now().strftime("%b %d, %H:%M")
@@ -386,6 +419,12 @@ def apply_call_outcome(
386
  lead.notes = f"{lead.notes}\n{entry}".strip() if lead.notes else entry
387
  if meeting_at is not None:
388
  lead.meeting_at = meeting_at
 
 
 
 
 
 
389
  lead.updated_at = _now()
390
  if user is not None:
391
  lead.last_touched_by_user_id = user.id
@@ -436,6 +475,83 @@ def add_note(db: Session, lead: Lead, text: str, user=None) -> Lead:
436
  return lead
437
 
438
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  def delete_lead(db: Session, lead: Lead) -> None:
440
  db.delete(lead)
441
  db.commit()
@@ -487,12 +603,13 @@ def get_crm_metrics(db: Session) -> CrmMetrics:
487
  )
488
  ).scalar_one()
489
 
 
 
 
 
490
  call_ready = db.execute(
491
  select(func.count()).where(
492
- and_(
493
- Lead.pipeline_stage.in_(CALL_READY_STAGES),
494
- Lead.phone_status != "dead_disconnected",
495
- )
496
  )
497
  ).scalar_one()
498
 
 
19
  from sqlalchemy import Select, and_, case, func, or_, select
20
  from sqlalchemy.orm import Session
21
 
22
+ from app.constants import ACTIVE_STAGES, DEFAULT_CATEGORY, STAGE_IDS
23
  from app.models import Lead, SalesProblem
24
  from app.models import _utcnow as _now
25
  from app.schemas.lead import CreateLeadInput, UpdateLeadInput, to_decimal
 
38
  ) -> Sequence[Lead]:
39
  stmt: Select = select(Lead)
40
 
41
+ # Dead Line / Not Interested -- recorded, shown nowhere. Unconditional: every list consumer
42
+ # (Contacts, Pipeline, Cold Call Desk) is covered here, nothing to remember per page. A single lead
43
+ # fetched by id (get_lead()) is NOT filtered -- only this list.
44
+ stmt = stmt.where(Lead.hidden_at.is_(None))
45
+
46
  if stage and stage != "all":
47
  stmt = stmt.where(Lead.pipeline_stage == stage)
48
 
 
181
  screenshot_path="",
182
  enrichment_source="",
183
  enriched_at=None,
184
+ # Reverses structure-plan.md Phase 2/3's "a lead reaches Cold Call Desk only once Contacts
185
+ # explicitly sends it" (user, 2026-09-22: "hum ab chahty hein woh leads coldcalldesk me bhi jati
186
+ # rhyn... jo abhi abhi ati jayengi wo upar coldcalldesk me ati rhyngi") -- now every new lead lands
187
+ # there immediately, same moment it's created. "Send to Cold Call Desk"/"Resend" stays for a lead
188
+ # that later left the desk (e.g. Wrong Number) and needs to go back.
189
+ sent_to_desk_at=now,
190
  )
191
  db.add(lead)
192
  db.commit()
 
211
  contact_linkedin=(payload.get("contact_linkedin") or "").strip(),
212
  country=(payload.get("country") or "").strip().upper()[:2],
213
  industry_tag=(payload.get("industry_tag") or DEFAULT_CATEGORY).strip(),
214
+ # structure-plan.md Phase 8: default deal value is 10000 everywhere (was 15000 here, 20000 in
215
+ # lead_engine.py's CSV import, 18000 in the frontend's Add Lead form).
216
+ deal_value=to_decimal(payload.get("deal_value") or 10000.0),
217
  pipeline_stage=payload.get("pipeline_stage") or "draft_ready",
218
  status="new",
219
  reason=(payload.get("reason") or "").strip(),
 
253
  phone_country=(payload.get("phone_country") or "").strip(),
254
  signalhire_uid=(payload.get("signalhire_uid") or "").strip(),
255
  discovery_citations=payload.get("discovery_citations") or None,
256
+ # Reverses structure-plan.md Phase 2/3's "a lead reaches Cold Call Desk only once Contacts
257
+ # explicitly sends it" (user, 2026-09-22) -- every new lead lands there immediately now,
258
+ # regardless of source (AI discovery, CSV import, Google Maps, Apollo -- all go through this same
259
+ # function). See create_lead()'s matching comment above.
260
+ sent_to_desk_at=now,
261
  )
262
 
263
 
 
388
  return f"{name} · " if name else ""
389
 
390
 
391
+ # Outcomes that leave the Cold Call Desk -- structure-plan.md Phase 3. Dead Line/Not Interested also go
392
+ # further (hidden everywhere, below); Wrong Number/Callback/Meeting Booked/Closed just leave THIS desk,
393
+ # each landing somewhere else (Contacts, Pipeline, Pipeline+Meetings, Projects respectively).
394
+ _LEAVES_DESK = ("dead_number", "not_interested", "wrong_number", "callback_scheduled", "meeting_booked", "closed")
395
+ # Hidden everywhere (Contacts, Pipeline, Cold Call Desk) -- recorded, shown on no screen. See
396
+ # crud.list_leads()'s unconditional hidden_at filter.
397
+ _HIDES_LEAD = ("dead_number", "not_interested")
398
+
399
+
400
  def apply_call_outcome(
401
+ db: Session,
402
+ lead: Lead,
403
+ outcome: str,
404
+ rep_notes: str = "",
405
+ meeting_at: datetime | None = None,
406
+ user=None,
407
+ callback_at: datetime | None = None,
408
  ) -> Lead:
409
  """Port of db.update_lead_call_outcome, including the appended note format."""
410
  stamp = datetime.now().strftime("%b %d, %H:%M")
 
419
  lead.notes = f"{lead.notes}\n{entry}".strip() if lead.notes else entry
420
  if meeting_at is not None:
421
  lead.meeting_at = meeting_at
422
+ if outcome == "callback_scheduled" and callback_at is not None:
423
+ lead.callback_at = callback_at
424
+ if outcome in _LEAVES_DESK:
425
+ lead.sent_to_desk_at = None
426
+ if outcome in _HIDES_LEAD:
427
+ lead.hidden_at = _now()
428
  lead.updated_at = _now()
429
  if user is not None:
430
  lead.last_touched_by_user_id = user.id
 
475
  return lead
476
 
477
 
478
+ def send_to_desk(db: Session, lead: Lead, user=None) -> Lead:
479
+ """
480
+ "Send to Cold Call Desk" on Contacts -- structure-plan.md Phase 1/2. Always allowed (no
481
+ phone/email gate, per the user's decision): the rep decides when a lead is ready to call.
482
+ Idempotent -- pressing it again on a lead already on the desk just refreshes the timestamp,
483
+ same as re-sending.
484
+ """
485
+ lead.sent_to_desk_at = _now()
486
+ lead.updated_at = _now()
487
+ if user is not None:
488
+ lead.last_touched_by_user_id = user.id
489
+ db.commit()
490
+ db.refresh(lead)
491
+ from app.crud import call_events
492
+
493
+ call_events.record_event(db, lead.id, "sent_to_desk", None, getattr(user, "id", None))
494
+ return lead
495
+
496
+
497
+ def set_call_picked_by(db: Session, lead: Lead, value: str, user=None) -> Lead:
498
+ """
499
+ "Call picked by" row (Receptionist / Decision Maker / Team Member) -- structure-plan.md Phase 3.
500
+ Tag only: no stage change, no queue movement, not logged as a call_events row (it is not itself an
501
+ action -- the rep still picks a real disposition button separately, same call).
502
+ """
503
+ lead.call_picked_by = value
504
+ lead.updated_at = _now()
505
+ if user is not None:
506
+ lead.last_touched_by_user_id = user.id
507
+ db.commit()
508
+ db.refresh(lead)
509
+ return lead
510
+
511
+
512
+ def mark_needs_email(db: Session, lead: Lead, user=None) -> Lead:
513
+ """
514
+ "Email Send" button -- structure-plan.md Phase 3/4. A forward-looking flag ("this lead now needs an
515
+ email"), NOT a record that one was sent (that is still Mark Email Sent / record_email_sent,
516
+ unchanged, and does not touch this). Moves the lead into Pipeline and off the Cold Call Desk; the
517
+ stage id stays "contacted" (PIPELINE_STAGES' display label is what changed, "Outreach Sent" ->
518
+ "Email Send" -- see outreach-frontend/lib/constants.ts).
519
+ """
520
+ lead.pipeline_stage = "contacted"
521
+ lead.sent_to_desk_at = None
522
+ lead.updated_at = _now()
523
+ if user is not None:
524
+ lead.last_touched_by_user_id = user.id
525
+ db.commit()
526
+ db.refresh(lead)
527
+ from app.crud import call_events
528
+
529
+ call_events.record_event(db, lead.id, "stage_change", "contacted", getattr(user, "id", None))
530
+ return lead
531
+
532
+
533
+ def set_meeting_outcome(db: Session, lead: Lead, stage: str, user=None) -> Lead:
534
+ """
535
+ The Meetings detail popup's 4-way picker -- structure-plan.md Phase 5. Was a plain PATCH
536
+ pipeline_stage before; a dedicated function now because "lost" (Not Interested) must ALSO hide
537
+ the lead everywhere (same rule as Dead Line/Not Interested on Cold Call Desk, crud.apply_call_outcome()),
538
+ which the generic UpdateLeadInput deliberately cannot do -- hidden_at is not client-settable through
539
+ that path, so a manual Pipeline/Contacts stage change to Closed Lost does NOT hide a lead, only this does.
540
+ """
541
+ lead.pipeline_stage = stage
542
+ if stage == "lost":
543
+ lead.hidden_at = _now()
544
+ lead.updated_at = _now()
545
+ if user is not None:
546
+ lead.last_touched_by_user_id = user.id
547
+ db.commit()
548
+ db.refresh(lead)
549
+ from app.crud import call_events
550
+
551
+ call_events.record_event(db, lead.id, "stage_change", stage, getattr(user, "id", None))
552
+ return lead
553
+
554
+
555
  def delete_lead(db: Session, lead: Lead) -> None:
556
  db.delete(lead)
557
  db.commit()
 
603
  )
604
  ).scalar_one()
605
 
606
+ # Cold Call Desk's own membership rule, structure-plan.md Phase 3 -- a lead is on the desk once a
607
+ # rep has explicitly sent it there (sent_to_desk_at), full stop; hidden_at is redundant here (every
608
+ # outcome that hides a lead also clears sent_to_desk_at, see apply_call_outcome()) but kept explicit
609
+ # so this count can never drift from crud.list_leads()'s own rule if that ever changes independently.
610
  call_ready = db.execute(
611
  select(func.count()).where(
612
+ and_(Lead.sent_to_desk_at.is_not(None), Lead.hidden_at.is_(None))
 
 
 
613
  )
614
  ).scalar_one()
615
 
app/models.py CHANGED
@@ -70,6 +70,9 @@ class Lead(Base):
70
  Index("idx_leads_industry", "industry_tag"),
71
  Index("idx_leads_company", "company_name"),
72
  Index("idx_leads_created", "created_at"),
 
 
 
73
  )
74
 
75
  id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -86,7 +89,11 @@ class Lead(Base):
86
  contact_phone: Mapped[str] = mapped_column(String(500), default="")
87
  contact_linkedin: Mapped[str] = mapped_column(String(500), default="")
88
  industry_tag: Mapped[str] = mapped_column(String(190), default="⚡ Tech & Commercial Products")
89
- deal_value: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("15000.00"))
 
 
 
 
90
  pipeline_stage: Mapped[str] = mapped_column(String(32), nullable=False, default="draft_ready")
91
  # Vestigial in the Streamlit app: everything writes "new" and reads pipeline_stage.
92
  status: Mapped[str] = mapped_column(String(32), nullable=False, default="new")
@@ -175,6 +182,23 @@ class Lead(Base):
175
  # Follow-ups page filter. "" until a lead has ever been called.
176
  last_call_outcome: Mapped[str] = mapped_column(String(32), default="")
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  contacts: Mapped[list["LeadContact"]] = relationship(
179
  back_populates="lead", cascade="all, delete-orphan", passive_deletes=True
180
  )
 
70
  Index("idx_leads_industry", "industry_tag"),
71
  Index("idx_leads_company", "company_name"),
72
  Index("idx_leads_created", "created_at"),
73
+ Index("idx_leads_sent_to_desk", "sent_to_desk_at"),
74
+ Index("idx_leads_hidden", "hidden_at"),
75
+ Index("idx_leads_callback", "callback_at"),
76
  )
77
 
78
  id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
 
89
  contact_phone: Mapped[str] = mapped_column(String(500), default="")
90
  contact_linkedin: Mapped[str] = mapped_column(String(500), default="")
91
  industry_tag: Mapped[str] = mapped_column(String(190), default="⚡ Tech & Commercial Products")
92
+ # structure-plan.md Phase 8: default deal value is 10000 everywhere (was 15000/18000/20000 in three
93
+ # different places -- create_lead(), lead_from_dict() and this column default all disagreed). This
94
+ # column default is not actually reachable today (both places that build a Lead() always pass
95
+ # deal_value explicitly), kept in sync anyway so nothing here still says a different number.
96
+ deal_value: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("10000.00"))
97
  pipeline_stage: Mapped[str] = mapped_column(String(32), nullable=False, default="draft_ready")
98
  # Vestigial in the Streamlit app: everything writes "new" and reads pipeline_stage.
99
  status: Mapped[str] = mapped_column(String(32), nullable=False, default="new")
 
182
  # Follow-ups page filter. "" until a lead has ever been called.
183
  last_call_outcome: Mapped[str] = mapped_column(String(32), default="")
184
 
185
+ # --- structure restructure (revision 0013, structure-plan.md) ---
186
+ # Set the moment a rep presses "Send to Cold Call Desk" on Contacts; cleared when the lead leaves
187
+ # the desk on its own (Wrong Number goes back to Contacts and must be re-sent) or advances past it
188
+ # (Pipeline/Projects). Cold Call Desk's own query is "sent_to_desk_at is not null" -- this REPLACES
189
+ # "every lead not yet dispositioned" as the desk's membership rule.
190
+ sent_to_desk_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
191
+ # Who picked up the call, from the "Call picked by" row -- tag only, never changes pipeline_stage or
192
+ # queue position. NULL until set; "" is never written (unlike several other free-text columns above).
193
+ call_picked_by: Mapped[str | None] = mapped_column(String(16), nullable=True)
194
+ # Set by Dead Line / Not Interested (cold call), and later Meeting's Not Interested -- the lead is
195
+ # recorded but shown NOWHERE (crud.list_leads() excludes it unconditionally; a single-lead GET by id
196
+ # still works). A later phase adds an explicit audit view; until then this is genuinely invisible.
197
+ hidden_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
198
+ # Callback Scheduled's date/time (separate from meeting_at -- a callback is not a meeting and must
199
+ # never appear on the Meetings calendar). Pipeline shows/filters by this.
200
+ callback_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
201
+
202
  contacts: Mapped[list["LeadContact"]] = relationship(
203
  back_populates="lead", cascade="all, delete-orphan", passive_deletes=True
204
  )
app/schemas/lead.py CHANGED
@@ -162,6 +162,17 @@ class LeadOut(BaseModel):
162
  # None = before login existed.
163
  created_by_user_id: int | None = None
164
  last_touched_by_user_id: int | None = None
 
 
 
 
 
 
 
 
 
 
 
165
 
166
  @classmethod
167
  def from_model(cls, m: Any) -> "LeadOut":
@@ -219,6 +230,10 @@ class LeadOut(BaseModel):
219
  last_call_outcome=_s(getattr(m, "last_call_outcome", "")),
220
  created_by_user_id=getattr(m, "created_by_user_id", None),
221
  last_touched_by_user_id=getattr(m, "last_touched_by_user_id", None),
 
 
 
 
222
  call_tz=call_timezone(
223
  _s(m.contact_phone),
224
  _s(getattr(m, "phone_country", "")) or _s(getattr(m, "country", "")),
@@ -317,7 +332,8 @@ class CreateLeadInput(BaseModel):
317
  contact_email: str = Field(default="unknown", max_length=320)
318
  contact_phone: str = Field(default="", max_length=500)
319
  country: str = Field(default="", max_length=2)
320
- deal_value: float = Field(default=18000.0, ge=0, le=100_000_000)
 
321
  industry_tag: str = Field(default=DEFAULT_CATEGORY, max_length=120)
322
  pipeline_stage: PipelineStage = "draft_ready"
323
  reason: str = Field(default="", max_length=5000)
@@ -392,6 +408,29 @@ class CallOutcomeInput(BaseModel):
392
  # prompt shown when a rep clicks "🎯 Booked!" (see battlecard.tsx). Other
393
  # outcomes just omit it.
394
  meeting_at: datetime | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
 
397
  class AddNoteInput(BaseModel):
 
162
  # None = before login existed.
163
  created_by_user_id: int | None = None
164
  last_touched_by_user_id: int | None = None
165
+ # --- structure restructure (schema revision 0013, structure-plan.md) ---
166
+ # "" = not on the Cold Call Desk (still in Contacts, or sent back there e.g. by Wrong Number) --
167
+ # same "" -> unset convention as every other timestamp/string field above, not None.
168
+ sent_to_desk_at: str = ""
169
+ # "receptionist" | "decision_maker" | "team_member" | "" -- tag only, see models.Lead.
170
+ call_picked_by: str = ""
171
+ # "" = not hidden. Present on LeadOut for completeness; crud.list_leads() already excludes a hidden
172
+ # lead from every LIST response, so this is only ever seen via GET /leads/{id} directly.
173
+ hidden_at: str = ""
174
+ # "" = no callback scheduled. Pipeline shows/filters leads by this.
175
+ callback_at: str = ""
176
 
177
  @classmethod
178
  def from_model(cls, m: Any) -> "LeadOut":
 
230
  last_call_outcome=_s(getattr(m, "last_call_outcome", "")),
231
  created_by_user_id=getattr(m, "created_by_user_id", None),
232
  last_touched_by_user_id=getattr(m, "last_touched_by_user_id", None),
233
+ sent_to_desk_at=_dt_utc(getattr(m, "sent_to_desk_at", None)),
234
+ call_picked_by=_s(getattr(m, "call_picked_by", "")),
235
+ hidden_at=_dt_utc(getattr(m, "hidden_at", None)),
236
+ callback_at=_dt_utc(getattr(m, "callback_at", None)),
237
  call_tz=call_timezone(
238
  _s(m.contact_phone),
239
  _s(getattr(m, "phone_country", "")) or _s(getattr(m, "country", "")),
 
332
  contact_email: str = Field(default="unknown", max_length=320)
333
  contact_phone: str = Field(default="", max_length=500)
334
  country: str = Field(default="", max_length=2)
335
+ # structure-plan.md Phase 8: default deal value is 10000 everywhere (was 18000 here).
336
+ deal_value: float = Field(default=10000.0, ge=0, le=100_000_000)
337
  industry_tag: str = Field(default=DEFAULT_CATEGORY, max_length=120)
338
  pipeline_stage: PipelineStage = "draft_ready"
339
  reason: str = Field(default="", max_length=5000)
 
408
  # prompt shown when a rep clicks "🎯 Booked!" (see battlecard.tsx). Other
409
  # outcomes just omit it.
410
  meeting_at: datetime | None = None
411
+ # Only meaningful for outcome="callback_scheduled" -- same date/time prompt style as meeting_at
412
+ # above, but a SEPARATE column (callback_at) so it never shows on the Meetings calendar.
413
+ callback_at: datetime | None = None
414
+
415
+
416
+ class CallPickedByInput(BaseModel):
417
+ """POST /leads/{id}/call-picked-by -- the "Call picked by" row. Tag only, see crud.set_call_picked_by()."""
418
+
419
+ model_config = ConfigDict(extra="forbid")
420
+
421
+ value: Literal["receptionist", "decision_maker", "team_member"]
422
+
423
+
424
+ class MeetingOutcomeInput(BaseModel):
425
+ """
426
+ POST /leads/{id}/meeting-outcome -- the 4-way picker in the Meetings detail popup
427
+ (Client Closed / Send Proposal / Needs Follow-up / Not Interested). See
428
+ crud.set_meeting_outcome(). structure-plan.md Phase 5.
429
+ """
430
+
431
+ model_config = ConfigDict(extra="forbid")
432
+
433
+ stage: Literal["won", "proposal_sent", "followup_due", "lost"]
434
 
435
 
436
  class AddNoteInput(BaseModel):
app/services/agent_core.py CHANGED
@@ -106,7 +106,7 @@ Return a valid JSON array of objects:
106
  "contact_linkedin": "Direct personal LinkedIn profile URL (https://www.linkedin.com/in/...) or company LinkedIn page (https://www.linkedin.com/company/...)",
107
  "industry_tag": "Must be one of: '⛳ Custom Golf Carts', '🏎️ Custom Automotive & Mobility', '🛋️ Luxury Furniture & Interiors', '🏗️ Real Estate & Megaprojects', '⛵ Superyachts & Marine', '⚡ Tech & Commercial Products'",
108
  "country": "ISO 3166-1 alpha-2 code of where this company is actually based, e.g. US, CA, AE, GB",
109
- "deal_value": 35000,
110
  "reason_no_configurator": "Precise reason why Elipse Studio adds massive value"
111
  }
112
  ]
@@ -1278,7 +1278,8 @@ Identify {max_leads} real commercial businesses/brands/exhibitors matching this
1278
  # slice (covers an already-correct code the dict doesn't translate).
1279
  raw_country = (c.get("country") or "").strip()
1280
  country = countries.name_to_iso2(raw_country) or raw_country.upper()[:2]
1281
- deal_val = float(c.get("deal_value") or 25000.0)
 
1282
  # No email draft is generated at discovery any more (it cost output
1283
  # tokens for every lead, most of which never reach follow-up). A draft
1284
  # is written only when a lead is in follow-ups -- see the Follow-ups
 
106
  "contact_linkedin": "Direct personal LinkedIn profile URL (https://www.linkedin.com/in/...) or company LinkedIn page (https://www.linkedin.com/company/...)",
107
  "industry_tag": "Must be one of: '⛳ Custom Golf Carts', '🏎️ Custom Automotive & Mobility', '🛋️ Luxury Furniture & Interiors', '🏗️ Real Estate & Megaprojects', '⛵ Superyachts & Marine', '⚡ Tech & Commercial Products'",
108
  "country": "ISO 3166-1 alpha-2 code of where this company is actually based, e.g. US, CA, AE, GB",
109
+ "deal_value": 10000,
110
  "reason_no_configurator": "Precise reason why Elipse Studio adds massive value"
111
  }
112
  ]
 
1278
  # slice (covers an already-correct code the dict doesn't translate).
1279
  raw_country = (c.get("country") or "").strip()
1280
  country = countries.name_to_iso2(raw_country) or raw_country.upper()[:2]
1281
+ # structure-plan.md Phase 8: default deal value is 10000 everywhere (was 25000 here).
1282
+ deal_val = float(c.get("deal_value") or 10000.0)
1283
  # No email draft is generated at discovery any more (it cost output
1284
  # tokens for every lead, most of which never reach follow-up). A draft
1285
  # is written only when a lead is in follow-ups -- see the Follow-ups
app/services/lead_engine.py CHANGED
@@ -155,7 +155,8 @@ def query_apollo_leads(icp_prompt: str, limit: int = 50, api_key: str = None) ->
155
  "contact_phone": scrubbed_phone["phone"],
156
  "contact_linkedin": linkedin,
157
  "industry_tag": clean_cat,
158
- "deal_value": 25000.0,
 
159
  "phone_status": scrubbed_phone["status"],
160
  "source_prompt": f"Apollo ICP: {icp_prompt}",
161
  })
@@ -289,7 +290,8 @@ def parse_and_enrich_csv(
289
  website = ""
290
  industry_val = ""
291
  country_val = ""
292
- deal_val = 20000.0
 
293
  deal_found = False
294
  for row in rows:
295
  if not website and c_website and row.get(c_website, "").strip():
@@ -301,7 +303,7 @@ def parse_and_enrich_csv(
301
  if not deal_found and c_deal and row.get(c_deal):
302
  try:
303
  v = float(re.sub(r"[^\d.]", "", str(row[c_deal])))
304
- deal_val = v if v >= 5000 else 15000.0
305
  deal_found = True
306
  except Exception:
307
  pass
 
155
  "contact_phone": scrubbed_phone["phone"],
156
  "contact_linkedin": linkedin,
157
  "industry_tag": clean_cat,
158
+ # structure-plan.md Phase 8: default deal value is 10000 everywhere (was 25000 here).
159
+ "deal_value": 10000.0,
160
  "phone_status": scrubbed_phone["status"],
161
  "source_prompt": f"Apollo ICP: {icp_prompt}",
162
  })
 
290
  website = ""
291
  industry_val = ""
292
  country_val = ""
293
+ # structure-plan.md Phase 8: default deal value is 10000 everywhere.
294
+ deal_val = 10000.0
295
  deal_found = False
296
  for row in rows:
297
  if not website and c_website and row.get(c_website, "").strip():
 
303
  if not deal_found and c_deal and row.get(c_deal):
304
  try:
305
  v = float(re.sub(r"[^\d.]", "", str(row[c_deal])))
306
+ deal_val = v if v >= 5000 else 10000.0
307
  deal_found = True
308
  except Exception:
309
  pass
scripts/check_duplicate_leads.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ check_duplicate_leads.py -- read-only: lists every company_name that has more than one Lead row.
3
+
4
+ Investigating a user report (2026-09-22): "Key West Boats" showing twice on the Pipeline tab. Confirmed:
5
+ every duplicate found so far is source="Manual entry", created 2026-09-15/17 -- crud.is_duplicate() (the
6
+ case-insensitive company_name check) wasn't wired into the "Add a Lead" popover's endpoint until it went
7
+ live 2026-09-17 (see app/api/leads.py create_lead()'s own docstring), so a rep could accidentally submit
8
+ the same company twice before that fix landed. It's already wired into every creation path now (manual,
9
+ AI discovery, CSV import, Google Maps) -- these groups are historical leftovers from just before the fix,
10
+ not an ongoing bug.
11
+
12
+ uv run python scripts/check_duplicate_leads.py
13
+
14
+ Read-only -- prints what it finds, changes nothing. Prints enough per row (deal value, category, meeting/
15
+ callback dates) to tell which one is the real record before deciding which to delete.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+ from collections import defaultdict
22
+ from pathlib import Path
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
25
+
26
+ from sqlalchemy import select # noqa: E402
27
+
28
+ from app.database import SessionLocal # noqa: E402
29
+ from app.models import Lead # noqa: E402
30
+
31
+
32
+ def main() -> int:
33
+ db = SessionLocal()
34
+ try:
35
+ leads = list(db.scalars(select(Lead).order_by(Lead.company_name, Lead.created_at)).all())
36
+ groups: dict[str, list[Lead]] = defaultdict(list)
37
+ for lead in leads:
38
+ key = (lead.company_name or "").strip().lower()
39
+ if key:
40
+ groups[key].append(lead)
41
+
42
+ dupes = {k: v for k, v in groups.items() if len(v) > 1}
43
+ if not dupes:
44
+ print("No duplicate company names found.")
45
+ return 0
46
+
47
+ print(f"{len(dupes)} company name(s) with more than one lead row:\n")
48
+ for key, rows in sorted(dupes.items(), key=lambda kv: -len(kv[1])):
49
+ print(f"'{rows[0].company_name}' -- {len(rows)} rows:")
50
+ for lead in rows:
51
+ print(
52
+ f" #{lead.id:<6} stage={lead.pipeline_stage:<15} "
53
+ f"deal_value={lead.deal_value} industry_tag={lead.industry_tag!r}"
54
+ )
55
+ print(
56
+ f" meeting_at={lead.meeting_at} callback_at={lead.callback_at} "
57
+ f"sent_to_desk_at={lead.sent_to_desk_at} hidden_at={lead.hidden_at}"
58
+ )
59
+ print(
60
+ f" created_at={lead.created_at} source={lead.source_prompt!r} "
61
+ f"notes={'yes' if (lead.notes or '').strip() else 'no'}"
62
+ )
63
+ print()
64
+ return 0
65
+ finally:
66
+ db.close()
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())
scripts/check_structure_auto_desk.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ check_structure_auto_desk.py -- offline: every new lead now lands on Cold Call Desk immediately
3
+ (user, 2026-09-22: "hum ab chahty hein woh leads coldcalldesk me bhi jati rhyn... jo abhi abhi ati
4
+ jayengi wo upar coldcalldesk me ati rhyngi"). Reverses structure-plan.md Phase 2/3's "a lead reaches
5
+ the desk only once Contacts explicitly sends it".
6
+
7
+ uv run python scripts/check_structure_auto_desk.py
8
+
9
+ No database, no network -- throwaway in-memory SQLite. Covers both lead-creation paths: create_lead()
10
+ (manual Add Lead popover) and add_lead_dict()/lead_from_dict() (AI discovery, CSV import, Google Maps,
11
+ Apollo -- all four go through the same function).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
20
+
21
+ from sqlalchemy import create_engine # noqa: E402
22
+ from sqlalchemy.orm import sessionmaker # noqa: E402
23
+ from sqlalchemy.pool import StaticPool # noqa: E402
24
+
25
+ import app.models # noqa: E402,F401
26
+ from app.crud import leads as crud_leads # noqa: E402
27
+ from app.database import Base # noqa: E402
28
+ from app.schemas.lead import CreateLeadInput # noqa: E402
29
+
30
+ FAILED = 0
31
+
32
+
33
+ def check(label: str, ok: bool, detail: str = "") -> None:
34
+ global FAILED
35
+ FAILED += 0 if ok else 1
36
+ print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
37
+
38
+
39
+ def main() -> int:
40
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
41
+ Base.metadata.create_all(engine)
42
+ db = sessionmaker(bind=engine)()
43
+
44
+ print("\ncreate_lead() -- manual Add Lead popover")
45
+ lead = crud_leads.create_lead(db, CreateLeadInput(company_name="Manual Co"))
46
+ check("sent_to_desk_at set at creation", lead.sent_to_desk_at is not None)
47
+ # Cold Call Desk's own membership rule (components/cold-call/cold-call-view.tsx): every non-hidden
48
+ # lead with sent_to_desk_at set. list_leads() has no separate "call ready" filter of its own.
49
+ fetched = next(l for l in crud_leads.list_leads(db) if l.id == lead.id)
50
+ check("on the call-ready queue (list_leads + sent_to_desk_at set)", fetched.sent_to_desk_at is not None)
51
+
52
+ print("\nadd_lead_dict() -- AI discovery / CSV import / Google Maps / Apollo (shared path)")
53
+ for label in ("AI Co", "CSV Co", "Maps Co", "Apollo Co"):
54
+ lead = crud_leads.add_lead_dict(db, {"company_name": label, "source_prompt": "CSV Batch Import"})
55
+ check(f"{label}: sent_to_desk_at set at creation", lead.sent_to_desk_at is not None)
56
+
57
+ print(f"\n{'FAILED: ' + str(FAILED) if FAILED else 'All checks passed.'}")
58
+ return 1 if FAILED else 0
59
+
60
+
61
+ if __name__ == "__main__":
62
+ sys.exit(main())
scripts/check_structure_phase1.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ check_structure_phase1.py -- offline: do the new Phase 1 columns (structure-plan.md) line up?
3
+
4
+ uv run python scripts/check_structure_phase1.py
5
+
6
+ No database, no network. Checks: the model has sent_to_desk_at/call_picked_by, the migration
7
+ 0013 adds exactly those two columns and reverses cleanly, and LeadOut.from_model() round-trips
8
+ both through a throwaway in-memory SQLite lead. This only proves the plumbing is consistent --
9
+ it does NOT touch Neon, so `uv run alembic upgrade head` is still required separately.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from sqlalchemy import create_engine, inspect # noqa: E402
20
+ from sqlalchemy.orm import sessionmaker # noqa: E402
21
+ from sqlalchemy.pool import StaticPool # noqa: E402
22
+
23
+ import app.models # noqa: E402,F401
24
+ from app.database import Base # noqa: E402
25
+ from app.models import Lead # noqa: E402
26
+ from app.schemas.lead import LeadOut # noqa: E402
27
+
28
+ FAILED = 0
29
+
30
+
31
+ def check(label: str, ok: bool, detail: str = "") -> None:
32
+ global FAILED
33
+ FAILED += 0 if ok else 1
34
+ print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
35
+
36
+
37
+ def main() -> int:
38
+ print("\nmodel + migration")
39
+ cols = {c.name for c in Lead.__table__.columns}
40
+ check("Lead has sent_to_desk_at", "sent_to_desk_at" in cols)
41
+ check("Lead has call_picked_by", "call_picked_by" in cols)
42
+
43
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
44
+ Base.metadata.create_all(engine)
45
+ db_cols = {c["name"] for c in inspect(engine).get_columns("leads")}
46
+ check("sent_to_desk_at actually lands in the table DDL", "sent_to_desk_at" in db_cols)
47
+ check("call_picked_by actually lands in the table DDL", "call_picked_by" in db_cols)
48
+
49
+ print("\nLeadOut round-trip")
50
+ db = sessionmaker(bind=engine)()
51
+ lead = Lead(company_name="Acme")
52
+ db.add(lead)
53
+ db.commit()
54
+ db.refresh(lead)
55
+ out = LeadOut.from_model(lead)
56
+ check("a fresh lead reads sent_to_desk_at as '' (not on the desk)", out.sent_to_desk_at == "")
57
+ check("a fresh lead reads call_picked_by as ''", out.call_picked_by == "")
58
+
59
+ from datetime import datetime
60
+
61
+ lead.sent_to_desk_at = datetime(2026, 9, 22, 10, 0)
62
+ lead.call_picked_by = "receptionist"
63
+ db.commit()
64
+ db.refresh(lead)
65
+ out2 = LeadOut.from_model(lead)
66
+ check("sent_to_desk_at serializes as a UTC ISO string ('Z' suffix)", out2.sent_to_desk_at.endswith("Z"))
67
+ check("call_picked_by round-trips", out2.call_picked_by == "receptionist")
68
+
69
+ # Wrong Number's "leaves the desk" behaviour: clearing sent_to_desk_at.
70
+ lead.sent_to_desk_at = None
71
+ db.commit()
72
+ db.refresh(lead)
73
+ check("clearing sent_to_desk_at (Wrong Number -> back to Contacts) reads back as ''",
74
+ LeadOut.from_model(lead).sent_to_desk_at == "")
75
+
76
+ print()
77
+ print("All checks passed." if not FAILED else f"{FAILED} check(s) FAILED")
78
+ return 1 if FAILED else 0
79
+
80
+
81
+ if __name__ == "__main__":
82
+ sys.exit(main())
scripts/check_structure_phase2.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ check_structure_phase2.py -- offline: "Send to Cold Call Desk" (structure-plan.md Phase 2).
3
+
4
+ uv run python scripts/check_structure_phase2.py
5
+
6
+ No database, no network -- throwaway in-memory SQLite. Covers: sending a fresh lead marks it (and is
7
+ attributed to the signed-in user), re-sending an already-sent lead just refreshes the timestamp, it is
8
+ logged as an activity event, and a lead nobody has sent yet reads as "" (matches structure-plan.md's
9
+ "Cold Call Desk query becomes sent_to_desk_at is not null").
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from sqlalchemy import select # noqa: E402
20
+ from sqlalchemy.orm import sessionmaker # noqa: E402
21
+ from sqlalchemy.pool import StaticPool # noqa: E402
22
+ from sqlalchemy import create_engine # noqa: E402
23
+
24
+ import app.models # noqa: E402,F401
25
+ from app.crud import leads as crud_leads # noqa: E402
26
+ from app.database import Base # noqa: E402
27
+ from app.models import CallEvent, User # noqa: E402
28
+ from app.schemas.lead import LeadOut # noqa: E402
29
+
30
+ FAILED = 0
31
+
32
+
33
+ def check(label: str, ok: bool, detail: str = "") -> None:
34
+ global FAILED
35
+ FAILED += 0 if ok else 1
36
+ print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
37
+
38
+
39
+ def main() -> int:
40
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
41
+ Base.metadata.create_all(engine)
42
+ db = sessionmaker(bind=engine)()
43
+
44
+ ali = User(email="ali@elipsestudio.com", name="Ali Khan", is_active=True)
45
+ db.add(ali)
46
+ db.commit()
47
+
48
+ lead = crud_leads.add_lead_dict(db, {"company_name": "Acme"})
49
+ check("a fresh lead is not on the desk", LeadOut.from_model(lead).sent_to_desk_at == "")
50
+
51
+ sent = crud_leads.send_to_desk(db, lead, ali)
52
+ out = LeadOut.from_model(sent)
53
+ check("sending it sets sent_to_desk_at", out.sent_to_desk_at != "")
54
+ check("sending it credits the signed-in user as last-touched", sent.last_touched_by_user_id == ali.id)
55
+
56
+ events = db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all()
57
+ check("it is logged as one activity event", len(events) == 1 and events[0].event_type == "sent_to_desk")
58
+ check("the event is attributed to the user", events[0].user_id == ali.id)
59
+
60
+ first_stamp = sent.sent_to_desk_at
61
+ resent = crud_leads.send_to_desk(db, lead, None) # re-send with nobody signed in
62
+ check("re-sending refreshes the timestamp", resent.sent_to_desk_at is not None
63
+ and resent.sent_to_desk_at >= first_stamp)
64
+ check("re-sending with nobody signed in still works (no crash)", True)
65
+ check("re-sending logs a second event", len(db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all()) == 2)
66
+
67
+ print()
68
+ print("All checks passed." if not FAILED else f"{FAILED} check(s) FAILED")
69
+ return 1 if FAILED else 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main())
scripts/check_structure_phase3.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ check_structure_phase3.py -- offline: Cold Call Desk card redesign (structure-plan.md Phase 3).
3
+
4
+ uv run python scripts/check_structure_phase3.py
5
+
6
+ No database, no network -- throwaway in-memory SQLite. Covers: which outcomes leave the desk
7
+ (sent_to_desk_at cleared) vs stay on it, Dead Line/Not Interested hiding a lead from every list
8
+ query while a direct-by-id fetch still finds it, Wrong Number leaving the desk WITHOUT hiding it,
9
+ Callback Scheduled's separate callback_at column, "Call picked by" (tag only, no call_events row),
10
+ and "Email Send" (crud.mark_needs_email: stage -> contacted, leaves the desk, one activity event).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
20
+
21
+ from sqlalchemy import create_engine, select # noqa: E402
22
+ from sqlalchemy.orm import sessionmaker # noqa: E402
23
+ from sqlalchemy.pool import StaticPool # noqa: E402
24
+
25
+ import app.models # noqa: E402,F401
26
+ from app.crud import leads as crud_leads # noqa: E402
27
+ from app.database import Base # noqa: E402
28
+ from app.models import CallEvent # noqa: E402
29
+
30
+ FAILED = 0
31
+
32
+
33
+ def check(label: str, ok: bool, detail: str = "") -> None:
34
+ global FAILED
35
+ FAILED += 0 if ok else 1
36
+ print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
37
+
38
+
39
+ def fresh_lead(db, name: str):
40
+ lead = crud_leads.add_lead_dict(db, {"company_name": name})
41
+ crud_leads.send_to_desk(db, lead)
42
+ return lead
43
+
44
+
45
+ def visible_ids(db) -> set[int]:
46
+ return {l.id for l in crud_leads.list_leads(db)}
47
+
48
+
49
+ def main() -> int:
50
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
51
+ Base.metadata.create_all(engine)
52
+ db = sessionmaker(bind=engine)()
53
+
54
+ print("\nDead Line / Not Interested -- hidden everywhere")
55
+ for outcome, label in [("dead_number", "Dead Line"), ("not_interested", "Not Interested")]:
56
+ lead = fresh_lead(db, f"{label} Co")
57
+ crud_leads.apply_call_outcome(db, lead, outcome)
58
+ check(f"{label}: hidden_at is set", lead.hidden_at is not None)
59
+ check(f"{label}: leaves the desk too", lead.sent_to_desk_at is None)
60
+ check(f"{label}: excluded from list_leads()", lead.id not in visible_ids(db))
61
+ check(f"{label}: still fetchable directly by id", crud_leads.get_lead(db, lead.id) is not None)
62
+
63
+ print("\nWrong Number -- leaves the desk, stays visible (not hidden)")
64
+ lead = fresh_lead(db, "Wrong Number Co")
65
+ crud_leads.apply_call_outcome(db, lead, "wrong_number")
66
+ check("hidden_at is NOT set", lead.hidden_at is None)
67
+ check("leaves the desk", lead.sent_to_desk_at is None)
68
+ check("still shows in list_leads() (back in Contacts)", lead.id in visible_ids(db))
69
+ check("last_call_outcome records it for the Contacts badge", lead.last_call_outcome == "wrong_number")
70
+
71
+ print("\nCallback Scheduled -- separate column, leaves the desk, stays visible")
72
+ lead = fresh_lead(db, "Callback Co")
73
+ when = datetime(2026, 9, 25, 14, 30)
74
+ crud_leads.apply_call_outcome(db, lead, "callback_scheduled", callback_at=when)
75
+ check("callback_at is set to the given time", lead.callback_at == when)
76
+ check("meeting_at is untouched (never confused with a meeting)", lead.meeting_at is None)
77
+ check("leaves the desk", lead.sent_to_desk_at is None)
78
+ check("still shows in list_leads() (visible in Pipeline)", lead.id in visible_ids(db))
79
+
80
+ print("\nMeeting Booked / Closed -- leave the desk (unchanged destination, new desk-leaving rule)")
81
+ for outcome in ("meeting_booked", "closed"):
82
+ lead = fresh_lead(db, f"{outcome} Co")
83
+ crud_leads.apply_call_outcome(db, lead, outcome)
84
+ check(f"{outcome}: leaves the desk", lead.sent_to_desk_at is None)
85
+ check(f"{outcome}: not hidden", lead.hidden_at is None)
86
+
87
+ print("\nVoicemail / Hang Up / No Answer -- STAY on the desk")
88
+ for outcome in ("voicemail", "hang_up", "no_answer"):
89
+ lead = fresh_lead(db, f"{outcome} Co")
90
+ before = lead.sent_to_desk_at
91
+ crud_leads.apply_call_outcome(db, lead, outcome)
92
+ check(f"{outcome}: sent_to_desk_at unchanged", lead.sent_to_desk_at == before)
93
+
94
+ print("\nCall picked by -- tag only")
95
+ lead = fresh_lead(db, "Tag Co") # fresh_lead() itself logs one "sent_to_desk" event -- that is the baseline
96
+ before_stage, before_desk = lead.pipeline_stage, lead.sent_to_desk_at
97
+ before_events = len(db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all())
98
+ crud_leads.set_call_picked_by(db, lead, "receptionist")
99
+ check("call_picked_by is set", lead.call_picked_by == "receptionist")
100
+ check("pipeline_stage is untouched", lead.pipeline_stage == before_stage)
101
+ check("sent_to_desk_at is untouched (still on the desk)", lead.sent_to_desk_at == before_desk)
102
+ after_events = len(db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all())
103
+ check("no NEW call_events row is created", after_events == before_events, f"{before_events} -> {after_events}")
104
+ crud_leads.set_call_picked_by(db, lead, "team_member")
105
+ check("re-tagging overwrites the previous mark", lead.call_picked_by == "team_member")
106
+
107
+ print("\nEmail Send (mark_needs_email) -- flags for email, moves to Pipeline, leaves the desk")
108
+ lead = fresh_lead(db, "Email Send Co") # fresh_lead() logs one "sent_to_desk" event -- the baseline
109
+ before_events = len(db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all())
110
+ crud_leads.mark_needs_email(db, lead)
111
+ check("stage becomes contacted (\"Email Send\")", lead.pipeline_stage == "contacted")
112
+ check("leaves the desk", lead.sent_to_desk_at is None)
113
+ events = db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all()
114
+ check("logs exactly ONE NEW activity event", len(events) == before_events + 1)
115
+ check("...and it is a stage_change", events[-1].event_type == "stage_change" and events[-1].outcome == "contacted")
116
+ check("still shows in list_leads() (visible in Pipeline)", lead.id in visible_ids(db))
117
+
118
+ print()
119
+ print("All checks passed." if not FAILED else f"{FAILED} check(s) FAILED")
120
+ return 1 if FAILED else 0
121
+
122
+
123
+ if __name__ == "__main__":
124
+ sys.exit(main())
scripts/check_structure_phase5.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ check_structure_phase5.py -- offline: Meetings 4-way outcome picker (structure-plan.md Phase 5).
3
+
4
+ uv run python scripts/check_structure_phase5.py
5
+
6
+ No database, no network -- throwaway in-memory SQLite. Covers: crud.set_meeting_outcome() for all four
7
+ outcomes -- Client Closed / Send Proposal / Needs Follow-up just change stage (unchanged behaviour);
8
+ Not Interested ALSO hides the lead (the whole point of this phase); a manual stage-dropdown PATCH to
9
+ "lost" (Contacts/Pipeline's own Closed Lost option) does NOT hide a lead -- only this dedicated path does.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from sqlalchemy import create_engine, select # noqa: E402
20
+ from sqlalchemy.orm import sessionmaker # noqa: E402
21
+ from sqlalchemy.pool import StaticPool # noqa: E402
22
+
23
+ import app.models # noqa: E402,F401
24
+ from app.crud import leads as crud_leads # noqa: E402
25
+ from app.database import Base # noqa: E402
26
+ from app.models import CallEvent # noqa: E402
27
+ from app.schemas.lead import UpdateLeadInput # noqa: E402
28
+
29
+ FAILED = 0
30
+
31
+
32
+ def check(label: str, ok: bool, detail: str = "") -> None:
33
+ global FAILED
34
+ FAILED += 0 if ok else 1
35
+ print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
36
+
37
+
38
+ def main() -> int:
39
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
40
+ Base.metadata.create_all(engine)
41
+ db = sessionmaker(bind=engine)()
42
+
43
+ print("\nClient Closed / Send Proposal / Needs Follow-up -- stage only, never hidden")
44
+ for stage in ("won", "proposal_sent", "followup_due"):
45
+ lead = crud_leads.add_lead_dict(db, {"company_name": f"{stage} Co"})
46
+ crud_leads.set_meeting_outcome(db, lead, stage)
47
+ check(f"{stage}: pipeline_stage is set", lead.pipeline_stage == stage)
48
+ check(f"{stage}: not hidden", lead.hidden_at is None)
49
+ events = db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all()
50
+ check(f"{stage}: logs one stage_change event", len(events) == 1 and events[0].outcome == stage)
51
+
52
+ print("\nNot Interested -- hides the lead everywhere")
53
+ lead = crud_leads.add_lead_dict(db, {"company_name": "Not Interested Co"})
54
+ crud_leads.set_meeting_outcome(db, lead, "lost")
55
+ check("pipeline_stage is lost", lead.pipeline_stage == "lost")
56
+ check("hidden_at is set", lead.hidden_at is not None)
57
+ check("excluded from list_leads()", lead.id not in {l.id for l in crud_leads.list_leads(db)})
58
+ check("still fetchable directly by id", crud_leads.get_lead(db, lead.id) is not None)
59
+
60
+ print("\nManual stage change to Closed Lost (Contacts/Pipeline dropdown) does NOT hide a lead")
61
+ lead = crud_leads.add_lead_dict(db, {"company_name": "Manual Lost Co"})
62
+ crud_leads.update_lead(db, lead, UpdateLeadInput(pipeline_stage="lost"))
63
+ check("pipeline_stage is lost", lead.pipeline_stage == "lost")
64
+ check("hidden_at is NOT set -- only the meeting-outcome path hides a lead", lead.hidden_at is None)
65
+ check("still shows in list_leads()", lead.id in {l.id for l in crud_leads.list_leads(db)})
66
+
67
+ print()
68
+ print("All checks passed." if not FAILED else f"{FAILED} check(s) FAILED")
69
+ return 1 if FAILED else 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main())
scripts/check_structure_phase8.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ check_structure_phase8.py -- offline: default deal value is 10000 everywhere (structure-plan.md Phase 8).
3
+
4
+ uv run python scripts/check_structure_phase8.py
5
+
6
+ No database, no network. Checks all five places a default deal value used to disagree (15000/18000/20000):
7
+ the Lead model's own column default, lead_from_dict()'s fallback (AI/Maps leads), lead_engine.py's CSV
8
+ importer (its own default AND its "value too small, don't trust it" fallback), and CreateLeadInput's
9
+ schema default (manual Add Lead, POST /leads). All five now read 10000.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from decimal import Decimal
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
19
+
20
+ import app.models # noqa: E402,F401
21
+ from app.crud.leads import lead_from_dict # noqa: E402
22
+ from app.models import Lead # noqa: E402
23
+ from app.schemas.lead import CreateLeadInput # noqa: E402
24
+ from app.services.lead_engine import parse_and_enrich_csv # noqa: E402
25
+
26
+ FAILED = 0
27
+
28
+
29
+ def check(label: str, ok: bool, detail: str = "") -> None:
30
+ global FAILED
31
+ FAILED += 0 if ok else 1
32
+ print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
33
+
34
+
35
+ def main() -> int:
36
+ print("\nLead model column default")
37
+ col = Lead.__table__.columns["deal_value"]
38
+ check("Lead.deal_value defaults to 10000.00", col.default.arg == Decimal("10000.00"), str(col.default.arg))
39
+
40
+ print("\nlead_from_dict() -- AI / Maps leads with no deal_value given")
41
+ lead = lead_from_dict({"company_name": "No Deal Value Co"})
42
+ check("defaults to 10000", lead.deal_value == Decimal("10000.00"), str(lead.deal_value))
43
+
44
+ print("\nCreateLeadInput -- POST /leads (Add a Lead) schema default")
45
+ created = CreateLeadInput(company_name="Schema Default Co")
46
+ check("defaults to 10000.0", created.deal_value == 10000.0, str(created.deal_value))
47
+
48
+ print("\nCSV import -- lead_engine.parse_and_enrich_csv()")
49
+ no_deal_csv = "Company,Website\nNo Deal Co,example.com\n"
50
+ result = parse_and_enrich_csv(no_deal_csv)
51
+ check("row with no deal-value column at all defaults to 10000", result["leads"][0]["deal_value"] == 10000.0,
52
+ str(result["leads"][0]["deal_value"]))
53
+
54
+ junk_csv = "Company,Deal Value\nJunk Deal Co,not-a-real-number\n"
55
+ result2 = parse_and_enrich_csv(junk_csv)
56
+ check("an unparseable Deal Value cell also defaults to 10000", result2["leads"][0]["deal_value"] == 10000.0,
57
+ str(result2["leads"][0]["deal_value"]))
58
+
59
+ too_small_csv = "Company,Deal Value\nTiny Deal Co,500\n"
60
+ result3 = parse_and_enrich_csv(too_small_csv)
61
+ check("a parsed value under 5000 (untrustworthy) falls back to 10000, not the raw 500",
62
+ result3["leads"][0]["deal_value"] == 10000.0, str(result3["leads"][0]["deal_value"]))
63
+
64
+ real_csv = "Company,Deal Value\nReal Deal Co,75000\n"
65
+ result4 = parse_and_enrich_csv(real_csv)
66
+ check("a real value >= 5000 is kept as-is, not overridden", result4["leads"][0]["deal_value"] == 75000.0,
67
+ str(result4["leads"][0]["deal_value"]))
68
+
69
+ print()
70
+ print("All checks passed." if not FAILED else f"{FAILED} check(s) FAILED")
71
+ return 1 if FAILED else 0
72
+
73
+
74
+ if __name__ == "__main__":
75
+ sys.exit(main())
scripts/delete_lead_by_id.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ delete_lead_by_id.py -- permanently deletes one lead, by id, after printing it for confirmation.
3
+
4
+ Generic cleanup tool for the duplicate-lead groups check_duplicate_leads.py finds (user, 2026-09-22 --
5
+ "Key West Boats" and several others turned out to be manually double-added before the create_lead()
6
+ duplicate check went live 2026-09-17). Deletes exactly one id per run, on purpose -- no bulk mode -- so a
7
+ wrong id typed in only ever costs one lead, not a whole company's history.
8
+
9
+ uv run python scripts/delete_lead_by_id.py <id> # dry run -- prints the lead, deletes nothing
10
+ uv run python scripts/delete_lead_by_id.py <id> --apply # actually deletes it, permanently
11
+
12
+ Irreversible once run with --apply -- lead_contacts rows for it cascade-delete too.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22
+
23
+ from app.crud import leads as crud_leads # noqa: E402
24
+ from app.database import SessionLocal # noqa: E402
25
+
26
+
27
+ def main() -> int:
28
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
29
+ parser.add_argument("lead_id", type=int, help="the lead's id, e.g. from check_duplicate_leads.py")
30
+ parser.add_argument("--apply", action="store_true", help="actually delete (default: dry run only)")
31
+ args = parser.parse_args()
32
+
33
+ db = SessionLocal()
34
+ try:
35
+ lead = crud_leads.get_lead(db, args.lead_id)
36
+ if not lead:
37
+ print(f"No lead with id #{args.lead_id}.")
38
+ return 1
39
+
40
+ print(f"#{lead.id} {lead.company_name}")
41
+ print(f" stage={lead.pipeline_stage} deal_value={lead.deal_value} industry_tag={lead.industry_tag!r}")
42
+ print(f" meeting_at={lead.meeting_at} callback_at={lead.callback_at}")
43
+ print(f" created_at={lead.created_at} source={lead.source_prompt!r}")
44
+
45
+ if not args.apply:
46
+ print(f"\nDry run only -- re-run with --apply to permanently delete #{lead.id}.")
47
+ return 0
48
+
49
+ crud_leads.delete_lead(db, lead)
50
+ print(f"\nDeleted #{lead.id} ({lead.company_name}) permanently.")
51
+ return 0
52
+ finally:
53
+ db.close()
54
+
55
+
56
+ if __name__ == "__main__":
57
+ sys.exit(main())
scripts/delete_user.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ delete_user.py -- remove (or just switch off) one account. Admin tool, dry run by default.
3
+
4
+ uv run python scripts/delete_user.py --email demo@elipsestudio.com # shows what it would do
5
+ uv run python scripts/delete_user.py --email demo@elipsestudio.com --apply # deletes the account
6
+ uv run python scripts/delete_user.py --email demo@elipsestudio.com --deactivate --apply # keeps it, blocks sign-in
7
+
8
+ What a delete does to the person's history (the database rows point at the user with ON DELETE SET NULL):
9
+ their calls, notes, stage changes, added leads and bulk jobs stay, but read as "Unassigned" from then on, and they
10
+ disappear from the Today and Weekly Report rows. Notifications sent TO them are removed. Nothing else is touched.
11
+ If you would rather keep their name on old activity, use --deactivate: they cannot sign in and drop out of the @mention
12
+ list, and their history keeps their name. A deleted email can sign up again later while it is still on the approved list.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22
+
23
+ from sqlalchemy import func, select # noqa: E402
24
+
25
+ from app.crud import users as crud_users # noqa: E402
26
+ from app.database import SessionLocal # noqa: E402
27
+ from app.models import CallEvent, Job, Lead, Notification # noqa: E402
28
+
29
+
30
+ def count(db, stmt) -> int:
31
+ return int(db.execute(stmt).scalar_one())
32
+
33
+
34
+ def main() -> int:
35
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
36
+ ap.add_argument("--email", required=True)
37
+ ap.add_argument("--apply", action="store_true", help="actually do it (without this it only reports)")
38
+ ap.add_argument("--deactivate", action="store_true", help="switch the account off instead of deleting it")
39
+ args = ap.parse_args()
40
+ email = args.email.strip().lower()
41
+
42
+ db = SessionLocal()
43
+ try:
44
+ user = crud_users.get_user_by_email(db, email)
45
+ if user is None:
46
+ print(f"No account for {email}. Run scripts/check_users.py to see the exact emails.")
47
+ return 1
48
+
49
+ uid = user.id
50
+ n_events = count(db, select(func.count()).select_from(CallEvent).where(CallEvent.user_id == uid))
51
+ n_created = count(db, select(func.count()).select_from(Lead).where(Lead.created_by_user_id == uid))
52
+ n_touched = count(db, select(func.count()).select_from(Lead).where(Lead.last_touched_by_user_id == uid))
53
+ n_jobs = count(db, select(func.count()).select_from(Job).where(Job.user_id == uid))
54
+ n_in = count(db, select(func.count()).select_from(Notification).where(Notification.user_id == uid))
55
+ n_out = count(db, select(func.count()).select_from(Notification).where(Notification.actor_user_id == uid))
56
+
57
+ print(f"Account: {user.email} (id {uid}, name {user.name!r}, {'active' if user.is_active else 'already inactive'})")
58
+ print(f" actions recorded (calls, notes, stage changes, emails): {n_events}")
59
+ print(f" leads added: {n_created} leads last touched: {n_touched} bulk jobs started: {n_jobs}")
60
+ print(f" notifications received: {n_in} notifications they sent: {n_out}")
61
+ print()
62
+ if args.deactivate:
63
+ print("Would: switch the account off (cannot sign in, leaves the @mention list). History keeps the name.")
64
+ else:
65
+ print("Would: DELETE the account. The actions/leads/jobs above stay but become 'Unassigned';")
66
+ print(" notifications received are deleted; notifications they sent lose the sender name.")
67
+ if not args.apply:
68
+ print("\nDry run: nothing changed. Add --apply to do it.")
69
+ return 0
70
+
71
+ if args.deactivate:
72
+ user.is_active = False
73
+ db.commit()
74
+ print(f"\nDone. {email} is deactivated.")
75
+ else:
76
+ db.delete(user)
77
+ db.commit()
78
+ print(f"\nDone. {email} is deleted.")
79
+ finally:
80
+ db.close()
81
+ return 0
82
+
83
+
84
+ if __name__ == "__main__":
85
+ sys.exit(main())
scripts/revert_last_csv_import.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ revert_last_csv_import.py — permanently deletes the most recent CSV-import batch (user request,
3
+ 2026-09-22: "kal raat ko jo humny csv sy leads add ki thin woh revert krdyn sari, ~105 leads, permanent
4
+ delete"). Same dry-run-by-default / --apply convention as cleanup_partial_september_import.py.
5
+
6
+ Only ever touches leads with source_prompt == "CSV Batch Import" (never any other source) -- and only the
7
+ most recent CONTIGUOUS cluster of them (gaps between consecutive created_at values under 4 hours are
8
+ treated as the same import run; a gap of 4+ hours starts a new, untouched, older batch). This finds "last
9
+ night's import" without hardcoding a date/timezone, so it can't accidentally reach back into an older CSV
10
+ import (e.g. the September tab) that happens to share the same source_prompt.
11
+
12
+ uv run python scripts/revert_last_csv_import.py # dry run -- prints what it would delete
13
+ uv run python scripts/revert_last_csv_import.py --apply # actually deletes it, permanently
14
+
15
+ Irreversible once run with --apply -- lead_contacts rows for each lead cascade-delete with it. Read the
16
+ dry-run output carefully (company count, name list, created_at range, who added them) before applying.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import sys
23
+ from datetime import timedelta
24
+ from pathlib import Path
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
27
+
28
+ from sqlalchemy import select # noqa: E402
29
+
30
+ from app.crud import leads as crud_leads # noqa: E402
31
+ from app.database import SessionLocal # noqa: E402
32
+ from app.models import Lead, User # noqa: E402
33
+
34
+ # Leads created closer together than this are treated as one import run.
35
+ BATCH_GAP = timedelta(hours=4)
36
+
37
+
38
+ def main() -> int:
39
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
40
+ parser.add_argument("--apply", action="store_true", help="actually delete (default: dry run only)")
41
+ args = parser.parse_args()
42
+
43
+ db = SessionLocal()
44
+ try:
45
+ candidates = list(
46
+ db.scalars(
47
+ select(Lead).where(Lead.source_prompt == "CSV Batch Import").order_by(Lead.created_at.desc())
48
+ ).all()
49
+ )
50
+ if not candidates:
51
+ print("No CSV-imported leads found at all -- nothing to do.")
52
+ return 0
53
+
54
+ batch = [candidates[0]]
55
+ for lead in candidates[1:]:
56
+ if batch[-1].created_at - lead.created_at <= BATCH_GAP:
57
+ batch.append(lead)
58
+ else:
59
+ break
60
+
61
+ users = {u.id: u.name or u.email for u in db.scalars(select(User)).all()}
62
+
63
+ print(f"Most recent CSV-import batch: {len(batch)} lead(s)")
64
+ print(f" created_at range: {batch[-1].created_at} .. {batch[0].created_at} (UTC)")
65
+ older = len(candidates) - len(batch)
66
+ if older:
67
+ print(f" ({older} older CSV-imported lead(s) exist from before this batch -- NOT touched)")
68
+ print()
69
+ for lead in batch:
70
+ who = users.get(lead.created_by_user_id, "—") if lead.created_by_user_id else "—"
71
+ print(f" #{lead.id:<6} {lead.company_name:<40} created_at={lead.created_at} by={who}")
72
+
73
+ if not args.apply:
74
+ print(f"\nDry run only -- {len(batch)} lead(s) would be permanently deleted.")
75
+ print("Check the count and date range above match what you expect, then re-run with --apply.")
76
+ return 0
77
+
78
+ for lead in batch:
79
+ crud_leads.delete_lead(db, lead)
80
+ print(f"\nDeleted {len(batch)} lead(s) permanently (their lead_contacts rows cascade-deleted with them).")
81
+ return 0
82
+ finally:
83
+ db.close()
84
+
85
+
86
+ if __name__ == "__main__":
87
+ sys.exit(main())
scripts/review_duplicate_leads.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ review_duplicate_leads.py -- scores each row in a duplicate-company group and recommends which to keep.
3
+
4
+ check_duplicate_leads.py found 22 companies with more than one Lead row, all source="Manual entry",
5
+ created 2026-09-15/17 (before create_lead()'s duplicate check went live 2026-09-17 -- see that script's
6
+ docstring). Reviewing all 22 pairs by hand in chat doesn't scale, so this scores each row:
7
+
8
+ +100 has notes (a rep actually wrote something -- the strongest "this one is real" signal)
9
+ +10 * stage rank (lost=1, draft_ready=0, followup_due=2, contacted=3, meeting_booked=4,
10
+ proposal_sent=5, won=6 -- "lost" ranks above draft_ready because it required an
11
+ actual outcome, not just sitting untouched)
12
+ +5 meeting_at or callback_at is set
13
+
14
+ A group only gets an automatic recommendation when exactly one row has the highest score (a clear win),
15
+ OR every tied top-scoring row has IDENTICAL deal_value and industry_tag (a true no-information-loss
16
+ duplicate, e.g. three byte-for-byte-identical rows). Every other tie -- most commonly "both rows have
17
+ notes" or "both are untouched but disagree on category/value" -- is never auto-resolved. It prints as
18
+ NEEDS MANUAL REVIEW with full detail on every row, and nothing about it is touched.
19
+
20
+ uv run python scripts/review_duplicate_leads.py # dry run -- prints recommendations only
21
+ uv run python scripts/review_duplicate_leads.py --apply # deletes the CLEAR-WIN groups only
22
+
23
+ Groups flagged NEEDS MANUAL REVIEW are never touched by --apply -- use delete_lead_by_id.py for those,
24
+ one id at a time, after deciding by hand.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import sys
31
+ from collections import defaultdict
32
+ from pathlib import Path
33
+
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
35
+
36
+ from sqlalchemy import select # noqa: E402
37
+
38
+ from app.crud import leads as crud_leads # noqa: E402
39
+ from app.database import SessionLocal # noqa: E402
40
+ from app.models import Lead # noqa: E402
41
+
42
+ STAGE_RANK = {
43
+ "draft_ready": 0,
44
+ "lost": 1,
45
+ "followup_due": 2,
46
+ "contacted": 3,
47
+ "meeting_booked": 4,
48
+ "proposal_sent": 5,
49
+ "won": 6,
50
+ }
51
+
52
+
53
+ def score(lead: Lead) -> int:
54
+ s = 0
55
+ if (lead.notes or "").strip():
56
+ s += 100
57
+ s += STAGE_RANK.get(lead.pipeline_stage, 0) * 10
58
+ if lead.meeting_at or lead.callback_at:
59
+ s += 5
60
+ return s
61
+
62
+
63
+ def describe(lead: Lead) -> str:
64
+ return (
65
+ f"#{lead.id:<6} stage={lead.pipeline_stage:<15} deal_value={lead.deal_value} "
66
+ f"industry_tag={lead.industry_tag!r} notes={'yes' if (lead.notes or '').strip() else 'no'} "
67
+ f"created_at={lead.created_at}"
68
+ )
69
+
70
+
71
+ def main() -> int:
72
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
73
+ parser.add_argument("--apply", action="store_true", help="delete the clear-win groups (default: dry run)")
74
+ args = parser.parse_args()
75
+
76
+ db = SessionLocal()
77
+ try:
78
+ leads = list(db.scalars(select(Lead).order_by(Lead.company_name, Lead.created_at)).all())
79
+ groups: dict[str, list[Lead]] = defaultdict(list)
80
+ for lead in leads:
81
+ key = (lead.company_name or "").strip().lower()
82
+ if key:
83
+ groups[key].append(lead)
84
+ dupes = {k: v for k, v in groups.items() if len(v) > 1}
85
+
86
+ if not dupes:
87
+ print("No duplicate company names found.")
88
+ return 0
89
+
90
+ to_delete: list[Lead] = []
91
+ needs_review: list[list[Lead]] = []
92
+
93
+ for rows in dupes.values():
94
+ scored = [(score(r), r) for r in rows]
95
+ top = max(s for s, _ in scored)
96
+ leaders = [r for s, r in scored if s == top]
97
+
98
+ if len(leaders) == 1:
99
+ keep = leaders[0]
100
+ losers = [r for r in rows if r.id != keep.id]
101
+ print(f"'{keep.company_name}' -- CLEAR WIN, keep #{keep.id}, delete {[r.id for r in losers]}")
102
+ print(f" KEEP {describe(keep)}")
103
+ for l in losers:
104
+ print(f" DELETE {describe(l)}")
105
+ print()
106
+ to_delete.extend(losers)
107
+ continue
108
+
109
+ identical = all(
110
+ (r.deal_value, r.industry_tag) == (leaders[0].deal_value, leaders[0].industry_tag)
111
+ for r in leaders
112
+ )
113
+ if identical:
114
+ keep = min(leaders, key=lambda r: r.created_at)
115
+ losers = [r for r in rows if r.id != keep.id]
116
+ print(
117
+ f"'{keep.company_name}' -- IDENTICAL duplicate(s), keep oldest #{keep.id}, "
118
+ f"delete {[r.id for r in losers]}"
119
+ )
120
+ print(f" KEEP {describe(keep)}")
121
+ for l in losers:
122
+ print(f" DELETE {describe(l)}")
123
+ print()
124
+ to_delete.extend(losers)
125
+ continue
126
+
127
+ needs_review.append(rows)
128
+
129
+ if needs_review:
130
+ print(f"\n{'=' * 60}\n{len(needs_review)} group(s) NEED MANUAL REVIEW -- not touched:\n")
131
+ for rows in needs_review:
132
+ print(f"'{rows[0].company_name}':")
133
+ for r in rows:
134
+ print(f" {describe(r)}")
135
+ print()
136
+
137
+ print(f"{'=' * 60}")
138
+ print(f"{len(to_delete)} lead(s) recommended for deletion across the clear-win groups above.")
139
+ print(f"{len(needs_review)} group(s) need a manual decision (use delete_lead_by_id.py after choosing).")
140
+
141
+ if not args.apply:
142
+ print("\nDry run only -- re-run with --apply to delete the clear-win groups' losers.")
143
+ return 0
144
+
145
+ for lead in to_delete:
146
+ crud_leads.delete_lead(db, lead)
147
+ print(f"\nDeleted {len(to_delete)} lead(s).")
148
+ return 0
149
+ finally:
150
+ db.close()
151
+
152
+
153
+ if __name__ == "__main__":
154
+ sys.exit(main())
scripts/seed_leads.py CHANGED
@@ -9,6 +9,10 @@ want the sample rows.
9
  uv run python scripts/seed_leads.py
10
 
11
  Skips any company already present, so it is safe to re-run.
 
 
 
 
12
  """
13
 
14
  from __future__ import annotations
@@ -34,7 +38,7 @@ VERIFIED_SAMPLE_LEADS = [
34
  "contact_phone": "+1 (843) 881-8883",
35
  "contact_linkedin": "https://www.linkedin.com/in/hal-garrett-b02b6623",
36
  "industry_tag": "⛳ Custom Golf Carts",
37
- "deal_value": 18500.0,
38
  "reason": "Buyers customize body paint, upholstery stitching, lift kits, and rim styles using static gallery photos, leading to drop-offs before requesting a quote.",
39
  "subject": "3D cart builder for Garrett's Custom Golf Carts",
40
  "body": "Hi Hal,\n\nI was looking at your custom golf cart builds at Garrett's, specifically your custom diamond-stitched seating and lift kit options. Your builds look great.\n\nRight now, buyers browse static photos to imagine custom paint and upholstery combinations. We build real-time 3D web configurators that let customers customize paint, seats, and rims in 3D directly on your site before buying.\n\nWould you be open to a quick demo tailored for your custom builds? You can pick a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
@@ -48,7 +52,7 @@ VERIFIED_SAMPLE_LEADS = [
48
  "contact_phone": "+1 (803) 865-8016",
49
  "contact_linkedin": "https://www.linkedin.com/company/tidewater-carts",
50
  "industry_tag": "⛳ Custom Golf Carts",
51
- "deal_value": 21000.0,
52
  "reason": "Offers extensive custom colors, tops, and high-performance lift packages, but currently relies on static inventory listings.",
53
  "subject": "Interactive 3D configurator for Tidewater Carts",
54
  "body": "Hi Team,\n\nI came across Tidewater Carts while researching top custom golf cart builders in the US. Your custom lifted builds and sound bar packages stand out.\n\nSince buyers often want to compare custom body colors and seat patterns before ordering, an interactive 3D configurator on your website would allow them to visualize their build live and submit quote-ready specifications.\n\nWould you be open to seeing a 5-minute visual demo? Feel free to pick a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
@@ -62,7 +66,7 @@ VERIFIED_SAMPLE_LEADS = [
62
  "contact_phone": "+1 (800) 508-4813",
63
  "contact_linkedin": "https://www.linkedin.com/company/performance-golf-carts",
64
  "industry_tag": "⛳ Custom Golf Carts",
65
- "deal_value": 19500.0,
66
  "reason": "High-volume custom builder with extensive parts & accessories inventory that would see higher conversions with live 3D visual customization.",
67
  "subject": "3D visualizer for Performance Golf Carts",
68
  "body": "Hi Team,\n\nI was browsing your custom EZ-GO and Club Car inventory at Performance Golf Carts. Your Double Take body kits and custom wheels look sharp.\n\nRight now, buyers browse 2D images to pick options. We create interactive 3D configurators that let customers swap body colors, seats, and lift kits in real time on your website.\n\nAre you open to exploring a quick 3D demo tailored for your builds? You can grab a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
@@ -76,7 +80,7 @@ VERIFIED_SAMPLE_LEADS = [
76
  "contact_phone": "+1 (949) 328-9899",
77
  "contact_linkedin": "https://www.linkedin.com/company/apex-golf-carts",
78
  "industry_tag": "⛳ Custom Golf Carts",
79
- "deal_value": 17500.0,
80
  "reason": "Specializes in luxury street-legal electric carts with premium custom finishes that require high-end 3D visualization.",
81
  "subject": "3D customization for Apex Golf Carts",
82
  "body": "Hi Team,\n\nI was checking out your luxury street-legal electric cart models at Apex. The modern styling and premium interior finishes look exceptional.\n\nSince high-end buyers expect interactive digital experiences, a real-time 3D configurator on your site would let clients customize their cart exterior, wheels, and seating in full 3D before inquiring.\n\nWould you be open to a brief preview of how this looks in action? Pick a convenient time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
@@ -90,7 +94,7 @@ VERIFIED_SAMPLE_LEADS = [
90
  "contact_phone": "+1 (712) 324-4900",
91
  "contact_linkedin": "https://www.linkedin.com/company/streetrod-golf-cars",
92
  "industry_tag": "⛳ Custom Golf Carts",
93
- "deal_value": 18000.0,
94
  "reason": "Handcrafted vintage hot rod replica golf carts with bespoke paint and chrome options, ideal for high-ticket 3D interactive customization.",
95
  "subject": "Real-time 3D configurator for Streetrod Golf Cars",
96
  "body": "Hi Team,\n\nI was admiring your handcrafted hot rod golf cars at Streetrod. The vintage fiberglass bodywork and custom chrome details are works of art.\n\nFor bespoke vehicles at this price point, an interactive 3D web configurator allows collectors to test paint colors, flame graphics, and wheel packages in photorealistic 3D directly on your site.\n\nWould you be open to seeing a quick concept tailored for Streetrod? You can choose a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
 
9
  uv run python scripts/seed_leads.py
10
 
11
  Skips any company already present, so it is safe to re-run.
12
+
13
+ structure-plan.md Phase 8: every deal_value here was made 10000, at the user's explicit request ("sab
14
+ jaga kr dy" -- do it everywhere) -- they were previously varied (17500-21000) on purpose so the sample
15
+ data didn't look uniform; that reasoning is now overridden by the same-default-everywhere rule.
16
  """
17
 
18
  from __future__ import annotations
 
38
  "contact_phone": "+1 (843) 881-8883",
39
  "contact_linkedin": "https://www.linkedin.com/in/hal-garrett-b02b6623",
40
  "industry_tag": "⛳ Custom Golf Carts",
41
+ "deal_value": 10000.0,
42
  "reason": "Buyers customize body paint, upholstery stitching, lift kits, and rim styles using static gallery photos, leading to drop-offs before requesting a quote.",
43
  "subject": "3D cart builder for Garrett's Custom Golf Carts",
44
  "body": "Hi Hal,\n\nI was looking at your custom golf cart builds at Garrett's, specifically your custom diamond-stitched seating and lift kit options. Your builds look great.\n\nRight now, buyers browse static photos to imagine custom paint and upholstery combinations. We build real-time 3D web configurators that let customers customize paint, seats, and rims in 3D directly on your site before buying.\n\nWould you be open to a quick demo tailored for your custom builds? You can pick a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
 
52
  "contact_phone": "+1 (803) 865-8016",
53
  "contact_linkedin": "https://www.linkedin.com/company/tidewater-carts",
54
  "industry_tag": "⛳ Custom Golf Carts",
55
+ "deal_value": 10000.0,
56
  "reason": "Offers extensive custom colors, tops, and high-performance lift packages, but currently relies on static inventory listings.",
57
  "subject": "Interactive 3D configurator for Tidewater Carts",
58
  "body": "Hi Team,\n\nI came across Tidewater Carts while researching top custom golf cart builders in the US. Your custom lifted builds and sound bar packages stand out.\n\nSince buyers often want to compare custom body colors and seat patterns before ordering, an interactive 3D configurator on your website would allow them to visualize their build live and submit quote-ready specifications.\n\nWould you be open to seeing a 5-minute visual demo? Feel free to pick a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
 
66
  "contact_phone": "+1 (800) 508-4813",
67
  "contact_linkedin": "https://www.linkedin.com/company/performance-golf-carts",
68
  "industry_tag": "⛳ Custom Golf Carts",
69
+ "deal_value": 10000.0,
70
  "reason": "High-volume custom builder with extensive parts & accessories inventory that would see higher conversions with live 3D visual customization.",
71
  "subject": "3D visualizer for Performance Golf Carts",
72
  "body": "Hi Team,\n\nI was browsing your custom EZ-GO and Club Car inventory at Performance Golf Carts. Your Double Take body kits and custom wheels look sharp.\n\nRight now, buyers browse 2D images to pick options. We create interactive 3D configurators that let customers swap body colors, seats, and lift kits in real time on your website.\n\nAre you open to exploring a quick 3D demo tailored for your builds? You can grab a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
 
80
  "contact_phone": "+1 (949) 328-9899",
81
  "contact_linkedin": "https://www.linkedin.com/company/apex-golf-carts",
82
  "industry_tag": "⛳ Custom Golf Carts",
83
+ "deal_value": 10000.0,
84
  "reason": "Specializes in luxury street-legal electric carts with premium custom finishes that require high-end 3D visualization.",
85
  "subject": "3D customization for Apex Golf Carts",
86
  "body": "Hi Team,\n\nI was checking out your luxury street-legal electric cart models at Apex. The modern styling and premium interior finishes look exceptional.\n\nSince high-end buyers expect interactive digital experiences, a real-time 3D configurator on your site would let clients customize their cart exterior, wheels, and seating in full 3D before inquiring.\n\nWould you be open to a brief preview of how this looks in action? Pick a convenient time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
 
94
  "contact_phone": "+1 (712) 324-4900",
95
  "contact_linkedin": "https://www.linkedin.com/company/streetrod-golf-cars",
96
  "industry_tag": "⛳ Custom Golf Carts",
97
+ "deal_value": 10000.0,
98
  "reason": "Handcrafted vintage hot rod replica golf carts with bespoke paint and chrome options, ideal for high-ticket 3D interactive customization.",
99
  "subject": "Real-time 3D configurator for Streetrod Golf Cars",
100
  "body": "Hi Team,\n\nI was admiring your handcrafted hot rod golf cars at Streetrod. The vintage fiberglass bodywork and custom chrome details are works of art.\n\nFor bespoke vehicles at this price point, an interactive 3D web configurator allows collectors to test paint colors, flame graphics, and wheel packages in photorealistic 3D directly on your site.\n\nWould you be open to seeing a quick concept tailored for Streetrod? You can choose a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
scripts/smoke_test.py CHANGED
@@ -137,7 +137,7 @@ def main() -> int:
137
  "company_website": "https://example.com",
138
  "contact_email": "probe@example.com",
139
  "industry_tag": EMOJI_CATEGORY,
140
- "deal_value": 24000,
141
  }, timeout=15)
142
  check("POST /leads -> 201", r.status_code == 201, f"{r.status_code} {r.text[:200]}")
143
  if not r.ok:
 
137
  "company_website": "https://example.com",
138
  "contact_email": "probe@example.com",
139
  "industry_tag": EMOJI_CATEGORY,
140
+ "deal_value": 10000, # structure-plan.md Phase 8: matches the app-wide default (was 24000).
141
  }, timeout=15)
142
  check("POST /leads -> 201", r.status_code == 201, f"{r.status_code} {r.text[:200]}")
143
  if not r.ok: