first commit
This commit is contained in:
commit
efa5080897
127 changed files with 18610 additions and 0 deletions
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Urmi control-plane API."""
|
||||
359
backend/app/analytics_service.py
Normal file
359
backend/app/analytics_service.py
Normal 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,
|
||||
}
|
||||
63
backend/app/api_key_auth.py
Normal file
63
backend/app/api_key_auth.py
Normal 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
|
||||
48
backend/app/api_keys_service.py
Normal file
48
backend/app/api_keys_service.py
Normal 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
85
backend/app/auth.py
Normal 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
|
||||
171
backend/app/campaign_csv_service.py
Normal file
171
backend/app/campaign_csv_service.py
Normal 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
38
backend/app/config.py
Normal 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
19
backend/app/database.py
Normal 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
|
||||
13
backend/app/dlt_constants.py
Normal file
13
backend/app/dlt_constants.py
Normal 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",)
|
||||
57
backend/app/dlt_service.py
Normal file
57
backend/app/dlt_service.py
Normal 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"
|
||||
62
backend/app/endpoint_security.py
Normal file
62
backend/app/endpoint_security.py
Normal 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()}"
|
||||
15
backend/app/kyc_constants.py
Normal file
15
backend/app/kyc_constants.py
Normal 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"}
|
||||
65
backend/app/kyc_service.py
Normal file
65
backend/app/kyc_service.py
Normal 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
|
||||
26
backend/app/kyc_validators.py
Normal file
26
backend/app/kyc_validators.py
Normal 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
|
||||
68
backend/app/log_service.py
Normal file
68
backend/app/log_service.py
Normal 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
125
backend/app/main.py
Normal 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
|
||||
],
|
||||
}
|
||||
221
backend/app/message_send_service.py
Normal file
221
backend/app/message_send_service.py
Normal 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
493
backend/app/models.py
Normal 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")
|
||||
90
backend/app/otp_service.py
Normal file
90
backend/app/otp_service.py
Normal 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,
|
||||
}
|
||||
30
backend/app/phone_number_validators.py
Normal file
30
backend/app/phone_number_validators.py
Normal 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
518
backend/app/routes_admin.py
Normal 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)
|
||||
50
backend/app/routes_analytics.py
Normal file
50
backend/app/routes_analytics.py
Normal 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]
|
||||
)
|
||||
127
backend/app/routes_api_keys.py
Normal file
127
backend/app/routes_api_keys.py
Normal 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
222
backend/app/routes_auth.py
Normal 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
|
||||
307
backend/app/routes_campaigns.py
Normal file
307
backend/app/routes_campaigns.py
Normal 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.",
|
||||
}
|
||||
21
backend/app/routes_compliance.py
Normal file
21
backend/app/routes_compliance.py
Normal 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
130
backend/app/routes_dlt.py
Normal 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)
|
||||
363
backend/app/routes_integrations.py
Normal file
363
backend/app/routes_integrations.py
Normal 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
133
backend/app/routes_kyc.py
Normal 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
192
backend/app/routes_logs.py
Normal 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,
|
||||
}
|
||||
118
backend/app/routes_messages.py
Normal file
118
backend/app/routes_messages.py
Normal 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"],
|
||||
}
|
||||
211
backend/app/routes_messaging.py
Normal file
211
backend/app/routes_messaging.py
Normal 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
|
||||
79
backend/app/routes_numbers.py
Normal file
79
backend/app/routes_numbers.py
Normal 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
18
backend/app/routes_otp.py
Normal 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)
|
||||
176
backend/app/routes_urmiotp.py
Normal file
176
backend/app/routes_urmiotp.py
Normal 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
435
backend/app/routes_voice.py
Normal 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
27
backend/app/schemas.py
Normal 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]
|
||||
38
backend/app/sender_validators.py
Normal file
38
backend/app/sender_validators.py
Normal 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
|
||||
68
backend/app/sms_webhooks.py
Normal file
68
backend/app/sms_webhooks.py
Normal 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,
|
||||
)
|
||||
16
backend/app/template_message_validator.py
Normal file
16
backend/app/template_message_validator.py
Normal 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
|
||||
32
backend/app/template_validators.py
Normal file
32
backend/app/template_validators.py
Normal 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
|
||||
92
backend/app/ultronsms_client.py
Normal file
92
backend/app/ultronsms_client.py
Normal 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,
|
||||
)
|
||||
93
backend/app/urmiotp_catalog.py
Normal file
93
backend/app/urmiotp_catalog.py
Normal 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"},
|
||||
},
|
||||
}
|
||||
1
backend/app/voice/__init__.py
Normal file
1
backend/app/voice/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
40
backend/app/voice/audio_utils.py
Normal file
40
backend/app/voice/audio_utils.py
Normal 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
|
||||
51
backend/app/voice/bonvoice_client.py
Normal file
51
backend/app/voice/bonvoice_client.py
Normal 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
|
||||
76
backend/app/voice/bonvoice_media.py
Normal file
76
backend/app/voice/bonvoice_media.py
Normal 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()
|
||||
65
backend/app/voice/call_resolver.py
Normal file
65
backend/app/voice/call_resolver.py
Normal 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
|
||||
199
backend/app/voice/call_service.py
Normal file
199
backend/app/voice/call_service.py
Normal 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
|
||||
50
backend/app/voice/presence.py
Normal file
50
backend/app/voice/presence.py
Normal 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()
|
||||
40
backend/app/voice/public_url.py
Normal file
40
backend/app/voice/public_url.py
Normal 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"
|
||||
18
backend/app/voice/relay_config.py
Normal file
18
backend/app/voice/relay_config.py
Normal 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),
|
||||
)
|
||||
)
|
||||
50
backend/app/voice/session_store.py
Normal file
50
backend/app/voice/session_store.py
Normal 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))
|
||||
66
backend/app/voice/tunnel.py
Normal file
66
backend/app/voice/tunnel.py
Normal 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())
|
||||
52
backend/app/voice/validators.py
Normal file
52
backend/app/voice/validators.py
Normal 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
52
backend/app/voice_auth.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue