Seth commited on
Commit
b645a7a
·
1 Parent(s): 1e0388b
Files changed (2) hide show
  1. backend/app/main.py +79 -18
  2. frontend/src/pages/Contacts.jsx +98 -18
backend/app/main.py CHANGED
@@ -79,6 +79,44 @@ def _pick_from_raw(raw_data: Dict, aliases: List[str]) -> str:
79
  return ""
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  @app.get("/api/health")
83
  def health():
84
  return {"status": "ok"}
@@ -160,6 +198,9 @@ async def list_contacts(
160
  search: str = Query("", description="Search by name/email/company/title"),
161
  field: str = Query("", description="Optional field name to filter"),
162
  value: str = Query("", description="Optional field value to filter"),
 
 
 
163
  limit: int = Query(200, ge=1, le=1000),
164
  offset: int = Query(0, ge=0),
165
  db: Session = Depends(get_db)
@@ -177,26 +218,46 @@ async def list_contacts(
177
  contacts_all = query.order_by(Contact.created_at.desc(), Contact.id.desc()).all()
178
 
179
  # Dynamic field filtering for all imported Apollo attributes.
180
- if field and value:
181
- v = value.lower()
 
 
 
182
 
183
  def match_dynamic(c: Contact):
184
- if field == "first_name":
185
- return v in (c.first_name or "").lower()
186
- if field == "last_name":
187
- return v in (c.last_name or "").lower()
188
- if field == "email":
189
- return v in (c.email or "").lower()
190
- if field == "company":
191
- return v in (c.company or "").lower()
192
- if field == "title":
193
- return v in (c.title or "").lower()
194
- if field == "file_id":
195
- return v in (c.file_id or "").lower()
196
- if field == "created_at":
197
- return v in ((c.created_at.isoformat() if c.created_at else "").lower())
198
- raw_val = (c.raw_data or {}).get(field)
199
- return v in _safe_str(raw_val).lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  contacts_all = [c for c in contacts_all if match_dynamic(c)]
202
 
 
79
  return ""
80
 
81
 
82
+ def _contact_value(contact: Contact, field: str):
83
+ if field == "first_name":
84
+ return contact.first_name or _pick_from_raw(contact.raw_data, ["first name", "firstname", "first_name"])
85
+ if field == "last_name":
86
+ return contact.last_name or _pick_from_raw(contact.raw_data, ["last name", "lastname", "last_name"])
87
+ if field == "email":
88
+ return contact.email or _pick_from_raw(contact.raw_data, ["email", "work email", "email address", "secondary email", "tertiary email"])
89
+ if field == "company":
90
+ return contact.company or _pick_from_raw(contact.raw_data, ["company", "company name", "company name for emails", "organization name", "account name"])
91
+ if field == "title":
92
+ return contact.title or _pick_from_raw(contact.raw_data, ["title", "job title"])
93
+ if field == "file_id":
94
+ return contact.file_id or ""
95
+ if field == "created_at":
96
+ return contact.created_at.isoformat() if contact.created_at else ""
97
+ return (contact.raw_data or {}).get(field)
98
+
99
+
100
+ def _to_float(val):
101
+ s = _safe_str(val).replace(",", "")
102
+ if not s:
103
+ return None
104
+ try:
105
+ return float(s)
106
+ except Exception:
107
+ return None
108
+
109
+
110
+ def _to_datetime(val):
111
+ s = _safe_str(val)
112
+ if not s:
113
+ return None
114
+ try:
115
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
116
+ except Exception:
117
+ return None
118
+
119
+
120
  @app.get("/api/health")
121
  def health():
122
  return {"status": "ok"}
 
198
  search: str = Query("", description="Search by name/email/company/title"),
199
  field: str = Query("", description="Optional field name to filter"),
200
  value: str = Query("", description="Optional field value to filter"),
201
+ op: str = Query("contains", description="Filter op: contains|equals|from|to|between"),
202
+ from_value: str = Query("", description="From value for range filters"),
203
+ to_value: str = Query("", description="To value for range filters"),
204
  limit: int = Query(200, ge=1, le=1000),
205
  offset: int = Query(0, ge=0),
206
  db: Session = Depends(get_db)
 
218
  contacts_all = query.order_by(Contact.created_at.desc(), Contact.id.desc()).all()
219
 
220
  # Dynamic field filtering for all imported Apollo attributes.
221
+ if field:
222
+ cmp_value = value or from_value
223
+ v = _safe_str(cmp_value).lower()
224
+ v_from = _safe_str(from_value)
225
+ v_to = _safe_str(to_value)
226
 
227
  def match_dynamic(c: Contact):
228
+ field_val = _contact_value(c, field)
229
+ field_str = _safe_str(field_val).lower()
230
+ op_local = (op or "contains").lower()
231
+ if op_local == "equals":
232
+ return field_str == v
233
+ if op_local == "from":
234
+ # Numeric/date first, then lexical fallback
235
+ f_num, v_num = _to_float(field_val), _to_float(v_from or value)
236
+ if f_num is not None and v_num is not None:
237
+ return f_num >= v_num
238
+ f_dt, v_dt = _to_datetime(field_val), _to_datetime(v_from or value)
239
+ if f_dt and v_dt:
240
+ return f_dt >= v_dt
241
+ return field_str >= _safe_str(v_from or value).lower()
242
+ if op_local == "to":
243
+ f_num, v_num = _to_float(field_val), _to_float(v_to or value)
244
+ if f_num is not None and v_num is not None:
245
+ return f_num <= v_num
246
+ f_dt, v_dt = _to_datetime(field_val), _to_datetime(v_to or value)
247
+ if f_dt and v_dt:
248
+ return f_dt <= v_dt
249
+ return field_str <= _safe_str(v_to or value).lower()
250
+ if op_local == "between":
251
+ f_num, from_num, to_num = _to_float(field_val), _to_float(v_from), _to_float(v_to)
252
+ if f_num is not None and from_num is not None and to_num is not None:
253
+ return from_num <= f_num <= to_num
254
+ f_dt, from_dt, to_dt = _to_datetime(field_val), _to_datetime(v_from), _to_datetime(v_to)
255
+ if f_dt and from_dt and to_dt:
256
+ return from_dt <= f_dt <= to_dt
257
+ low, high = v_from.lower(), v_to.lower()
258
+ return low <= field_str <= high
259
+ # default contains
260
+ return v in field_str
261
 
262
  contacts_all = [c for c in contacts_all if match_dynamic(c)]
263
 
frontend/src/pages/Contacts.jsx CHANGED
@@ -9,12 +9,16 @@ export default function Contacts() {
9
  const [contacts, setContacts] = useState([]);
10
  const [fields, setFields] = useState([]);
11
  const [selectedContact, setSelectedContact] = useState(null);
 
12
  const [sequences, setSequences] = useState([]);
13
  const [loading, setLoading] = useState(true);
14
  const [seqLoading, setSeqLoading] = useState(false);
15
  const [searchQuery, setSearchQuery] = useState('');
16
  const [filterField, setFilterField] = useState('none');
 
17
  const [filterValue, setFilterValue] = useState('');
 
 
18
 
19
  useEffect(() => {
20
  fetchFields();
@@ -24,7 +28,7 @@ export default function Contacts() {
24
  useEffect(() => {
25
  const timer = setTimeout(() => fetchContacts(), 250);
26
  return () => clearTimeout(timer);
27
- }, [searchQuery, filterField, filterValue]);
28
 
29
  const fetchFields = async () => {
30
  try {
@@ -44,9 +48,19 @@ export default function Contacts() {
44
  const params = new URLSearchParams();
45
  params.set('limit', '1000');
46
  if (searchQuery.trim()) params.set('search', searchQuery.trim());
47
- if (filterField !== 'none' && filterValue.trim()) {
48
  params.set('field', filterField);
49
- params.set('value', filterValue.trim());
 
 
 
 
 
 
 
 
 
 
50
  }
51
  const res = await fetch(`/api/contacts?${params.toString()}`);
52
  if (res.ok) {
@@ -62,15 +76,21 @@ export default function Contacts() {
62
 
63
  const openContact = async (contact) => {
64
  setSelectedContact(contact);
 
65
  setSeqLoading(true);
66
  try {
67
- const res = await fetch(`/api/contacts/${contact.id}/sequences`);
68
- if (res.ok) {
69
- const data = await res.json();
70
- setSequences(data.sequences || []);
71
- } else {
72
- setSequences([]);
 
73
  }
 
 
 
 
74
  } catch (e) {
75
  console.error('Failed to fetch contact sequences:', e);
76
  setSequences([]);
@@ -112,11 +132,55 @@ export default function Contacts() {
112
  </SelectContent>
113
  </Select>
114
  {filterField !== 'none' && (
115
- <Input
116
- placeholder={`Contains value for "${filterField}"`}
117
- value={filterValue}
118
- onChange={(e) => setFilterValue(e.target.value)}
119
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  )}
121
  </div>
122
  <div className="max-h-[70vh] overflow-y-auto space-y-2 pr-1">
@@ -164,15 +228,31 @@ export default function Contacts() {
164
  <div>
165
  <div className="mb-6">
166
  <h3 className="text-xl font-semibold text-slate-800">
167
- {selectedContact.first_name} {selectedContact.last_name}
168
  </h3>
169
  <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
170
- <Badge variant="outline" className="gap-1"><Mail className="h-3 w-3" /> {selectedContact.email || 'N/A'}</Badge>
171
- <Badge variant="outline" className="gap-1"><Building2 className="h-3 w-3" /> {selectedContact.company || 'N/A'}</Badge>
172
- <Badge variant="outline" className="gap-1"><Briefcase className="h-3 w-3" /> {selectedContact.title || 'N/A'}</Badge>
173
  </div>
174
  </div>
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  {seqLoading ? (
177
  <p className="text-sm text-slate-500">Loading sequences...</p>
178
  ) : sequences.length === 0 ? (
 
9
  const [contacts, setContacts] = useState([]);
10
  const [fields, setFields] = useState([]);
11
  const [selectedContact, setSelectedContact] = useState(null);
12
+ const [selectedContactDetails, setSelectedContactDetails] = useState(null);
13
  const [sequences, setSequences] = useState([]);
14
  const [loading, setLoading] = useState(true);
15
  const [seqLoading, setSeqLoading] = useState(false);
16
  const [searchQuery, setSearchQuery] = useState('');
17
  const [filterField, setFilterField] = useState('none');
18
+ const [filterOp, setFilterOp] = useState('contains');
19
  const [filterValue, setFilterValue] = useState('');
20
+ const [filterFrom, setFilterFrom] = useState('');
21
+ const [filterTo, setFilterTo] = useState('');
22
 
23
  useEffect(() => {
24
  fetchFields();
 
28
  useEffect(() => {
29
  const timer = setTimeout(() => fetchContacts(), 250);
30
  return () => clearTimeout(timer);
31
+ }, [searchQuery, filterField, filterOp, filterValue, filterFrom, filterTo]);
32
 
33
  const fetchFields = async () => {
34
  try {
 
48
  const params = new URLSearchParams();
49
  params.set('limit', '1000');
50
  if (searchQuery.trim()) params.set('search', searchQuery.trim());
51
+ if (filterField !== 'none') {
52
  params.set('field', filterField);
53
+ params.set('op', filterOp);
54
+ if (filterOp === 'between') {
55
+ if (filterFrom.trim()) params.set('from_value', filterFrom.trim());
56
+ if (filterTo.trim()) params.set('to_value', filterTo.trim());
57
+ } else if (filterOp === 'from') {
58
+ if (filterFrom.trim()) params.set('from_value', filterFrom.trim());
59
+ } else if (filterOp === 'to') {
60
+ if (filterTo.trim()) params.set('to_value', filterTo.trim());
61
+ } else if (filterValue.trim()) {
62
+ params.set('value', filterValue.trim());
63
+ }
64
  }
65
  const res = await fetch(`/api/contacts?${params.toString()}`);
66
  if (res.ok) {
 
76
 
77
  const openContact = async (contact) => {
78
  setSelectedContact(contact);
79
+ setSelectedContactDetails(null);
80
  setSeqLoading(true);
81
  try {
82
+ const [detailRes, seqRes] = await Promise.all([
83
+ fetch(`/api/contacts/${contact.id}`),
84
+ fetch(`/api/contacts/${contact.id}/sequences`)
85
+ ]);
86
+ if (detailRes.ok) {
87
+ const detailData = await detailRes.json();
88
+ setSelectedContactDetails(detailData);
89
  }
90
+ if (seqRes.ok) {
91
+ const seqData = await seqRes.json();
92
+ setSequences(seqData.sequences || []);
93
+ } else setSequences([]);
94
  } catch (e) {
95
  console.error('Failed to fetch contact sequences:', e);
96
  setSequences([]);
 
132
  </SelectContent>
133
  </Select>
134
  {filterField !== 'none' && (
135
+ <>
136
+ <Select value={filterOp} onValueChange={setFilterOp}>
137
+ <SelectTrigger>
138
+ <SelectValue />
139
+ </SelectTrigger>
140
+ <SelectContent>
141
+ <SelectItem value="contains">contains</SelectItem>
142
+ <SelectItem value="equals">equals</SelectItem>
143
+ <SelectItem value="from">from</SelectItem>
144
+ <SelectItem value="to">to</SelectItem>
145
+ <SelectItem value="between">between</SelectItem>
146
+ </SelectContent>
147
+ </Select>
148
+ {(filterOp === 'contains' || filterOp === 'equals') && (
149
+ <Input
150
+ placeholder={`Value for "${filterField}"`}
151
+ value={filterValue}
152
+ onChange={(e) => setFilterValue(e.target.value)}
153
+ />
154
+ )}
155
+ {filterOp === 'from' && (
156
+ <Input
157
+ placeholder="From value"
158
+ value={filterFrom}
159
+ onChange={(e) => setFilterFrom(e.target.value)}
160
+ />
161
+ )}
162
+ {filterOp === 'to' && (
163
+ <Input
164
+ placeholder="To value"
165
+ value={filterTo}
166
+ onChange={(e) => setFilterTo(e.target.value)}
167
+ />
168
+ )}
169
+ {filterOp === 'between' && (
170
+ <div className="grid grid-cols-2 gap-2">
171
+ <Input
172
+ placeholder="From"
173
+ value={filterFrom}
174
+ onChange={(e) => setFilterFrom(e.target.value)}
175
+ />
176
+ <Input
177
+ placeholder="To"
178
+ value={filterTo}
179
+ onChange={(e) => setFilterTo(e.target.value)}
180
+ />
181
+ </div>
182
+ )}
183
+ </>
184
  )}
185
  </div>
186
  <div className="max-h-[70vh] overflow-y-auto space-y-2 pr-1">
 
228
  <div>
229
  <div className="mb-6">
230
  <h3 className="text-xl font-semibold text-slate-800">
231
+ {(selectedContactDetails?.first_name || selectedContact.first_name)} {(selectedContactDetails?.last_name || selectedContact.last_name)}
232
  </h3>
233
  <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
234
+ <Badge variant="outline" className="gap-1"><Mail className="h-3 w-3" /> {(selectedContactDetails?.email || selectedContact.email) || 'N/A'}</Badge>
235
+ <Badge variant="outline" className="gap-1"><Building2 className="h-3 w-3" /> {(selectedContactDetails?.company || selectedContact.company) || 'N/A'}</Badge>
236
+ <Badge variant="outline" className="gap-1"><Briefcase className="h-3 w-3" /> {(selectedContactDetails?.title || selectedContact.title) || 'N/A'}</Badge>
237
  </div>
238
  </div>
239
 
240
+ {selectedContactDetails?.raw_data && (
241
+ <div className="mb-6 rounded-xl border border-slate-200 bg-slate-50/50 p-4">
242
+ <h4 className="text-sm font-semibold text-slate-700 mb-3">Company Details</h4>
243
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
244
+ <div><span className="text-slate-500">Company Name:</span> <span className="text-slate-800">{selectedContactDetails.raw_data['Company Name'] || selectedContactDetails.company || 'N/A'}</span></div>
245
+ <div><span className="text-slate-500">Company Name for Emails:</span> <span className="text-slate-800">{selectedContactDetails.raw_data['Company Name for Emails'] || 'N/A'}</span></div>
246
+ <div><span className="text-slate-500">Industry:</span> <span className="text-slate-800">{selectedContactDetails.raw_data['Industry'] || 'N/A'}</span></div>
247
+ <div><span className="text-slate-500">Employees:</span> <span className="text-slate-800">{selectedContactDetails.raw_data['# Employees'] || 'N/A'}</span></div>
248
+ <div><span className="text-slate-500">Annual Revenue:</span> <span className="text-slate-800">{selectedContactDetails.raw_data['Annual Revenue'] || 'N/A'}</span></div>
249
+ <div><span className="text-slate-500">Last Raised At:</span> <span className="text-slate-800">{selectedContactDetails.raw_data['Last Raised At'] || 'N/A'}</span></div>
250
+ <div><span className="text-slate-500">Website:</span> <span className="text-slate-800">{selectedContactDetails.raw_data['Website'] || 'N/A'}</span></div>
251
+ <div><span className="text-slate-500">Location:</span> <span className="text-slate-800">{[selectedContactDetails.raw_data['City'], selectedContactDetails.raw_data['State'], selectedContactDetails.raw_data['Country']].filter(Boolean).join(', ') || 'N/A'}</span></div>
252
+ </div>
253
+ </div>
254
+ )}
255
+
256
  {seqLoading ? (
257
  <p className="text-sm text-slate-500">Loading sequences...</p>
258
  ) : sequences.length === 0 ? (