diff --git a/README.md b/README.md index 80808e8a..47b934eb 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ # Midas -Midas is a production-oriented MVP for automated cold outreach, follow-up orchestration, and reply handling. It combines: +Midas is a production-oriented platform for autonomous cold outreach operations. This iteration ships a robust **lead ingestion and storage foundation** with a modern UX and API-first backend. -- **Lead ingestion** (CSV/TXT/JSON) -- **De-duplication** against prior contacts and opt-outs -- **Template generation/optimization agents** -- **Outreach sending and follow-up sequencing** -- **Reply sentiment analysis + draft response generation** -- **Dashboard** with campaign metrics, alerts, and controls -- **Provider/model failover** (API key/model rotation) +## What this base includes + +- Multi-format lead upload: **CSV / TXT / JSON / XLSX** +- Field normalization for common header variations (`full_name`, `email_address`, `job_title`, etc.) +- Database-backed deduplication and opt-out safeguards +- Lead inventory browsing with search + status filters +- API-first backend designed for separate frontend/backend deployments +- Standalone frontend bundle (`/frontend`) that can run on a different host from the API ## Stack -- FastAPI + Jinja dashboard -- SQLAlchemy + SQLite (swap-ready for PostgreSQL) -- Agent layer with pluggable providers (Google ADK/Gemini-ready interfaces) +- Backend: FastAPI + SQLAlchemy +- Database: SQLite by default (PostgreSQL-ready) +- Frontend: static app (HTML/CSS/JS) with API integration ## Quickstart @@ -25,19 +26,26 @@ pip install -e .[dev] uvicorn app.main:app --reload ``` -Open `http://127.0.0.1:8000`. +Open `http://127.0.0.1:8000` for the integrated dashboard. -## Environment +To host frontend separately: + +1. Serve `frontend/` on any static host. +2. Set `window.MIDAS_API_BASE` in `frontend/index.html` to your backend URL. +3. Configure backend CORS via `MIDAS_CORS_ALLOWED_ORIGINS`. + +## API endpoints (lead foundation) -Set optional env vars: +- `GET /api/v1/health` +- `GET /api/v1/dashboard` +- `GET /api/v1/leads?status=&search=&limit=&offset=` +- `POST /api/v1/leads/import` (multipart `file`) + +## Environment - `MIDAS_DB_URL` (default: `sqlite:///./midas.db`) +- `MIDAS_CORS_ALLOWED_ORIGINS` (default: `*`, comma-separated) - `MIDAS_SENDER_EMAIL` (default: `hello@midas.local`) - `MIDAS_DAILY_SEND_LIMIT_PER_MAILBOX` (default: `80`) - `MIDAS_REPLY_AUTO_SEND_DELAY_MINUTES` (default: `60`) -- `MIDAS_MODEL_CONFIG` JSON list for model/key rotation (see `app/core/config.py`) - -## Notes - -- Email sending and inbound sync use adapter interfaces with a safe local logger implementation by default. -- Replace adapters in `app/services/email_gateway.py` and `app/services/inbox_sync.py` for SMTP/IMAP, Gmail API, SES, etc. +- `MIDAS_MODEL_CONFIG` JSON list for model/key rotation diff --git a/app/api/routes.py b/app/api/routes.py index 75b96d4d..94b9925e 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,47 +1,130 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, File, Form, Request, UploadFile +from datetime import datetime + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.db.session import get_db -from app.models.entities import Alert, Lead, ReplyMessage -from app.models.schemas import IncomingReply +from app.models.entities import Alert, Lead, LeadStatus, ReplyMessage +from app.models.schemas import IncomingReply, LeadImportResult, LeadListResponse, LeadOut from app.services.campaign_service import CampaignService from app.services.lead_importer import LeadImporter router = APIRouter() +api_router = APIRouter(prefix="/api/v1", tags=["api"]) templates = Jinja2Templates(directory="app/templates") @router.get("/", response_class=HTMLResponse) -def dashboard(request: Request, db: Session = Depends(get_db)): +def dashboard(request: Request): + return templates.TemplateResponse(request, "dashboard.html", {}) + + +@router.post("/leads/import") +async def import_leads(file: UploadFile = File(...), db: Session = Depends(get_db)): + payload = await file.read() + importer = LeadImporter(db) + rows = importer.parse(file.filename or "upload", payload) + _ = importer.import_rows(rows) + return RedirectResponse(url="/", status_code=303) + + +@api_router.get("/health") +def health_check() -> dict[str, str]: + return {"status": "ok"} + + +@api_router.get("/dashboard") +def dashboard_data(db: Session = Depends(get_db)): service = CampaignService(db) metrics = service.metrics() - leads = db.scalars(select(Lead).order_by(Lead.created_at.desc()).limit(20)).all() alerts = db.scalars(select(Alert).order_by(Alert.created_at.desc()).limit(10)).all() replies = db.scalars(select(ReplyMessage).order_by(ReplyMessage.received_at.desc()).limit(10)).all() - return templates.TemplateResponse( - request, - "dashboard.html", - { - "metrics": metrics, - "leads": leads, - "alerts": alerts, - "replies": replies, - }, + return { + "metrics": metrics.model_dump(), + "alerts": [ + { + "id": item.id, + "severity": item.severity, + "message": item.message, + "created_at": item.created_at.isoformat(), + } + for item in alerts + ], + "replies": [ + { + "id": reply.id, + "lead_id": reply.lead_id, + "sentiment": reply.sentiment.value, + "received_at": reply.received_at.isoformat(), + } + for reply in replies + ], + } + + +@api_router.get("/leads", response_model=LeadListResponse) +def list_leads( + db: Session = Depends(get_db), + status: LeadStatus | None = Query(default=None), + search: str = Query(default="", min_length=0, max_length=255), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +): + query = select(Lead) + count_query = select(func.count()).select_from(Lead) + + if status: + query = query.where(Lead.status == status) + count_query = count_query.where(Lead.status == status) + + search_value = search.strip() + if search_value: + pattern = f"%{search_value}%" + query = query.where((Lead.email.ilike(pattern)) | (Lead.name.ilike(pattern)) | (Lead.company.ilike(pattern))) + count_query = count_query.where((Lead.email.ilike(pattern)) | (Lead.name.ilike(pattern)) | (Lead.company.ilike(pattern))) + + total = db.scalar(count_query) or 0 + leads = db.scalars(query.order_by(Lead.created_at.desc()).offset(offset).limit(limit)).all() + + return LeadListResponse( + total=total, + leads=[ + LeadOut( + id=lead.id, + name=lead.name, + email=lead.email, + company=lead.company, + position=lead.position, + niche=lead.niche, + status=lead.status.value, + created_at=lead.created_at.isoformat(), + ) + for lead in leads + ], ) -@router.post("/leads/import") -async def import_leads(file: UploadFile = File(...), db: Session = Depends(get_db)): +@api_router.post("/leads/import", response_model=LeadImportResult) +async def import_leads_api(file: UploadFile = File(...), db: Session = Depends(get_db)): + if not file.filename: + raise HTTPException(status_code=400, detail="Uploaded file must include a filename.") + payload = await file.read() + if not payload: + raise HTTPException(status_code=400, detail="Uploaded file is empty.") + importer = LeadImporter(db) - rows = importer.parse(file.filename, payload) - _ = importer.import_rows(rows) - return RedirectResponse(url="/", status_code=303) + try: + rows = importer.parse(file.filename, payload) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return importer.import_rows(rows) @router.post("/templates/generate") @@ -69,7 +152,7 @@ def send_followups(db: Session = Depends(get_db)): def ingest_reply(payload: IncomingReply, db: Session = Depends(get_db)): service = CampaignService(db) service.process_incoming_reply(payload.lead_email, payload.raw_body) - return {"status": "ok"} + return {"status": "ok", "received_at": datetime.utcnow().isoformat()} @router.post("/reply/{lead_id}/approve") diff --git a/app/core/config.py b/app/core/config.py index a6246578..76ca3f2e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -25,6 +25,13 @@ class Settings: reply_auto_send_delay_minutes: int = int( os.getenv("MIDAS_REPLY_AUTO_SEND_DELAY_MINUTES", "60") ) + cors_allowed_origins: list[str] = field( + default_factory=lambda: [ + origin.strip() + for origin in os.getenv("MIDAS_CORS_ALLOWED_ORIGINS", "*").split(",") + if origin.strip() + ] + ) model_config_raw: str = os.getenv( "MIDAS_MODEL_CONFIG", json.dumps( diff --git a/app/main.py b/app/main.py index 760a8e35..6fe4fb1c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,14 +1,24 @@ from __future__ import annotations from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from app.api.routes import router +from app.api.routes import api_router, router +from app.core.config import settings from app.db.session import init_db app = FastAPI(title="Midas") +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) app.mount("/static", StaticFiles(directory="app/static"), name="static") app.include_router(router) +app.include_router(api_router) @app.on_event("startup") diff --git a/app/models/schemas.py b/app/models/schemas.py index f87b85e0..3443305e 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -17,6 +17,23 @@ class LeadImportResult(BaseModel): inserted: int skipped_existing: int skipped_opted_out: int + invalid_rows: int + + +class LeadOut(BaseModel): + id: int + name: str + email: EmailStr + company: str | None + position: str | None + niche: str | None + status: str + created_at: str + + +class LeadListResponse(BaseModel): + total: int + leads: list[LeadOut] class DraftEmail(BaseModel): diff --git a/app/services/lead_importer.py b/app/services/lead_importer.py index 25f2430a..4f234e12 100644 --- a/app/services/lead_importer.py +++ b/app/services/lead_importer.py @@ -5,41 +5,49 @@ import json from collections.abc import Iterable +from openpyxl import load_workbook from sqlalchemy import select from sqlalchemy.orm import Session from app.models.entities import Lead from app.models.schemas import LeadImportResult +_FIELD_ALIASES = { + "name": {"name", "full_name", "lead_name", "contact_name"}, + "email": {"email", "email_address", "mail"}, + "company": {"company", "company_name", "organization", "org"}, + "position": {"position", "title", "job_title", "role"}, + "niche": {"niche", "segment", "industry"}, +} + class LeadImporter: def __init__(self, db: Session) -> None: self.db = db - def parse(self, filename: str, payload: bytes) -> Iterable[dict[str, str]]: + def parse(self, filename: str, payload: bytes) -> list[dict[str, str]]: lower = filename.lower() - text = payload.decode("utf-8") if lower.endswith(".csv"): - reader = csv.DictReader(io.StringIO(text)) - return [dict(row) for row in reader] + return self._parse_csv(payload) if lower.endswith(".json"): - return json.loads(text) + return self._parse_json(payload) if lower.endswith(".txt"): - rows = [] - for line in text.splitlines(): - parts = [p.strip() for p in line.split(",")] - if len(parts) >= 2: - rows.append({"name": parts[0], "email": parts[1]}) - return rows - raise ValueError("Unsupported file type. Use CSV, JSON, or TXT.") + return self._parse_txt(payload) + if lower.endswith(".xlsx"): + return self._parse_xlsx(payload) + raise ValueError("Unsupported file type. Use CSV, TXT, JSON, or XLSX.") def import_rows(self, rows: Iterable[dict[str, str]]) -> LeadImportResult: - inserted = skipped_existing = skipped_opted_out = 0 - for row in rows: + inserted = skipped_existing = skipped_opted_out = invalid_rows = 0 + for raw in rows: + row = self._normalize_row(raw) email = (row.get("email") or "").strip().lower() name = (row.get("name") or "").strip() + if not email or not name: + invalid_rows += 1 continue + existing = self.db.scalar(select(Lead).where(Lead.email == email)) if existing: if existing.opt_out: @@ -47,6 +55,7 @@ def import_rows(self, rows: Iterable[dict[str, str]]) -> LeadImportResult: else: skipped_existing += 1 continue + lead = Lead( name=name, email=email, @@ -56,9 +65,79 @@ def import_rows(self, rows: Iterable[dict[str, str]]) -> LeadImportResult: ) self.db.add(lead) inserted += 1 + self.db.commit() return LeadImportResult( inserted=inserted, skipped_existing=skipped_existing, skipped_opted_out=skipped_opted_out, + invalid_rows=invalid_rows, ) + + def _parse_csv(self, payload: bytes) -> list[dict[str, str]]: + text = payload.decode("utf-8-sig") + reader = csv.DictReader(io.StringIO(text)) + return [dict(row) for row in reader] + + def _parse_json(self, payload: bytes) -> list[dict[str, str]]: + text = payload.decode("utf-8") + data = json.loads(text) + if isinstance(data, dict): + data = [data] + if not isinstance(data, list): + raise ValueError("JSON import expects an object or an array of objects.") + return [dict(row) for row in data if isinstance(row, dict)] + + def _parse_txt(self, payload: bytes) -> list[dict[str, str]]: + text = payload.decode("utf-8") + rows: list[dict[str, str]] = [] + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + continue + parts = [p.strip() for p in stripped.split(",")] + if len(parts) >= 2: + rows.append( + { + "name": parts[0], + "email": parts[1], + "company": parts[2] if len(parts) > 2 else "", + "position": parts[3] if len(parts) > 3 else "", + } + ) + else: + rows.append({"name": "", "email": ""}) + return rows + + def _parse_xlsx(self, payload: bytes) -> list[dict[str, str]]: + workbook = load_workbook(filename=io.BytesIO(payload), read_only=True) + sheet = workbook.active + rows_iter = sheet.iter_rows(values_only=True) + header_row = next(rows_iter, None) + if not header_row: + return [] + + headers = [str(h).strip() if h is not None else "" for h in header_row] + records: list[dict[str, str]] = [] + for values in rows_iter: + row: dict[str, str] = {} + for idx, header in enumerate(headers): + if not header: + continue + value = values[idx] if idx < len(values) else None + row[header] = "" if value is None else str(value).strip() + if any(v for v in row.values()): + records.append(row) + return records + + def _normalize_row(self, row: dict[str, str]) -> dict[str, str]: + lowered = {(k or "").strip().lower(): (v if isinstance(v, str) else str(v or "")) for k, v in row.items()} + normalized: dict[str, str] = {} + for target, aliases in _FIELD_ALIASES.items(): + value = "" + for alias in aliases: + if alias in lowered and lowered[alias].strip(): + value = lowered[alias].strip() + break + normalized[target] = value + return normalized diff --git a/app/static/css/style.css b/app/static/css/style.css index 8e4c4b43..3a95fb36 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1,76 +1,117 @@ :root { - --bg: #0f172a; - --card: #111827; - --text: #e5e7eb; - --muted: #9ca3af; - --accent: #d4a72c; - --accent-soft: rgba(212, 167, 44, 0.15); + --bg: #060a17; + --surface: #0f172a; + --surface-2: #111b33; + --text: #edf2ff; + --muted: #95a3c3; + --accent: #5b8cff; + --success: #00c06b; + --danger: #ff4d6d; + --border: #24314f; } * { box-sizing: border-box; } + body { margin: 0; - font-family: Inter, system-ui, sans-serif; - background: linear-gradient(180deg, #0b1224, #0f172a); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--text); + background: radial-gradient(circle at top right, #172447, var(--bg) 60%); } -header { padding: 1.5rem 2rem 0.5rem; } -header h1 { margin: 0; } -header p { color: var(--muted); margin-top: 0.3rem; } -.grid { - padding: 1rem 2rem 2rem; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; +.shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; } + +.sidebar { + border-right: 1px solid var(--border); + background: rgba(5, 10, 20, 0.65); + backdrop-filter: blur(8px); + padding: 2rem 1.25rem; +} +.sidebar h1 { margin: 0; letter-spacing: 0.05em; } +.sidebar p { color: var(--muted); margin-top: 0.4rem; } +.sidebar nav { margin-top: 2rem; display: grid; } +.sidebar a { + text-decoration: none; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.8rem; } + +.content { padding: 2rem; display: grid; gap: 1rem; } +.topbar h2 { margin: 0; } +.topbar p { margin: 0.35rem 0 0; color: var(--muted); } + +.cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.8rem; } .card { - background: var(--card); - border: 1px solid #1f2937; + background: linear-gradient(180deg, var(--surface-2), #0f1730); + border: 1px solid var(--border); border-radius: 14px; padding: 1rem; } -.metrics { grid-column: span 2; } -.metric-grid { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 0.5rem; +.card .label { color: var(--muted); font-size: 0.8rem; } +.card .value { font-size: 1.5rem; margin-top: 0.3rem; font-weight: 700; } + +.panel { + background: rgba(11, 21, 41, 0.92); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1rem; } -.metric-grid div { - background: #0b1220; - border: 1px solid #1f2937; - border-left: 3px solid var(--accent); - border-radius: 10px; - padding: 0.8rem; +.panel-upload { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: center; } -.metric-grid span { color: var(--muted); font-size: 0.75rem; display:block; } -.metric-grid strong { font-size: 1.2rem; } -input, textarea, button { - width: 100%; - border-radius: 8px; - border: 1px solid #374151; - background: #0b1220; +#upload-form { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; } + +input, select, button { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); color: var(--text); - padding: 0.55rem 0.65rem; + padding: 0.6rem 0.75rem; } button { - border-color: var(--accent); - background: var(--accent-soft); + background: linear-gradient(180deg, #6a97ff, var(--accent)); + border-color: #4f7de4; + font-weight: 600; cursor: pointer; } -.stack { display: grid; gap: 0.5rem; } -.actions { margin-top: 0.7rem; display: flex; gap: 0.5rem; } -.actions form { flex: 1; } +button:disabled { opacity: 0.6; cursor: not-allowed; } + +.message { margin: 0; color: var(--muted); } +.message.success { color: var(--success); } +.message.error { color: var(--danger); } + +.panel-head { display: flex; justify-content: space-between; gap: 1rem; align-items: center; } +.filters { display: flex; gap: 0.5rem; } + +.table-wrap { overflow: auto; } +table { width: 100%; border-collapse: collapse; margin-top: 0.8rem; } +th, td { padding: 0.7rem 0.6rem; border-bottom: 1px solid var(--border); text-align: left; } +th { color: var(--muted); font-weight: 600; font-size: 0.85rem; } +.status-pill { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.15rem 0.55rem; + text-transform: capitalize; + font-size: 0.8rem; + display: inline-block; +} -ul { padding-left: 1rem; } -li { margin-bottom: 0.4rem; } -table { width: 100%; border-collapse: collapse; } -th, td { border-bottom: 1px solid #1f2937; padding: 0.4rem; text-align:left; font-size: 0.9rem; } -.standalone { max-width: 520px; margin: 3rem auto; } +@media (max-width: 1100px) { + .shell { grid-template-columns: 1fr; } + .sidebar { border-right: none; border-bottom: 1px solid var(--border); } + .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} -@media (max-width: 900px) { - .grid { grid-template-columns: 1fr; } - .metrics { grid-column: span 1; } - .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +@media (max-width: 720px) { + .content { padding: 1rem; } + .cards { grid-template-columns: 1fr; } + .panel-upload { grid-template-columns: 1fr; } + .panel-head, .filters { flex-direction: column; align-items: stretch; } } diff --git a/app/static/js/app.js b/app/static/js/app.js index 37e077d8..e64d4089 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1 +1,162 @@ -// Reserved for interactive dashboard enhancements. +const API_BASE = window.MIDAS_API_BASE || ""; + +const metricsEl = document.getElementById("metrics"); +const tableEl = document.getElementById("lead-table"); +const uploadForm = document.getElementById("upload-form"); +const fileInput = document.getElementById("lead-file"); +const uploadMessage = document.getElementById("upload-message"); +const searchInput = document.getElementById("search-input"); +const statusFilter = document.getElementById("status-filter"); + +const defaultMetrics = [ + ["Total Leads", 0], + ["New", 0], + ["Outreached", 0], + ["Follow Up Due", 0], + ["Replied", 0], + ["Opted Out", 0], +]; + +function renderMetricCards(metrics) { + const cards = [ + ["Total Leads", metrics.total_leads ?? 0], + ["New", (metrics.total_leads ?? 0) - (metrics.outreached ?? 0) - (metrics.replied ?? 0) - (metrics.follow_up_due ?? 0)], + ["Outreached", metrics.outreached ?? 0], + ["Follow Up Due", metrics.follow_up_due ?? 0], + ["Replied", metrics.replied ?? 0], + ["Conversion Rate", `${metrics.conversion_rate ?? 0}%`], + ]; + + metricsEl.innerHTML = cards + .map( + ([label, value]) => + `
${label}
${value}
`, + ) + .join(""); +} + +function renderFallbackMetrics() { + metricsEl.innerHTML = defaultMetrics + .map( + ([label, value]) => + `
${label}
${value}
`, + ) + .join(""); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderLeads(leads) { + if (!leads.length) { + tableEl.innerHTML = 'No leads found.'; + return; + } + + tableEl.innerHTML = leads + .map((lead) => { + const createdDate = new Date(lead.created_at); + const formatted = Number.isNaN(createdDate.valueOf()) + ? "-" + : createdDate.toLocaleDateString(); + return ` + ${escapeHtml(lead.name)} + ${escapeHtml(lead.email)} + ${escapeHtml(lead.company || "-")} + ${escapeHtml(lead.position || "-")} + ${escapeHtml(lead.status.replaceAll("_", " "))} + ${formatted} + `; + }) + .join(""); +} + +async function loadDashboard() { + try { + const response = await fetch(`${API_BASE}/api/v1/dashboard`); + if (!response.ok) throw new Error(`dashboard status ${response.status}`); + const payload = await response.json(); + renderMetricCards(payload.metrics || {}); + } catch { + renderFallbackMetrics(); + } +} + +async function loadLeads() { + const search = searchInput.value.trim(); + const status = statusFilter.value; + const query = new URLSearchParams({ limit: "100" }); + if (search) query.set("search", search); + if (status) query.set("status", status); + + const response = await fetch(`${API_BASE}/api/v1/leads?${query.toString()}`); + if (!response.ok) { + throw new Error("Failed to load leads"); + } + const payload = await response.json(); + renderLeads(payload.leads || []); +} + +async function uploadLeads(event) { + event.preventDefault(); + uploadMessage.className = "message"; + uploadMessage.textContent = ""; + + const file = fileInput.files?.[0]; + if (!file) { + uploadMessage.classList.add("error"); + uploadMessage.textContent = "Select a file before uploading."; + return; + } + + const formData = new FormData(); + formData.append("file", file); + + const submitBtn = uploadForm.querySelector("button[type='submit']"); + submitBtn.disabled = true; + + try { + const response = await fetch(`${API_BASE}/api/v1/leads/import`, { + method: "POST", + body: formData, + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.detail || "Lead upload failed."); + } + + uploadMessage.classList.add("success"); + uploadMessage.textContent = `Imported ${payload.inserted}. Existing ${payload.skipped_existing}. Opted-out skipped ${payload.skipped_opted_out}. Invalid ${payload.invalid_rows}.`; + fileInput.value = ""; + + await Promise.all([loadDashboard(), loadLeads()]); + } catch (error) { + uploadMessage.classList.add("error"); + uploadMessage.textContent = error instanceof Error ? error.message : "Upload failed."; + } finally { + submitBtn.disabled = false; + } +} + +uploadForm.addEventListener("submit", uploadLeads); +searchInput.addEventListener("input", () => { + loadLeads().catch(() => { + tableEl.innerHTML = 'Could not load leads.'; + }); +}); +statusFilter.addEventListener("change", () => { + loadLeads().catch(() => { + tableEl.innerHTML = 'Could not load leads.'; + }); +}); + +loadDashboard(); +loadLeads().catch(() => { + tableEl.innerHTML = 'Could not load leads.'; +}); diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 869fc052..dfa24a08 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -3,81 +3,75 @@ - Midas Dashboard + Midas | Lead Foundation -
-

Midas

-

Outreach, replies, follow-ups, and conversion intelligence.

-
+
+ -
-
-

Core Metrics

-
-
Total Leads{{ metrics.total_leads }}
-
Outreached{{ metrics.outreached }}
-
Replied{{ metrics.replied }}
-
Follow-up Stage{{ metrics.follow_up_due }}
-
Conversion{{ metrics.conversion_rate }}%
-
Templates{{ metrics.templates_total }}
-
-
+
+
+
+

Lead Upload & Storage

+

Upload validated lead files and track segmented lead inventory.

+
+
-
-

Lead Import

-
- - -
+
-

Generate Outreach Templates

-
- - - -
+
+
+

Import Leads

+

Accepted formats: CSV, TXT, JSON, XLSX

+
+
+ + +
+

+
-
-
-
-
-
- -
-

Recent Leads

- - - - {% for lead in leads %} - - - - {% endfor %} - -
NameEmailCompanyStatus
{{ lead.name }}{{ lead.email }}{{ lead.company or '-' }}{{ lead.status.value }}
-
- -
-

Alerts & Reply Drafts

-
    - {% for alert in alerts %} -
  • [{{ alert.severity|upper }}] {{ alert.message }}
  • - {% endfor %} -
-

Latest Replies

-
    - {% for reply in replies %} -
  • - lead_id={{ reply.lead_id }} sentiment={{ reply.sentiment.value }} -
    - -
    -
  • - {% endfor %} -
-
-
+
+
+

Lead Records

+
+ + +
+
+
+ + + + + + + + + + + + +
NameEmailCompanyPositionStatusAdded
+
+
+
+
+ diff --git a/frontend/assets/app.js b/frontend/assets/app.js new file mode 100644 index 00000000..e64d4089 --- /dev/null +++ b/frontend/assets/app.js @@ -0,0 +1,162 @@ +const API_BASE = window.MIDAS_API_BASE || ""; + +const metricsEl = document.getElementById("metrics"); +const tableEl = document.getElementById("lead-table"); +const uploadForm = document.getElementById("upload-form"); +const fileInput = document.getElementById("lead-file"); +const uploadMessage = document.getElementById("upload-message"); +const searchInput = document.getElementById("search-input"); +const statusFilter = document.getElementById("status-filter"); + +const defaultMetrics = [ + ["Total Leads", 0], + ["New", 0], + ["Outreached", 0], + ["Follow Up Due", 0], + ["Replied", 0], + ["Opted Out", 0], +]; + +function renderMetricCards(metrics) { + const cards = [ + ["Total Leads", metrics.total_leads ?? 0], + ["New", (metrics.total_leads ?? 0) - (metrics.outreached ?? 0) - (metrics.replied ?? 0) - (metrics.follow_up_due ?? 0)], + ["Outreached", metrics.outreached ?? 0], + ["Follow Up Due", metrics.follow_up_due ?? 0], + ["Replied", metrics.replied ?? 0], + ["Conversion Rate", `${metrics.conversion_rate ?? 0}%`], + ]; + + metricsEl.innerHTML = cards + .map( + ([label, value]) => + `
${label}
${value}
`, + ) + .join(""); +} + +function renderFallbackMetrics() { + metricsEl.innerHTML = defaultMetrics + .map( + ([label, value]) => + `
${label}
${value}
`, + ) + .join(""); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderLeads(leads) { + if (!leads.length) { + tableEl.innerHTML = 'No leads found.'; + return; + } + + tableEl.innerHTML = leads + .map((lead) => { + const createdDate = new Date(lead.created_at); + const formatted = Number.isNaN(createdDate.valueOf()) + ? "-" + : createdDate.toLocaleDateString(); + return ` + ${escapeHtml(lead.name)} + ${escapeHtml(lead.email)} + ${escapeHtml(lead.company || "-")} + ${escapeHtml(lead.position || "-")} + ${escapeHtml(lead.status.replaceAll("_", " "))} + ${formatted} + `; + }) + .join(""); +} + +async function loadDashboard() { + try { + const response = await fetch(`${API_BASE}/api/v1/dashboard`); + if (!response.ok) throw new Error(`dashboard status ${response.status}`); + const payload = await response.json(); + renderMetricCards(payload.metrics || {}); + } catch { + renderFallbackMetrics(); + } +} + +async function loadLeads() { + const search = searchInput.value.trim(); + const status = statusFilter.value; + const query = new URLSearchParams({ limit: "100" }); + if (search) query.set("search", search); + if (status) query.set("status", status); + + const response = await fetch(`${API_BASE}/api/v1/leads?${query.toString()}`); + if (!response.ok) { + throw new Error("Failed to load leads"); + } + const payload = await response.json(); + renderLeads(payload.leads || []); +} + +async function uploadLeads(event) { + event.preventDefault(); + uploadMessage.className = "message"; + uploadMessage.textContent = ""; + + const file = fileInput.files?.[0]; + if (!file) { + uploadMessage.classList.add("error"); + uploadMessage.textContent = "Select a file before uploading."; + return; + } + + const formData = new FormData(); + formData.append("file", file); + + const submitBtn = uploadForm.querySelector("button[type='submit']"); + submitBtn.disabled = true; + + try { + const response = await fetch(`${API_BASE}/api/v1/leads/import`, { + method: "POST", + body: formData, + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.detail || "Lead upload failed."); + } + + uploadMessage.classList.add("success"); + uploadMessage.textContent = `Imported ${payload.inserted}. Existing ${payload.skipped_existing}. Opted-out skipped ${payload.skipped_opted_out}. Invalid ${payload.invalid_rows}.`; + fileInput.value = ""; + + await Promise.all([loadDashboard(), loadLeads()]); + } catch (error) { + uploadMessage.classList.add("error"); + uploadMessage.textContent = error instanceof Error ? error.message : "Upload failed."; + } finally { + submitBtn.disabled = false; + } +} + +uploadForm.addEventListener("submit", uploadLeads); +searchInput.addEventListener("input", () => { + loadLeads().catch(() => { + tableEl.innerHTML = 'Could not load leads.'; + }); +}); +statusFilter.addEventListener("change", () => { + loadLeads().catch(() => { + tableEl.innerHTML = 'Could not load leads.'; + }); +}); + +loadDashboard(); +loadLeads().catch(() => { + tableEl.innerHTML = 'Could not load leads.'; +}); diff --git a/frontend/assets/style.css b/frontend/assets/style.css new file mode 100644 index 00000000..3a95fb36 --- /dev/null +++ b/frontend/assets/style.css @@ -0,0 +1,117 @@ +:root { + --bg: #060a17; + --surface: #0f172a; + --surface-2: #111b33; + --text: #edf2ff; + --muted: #95a3c3; + --accent: #5b8cff; + --success: #00c06b; + --danger: #ff4d6d; + --border: #24314f; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + color: var(--text); + background: radial-gradient(circle at top right, #172447, var(--bg) 60%); +} + +.shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; } + +.sidebar { + border-right: 1px solid var(--border); + background: rgba(5, 10, 20, 0.65); + backdrop-filter: blur(8px); + padding: 2rem 1.25rem; +} +.sidebar h1 { margin: 0; letter-spacing: 0.05em; } +.sidebar p { color: var(--muted); margin-top: 0.4rem; } +.sidebar nav { margin-top: 2rem; display: grid; } +.sidebar a { + text-decoration: none; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.8rem; +} + +.content { padding: 2rem; display: grid; gap: 1rem; } +.topbar h2 { margin: 0; } +.topbar p { margin: 0.35rem 0 0; color: var(--muted); } + +.cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.8rem; } +.card { + background: linear-gradient(180deg, var(--surface-2), #0f1730); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1rem; +} +.card .label { color: var(--muted); font-size: 0.8rem; } +.card .value { font-size: 1.5rem; margin-top: 0.3rem; font-weight: 700; } + +.panel { + background: rgba(11, 21, 41, 0.92); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1rem; +} +.panel-upload { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: center; +} + +#upload-form { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; } + +input, select, button { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--text); + padding: 0.6rem 0.75rem; +} +button { + background: linear-gradient(180deg, #6a97ff, var(--accent)); + border-color: #4f7de4; + font-weight: 600; + cursor: pointer; +} +button:disabled { opacity: 0.6; cursor: not-allowed; } + +.message { margin: 0; color: var(--muted); } +.message.success { color: var(--success); } +.message.error { color: var(--danger); } + +.panel-head { display: flex; justify-content: space-between; gap: 1rem; align-items: center; } +.filters { display: flex; gap: 0.5rem; } + +.table-wrap { overflow: auto; } +table { width: 100%; border-collapse: collapse; margin-top: 0.8rem; } +th, td { padding: 0.7rem 0.6rem; border-bottom: 1px solid var(--border); text-align: left; } +th { color: var(--muted); font-weight: 600; font-size: 0.85rem; } +.status-pill { + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.15rem 0.55rem; + text-transform: capitalize; + font-size: 0.8rem; + display: inline-block; +} + +@media (max-width: 1100px) { + .shell { grid-template-columns: 1fr; } + .sidebar { border-right: none; border-bottom: 1px solid var(--border); } + .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 720px) { + .content { padding: 1rem; } + .cards { grid-template-columns: 1fr; } + .panel-upload { grid-template-columns: 1fr; } + .panel-head, .filters { flex-direction: column; align-items: stretch; } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..9b5f6363 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,81 @@ + + + + + + Midas Leads Console + + + + +
+ + +
+
+
+

Lead Upload & Storage

+

Upload validated lead files and track segmented lead inventory.

+
+
+ +
+ +
+
+

Import Leads

+

Accepted formats: CSV, TXT, JSON, XLSX

+
+
+ + +
+

+
+ +
+
+

Lead Records

+
+ + +
+
+
+ + + + + + + + + + + + +
NameEmailCompanyPositionStatusAdded
+
+
+
+
+ + + + diff --git a/pyproject.toml b/pyproject.toml index a487dbf6..35a89e20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,8 @@ dependencies = [ "python-multipart>=0.0.9", "email-validator>=2.2.0", "aiosqlite>=0.20.0", - "httpx>=0.27.0" + "httpx>=0.27.0", + "openpyxl>=3.1.5" ] [project.optional-dependencies] @@ -28,3 +29,6 @@ dev = [ [tool.pytest.ini_options] pythonpath = ["."] + +[tool.setuptools.packages.find] +include = ["app*"] diff --git a/tests/test_campaign.py b/tests/test_campaign.py index 4f61d320..30c3464c 100644 --- a/tests/test_campaign.py +++ b/tests/test_campaign.py @@ -1,5 +1,6 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from openpyxl import Workbook from app.core.config import settings from app.models.entities import Lead, LeadStatus @@ -24,6 +25,7 @@ def test_import_and_outreach_flow(): ] ) assert result.inserted == 2 + assert result.invalid_rows == 0 service = CampaignService(db) created = service.seed_templates("get demos", "SaaS") @@ -37,6 +39,33 @@ def test_import_and_outreach_flow(): assert metrics.replied == 1 +def test_xlsx_import_and_invalid_rows_are_tracked(): + db = _db() + importer = LeadImporter(db) + + wb = Workbook() + ws = wb.active + ws.append(["full_name", "email_address", "company_name", "job_title"]) + ws.append(["Dana", "dana@org.com", "Org", "Founder"]) + ws.append(["", "", "", ""]) # invalid + + from io import BytesIO + + stream = BytesIO() + wb.save(stream) + rows = importer.parse("leads.xlsx", stream.getvalue()) + result = importer.import_rows(rows) + + assert result.inserted == 1 + assert result.invalid_rows == 0 + + dup_result = importer.import_rows([ + {"name": "Dana", "email": "dana@org.com"}, + {"name": "", "email": "missing@org.com"}, + ]) + assert dup_result.skipped_existing == 1 + assert dup_result.invalid_rows == 1 + def test_unsubscribe(): db = _db() importer = LeadImporter(db)