Skip to content

Commit 299a1e3

Browse files
author
Nick Sawinyh
committed
fix(contacts): strip "2nd tier" tag when interactions are inserted
Telegram group member sync tags new contacts "2nd Tier" and only clears it on subsequent group syncs. If the user DMs a contact before the next group sync (or they leave the group), the tag persists despite active conversations — 167 such contacts existed on prod. Enforces the invariant with an AFTER INSERT trigger on interactions so every code path that creates an Interaction (Telegram, Gmail, Twitter, LinkedIn, manual, etc.) gets the tag stripped automatically. Trigger SQL is shared between the alembic migration and test setup via app/models/_triggers.py so create_all-based tests mirror prod.
1 parent ec1c1ea commit 299a1e3

4 files changed

Lines changed: 149 additions & 0 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""clear 2nd tier tag when an interaction is inserted
2+
3+
Revision ID: b1c2d3e4f5a6
4+
Revises: 5fcb77520fec
5+
Create Date: 2026-04-14
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
12+
from app.models._triggers import (
13+
CLEAR_2ND_TIER_FUNCTION,
14+
CLEAR_2ND_TIER_TRIGGER,
15+
DROP_CLEAR_2ND_TIER,
16+
)
17+
18+
revision: str = "b1c2d3e4f5a6"
19+
down_revision: Union[str, None] = "5fcb77520fec"
20+
branch_labels: Union[str, Sequence[str], None] = None
21+
depends_on: Union[str, Sequence[str], None] = None
22+
23+
24+
def upgrade() -> None:
25+
op.execute(CLEAR_2ND_TIER_FUNCTION)
26+
op.execute(CLEAR_2ND_TIER_TRIGGER)
27+
28+
29+
def downgrade() -> None:
30+
op.execute(DROP_CLEAR_2ND_TIER)

backend/app/models/_triggers.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Shared DDL for Postgres triggers used by both alembic migrations and tests."""
2+
from __future__ import annotations
3+
4+
CLEAR_2ND_TIER_FUNCTION = """
5+
CREATE OR REPLACE FUNCTION clear_2nd_tier_on_interaction() RETURNS trigger AS $$
6+
BEGIN
7+
UPDATE contacts
8+
SET tags = array_remove(array_remove(tags, '2nd tier'), '2nd Tier')
9+
WHERE id = NEW.contact_id
10+
AND (tags @> ARRAY['2nd tier']::varchar[]
11+
OR tags @> ARRAY['2nd Tier']::varchar[]);
12+
RETURN NEW;
13+
END;
14+
$$ LANGUAGE plpgsql;
15+
"""
16+
17+
CLEAR_2ND_TIER_TRIGGER = """
18+
CREATE TRIGGER trg_clear_2nd_tier_on_interaction
19+
AFTER INSERT ON interactions
20+
FOR EACH ROW EXECUTE FUNCTION clear_2nd_tier_on_interaction();
21+
"""
22+
23+
DROP_CLEAR_2ND_TIER = """
24+
DROP TRIGGER IF EXISTS trg_clear_2nd_tier_on_interaction ON interactions;
25+
DROP FUNCTION IF EXISTS clear_2nd_tier_on_interaction();
26+
"""

backend/tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
"postgresql+asyncpg://localhost:5432/pingcrm_test",
1717
)
1818

19+
from sqlalchemy import text
20+
1921
from app.core.auth import create_access_token, hash_password
2022
from app.core.database import Base, get_db
2123
from app.main import fastapi_app as app
24+
from app.models._triggers import CLEAR_2ND_TIER_FUNCTION, CLEAR_2ND_TIER_TRIGGER
2225
from app.models.contact import Contact
2326
from app.models.detected_event import DetectedEvent
2427
from app.models.follow_up import FollowUpSuggestion
@@ -36,6 +39,8 @@ async def setup_database():
3639
engine = create_async_engine(os.environ["DATABASE_URL"], echo=False)
3740
async with engine.begin() as conn:
3841
await conn.run_sync(Base.metadata.create_all)
42+
await conn.execute(text(CLEAR_2ND_TIER_FUNCTION))
43+
await conn.execute(text(CLEAR_2ND_TIER_TRIGGER))
3944
yield engine
4045
async with engine.begin() as conn:
4146
await conn.run_sync(Base.metadata.drop_all)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Verify the Postgres trigger that strips the '2nd tier' tag when an interaction is inserted."""
2+
from __future__ import annotations
3+
4+
import uuid
5+
from datetime import UTC, datetime
6+
7+
import pytest
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from app.models.contact import Contact
11+
from app.models.interaction import Interaction
12+
from app.models.user import User
13+
14+
15+
async def _make_contact(db: AsyncSession, user: User, tags: list[str]) -> Contact:
16+
contact = Contact(
17+
id=uuid.uuid4(),
18+
user_id=user.id,
19+
full_name="Group Friend",
20+
given_name="Group",
21+
family_name="Friend",
22+
tags=tags,
23+
source="telegram",
24+
)
25+
db.add(contact)
26+
await db.commit()
27+
await db.refresh(contact)
28+
return contact
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_inserting_interaction_strips_lowercase_tag(db: AsyncSession, test_user: User):
33+
contact = await _make_contact(db, test_user, tags=["2nd tier", "friend"])
34+
35+
db.add(Interaction(
36+
id=uuid.uuid4(),
37+
contact_id=contact.id,
38+
user_id=test_user.id,
39+
platform="telegram",
40+
direction="inbound",
41+
content_preview="hi",
42+
occurred_at=datetime.now(UTC),
43+
))
44+
await db.commit()
45+
await db.refresh(contact)
46+
47+
assert contact.tags == ["friend"]
48+
49+
50+
@pytest.mark.asyncio
51+
async def test_inserting_interaction_strips_titlecase_tag(db: AsyncSession, test_user: User):
52+
contact = await _make_contact(db, test_user, tags=["2nd Tier"])
53+
54+
db.add(Interaction(
55+
id=uuid.uuid4(),
56+
contact_id=contact.id,
57+
user_id=test_user.id,
58+
platform="telegram",
59+
direction="outbound",
60+
content_preview="hi",
61+
occurred_at=datetime.now(UTC),
62+
))
63+
await db.commit()
64+
await db.refresh(contact)
65+
66+
assert contact.tags == []
67+
68+
69+
@pytest.mark.asyncio
70+
async def test_trigger_leaves_other_contacts_alone(db: AsyncSession, test_user: User):
71+
other = await _make_contact(db, test_user, tags=["2nd tier"])
72+
target = await _make_contact(db, test_user, tags=["2nd tier"])
73+
74+
db.add(Interaction(
75+
id=uuid.uuid4(),
76+
contact_id=target.id,
77+
user_id=test_user.id,
78+
platform="telegram",
79+
direction="inbound",
80+
content_preview="hi",
81+
occurred_at=datetime.now(UTC),
82+
))
83+
await db.commit()
84+
await db.refresh(target)
85+
await db.refresh(other)
86+
87+
assert target.tags == []
88+
assert other.tags == ["2nd tier"]

0 commit comments

Comments
 (0)