window.pageBoot = async (me) => { const fmtDT = window.fmtDT || (s => s || "—"); const state = { page:1, pageSize:100, total:0, data:[] }; async function load(){ const d = await GET(`/phones/list?active_only=${$("#activeOnly").checked}&search=${encodeURIComponent($("#search").value)}&page=${state.page}&page_size=${state.pageSize}`); state.total = d.total; state.data = d.phones || []; $("#count").textContent = String(state.total); $("#empty").style.display = state.total ? "none" : ""; $("#body").innerHTML = state.data.map(p => ` ${esc(p.phone)} ${esc(p.label || "—")} ${p.is_active ? 'active' : 'inactive'} ${p.use_count} ${esc(fmtDT(p.created_at))}
`).join("") || `No numbers found`; const pages = Math.max(1, Math.ceil(state.total / state.pageSize)); $("#pager").innerHTML = [ ``, `Page ${state.page} / ${pages}`, `` ].join(""); } $("#btnAdd").addEventListener("click", () => { const ph = el("input", {class:"inp", required:"required", placeholder:"+1 555 010 1234"}); const lb = el("input", {class:"inp", placeholder:"Support line (optional)"}); openModal("Add Phone Number", el("div",{class:"form"}, el("label",{},"Phone (digits, spaces, + - allowed)"), ph, el("label",{},"Label"), lb), { buttons: [{ label:"Add", kind:"btn", onClick: async (close) => { try { await POST("/phones/add", { phone: ph.value, label: lb.value }); toast("Phone added", "ok"); close(); await load(); } catch (err) { toast("Add failed: " + err.message, "err"); } }}] }); }); $("#btnImport").addEventListener("click", () => { const f = el("input", {type:"file", accept:".csv,.txt", required:"required"}); openModal("Import Phone Numbers", el("div",{class:"form"}, el("p",{class:"mut",style:"margin-top:0;font-size:.85rem"},"CSV: phone,label per row (header optional). TXT: one phone per line. JSON is rejected. Great for bulk-adding 10+ numbers."), el("label",{},"File"), f), { buttons: [{ label:"Import", kind:"btn", onClick: async (close) => { if (!f.files[0]) return; const fd = new FormData(); fd.append("file", f.files[0]); try { const d = await api("/phones/import", { method:"POST", body: fd }); toast(`Added ${d.added} (${d.duplicates} duplicates, ${d.invalid.length} invalid)`, "ok"); close(); await load(); } catch (err) { toast("Import failed: " + err.message, "err"); } }}] }); }); $("#btnClear").addEventListener("click", () => confirmedAction( "Delete ALL phone numbers? This cannot be undone.", async () => { const d = await POST("/phones/clear-all"); toast(`Deleted ${d.deleted} numbers`, "ok"); await load(); })); $("#body").addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b) return; if (b.dataset.tog) { POST(`/phones/${b.dataset.tog}/patch`, { is_active: b.dataset.act !== "1" }) .then(() => { toast("Updated", "ok"); load(); }); } if (b.dataset.lbl) { const p = state.data.find(x => x.id == b.dataset.lbl); const lb = el("input", {class:"inp", value: p.label || ""}); openModal("Edit Label — " + p.phone, el("div",{class:"form"}, el("label",{},"Label"), lb), { buttons: [{ label:"Save", kind:"btn", onClick: async (close) => { await POST(`/phones/${b.dataset.lbl}/patch`, { label: lb.value }); toast("Saved", "ok"); close(); await load(); }}] }); } if (b.dataset.del) confirmedAction("Delete this phone number?", async () => { await POST(`/phones/${b.dataset.del}/delete`); toast("Deleted","ok"); await load(); }); }); $("#pager").addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b || b.disabled) return; state.page = +b.dataset.pg; load(); }); $("#activeOnly").addEventListener("change", () => { state.page=1; load(); }); $("#search").addEventListener("input", debounce(() => { state.page=1; load(); }, 400)); await load(); }; =====ROUTERS===== """Company phone numbers manager: manual add, CSV/TXT import, activation toggle, usage counts, delete, Clear All. Feeds %CompanyPhone% variable in campaigns.""" import csv import io import re from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile from pydantic import BaseModel, field_validator from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db, require_csrf from app.core.security import log_audit from app.models import CompanyPhoneNumber, User router = APIRouter(prefix="/phones", tags=["phones"]) PHONE_RE = re.compile(r"^\+?[0-9][0-9 ()\-.]{4,30}$") class PhoneIn(BaseModel): phone: str label: str = "" @field_validator("phone") @classmethod def valid_phone(cls, v): v = v.strip() if not PHONE_RE.match(v): raise ValueError("Invalid phone number format") return v class PhonePatch(BaseModel): label: Optional[str] = None is_active: Optional[bool] = None def _serialize(p: CompanyPhoneNumber) -> dict: return { "id": p.id, "label": p.label, "phone": p.phone, "is_active": bool(p.is_active), "use_count": p.use_count, "created_at": p.created_at.isoformat() if p.created_at else None, } async def _get_own(db: AsyncSession, user: User, pid: int) -> CompanyPhoneNumber: p = await db.get(CompanyPhoneNumber, pid) if not p or p.user_id != user.id: raise HTTPException(status_code=404, detail="Phone number not found") return p @router.get("/list") async def list_phones(active_only: bool = Query(False), search: str = Query(""), page: int = Query(1, ge=1), page_size: int = Query(200, ge=1, le=500), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): q = select(CompanyPhoneNumber).where(CompanyPhoneNumber.user_id == user.id) if active_only: q = q.where(CompanyPhoneNumber.is_active == True) # noqa: E712 if search: q = q.where(CompanyPhoneNumber.phone.ilike(f"%{search}%") | CompanyPhoneNumber.label.ilike(f"%{search}%")) total = (await db.execute( select(func.count()).select_from(q.subquery()))).scalar() or 0 rows = (await db.execute( q.order_by(CompanyPhoneNumber.id.desc()).offset((page - 1) * page_size).limit(page_size) )).scalars().all() return {"phones": [_serialize(p) for p in rows], "total": total, "page": page, "page_size": page_size} @router.post("/add") async def add_phone(payload: PhoneIn, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Add one phone number manually.""" dup = await db.execute( select(CompanyPhoneNumber).where(CompanyPhoneNumber.user_id == user.id, CompanyPhoneNumber.phone == payload.phone)) if dup.first(): raise HTTPException(status_code=409, detail="Phone number already exists") p = CompanyPhoneNumber(user_id=user.id, phone=payload.phone, label=payload.label.strip()) db.add(p) await db.commit() await log_audit(db, user.id, "phone.add", "company_phone", p.id, {"label": p.label}) return _serialize(p) @router.post("/import") async def import_phones(file: UploadFile = None, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Import phone numbers from CSV (phone[,label]) or TXT (one per line). Supports bulk import of 10+ numbers at once.""" if file is None: raise HTTPException(status_code=400, detail="No file uploaded") raw = await file.read() if len(raw) > 2 * 1024 * 1024: raise HTTPException(status_code=413, detail="File exceeds 2MB") try: text = raw.decode("utf-8-sig") except UnicodeDecodeError: raise HTTPException(status_code=400, detail="File must be UTF-8 text") # reject JSON-style input explicitly (CSV/TXT only per spec) stripped = text.lstrip() if stripped.startswith("{") or stripped.startswith("["): raise HTTPException(status_code=400, detail="Only CSV or TXT files are accepted") entries: list[dict] = [] invalid: list[str] = [] if "," in "\n".join(text.splitlines()[:50]) or file.filename.lower().endswith(".csv"): reader = csv.reader(io.StringIO(text)) for row in reader: if not row or not any(c.strip() for c in row): continue phone = (row[0] or "").strip() label = (row[1] or "").strip() if len(row) > 1 else "" if not phone or phone.lower().startswith(("phone", "number", "#")): continue # header if PHONE_RE.match(phone): entries.append({"phone": phone, "label": label}) else: invalid.append(phone) else: for line in text.splitlines(): phone = line.strip() if not phone or phone.lower().startswith(("phone", "number", "#")): continue if PHONE_RE.match(phone): entries.append({"phone": phone, "label": ""}) else: invalid.append(phone) if not entries: raise HTTPException(status_code=400, detail=f"No valid phone numbers found. Invalid: {invalid[:10]}") existing = set((await db.execute( select(CompanyPhoneNumber.phone).where(CompanyPhoneNumber.user_id == user.id))).scalars()) added, duplicates = 0, 0 for e in entries: if e["phone"] in existing: duplicates += 1 continue db.add(CompanyPhoneNumber(user_id=user.id, phone=e["phone"], label=e["label"])) existing.add(e["phone"]) added += 1 await db.commit() await log_audit(db, user.id, "phone.import", "company_phone", None, {"added": added, "duplicates": duplicates, "invalid": len(invalid)}) return {"added": added, "duplicates": duplicates, "invalid": invalid[:20], "total_valid": len(entries)} @router.post("/{pid}/patch") async def patch_phone(pid: int, payload: PhonePatch, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): p = await _get_own(db, user, pid) if payload.label is not None: p.label = payload.label.strip()[:150] if payload.is_active is not None: p.is_active = bool(payload.is_active) await db.commit() await log_audit(db, user.id, "phone.patch", "company_phone", pid, {"is_active": p.is_active}) return _serialize(p) @router.post("/{pid}/delete") async def delete_phone(pid: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): p = await _get_own(db, user, pid) phone_value = p.phone await db.delete(p) await db.commit() await log_audit(db, user.id, "phone.delete", "company_phone", pid, {"phone": phone_value}) return {"deleted": pid} @router.post("/clear-all") async def clear_all_phones(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): rows = (await db.execute( select(CompanyPhoneNumber).where(CompanyPhoneNumber.user_id == user.id))).scalars().all() for p in rows: await db.delete(p) await db.commit() await log_audit(db, user.id, "phone.clear_all", "company_phone", None, {"deleted": len(rows)}) return {"deleted": len(rows)}