first commit

This commit is contained in:
debu-2 2026-10-02 11:22:40 +05:30
commit efa5080897
127 changed files with 18610 additions and 0 deletions

45
backend/.env.example Normal file
View file

@ -0,0 +1,45 @@
# Copy to backend/.env when running the API outside Docker:
# cp .env.example .env
#
# Pydantic reads backend/.env and also ../.env (repo root) if present.
# --- Core ---
APP_NAME=Urmi API
ENVIRONMENT=development
# Local Postgres (host port 5433 when using docker compose postgres)
DATABASE_URL=postgresql+psycopg://urmi:urmi@localhost:5433/urmi
# Must match the browser origin serving the frontend
FRONTEND_ORIGIN=http://localhost:5173
# --- Auth / sessions ---
# Required in production. Use at least 32 random characters.
AUTH_SECRET=replace-with-at-least-32-random-characters
# Set true behind HTTPS in production
AUTH_COOKIE_SECURE=false
# Session cookie lifetime in minutes (default: 10080 = 7 days)
ACCESS_TOKEN_MINUTES=10080
# --- Google OAuth (optional for local email/password auth) ---
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/callback
# Admin portal (/admin)
ADMIN_USERNAME=root
ADMIN_PASSWORD=replace-with-strong-admin-password
# --- Ultronsms gateway (server-side only; never expose to frontend) ---
ULTRONSMS_BASE_URL=https://ultronsms.com
ULTRONSMS_USER=GENZENITH
ULTRONSMS_PASSWORD=replace-with-ultronsms-password
# --- Voice relay / Bonvoice (server-side only) ---
REDIS_URL=redis://localhost:6379/0
BONVOICE_TOKEN=replace-with-bonvoice-token
BONVOICE_API_URL=https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/
BONVOICE_MEDIA_ALLOWLIST=103.131.25.5
VOICE_RELAY_PUBLIC_URL=ws://localhost:8000/api/v1/voice

14
backend/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY assets ./assets
RUN mkdir -p uploads
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

1
backend/app/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Urmi control-plane API."""

View file

@ -0,0 +1,359 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Literal
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session
from .models import OtpMessageLog, PhoneNumber, SmsMessageLog, UserAccount, VoiceCall, VoiceSocketEndpoint
Period = Literal["weekly", "monthly", "quarterly"]
Service = Literal["sms", "inbound", "outbound", "otp"]
GroupBy = Literal["dlt_sender", "dlt_template", "did", "socket", "project", "otp_type"]
PERIOD_BUCKETS = {
"weekly": ("week", 12),
"monthly": ("month", 12),
"quarterly": ("quarter", 4),
}
SERVICE_GROUP_BY: dict[str, frozenset[str]] = {
"sms": frozenset({"dlt_sender", "dlt_template"}),
"inbound": frozenset({"did"}),
"outbound": frozenset({"socket", "did"}),
"otp": frozenset({"project", "otp_type"}),
}
DEFAULT_GROUP_BY: dict[str, str] = {
"sms": "dlt_sender",
"inbound": "did",
"outbound": "socket",
"otp": "project",
}
def _truncate_start(value: datetime, unit: str) -> datetime:
value = value.astimezone(timezone.utc)
if unit == "week":
start = value - timedelta(days=value.weekday())
return start.replace(hour=0, minute=0, second=0, microsecond=0)
if unit == "month":
return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
quarter_month = ((value.month - 1) // 3) * 3 + 1
return value.replace(month=quarter_month, day=1, hour=0, minute=0, second=0, microsecond=0)
def _shift_bucket(start: datetime, unit: str, offset: int) -> datetime:
if unit == "week":
return start + timedelta(weeks=offset)
if unit == "month":
month = start.month + offset
year = start.year + (month - 1) // 12
month = ((month - 1) % 12) + 1
return start.replace(year=year, month=month, day=1)
month = start.month + (offset * 3)
year = start.year + (month - 1) // 12
month = ((month - 1) % 12) + 1
return start.replace(year=year, month=month, day=1)
def _format_bucket_label(start: datetime, unit: str) -> str:
if unit == "week":
return start.strftime("%d %b")
if unit == "month":
return start.strftime("%b %Y")
quarter = ((start.month - 1) // 3) + 1
return f"Q{quarter} {start.year}"
def _build_bucket_keys(end: datetime, unit: str, count: int) -> list[tuple[datetime, str]]:
current = _truncate_start(end, unit)
keys: list[tuple[datetime, str]] = []
for offset in range(-(count - 1), 1):
start = _shift_bucket(current, unit, offset)
keys.append((start, _format_bucket_label(start, unit)))
return keys
def _bucket_key(value: datetime, unit: str) -> str:
return _truncate_start(value, unit).isoformat()
def _bucket_map(rows: list[tuple[datetime, int]], unit: str) -> dict[str, int]:
mapped: dict[str, int] = {}
for bucket, count in rows:
if bucket.tzinfo is None:
bucket = bucket.replace(tzinfo=timezone.utc)
mapped[_bucket_key(bucket.astimezone(timezone.utc), unit)] = int(count)
return mapped
def _breakdown_item(label: str, detail: str, count: int) -> dict:
return {"label": label, "detail": detail, "count": int(count)}
def _count_by_bucket(
db: Session,
*,
model,
user_id,
start: datetime,
end: datetime,
unit: str,
extra_filters=(),
) -> dict[str, int]:
bucket = func.date_trunc(unit, model.created_at).label("bucket")
rows = db.execute(
select(bucket, func.count())
.where(
model.user_id == user_id,
model.created_at >= start,
model.created_at <= end,
*extra_filters,
)
.group_by(bucket)
.order_by(bucket)
).all()
return _bucket_map(rows, unit)
def _voice_bucket_map(
db: Session,
*,
user_id,
direction: str,
start: datetime,
end: datetime,
unit: str,
) -> dict[str, int]:
bucket = func.date_trunc(unit, VoiceCall.created_at).label("bucket")
rows = db.execute(
select(bucket, func.count())
.where(
VoiceCall.user_id == user_id,
VoiceCall.direction == direction,
VoiceCall.created_at >= start,
VoiceCall.created_at <= end,
)
.group_by(bucket)
.order_by(bucket)
).all()
return _bucket_map(rows, unit)
def _breakdown_sms_sender(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
rows = db.execute(
select(SmsMessageLog.sender_id, func.count())
.where(
SmsMessageLog.user_id == user_id,
SmsMessageLog.created_at >= start,
SmsMessageLog.created_at <= end,
)
.group_by(SmsMessageLog.sender_id)
.order_by(func.count().desc())
.limit(10)
).all()
return [_breakdown_item(sender_id, "DLT sender header", count) for sender_id, count in rows]
def _breakdown_sms_template(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
rows = db.execute(
select(SmsMessageLog.template_name, func.count())
.where(
SmsMessageLog.user_id == user_id,
SmsMessageLog.created_at >= start,
SmsMessageLog.created_at <= end,
)
.group_by(SmsMessageLog.template_name)
.order_by(func.count().desc())
.limit(10)
).all()
return [_breakdown_item(template_name, "DLT template", count) for template_name, count in rows]
def _breakdown_inbound_did(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
rows = db.execute(
select(VoiceCall.from_did, func.count())
.where(
VoiceCall.user_id == user_id,
VoiceCall.direction == "inbound",
VoiceCall.created_at >= start,
VoiceCall.created_at <= end,
)
.group_by(VoiceCall.from_did)
.order_by(func.count().desc())
.limit(10)
).all()
return [_breakdown_item(did, "Your Urmi number", count) for did, count in rows]
def _breakdown_outbound_did(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
rows = db.execute(
select(VoiceCall.from_did, func.count())
.where(
VoiceCall.user_id == user_id,
VoiceCall.direction == "outbound",
VoiceCall.created_at >= start,
VoiceCall.created_at <= end,
)
.group_by(VoiceCall.from_did)
.order_by(func.count().desc())
.limit(10)
).all()
return [_breakdown_item(did, "Caller ID used", count) for did, count in rows]
def _breakdown_outbound_socket(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
socket_name = VoiceSocketEndpoint.name
phone_number = PhoneNumber.phone_number
label = case(
(socket_name.isnot(None), socket_name),
else_="No voice socket linked",
).label("label")
detail = phone_number.label("detail")
rows = db.execute(
select(label, detail, func.count())
.select_from(VoiceCall)
.join(PhoneNumber, VoiceCall.phone_number_id == PhoneNumber.id)
.outerjoin(
VoiceSocketEndpoint,
(VoiceSocketEndpoint.phone_number_id == VoiceCall.phone_number_id)
& (VoiceSocketEndpoint.user_id == VoiceCall.user_id),
)
.where(
VoiceCall.user_id == user_id,
VoiceCall.direction == "outbound",
VoiceCall.created_at >= start,
VoiceCall.created_at <= end,
)
.group_by(label, detail)
.order_by(func.count().desc())
.limit(10)
).all()
return [
_breakdown_item(
name,
f"Linked to {number}" if name != "No voice socket linked" else f"Number {number}",
count,
)
for name, number, count in rows
]
def _breakdown_otp_project(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
rows = db.execute(
select(OtpMessageLog.project_name, func.count())
.where(
OtpMessageLog.user_id == user_id,
OtpMessageLog.created_at >= start,
OtpMessageLog.created_at <= end,
)
.group_by(OtpMessageLog.project_name)
.order_by(func.count().desc())
.limit(10)
).all()
return [_breakdown_item(project, "UrmiOTP project", count) for project, count in rows]
def _breakdown_otp_type(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
rows = db.execute(
select(OtpMessageLog.template_type, func.count())
.where(
OtpMessageLog.user_id == user_id,
OtpMessageLog.created_at >= start,
OtpMessageLog.created_at <= end,
)
.group_by(OtpMessageLog.template_type)
.order_by(func.count().desc())
.limit(10)
).all()
return [_breakdown_item(template_type.replace("_", " ").title(), "OTP template type", count) for template_type, count in rows]
def _build_breakdown(
db: Session,
user_id,
*,
service: Service,
group_by: GroupBy,
start: datetime,
end: datetime,
) -> list[dict]:
builders = {
("sms", "dlt_sender"): _breakdown_sms_sender,
("sms", "dlt_template"): _breakdown_sms_template,
("inbound", "did"): _breakdown_inbound_did,
("outbound", "did"): _breakdown_outbound_did,
("outbound", "socket"): _breakdown_outbound_socket,
("otp", "project"): _breakdown_otp_project,
("otp", "otp_type"): _breakdown_otp_type,
}
builder = builders.get((service, group_by))
if not builder:
return []
return builder(db, user_id, start, end)
def build_usage_analytics(
db: Session,
user: UserAccount,
*,
period: Period,
service: Service,
group_by: GroupBy,
) -> dict:
if group_by not in SERVICE_GROUP_BY[service]:
group_by = DEFAULT_GROUP_BY[service] # type: ignore[assignment]
unit, bucket_count = PERIOD_BUCKETS[period]
end = datetime.now(timezone.utc)
first_bucket_start = _build_bucket_keys(end, unit, bucket_count)[0][0]
start = first_bucket_start
sms_map = _count_by_bucket(db, model=SmsMessageLog, user_id=user.id, start=start, end=end, unit=unit)
otp_map = _count_by_bucket(db, model=OtpMessageLog, user_id=user.id, start=start, end=end, unit=unit)
inbound_map = _voice_bucket_map(db, user_id=user.id, direction="inbound", start=start, end=end, unit=unit)
outbound_map = _voice_bucket_map(db, user_id=user.id, direction="outbound", start=start, end=end, unit=unit)
timeline = []
for bucket_start, label in _build_bucket_keys(end, unit, bucket_count):
key = _bucket_key(bucket_start, unit)
timeline.append(
{
"label": label,
"period_start": bucket_start,
"sms": sms_map.get(key, 0),
"inbound": inbound_map.get(key, 0),
"outbound": outbound_map.get(key, 0),
"otp": otp_map.get(key, 0),
}
)
totals = {
"sms": sum(item["sms"] for item in timeline),
"inbound": sum(item["inbound"] for item in timeline),
"outbound": sum(item["outbound"] for item in timeline),
"otp": sum(item["otp"] for item in timeline),
}
breakdown = _build_breakdown(
db,
user.id,
service=service,
group_by=group_by,
start=start,
end=end,
)
return {
"period": period,
"service": service,
"group_by": group_by,
"from": start,
"to": end,
"totals": totals,
"timeline": timeline,
"breakdown": breakdown,
}

View file

@ -0,0 +1,63 @@
import uuid
from datetime import datetime, timezone
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .api_keys_service import KEY_PREFIX, parse_api_token, verify_api_token
from .database import get_db
from .models import ApiKey, UserAccount
def _extract_api_key(
authorization: str | None,
x_api_key: str | None,
) -> str | None:
if x_api_key:
return x_api_key.strip()
if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip()
return None
def current_api_key(
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-Api-Key"),
db: Session = Depends(get_db),
) -> ApiKey:
token = _extract_api_key(authorization, x_api_key)
if not token or not token.startswith(KEY_PREFIX):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Valid API key required")
parsed = parse_api_token(token)
if parsed:
key_id, _ = parsed
api_key = db.scalar(
select(ApiKey)
.options(joinedload(ApiKey.user))
.where(ApiKey.id == key_id, ApiKey.revoked_at.is_(None))
)
if api_key and verify_api_token(token, api_key.key_hash):
api_key.last_used_at = datetime.now(timezone.utc)
db.commit()
return api_key
active_keys = db.scalars(
select(ApiKey)
.options(joinedload(ApiKey.user))
.where(ApiKey.revoked_at.is_(None))
).all()
for api_key in active_keys:
if verify_api_token(token, api_key.key_hash):
api_key.last_used_at = datetime.now(timezone.utc)
db.commit()
return api_key
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
def current_api_user(api_key: ApiKey = Depends(current_api_key)) -> UserAccount:
if api_key.user.status != "active":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account unavailable")
return api_key.user

View file

@ -0,0 +1,48 @@
import re
import secrets
import uuid
from pwdlib import PasswordHash
KEY_PREFIX = "urmi_live_"
KEY_RANDOM_BYTES = 24
NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 _-]{1,59}$")
password_hash = PasswordHash.recommended()
def validate_key_name(value: str) -> str:
name = value.strip()
if not NAME_PATTERN.match(name):
raise ValueError("Key name must be 2–60 characters using letters, numbers, spaces, _ or -")
return name
def generate_api_token(key_id: uuid.UUID) -> str:
return f"{KEY_PREFIX}{key_id.hex}_{secrets.token_urlsafe(KEY_RANDOM_BYTES)}"
def parse_api_token(token: str) -> tuple[uuid.UUID, str] | None:
if not token.startswith(KEY_PREFIX):
return None
rest = token[len(KEY_PREFIX) :]
if "_" not in rest:
return None
key_hex, _secret = rest.split("_", 1)
try:
return uuid.UUID(hex=key_hex), token
except ValueError:
return None
def token_prefix(token: str) -> str:
visible = token[:16]
return f"{visible}…"
def hash_api_token(token: str) -> str:
return password_hash.hash(token)
def verify_api_token(token: str, stored_hash: str) -> bool:
return password_hash.verify(token, stored_hash)

85
backend/app/auth.py Normal file
View file

@ -0,0 +1,85 @@
import uuid
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import Cookie, Depends, HTTPException, status
from pwdlib import PasswordHash
from sqlalchemy import select
from sqlalchemy.orm import Session
from .config import settings
from .database import get_db
from .models import UserAccount
COOKIE_NAME = "urmi_session"
ADMIN_COOKIE_NAME = "urmi_admin_session"
password_hash = PasswordHash.recommended()
def normalize_email(email: str) -> str:
return email.strip().casefold()
def create_token(user_id: uuid.UUID, purpose: str = "session", minutes: int | None = None) -> str:
now = datetime.now(timezone.utc)
return jwt.encode(
{
"sub": str(user_id),
"purpose": purpose,
"iat": now,
"exp": now + timedelta(minutes=minutes or settings.access_token_minutes),
},
settings.auth_secret,
algorithm="HS256",
)
def decode_token(token: str, purpose: str = "session") -> dict:
try:
payload = jwt.decode(token, settings.auth_secret, algorithms=["HS256"])
if payload.get("purpose") != purpose:
raise ValueError("invalid token purpose")
return payload
except (jwt.PyJWTError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session") from exc
def current_user(
urmi_session: str | None = Cookie(default=None, alias=COOKIE_NAME),
db: Session = Depends(get_db),
) -> UserAccount:
if not urmi_session:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = decode_token(urmi_session)
try:
user_id = uuid.UUID(payload["sub"])
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session") from exc
user = db.scalar(select(UserAccount).where(UserAccount.id == user_id))
if not user or user.status != "active":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account unavailable")
return user
def create_admin_token(username: str) -> str:
now = datetime.now(timezone.utc)
return jwt.encode(
{
"sub": username,
"purpose": "admin_session",
"iat": now,
"exp": now + timedelta(minutes=settings.access_token_minutes),
},
settings.auth_secret,
algorithm="HS256",
)
def current_admin(urmi_admin_session: str | None = Cookie(default=None, alias=ADMIN_COOKIE_NAME)) -> str:
if not urmi_admin_session:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin authentication required")
payload = decode_token(urmi_admin_session, purpose="admin_session")
username = payload.get("sub")
if username != settings.admin_username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin session")
return username

View file

@ -0,0 +1,171 @@
import csv
import io
import re
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.orm import Session
from .config import settings
from .models import Campaign, CampaignCsvUpload, TemplateConfig
from .template_message_validator import PLACEHOLDER_PATTERN, message_matches_template
PHONE_RE = re.compile(r"^\d{10,15}$")
@dataclass
class CsvValidationResult:
total_rows: int
headers: list[str]
def template_variable_count(template_text: str) -> int:
return len(PLACEHOLDER_PATTERN.split(template_text.strip())) - 1
def build_message_from_row(template_text: str, variable_values: list[str]) -> str:
parts = PLACEHOLDER_PATTERN.split(template_text.strip())
message = ""
for index, part in enumerate(parts):
message += part
if index < len(parts) - 1:
message += variable_values[index] if index < len(variable_values) else ""
return message
def normalize_phone(value: str) -> str:
return re.sub(r"\D", "", value.strip())
def validate_campaign_csv(
*,
file_bytes: bytes,
template: TemplateConfig,
number_column: str,
variable_columns: list[str],
max_rows: int = 50_000,
) -> tuple[CsvValidationResult, list[str]]:
errors: list[str] = []
expected_vars = template_variable_count(template.template_text)
if len(variable_columns) != expected_vars:
errors.append(
f"Campaign expects {expected_vars} template variable column(s); "
f"{len(variable_columns)} configured"
)
return CsvValidationResult(total_rows=0, headers=[]), errors
try:
text = file_bytes.decode("utf-8-sig")
except UnicodeDecodeError:
return CsvValidationResult(total_rows=0, headers=[]), ["CSV must be UTF-8 encoded"]
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames:
return CsvValidationResult(total_rows=0, headers=[]), ["CSV must include a header row"]
headers = [header.strip() for header in reader.fieldnames if header and header.strip()]
required = [number_column, *variable_columns]
missing = [column for column in required if column not in headers]
if missing:
return CsvValidationResult(total_rows=0, headers=headers), [
f"Missing required column(s): {', '.join(missing)}"
]
total_rows = 0
for row_number, row in enumerate(reader, start=2):
total_rows += 1
if total_rows > max_rows:
errors.append(f"CSV exceeds maximum of {max_rows} rows")
break
phone = normalize_phone(row.get(number_column, "") or "")
if not phone:
errors.append(f"Row {row_number}: empty number in column '{number_column}'")
continue
if not PHONE_RE.fullmatch(phone):
errors.append(f"Row {row_number}: invalid number '{row.get(number_column, '')}'")
continue
variable_values = [(row.get(column, "") or "").strip() for column in variable_columns]
if any(not value for value in variable_values):
errors.append(f"Row {row_number}: all template variable columns must be filled")
continue
message = build_message_from_row(template.template_text, variable_values)
if not message_matches_template(template.template_text, message):
errors.append(f"Row {row_number}: message does not match the approved template")
if len(errors) >= 25:
errors.append("Too many validation errors; fix the CSV and re-upload")
break
if total_rows == 0 and not errors:
errors.append("CSV must contain at least one data row")
return CsvValidationResult(total_rows=total_rows, headers=headers), errors
def save_campaign_csv(
db: Session,
campaign: Campaign,
*,
original_filename: str,
file_bytes: bytes,
auto_run: bool,
) -> CampaignCsvUpload:
result, errors = validate_campaign_csv(
file_bytes=file_bytes,
template=campaign.template,
number_column=campaign.number_column,
variable_columns=campaign.variable_columns,
)
if errors:
raise ValueError("; ".join(errors))
upload_id = uuid.uuid4()
campaign_dir = Path(settings.upload_dir) / "campaigns" / str(campaign.id)
campaign_dir.mkdir(parents=True, exist_ok=True)
stored_path = campaign_dir / f"{upload_id}.csv"
stored_path.write_bytes(file_bytes)
upload = CampaignCsvUpload(
id=upload_id,
campaign_id=campaign.id,
original_filename=original_filename,
stored_path=str(stored_path),
total_rows=result.total_rows,
status="validated",
)
db.add(upload)
db.commit()
db.refresh(upload)
if auto_run:
stub_run_csv_upload(db, upload)
return upload
def stub_run_csv_upload(db: Session, upload: CampaignCsvUpload) -> CampaignCsvUpload:
"""Placeholder until Redis/worker batch jobs are wired (see docs/BATCH_JOBS_TODO.md)."""
now = datetime.now(timezone.utc)
upload.status = "running"
upload.run_started_at = now
db.commit()
upload.status = "completed"
upload.run_completed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(upload)
return upload
def stub_run_campaign_automation(db: Session, campaign: Campaign) -> list[CampaignCsvUpload]:
pending = [
upload
for upload in campaign.csv_uploads
if upload.status == "validated"
]
return [stub_run_csv_upload(db, upload) for upload in pending]

38
backend/app/config.py Normal file
View file

@ -0,0 +1,38 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Urmi API"
environment: str = "development"
database_url: str = "postgresql+psycopg://urmi:urmi@localhost:5432/urmi"
frontend_origin: str = "http://localhost:5173"
auth_secret: str = "change-me-in-production"
auth_cookie_secure: bool = False
access_token_minutes: int = 60 * 24 * 7
google_client_id: str = ""
google_client_secret: str = ""
google_redirect_uri: str = "http://localhost:8000/api/v1/auth/google/callback"
admin_username: str = "root"
admin_password: str = "Nishtha#01"
upload_dir: str = "uploads"
kyc_template_path: str = "assets/kyc-template.docx"
dlt_loa_template_path: str = "assets/dlt-loa-template.docx"
max_upload_bytes: int = 10 * 1024 * 1024
ultronsms_base_url: str = "https://ultronsms.com"
ultronsms_user: str = ""
ultronsms_password: str = ""
redis_url: str = "redis://localhost:6379/0"
voice_relay_public_url: str = "ws://localhost:8000/api/v1/voice"
bonvoice_token: str = ""
bonvoice_api_url: str = "https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/"
bonvoice_media_allowlist: str = "103.131.25.5"
voice_session_ttl_seconds: int = 600
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file_encoding="utf-8",
extra="ignore",
)
settings = Settings()

19
backend/app/database.py Normal file
View file

@ -0,0 +1,19 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import settings
class Base(DeclarativeBase):
pass
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_db() -> Generator[Session, None, None]:
with SessionLocal() as session:
yield session

View file

@ -0,0 +1,13 @@
PE_TM_CHAIN_BINDING_ID = "1302158098680292888"
DLT_DOCUMENT_TYPES = (
"proof_of_directorship",
"pe_tm_chain_binding",
)
DLT_DOCUMENT_LABELS = {
"proof_of_directorship": "Proof of directorship",
"pe_tm_chain_binding": "PE TM chain binding status",
}
DLT_UPLOAD_DOCUMENT_TYPES = ("proof_of_directorship",)

View file

@ -0,0 +1,57 @@
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from .dlt_constants import DLT_DOCUMENT_TYPES, PE_TM_CHAIN_BINDING_ID
from .models import DltApplication, DltDocument, UserAccount
def ensure_dlt_application(db: Session, user: UserAccount) -> DltApplication:
if user.dlt_application:
return user.dlt_application
application = DltApplication(user_id=user.id, status="not_started")
db.add(application)
db.flush()
for document_type in DLT_DOCUMENT_TYPES:
db.add(DltDocument(application_id=application.id, document_type=document_type, status="missing"))
db.commit()
db.refresh(application)
return application
def serialize_dlt(application: DltApplication) -> dict:
documents = sorted(application.documents, key=lambda doc: doc.document_type)
all_approved = bool(documents) and all(doc.status == "approved" for doc in documents)
return {
"id": str(application.id),
"status": "approved" if all_approved and application.status != "not_started" else application.status,
"completed": all_approved,
"submitted_at": application.submitted_at,
"approved_at": application.approved_at,
"pe_tm_chain_binding_id": PE_TM_CHAIN_BINDING_ID,
"documents": [
{
"id": str(doc.id),
"document_type": doc.document_type,
"original_filename": doc.original_filename,
"status": doc.status,
"review_note": doc.review_note,
"uploaded_at": doc.uploaded_at,
"reviewed_at": doc.reviewed_at,
}
for doc in documents
],
}
def refresh_dlt_application_status(application: DltApplication) -> None:
documents = application.documents
if not documents:
return
if all(doc.status == "approved" for doc in documents):
application.status = "approved"
application.approved_at = datetime.now(timezone.utc)
elif any(doc.status == "rejected" for doc in documents):
application.status = "rejected"
elif application.submitted_at:
application.status = "submitted"

View file

@ -0,0 +1,62 @@
import hashlib
import hmac
import ipaddress
import socket
import time
from urllib.parse import urlsplit
from .config import settings
def validate_endpoint_url(value: str, *, websocket: bool) -> str:
url = value.strip()
parsed = urlsplit(url)
allowed_schemes = {"wss"} if websocket else {"https"}
if settings.environment == "development":
allowed_schemes.add("ws" if websocket else "http")
if parsed.scheme.lower() not in allowed_schemes:
expected = "wss://" if websocket else "https://"
raise ValueError(f"URL must start with {expected}")
if not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
raise ValueError("URL must contain a valid host and no credentials or fragment")
try:
address = ipaddress.ip_address(parsed.hostname)
except ValueError:
address = None
if settings.environment != "development" and address and not address.is_global:
raise ValueError("Private, loopback, and reserved network addresses are not allowed")
if settings.environment != "development" and not address:
try:
resolved = {
ipaddress.ip_address(item[4][0])
for item in socket.getaddrinfo(parsed.hostname, parsed.port, type=socket.SOCK_STREAM)
}
except socket.gaierror as exc:
raise ValueError("Endpoint host could not be resolved") from exc
if not resolved or any(not item.is_global for item in resolved):
raise ValueError("Endpoint host resolves to a private or reserved network address")
return url
def endpoint_signing_secret(kind: str, endpoint_id: object) -> str:
message = f"{kind}:{endpoint_id}".encode()
digest = hmac.new(settings.auth_secret.encode(), message, hashlib.sha256).hexdigest()
return f"urmi_{kind}_{digest}"
def signed_headers(kind: str, endpoint_id: object) -> dict[str, str]:
timestamp = str(int(time.time()))
secret = endpoint_signing_secret(kind, endpoint_id)
message = f"{endpoint_id}.{timestamp}".encode()
signature = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return {
"X-Urmi-Endpoint-Id": str(endpoint_id),
"X-Urmi-Timestamp": timestamp,
"X-Urmi-Signature": f"sha256={signature}",
"User-Agent": "Urmi-Relay/1.0",
}
def payload_signature(kind: str, endpoint_id: object, body: bytes) -> str:
secret = endpoint_signing_secret(kind, endpoint_id)
return f"sha256={hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()}"

View file

@ -0,0 +1,15 @@
KYC_DOCUMENT_TYPES = (
"business_pan_card",
"business_documents",
"gst_certificate",
"kyc_form",
)
KYC_DOCUMENT_LABELS = {
"business_pan_card": "Business PAN card",
"business_documents": "Business documents",
"gst_certificate": "GST certificate",
"kyc_form": "Signed KYC form (Pvt Ltd)",
}
ALLOWED_UPLOAD_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".doc", ".docx"}

View file

@ -0,0 +1,65 @@
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.orm import Session
from .kyc_constants import KYC_DOCUMENT_TYPES
from .models import KycApplication, KycDocument, UserAccount
def ensure_kyc_application(db: Session, user: UserAccount) -> KycApplication:
if user.kyc_application:
return user.kyc_application
application = KycApplication(user_id=user.id, status="not_started")
db.add(application)
db.flush()
for document_type in KYC_DOCUMENT_TYPES:
db.add(KycDocument(application_id=application.id, document_type=document_type, status="missing"))
db.commit()
db.refresh(application)
return application
def serialize_kyc(application: KycApplication) -> dict:
documents = sorted(application.documents, key=lambda doc: doc.document_type)
all_approved = bool(documents) and all(doc.status == "approved" for doc in documents)
return {
"id": str(application.id),
"pan_number": application.pan_number,
"gst_number": application.gst_number,
"status": "approved" if all_approved and application.status != "not_started" else application.status,
"completed": all_approved and application.pan_number and application.gst_number,
"submitted_at": application.submitted_at,
"approved_at": application.approved_at,
"documents": [
{
"id": str(doc.id),
"document_type": doc.document_type,
"original_filename": doc.original_filename,
"status": doc.status,
"review_note": doc.review_note,
"uploaded_at": doc.uploaded_at,
"reviewed_at": doc.reviewed_at,
}
for doc in documents
],
}
def refresh_application_status(application: KycApplication) -> None:
documents = application.documents
if not documents:
return
if all(doc.status == "approved" for doc in documents) and application.pan_number and application.gst_number:
application.status = "approved"
application.approved_at = datetime.now(timezone.utc)
elif any(doc.status == "rejected" for doc in documents):
application.status = "rejected"
elif application.submitted_at:
application.status = "submitted"
def upload_root(base_dir: str) -> Path:
path = Path(base_dir)
path.mkdir(parents=True, exist_ok=True)
return path

View file

@ -0,0 +1,26 @@
import re
PAN_PATTERN = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
GST_PATTERN = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$")
def normalize_pan(value: str) -> str:
return value.strip().upper()
def normalize_gst(value: str) -> str:
return value.strip().upper()
def validate_pan(value: str) -> str:
pan = normalize_pan(value)
if not PAN_PATTERN.match(pan):
raise ValueError("PAN must match format ABCDE1234F")
return pan
def validate_gst(value: str) -> str:
gst = normalize_gst(value)
if not GST_PATTERN.match(gst):
raise ValueError("GST must be a valid 15-character Indian GSTIN")
return gst

View file

@ -0,0 +1,68 @@
from typing import Protocol
from uuid import UUID
from sqlalchemy.orm import Session
from .models import OtpMessageLog, SmsMessageLog, UserAccount
class SendResultLike(Protocol):
to: str
success: bool
job_id: str | None
message_id: str | None
error: str | None
def record_sms_logs(
db: Session,
user: UserAccount,
*,
sender_id: str,
template_name: str,
template_id: UUID,
results: list[SendResultLike],
) -> None:
for item in results:
db.add(
SmsMessageLog(
user_id=user.id,
sender_id=sender_id,
to_number=item.to,
template_name=template_name,
template_id=template_id,
status="sent" if item.success else "failed",
job_id=item.job_id,
message_id=item.message_id,
error_message=item.error,
)
)
db.commit()
def record_otp_log(
db: Session,
user: UserAccount,
*,
project_name: str,
template_type: str,
to_number: str,
status: str,
job_id: str | None = None,
message_id: str | None = None,
error_message: str | None = None,
) -> OtpMessageLog:
entry = OtpMessageLog(
user_id=user.id,
project_name=project_name,
template_type=template_type,
to_number=to_number,
status=status,
job_id=job_id,
message_id=message_id,
error_message=error_message,
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry

125
backend/app/main.py Normal file
View file

@ -0,0 +1,125 @@
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from .config import settings
from .database import Base, engine, get_db
from .auth import current_user
from .models import ActivityEvent, UserAccount
from .routes_admin import router as admin_router
from .routes_api_keys import router as api_keys_router
from .routes_auth import router as auth_router
from .routes_compliance import router as compliance_router
from .routes_dlt import router as dlt_router
from .routes_kyc import router as kyc_router
from .routes_analytics import router as analytics_router
from .routes_logs import router as logs_router
from .routes_otp import router as otp_router
from .routes_messages import router as messages_router
from .routes_messaging import router as messaging_router
from .routes_campaigns import router as campaigns_router
from .routes_integrations import router as integrations_router
from .routes_numbers import router as numbers_router
from .routes_urmiotp import router as urmiotp_router
from .routes_voice import router as voice_router
@asynccontextmanager
async def lifespan(_: FastAPI):
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE urmiotp_projects "
"ADD COLUMN IF NOT EXISTS message_label VARCHAR(20) NOT NULL DEFAULT 'URMI'"
)
)
conn.execute(
text(
"ALTER TABLE phone_numbers "
"ADD COLUMN IF NOT EXISTS monthly_number_price NUMERIC(10,2) NOT NULL DEFAULT 0"
)
)
conn.execute(
text(
"ALTER TABLE phone_numbers "
"ADD COLUMN IF NOT EXISTS monthly_inbound_price NUMERIC(10,2) NOT NULL DEFAULT 0"
)
)
conn.execute(
text(
"ALTER TABLE phone_numbers "
"ADD COLUMN IF NOT EXISTS monthly_outbound_price NUMERIC(10,2) NOT NULL DEFAULT 0"
)
)
yield
app = FastAPI(
title=settings.app_name,
version="0.1.0",
lifespan=lifespan,
docs_url="/docs",
)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(kyc_router)
app.include_router(dlt_router)
app.include_router(compliance_router)
app.include_router(messaging_router)
app.include_router(api_keys_router)
app.include_router(messages_router)
app.include_router(otp_router)
app.include_router(logs_router)
app.include_router(analytics_router)
app.include_router(urmiotp_router)
app.include_router(campaigns_router)
app.include_router(numbers_router)
app.include_router(integrations_router)
app.include_router(voice_router)
app.include_router(admin_router)
@app.get("/health")
def health(db: Session = Depends(get_db)) -> dict[str, str]:
db.execute(text("SELECT 1"))
return {"status": "healthy", "service": settings.app_name}
@app.get("/api/v1/dashboard")
def dashboard(
db: Session = Depends(get_db),
_: UserAccount = Depends(current_user),
) -> dict:
events = db.scalars(
select(ActivityEvent).order_by(ActivityEvent.occurred_at.desc()).limit(8)
).all()
return {
"metrics": {
"voice_calls": 1243,
"messages_sent": 45231,
"delivery_rate": 98.7,
"api_requests": 1_200_000,
"balance": 24820,
},
"activity": [
{
"id": str(event.id),
"type": event.event_type,
"title": event.title,
"detail": event.detail,
"status": event.status,
"occurred_at": event.occurred_at,
}
for event in events
],
}

View file

@ -0,0 +1,221 @@
import re
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .log_service import record_sms_logs
from .models import SenderConfig, TemplateConfig, UserAccount
from .template_message_validator import message_matches_template
from .ultronsms_client import SendResult, UltronsmsClient
PHONE_DIGITS_RE = re.compile(r"^\d{10,15}$")
class SendMessageError(Exception):
def __init__(self, detail: str, status_code: int = 422) -> None:
super().__init__(detail)
self.detail = detail
self.status_code = status_code
@dataclass
class RecipientSendResult:
to: str
success: bool
job_id: str | None = None
message_id: str | None = None
error: str | None = None
def normalize_recipients(raw_numbers: list[str]) -> list[str]:
recipients: list[str] = []
seen: set[str] = set()
for raw in raw_numbers:
digits = "".join(character for character in raw if character.isdigit())
if len(digits) == 10:
digits = f"91{digits}"
if not PHONE_DIGITS_RE.match(digits):
raise SendMessageError(f"Invalid recipient number: {raw.strip() or 'blank'}")
if digits not in seen:
seen.add(digits)
recipients.append(digits)
if not recipients:
raise SendMessageError("At least one recipient number is required")
if len(recipients) > 100:
raise SendMessageError("A maximum of 100 recipient numbers is allowed per request")
return recipients
def parse_recipient_input(numbers: str | list[str]) -> list[str]:
if isinstance(numbers, list):
items = numbers
else:
items = [part.strip() for part in numbers.split(",")]
return normalize_recipients([item for item in items if item.strip()])
def _load_sender_and_template(
db: Session,
user: UserAccount,
*,
sender_config_id: UUID | None = None,
sender_id: str | None = None,
template_id: UUID,
) -> tuple[SenderConfig, TemplateConfig]:
sender_query = select(SenderConfig).where(
SenderConfig.user_id == user.id,
SenderConfig.status == "approved",
)
if sender_config_id is not None:
sender_query = sender_query.where(SenderConfig.id == sender_config_id)
elif sender_id is not None:
sender_query = sender_query.where(SenderConfig.sender_id == sender_id.strip().upper())
else:
raise SendMessageError("Sender ID is required")
sender = db.scalar(sender_query)
if not sender:
raise SendMessageError("Approved sender ID not found", status_code=404)
template = db.scalar(
select(TemplateConfig)
.options(joinedload(TemplateConfig.sender_config))
.where(
TemplateConfig.id == template_id,
TemplateConfig.user_id == user.id,
TemplateConfig.status == "approved",
)
)
if not template:
raise SendMessageError("Approved template not found", status_code=404)
if template.sender_config_id != sender.id:
raise SendMessageError("Template is not linked to the selected sender ID")
return sender, template
def validate_message_for_template(template_text: str, message: str) -> str:
normalized = message.strip()
if not normalized:
raise SendMessageError("Message is required")
if len(normalized) > 1000:
raise SendMessageError("Message must be 1000 characters or fewer")
if not message_matches_template(template_text, normalized):
raise SendMessageError("Message does not match the approved DLT template")
return normalized
def _dispatch_sms(
*,
recipient: str,
message: str,
sender: SenderConfig,
template: TemplateConfig,
client: UltronsmsClient,
) -> RecipientSendResult:
try:
result: SendResult = client.send_template_sms(
mobile=recipient,
message=message,
sender_id=sender.sender_id,
pe_id=sender.dlt_entity_id,
dlt_template_id=template.dlt_template_id,
sender_type=sender.sender_type,
)
except RuntimeError as exc:
return RecipientSendResult(to=recipient, success=False, error=str(exc))
except Exception:
return RecipientSendResult(to=recipient, success=False, error="SMS gateway request failed")
if not result.success:
error_message = result.raw.get("ErrorMessage") or "SMS gateway rejected the request"
return RecipientSendResult(
to=recipient,
success=False,
error=error_message,
)
return RecipientSendResult(
to=result.number or recipient,
success=True,
job_id=result.job_id,
message_id=result.message_id,
)
def send_template_messages(
db: Session,
user: UserAccount,
*,
template_id: UUID,
message: str,
recipients: list[str],
sender_config_id: UUID | None = None,
sender_id: str | None = None,
campaign_name: str | None = None,
) -> dict:
if campaign_name is not None and not campaign_name.strip():
raise SendMessageError("Campaign name cannot be blank")
if campaign_name and len(campaign_name.strip()) > 120:
raise SendMessageError("Campaign name must be 120 characters or fewer")
sender, template = _load_sender_and_template(
db,
user,
sender_config_id=sender_config_id,
sender_id=sender_id,
template_id=template_id,
)
normalized_message = validate_message_for_template(template.template_text, message)
normalized_recipients = normalize_recipients(recipients)
client = UltronsmsClient()
results: list[RecipientSendResult] = []
for recipient in normalized_recipients:
results.append(
_dispatch_sms(
recipient=recipient,
message=normalized_message,
sender=sender,
template=template,
client=client,
)
)
record_sms_logs(
db,
user,
sender_id=sender.sender_id,
template_name=template.template_name,
template_id=template.id,
results=results,
)
sent = sum(1 for item in results if item.success)
failed = len(results) - sent
payload = {
"success": failed == 0,
"campaign_name": campaign_name.strip() if campaign_name else None,
"sent": sent,
"failed": failed,
"sender_id": sender.sender_id,
"template_id": str(template.id),
"template_name": template.template_name,
"dlt_template_id": template.dlt_template_id,
"results": [
{
"to": item.to,
"success": item.success,
"job_id": item.job_id,
"message_id": item.message_id,
"error": item.error,
}
for item in results
],
}
if sent == 0:
raise SendMessageError(
payload["results"][0]["error"] if payload["results"] else "SMS send failed",
status_code=422,
)
return payload

493
backend/app/models.py Normal file
View file

@ -0,0 +1,493 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSON, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class Organization(Base):
__tablename__ = "organizations"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(160), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
home_region: Mapped[str] = mapped_column(String(32), default="ap-south-1")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
projects: Mapped[list["Project"]] = relationship(back_populates="organization")
class Project(Base):
__tablename__ = "projects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
organization_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("organizations.id"))
name: Mapped[str] = mapped_column(String(160), nullable=False)
environment: Mapped[str] = mapped_column(String(24), default="test")
status: Mapped[str] = mapped_column(String(24), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
organization: Mapped[Organization] = relationship(back_populates="projects")
class ActivityEvent(Base):
__tablename__ = "activity_events"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id"), index=True)
event_type: Mapped[str] = mapped_column(String(80), index=True)
title: Mapped[str] = mapped_column(String(200))
detail: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(24), default="success")
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class UserAccount(Base):
"""Canonical person record, independent of any login provider."""
__tablename__ = "user_accounts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(160))
avatar_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[str] = mapped_column(String(24), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
password_credential: Mapped["PasswordCredential | None"] = relationship(
back_populates="user", cascade="all, delete-orphan", uselist=False
)
oidc_identities: Mapped[list["OidcIdentity"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
kyc_application: Mapped["KycApplication | None"] = relationship(
back_populates="user", cascade="all, delete-orphan", uselist=False
)
dlt_application: Mapped["DltApplication | None"] = relationship(
back_populates="user", cascade="all, delete-orphan", uselist=False
)
sender_configs: Mapped[list["SenderConfig"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
template_configs: Mapped[list["TemplateConfig"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
api_keys: Mapped[list["ApiKey"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
urmiotp_projects: Mapped[list["UrmiOtpProject"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
campaigns: Mapped[list["Campaign"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
phone_numbers: Mapped[list["PhoneNumber"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
voice_calls: Mapped[list["VoiceCall"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
sms_message_logs: Mapped[list["SmsMessageLog"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
otp_message_logs: Mapped[list["OtpMessageLog"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
voice_socket_endpoints: Mapped[list["VoiceSocketEndpoint"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
sms_delivery_webhooks: Mapped[list["SmsDeliveryWebhook"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class PasswordCredential(Base):
"""First-party credential kept separate from the canonical user."""
__tablename__ = "password_credentials"
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), primary_key=True
)
password_hash: Mapped[str] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped[UserAccount] = relationship(back_populates="password_credential")
class OidcIdentity(Base):
"""Provider identity; no provider claims are stored on the user table."""
__tablename__ = "oidc_identities"
__table_args__ = (
UniqueConstraint("issuer", "subject", name="uq_oidc_issuer_subject"),
UniqueConstraint("user_id", "issuer", name="uq_oidc_user_issuer"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
issuer: Mapped[str] = mapped_column(String(500))
subject: Mapped[str] = mapped_column(String(500))
provider_email: Mapped[str] = mapped_column(String(320))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
last_login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[UserAccount] = relationship(back_populates="oidc_identities")
class KycApplication(Base):
__tablename__ = "kyc_applications"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), unique=True, index=True
)
pan_number: Mapped[str | None] = mapped_column(String(10), nullable=True)
gst_number: Mapped[str | None] = mapped_column(String(15), nullable=True)
status: Mapped[str] = mapped_column(String(24), default="not_started", index=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped[UserAccount] = relationship(back_populates="kyc_application")
documents: Mapped[list["KycDocument"]] = relationship(
back_populates="application", cascade="all, delete-orphan"
)
class KycDocument(Base):
__tablename__ = "kyc_documents"
__table_args__ = (UniqueConstraint("application_id", "document_type", name="uq_kyc_app_doc_type"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
application_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("kyc_applications.id", ondelete="CASCADE"), index=True
)
document_type: Mapped[str] = mapped_column(String(40))
original_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
stored_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
status: Mapped[str] = mapped_column(String(24), default="missing")
review_note: Mapped[str | None] = mapped_column(String(500), nullable=True)
uploaded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
application: Mapped[KycApplication] = relationship(back_populates="documents")
class DltApplication(Base):
__tablename__ = "dlt_applications"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), unique=True, index=True
)
status: Mapped[str] = mapped_column(String(24), default="not_started", index=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped[UserAccount] = relationship(back_populates="dlt_application")
documents: Mapped[list["DltDocument"]] = relationship(
back_populates="application", cascade="all, delete-orphan"
)
class DltDocument(Base):
__tablename__ = "dlt_documents"
__table_args__ = (UniqueConstraint("application_id", "document_type", name="uq_dlt_app_doc_type"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
application_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("dlt_applications.id", ondelete="CASCADE"), index=True
)
document_type: Mapped[str] = mapped_column(String(40))
original_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
stored_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
status: Mapped[str] = mapped_column(String(24), default="missing")
review_note: Mapped[str | None] = mapped_column(String(500), nullable=True)
uploaded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
application: Mapped[DltApplication] = relationship(back_populates="documents")
class SenderConfig(Base):
__tablename__ = "sender_configs"
__table_args__ = (UniqueConstraint("user_id", "sender_id", name="uq_sender_user_sender_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
sender_id: Mapped[str] = mapped_column(String(11), index=True)
sender_type: Mapped[str] = mapped_column(String(20))
purpose: Mapped[str] = mapped_column(String(500))
dlt_entity_id: Mapped[str] = mapped_column(String(50))
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
review_note: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped[UserAccount] = relationship(back_populates="sender_configs")
templates: Mapped[list["TemplateConfig"]] = relationship(
back_populates="sender_config", cascade="all, delete-orphan"
)
class TemplateConfig(Base):
__tablename__ = "template_configs"
__table_args__ = (UniqueConstraint("user_id", "template_name", name="uq_template_user_name"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
sender_config_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("sender_configs.id", ondelete="CASCADE"), index=True
)
template_name: Mapped[str] = mapped_column(String(40), index=True)
template_text: Mapped[str] = mapped_column(String(1000))
dlt_template_id: Mapped[str] = mapped_column(String(50))
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
review_note: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped[UserAccount] = relationship(back_populates="template_configs")
sender_config: Mapped[SenderConfig] = relationship(back_populates="templates")
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(60))
key_prefix: Mapped[str] = mapped_column(String(24))
key_hash: Mapped[str] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped[UserAccount] = relationship(back_populates="api_keys")
class UrmiOtpProject(Base):
__tablename__ = "urmiotp_projects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
project_name: Mapped[str] = mapped_column(String(30), unique=True, index=True)
message_label: Mapped[str] = mapped_column(String(20), server_default="URMI")
app_type: Mapped[str] = mapped_column(String(16))
app_url: Mapped[str] = mapped_column(String(1000))
purpose: Mapped[str] = mapped_column(String(1000))
terms_accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
review_note: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped[UserAccount] = relationship(back_populates="urmiotp_projects")
class Campaign(Base):
__tablename__ = "campaigns"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(80))
sender_config_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("sender_configs.id", ondelete="CASCADE"), index=True
)
template_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("template_configs.id", ondelete="CASCADE"), index=True
)
number_column: Mapped[str] = mapped_column(String(80))
variable_columns: Mapped[list] = mapped_column(JSON)
run_mode: Mapped[str] = mapped_column(String(20), index=True)
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(24), default="active", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped[UserAccount] = relationship(back_populates="campaigns")
sender_config: Mapped["SenderConfig"] = relationship()
template: Mapped["TemplateConfig"] = relationship()
csv_uploads: Mapped[list["CampaignCsvUpload"]] = relationship(
back_populates="campaign", cascade="all, delete-orphan"
)
class CampaignCsvUpload(Base):
__tablename__ = "campaign_csv_uploads"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
campaign_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("campaigns.id", ondelete="CASCADE"), index=True
)
original_filename: Mapped[str] = mapped_column(String(255))
stored_path: Mapped[str] = mapped_column(String(500))
total_rows: Mapped[int] = mapped_column(default=0)
status: Mapped[str] = mapped_column(String(24), default="validated", index=True)
run_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
run_completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
campaign: Mapped[Campaign] = relationship(back_populates="csv_uploads")
class PhoneNumber(Base):
__tablename__ = "phone_numbers"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
phone_number: Mapped[str] = mapped_column(String(20), unique=True, index=True)
user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("user_accounts.id", ondelete="SET NULL"), index=True, nullable=True
)
inbound_price: Mapped[float] = mapped_column(Numeric(10, 4))
outbound_price: Mapped[float] = mapped_column(Numeric(10, 4))
monthly_number_price: Mapped[float] = mapped_column(Numeric(10, 2), server_default="0")
currency: Mapped[str] = mapped_column(String(8), default="INR")
status: Mapped[str] = mapped_column(String(20), default="available", index=True)
acquisition_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
assigned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[UserAccount | None] = relationship(back_populates="phone_numbers")
voice_calls: Mapped[list["VoiceCall"]] = relationship(back_populates="phone_number")
voice_socket_endpoint: Mapped["VoiceSocketEndpoint | None"] = relationship(
back_populates="phone_number", cascade="all, delete-orphan", uselist=False
)
sms_delivery_webhooks: Mapped[list["SmsDeliveryWebhook"]] = relationship(
back_populates="phone_number", cascade="all, delete-orphan"
)
class VoiceSocketEndpoint(Base):
"""Customer WebSocket destination associated with one owned phone number."""
__tablename__ = "voice_socket_endpoints"
__table_args__ = (
UniqueConstraint("phone_number_id", name="uq_voice_socket_phone_number"),
UniqueConstraint("user_id", "name", name="uq_voice_socket_user_name"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
phone_number_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("phone_numbers.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(120))
url: Mapped[str] = mapped_column(String(500))
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped[UserAccount] = relationship(back_populates="voice_socket_endpoints")
phone_number: Mapped[PhoneNumber] = relationship(back_populates="voice_socket_endpoint")
class SmsDeliveryWebhook(Base):
"""Named SMS delivery-report destination associated with an owned number."""
__tablename__ = "sms_delivery_webhooks"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_sms_webhook_user_name"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
phone_number_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("phone_numbers.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(120))
url: Mapped[str] = mapped_column(String(500))
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped[UserAccount] = relationship(back_populates="sms_delivery_webhooks")
phone_number: Mapped[PhoneNumber] = relationship(back_populates="sms_delivery_webhooks")
class VoiceCall(Base):
__tablename__ = "voice_calls"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
phone_number_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("phone_numbers.id", ondelete="CASCADE"), index=True
)
direction: Mapped[str] = mapped_column(String(16), index=True)
from_did: Mapped[str] = mapped_column(String(20))
to_number: Mapped[str] = mapped_column(String(20))
status: Mapped[str] = mapped_column(String(24), default="initiated", index=True)
provider_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
session_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
stream_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
call_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
bridge_mode: Mapped[str] = mapped_column(String(16), default="console")
upstream_ws: Mapped[str | None] = mapped_column(String(500), nullable=True)
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
answered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[UserAccount] = relationship(back_populates="voice_calls")
phone_number: Mapped[PhoneNumber] = relationship(back_populates="voice_calls")
class SmsMessageLog(Base):
__tablename__ = "sms_message_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
sender_id: Mapped[str] = mapped_column(String(11), index=True)
to_number: Mapped[str] = mapped_column(String(20), index=True)
template_name: Mapped[str] = mapped_column(String(40))
template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
status: Mapped[str] = mapped_column(String(24), default="sent", index=True)
job_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
message_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
user: Mapped[UserAccount] = relationship(back_populates="sms_message_logs")
class OtpMessageLog(Base):
__tablename__ = "otp_message_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
project_name: Mapped[str] = mapped_column(String(30), index=True)
template_type: Mapped[str] = mapped_column(String(40), index=True)
to_number: Mapped[str] = mapped_column(String(20), index=True)
status: Mapped[str] = mapped_column(String(24), default="sent", index=True)
job_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
message_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
user: Mapped[UserAccount] = relationship(back_populates="otp_message_logs")

View file

@ -0,0 +1,90 @@
from fastapi import HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from .log_service import record_otp_log
from .message_send_service import SendMessageError, normalize_recipients
from .models import UrmiOtpProject, UserAccount
from .urmiotp_catalog import OTP_TEMPLATES
TEMPLATE_TYPES = {template["type"] for template in OTP_TEMPLATES}
class GenerateOtpPayload(BaseModel):
project_name: str = Field(min_length=3, max_length=30)
template_type: str = Field(min_length=3, max_length=40)
to: str = Field(min_length=10, max_length=15)
variables: dict[str, str] = Field(default_factory=dict)
def _build_otp_message(template_type: str, message_label: str, variables: dict[str, str]) -> str:
template = next((item for item in OTP_TEMPLATES if item["type"] == template_type), None)
if not template:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unknown template type")
otp_value = variables.get("otp", "").strip()
validity = variables.get("validity_minutes", "").strip()
if not otp_value or not validity:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="variables.otp and variables.validity_minutes are required",
)
content = template["content"].replace("{message_label}", message_label)
for value in (otp_value, validity):
content = content.replace("{#var#}", value, 1)
return content
def generate_otp_for_user(
db: Session,
user: UserAccount,
payload: GenerateOtpPayload,
) -> dict:
if payload.template_type not in TEMPLATE_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"template_type must be one of: {', '.join(sorted(TEMPLATE_TYPES))}",
)
project = db.scalar(
select(UrmiOtpProject).where(
UrmiOtpProject.user_id == user.id,
UrmiOtpProject.project_name == payload.project_name.strip(),
)
)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UrmiOTP project not found")
if project.status != "approved":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="UrmiOTP project is not approved yet",
)
try:
recipients = normalize_recipients([payload.to])
except SendMessageError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
recipient = recipients[0]
message = _build_otp_message(payload.template_type, project.message_label, payload.variables)
entry = record_otp_log(
db,
user,
project_name=project.project_name,
template_type=payload.template_type,
to_number=recipient,
status="accepted",
)
return {
"success": True,
"log_id": str(entry.id),
"project_name": project.project_name,
"template_type": payload.template_type,
"to": recipient,
"message_preview": message,
"status": entry.status,
}

View file

@ -0,0 +1,30 @@
import re
INDIAN_MOBILE_RE = re.compile(r"^[6-9]\d{9}$")
def normalize_phone_number(value: str) -> str:
cleaned = re.sub(r"\D", "", value.strip())
if cleaned.startswith("91") and len(cleaned) == 12:
cleaned = cleaned[2:]
if cleaned.startswith("0") and len(cleaned) == 11:
cleaned = cleaned[1:]
if not INDIAN_MOBILE_RE.fullmatch(cleaned):
raise ValueError("Enter a 10-digit mobile number without +91, e.g. 9876543210")
return cleaned
def validate_price(value: float, label: str) -> float:
if value < 0:
raise ValueError(f"{label} must be zero or greater")
if value > 999999:
raise ValueError(f"{label} is too large")
return round(float(value), 2)
def validate_per_min_price(value: float, label: str) -> float:
if value < 0:
raise ValueError(f"{label} must be zero or greater")
if value > 9999:
raise ValueError(f"{label} is too large")
return round(float(value), 4)

518
backend/app/routes_admin.py Normal file
View file

@ -0,0 +1,518 @@
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .auth import ADMIN_COOKIE_NAME, create_admin_token, current_admin
from .config import settings
from .database import get_db
from .dlt_service import refresh_dlt_application_status, serialize_dlt
from .kyc_service import refresh_application_status, serialize_kyc
from .routes_messaging import serialize_sender, serialize_template
from .models import (
DltApplication,
DltDocument,
KycApplication,
KycDocument,
PhoneNumber,
SenderConfig,
TemplateConfig,
UrmiOtpProject,
UserAccount,
)
from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price
from .routes_urmiotp import serialize_project
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
class AdminLoginRequest(BaseModel):
username: str
password: str
class DocumentReviewRequest(BaseModel):
status: str = Field(pattern="^(approved|rejected)$")
review_note: str | None = Field(default=None, max_length=500)
class ProjectReviewRequest(BaseModel):
status: str = Field(pattern="^(approved|rejected)$")
review_note: str | None = Field(default=None, max_length=500)
class CreatePhoneNumberRequest(BaseModel):
phone_number: str = Field(min_length=10, max_length=10)
monthly_number_price: float = Field(ge=0)
inbound_price: float = Field(ge=0)
outbound_price: float = Field(ge=0)
user_email: str | None = Field(default=None, max_length=320)
class UpdatePhoneNumberRequest(BaseModel):
monthly_number_price: float | None = Field(default=None, ge=0)
inbound_price: float | None = Field(default=None, ge=0)
outbound_price: float | None = Field(default=None, ge=0)
user_email: str | None = Field(default=None, max_length=320)
release: bool = False
def set_admin_session(response: Response) -> None:
response.set_cookie(
ADMIN_COOKIE_NAME,
create_admin_token(settings.admin_username),
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
max_age=settings.access_token_minutes * 60,
path="/",
)
@router.post("/login")
def admin_login(payload: AdminLoginRequest, response: Response) -> dict[str, str]:
if payload.username != settings.admin_username or payload.password != settings.admin_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin credentials")
set_admin_session(response)
return {"username": settings.admin_username}
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def admin_logout(response: Response) -> None:
response.delete_cookie(ADMIN_COOKIE_NAME, path="/")
@router.get("/me")
def admin_me(username: str = Depends(current_admin)) -> dict[str, str]:
return {"username": username}
@router.get("/compliance/kyc")
def list_kyc_applications(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
applications = db.scalars(
select(KycApplication)
.options(joinedload(KycApplication.user), joinedload(KycApplication.documents))
.order_by(KycApplication.updated_at.desc())
).unique().all()
return [
{
**serialize_kyc(app),
"user": {
"id": str(app.user.id),
"name": app.user.display_name,
"email": app.user.email,
},
}
for app in applications
if app.status != "not_started" or app.pan_number or any(doc.status != "missing" for doc in app.documents)
]
@router.patch("/compliance/kyc/{application_id}/documents/{document_id}")
def review_document(
application_id: UUID,
document_id: UUID,
payload: DocumentReviewRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
application = db.scalar(
select(KycApplication)
.options(joinedload(KycApplication.user), joinedload(KycApplication.documents))
.where(KycApplication.id == application_id)
)
if not application:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="KYC application not found")
document = db.get(KycDocument, document_id)
if not document or document.application_id != application.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
if document.status == "missing":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Document not uploaded yet")
document.status = payload.status
document.review_note = payload.review_note
document.reviewed_at = datetime.now(timezone.utc)
refresh_application_status(application)
db.commit()
db.refresh(application)
return {
**serialize_kyc(application),
"user": {
"id": str(application.user.id),
"name": application.user.display_name,
"email": application.user.email,
},
}
@router.get("/compliance/dlt")
def list_dlt_applications(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
applications = db.scalars(
select(DltApplication)
.options(joinedload(DltApplication.user), joinedload(DltApplication.documents))
.order_by(DltApplication.updated_at.desc())
).unique().all()
return [
{
**serialize_dlt(app),
"user": {
"id": str(app.user.id),
"name": app.user.display_name,
"email": app.user.email,
},
}
for app in applications
if app.status != "not_started" or any(doc.status != "missing" for doc in app.documents)
]
@router.patch("/compliance/dlt/{application_id}/documents/{document_id}")
def review_dlt_document(
application_id: UUID,
document_id: UUID,
payload: DocumentReviewRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
application = db.scalar(
select(DltApplication)
.options(joinedload(DltApplication.user), joinedload(DltApplication.documents))
.where(DltApplication.id == application_id)
)
if not application:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="DLT application not found")
document = db.get(DltDocument, document_id)
if not document or document.application_id != application.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
if document.status == "missing":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Document not uploaded yet")
document.status = payload.status
document.review_note = payload.review_note
document.reviewed_at = datetime.now(timezone.utc)
refresh_dlt_application_status(application)
db.commit()
db.refresh(application)
return {
**serialize_dlt(application),
"user": {
"id": str(application.user.id),
"name": application.user.display_name,
"email": application.user.email,
},
}
@router.get("/compliance/senders")
def list_sender_configs(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
senders = db.scalars(
select(SenderConfig)
.options(joinedload(SenderConfig.user))
.order_by(SenderConfig.created_at.desc())
).unique().all()
return [
{
**serialize_sender(sender),
"user": {
"id": str(sender.user.id),
"name": sender.user.display_name,
"email": sender.user.email,
},
}
for sender in senders
]
@router.patch("/compliance/senders/{sender_id}")
def review_sender(
sender_id: UUID,
payload: DocumentReviewRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
sender = db.scalar(
select(SenderConfig)
.options(joinedload(SenderConfig.user))
.where(SenderConfig.id == sender_id)
)
if not sender:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sender config not found")
if sender.status != "pending":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Sender already reviewed")
sender.status = payload.status
sender.review_note = payload.review_note
sender.reviewed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(sender)
return {
**serialize_sender(sender),
"user": {
"id": str(sender.user.id),
"name": sender.user.display_name,
"email": sender.user.email,
},
}
@router.get("/compliance/templates")
def list_template_configs(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
templates = db.scalars(
select(TemplateConfig)
.options(joinedload(TemplateConfig.user), joinedload(TemplateConfig.sender_config))
.order_by(TemplateConfig.created_at.desc())
).unique().all()
return [
{
**serialize_template(template),
"user": {
"id": str(template.user.id),
"name": template.user.display_name,
"email": template.user.email,
},
}
for template in templates
]
@router.patch("/compliance/templates/{template_id}")
def review_template(
template_id: UUID,
payload: DocumentReviewRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
template = db.scalar(
select(TemplateConfig)
.options(joinedload(TemplateConfig.user), joinedload(TemplateConfig.sender_config))
.where(TemplateConfig.id == template_id)
)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template config not found")
if template.status != "pending":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Template already reviewed")
template.status = payload.status
template.review_note = payload.review_note
template.reviewed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(template)
return {
**serialize_template(template),
"user": {
"id": str(template.user.id),
"name": template.user.display_name,
"email": template.user.email,
},
}
@router.get("/compliance/urmiotp-projects")
def list_urmiotp_projects(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
projects = db.scalars(
select(UrmiOtpProject)
.options(joinedload(UrmiOtpProject.user))
.order_by(UrmiOtpProject.created_at.desc())
).all()
return [
{
**serialize_project(project),
"user": {
"id": str(project.user.id),
"name": project.user.display_name,
"email": project.user.email,
},
}
for project in projects
]
@router.patch("/compliance/urmiotp-projects/{project_id}")
def review_urmiotp_project(
project_id: UUID,
payload: ProjectReviewRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
project = db.scalar(
select(UrmiOtpProject)
.options(joinedload(UrmiOtpProject.user))
.where(UrmiOtpProject.id == project_id)
)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UrmiOTP project not found")
if project.status != "pending":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Project already reviewed")
project.status = payload.status
project.review_note = payload.review_note
project.reviewed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(project)
return {
**serialize_project(project),
"user": {
"id": str(project.user.id),
"name": project.user.display_name,
"email": project.user.email,
},
}
def serialize_admin_phone_number(number: PhoneNumber) -> dict:
return {
"id": str(number.id),
"phone_number": number.phone_number,
"monthly_number_price": float(number.monthly_number_price),
"inbound_price": float(number.inbound_price),
"outbound_price": float(number.outbound_price),
"currency": number.currency,
"status": number.status,
"acquisition_type": number.acquisition_type,
"assigned_at": number.assigned_at,
"created_at": number.created_at,
"user": (
{
"id": str(number.user.id),
"name": number.user.display_name,
"email": number.user.email,
}
if number.user
else None
),
}
@router.get("/phone-numbers")
def list_phone_numbers(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
numbers = db.scalars(
select(PhoneNumber)
.options(joinedload(PhoneNumber.user))
.order_by(PhoneNumber.created_at.desc())
).all()
return [serialize_admin_phone_number(number) for number in numbers]
@router.post("/phone-numbers", status_code=status.HTTP_201_CREATED)
def create_phone_number(
payload: CreatePhoneNumberRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
try:
phone_number = normalize_phone_number(payload.phone_number)
monthly_number_price = validate_price(payload.monthly_number_price, "Number monthly price")
inbound_price = validate_per_min_price(payload.inbound_price, "Inbound price per min")
outbound_price = validate_per_min_price(payload.outbound_price, "Outbound price per min")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
existing = db.scalar(select(PhoneNumber).where(PhoneNumber.phone_number == phone_number))
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This phone number already exists")
user = None
if payload.user_email:
user = db.scalar(select(UserAccount).where(UserAccount.email == payload.user_email.strip().lower()))
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No user account found for that email",
)
now = datetime.now(timezone.utc)
number = PhoneNumber(
phone_number=phone_number,
inbound_price=inbound_price,
outbound_price=outbound_price,
monthly_number_price=monthly_number_price,
user_id=user.id if user else None,
status="assigned" if user else "available",
acquisition_type="admin_assigned" if user else None,
assigned_at=now if user else None,
)
db.add(number)
db.commit()
db.refresh(number)
if user:
number.user = user
return serialize_admin_phone_number(number)
@router.patch("/phone-numbers/{number_id}")
def update_phone_number(
number_id: UUID,
payload: UpdatePhoneNumberRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
number = db.scalar(
select(PhoneNumber)
.options(joinedload(PhoneNumber.user))
.where(PhoneNumber.id == number_id)
)
if not number:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found")
if payload.release:
number.user_id = None
number.status = "available"
number.acquisition_type = None
number.assigned_at = None
number.user = None
elif payload.user_email is not None:
email = payload.user_email.strip().lower()
if not email:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="User email cannot be empty",
)
user = db.scalar(select(UserAccount).where(UserAccount.email == email))
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No user account found for that email")
number.user_id = user.id
number.status = "assigned"
number.acquisition_type = "admin_assigned"
number.assigned_at = datetime.now(timezone.utc)
number.user = user
if payload.monthly_number_price is not None:
try:
number.monthly_number_price = validate_price(payload.monthly_number_price, "Number monthly price")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
if payload.inbound_price is not None:
try:
number.inbound_price = validate_per_min_price(payload.inbound_price, "Inbound price per min")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
if payload.outbound_price is not None:
try:
number.outbound_price = validate_per_min_price(payload.outbound_price, "Outbound price per min")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
db.commit()
db.refresh(number)
return serialize_admin_phone_number(number)

View file

@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from .analytics_service import DEFAULT_GROUP_BY, SERVICE_GROUP_BY, build_usage_analytics
from .auth import current_user
from .database import get_db
from .models import UserAccount
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
PERIODS = frozenset({"weekly", "monthly", "quarterly"})
SERVICES = frozenset({"sms", "inbound", "outbound", "otp"})
@router.get("/usage")
def usage_analytics(
period: str = Query(default="weekly"),
service: str = Query(default="outbound"),
group_by: str | None = Query(default=None),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
normalized_period = period.strip().lower()
normalized_service = service.strip().lower()
normalized_group = (group_by or DEFAULT_GROUP_BY.get(normalized_service, "did")).strip().lower()
if normalized_period not in PERIODS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"period must be one of: {', '.join(sorted(PERIODS))}",
)
if normalized_service not in SERVICES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"service must be one of: {', '.join(sorted(SERVICES))}",
)
if normalized_group not in SERVICE_GROUP_BY[normalized_service]:
allowed = ", ".join(sorted(SERVICE_GROUP_BY[normalized_service]))
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"group_by for {normalized_service} must be one of: {allowed}",
)
return build_usage_analytics(
db,
user,
period=normalized_period, # type: ignore[arg-type]
service=normalized_service, # type: ignore[arg-type]
group_by=normalized_group, # type: ignore[arg-type]
)

View file

@ -0,0 +1,127 @@
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from .api_keys_service import generate_api_token, hash_api_token, token_prefix, validate_key_name
from .auth import current_user
from .database import get_db
from .models import ApiKey, UserAccount
router = APIRouter(prefix="/api/v1/api-keys", tags=["api-keys"])
class CreateApiKeyRequest(BaseModel):
name: str = Field(min_length=2, max_length=60)
def serialize_api_key(api_key: ApiKey, *, include_token: str | None = None) -> dict:
payload = {
"id": str(api_key.id),
"name": api_key.name,
"key_prefix": api_key.key_prefix,
"created_at": api_key.created_at,
"last_used_at": api_key.last_used_at,
"revoked_at": api_key.revoked_at,
"status": "revoked" if api_key.revoked_at else "active",
}
if include_token:
payload["token"] = include_token
return payload
@router.get("")
def list_api_keys(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
keys = db.scalars(
select(ApiKey)
.where(ApiKey.user_id == user.id, ApiKey.revoked_at.is_(None))
.order_by(ApiKey.created_at.desc())
).all()
return [serialize_api_key(key) for key in keys]
@router.post("", status_code=status.HTTP_201_CREATED)
def create_api_key(
payload: CreateApiKeyRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
try:
name = validate_key_name(payload.name)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
existing = db.scalar(
select(ApiKey).where(
ApiKey.user_id == user.id,
ApiKey.name == name,
ApiKey.revoked_at.is_(None),
)
)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="An active API key with this name already exists")
api_key = ApiKey(
user_id=user.id,
name=name,
key_prefix="pending",
key_hash="pending",
)
db.add(api_key)
db.flush()
token = generate_api_token(api_key.id)
api_key.key_prefix = token_prefix(token)
api_key.key_hash = hash_api_token(token)
db.commit()
db.refresh(api_key)
return serialize_api_key(api_key, include_token=token)
@router.post("/{key_id}/regenerate")
def regenerate_api_key(
key_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
api_key = db.scalar(
select(ApiKey).where(
ApiKey.id == key_id,
ApiKey.user_id == user.id,
ApiKey.revoked_at.is_(None),
)
)
if not api_key:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
token = generate_api_token(api_key.id)
api_key.key_prefix = token_prefix(token)
api_key.key_hash = hash_api_token(token)
api_key.last_used_at = None
db.commit()
db.refresh(api_key)
return serialize_api_key(api_key, include_token=token)
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke_api_key(
key_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> None:
api_key = db.scalar(
select(ApiKey).where(
ApiKey.id == key_id,
ApiKey.user_id == user.id,
ApiKey.revoked_at.is_(None),
)
)
if not api_key:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
api_key.revoked_at = datetime.now(timezone.utc)
db.commit()

222
backend/app/routes_auth.py Normal file
View file

@ -0,0 +1,222 @@
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
import httpx
import jwt
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import RedirectResponse
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token
from sqlalchemy import select
from sqlalchemy.orm import Session
from .auth import COOKIE_NAME, create_token, current_user, normalize_email, password_hash
from .config import settings
from .database import get_db
from .models import OidcIdentity, PasswordCredential, UserAccount
from .schemas import LoginRequest, RegisterRequest, SetPasswordRequest, UserResponse
router = APIRouter(prefix="/api/v1/auth", tags=["authentication"])
GOOGLE_ISSUER = "https://accounts.google.com"
def public_user(user: UserAccount) -> UserResponse:
return UserResponse(
id=str(user.id),
email=user.email,
name=user.display_name,
avatar_url=user.avatar_url,
email_verified=user.email_verified,
has_password=user.password_credential is not None,
providers=["google" for identity in user.oidc_identities if identity.issuer == GOOGLE_ISSUER],
)
def set_session(response: Response, user: UserAccount) -> None:
response.set_cookie(
COOKIE_NAME,
create_token(user.id),
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
max_age=settings.access_token_minutes * 60,
path="/",
)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register(payload: RegisterRequest, response: Response, db: Session = Depends(get_db)) -> UserResponse:
email = normalize_email(str(payload.email))
user = db.scalar(select(UserAccount).where(UserAccount.email == email))
if user:
detail = (
"Sign in with Google, then add a password from Sign-in & Security"
if user.oidc_identities and not user.password_credential
else "An account already exists"
)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
user = UserAccount(email=email, display_name=payload.name.strip(), email_verified=False)
db.add(user)
db.flush()
user.password_credential = PasswordCredential(password_hash=password_hash.hash(payload.password))
db.commit()
db.refresh(user)
set_session(response, user)
return public_user(user)
@router.post("/login", response_model=UserResponse)
def login(payload: LoginRequest, response: Response, db: Session = Depends(get_db)) -> UserResponse:
user = db.scalar(select(UserAccount).where(UserAccount.email == normalize_email(str(payload.email))))
if not user or not user.password_credential:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
if not password_hash.verify(payload.password, user.password_credential.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
set_session(response, user)
return public_user(user)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(response: Response) -> None:
response.delete_cookie(COOKIE_NAME, path="/")
@router.get("/me", response_model=UserResponse)
def me(user: UserAccount = Depends(current_user)) -> UserResponse:
return public_user(user)
@router.post("/password", response_model=UserResponse)
def configure_password(
payload: SetPasswordRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> UserResponse:
if user.password_credential:
if not payload.current_password or not password_hash.verify(
payload.current_password, user.password_credential.password_hash
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
)
user.password_credential.password_hash = password_hash.hash(payload.password)
else:
user.password_credential = PasswordCredential(password_hash=password_hash.hash(payload.password))
db.commit()
db.refresh(user)
return public_user(user)
@router.get("/google/start")
def google_start(next_path: str = Query(default="/console", pattern=r"^/")) -> RedirectResponse:
if not settings.google_client_id or not settings.google_client_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Google sign-in is not configured",
)
now = datetime.now(timezone.utc)
nonce = secrets.token_urlsafe(24)
state = jwt.encode(
{
"purpose": "google_oauth",
"nonce": nonce,
"next": next_path,
"iat": now,
"exp": now + timedelta(minutes=10),
},
settings.auth_secret,
algorithm="HS256",
)
query = urlencode(
{
"client_id": settings.google_client_id,
"redirect_uri": settings.google_redirect_uri,
"response_type": "code",
"scope": "openid email profile",
"state": state,
"nonce": nonce,
"prompt": "select_account",
}
)
return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{query}")
@router.get("/google/callback")
def google_callback(
code: str,
state: str,
db: Session = Depends(get_db),
) -> RedirectResponse:
try:
state_data = jwt.decode(state, settings.auth_secret, algorithms=["HS256"])
if state_data.get("purpose") != "google_oauth":
raise ValueError("invalid state")
except (jwt.PyJWTError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state") from exc
token_response = httpx.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": settings.google_redirect_uri,
"grant_type": "authorization_code",
},
timeout=15,
)
if token_response.is_error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Google authentication failed")
try:
claims = id_token.verify_oauth2_token(
token_response.json()["id_token"],
google_requests.Request(),
settings.google_client_id,
)
if claims.get("nonce") != state_data["nonce"] or not claims.get("email_verified"):
raise ValueError("unverified Google identity")
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid Google identity") from exc
subject = str(claims["sub"])
identity = db.scalar(
select(OidcIdentity).where(
OidcIdentity.issuer == GOOGLE_ISSUER,
OidcIdentity.subject == subject,
)
)
if identity:
user = identity.user
identity.last_login_at = datetime.now(timezone.utc)
else:
email = normalize_email(str(claims["email"]))
user = db.scalar(select(UserAccount).where(UserAccount.email == email))
if not user:
user = UserAccount(
email=email,
display_name=str(claims.get("name") or email.split("@")[0]),
avatar_url=claims.get("picture"),
email_verified=True,
)
db.add(user)
db.flush()
else:
user.email_verified = True
user.avatar_url = user.avatar_url or claims.get("picture")
db.add(
OidcIdentity(
user_id=user.id,
issuer=GOOGLE_ISSUER,
subject=subject,
provider_email=email,
)
)
db.commit()
destination = str(state_data.get("next", "/console"))
response = RedirectResponse(f"{settings.frontend_origin}{destination}")
set_session(response, user)
return response

View file

@ -0,0 +1,307 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .auth import current_user
from .campaign_csv_service import (
save_campaign_csv,
stub_run_campaign_automation,
stub_run_csv_upload,
template_variable_count,
)
from .config import settings
from .database import get_db
from .models import Campaign, CampaignCsvUpload, SenderConfig, TemplateConfig, UserAccount
router = APIRouter(prefix="/api/v1/campaigns", tags=["campaigns"])
RUN_MODES = {"manual", "on_upload", "scheduled"}
class CreateCampaignRequest(BaseModel):
name: str = Field(min_length=3, max_length=80)
sender_config_id: str
template_id: str
number_column: str = Field(min_length=1, max_length=80)
variable_columns: list[str] = Field(min_length=0, max_length=10)
run_mode: str
scheduled_at: datetime | None = None
def serialize_csv_upload(upload: CampaignCsvUpload) -> dict:
return {
"id": str(upload.id),
"original_filename": upload.original_filename,
"total_rows": upload.total_rows,
"status": upload.status,
"run_started_at": upload.run_started_at,
"run_completed_at": upload.run_completed_at,
"created_at": upload.created_at,
}
def serialize_campaign(campaign: Campaign) -> dict:
return {
"id": str(campaign.id),
"name": campaign.name,
"sender_config_id": str(campaign.sender_config_id),
"sender_id": campaign.sender_config.sender_id,
"sender_type": campaign.sender_config.sender_type,
"template_id": str(campaign.template_id),
"template_name": campaign.template.template_name,
"template_text": campaign.template.template_text,
"number_column": campaign.number_column,
"variable_columns": campaign.variable_columns,
"run_mode": campaign.run_mode,
"scheduled_at": campaign.scheduled_at,
"status": campaign.status,
"created_at": campaign.created_at,
"updated_at": campaign.updated_at,
"csv_upload_count": len(campaign.csv_uploads),
"csv_uploads": [serialize_csv_upload(upload) for upload in campaign.csv_uploads],
}
def get_owned_campaign(db: Session, user: UserAccount, campaign_id: UUID) -> Campaign:
campaign = db.scalar(
select(Campaign)
.options(
joinedload(Campaign.sender_config),
joinedload(Campaign.template),
joinedload(Campaign.csv_uploads),
)
.where(Campaign.id == campaign_id, Campaign.user_id == user.id)
)
if not campaign:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
return campaign
def validate_campaign_links(
db: Session,
user: UserAccount,
*,
sender_config_id: str,
template_id: str,
variable_columns: list[str],
) -> tuple[SenderConfig, TemplateConfig]:
sender = db.scalar(
select(SenderConfig).where(
SenderConfig.id == sender_config_id,
SenderConfig.user_id == user.id,
SenderConfig.status == "approved",
)
)
if not sender:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Select an approved sender ID",
)
template = db.scalar(
select(TemplateConfig).where(
TemplateConfig.id == template_id,
TemplateConfig.user_id == user.id,
TemplateConfig.sender_config_id == sender.id,
TemplateConfig.status == "approved",
)
)
if not template:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Select an approved template linked to the sender",
)
expected = template_variable_count(template.template_text)
if len(variable_columns) != expected:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Template requires {expected} variable column mapping(s)",
)
cleaned_columns = [column.strip() for column in variable_columns]
if any(not column for column in cleaned_columns):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Variable column names cannot be empty",
)
return sender, template
@router.get("")
def list_campaigns(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
campaigns = db.scalars(
select(Campaign)
.options(
joinedload(Campaign.sender_config),
joinedload(Campaign.template),
joinedload(Campaign.csv_uploads),
)
.where(Campaign.user_id == user.id)
.order_by(Campaign.created_at.desc())
).unique().all()
return [serialize_campaign(campaign) for campaign in campaigns]
@router.post("", status_code=status.HTTP_201_CREATED)
def create_campaign(
payload: CreateCampaignRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
if payload.run_mode not in RUN_MODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Run mode must be manual, on_upload, or scheduled",
)
if payload.run_mode == "scheduled" and payload.scheduled_at is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Scheduled campaigns require a scheduled_at timestamp",
)
number_column = payload.number_column.strip()
if not number_column:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Number column is required",
)
sender, template = validate_campaign_links(
db,
user,
sender_config_id=payload.sender_config_id,
template_id=payload.template_id,
variable_columns=payload.variable_columns,
)
campaign = Campaign(
user_id=user.id,
name=payload.name.strip(),
sender_config_id=sender.id,
template_id=template.id,
number_column=number_column,
variable_columns=[column.strip() for column in payload.variable_columns],
run_mode=payload.run_mode,
scheduled_at=payload.scheduled_at,
status="active",
)
db.add(campaign)
db.commit()
db.refresh(campaign)
campaign.sender_config = sender
campaign.template = template
campaign.csv_uploads = []
return serialize_campaign(campaign)
@router.get("/{campaign_id}")
def get_campaign(
campaign_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
campaign = get_owned_campaign(db, user, campaign_id)
return serialize_campaign(campaign)
@router.get("/{campaign_id}/csv")
def list_campaign_csv_uploads(
campaign_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
campaign = get_owned_campaign(db, user, campaign_id)
uploads = sorted(campaign.csv_uploads, key=lambda item: item.created_at, reverse=True)
return [serialize_csv_upload(upload) for upload in uploads]
@router.post("/{campaign_id}/csv", status_code=status.HTTP_201_CREATED)
async def upload_campaign_csv(
campaign_id: UUID,
file: UploadFile = File(...),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
campaign = get_owned_campaign(db, user, campaign_id)
if not file.filename or not file.filename.lower().endswith(".csv"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Upload a .csv file",
)
file_bytes = await file.read()
if len(file_bytes) > settings.max_upload_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="CSV file is too large",
)
if not file_bytes.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="CSV file is empty",
)
try:
upload = save_campaign_csv(
db,
campaign,
original_filename=file.filename,
file_bytes=file_bytes,
auto_run=campaign.run_mode == "on_upload",
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return serialize_csv_upload(upload)
@router.post("/{campaign_id}/run")
def run_campaign_automation(
campaign_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
campaign = get_owned_campaign(db, user, campaign_id)
uploads = stub_run_campaign_automation(db, campaign)
return {
"campaign_id": str(campaign.id),
"processed_uploads": len(uploads),
"uploads": [serialize_csv_upload(upload) for upload in uploads],
"note": "Send functionality is not enabled yet; runs are recorded as completed stubs.",
}
@router.post("/{campaign_id}/csv/{upload_id}/run")
def run_campaign_csv(
campaign_id: UUID,
upload_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
campaign = get_owned_campaign(db, user, campaign_id)
upload = next((item for item in campaign.csv_uploads if item.id == upload_id), None)
if not upload:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="CSV upload not found")
if upload.status not in {"validated", "completed"}:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Cannot run CSV in status '{upload.status}'",
)
upload = stub_run_csv_upload(db, upload)
return {
**serialize_csv_upload(upload),
"note": "Send functionality is not enabled yet; run recorded as a completed stub.",
}

View file

@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .auth import current_user
from .database import get_db
from .dlt_service import ensure_dlt_application, serialize_dlt
from .kyc_service import ensure_kyc_application, serialize_kyc
from .models import UserAccount
router = APIRouter(prefix="/api/v1/compliance", tags=["compliance"])
@router.get("/status")
def compliance_status(db: Session = Depends(get_db), user: UserAccount = Depends(current_user)) -> dict:
kyc = serialize_kyc(ensure_kyc_application(db, user))
dlt = serialize_dlt(ensure_dlt_application(db, user))
return {
"kyc_completed": kyc["completed"],
"dlt_completed": dlt["completed"],
"console_unlocked": dlt["completed"],
}

130
backend/app/routes_dlt.py Normal file
View file

@ -0,0 +1,130 @@
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from .auth import current_user
from .config import settings
from .database import get_db
from .dlt_constants import DLT_DOCUMENT_TYPES, DLT_UPLOAD_DOCUMENT_TYPES, PE_TM_CHAIN_BINDING_ID
from .dlt_service import ensure_dlt_application, serialize_dlt
from .kyc_constants import ALLOWED_UPLOAD_EXTENSIONS
from .kyc_service import upload_root
from .models import UserAccount
router = APIRouter(prefix="/api/v1/dlt", tags=["dlt"])
@router.get("/me")
def get_my_dlt(db: Session = Depends(get_db), user: UserAccount = Depends(current_user)) -> dict:
application = ensure_dlt_application(db, user)
return serialize_dlt(application)
@router.get("/loa")
def download_loa(_: UserAccount = Depends(current_user)) -> FileResponse:
template = Path(settings.dlt_loa_template_path)
if not template.is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="LOA template not found")
return FileResponse(
template,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
filename="Updated LOA.docx",
)
@router.post("/chain-binding/confirm")
def confirm_chain_binding(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
application = ensure_dlt_application(db, user)
if application.status == "approved":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Approved DLT cannot be edited")
document = next(doc for doc in application.documents if doc.document_type == "pe_tm_chain_binding")
if document.status == "approved":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Chain binding already approved")
document.original_filename = f"Chain binding {PE_TM_CHAIN_BINDING_ID}"
document.stored_path = None
document.status = "pending"
document.review_note = None
document.uploaded_at = datetime.now(timezone.utc)
document.reviewed_at = None
if application.status in {"not_started", "in_progress", "rejected"}:
application.status = "in_progress"
db.commit()
db.refresh(application)
return serialize_dlt(application)
@router.post("/documents/{document_type}")
async def upload_document(
document_type: str,
file: UploadFile = File(...),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
if document_type not in DLT_DOCUMENT_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid document type")
if document_type not in DLT_UPLOAD_DOCUMENT_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="This item uses chain binding confirmation instead of file upload",
)
application = ensure_dlt_application(db, user)
if application.status == "approved":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Approved DLT cannot be edited")
filename = file.filename or ""
extension = Path(filename).suffix.lower()
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Allowed file types: PDF, PNG, JPG, DOC, DOCX",
)
content = await file.read()
if not content:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="File is empty")
if len(content) > settings.max_upload_bytes:
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="File exceeds 10 MB limit")
document = next(doc for doc in application.documents if doc.document_type == document_type)
root = upload_root(settings.upload_dir)
stored_name = f"{application.id}_{document_type}{extension}"
stored_path = root / stored_name
stored_path.write_bytes(content)
document.original_filename = filename
document.stored_path = str(stored_path)
document.status = "pending"
document.review_note = None
document.uploaded_at = datetime.now(timezone.utc)
document.reviewed_at = None
if application.status in {"not_started", "in_progress", "rejected"}:
application.status = "in_progress"
db.commit()
db.refresh(application)
return serialize_dlt(application)
@router.post("/submit")
def submit_dlt(db: Session = Depends(get_db), user: UserAccount = Depends(current_user)) -> dict:
application = ensure_dlt_application(db, user)
if application.status == "approved":
return serialize_dlt(application)
missing = [doc.document_type for doc in application.documents if doc.status == "missing"]
if missing:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Complete all required DLT items before submitting",
)
application.status = "submitted"
application.submitted_at = datetime.now(timezone.utc)
db.commit()
db.refresh(application)
return serialize_dlt(application)

View file

@ -0,0 +1,363 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from .database import get_db
from .endpoint_security import endpoint_signing_secret, validate_endpoint_url
from .models import (
PhoneNumber,
SmsDeliveryWebhook,
UserAccount,
VoiceSocketEndpoint,
)
from .sms_webhooks import deliver_test_report
from .voice_auth import current_voice_user
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
class EndpointCreate(BaseModel):
name: str = Field(min_length=2, max_length=120)
phone_number_id: UUID
url: str = Field(min_length=8, max_length=500)
description: str | None = Field(default=None, max_length=500)
enabled: bool = True
class EndpointUpdate(BaseModel):
name: str | None = Field(default=None, min_length=2, max_length=120)
phone_number_id: UUID | None = None
url: str | None = Field(default=None, min_length=8, max_length=500)
description: str | None = Field(default=None, max_length=500)
enabled: bool | None = None
def _owned_number(db: Session, user: UserAccount, number_id: UUID) -> PhoneNumber:
number = db.scalar(
select(PhoneNumber).where(
PhoneNumber.id == number_id,
PhoneNumber.user_id == user.id,
PhoneNumber.status == "assigned",
)
)
if not number:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found")
return number
def _clean_name(value: str) -> str:
name = " ".join(value.split())
if len(name) < 2:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Name must contain at least 2 characters",
)
return name
def _commit(db: Session, duplicate_detail: str) -> None:
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=duplicate_detail) from exc
def _serialize_socket(endpoint: VoiceSocketEndpoint, *, include_secret: bool = False) -> dict:
payload = {
"id": str(endpoint.id),
"name": endpoint.name,
"phone_number_id": str(endpoint.phone_number_id),
"phone_number": endpoint.phone_number.phone_number,
"url": endpoint.url,
"enabled": endpoint.enabled,
"created_at": endpoint.created_at,
"updated_at": endpoint.updated_at,
}
if include_secret:
payload["signing_secret"] = endpoint_signing_secret("socket", endpoint.id)
return payload
def _serialize_webhook(endpoint: SmsDeliveryWebhook, *, include_secret: bool = False) -> dict:
payload = {
"id": str(endpoint.id),
"name": endpoint.name,
"phone_number_id": str(endpoint.phone_number_id),
"phone_number": endpoint.phone_number.phone_number,
"url": endpoint.url,
"description": endpoint.description,
"event": "message.delivery.updated",
"enabled": endpoint.enabled,
"created_at": endpoint.created_at,
"updated_at": endpoint.updated_at,
}
if include_secret:
payload["signing_secret"] = endpoint_signing_secret("webhook", endpoint.id)
return payload
@router.get("/docs")
def integration_docs() -> dict:
return {
"title": "Webhooks & Sockets",
"summary": "Connect each Urmi number to your application.",
"relay": {
"principle": "Urmi forwards raw WebSocket and HTTP payloads without decoding or modifying them. We authenticate requests and route traffic — all processing happens on your infrastructure.",
"latency": "Voice media frames are relayed as they arrive to keep latency as low as possible.",
},
"socket": {
"title": "Voice sockets",
"description": "Link a secure WebSocket endpoint to a number. When a call connects, Urmi opens your endpoint and relays frames in both directions with no audio processing on our side.",
"events": ["connected", "start", "media", "end"],
"authentication": {
"headers": ["X-Urmi-Endpoint-Id", "X-Urmi-Timestamp", "X-Urmi-Signature"],
"signature": "HMAC-SHA256 of '<endpoint_id>.<timestamp>' using the signing secret.",
"replay_protection": "Reject timestamps older than five minutes.",
},
},
"webhook": {
"title": "SMS delivery webhooks",
"description": "Link a named HTTPS endpoint to a number to receive SMS delivery status updates.",
"event": "message.delivery.updated",
"statuses": ["queued", "sent", "delivered", "undelivered", "failed"],
"authentication": {
"headers": ["X-Urmi-Event", "X-Urmi-Endpoint-Id", "X-Urmi-Signature"],
"signature": "HMAC-SHA256 of the raw request body using the signing secret.",
},
"example": {
"id": "evt_01J8EXAMPLE",
"event": "message.delivery.updated",
"created_at": "2026-09-04T09:30:00Z",
"data": {
"message_id": "msg_01J8EXAMPLE",
"to": "+919999999999",
"from": "+917946350706",
"status": "delivered",
"updated_at": "2026-09-04T09:29:58Z",
},
},
},
"api_authentication": {
"header": "Authorization",
"format": "Bearer urmi_live_<your_api_key>",
"note": "Use API keys only from your server. Never expose them in browser code.",
},
"endpoints": [
{"method": "GET", "path": "/api/v1/integrations/sockets", "description": "List voice sockets"},
{"method": "POST", "path": "/api/v1/integrations/sockets", "description": "Create and link a voice socket"},
{"method": "PATCH", "path": "/api/v1/integrations/sockets/{id}", "description": "Update or pause a voice socket"},
{"method": "DELETE", "path": "/api/v1/integrations/sockets/{id}", "description": "Delete a voice socket"},
{"method": "GET", "path": "/api/v1/integrations/webhooks", "description": "List SMS delivery webhooks"},
{"method": "POST", "path": "/api/v1/integrations/webhooks", "description": "Create and link a delivery webhook"},
{"method": "PATCH", "path": "/api/v1/integrations/webhooks/{id}", "description": "Update or pause a delivery webhook"},
{"method": "DELETE", "path": "/api/v1/integrations/webhooks/{id}", "description": "Delete a delivery webhook"},
],
}
@router.get("/sockets")
def list_sockets(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_voice_user),
) -> list[dict]:
endpoints = db.scalars(
select(VoiceSocketEndpoint)
.options(joinedload(VoiceSocketEndpoint.phone_number))
.where(VoiceSocketEndpoint.user_id == user.id)
.order_by(VoiceSocketEndpoint.created_at.desc())
).all()
return [_serialize_socket(endpoint) for endpoint in endpoints]
@router.post("/sockets", status_code=status.HTTP_201_CREATED)
def create_socket(
payload: EndpointCreate,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_voice_user),
) -> dict:
number = _owned_number(db, user, payload.phone_number_id)
try:
url = validate_endpoint_url(payload.url, websocket=True)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
endpoint = VoiceSocketEndpoint(
user_id=user.id,
phone_number_id=number.id,
name=_clean_name(payload.name),
url=url,
enabled=payload.enabled,
)
db.add(endpoint)
_commit(db, "This name or phone number already has a voice socket")
db.refresh(endpoint)
endpoint.phone_number = number
return _serialize_socket(endpoint, include_secret=True)
@router.patch("/sockets/{endpoint_id}")
def update_socket(
endpoint_id: UUID,
payload: EndpointUpdate,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_voice_user),
) -> dict:
endpoint = db.scalar(
select(VoiceSocketEndpoint)
.options(joinedload(VoiceSocketEndpoint.phone_number))
.where(VoiceSocketEndpoint.id == endpoint_id, VoiceSocketEndpoint.user_id == user.id)
)
if not endpoint:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice socket not found")
if payload.name is not None:
endpoint.name = _clean_name(payload.name)
if payload.phone_number_id is not None:
endpoint.phone_number = _owned_number(db, user, payload.phone_number_id)
endpoint.phone_number_id = payload.phone_number_id
if payload.url is not None:
try:
endpoint.url = validate_endpoint_url(payload.url, websocket=True)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
if payload.enabled is not None:
endpoint.enabled = payload.enabled
_commit(db, "This name or phone number already has a voice socket")
db.refresh(endpoint)
return _serialize_socket(endpoint)
@router.delete("/sockets/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_socket(
endpoint_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_voice_user),
) -> None:
endpoint = db.scalar(
select(VoiceSocketEndpoint).where(
VoiceSocketEndpoint.id == endpoint_id,
VoiceSocketEndpoint.user_id == user.id,
)
)
if not endpoint:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice socket not found")
db.delete(endpoint)
db.commit()
@router.get("/webhooks")
def list_webhooks(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_voice_user),
) -> list[dict]:
endpoints = db.scalars(
select(SmsDeliveryWebhook)
.options(joinedload(SmsDeliveryWebhook.phone_number))
.where(SmsDeliveryWebhook.user_id == user.id)
.order_by(SmsDeliveryWebhook.created_at.desc())
).all()
return [_serialize_webhook(endpoint) for endpoint in endpoints]
@router.post("/webhooks", status_code=status.HTTP_201_CREATED)
def create_webhook(
payload: EndpointCreate,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_voice_user),
) -> dict:
number = _owned_number(db, user, payload.phone_number_id)
try:
url = validate_endpoint_url(payload.url, websocket=False)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
endpoint = SmsDeliveryWebhook(
user_id=user.id,
phone_number_id=number.id,
name=_clean_name(payload.name),
url=url,
description=payload.description.strip() if payload.description else None,
enabled=payload.enabled,
)
db.add(endpoint)
_commit(db, "A webhook with this name already exists")
db.refresh(endpoint)
endpoint.phone_number = number
return _serialize_webhook(endpoint, include_secret=True)
@router.patch("/webhooks/{endpoint_id}")
def update_webhook(
endpoint_id: UUID,
payload: EndpointUpdate,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_voice_user),
) -> dict:
endpoint = db.scalar(
select(SmsDeliveryWebhook)
.options(joinedload(SmsDeliveryWebhook.phone_number))
.where(SmsDeliveryWebhook.id == endpoint_id, SmsDeliveryWebhook.user_id == user.id)
)
if not endpoint:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
if payload.name is not None:
endpoint.name = _clean_name(payload.name)
if payload.phone_number_id is not None:
endpoint.phone_number = _owned_number(db, user, payload.phone_number_id)
endpoint.phone_number_id = payload.phone_number_id
if payload.url is not None:
try:
endpoint.url = validate_endpoint_url(payload.url, websocket=False)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
if "description" in payload.model_fields_set:
endpoint.description = payload.description.strip() if payload.description else None
if payload.enabled is not None:
endpoint.enabled = payload.enabled
_commit(db, "A webhook with this name already exists")
db.refresh(endpoint)
return _serialize_webhook(endpoint)
@router.post("/webhooks/{endpoint_id}/test")
async def test_webhook(
endpoint_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_voice_user),
) -> dict:
endpoint = db.scalar(
select(SmsDeliveryWebhook).where(
SmsDeliveryWebhook.id == endpoint_id,
SmsDeliveryWebhook.user_id == user.id,
)
)
if not endpoint:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
try:
response_code = await deliver_test_report(endpoint)
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=str(exc),
) from exc
return {"delivered": True, "response_code": response_code}
@router.delete("/webhooks/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_webhook(
endpoint_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_voice_user),
) -> None:
endpoint = db.scalar(
select(SmsDeliveryWebhook).where(
SmsDeliveryWebhook.id == endpoint_id,
SmsDeliveryWebhook.user_id == user.id,
)
)
if not endpoint:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
db.delete(endpoint)
db.commit()

133
backend/app/routes_kyc.py Normal file
View file

@ -0,0 +1,133 @@
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .auth import current_user
from .config import settings
from .database import get_db
from .kyc_constants import ALLOWED_UPLOAD_EXTENSIONS, KYC_DOCUMENT_TYPES
from .kyc_service import ensure_kyc_application, refresh_application_status, serialize_kyc, upload_root
from .kyc_validators import validate_gst, validate_pan
from .models import KycApplication, KycDocument, UserAccount
router = APIRouter(prefix="/api/v1/kyc", tags=["kyc"])
class KycDetailsRequest(BaseModel):
pan_number: str = Field(min_length=10, max_length=10)
gst_number: str = Field(min_length=15, max_length=15)
@router.get("/template")
def download_template(_: UserAccount = Depends(current_user)) -> FileResponse:
template = Path(settings.kyc_template_path)
if not template.is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="KYC template not found")
return FileResponse(
template,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
filename="KYC Form - Pvt Ltd Client.docx",
)
@router.get("/me")
def get_my_kyc(db: Session = Depends(get_db), user: UserAccount = Depends(current_user)) -> dict:
application = ensure_kyc_application(db, user)
return serialize_kyc(application)
@router.put("/me")
def update_my_kyc(
payload: KycDetailsRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
application = ensure_kyc_application(db, user)
if application.status == "approved":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Approved KYC cannot be edited")
try:
application.pan_number = validate_pan(payload.pan_number)
application.gst_number = validate_gst(payload.gst_number)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
if application.status == "not_started":
application.status = "in_progress"
db.commit()
db.refresh(application)
return serialize_kyc(application)
@router.post("/documents/{document_type}")
async def upload_document(
document_type: str,
file: UploadFile = File(...),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
if document_type not in KYC_DOCUMENT_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid document type")
application = ensure_kyc_application(db, user)
if application.status == "approved":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Approved KYC cannot be edited")
if not application.pan_number or not application.gst_number:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Enter PAN and GST before uploading documents",
)
filename = file.filename or ""
extension = Path(filename).suffix.lower()
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Allowed file types: PDF, PNG, JPG, DOC, DOCX",
)
content = await file.read()
if not content:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="File is empty")
if len(content) > settings.max_upload_bytes:
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="File exceeds 10 MB limit")
document = next(doc for doc in application.documents if doc.document_type == document_type)
root = upload_root(settings.upload_dir)
stored_name = f"{application.id}_{document_type}{extension}"
stored_path = root / stored_name
stored_path.write_bytes(content)
document.original_filename = filename
document.stored_path = str(stored_path)
document.status = "pending"
document.review_note = None
document.uploaded_at = datetime.now(timezone.utc)
document.reviewed_at = None
if application.status in {"not_started", "in_progress", "rejected"}:
application.status = "in_progress"
db.commit()
db.refresh(application)
return serialize_kyc(application)
@router.post("/submit")
def submit_kyc(db: Session = Depends(get_db), user: UserAccount = Depends(current_user)) -> dict:
application = ensure_kyc_application(db, user)
if application.status == "approved":
return serialize_kyc(application)
if not application.pan_number or not application.gst_number:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="PAN and GST are required")
missing = [doc.document_type for doc in application.documents if doc.status == "missing"]
if missing:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Upload all required documents before submitting",
)
application.status = "submitted"
application.submitted_at = datetime.now(timezone.utc)
db.commit()
db.refresh(application)
return serialize_kyc(application)

192
backend/app/routes_logs.py Normal file
View file

@ -0,0 +1,192 @@
from datetime import datetime, timedelta, timezone
from math import ceil
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
from .auth import current_user
from .database import get_db
from .models import OtpMessageLog, SmsMessageLog, UserAccount, VoiceCall, VoiceSocketEndpoint
router = APIRouter(prefix="/api/v1/logs", tags=["logs"])
LOG_TYPES = frozenset({"outbound", "inbound", "sms", "otp"})
DEFAULT_RANGE_DAYS = 7
MAX_RANGE_DAYS = 31
PAGE_SIZE = 10
def _parse_time(value: str | None, *, fallback: datetime) -> datetime:
if not value:
return fallback
normalized = value.strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid datetime format. Use ISO 8601.",
) from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _resolve_time_window(from_value: str | None, to_value: str | None) -> tuple[datetime, datetime]:
now = datetime.now(timezone.utc)
end = _parse_time(to_value, fallback=now)
start = _parse_time(from_value, fallback=end - timedelta(days=DEFAULT_RANGE_DAYS))
if start > end:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="'from' must be earlier than 'to'.",
)
if (end - start).days > MAX_RANGE_DAYS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Time range cannot exceed {MAX_RANGE_DAYS} days.",
)
return start, end
def _serialize_voice_call(call: VoiceCall, *, socket_name: str | None) -> dict:
if call.direction == "inbound":
from_number = call.to_number
to_number = call.from_did
else:
from_number = call.from_did
to_number = call.to_number
payload = {
"id": str(call.id),
"from": from_number,
"to": to_number,
"status": call.status,
"time": call.created_at,
}
if call.direction == "outbound":
payload["socket_name"] = socket_name or "—"
return payload
def _serialize_sms_log(entry: SmsMessageLog) -> dict:
return {
"id": str(entry.id),
"from": entry.sender_id,
"to": entry.to_number,
"template": entry.template_name,
"status": entry.status,
"time": entry.created_at,
}
def _serialize_otp_log(entry: OtpMessageLog) -> dict:
return {
"id": str(entry.id),
"to": entry.to_number,
"otp_type": entry.template_type,
"project": entry.project_name,
"status": entry.status,
"time": entry.created_at,
}
@router.get("")
def list_logs(
log_type: str = Query(..., alias="type"),
from_value: str | None = Query(default=None, alias="from"),
to_value: str | None = Query(default=None, alias="to"),
page: int = Query(default=1, ge=1),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
normalized_type = log_type.strip().lower()
if normalized_type not in LOG_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"type must be one of: {', '.join(sorted(LOG_TYPES))}",
)
start, end = _resolve_time_window(from_value, to_value)
offset = (page - 1) * PAGE_SIZE
if normalized_type in {"outbound", "inbound"}:
direction = normalized_type
filters = [
VoiceCall.user_id == user.id,
VoiceCall.direction == direction,
VoiceCall.created_at >= start,
VoiceCall.created_at <= end,
]
total = db.scalar(select(func.count()).select_from(VoiceCall).where(*filters)) or 0
calls = db.scalars(
select(VoiceCall)
.options(joinedload(VoiceCall.phone_number))
.where(*filters)
.order_by(VoiceCall.created_at.desc())
.offset(offset)
.limit(PAGE_SIZE)
).unique().all()
socket_by_phone: dict = {}
if direction == "outbound" and calls:
phone_ids = [call.phone_number_id for call in calls]
sockets = db.scalars(
select(VoiceSocketEndpoint).where(
VoiceSocketEndpoint.user_id == user.id,
VoiceSocketEndpoint.phone_number_id.in_(phone_ids),
)
).all()
socket_by_phone = {socket.phone_number_id: socket.name for socket in sockets}
items = [
_serialize_voice_call(call, socket_name=socket_by_phone.get(call.phone_number_id))
for call in calls
]
elif normalized_type == "sms":
filters = [
SmsMessageLog.user_id == user.id,
SmsMessageLog.created_at >= start,
SmsMessageLog.created_at <= end,
]
total = db.scalar(select(func.count()).select_from(SmsMessageLog).where(*filters)) or 0
entries = db.scalars(
select(SmsMessageLog)
.where(*filters)
.order_by(SmsMessageLog.created_at.desc())
.offset(offset)
.limit(PAGE_SIZE)
).all()
items = [_serialize_sms_log(entry) for entry in entries]
else:
filters = [
OtpMessageLog.user_id == user.id,
OtpMessageLog.created_at >= start,
OtpMessageLog.created_at <= end,
]
total = db.scalar(select(func.count()).select_from(OtpMessageLog).where(*filters)) or 0
entries = db.scalars(
select(OtpMessageLog)
.where(*filters)
.order_by(OtpMessageLog.created_at.desc())
.offset(offset)
.limit(PAGE_SIZE)
).all()
items = [_serialize_otp_log(entry) for entry in entries]
total_pages = ceil(total / PAGE_SIZE) if total else 0
return {
"type": normalized_type,
"page": page,
"page_size": PAGE_SIZE,
"total": total,
"total_pages": total_pages,
"from": start,
"to": end,
"items": items,
}

View file

@ -0,0 +1,118 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .api_key_auth import current_api_user
from .database import get_db
from .message_send_service import SendMessageError, send_template_messages
from .models import UserAccount
router = APIRouter(prefix="/api/v1/messages", tags=["messages"])
SEND_SMS_EXPLORER = {
"id": "send-sms",
"method": "POST",
"path": "/api/v1/messages/send",
"title": "Send template SMS",
"summary": "Send a DLT-approved SMS using an API key, approved sender ID, and template.",
"authentication": {
"type": "api_key",
"header": "Authorization",
"format": "Bearer urmi_live_<key>",
"alternate_header": "X-Api-Key",
},
"request_body": {
"sender_id": {
"type": "string",
"required": True,
"description": "Approved DLT sender header, e.g. URMIAI",
"example": "URMIAI",
},
"template_id": {
"type": "uuid",
"required": True,
"description": "Approved template UUID from Message settings",
},
"to": {
"type": "string",
"required": True,
"description": "Recipient mobile number with country code, e.g. 919999999999",
"example": "919999999999",
},
"message": {
"type": "string",
"required": True,
"description": "Final SMS text with all {#var#} / {#num#} placeholders replaced",
"example": "Your URMI app verification code is 482916. It is valid for 10 minutes. Do not share it to anyone - URMI AI",
},
},
"responses": {
"200": {
"description": "SMS accepted by the gateway",
"example": {
"success": True,
"job_id": "123456789",
"message_id": "987654321",
"to": "919999999999",
"sender_id": "URMIAI",
"template_id": "00000000-0000-0000-0000-000000000001",
"template_name": "URMI_OTP_VERIFICATION",
"dlt_template_id": "1777178797989033323",
},
},
"401": {"description": "Missing or invalid API key"},
"404": {"description": "Sender or template not found for this account"},
"422": {"description": "Template mismatch or gateway rejection"},
"502": {"description": "SMS gateway request failed"},
"503": {"description": "SMS gateway credentials are not configured"},
},
}
class SendMessageRequest(BaseModel):
sender_id: str = Field(min_length=3, max_length=11)
template_id: UUID
to: str = Field(min_length=10, max_length=15)
message: str = Field(min_length=1, max_length=1000)
@router.get("/docs/send")
def send_message_docs() -> dict:
return SEND_SMS_EXPLORER
def _raise_send_error(error: SendMessageError) -> None:
raise HTTPException(status_code=error.status_code, detail=error.detail)
@router.post("/send")
def send_message(
payload: SendMessageRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_api_user),
) -> dict:
try:
result = send_template_messages(
db,
user,
sender_id=payload.sender_id,
template_id=payload.template_id,
message=payload.message,
recipients=[payload.to],
)
except SendMessageError as exc:
_raise_send_error(exc)
first = result["results"][0]
return {
"success": result["success"],
"job_id": first["job_id"],
"message_id": first["message_id"],
"to": first["to"],
"sender_id": result["sender_id"],
"template_id": result["template_id"],
"template_name": result["template_name"],
"dlt_template_id": result["dlt_template_id"],
}

View file

@ -0,0 +1,211 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .auth import current_user
from .database import get_db
from .message_send_service import SendMessageError, send_template_messages
from .models import SenderConfig, TemplateConfig, UserAccount
from .sender_validators import (
validate_dlt_entity_id,
validate_purpose,
validate_sender_id,
validate_sender_type,
)
from .template_validators import (
validate_dlt_template_id,
validate_template_name,
validate_template_text,
)
router = APIRouter(prefix="/api/v1/messaging", tags=["messaging"])
class CreateSenderRequest(BaseModel):
sender_id: str = Field(min_length=3, max_length=11)
sender_type: str
purpose: str = Field(min_length=10, max_length=500)
dlt_entity_id: str = Field(min_length=12, max_length=25)
class CreateTemplateRequest(BaseModel):
sender_config_id: str
template_name: str = Field(min_length=3, max_length=40)
template_text: str = Field(min_length=10, max_length=1000)
dlt_template_id: str = Field(min_length=12, max_length=25)
def serialize_sender(sender: SenderConfig) -> dict:
return {
"id": str(sender.id),
"sender_id": sender.sender_id,
"sender_type": sender.sender_type,
"purpose": sender.purpose,
"dlt_entity_id": sender.dlt_entity_id,
"status": sender.status,
"review_note": sender.review_note,
"created_at": sender.created_at,
"reviewed_at": sender.reviewed_at,
}
def serialize_template(template: TemplateConfig) -> dict:
return {
"id": str(template.id),
"sender_config_id": str(template.sender_config_id),
"sender_id": template.sender_config.sender_id,
"template_name": template.template_name,
"template_text": template.template_text,
"dlt_template_id": template.dlt_template_id,
"status": template.status,
"review_note": template.review_note,
"created_at": template.created_at,
"reviewed_at": template.reviewed_at,
}
@router.get("/senders")
def list_senders(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
senders = db.scalars(
select(SenderConfig)
.where(SenderConfig.user_id == user.id)
.order_by(SenderConfig.created_at.desc())
).all()
return [serialize_sender(sender) for sender in senders]
@router.post("/senders", status_code=status.HTTP_201_CREATED)
def create_sender(
payload: CreateSenderRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
try:
sender_id = validate_sender_id(payload.sender_id)
sender_type = validate_sender_type(payload.sender_type)
purpose = validate_purpose(payload.purpose)
dlt_entity_id = validate_dlt_entity_id(payload.dlt_entity_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
existing = db.scalar(
select(SenderConfig).where(
SenderConfig.user_id == user.id,
SenderConfig.sender_id == sender_id,
)
)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Sender ID already exists")
sender = SenderConfig(
user_id=user.id,
sender_id=sender_id,
sender_type=sender_type,
purpose=purpose,
dlt_entity_id=dlt_entity_id,
status="pending",
)
db.add(sender)
db.commit()
db.refresh(sender)
return serialize_sender(sender)
@router.get("/templates")
def list_templates(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
templates = db.scalars(
select(TemplateConfig)
.options(joinedload(TemplateConfig.sender_config))
.where(TemplateConfig.user_id == user.id)
.order_by(TemplateConfig.created_at.desc())
).unique().all()
return [serialize_template(template) for template in templates]
@router.post("/templates", status_code=status.HTTP_201_CREATED)
def create_template(
payload: CreateTemplateRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
try:
template_name = validate_template_name(payload.template_name)
template_text = validate_template_text(payload.template_text)
dlt_template_id = validate_dlt_template_id(payload.dlt_template_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
sender = db.scalar(
select(SenderConfig).where(
SenderConfig.id == payload.sender_config_id,
SenderConfig.user_id == user.id,
)
)
if not sender:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sender ID not found")
if sender.status != "approved":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Template can only be linked to an approved sender ID",
)
existing = db.scalar(
select(TemplateConfig).where(
TemplateConfig.user_id == user.id,
TemplateConfig.template_name == template_name,
)
)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Template name already exists")
template = TemplateConfig(
user_id=user.id,
sender_config_id=sender.id,
template_name=template_name,
template_text=template_text,
dlt_template_id=dlt_template_id,
status="pending",
)
db.add(template)
db.commit()
db.refresh(template)
db.refresh(sender)
template.sender_config = sender
return serialize_template(template)
class ComposeSendRequest(BaseModel):
sender_config_id: UUID
template_id: UUID
numbers: str = Field(min_length=10, max_length=5000)
message: str = Field(min_length=1, max_length=1000)
campaign_name: str | None = Field(default=None, max_length=120)
@router.post("/send")
def compose_send_message(
payload: ComposeSendRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
try:
return send_template_messages(
db,
user,
sender_config_id=payload.sender_config_id,
template_id=payload.template_id,
message=payload.message,
recipients=[part.strip() for part in payload.numbers.split(",") if part.strip()],
campaign_name=payload.campaign_name,
)
except SendMessageError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc

View file

@ -0,0 +1,79 @@
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from .auth import current_user
from .database import get_db
from .models import PhoneNumber, UserAccount
router = APIRouter(prefix="/api/v1/numbers", tags=["numbers"])
def serialize_phone_number(number: PhoneNumber) -> dict:
return {
"id": str(number.id),
"phone_number": number.phone_number,
"monthly_number_price": float(number.monthly_number_price),
"inbound_price": float(number.inbound_price),
"outbound_price": float(number.outbound_price),
"currency": number.currency,
"status": number.status,
"acquisition_type": number.acquisition_type,
"assigned_at": number.assigned_at,
"created_at": number.created_at,
}
@router.get("/catalog")
def list_catalog(
db: Session = Depends(get_db),
_: UserAccount = Depends(current_user),
) -> list[dict]:
numbers = db.scalars(
select(PhoneNumber)
.where(PhoneNumber.status == "available", PhoneNumber.user_id.is_(None))
.order_by(PhoneNumber.created_at.desc())
).all()
return [serialize_phone_number(number) for number in numbers]
@router.get("/mine")
def list_my_numbers(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
numbers = db.scalars(
select(PhoneNumber)
.where(PhoneNumber.user_id == user.id, PhoneNumber.status == "assigned")
.order_by(PhoneNumber.assigned_at.desc())
).all()
return [serialize_phone_number(number) for number in numbers]
@router.post("/{number_id}/purchase")
def purchase_number(
number_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
number = db.scalar(
select(PhoneNumber).where(
PhoneNumber.id == number_id,
PhoneNumber.status == "available",
PhoneNumber.user_id.is_(None),
)
)
if not number:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Number is not available")
now = datetime.now(timezone.utc)
number.user_id = user.id
number.status = "assigned"
number.acquisition_type = "purchased"
number.assigned_at = now
db.commit()
db.refresh(number)
return serialize_phone_number(number)

18
backend/app/routes_otp.py Normal file
View file

@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .api_key_auth import current_api_user
from .database import get_db
from .models import UserAccount
from .otp_service import GenerateOtpPayload, generate_otp_for_user
router = APIRouter(prefix="/api/v1/otp", tags=["otp"])
@router.post("/generate")
def generate_otp(
payload: GenerateOtpPayload,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_api_user),
) -> dict:
return generate_otp_for_user(db, user, payload)

View file

@ -0,0 +1,176 @@
import re
from datetime import datetime, timezone
from urllib.parse import urlparse
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .auth import current_user
from .database import get_db
from .models import UrmiOtpProject, UserAccount
from .otp_service import GenerateOtpPayload, generate_otp_for_user
from .urmiotp_catalog import OTP_API_DOCUMENTATION, render_templates_for_project
router = APIRouter(prefix="/api/v1/urmiotp", tags=["urmiotp"])
PROJECT_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]{4,28}[a-z0-9]$")
MESSAGE_LABEL_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 -]{0,18}[A-Za-z0-9]$|^[A-Za-z0-9]$")
RESERVED_TERMS = ("google", "goog", "gcp", "ssl")
class CreateUrmiOtpProjectRequest(BaseModel):
project_name: str = Field(min_length=6, max_length=30)
message_label: str = Field(min_length=1, max_length=20)
app_type: str = Field(pattern="^(web|mobile)$")
app_url: str = Field(min_length=8, max_length=1000)
purpose: str = Field(min_length=20, max_length=1000)
terms_accepted: bool
def validate_project_name(value: str) -> str:
project_name = value.strip().lower()
if not PROJECT_ID_PATTERN.fullmatch(project_name):
raise ValueError(
"Project name must be 6–30 lowercase letters, numbers, or hyphens; "
"start with a letter and end with a letter or number"
)
if "--" in project_name:
raise ValueError("Project name cannot contain consecutive hyphens")
if any(term in project_name for term in RESERVED_TERMS):
raise ValueError("Project name contains a reserved term")
return project_name
def validate_message_label(value: str) -> str:
message_label = value.strip()
if not message_label or len(message_label) > 20:
raise ValueError("Message label must be 1–20 characters")
if not MESSAGE_LABEL_PATTERN.fullmatch(message_label):
raise ValueError(
"Message label may only contain letters, numbers, spaces, and hyphens; "
"start and end with a letter or number"
)
return message_label
def validate_app_url(value: str, app_type: str) -> str:
app_url = value.strip()
parsed = urlparse(app_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("Enter a valid HTTPS website, domain, or app-store URL")
if app_type == "mobile" and not any(
host in parsed.netloc.lower()
for host in ("play.google.com", "apps.apple.com", "appgallery.huawei.com")
):
raise ValueError("Mobile apps require a Google Play, Apple App Store, or AppGallery URL")
return app_url
def serialize_project(project: UrmiOtpProject) -> dict:
return {
"id": str(project.id),
"project_name": project.project_name,
"message_label": project.message_label,
"app_type": project.app_type,
"app_url": project.app_url,
"purpose": project.purpose,
"status": project.status,
"review_note": project.review_note,
"terms_accepted_at": project.terms_accepted_at,
"created_at": project.created_at,
"reviewed_at": project.reviewed_at,
}
@router.get("/projects")
def list_projects(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
projects = db.scalars(
select(UrmiOtpProject)
.where(UrmiOtpProject.user_id == user.id)
.order_by(UrmiOtpProject.created_at.desc())
).all()
return [serialize_project(project) for project in projects]
@router.post("/projects", status_code=status.HTTP_201_CREATED)
def create_project(
payload: CreateUrmiOtpProjectRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
if not payload.terms_accepted:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="You must agree to the UrmiOTP terms and conditions",
)
try:
project_name = validate_project_name(payload.project_name)
message_label = validate_message_label(payload.message_label)
app_url = validate_app_url(payload.app_url, payload.app_type)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
project = UrmiOtpProject(
user_id=user.id,
project_name=project_name,
message_label=message_label,
app_type=payload.app_type,
app_url=app_url,
purpose=payload.purpose.strip(),
terms_accepted_at=datetime.now(timezone.utc),
status="pending",
)
db.add(project)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This project name is already in use",
) from exc
db.refresh(project)
return serialize_project(project)
@router.get("/projects/{project_id}/documentation")
def project_documentation(
project_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
project = db.scalar(
select(UrmiOtpProject).where(
UrmiOtpProject.id == project_id,
UrmiOtpProject.user_id == user.id,
)
)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UrmiOTP project not found")
if project.status != "approved":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API documentation is available after project approval",
)
return {
"project": serialize_project(project),
"templates": render_templates_for_project(project.message_label),
"api": OTP_API_DOCUMENTATION,
}
@router.post("/generate")
def generate_otp_console(
payload: GenerateOtpPayload,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
"""Console/session OTP generate — writes to otp_message_logs for Logs & Analytics."""
return generate_otp_for_user(db, user, payload)

435
backend/app/routes_voice.py Normal file
View file

@ -0,0 +1,435 @@
import json
import logging
import uuid
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from .auth import COOKIE_NAME, decode_token
from .config import settings
from .database import SessionLocal, get_db
from .models import UserAccount, VoiceCall
from .voice.bonvoice_client import BonvoiceError
from .voice.call_service import (
create_inbound_call_record,
create_outbound_call,
finalize_call_async,
refresh_call_lifecycle,
serialize_call,
)
from .voice.bonvoice_media import extract_start_metadata, parse_event, run_console_raw_relay
from .voice.call_resolver import find_inbound_by_did, find_pending_outbound_call
from .voice.presence import console_presence
from .voice.public_url import unified_voicebot_url
from .endpoint_security import signed_headers
from .voice.relay_config import resolve_voice_socket
from .voice.session_store import update_session
from .voice.tunnel import relay_provider_to_upstream
from .voice.validators import normalize_phone_digits, require_owned_did
from .voice_auth import current_voice_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/voice", tags=["voice"])
VOICE_API_DOCS = {
"title": "Initiate outbound call",
"summary": "Place an outbound call from your Urmi number. Live audio is relayed to the voice socket linked to that number.",
"method": "POST",
"path": "/api/v1/voice/calls",
"authentication": {
"header": "Authorization",
"format": "Bearer urmi_live_<key>",
"alternate_header": "X-Api-Key",
},
"request_body": {
"to": {
"type": "string",
"required": True,
"example": "9149154337",
"description": "Destination number with country code",
},
"from": {
"type": "string",
"required": True,
"example": "7946350706",
"description": "Your Urmi number (must be assigned to your account)",
},
"bridge_mode": {
"type": "string",
"required": False,
"example": "upstream",
"description": "Use upstream for production. Console mode is for internal testing only.",
},
},
"response_example": {
"id": "00000000-0000-0000-0000-000000000001",
"status": "initiated",
"direction": "outbound",
"from": "7946350706",
"to": "9149154337",
},
"relay": {
"principle": "Urmi is a transparent relay and authentication gateway. We validate your API key, place the call, and forward raw WebSocket frames. We do not decode, transcode, buffer, or inject audio.",
"latency": "Frames are forwarded as they arrive to keep latency as low as possible. All speech processing happens on your server.",
},
"outbound_flow": [
"Link your Urmi number to a voice socket under Webhooks & Sockets.",
"POST /api/v1/voice/calls with from, to, and bridge_mode upstream.",
"Urmi validates the number belongs to your account and places the call.",
"When the callee answers, Urmi opens your voice socket and relays connected, start, media, and end frames unchanged.",
"Poll GET /api/v1/voice/calls/{call_id} for call status.",
],
"endpoints": [
{"method": "POST", "path": "/api/v1/voice/calls", "description": "Place an outbound call"},
{"method": "GET", "path": "/api/v1/voice/calls/{call_id}", "description": "Get call status"},
],
}
class CreateCallRequest(BaseModel):
to: str = Field(min_length=10, max_length=15)
from_number: str = Field(alias="from", min_length=10, max_length=15)
bridge_mode: str = Field(default="upstream", pattern="^(upstream|console)$")
metadata: dict | None = None
model_config = {"populate_by_name": True}
def _provider_ip_allowed(client_host: str | None) -> bool:
if settings.environment == "development":
return True
allowlist = [item.strip() for item in settings.bonvoice_media_allowlist.split(",") if item.strip()]
if not allowlist:
return False
if not client_host:
return False
return client_host in allowlist
def _websocket_user(db: Session, websocket: WebSocket) -> UserAccount | None:
token = websocket.cookies.get(COOKIE_NAME)
if not token:
return None
try:
payload = decode_token(token)
user_id = uuid.UUID(payload["sub"])
except (ValueError, KeyError):
return None
user = db.scalar(select(UserAccount).where(UserAccount.id == user_id))
if user and user.status == "active":
return user
return None
@router.get("/docs")
def voice_docs() -> dict:
return VOICE_API_DOCS
@router.post("/calls")
async def initiate_call(
payload: CreateCallRequest,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_voice_user),
) -> dict:
bridge_mode = payload.bridge_mode
if bridge_mode == "console":
if not await console_presence.is_present(user.id):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Open /console/voice with an active connection before placing a console-bridged call",
)
upstream = None
else:
number = require_owned_did(db, user, payload.from_number)
endpoint = resolve_voice_socket(db, phone_number=number)
if not endpoint:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Link this number to an enabled voice socket under Webhooks & Sockets",
)
upstream = endpoint.url
try:
call = await create_outbound_call(
db,
user=user,
from_value=payload.from_number,
to_value=payload.to,
upstream=upstream,
bridge_mode=bridge_mode,
metadata=payload.metadata,
)
except BonvoiceError as exc:
raise HTTPException(status_code=exc.status_code or 502, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
body = serialize_call(call)
return body
@router.get("/calls/{call_id}")
async def get_call(
call_id: UUID,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_voice_user),
) -> dict:
call = db.scalar(
select(VoiceCall).where(VoiceCall.id == call_id, VoiceCall.user_id == user.id)
)
if not call:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Call not found")
call = await refresh_call_lifecycle(db, call)
body = serialize_call(call)
return body
@router.websocket("/console")
async def voice_console(websocket: WebSocket) -> None:
await websocket.accept()
db = SessionLocal()
user = _websocket_user(db, websocket)
if not user:
await websocket.close(code=4401, reason="Authentication required")
db.close()
return
await console_presence.register(user.id, websocket)
await websocket.send_text(
json.dumps(
{
"event": "console.ready",
"message": "Console audio bridge active for outbound calls.",
"media_url": unified_voicebot_url(),
}
)
)
try:
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
if message["type"] != "websocket.receive":
continue
text = message.get("text")
if text:
await console_presence.forward_from_console(user.id, text)
except WebSocketDisconnect:
pass
finally:
await console_presence.unregister(user.id, websocket)
db.close()
async def _run_bonvoice_media_stream(websocket: WebSocket) -> None:
"""Match Bonvoice media to outbound or inbound, then raw-relay to upstream or console."""
await websocket.accept()
db = SessionLocal()
call: VoiceCall | None = None
console_ws: WebSocket | None = None
buffered: list[str] = []
async def on_end() -> None:
if call:
await update_session(call.session_token, status="ended")
await finalize_call_async(db, call)
if console_ws:
try:
await console_ws.send_text(
json.dumps(
{
"event": "call.ended",
"call_id": str(call.id),
"direction": call.direction,
}
)
)
except RuntimeError:
pass
try:
while True:
raw = await websocket.receive_text()
buffered.append(raw)
message = parse_event(raw)
if not message or message.get("event") != "start":
continue
meta = extract_start_metadata(message)
logger.info(
"Bonvoice media start stream_id=%s call_id=%s from=%s to=%s",
meta["stream_id"],
meta["call_id"],
meta["from"],
meta["to"],
)
call = find_pending_outbound_call(db, from_value=meta["from"], to_value=meta["to"])
if call:
call.status = "active"
call.answered_at = datetime.now(timezone.utc)
call.stream_id = meta["stream_id_str"] or None
call.call_id = meta["call_id"] or None
db.commit()
db.refresh(call)
await update_session(call.session_token, status="active", stream_id=meta["stream_id_str"])
if call.bridge_mode == "upstream":
endpoint = resolve_voice_socket(db, phone_number=call.phone_number)
if not endpoint:
await websocket.close(code=4403, reason="No upstream URL")
break
try:
await relay_provider_to_upstream(
websocket,
endpoint.url,
initial_frames=buffered,
additional_headers=signed_headers("socket", endpoint.id),
)
except Exception as exc:
logger.error("Upstream tunnel failed for call %s: %s", call.id, exc)
call.status = "failed"
call.error_message = f"Upstream connection failed: {exc}"
call.ended_at = datetime.now(timezone.utc)
db.commit()
break
if call.bridge_mode == "console":
console_ws = await console_presence.get_connection(call.user_id)
if not console_ws:
logger.warning("Console not connected for outbound call %s", call.id)
await websocket.close(code=4403, reason="Console not active")
break
await console_ws.send_text(
json.dumps(
{
"event": "call.outbound",
"call_id": str(call.id),
"from": call.from_did,
"to": call.to_number,
"stream_id": meta["stream_id"],
}
)
)
await run_console_raw_relay(
websocket,
call.user_id,
console_ws,
initial_frames=buffered,
on_end=on_end,
)
call = None
break
await websocket.close(code=4403, reason="Unsupported bridge mode")
break
phone_number = find_inbound_by_did(db, meta["to"])
if not phone_number:
logger.warning(
"No outbound or inbound match for from=%s to=%s",
meta["from"],
meta["to"],
)
did = normalize_phone_digits(meta["to"])
if len(did) == 10:
stale = db.scalar(
select(VoiceCall)
.where(
VoiceCall.direction == "outbound",
VoiceCall.status == "initiated",
VoiceCall.from_did == did,
)
.order_by(VoiceCall.created_at.desc())
)
if stale:
stale.status = "failed"
stale.error_message = "Media session could not be matched to pending call"
stale.ended_at = datetime.now(timezone.utc)
db.commit()
await websocket.close(code=4403, reason="No matching call or DID")
break
endpoint = resolve_voice_socket(db, phone_number=phone_number)
if not endpoint:
logger.warning("No upstream configured for inbound DID %s", phone_number.phone_number)
await websocket.close(code=4403, reason="No upstream configured")
break
caller = normalize_phone_digits(meta["from"])
call = create_inbound_call_record(
db,
phone_number=phone_number,
caller=caller,
upstream=endpoint.url,
stream_id=meta["stream_id_str"] or None,
provider_call_id=meta["call_id"] or None,
)
try:
await relay_provider_to_upstream(
websocket,
endpoint.url,
initial_frames=buffered,
additional_headers=signed_headers("socket", endpoint.id),
)
except Exception as exc:
logger.error("Inbound upstream tunnel failed for call %s: %s", call.id, exc)
call.status = "failed"
call.error_message = f"Upstream connection failed: {exc}"
call.ended_at = datetime.now(timezone.utc)
db.commit()
break
except WebSocketDisconnect:
logger.info("Bonvoice disconnected call=%s", call.id if call else None)
except RuntimeError as exc:
if "WebSocket is not connected" not in str(exc):
raise
finally:
if call:
await on_end()
db.close()
async def _run_outbound_media_stream(websocket: WebSocket) -> None:
await _run_bonvoice_media_stream(websocket)
@router.websocket("/ws/media")
async def bonvoice_ws_media(websocket: WebSocket) -> None:
client_host = websocket.client.host if websocket.client else None
if not _provider_ip_allowed(client_host):
await websocket.close(code=4403, reason="Provider IP not allowed")
return
logger.info("Bonvoice WebSocket connect /ws/media from %s", client_host)
await _run_outbound_media_stream(websocket)
@router.websocket("/media/{session_token}")
async def provider_outbound_media(websocket: WebSocket, session_token: str) -> None:
client_host = websocket.client.host if websocket.client else None
if not _provider_ip_allowed(client_host):
await websocket.close(code=4403, reason="Provider IP not allowed")
return
logger.info("Bonvoice WebSocket connect /media/%s from %s", session_token[:8], client_host)
await _run_outbound_media_stream(websocket)
@router.post("/ws/media")
async def ws_media_post_debug(request: Request) -> dict:
body = await request.body()
text = body.decode("utf-8", errors="replace")
logger.warning("POST /ws/media (not WebSocket). Body: %s", text[:500])
return {
"error": "WebSocket required",
"hint": f"Use wss://<host>/api/v1/voice/ws/media (not https://)",
"received": text[:200] if text else None,
}

27
backend/app/schemas.py Normal file
View file

@ -0,0 +1,27 @@
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
name: str = Field(min_length=2, max_length=160)
email: EmailStr
password: str = Field(min_length=10, max_length=128)
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=128)
class SetPasswordRequest(BaseModel):
password: str = Field(min_length=10, max_length=128)
current_password: str | None = Field(default=None, max_length=128)
class UserResponse(BaseModel):
id: str
email: EmailStr
name: str
avatar_url: str | None
email_verified: bool
has_password: bool
providers: list[str]

View file

@ -0,0 +1,38 @@
import re
SENDER_ID_PATTERN = re.compile(r"^[A-Z0-9]{3,11}$")
DLT_ENTITY_PATTERN = re.compile(r"^\d{12,25}$")
def normalize_sender_id(value: str) -> str:
return value.strip().upper()
def validate_sender_id(value: str) -> str:
sender_id = normalize_sender_id(value)
if not SENDER_ID_PATTERN.match(sender_id):
raise ValueError("Sender ID must be 3–11 uppercase letters or numbers")
return sender_id
def validate_sender_type(value: str) -> str:
normalized = value.strip().lower()
if normalized not in {"promotional", "transactional"}:
raise ValueError("Type must be promotional or transactional")
return normalized
def validate_purpose(value: str) -> str:
purpose = value.strip()
if len(purpose) < 10:
raise ValueError("Purpose must be at least 10 characters")
if len(purpose) > 500:
raise ValueError("Purpose must be 500 characters or fewer")
return purpose
def validate_dlt_entity_id(value: str) -> str:
entity_id = value.strip()
if not DLT_ENTITY_PATTERN.match(entity_id):
raise ValueError("DLT Entity ID must be 12–25 digits")
return entity_id

View file

@ -0,0 +1,68 @@
import asyncio
import json
import logging
import uuid
from datetime import datetime, timezone
import httpx
from .endpoint_security import payload_signature
from .models import SmsDeliveryWebhook
logger = logging.getLogger(__name__)
def _event_body(data: dict) -> bytes:
payload = {
"id": f"evt_{uuid.uuid4().hex}",
"event": "message.delivery.updated",
"created_at": datetime.now(timezone.utc).isoformat(),
"data": data,
}
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
async def deliver_report(
endpoint: SmsDeliveryWebhook,
data: dict,
*,
attempts: int = 3,
) -> int:
body = _event_body(data)
headers = {
"Content-Type": "application/json",
"User-Agent": "Urmi-Webhooks/1.0",
"X-Urmi-Event": "message.delivery.updated",
"X-Urmi-Endpoint-Id": str(endpoint.id),
"X-Urmi-Signature": payload_signature("webhook", endpoint.id, body),
}
last_error: Exception | None = None
async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client:
for attempt in range(attempts):
try:
response = await client.post(endpoint.url, content=body, headers=headers)
response.raise_for_status()
return response.status_code
except (httpx.HTTPError, httpx.TimeoutException) as exc:
last_error = exc
if attempt + 1 < attempts:
await asyncio.sleep(2**attempt)
logger.warning("SMS delivery webhook failed endpoint=%s: %s", endpoint.id, last_error)
raise RuntimeError("Webhook endpoint did not return a successful response") from last_error
async def deliver_test_report(endpoint: SmsDeliveryWebhook) -> int:
if not endpoint.enabled:
raise RuntimeError("Webhook is disabled")
return await deliver_report(
endpoint,
{
"message_id": f"msg_test_{uuid.uuid4().hex[:16]}",
"to": "+919999999999",
"from": endpoint.phone_number.phone_number if endpoint.phone_number else "test",
"status": "delivered",
"updated_at": datetime.now(timezone.utc).isoformat(),
"test": True,
},
attempts=1,
)

View file

@ -0,0 +1,16 @@
import re
PLACEHOLDER_PATTERN = re.compile(r"\{#(?:var|num)#\}")
def message_matches_template(template_text: str, message: str) -> bool:
message = message.strip()
if not message:
return False
parts = PLACEHOLDER_PATTERN.split(template_text.strip())
pattern = ""
for index, part in enumerate(parts):
pattern += re.escape(part)
if index < len(parts) - 1:
pattern += r".+?"
return re.fullmatch(pattern, message, flags=re.DOTALL) is not None

View file

@ -0,0 +1,32 @@
import re
TEMPLATE_NAME_PATTERN = re.compile(r"^[A-Z0-9_]{3,40}$")
DLT_TEMPLATE_ID_PATTERN = re.compile(r"^\d{12,25}$")
VARIABLE_TOKEN = "{#var#}"
def normalize_template_name(value: str) -> str:
return value.strip().upper().replace(" ", "_")
def validate_template_name(value: str) -> str:
name = normalize_template_name(value)
if not TEMPLATE_NAME_PATTERN.match(name):
raise ValueError("Template name must be 3–40 uppercase letters, numbers, or underscores")
return name
def validate_template_text(value: str) -> str:
text = value.strip()
if len(text) < 10:
raise ValueError("Template must be at least 10 characters")
if len(text) > 1000:
raise ValueError("Template must be 1000 characters or fewer")
return text
def validate_dlt_template_id(value: str) -> str:
template_id = value.strip()
if not DLT_TEMPLATE_ID_PATTERN.match(template_id):
raise ValueError("DLT Template ID must be 12–25 digits")
return template_id

View file

@ -0,0 +1,92 @@
from dataclasses import dataclass
from enum import Enum
from typing import Any
import httpx
from .config import settings
class SmsChannel(str, Enum):
TRANSACTIONAL = "Trans"
PROMOTIONAL = "Promo"
@dataclass
class SendResult:
success: bool
job_id: str | None
message_id: str | None
number: str | None
raw: dict[str, Any]
class UltronsmsClient:
def __init__(
self,
base_url: str | None = None,
user: str | None = None,
password: str | None = None,
) -> None:
self.base_url = (base_url or settings.ultronsms_base_url).rstrip("/")
self.user = user or settings.ultronsms_user
self.password = password or settings.ultronsms_password
@staticmethod
def normalize_phone(number: str) -> str:
digits = "".join(character for character in number if character.isdigit())
if len(digits) == 10:
return f"91{digits}"
return digits
@staticmethod
def route_for_channel(channel: SmsChannel) -> str:
return "2" if channel == SmsChannel.TRANSACTIONAL else "1"
@staticmethod
def channel_for_sender_type(sender_type: str) -> SmsChannel:
return SmsChannel.PROMOTIONAL if sender_type == "promotional" else SmsChannel.TRANSACTIONAL
def _get(self, path: str, params: dict[str, str]) -> dict[str, Any]:
if not self.user or not self.password:
raise RuntimeError("Ultronsms credentials are not configured")
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
response = client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
return response.json()
def send_template_sms(
self,
mobile: str,
message: str,
*,
sender_id: str,
pe_id: str,
dlt_template_id: str,
sender_type: str,
dcs: str = "0",
flash_sms: str = "0",
) -> SendResult:
channel = self.channel_for_sender_type(sender_type)
params = {
"user": self.user,
"password": self.password,
"senderid": sender_id,
"channel": channel.value,
"route": self.route_for_channel(channel),
"DCS": dcs,
"flashsms": flash_sms,
"number": self.normalize_phone(mobile),
"text": message,
"peid": pe_id,
"dlttemplateid": dlt_template_id,
}
raw = self._get("/api/mt/SendSMS", params)
message_data = (raw.get("MessageData") or [{}])[0]
return SendResult(
success=raw.get("ErrorCode") == "000",
job_id=raw.get("JobId"),
message_id=message_data.get("MessageId"),
number=message_data.get("Number"),
raw=raw,
)

View file

@ -0,0 +1,93 @@
OTP_TEMPLATES = (
{
"type": "general_verification",
"name": "URMI General Verification OTP",
"content": "Your URMI verification code for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
"sample": "Your URMI verification code for {message_label} is 583921. It is valid for 10 minutes. - URMI",
"uses": [
"Phone or email verification", "Signup and registration", "KYC or identity verification",
"Invitation acceptance", "General verification",
],
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
},
{
"type": "login_authentication",
"name": "URMI Login Authentication OTP",
"content": "Your URMI login verification code for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
"sample": "Your URMI login verification code for {message_label} is 583921. It is valid for 10 minutes. - URMI",
"uses": ["Normal or passwordless login", "MFA and 2FA", "Admin login", "Authentication"],
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
},
{
"type": "account_recovery",
"name": "URMI Account Recovery OTP",
"content": "Your URMI account recovery code for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
"sample": "Your URMI account recovery code for {message_label} is 583921. It is valid for 10 minutes. - URMI",
"uses": ["Forgot password", "Password reset", "Account or locked-account recovery"],
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
},
{
"type": "account_change",
"name": "URMI Account Change OTP",
"content": "Your URMI verification code to update your account for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
"sample": "Your URMI verification code to update your account for {message_label} is 583921. It is valid for 10 minutes. - URMI",
"uses": ["Change mobile number or email", "Change password", "MFA or security setting changes"],
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
},
{
"type": "action_authorization",
"name": "URMI Action Authorization OTP",
"content": "Your URMI authorization code for this action on {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
"sample": "Your URMI authorization code for this action on {message_label} is 583921. It is valid for 10 minutes. - URMI",
"uses": ["Sensitive or high-risk operations", "API key changes", "Account deletion", "Ownership changes"],
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
},
{
"type": "transaction_verification",
"name": "URMI Transaction Verification OTP",
"content": "Your URMI transaction verification code for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
"sample": "Your URMI transaction verification code for {message_label} is 583921. It is valid for 10 minutes. - URMI",
"uses": ["Payment authorization", "Transfers and withdrawals", "Financial transaction confirmation"],
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
},
{
"type": "device_verification",
"name": "URMI Device Verification OTP",
"content": "Your URMI device verification code for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
"sample": "Your URMI device verification code for {message_label} is 583921. It is valid for 10 minutes. - URMI",
"uses": ["Device registration or pairing", "Device activation", "IoT ownership verification", "Device transfer"],
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
},
)
def render_templates_for_project(message_label: str) -> list[dict]:
rendered: list[dict] = []
for template in OTP_TEMPLATES:
rendered.append(
{
**template,
"content": template["content"].replace("{message_label}", message_label),
"sample": template["sample"].replace("{message_label}", message_label),
}
)
return rendered
OTP_API_DOCUMENTATION = {
"method": "POST",
"path": "/api/v1/otp/generate",
"implemented": True,
"summary": "Generate and deliver an OTP using one of Urmi's unified verification templates.",
"parameters": {
"project_name": "Your approved UrmiOTP project name.",
"template_type": "One of the supported template type identifiers.",
"variables": "Values for OTP and validity duration ({#var#} placeholders), in template order.",
"message_label": "Registered with your project; fills the {message_label} slot as your product identification. URMI branding stays fixed.",
},
"example": {
"project_name": "acme-login",
"template_type": "login_authentication",
"variables": {"otp": "583921", "validity_minutes": "10"},
},
}

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,40 @@
"""PCM helpers for Bonvoice media (from test_bonvoice/audio_utils.py)."""
import audioop
import wave
from pathlib import Path
TARGET_RATE = 8000
CHUNK_SIZE = 320
def wav_to_pcm8000_mono(path: str | Path) -> bytes:
path = Path(path)
with wave.open(str(path), "rb") as wf:
channels = wf.getnchannels()
sample_width = wf.getsampwidth()
frame_rate = wf.getframerate()
pcm = wf.readframes(wf.getnframes())
if sample_width != 2:
raise ValueError(f"Expected 16-bit audio, got {sample_width * 8}-bit")
if channels == 2:
pcm = audioop.tomono(pcm, 2, 0.5, 0.5)
elif channels != 1:
raise ValueError(f"Unsupported channel count: {channels}")
if frame_rate != TARGET_RATE:
pcm, _ = audioop.ratecv(pcm, 2, 1, frame_rate, TARGET_RATE, None)
return pcm
def chunk_pcm(pcm: bytes, chunk_size: int = CHUNK_SIZE) -> list[bytes]:
chunks: list[bytes] = []
for i in range(0, len(pcm), chunk_size):
chunk = pcm[i : i + chunk_size]
if len(chunk) < chunk_size:
chunk = chunk + b"\x00" * (chunk_size - len(chunk))
chunks.append(chunk)
return chunks

View file

@ -0,0 +1,51 @@
import uuid
import httpx
from ..config import settings
class BonvoiceError(Exception):
def __init__(self, message: str, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
async def initiate_outbound_call(
*,
destination: str,
leg_a_caller_id: str,
voicebot_url: str,
event_id: str | None = None,
) -> dict:
if not settings.bonvoice_token:
raise BonvoiceError("Bonvoice credentials are not configured", status_code=503)
payload = {
"autocallType": "5",
"destination": destination,
"legACallerID": leg_a_caller_id,
"eventID": event_id or uuid.uuid4().hex.upper(),
"voicebotProvider": "BONVOICE",
"voicebotURL": voicebot_url,
}
headers = {
"Authorization": f"Token {settings.bonvoice_token}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(settings.bonvoice_api_url, headers=headers, json=payload)
if response.status_code >= 400:
raise BonvoiceError(
f"Bonvoice API rejected the call ({response.status_code})",
status_code=502,
)
body = response.json()
if body.get("responseCode") not in (200, "200"):
raise BonvoiceError(
body.get("responseDescription") or "Bonvoice API returned an error",
status_code=502,
)
return body

View file

@ -0,0 +1,76 @@
"""Bonvoice media — raw frame relay only (no PCM processing on the API)."""
import asyncio
import json
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from uuid import UUID
from fastapi import WebSocket, WebSocketDisconnect
from .presence import console_presence
logger = logging.getLogger(__name__)
OnEndCallback = Callable[[], Awaitable[None]]
def parse_event(raw: str) -> dict[str, Any] | None:
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return None
return payload if isinstance(payload, dict) else None
def extract_start_metadata(message: dict[str, Any]) -> dict[str, Any]:
data = message.get("data") or message.get("start") or {}
stream_id = data.get("stream_id") if data.get("stream_id") is not None else message.get("stream_id")
return {
"stream_id": stream_id,
"stream_id_str": str(stream_id) if stream_id is not None else "",
"call_id": str(data.get("call_id") or message.get("call_id") or ""),
"from": str(data.get("from") or message.get("from") or ""),
"to": str(data.get("to") or message.get("to") or ""),
}
async def run_console_raw_relay(
provider: WebSocket,
user_id: UUID,
console_ws: WebSocket,
*,
initial_frames: list[str] | None = None,
on_end: OnEndCallback | None = None,
) -> None:
"""Console test bridge — forward raw JSON frames both ways (no server audio processing)."""
stop = asyncio.Event()
relay_queue = await console_presence.start_relay(user_id)
for raw in initial_frames or []:
await console_ws.send_text(raw)
async def console_to_provider() -> None:
try:
while not stop.is_set():
raw = await relay_queue.get()
await provider.send_text(raw)
except (WebSocketDisconnect, RuntimeError):
stop.set()
async def provider_to_console() -> None:
try:
while not stop.is_set():
raw = await provider.receive_text()
await console_ws.send_text(raw)
except (WebSocketDisconnect, RuntimeError):
stop.set()
try:
await asyncio.gather(console_to_provider(), provider_to_console())
finally:
stop.set()
await console_presence.stop_relay(user_id)
if on_end:
await on_end()

View file

@ -0,0 +1,65 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..models import PhoneNumber, VoiceCall
from .validators import format_bonvoice_destination, normalize_phone_digits
def find_inbound_by_did(db: Session, did_value: str) -> PhoneNumber | None:
did = normalize_phone_digits(did_value)
if len(did) != 10:
return None
return db.scalar(
select(PhoneNumber).where(
PhoneNumber.phone_number == did,
PhoneNumber.status == "assigned",
PhoneNumber.user_id.is_not(None),
)
)
def _phone_variants(value: str) -> set[str]:
raw = value.strip()
normalized = normalize_phone_digits(raw)
variants = {raw, normalized, format_bonvoice_destination(raw)}
if len(normalized) == 10:
variants.add(f"91{normalized}")
return {item for item in variants if item}
def find_pending_outbound_call(db: Session, *, from_value: str, to_value: str) -> VoiceCall | None:
"""
Match a pending outbound call to Bonvoice media `start`.
Outbound Bonvoice streams report:
- `to` = leg-A caller ID (purchased DID, e.g. 7946350706)
- `from` = destination/callee (e.g. 919149154337)
"""
did_candidates = _phone_variants(to_value)
owned_dids = {
normalize_phone_digits(value)
for value in did_candidates
if len(normalize_phone_digits(value)) == 10
}
if not owned_dids:
return None
candidates = db.scalars(
select(VoiceCall)
.where(
VoiceCall.direction == "outbound",
VoiceCall.status == "initiated",
VoiceCall.from_did.in_(owned_dids),
)
.order_by(VoiceCall.created_at.desc())
.limit(5)
).all()
if not candidates:
return None
callee_variants = _phone_variants(from_value)
for call in candidates:
if _phone_variants(call.to_number) & callee_variants:
return call
return None

View file

@ -0,0 +1,199 @@
import secrets
import uuid
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from ..config import settings
from ..models import PhoneNumber, UserAccount, VoiceCall
from .bonvoice_client import BonvoiceError, initiate_outbound_call
from .public_url import unified_voicebot_url
from .session_store import delete_session, get_session, save_session, update_session
from .validators import format_bonvoice_destination, require_owned_did
TERMINAL_STATUSES = frozenset({"ended", "failed", "no_answer"})
def media_url_for_session(session_token: str) -> str:
return unified_voicebot_url()
def voice_media_ws_url() -> str:
return unified_voicebot_url()
def serialize_call(call: VoiceCall) -> dict:
return {
"id": str(call.id),
"direction": call.direction,
"from": call.from_did,
"to": call.to_number,
"status": call.status,
"stream_id": call.stream_id,
"call_id": call.call_id,
"bridge_mode": call.bridge_mode,
"upstream": call.upstream_ws,
"error_message": call.error_message,
"answered_at": call.answered_at,
"ended_at": call.ended_at,
"created_at": call.created_at,
}
async def create_outbound_call(
db: Session,
*,
user: UserAccount,
from_value: str,
to_value: str,
upstream: str | None,
bridge_mode: str,
metadata: dict | None = None,
) -> VoiceCall:
owned_number = require_owned_did(db, user, from_value)
destination = format_bonvoice_destination(to_value)
if len(destination) < 10:
raise ValueError("to must be a valid phone number")
session_token = secrets.token_urlsafe(24)
provider_event_id = uuid.uuid4().hex.upper()
call = VoiceCall(
user_id=user.id,
phone_number_id=owned_number.id,
direction="outbound",
from_did=owned_number.phone_number,
to_number=destination,
status="initiated",
provider_event_id=provider_event_id,
session_token=session_token,
bridge_mode=bridge_mode,
upstream_ws=upstream,
)
db.add(call)
db.commit()
db.refresh(call)
await save_session(
session_token,
{
"call_id": str(call.id),
"user_id": str(user.id),
"direction": "outbound",
"from": owned_number.phone_number,
"to": destination,
"bridge_mode": bridge_mode,
"upstream_ws": upstream,
"status": "pending",
"metadata": metadata or {},
},
)
voicebot_url = unified_voicebot_url()
try:
await initiate_outbound_call(
destination=destination,
leg_a_caller_id=owned_number.phone_number,
voicebot_url=voicebot_url,
event_id=provider_event_id,
)
except BonvoiceError as exc:
call.status = "failed"
call.error_message = str(exc)
call.ended_at = datetime.now(timezone.utc)
db.commit()
raise
return call
def create_inbound_call_record(
db: Session,
*,
phone_number: PhoneNumber,
caller: str,
upstream: str,
stream_id: str | None,
provider_call_id: str | None,
) -> VoiceCall:
session_token = secrets.token_urlsafe(24)
call = VoiceCall(
user_id=phone_number.user_id,
phone_number_id=phone_number.id,
direction="inbound",
from_did=phone_number.phone_number,
to_number=caller,
status="active",
session_token=session_token,
stream_id=stream_id,
call_id=provider_call_id,
bridge_mode="upstream",
upstream_ws=upstream,
answered_at=datetime.now(timezone.utc),
)
db.add(call)
db.commit()
db.refresh(call)
return call
def finalize_call(db: Session, call: VoiceCall | None, *, status: str = "ended") -> None:
if not call:
return
db.refresh(call)
if call.status in TERMINAL_STATUSES:
return
call.status = status
if not call.ended_at:
call.ended_at = datetime.now(timezone.utc)
db.commit()
async def finalize_call_async(db: Session, call: VoiceCall | None, *, status: str = "ended") -> None:
if not call:
return
finalize_call(db, call, status=status)
if call.session_token:
await delete_session(call.session_token)
async def refresh_call_lifecycle(db: Session, call: VoiceCall) -> VoiceCall:
if call.status in TERMINAL_STATUSES:
return call
now = datetime.now(timezone.utc)
age_seconds = (now - call.created_at).total_seconds()
if call.status == "initiated":
if age_seconds >= 45:
call.status = "no_answer"
call.ended_at = now
if not call.error_message:
call.error_message = "Call ended before media session connected"
db.commit()
db.refresh(call)
await delete_session(call.session_token)
if call.status == "active":
session = await get_session(call.session_token)
if not session:
call.status = "ended"
call.ended_at = now
db.commit()
db.refresh(call)
elif session.get("status") == "ended":
call.status = "ended"
if not call.ended_at:
call.ended_at = now
db.commit()
db.refresh(call)
await delete_session(call.session_token)
elif age_seconds >= 600:
call.status = "ended"
call.ended_at = now
if not call.error_message:
call.error_message = "Call exceeded maximum duration"
db.commit()
db.refresh(call)
await delete_session(call.session_token)
return call

View file

@ -0,0 +1,50 @@
import asyncio
from uuid import UUID
from fastapi import WebSocket
class ConsolePresenceManager:
def __init__(self) -> None:
self._connections: dict[UUID, WebSocket] = {}
self._relay_queues: dict[UUID, asyncio.Queue[str]] = {}
self._lock = asyncio.Lock()
async def register(self, user_id: UUID, websocket: WebSocket) -> None:
async with self._lock:
self._connections[user_id] = websocket
async def unregister(self, user_id: UUID, websocket: WebSocket) -> None:
async with self._lock:
if self._connections.get(user_id) is websocket:
self._connections.pop(user_id, None)
self._relay_queues.pop(user_id, None)
async def is_present(self, user_id: UUID) -> bool:
async with self._lock:
return user_id in self._connections
async def get_connection(self, user_id: UUID) -> WebSocket | None:
async with self._lock:
return self._connections.get(user_id)
async def start_relay(self, user_id: UUID) -> asyncio.Queue[str]:
async with self._lock:
queue: asyncio.Queue[str] = asyncio.Queue()
self._relay_queues[user_id] = queue
return queue
async def stop_relay(self, user_id: UUID) -> None:
async with self._lock:
self._relay_queues.pop(user_id, None)
async def forward_from_console(self, user_id: UUID, text: str) -> bool:
async with self._lock:
queue = self._relay_queues.get(user_id)
if not queue:
return False
await queue.put(text)
return True
console_presence = ConsolePresenceManager()

View file

@ -0,0 +1,40 @@
import json
import logging
import urllib.error
import urllib.request
from ..config import settings
logger = logging.getLogger(__name__)
def get_ngrok_wss_base() -> str | None:
"""Detect ngrok public WSS base (test_bonvoice/outbound_call.py pattern)."""
tunnel_hosts = ("127.0.0.1:4040", "host.docker.internal:4040")
for host in tunnel_hosts:
try:
with urllib.request.urlopen(f"http://{host}/api/tunnels", timeout=2) as resp:
data = json.load(resp)
for tunnel in data.get("tunnels", []):
public = tunnel.get("public_url", "")
if public.startswith("https://"):
ngrok_host = public.removeprefix("https://")
return f"wss://{ngrok_host}/api/v1/voice"
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
continue
return None
def resolve_voice_relay_public_base() -> str:
configured = settings.voice_relay_public_url.rstrip("/")
if "localhost" in configured or "127.0.0.1" in configured:
ngrok_base = get_ngrok_wss_base()
if ngrok_base:
logger.info("Using ngrok voice relay base: %s", ngrok_base)
return ngrok_base
return configured
def unified_voicebot_url() -> str:
"""Bonvoice outbound media URL (voicebotURL)."""
return f"{resolve_voice_relay_public_base()}/ws/media"

View file

@ -0,0 +1,18 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..models import PhoneNumber, VoiceSocketEndpoint
def resolve_voice_socket(
db: Session,
*,
phone_number: PhoneNumber,
) -> VoiceSocketEndpoint | None:
return db.scalar(
select(VoiceSocketEndpoint).where(
VoiceSocketEndpoint.phone_number_id == phone_number.id,
VoiceSocketEndpoint.user_id == phone_number.user_id,
VoiceSocketEndpoint.enabled.is_(True),
)
)

View file

@ -0,0 +1,50 @@
import json
from datetime import datetime, timezone
from typing import Any
import redis.asyncio as redis
from ..config import settings
_redis_client: redis.Redis | None = None
def _session_key(session_token: str) -> str:
return f"voice:session:{session_token}"
async def get_redis() -> redis.Redis:
global _redis_client
if _redis_client is None:
_redis_client = redis.from_url(settings.redis_url, decode_responses=True)
return _redis_client
async def save_session(session_token: str, data: dict[str, Any]) -> None:
client = await get_redis()
payload = dict(data)
payload["session_token"] = session_token
payload.setdefault("created_at", datetime.now(timezone.utc).isoformat())
await client.set(_session_key(session_token), json.dumps(payload), ex=settings.voice_session_ttl_seconds)
async def get_session(session_token: str) -> dict[str, Any] | None:
client = await get_redis()
raw = await client.get(_session_key(session_token))
if not raw:
return None
return json.loads(raw)
async def update_session(session_token: str, **fields: Any) -> dict[str, Any] | None:
session = await get_session(session_token)
if not session:
return None
session.update(fields)
await save_session(session_token, session)
return session
async def delete_session(session_token: str) -> None:
client = await get_redis()
await client.delete(_session_key(session_token))

View file

@ -0,0 +1,66 @@
"""Transparent WebSocket frame relay — no parse, no audio processing."""
import asyncio
import logging
import websockets
from fastapi import WebSocket, WebSocketDisconnect
from websockets.exceptions import ConnectionClosed
logger = logging.getLogger(__name__)
async def relay_text_websockets(left: WebSocket, right: WebSocket) -> None:
stop = asyncio.Event()
async def forward(source: WebSocket, destination: WebSocket) -> None:
try:
while not stop.is_set():
message = await source.receive_text()
await destination.send_text(message)
except (WebSocketDisconnect, RuntimeError):
stop.set()
await asyncio.gather(forward(left, right), forward(right, left))
async def relay_provider_to_upstream(
provider: WebSocket,
upstream_url: str,
*,
initial_frames: list[str] | None = None,
additional_headers: dict[str, str] | None = None,
) -> None:
"""Raw carrier ↔ customer upstream tunnel."""
logger.info("Opening upstream tunnel to %s", upstream_url)
async with websockets.connect(
upstream_url,
open_timeout=10,
close_timeout=5,
additional_headers=additional_headers,
ping_interval=20,
ping_timeout=20,
max_queue=16,
) as upstream:
for frame in initial_frames or []:
await upstream.send(frame)
async def provider_to_upstream() -> None:
try:
while True:
raw = await provider.receive_text()
await upstream.send(raw)
except (WebSocketDisconnect, RuntimeError):
return
async def upstream_to_provider() -> None:
try:
while True:
raw = await upstream.recv()
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
await provider.send_text(raw)
except (ConnectionClosed, WebSocketDisconnect, RuntimeError):
return
await asyncio.gather(provider_to_upstream(), upstream_to_provider())

View file

@ -0,0 +1,52 @@
import re
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..models import PhoneNumber, UserAccount
def normalize_phone_digits(value: str) -> str:
digits = re.sub(r"\D", "", value.strip())
if len(digits) > 10 and digits.startswith("91"):
digits = digits[2:]
if len(digits) > 10:
digits = digits[-10:]
return digits
def format_bonvoice_destination(value: str) -> str:
digits = re.sub(r"\D", "", value.strip())
if len(digits) == 10:
return f"91{digits}"
return digits
def require_owned_did(db: Session, user: UserAccount, from_value: str) -> PhoneNumber:
normalized = normalize_phone_digits(from_value)
if len(normalized) != 10:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="from must be a valid 10-digit purchased number",
)
number = db.scalar(
select(PhoneNumber).where(
PhoneNumber.user_id == user.id,
PhoneNumber.status == "assigned",
PhoneNumber.phone_number == normalized,
)
)
if not number:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="from must be a purchased number assigned to your account",
)
return number
def parse_user_id(value: str) -> UUID:
try:
return UUID(value)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc

52
backend/app/voice_auth.py Normal file
View file

@ -0,0 +1,52 @@
import uuid
from fastapi import Cookie, Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from .api_key_auth import _extract_api_key, parse_api_token, verify_api_token
from .api_keys_service import KEY_PREFIX
from .auth import COOKIE_NAME, decode_token
from .database import get_db
from .models import ApiKey, UserAccount
def current_voice_user(
urmi_session: str | None = Cookie(default=None, alias=COOKIE_NAME),
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-Api-Key"),
db: Session = Depends(get_db),
) -> UserAccount:
if urmi_session:
payload = decode_token(urmi_session)
try:
user_id = uuid.UUID(payload["sub"])
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session") from exc
user = db.scalar(select(UserAccount).where(UserAccount.id == user_id))
if user and user.status == "active":
return user
token = _extract_api_key(authorization, x_api_key)
if not token or not token.startswith(KEY_PREFIX):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
parsed = parse_api_token(token)
if parsed:
key_id, _ = parsed
api_key = db.scalar(
select(ApiKey).where(ApiKey.id == key_id, ApiKey.revoked_at.is_(None))
)
if api_key and verify_api_token(token, api_key.key_hash):
user = db.scalar(select(UserAccount).where(UserAccount.id == api_key.user_id))
if user and user.status == "active":
return user
active_keys = db.scalars(select(ApiKey).where(ApiKey.revoked_at.is_(None))).all()
for api_key in active_keys:
if verify_api_token(token, api_key.key_hash):
user = db.scalar(select(UserAccount).where(UserAccount.id == api_key.user_id))
if user and user.status == "active":
return user
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")

Binary file not shown.

Binary file not shown.

13
backend/requirements.txt Normal file
View file

@ -0,0 +1,13 @@
fastapi>=0.116,<1.0
uvicorn[standard]>=0.35,<1.0
sqlalchemy>=2.0,<3.0
psycopg[binary]>=3.2,<4.0
pydantic-settings>=2.10,<3.0
email-validator>=2.2,<3.0
httpx>=0.28,<1.0
google-auth[requests]>=2.40,<3.0
PyJWT>=2.10,<3.0
pwdlib[argon2]>=0.2,<1.0
python-multipart>=0.0.20,<1.0
redis>=5.0,<6.0
websockets>=14.0,<16.0

View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Verify SMS and OTP logging pipelines write to the database."""
from uuid import uuid4
from sqlalchemy import func, select
from app.database import SessionLocal
from app.log_service import record_otp_log, record_sms_logs
from app.message_send_service import RecipientSendResult, SendMessageError, send_template_messages
from app.models import OtpMessageLog, SenderConfig, SmsMessageLog, TemplateConfig, UrmiOtpProject, UserAccount
def main() -> None:
db = SessionLocal()
try:
user = db.scalar(select(UserAccount).limit(1))
if not user:
print("SKIP: no user account")
return
sms_before = db.scalar(select(func.count()).select_from(SmsMessageLog)) or 0
otp_before = db.scalar(select(func.count()).select_from(OtpMessageLog)) or 0
sender = db.scalar(
select(SenderConfig).where(
SenderConfig.user_id == user.id,
SenderConfig.status == "approved",
)
)
template = db.scalar(
select(TemplateConfig).where(
TemplateConfig.user_id == user.id,
TemplateConfig.status == "approved",
)
)
if sender and template:
try:
send_template_messages(
db,
user,
sender_id=sender.sender_id,
template_id=template.id,
message=template.template_text.replace("{#var#}", "123456", 1).replace("{#var#}", "10", 1)
if "{#var#}" in template.template_text
else template.template_text,
recipients=["919999999999"],
)
except SendMessageError as exc:
print(f"SMS send path exercised (gateway may reject): {exc.detail}")
else:
record_sms_logs(
db,
user,
sender_id="TESTDLT",
template_name="TEST_TEMPLATE",
template_id=uuid4(),
results=[
RecipientSendResult(
to="919999999999",
success=True,
job_id="test-job",
message_id="test-msg",
)
],
)
print("SMS log inserted via record_sms_logs fallback")
project = db.scalar(
select(UrmiOtpProject).where(
UrmiOtpProject.user_id == user.id,
UrmiOtpProject.status == "approved",
)
)
record_otp_log(
db,
user,
project_name=project.project_name if project else "test-project",
template_type="login_authentication",
to_number="919999999999",
status="accepted",
)
print("OTP log inserted")
sms_after = db.scalar(select(func.count()).select_from(SmsMessageLog)) or 0
otp_after = db.scalar(select(func.count()).select_from(OtpMessageLog)) or 0
assert sms_after > sms_before, "SMS log count did not increase"
assert otp_after > otp_before, "OTP log count did not increase"
latest_sms = db.scalar(select(SmsMessageLog).order_by(SmsMessageLog.created_at.desc()).limit(1))
latest_otp = db.scalar(select(OtpMessageLog).order_by(OtpMessageLog.created_at.desc()).limit(1))
print(f"OK sms_logs={sms_after} latest={latest_sms.sender_id}->{latest_sms.to_number} status={latest_sms.status}")
print(f"OK otp_logs={otp_after} latest={latest_otp.project_name}->{latest_otp.to_number} type={latest_otp.template_type}")
finally:
db.close()
if __name__ == "__main__":
main()