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)}