Seth commited on
Commit ·
b645a7a
1
Parent(s): 1e0388b
update
Browse files- backend/app/main.py +79 -18
- 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
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
def match_dynamic(c: Contact):
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
return
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
| 48 |
params.set('field', filterField);
|
| 49 |
-
params.set('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
| 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 |
-
<
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 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 ? (
|