first commit

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

31
.env.example Normal file
View file

@ -0,0 +1,31 @@
# Copy to repo root for Docker Compose:
# cp .env.example .env
#
# Compose uses this file for ${VAR} substitution and passes selected values
# into the backend container. See docs/ENVIRONMENT.md for the full flow.
# --- Backend auth (passed into urmi-backend) ---
AUTH_SECRET=replace-with-at-least-32-random-characters
AUTH_COOKIE_SECURE=false
# Google OAuth — optional until you enable "Continue with Google"
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/callback
# --- Optional overrides (uncomment if needed) ---
# FRONTEND_ORIGIN=http://localhost:5173
# DATABASE_URL=postgresql+psycopg://urmi:urmi@postgres:5432/urmi
# Ultronsms gateway credentials (backend only)
ULTRONSMS_BASE_URL=https://ultronsms.com
ULTRONSMS_USER=GENZENITH
ULTRONSMS_PASSWORD=replace-with-ultronsms-password
# Voice relay (Bonvoice provider — backend only)
REDIS_URL=redis://redis:6379/0
BONVOICE_TOKEN=replace-with-bonvoice-token
BONVOICE_API_URL=https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/
BONVOICE_MEDIA_ALLOWLIST=103.131.25.5
# Public WSS base for Bonvoice templates (use wss:// in production, e.g. ngrok or your domain)
VOICE_RELAY_PUBLIC_URL=ws://localhost:8000/api/v1/voice

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
.env
backend/.env
frontend/.env
!.env.example
!backend/.env.example
!frontend/.env.example
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
node_modules/
data/uploads/
backend/uploads/
dist/
.DS_Store
*.log

View file

@ -0,0 +1,973 @@
# CPaaS WebSocket Media Relay — Architecture & Integration Plan
**Version:** 1.0
**Status:** Design
**Scope:** Inbound + outbound voice media relay for Bonvoice (and future providers)
**Primary goal:** Act as a **transparent WebSocket tunnel** between the telecom provider and the customer AI backend, with **authentication and routing control** at the relay layer and **no added media processing latency**.
---
## 1. Executive Summary
Your CPaaS sits between:
| Leg | Party | Protocol |
|-----|-------|----------|
| **Provider leg** | Bonvoice (or other CPaaS) | WebSocket + JSON media frames |
| **Customer leg** | Tenant AI / voicebot backend | WebSocket + same JSON frame format |
The relay **must not** decode, transcode, re-chunk, or buffer audio in the hot path. It forwards WebSocket text frames as-is after authentication and tenant routing.
**You own:**
- Provider credentials (Bonvoice token, DID inventory)
- Customer API keys / JWT issuance
- Session routing (`call_id`, `stream_id`, DID → tenant)
- Audit logs, rate limits, billing metadata
- Outbound call orchestration API
**Customers own:**
- AI logic, STT/TTS, conversation state
- Their backend WebSocket endpoint (or pre-connect to your upstream socket)
---
## 2. Design Principles
### 2.1 Zero-processing media path
```
Bonvoice WS ──► Relay (auth + route) ──► Customer WS
▲ │ │
└────────────────────┴──────────────────────┘
raw JSON text frames, both directions
```
| Do | Don't |
|----|-------|
| Forward raw WebSocket text frames | `json.loads()` on every `media` event |
| Parse only control events (`start`, `connected`, `end`) | Base64 decode / PCM transcoding |
| Use async bidirectional copy (`asyncio.gather`) | Artificial `sleep()` or batching |
| Colocate relay in same region as provider | Cross-region relay for real-time audio |
| Pre-resolve tenant route before bridging | DB lookups inside media loop |
**Target added latency:** < 5 ms p99 per hop (relay processing only, excluding network RTT to customer).
### 2.2 Fail closed on auth
If authentication or routing fails, **reject before bridging**. Never forward media to an unauthenticated or unmapped session.
### 2.3 Provider URL is always yours
Bonvoice (inbound template + outbound API) is configured with **your** `wss://` endpoint. Customers never expose their URL to the provider directly. This gives you full control over access, metering, and multi-tenancy.
---
## 3. System Context
```mermaid
flowchart LR
subgraph Provider["Telecom Provider (Bonvoice)"]
DID[Virtual DID]
PBX[PBX / Voicebot Platform]
end
subgraph Relay["Your CPaaS Relay"]
GW[WebSocket Gateway]
AUTH[Auth & Routing]
API[REST Control API]
SESS[Session Store]
end
subgraph Customer["Customer Tenant"]
BOT[AI Voicebot Backend]
end
Caller((Caller)) --> DID
DID --> PBX
PBX <-->|wss JSON media| GW
GW <--> AUTH
AUTH <--> SESS
API --> SESS
Customer -->|API key| API
GW <-->|wss JSON media| BOT
```
---
## 4. Bonvoice Provider Protocol Reference
Based on validated integration behaviour.
### 4.1 Connection
- Transport: **WebSocket** (`wss://`), not HTTP POST
- Bonvoice server IP observed: `103.131.25.5` (maintain allowlist)
- Endpoint must use `wss://`, not `https://`
### 4.2 Message types
**Call start**
```json
{
"event": "start",
"data": {
"stream_id": "<id>",
"call_id": "<id>",
"from": "<caller>",
"to": "<did>"
}
}
```
**Media (bidirectional)**
```json
{
"event": "media",
"stream_id": "<id>",
"media": {
"packet_id": 1,
"timestamp": 0,
"payload": "<base64 PCM>"
}
}
```
- Audio: PCM 16-bit, 8000 Hz, mono
- Chunk size: **320 bytes** (20 ms), multiples of 320 only
**Interruption (customer → provider)**
```json
{
"event": "clear",
"stream_id": "<id>"
}
```
**Additional events observed**
| Event | Direction | Relay action |
|-------|-----------|--------------|
| `connected` | Provider → relay | Forward as-is; optional metadata log |
| `end` | Provider → relay | Forward, then close both legs gracefully |
### 4.3 Outbound API (control plane)
```
POST https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/
Authorization: Token <provider_token>
```
```json
{
"autocallType": "5",
"destination": "<callee>",
"legACallerID": "<did>",
"eventID": "<unique_id>",
"voicebotProvider": "BONVOICE",
"voicebotURL": "wss://<your-relay-host>/v1/media/<session_token>"
}
```
Response:
```json
{
"responseCode": 200,
"responseDescription": "Success",
"responseType": "Success",
"eventID": "..."
}
```
---
## 5. Relay Component Architecture
```mermaid
flowchart TB
subgraph ControlPlane["Control Plane"]
REST[REST API]
PROV[Provider Adapter - Bonvoice]
TENANT[Tenant Registry]
KEYS[API Key / JWT Service]
end
subgraph DataPlane["Data Plane - hot path"]
PWS[Provider WebSocket Handler]
TUNNEL[Frame Tunnel]
CWS[Customer WebSocket Client/Handler]
end
REST --> PROV
REST --> KEYS
REST --> TENANT
PWS --> TUNNEL
TUNNEL --> CWS
TENANT --> PWS
KEYS --> PWS
KEYS --> CWS
```
| Component | Responsibility |
|-----------|----------------|
| **Provider WebSocket Handler** | Accept Bonvoice WS; provider-side auth |
| **Customer WebSocket Client** | Dial customer upstream WS (outbound leg) |
| **Frame Tunnel** | Bidirectional raw frame copy |
| **Session Store** | Redis: session_token → tenant, upstream URL, metadata |
| **Tenant Registry** | DID → tenant; tenant → upstream WS URL + credentials |
| **Provider Adapter** | Bonvoice outbound API, token rotation |
| **REST API** | Customer-facing call control + config |
---
## 6. WebSocket Tunnel — Core Mechanism
### 6.1 Session lifecycle
```
1. Provider connects → wss://relay/v1/media/{session_token}
2. Relay validates session_token (or resolves inbound by DID on `start`)
3. Relay opens upstream → wss://customer/configured-endpoint
4. Relay starts two async copy loops
5. On `end` or disconnect → cancel both loops, purge session
```
### 6.2 Tunnel pseudocode (hot path)
```python
async def tunnel(provider_ws, customer_ws):
async def p2c():
async for raw in provider_ws:
await customer_ws.send(raw) # no parse, no modify
async def c2p():
async def c2p():
async for raw in customer_ws:
await provider_ws.send(raw)
await asyncio.gather(p2c(), c2p())
```
### 6.3 Optional control-plane hook (non-blocking)
Parse **only the first** `start` frame (and `end`) in a side task for logging/billing. Never block the copy loop.
```python
async def observe_control_events(raw: str, session: Session):
if '"event":"start"' in raw: # or parse once with orjson
session.mark_started()
elif '"event":"end"' in raw:
session.mark_ended()
```
Use string prefix check or single parse on non-media events only.
### 6.4 Upstream connection modes
| Mode | Latency | Use case |
|------|---------|----------|
| **A. Relay dials customer on `start`** | +1 WS handshake (~30–150 ms) | Simplest; acceptable for most bots |
| **B. Customer pre-connects to relay** | Lowest | Customer opens `wss://relay/v1/upstream/{session_token}` before answering |
| **C. Persistent tenant socket** | Low | Customer keeps one WS open; relay multiplexes by `stream_id` (complex) |
**Recommendation:** Start with **Mode A**; offer **Mode B** for latency-sensitive tenants.
---
## 7. Authentication Model
Authentication is enforced at **three layers**.
### 7.1 Layer 1 — Provider leg (Bonvoice → Relay)
| Method | Implementation |
|--------|----------------|
| **IP allowlist** | Only accept WS from known Bonvoice egress IPs |
| **Session token in URL** | Outbound: `wss://relay/v1/media/{session_token}` — token is single-use, TTL 5 min |
| **Inbound DID routing** | On `start`, map `to` (DID) → tenant; reject unknown DID |
| **mTLS (optional)** | Mutual TLS if Bonvoice supports client certs |
Provider token (`Authorization: Token ...`) is used **only** on outbound REST API calls **from relay to Bonvoice**, never exposed to customers.
### 7.2 Layer 2 — Relay control plane (Customer → Relay REST)
| Method | Implementation |
|--------|----------------|
| **API key** | `Authorization: Bearer sk_live_...` on all REST endpoints |
| **Scoped permissions** | `calls:write`, `numbers:read`, etc. |
| **Rate limiting** | Per-tenant token bucket |
### 7.3 Layer 3 — Customer leg (Relay → Customer backend)
Choose one per tenant (configured in dashboard):
| Method | Header / mechanism |
|--------|-------------------|
| **Static secret** | `Authorization: Bearer <tenant_upstream_secret>` |
| **Signed JWT** | Short-lived JWT signed by relay, claims: `tenant_id`, `session_id`, `call_id` |
| **HMAC query param** | `wss://customer/bot?sig=HMAC(session_token, secret)` |
| **mTLS** | Client cert per tenant |
Customer validates the relay; relay validates the customer using pre-shared secret or JWT.
### 7.4 Session token design
```
session_token = base64url(tenant_id || call_uuid || HMAC-SHA256(...))
```
Properties:
- Single-use or idempotent per `eventID`
- TTL: 300 seconds
- Bound to: tenant, direction, destination/caller, DID
- Stored in Redis before outbound API call
---
## 8. Inbound Call Flow
```mermaid
sequenceDiagram
participant Caller
participant Bonvoice
participant Relay
participant Redis
participant Customer
Caller->>Bonvoice: Inbound call to DID
Bonvoice->>Relay: wss connect /v1/media/inbound
Bonvoice->>Relay: event=start {to: DID, from, call_id, stream_id}
Relay->>Redis: Lookup DID → tenant + upstream URL
alt Unknown DID or tenant suspended
Relay-->>Bonvoice: Close WS (4403)
else OK
Relay->>Customer: wss connect + auth headers
Relay->>Relay: Start bidirectional tunnel
loop Every 20ms
Bonvoice->>Relay: event=media
Relay->>Customer: forward raw frame
Customer->>Relay: event=media
Relay->>Bonvoice: forward raw frame
end
Bonvoice->>Relay: event=end
Relay->>Customer: forward end
Relay->>Relay: Close both sockets
end
```
### 8.1 Bonvoice configuration (per DID)
| Field | Value |
|-------|-------|
| Provider | BONVOICE |
| Endpoint | `wss://media.yourcpaas.com/v1/media/inbound` |
All tenants share one inbound WS URL; routing happens on `start.data.to` (DID).
### 8.2 Inbound routing table
```
DID 7946350706 → tenant_abc → wss://bot.customer.com/voice
DID 7946350707 → tenant_xyz → wss://api.other.com/ws/bonvoice
```
---
## 9. Outbound Call Flow
```mermaid
sequenceDiagram
participant Customer
participant Relay
participant Redis
participant Bonvoice
participant Callee
Customer->>Relay: POST /v1/calls {to, from_did} + API key
Relay->>Relay: Validate API key, quota
Relay->>Redis: Store session_token → tenant, upstream, metadata
Relay->>Bonvoice: POST autoCallBridging {voicebotURL: wss://relay/.../session_token}
Bonvoice-->>Relay: 200 Success
Relay-->>Customer: {call_id, session_id, status: initiated}
opt Mode B - pre-connect
Customer->>Relay: wss /v1/upstream/{session_token}
end
Bonvoice->>Callee: Ring
Callee->>Bonvoice: Answer
Bonvoice->>Relay: wss /v1/media/{session_token}
Relay->>Redis: Validate session_token
Relay->>Customer: wss connect upstream (Mode A) or attach pre-connect (Mode B)
Relay->>Relay: Tunnel media both ways
```
### 9.1 Customer REST API (your CPaaS)
```
POST /v1/calls
Authorization: Bearer sk_live_...
Content-Type: application/json
{
"to": "9149154337",
"from": "7946350706",
"upstream": "wss://bot.customer.com/voice",
"metadata": { "campaign_id": "xyz" }
}
```
Response:
```json
{
"id": "call_01J...",
"session_token": "...",
"status": "initiated",
"provider_event_id": "11A31A12FF1C4455"
}
```
Relay internally calls Bonvoice with `voicebotURL = wss://media.yourcpaas.com/v1/media/{session_token}`.
---
## 10. URL & Routing Scheme
| Endpoint | Purpose | Auth |
|----------|---------|------|
| `wss://media.../v1/media/inbound` | All inbound Bonvoice streams | IP allowlist + DID routing on `start` |
| `wss://media.../v1/media/{session_token}` | Outbound + optional direct sessions | Session token validation |
| `wss://media.../v1/upstream/{session_token}` | Customer pre-connect (Mode B) | API key or session_token |
| `POST /v1/calls` | Initiate outbound | Customer API key |
| `GET /v1/calls/{id}` | Call status | Customer API key |
| `POST /v1/numbers` | Assign DID to tenant | Customer API key |
---
## 11. Multi-Tenancy & Data Model
### 11.1 Core entities
```
Tenant
- id, name, status
- api_key_hash
- upstream_ws_url
- upstream_auth_type, upstream_secret
- quota_limits
PhoneNumber (DID)
- e164, tenant_id, provider, provider_account_id
CallSession
- session_token, tenant_id, direction
- call_id, stream_id, from, to
- status: pending | bridging | active | ended | failed
- created_at, answered_at, ended_at
ProviderAccount
- provider: bonvoice
- token_encrypted, default_caller_id
```
### 11.2 Redis session record (ephemeral)
```json
{
"session_token": "abc123",
"tenant_id": "ten_01",
"direction": "outbound",
"upstream_ws": "wss://bot.customer.com/voice",
"upstream_auth": "Bearer xxx",
"to": "9149154337",
"from": "7946350706",
"status": "pending",
"expires_at": 1699999999
}
```
TTL matches Bonvoice ring timeout + margin (e.g. 10 minutes).
---
## 12. Latency Budget
| Stage | Target |
|-------|--------|
| Relay frame forward (provider → customer) | < 2 ms p99 |
| Relay frame forward (customer → provider) | < 2 ms p99 |
| Session lookup (Redis) | < 1 ms |
| Upstream WS connect (Mode A, one-time) | 30–150 ms (not per frame) |
| JSON parse on hot path | **0** (avoided) |
| Audio transcoding | **0** (avoided) |
**20 ms audio frames:** relay must sustain > 50 frames/sec per call with headroom for 500+ concurrent calls per node.
---
## 13. Failure Handling
| Scenario | Behaviour |
|----------|-----------|
| Invalid session token | Close provider WS immediately with code `4401` |
| Unknown inbound DID | Close after `start` with `4403`; log alert |
| Customer upstream unreachable | Retry connect 2× with 100 ms backoff; then close provider leg |
| Customer WS drops mid-call | Close provider leg; emit webhook `call.upstream_disconnected` |
| Provider WS drops | Close customer leg |
| Bonvoice sends POST not WS | Return `426` + log; misconfigured URL |
| Provider API failure on outbound | Return error to customer; do not create session |
### 13.1 Webhook events (control plane, async)
Deliver to customer via HTTP POST (not in media path):
- `call.initiated`
- `call.answered`
- `call.media_started`
- `call.ended`
- `call.failed`
---
## 14. Security Checklist
- [ ] Provider token stored encrypted (KMS / vault); never in logs
- [ ] Customer API keys stored as hashes
- [ ] Session tokens: cryptographically random, single-use, short TTL
- [ ] TLS 1.2+ on all WS and REST
- [ ] IP allowlist for Bonvoice egress
- [ ] Per-tenant rate limits on `/v1/calls`
- [ ] No customer can specify arbitrary `voicebotURL` to Bonvoice — relay always injects its own URL
- [ ] Audit log: who initiated call, DID used, duration, bytes relayed
---
## 15. Observability
### 15.1 Metrics (per call)
- `relay_session_active{tenant, direction}`
- `relay_frame_forward_total{direction=provider_to_customer|customer_to_provider}`
- `relay_frame_forward_latency_ms` (histogram)
- `relay_upstream_connect_duration_ms`
- `relay_auth_failure_total{reason}`
### 15.2 Tracing
- Trace ID = `call_id` or `session_token`
- Span: `provider.accept` → `session.resolve` → `upstream.connect` → `tunnel.active`
### 15.3 Logging
Log control events only (`start`, `end`, `connected`). Do **not** log media payloads.
---
## 16. Implementation Roadmap
### Phase 1 — MVP tunnel (2–3 weeks)
- [ ] WebSocket gateway with raw frame tunnel
- [ ] Redis session store
- [ ] Outbound: `POST /v1/calls` → Bonvoice API → session routing
- [ ] Inbound: DID → tenant lookup on `start`
- [ ] API key auth on REST
- [ ] Provider IP allowlist
- [ ] Basic metrics + structured logs
### Phase 2 — Production hardening (2 weeks)
- [ ] Customer upstream auth (JWT / HMAC)
- [ ] Mode B pre-connect upstream endpoint
- [ ] Webhooks for call lifecycle
- [ ] Graceful shutdown / connection draining
- [ ] Horizontal scaling behind L4 LB with sticky sessions OR shared Redis session state
### Phase 3 — Platform (ongoing)
- [ ] Multi-provider adapter interface (abstract Bonvoice)
- [ ] Dashboard: DID management, API keys, call logs
- [ ] Billing meter: call duration + minute rounding
- [ ] HA Redis cluster, multi-region consideration
---
## 17. Deployment Topology
```mermaid
flowchart TB
subgraph Internet
BV[Bonvoice]
CUST[Customer Backends]
end
subgraph YourCloud["Your CPaaS Region (e.g. ap-south-1)"]
LB[L4/L7 Load Balancer]
R1[Relay Node 1]
R2[Relay Node 2]
REDIS[(Redis Cluster)]
API[Control API]
DB[(PostgreSQL)]
end
BV -->|wss| LB
LB --> R1
LB --> R2
R1 --> REDIS
R2 --> REDIS
API --> DB
API --> REDIS
R1 -->|wss| CUST
R2 -->|wss| CUST
CUST -->|REST| API
```
**Notes:**
- WebSocket LB must support connection upgrade and long-lived connections
- Sticky sessions optional if session state is fully in Redis
- Run relay nodes close to Bonvoice infrastructure (India) to minimize RTT
---
## 18. Provider Abstraction (future-proof)
Define a provider interface so Bonvoice is one adapter:
```python
class VoiceProvider(Protocol):
async def initiate_outbound(self, req: OutboundCallRequest) -> ProviderCallRef: ...
def media_websocket_path(self) -> str: ...
def validate_inbound(self, start_event: dict) -> bool: ...
```
Bonvoice adapter implements `autoCallBridging` and protocol quirks (`connected`, `end` events).
---
## 19. Customer Integration Guide (summary for docs)
### 19.1 Inbound
1. Customer claims a DID via your dashboard/API
2. Customer registers upstream WebSocket URL
3. You configure Bonvoice template → `wss://media.yourcpaas.com/v1/media/inbound`
4. Customer implements Bonvoice JSON protocol on their WS server
5. Customer receives `start`, then `media` frames; responds with `media` and optional `clear`
### 19.2 Outbound
1. Customer calls `POST /v1/calls` with API key
2. Optional: pre-connect to `wss://media.yourcpaas.com/v1/upstream/{session_token}`
3. Relay bridges Bonvoice ↔ customer when callee answers
4. Customer handles protocol identically to direct Bonvoice integration
### 19.3 What customers do NOT need
- Bonvoice account or token
- Direct exposure of their WS URL to Bonvoice
- Provider-specific outbound API knowledge
---
## 20. Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| Added latency from relay hop | Raw tunnel, no parsing, regional colocation |
| Upstream connect delay (Mode A) | Offer pre-connect Mode B |
| Single relay node failure | Multiple nodes + LB + Redis |
| Token leakage in URL | Short TTL, single-use, HTTPS/WSS only |
| Tenant sends malformed media | Provider enforces 320-byte rule; relay forwards as-is |
| Bonvoice protocol changes | Provider adapter layer + contract tests |
---
## 21. Success Criteria
| Metric | Target |
|--------|--------|
| Relay processing latency | < 5 ms p99 per frame |
| Call setup (outbound, excl. ring time) | < 200 ms from answer to first media frame forwarded |
| Auth failure rate (invalid sessions) | 100% blocked, 0% bridged |
| Concurrent calls per node | ≥ 500 with < 60% CPU |
| Uptime | 99.9% for media gateway |
---
## 22. Appendix — Mapping from Current PoC
Your existing PoC maps to the relay as follows:
| PoC file | Relay equivalent |
|----------|------------------|
| `main.py` (`handle_media_stream`) | Replace playback logic with `tunnel(provider_ws, customer_ws)` |
| `outbound_call.py` | Becomes internal `BonvoiceProvider.initiate_outbound()` |
| `audio_utils.py` | **Not used in relay** — only in test/demo tooling |
| Bonvoice template endpoint | `wss://<relay>/v1/media/inbound` |
| `voicebotURL` in outbound API | `wss://<relay>/v1/media/{session_token}` |
---
## 23. Appendix — Bonvoice Credentials & API Reference (PoC / Test)
> **Security warning:** This section contains live credentials used during PoC testing.
> Do **not** commit this file to a public repository. Rotate the provider token before production.
> Store secrets in a vault (e.g. AWS Secrets Manager, HashiCorp Vault) in the relay implementation.
### 23.1 Provider account credentials
| Item | Value | Usage |
|------|-------|-------|
| **Auth Token** | `8de97c5932280c84c4bbf36140a1ec7ab7ae0b23` | `Authorization: Token <token>` on Bonvoice REST APIs |
| **Virtual DID** | `7946350706` | Inbound number; use as `legACallerID` on outbound |
| **Provider name** | `BONVOICE` | Voicebot template + `voicebotProvider` field |
### 23.2 Bonvoice API endpoints
| API | Method | URL |
|-----|--------|-----|
| **Auto Call Bridging (outbound voicebot)** | `POST` | `https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/` |
| **Media stream (WebSocket)** | `GET` (Upgrade) | Configured per template / `voicebotURL` — must be `wss://`, not `https://` |
**Outbound request headers:**
```http
Authorization: Token 8de97c5932280c84c4bbf36140a1ec7ab7ae0b23
Content-Type: application/json
```
**Outbound request body (validated PoC example):**
```json
{
"autocallType": "5",
"destination": "9149154337",
"legACallerID": "7946350706",
"eventID": "11A31A12FF1C4455",
"voicebotProvider": "BONVOICE",
"voicebotURL": "wss://<your-relay-or-ngrok-host>/ws/media"
}
```
**Successful outbound response (observed):**
```json
{
"responseCode": 200,
"responseDescription": "Success",
"responseType": "Success",
"eventID": "11A31A12FF1C4455"
}
```
| Field | Description |
|-------|-------------|
| `autocallType` | `"5"` — voicebot auto-call type per Bonvoice API |
| `destination` | Callee E.164 (no `+` in PoC tests) |
| `legACallerID` | Caller ID shown to callee; use assigned DID |
| `eventID` | Unique tracking ID (UUID / random hex recommended) |
| `voicebotProvider` | Must be `BONVOICE` for Bonvoice voicebot templates |
| `voicebotURL` | **Your relay WebSocket URL** — Bonvoice connects here when call is answered |
### 23.3 Inbound voicebot template (Bonvoice portal)
Configure under **Voicebot Templates → Create new template**:
| Field | PoC value |
|-------|-----------|
| Template Name | `test` (or any label) |
| Provider | `BONVOICE` |
| Endpoint | `wss://<your-relay-or-ngrok-host>/ws/media` |
**Important:** Use `wss://` (WebSocket Secure). Using `https://` causes Bonvoice to send HTTP `POST` instead of a WebSocket upgrade, resulting in `404 Not Found`.
### 23.4 Network & infrastructure (observed during PoC)
| Item | Value | Notes |
|------|-------|-------|
| **Bonvoice media server IP** | `103.131.25.5` | Source IP for inbound WebSocket connections — add to provider allowlist |
| **Local dev server** | `http://0.0.0.0:8000` | Uvicorn PoC |
| **PoC WebSocket path** | `/ws/media` | Also accepts `/` in PoC |
| **ngrok URL (ephemeral)** | `https://1af5-2401-4900-88ce-916c-2c02-6571-5bcd-b1eb.ngrok-free.app` | Changes every ngrok restart; WSS = same host with `wss://` |
**PoC WebSocket endpoint used in tests:**
```
wss://1af5-2401-4900-88ce-916c-2c02-6571-5bcd-b1eb.ngrok-free.app/ws/media
```
### 23.5 Test phone numbers
| Number | Role |
|--------|------|
| `7946350706` | Assigned virtual DID (inbound destination / outbound caller ID) |
| `9149154337` | Test mobile used for outbound dial + inbound caller during PoC |
| `9846098460` | Example destination from Bonvoice API documentation |
### 23.6 Observed call / stream identifiers (PoC logs)
Successful inbound WebSocket session (for correlation testing):
| Field | Value |
|-------|-------|
| `stream_id` | `6379` |
| `call_id` | `12775` |
| `from` | `9149154337` |
| `to` | `7946350706` |
Additional WebSocket events observed from Bonvoice (forward in relay; do not drop):
| Event | When |
|-------|------|
| `connected` | Immediately after WS handshake, before `start` |
| `start` | Call metadata — use for DID routing / session binding |
| `media` | Audio frames (320-byte PCM chunks, Base64) |
| `clear` | Barge-in / flush playback buffer |
| `end` | Call teardown |
### 23.7 Ready-to-run curl (outbound)
Replace `voicebotURL` with your current relay or ngrok WSS URL:
```bash
curl -X POST "https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/" \
-H "Authorization: Token 8de97c5932280c84c4bbf36140a1ec7ab7ae0b23" \
-H "Content-Type: application/json" \
-d '{
"autocallType": "5",
"destination": "9149154337",
"legACallerID": "7946350706",
"eventID": "TEST'"$(date +%s)"'",
"voicebotProvider": "BONVOICE",
"voicebotURL": "wss://YOUR-RELAY-HOST/ws/media"
}'
```
PoC script equivalent (auto-detects ngrok URL):
```bash
cd test_bonvoice
source .venv/bin/activate
python3 outbound_call.py 9149154337
```
### 23.8 Environment variables (recommended for relay implementation)
| Variable | PoC / example value |
|----------|---------------------|
| `BONVOICE_TOKEN` | `8de97c5932280c84c4bbf36140a1ec7ab7ae0b23` |
| `BONVOICE_DID` | `7946350706` |
| `BONVOICE_VOICEBOT_URL` | `wss://media.yourcpaas.com/v1/media/inbound` |
| `BONVOICE_API_URL` | `https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/` |
| `BONVOICE_MEDIA_ALLOWLIST` | `103.131.25.5` |
### 23.9 Audio format requirements (provider-enforced)
| Parameter | Value |
|-----------|-------|
| Encoding | PCM |
| Bit depth | 16-bit |
| Sample rate | 8000 Hz |
| Channels | Mono |
| Chunk size | **320 bytes** (multiples of 320 only) |
| Wire format | Base64 in JSON `media.payload` |
---
## 24. Urmi CPaaS — Implemented Customer API Routes
Urmi exposes production voice relay endpoints under `/api/v1/voice` (REST + WebSocket). All outbound calls validate that `from` is a **purchased DID** assigned to the authenticated account.
### 24.1 REST control plane
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/v1/voice/calls` | API key or session cookie | Initiate outbound call |
| `GET` | `/api/v1/voice/calls/{id}` | API key or session cookie | Poll call status |
| `GET` | `/api/v1/voice/docs` | API key or session cookie | API reference for integrators |
**Create outbound call**
```http
POST /api/v1/voice/calls
Authorization: Bearer urmi_live_...
Content-Type: application/json
{
"to": "9149154337",
"from": "7946350706",
"bridge_mode": "upstream",
"upstream": "wss://bot.customer.com/voice"
}
```
Console testing (Urmi dashboard at `/console/voice`):
```json
{
"to": "9149154337",
"from": "7946350706",
"bridge_mode": "console"
}
```
Validation rules:
- `from` must match a `phone_numbers` row with `status=assigned` and `user_id` = caller
- `upstream` is required when `bridge_mode=upstream`
- Relay always injects Bonvoice `voicebotURL` — customers never pass provider URLs
### 24.2 Media relay (provider leg)
| WebSocket | Purpose | Auth |
|-----------|---------|------|
| `wss://<host>/api/v1/voice/ws/media` | Bonvoice outbound voicebotURL | IP allowlist + pending call match on `start` |
| `wss://<host>/api/v1/voice/media/{session_token}` | Legacy alias | Same handler |
Configure Bonvoice outbound API `voicebotURL`:
```
wss://<public-host>/api/v1/voice/ws/media
```
Set `VOICE_RELAY_PUBLIC_URL=wss://<public-host>/api/v1/voice` in production.
### 24.3 Console audio bridge (outbound testing)
| WebSocket | Purpose |
|-----------|---------|
| `wss://<host>/api/v1/voice/console` | Browser mic/speaker bridge for `bridge_mode: console` |
When placing a console-bridged outbound call from `/console/voice`, the account must have an active console WebSocket. Provider media is forwarded to the browser; browser mic frames are relayed back to Bonvoice.
### 24.4 Environment variables (Urmi)
| Variable | Example |
|----------|---------|
| `REDIS_URL` | `redis://redis:6379/0` |
| `BONVOICE_TOKEN` | `8de97c5932280c84c4bbf36140a1ec7ab7ae0b23` |
| `BONVOICE_API_URL` | `https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/` |
| `BONVOICE_MEDIA_ALLOWLIST` | `103.131.25.5` |
| `VOICE_RELAY_PUBLIC_URL` | `wss://media.yourcpaas.com/api/v1/voice` |
---
## 25. Next Steps
1. **Approve** URL scheme and auth model (Section 7, 10)
2. ~~**Implement** Phase 1 tunnel with Redis session store~~ (Urmi MVP shipped)
3. **Migrate** Bonvoice template + outbound API to relay URLs
4. **Load test** at 320-byte / 20 ms frame rate per call
5. **Document** customer-facing API and WebSocket protocol (derived from this plan)
---
*Document maintained by CPaaS platform team. Update when adding providers or changing auth model.*

387
GENERIC_TEMPLATE_SMS.md Normal file
View file

@ -0,0 +1,387 @@
# Ultronsms — generic DLT template SMS
Parameterized integration for sending **any DLT-approved SMS** via [Ultronsms](https://ultronsms.com) by supplying `peid`, `dlttemplateid`, sender ID, and filled template text at runtime.
Use this when you have multiple approved templates (OTP, password reset, alerts, etc.) and want one reusable client instead of hardcoding each flow.
---
## Overview
```
┌──────────────┐ peid + dlttemplateid ┌─────────────────┐ SendSMS ┌─────────────┐
│ Caller │ senderid + message │ Generic client │ ───────────► │ Ultronsms │
│ (your app) │ ───────────────────────► │ (this doc) │ │ gateway │
└──────────────┘ └─────────────────┘ └──────┬──────┘
│ │
│ GetDelivery (optional) │
◄──────────────────────────────┘
```
**Caller provides**
| Input | Description |
|-------|-------------|
| `mobile` | Recipient number (`+91…` or `91…`) |
| `sender_id` | DLT-approved header, e.g. `URMIAI`, `WEBDMG` |
| `pe_id` | Principal Entity ID linked to that sender |
| `dlt_template_id` | Approved content template ID |
| `message` | Final SMS text with all `{#var#}` / `{#num#}` placeholders replaced |
| `channel` | `Trans` (transactional) or `Promo` (promotional) — default `Trans` |
**Client returns**
- Send response with `JobId` and `MessageId`
- Optional delivery report via `GetDelivery`
---
## Environment configuration
Store credentials in environment variables (never commit secrets).
| Variable | Example | Description |
|----------|---------|-------------|
| `ULTRONSMS_BASE_URL` | `https://ultronsms.com` | Gateway base URL |
| `ULTRONSMS_USER` | `GENZENITH` | Account username |
| `ULTRONSMS_PASSWORD` | *(secret)* | Account password |
### Route mapping
| Channel | `channel` param | `route` param |
|---------|-----------------|---------------|
| Transactional | `Trans` | `2` |
| Promotional | `Promo` | `1` |
The UI may show internal route labels (e.g. `T_22`); the API expects numeric routes above.
---
## API endpoints
| Action | Method | URL |
|--------|--------|-----|
| Send SMS | `GET` | `{BASE_URL}/api/mt/SendSMS` |
| Delivery status | `GET` | `{BASE_URL}/api/mt/GetDelivery` |
### SendSMS — all parameters
| Parameter | Required | Notes |
|-----------|----------|-------|
| `user` | yes | From `ULTRONSMS_USER` |
| `password` | yes | From `ULTRONSMS_PASSWORD` |
| `senderid` | yes | DLT-approved header |
| `channel` | yes | `Trans` or `Promo` |
| `route` | yes | `2` (Trans) or `1` (Promo) |
| `number` | yes | Country code + number, no `+` |
| `text` | yes | Filled DLT template body |
| `peid` | yes | PE ID for this sender |
| `dlttemplateid` | yes | Lowercase parameter name |
| `DCS` | yes | `0` = English, `8` = Unicode |
| `flashsms` | yes | `0` = normal, `1` = flash |
### GetDelivery parameters
| Parameter | Required |
|-----------|----------|
| `user` | yes |
| `password` | yes |
| `jobid` | yes — from SendSMS `JobId` |
---
## DLT template rules
1. **Exact match** — Sent text must match the approved template except for variable substitutions.
2. **Replace all placeholders** — Common patterns: `{#var#}`, `{#num#}`. Fill every occurrence.
3. **Sender ↔ PE ID** — Each sender ID is bound to a PE ID in the DLT portal; they must match.
4. **Template ID** — Use the ID from the approved template row, not the PE ID.
### Example templates (verified in testing)
**URMI OTP** (transactional)
| Field | Value |
|-------|-------|
| Sender | `URMIAI` |
| PE ID | `1701178780498045011` |
| Template ID | `1777178797989033323` |
| Template | `Your URMI app verification code is {#num#}. It is valid for {#num#} minutes. Do not share it to anyone - URMI AI` |
| Example message | `Your URMI app verification code is 482916. It is valid for 10 minutes. Do not share it to anyone - URMI AI` |
**Password reset** (transactional)
| Field | Value |
|-------|-------|
| Sender | `WEBDMG` |
| PE ID | `1301159136621106678` |
| Template ID | `1307161537613263303` |
| Template | `Hello Customer, Your new password is '{#var#}'. Thank you. Regards, WebXion` |
| Example message | `Hello Customer, Your new password is 'Kx9#mP2q'. Thank you. Regards, WebXion` |
---
## Python implementation (generic)
```python
import os
import time
from dataclasses import dataclass
from enum import Enum
from typing import Any
from urllib.parse import urlencode
from urllib.request import urlopen
import json
class SmsChannel(str, Enum):
TRANSACTIONAL = "Trans"
PROMOTIONAL = "Promo"
@dataclass(frozen=True)
class DltTemplate:
"""Register each approved template in your app or database."""
sender_id: str
pe_id: str
dlt_template_id: str
channel: SmsChannel = SmsChannel.TRANSACTIONAL
@property
def route(self) -> str:
return "2" if self.channel == SmsChannel.TRANSACTIONAL else "1"
@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 os.environ["ULTRONSMS_BASE_URL"]).rstrip("/")
self.user = user or os.environ["ULTRONSMS_USER"]
self.password = password or os.environ["ULTRONSMS_PASSWORD"]
@staticmethod
def normalize_phone(number: str) -> str:
digits = "".join(c for c in number if c.isdigit())
if len(digits) == 10:
return f"91{digits}"
return digits
def _get(self, path: str, params: dict[str, str]) -> dict[str, Any]:
url = f"{self.base_url}{path}?{urlencode(params)}"
with urlopen(url, timeout=30) as resp:
return json.loads(resp.read().decode())
def send_template_sms(
self,
mobile: str,
message: str,
template: DltTemplate,
*,
dcs: str = "0",
flash_sms: str = "0",
) -> SendResult:
"""
Send SMS using any DLT-approved template.
Args:
mobile: Recipient, e.g. +916296346452
message: Fully substituted DLT template text
template: sender_id, pe_id, dlt_template_id, channel
"""
number = self.normalize_phone(mobile)
params = {
"user": self.user,
"password": self.password,
"senderid": template.sender_id,
"channel": template.channel.value,
"route": template.route,
"DCS": dcs,
"flashsms": flash_sms,
"number": number,
"text": message,
"peid": template.pe_id,
"dlttemplateid": template.dlt_template_id,
}
raw = self._get("/api/mt/SendSMS", params)
ok = raw.get("ErrorCode") == "000"
message_data = (raw.get("MessageData") or [{}])[0]
return SendResult(
success=ok,
job_id=raw.get("JobId"),
message_id=message_data.get("MessageId"),
number=message_data.get("Number"),
raw=raw,
)
def get_delivery(self, job_id: str) -> dict[str, Any]:
return self._get(
"/api/mt/GetDelivery",
{"user": self.user, "password": self.password, "jobid": job_id},
)
def wait_for_delivery(
self,
job_id: str,
*,
timeout_seconds: int = 15,
poll_interval_seconds: float = 3.0,
) -> dict[str, Any]:
deadline = time.time() + timeout_seconds
last: dict[str, Any] = {}
while time.time() < deadline:
last = self.get_delivery(job_id)
reports = last.get("DeliveryReports") or []
if reports and reports[0].get("DeliveryStatus") == "Delivered":
return last
time.sleep(poll_interval_seconds)
return last
```
### Template registry example
```python
TEMPLATES = {
"urmi_otp": DltTemplate(
sender_id="URMIAI",
pe_id="1701178780498045011",
dlt_template_id="1777178797989033323",
channel=SmsChannel.TRANSACTIONAL,
),
"webxion_password": DltTemplate(
sender_id="WEBDMG",
pe_id="1301159136621106678",
dlt_template_id="1307161537613263303",
channel=SmsChannel.TRANSACTIONAL,
),
}
def build_message(template_key: str, **variables: str) -> str:
"""Fill placeholders manually per template — keep in sync with DLT approval."""
builders = {
"urmi_otp": lambda otp, minutes: (
f"Your URMI app verification code is {otp}. "
f"It is valid for {minutes} minutes. "
f"Do not share it to anyone - URMI AI"
),
"webxion_password": lambda password: (
f"Hello Customer, Your new password is '{password}'. "
f"Thank you. Regards, WebXion"
),
}
return builders[template_key](**variables)
```
### Usage
```python
client = UltronsmsClient()
# URMI OTP
result = client.send_template_sms(
mobile="+916296346452",
message=build_message("urmi_otp", otp="482916", minutes="10"),
template=TEMPLATES["urmi_otp"],
)
# Password reset
result = client.send_template_sms(
mobile="+919149154337",
message=build_message("webxion_password", password="Kx9#mP2q"),
template=TEMPLATES["webxion_password"],
)
if result.success:
delivery = client.wait_for_delivery(result.job_id)
print(delivery)
else:
print(result.raw) # handle ErrorCode / ErrorMessage
```
---
## cURL template
Replace `{...}` placeholders at call time.
```bash
curl -sS -L -G "https://ultronsms.com/api/mt/SendSMS" \
--data-urlencode "user=${ULTRONSMS_USER}" \
--data-urlencode "password=${ULTRONSMS_PASSWORD}" \
--data-urlencode "senderid={SENDER_ID}" \
--data-urlencode "channel=Trans" \
--data-urlencode "DCS=0" \
--data-urlencode "flashsms=0" \
--data-urlencode "number={MOBILE_91XXXXXXXXXX}" \
--data-urlencode "text={FILLED_DLT_MESSAGE}" \
--data-urlencode "route=2" \
--data-urlencode "peid={PE_ID}" \
--data-urlencode "dlttemplateid={DLT_TEMPLATE_ID}"
```
**Delivery check**
```bash
curl -sS -L -G "https://ultronsms.com/api/mt/GetDelivery" \
--data-urlencode "user=${ULTRONSMS_USER}" \
--data-urlencode "password=${ULTRONSMS_PASSWORD}" \
--data-urlencode "jobid={JOB_ID}"
```
---
## Response handling
### Success criteria
| API | Success signal |
|-----|----------------|
| SendSMS | `ErrorCode == "000"`, non-empty `JobId` |
| GetDelivery | `DeliveryReports[0].DeliveryStatus == "Delivered"`, `ErrorCode == "000"` |
### Common errors
| ErrorCode | Meaning | Action |
|-----------|---------|--------|
| `7` | Invalid username/password | Check env credentials |
| `25` | Server NullReferenceException | Usually invalid `route` or malformed request |
| Non-`000` on delivery | Operator / DLT rejection | Verify template text, PE ID, sender binding |
---
## Adding a new template
1. Register header and content on the DLT portal; wait for **Approved** status.
2. Note **Sender ID**, **PE ID**, and **Template ID** from the Ultronsms dashboard.
3. Add a `DltTemplate` entry to your registry.
4. Implement a message builder that produces text identical to the approved template (variables filled only).
5. Send one test SMS to a real number; confirm delivery via `GetDelivery`.
6. Only then enable in production.
---
## Security notes
- Load `ULTRONSMS_PASSWORD` from secrets manager or `.env` (gitignored).
- Never expose gateway credentials to frontend or mobile apps.
- Log `JobId` and `MessageId` for support; avoid logging full message content if it contains OTPs or passwords.
- Use the demo account (`GENZENITHDemo`) for staging only.
---
## Related document
- [OTP_SMS_IMPLEMENTATION.md](./OTP_SMS_IMPLEMENTATION.md) — URMI OTP flow with all values hardcoded for quick drop-in use.

Binary file not shown.

310
OTP_SMS_IMPLEMENTATION.md Normal file
View file

@ -0,0 +1,310 @@
# Ultronsms — URMI OTP flow (hardcoded)
Hardcoded integration for sending **URMI app OTP verification SMS** via [Ultronsms](https://ultronsms.com). Parameters below were verified with live sends and delivery reports on **2026-08-30**.
---
## Overview
```
┌─────────────┐ generate OTP ┌──────────────┐ SendSMS API ┌─────────────┐
│ Your app │ ────────────────────► │ SMS client │ ──────────────────► │ Ultronsms │
│ (backend) │ │ (this doc) │ │ gateway │
└─────────────┘ └──────────────┘ └──────┬──────┘
│ │ │
│ │ GetDelivery API │
│ ◄──────────────────────────────────────┘
│ │
▼ ▼
Store OTP + expiry Delivered / Failed
```
**Flow**
1. Generate a numeric OTP (e.g. 6 digits).
2. Call `SendSMS` with the DLT-approved template text (OTP + validity substituted).
3. Store OTP server-side with expiry; return success/failure to the client (never return the OTP in API responses if SMS is the only channel).
4. Optionally poll `GetDelivery` with the returned `JobId` for delivery confirmation.
---
## Hardcoded configuration
These values are fixed for the URMI OTP use case.
| Setting | Value |
|---------|-------|
| **Base URL** | `https://ultronsms.com` |
| **Username** | `GENZENITH` |
| **Password** | `dssdl@33` |
| **Sender ID** | `URMIAI` |
| **Channel** | `Trans` (Transactional) |
| **Route** | `2` |
| **PE ID** (`peid`) | `1701178780498045011` |
| **DLT Template ID** (`dlttemplateid`) | `1777178797989033323` |
| **DCS** | `0` (English) |
| **Flash SMS** | `0` (normal SMS) |
### DLT-approved template
**Registered text** (placeholders `{#num#}` × 2):
```
Your URMI app verification code is {#num#}. It is valid for {#num#} minutes. Do not share it to anyone - URMI AI
```
**Sent text** (example — replace variables only):
```
Your URMI app verification code is 482916. It is valid for 10 minutes. Do not share it to anyone - URMI AI
```
The message body must match the approved template character-for-character except for the variable values.
---
## API endpoints
| Action | Method | URL |
|--------|--------|-----|
| Send SMS | `GET` | `https://ultronsms.com/api/mt/SendSMS` |
| Delivery status | `GET` | `https://ultronsms.com/api/mt/GetDelivery` |
Always use **HTTPS**. HTTP returns `301 Moved Permanently`.
### SendSMS query parameters
| Parameter | Required | OTP value |
|-----------|----------|-----------|
| `user` | yes | `GENZENITH` |
| `password` | yes | `dssdl@33` |
| `senderid` | yes | `URMIAI` |
| `channel` | yes | `Trans` |
| `route` | yes | `2` |
| `number` | yes | E.164 without `+`, e.g. `916296346452` |
| `text` | yes | Filled DLT template (see above) |
| `peid` | yes | `1701178780498045011` |
| `dlttemplateid` | yes | `1777178797989033323` |
| `DCS` | yes | `0` |
| `flashsms` | yes | `0` |
### GetDelivery query parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `user` | yes | Same as SendSMS |
| `password` | yes | Same as SendSMS |
| `jobid` | yes | `JobId` from SendSMS response |
---
## Response reference
### SendSMS — success
```json
{
"ErrorCode": "000",
"ErrorMessage": "Done",
"JobId": "211261464",
"MessageData": [
{
"Number": "916296346452",
"MessageId": "X4jHLUsSDUSu6cD3yodiYw",
"Message": null
}
]
}
```
### SendSMS — common errors
| ErrorCode | ErrorMessage | Cause |
|-----------|--------------|-------|
| `7` | username or password is invalid | Wrong credentials |
| `25` | NullReferenceException (server) | Invalid `route` (e.g. UI label `T_22` instead of `2`) |
### GetDelivery — success
```json
{
"ErrorCode": "0",
"ErrorMessage": "Done",
"DeliveryReports": [
{
"MessageId": "X4jHLUsSDUSu6cD3yodiYw",
"DeliveryStatus": "Delivered",
"DeliveryDate": "2026-08-30T11:37:18.523",
"ErrorCode": "000"
}
]
}
```
`DeliveryStatus` may be `Sent` briefly before updating to `Delivered`. Poll again after 3–5 seconds if needed.
---
## Python implementation (hardcoded)
```python
import random
import time
from urllib.parse import urlencode
from urllib.request import urlopen
# --- Hardcoded URMI OTP config ---
ULTRONSMS_BASE = "https://ultronsms.com"
ULTRONSMS_USER = "GENZENITH"
ULTRONSMS_PASSWORD = "dssdl@33"
SENDER_ID = "URMIAI"
PE_ID = "1701178780498045011"
DLT_TEMPLATE_ID = "1777178797989033323"
OTP_VALIDITY_MINUTES = 10
def _get_json(url: str) -> dict:
with urlopen(url, timeout=30) as resp:
import json
return json.loads(resp.read().decode())
def _build_url(path: str, params: dict) -> str:
return f"{ULTRONSMS_BASE}{path}?{urlencode(params)}"
def generate_otp(length: int = 6) -> str:
return "".join(str(random.randint(0, 9)) for _ in range(length))
def format_otp_message(otp: str, validity_minutes: int = OTP_VALIDITY_MINUTES) -> str:
return (
f"Your URMI app verification code is {otp}. "
f"It is valid for {validity_minutes} minutes. "
f"Do not share it to anyone - URMI AI"
)
def normalize_phone(number: str) -> str:
"""Convert +91XXXXXXXXXX or 91XXXXXXXXXX to gateway format."""
digits = "".join(c for c in number if c.isdigit())
if len(digits) == 10:
return f"91{digits}"
return digits
def send_urmi_otp(mobile: str, otp: str | None = None) -> dict:
"""
Send URMI OTP SMS. Returns gateway response including JobId.
If otp is None, generates a new 6-digit code.
"""
code = otp or generate_otp()
params = {
"user": ULTRONSMS_USER,
"password": ULTRONSMS_PASSWORD,
"senderid": SENDER_ID,
"channel": "Trans",
"route": "2",
"DCS": "0",
"flashsms": "0",
"number": normalize_phone(mobile),
"text": format_otp_message(code),
"peid": PE_ID,
"dlttemplateid": DLT_TEMPLATE_ID,
}
result = _get_json(_build_url("/api/mt/SendSMS", params))
result["_otp"] = code # for app storage; do not expose to client
return result
def get_delivery(job_id: str) -> dict:
params = {
"user": ULTRONSMS_USER,
"password": ULTRONSMS_PASSWORD,
"jobid": job_id,
}
return _get_json(_build_url("/api/mt/GetDelivery", params))
def send_otp_and_wait_delivery(mobile: str, max_wait_seconds: int = 15) -> dict:
"""Send OTP and poll delivery until Delivered or timeout."""
send_result = send_urmi_otp(mobile)
if send_result.get("ErrorCode") != "000":
return {"send": send_result, "delivery": None, "otp": send_result.get("_otp")}
job_id = send_result["JobId"]
deadline = time.time() + max_wait_seconds
delivery = None
while time.time() < deadline:
delivery = get_delivery(job_id)
reports = delivery.get("DeliveryReports") or []
if reports and reports[0].get("DeliveryStatus") == "Delivered":
break
time.sleep(3)
return {
"send": send_result,
"delivery": delivery,
"otp": send_result["_otp"],
}
```
### Usage
```python
result = send_otp_and_wait_delivery("+916296346452")
otp = result["otp"] # store in Redis/DB with expiry
job_id = result["send"]["JobId"]
```
---
## cURL (quick test)
**Send OTP**
```bash
curl -sS -L -G "https://ultronsms.com/api/mt/SendSMS" \
--data-urlencode "user=GENZENITH" \
--data-urlencode "password=dssdl@33" \
--data-urlencode "senderid=URMIAI" \
--data-urlencode "channel=Trans" \
--data-urlencode "DCS=0" \
--data-urlencode "flashsms=0" \
--data-urlencode "number=916296346452" \
--data-urlencode "text=Your URMI app verification code is 482916. It is valid for 10 minutes. Do not share it to anyone - URMI AI" \
--data-urlencode "route=2" \
--data-urlencode "peid=1701178780498045011" \
--data-urlencode "dlttemplateid=1777178797989033323"
```
**Check delivery**
```bash
curl -sS -L -G "https://ultronsms.com/api/mt/GetDelivery" \
--data-urlencode "user=GENZENITH" \
--data-urlencode "password=dssdl@33" \
--data-urlencode "jobid=211261464"
```
---
## Application integration checklist
- [ ] Generate OTP server-side; store with TTL matching template validity (10 minutes).
- [ ] Rate-limit OTP requests per phone number and IP.
- [ ] Do not log full OTP or password in application logs.
- [ ] Verify user-submitted OTP against stored value before marking session verified.
- [ ] Handle `ErrorCode != 000` with a generic user message ("Unable to send OTP, try again").
- [ ] Use demo account (`GENZENITHDemo`) only in non-production environments.
---
## Verified test (2026-08-30)
| Field | Value |
|-------|-------|
| Number | `+91 6296346452` |
| Job ID | `211261464` |
| Message ID | `X4jHLUsSDUSu6cD3yodiYw` |
| Delivery | **Delivered** at `2026-08-30T11:37:18.523` |

63
README.md Normal file
View file

@ -0,0 +1,63 @@
# Urmi
Phase-one CPaaS console skeleton with independently deployable frontend, API, and data infrastructure.
## Modules
- `frontend/` — Vite, React, TypeScript console
- `backend/` — FastAPI control-plane API
- `database/` — PostgreSQL bootstrap schema and seed data
- `docker-compose.yml` — local modular development stack
## Run locally
```bash
docker compose up --build
```
- Console: http://localhost:5173
- API docs: http://localhost:8000/docs
- API health: http://localhost:8000/health
## Environment configuration
Urmi uses separate env templates for each layer:
| Template | Copy to | When |
|----------|---------|------|
| `.env.example` | `.env` | Docker Compose (backend auth + Google OAuth) |
| `backend/.env.example` | `backend/.env` | Running FastAPI on the host |
| `frontend/.env.example` | `frontend/.env` | Running Vite on the host |
Full variable reference, flows, and troubleshooting: **[docs/ENVIRONMENT.md](docs/ENVIRONMENT.md)**
Quick start for Compose:
```bash
cp .env.example .env
docker compose up --build
```
Email/password auth works without Google vars. Set `AUTH_SECRET` before any non-local deploy.
## Authentication
Google sign-in requires `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and this redirect URI in Google Cloud Console:
```text
http://localhost:8000/api/v1/auth/google/callback
```
The canonical `user_accounts` table is separate from `password_credentials` and `oidc_identities`. A verified Google identity is linked to an existing user by normalized email, preventing duplicate accounts. Google-first users can add a password from **Console → profile → Sign-in & security**.
Core development starts PostgreSQL, Redis, the backend, and frontend. Optional phase-one infrastructure is isolated behind Compose profiles:
```bash
docker compose --profile data-plane --profile analytics --profile object-storage up --build
```
This additionally starts Kafka, ClickHouse, and MinIO (a local S3-compatible store).
## Architectural boundary
The frontend never accesses a database directly. The FastAPI service owns transactional PostgreSQL access. Kafka, ClickHouse, and object storage are available as separate infrastructure modules for data-plane events, analytics, and files as those features are implemented.

BIN
Updated LOA.docx Normal file

Binary file not shown.

45
backend/.env.example Normal file
View file

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

14
backend/Dockerfile Normal file
View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

View file

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

View file

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

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

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

View file

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

View file

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

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

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

View file

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

View file

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

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

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

Binary file not shown.

Binary file not shown.

13
backend/requirements.txt Normal file
View file

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

View file

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

View file

@ -0,0 +1,86 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Canonical user data is independent of every authentication method.
CREATE TABLE IF NOT EXISTS user_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(320) UNIQUE NOT NULL,
display_name VARCHAR(160) NOT NULL,
avatar_url VARCHAR(1000),
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(24) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS password_credentials (
user_id UUID PRIMARY KEY REFERENCES user_accounts(id) ON DELETE CASCADE,
password_hash VARCHAR(500) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oidc_identities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES user_accounts(id) ON DELETE CASCADE,
issuer VARCHAR(500) NOT NULL,
subject VARCHAR(500) NOT NULL,
provider_email VARCHAR(320) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_oidc_issuer_subject UNIQUE (issuer, subject),
CONSTRAINT uq_oidc_user_issuer UNIQUE (user_id, issuer)
);
CREATE INDEX IF NOT EXISTS idx_oidc_user_id ON oidc_identities(user_id);
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(160) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
home_region VARCHAR(32) NOT NULL DEFAULT 'ap-south-1',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id),
name VARCHAR(160) NOT NULL,
environment VARCHAR(24) NOT NULL DEFAULT 'test',
status VARCHAR(24) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS activity_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id),
event_type VARCHAR(80) NOT NULL,
title VARCHAR(200) NOT NULL,
detail VARCHAR(500) NOT NULL DEFAULT '',
status VARCHAR(24) NOT NULL DEFAULT 'success',
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_activity_project_time
ON activity_events(project_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_type
ON activity_events(event_type);
WITH org AS (
INSERT INTO organizations (name, slug)
VALUES ('Urmi Technologies', 'urmi-technologies')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id
), project AS (
INSERT INTO projects (organization_id, name, environment)
SELECT id, 'Production API', 'production' FROM org
RETURNING id
)
INSERT INTO activity_events (project_id, event_type, title, detail, status)
SELECT project.id, seed.event_type, seed.title, seed.detail, seed.status
FROM project
CROSS JOIN (VALUES
('call.completed', 'Outbound call completed', '+91 98765 43210 · 02:14', 'success'),
('message.delivered', 'Message delivered', 'msg_8f7g2h1 · +91 99876 54321', 'success'),
('dlt.template.approved', 'DLT template approved', 'OTP_LOGIN · URMIOT', 'success'),
('webhook.retried', 'Webhook delivery retried', 'endpoint_checkout · attempt 2', 'warning')
) AS seed(event_type, title, detail, status);

127
docker-compose.yml Normal file
View file

@ -0,0 +1,127 @@
name: urmi
services:
frontend:
build: ./frontend
ports:
- "5173:5173"
environment:
VITE_API_URL: http://localhost:8000
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
depends_on:
backend:
condition: service_healthy
backend:
build: ./backend
ports:
- "8000:8000"
extra_hosts:
- "host.docker.internal:host-gateway"
env_file:
- path: .env
required: false
environment:
DATABASE_URL: postgresql+psycopg://urmi:urmi@postgres:5432/urmi
REDIS_URL: redis://redis:6379/0
FRONTEND_ORIGIN: http://localhost:5173
AUTH_SECRET: ${AUTH_SECRET:-urmi-local-development-secret-change-me}
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:8000/api/v1/auth/google/callback}
ADMIN_USERNAME: ${ADMIN_USERNAME:-root}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-Nishtha#01}
ULTRONSMS_BASE_URL: ${ULTRONSMS_BASE_URL:-https://ultronsms.com}
ULTRONSMS_USER: ${ULTRONSMS_USER:-}
ULTRONSMS_PASSWORD: ${ULTRONSMS_PASSWORD:-}
BONVOICE_TOKEN: ${BONVOICE_TOKEN:-}
BONVOICE_API_URL: ${BONVOICE_API_URL:-https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/}
BONVOICE_MEDIA_ALLOWLIST: ${BONVOICE_MEDIA_ALLOWLIST:-103.131.25.5}
VOICE_RELAY_PUBLIC_URL: ${VOICE_RELAY_PUBLIC_URL:-ws://localhost:8000/api/v1/voice}
UPLOAD_DIR: uploads
KYC_TEMPLATE_PATH: assets/kyc-template.docx
volumes:
- ./data/uploads:/app/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 5s
timeout: 3s
retries: 10
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: urmi
POSTGRES_USER: urmi
POSTGRES_PASSWORD: urmi
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U urmi -d urmi"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:8-alpine
command: redis-server --appendonly yes
ports:
- "6379:6379"
volumes:
- redis_data:/data
kafka:
image: bitnami/kafka:4.0
profiles: ["data-plane"]
ports:
- "9092:9092"
environment:
KAFKA_CFG_NODE_ID: 0
KAFKA_CFG_PROCESS_ROLES: controller,broker
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
volumes:
- kafka_data:/bitnami/kafka
clickhouse:
image: clickhouse/clickhouse-server:25
profiles: ["analytics"]
ports:
- "8123:8123"
- "9000:9000"
volumes:
- clickhouse_data:/var/lib/clickhouse
minio:
image: minio/minio:latest
profiles: ["object-storage"]
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: urmi
MINIO_ROOT_PASSWORD: urmi-development-only
ports:
- "9001:9001"
- "9002:9000"
volumes:
- object_data:/data
volumes:
postgres_data:
redis_data:
kafka_data:
clickhouse_data:
object_data:

54
docs/BATCH_JOBS_TODO.md Normal file
View file

@ -0,0 +1,54 @@
# Batch jobs — migrate / improve later
Campaign CSV runs and scheduled sends are **UI + data-model only** for now. Actual delivery is stubbed.
## Current stack (Docker)
| Service | Status | Role |
|---------|--------|------|
| **Redis** | Running, unused | Recommended job broker |
| **Kafka** | Profile `data-plane`, off | Event stream at scale |
| **Postgres** | Running | Job state + queue (fallback) |
| **ClickHouse** | Profile `analytics`, off | Analytics rollups |
| **MinIO** | Profile `object-storage`, off | CSV / export storage |
## Recommended migration path
### Phase 1 — Redis worker (short term)
1. Wire `REDIS_URL=redis://redis:6379/0` into backend and a new `worker` service (same image as API, different `command`).
2. Add **Celery**, **RQ**, or **ARQ** to `backend/requirements.txt`.
3. Move campaign run logic from stub endpoints to background tasks:
- `run_campaign_csv_upload(upload_id)`
- `run_campaign_automation(campaign_id)` — batch all validated CSVs
4. Campaign CSV upload with `run_mode=on_upload` should enqueue a job after validation instead of inline stub.
5. Scheduled campaigns (`run_mode=scheduled`) need a periodic beat/cron worker to enqueue due runs.
### Phase 2 — Kafka (when volume grows)
1. `docker compose --profile data-plane up -d`
2. Publish outbound SMS events; consume with dedicated send workers.
3. Use ClickHouse profile for delivery analytics.
### Phase 3 — Object storage
1. `docker compose --profile object-storage up -d`
2. Store campaign CSVs in MinIO instead of local `uploads/campaigns/`.
## Campaign send integration checklist
- [ ] Replace stub `POST .../run` handlers with worker enqueue
- [ ] Implement Ultronsms batch/chunked send in worker
- [ ] Persist per-row send results (success / failure / message_id)
- [ ] Retry failed rows with backoff
- [ ] Respect `scheduled_at` for scheduled campaigns
- [ ] Idempotency key per CSV row to avoid duplicate sends on retry
- [ ] Rate limiting per sender / DLT template
## Files to touch when implementing
- `backend/app/routes_campaigns.py` — replace `_stub_run_upload` with enqueue
- `backend/app/campaign_csv_service.py` — row iteration + send hook
- `backend/app/message_send_service.py` — reuse template validation + gateway client
- `docker-compose.yml` — `worker` service + Redis dependency on backend
- `frontend/src/features/campaigns/` — show real run progress / row counts

260
docs/ENVIRONMENT.md Normal file
View file

@ -0,0 +1,260 @@
# Urmi environment configuration
This document tracks every environment variable, where it lives, and how it flows through local development and Docker.
## Quick start
### Docker Compose (recommended)
```bash
cp .env.example .env
# Edit .env — at minimum set AUTH_SECRET; add Google vars if using Google sign-in
docker compose up --build
```
### Run services outside Docker
```bash
# Terminal 1 — Postgres must already be running (e.g. via compose)
cp backend/.env.example backend/.env
# Terminal 2
cd backend && pip install -r requirements.txt && uvicorn app.main:app --reload
# Terminal 3
cp frontend/.env.example frontend/.env
cd frontend && npm install && npm run dev
```
---
## Env file locations
| File | Used by | Purpose |
|------|---------|---------|
| `.env` (repo root) | Docker Compose | Auth secrets + Google OAuth for the backend container |
| `.env.example` (repo root) | You | Template for Compose |
| `backend/.env` | FastAPI (local run) | Full backend config when not using Compose |
| `backend/.env.example` | You | Template for local backend |
| `frontend/.env` | Vite (local run) | Browser API URL |
| `frontend/.env.example` | You | Template for local frontend |
**There is no committed `.env` file.** Only `.env.example` templates are checked in. Copy the relevant example before running.
---
## How variables flow
### Docker Compose path
```text
.env (repo root)
│
├─► docker-compose.yml ${VAR} substitution
│
└─► backend container environment
│
└─► pydantic Settings in backend/app/config.py
```
Compose **loads** the root `.env` for `${VAR}` substitution in the compose file automatically. The backend service also mounts `.env` via `env_file` when the file exists (`required: false` — stack starts without it).
| Compose env var | Backend setting | Default if unset |
|-----------------|-----------------|------------------|
| `DATABASE_URL` | `database_url` | `postgresql+psycopg://urmi:urmi@postgres:5432/urmi` |
| `FRONTEND_ORIGIN` | `frontend_origin` | `http://localhost:5173` |
| `AUTH_SECRET` | `auth_secret` | `urmi-local-development-secret-change-me` |
| `AUTH_COOKIE_SECURE` | `auth_cookie_secure` | `false` |
| `GOOGLE_CLIENT_ID` | `google_client_id` | empty |
| `GOOGLE_CLIENT_SECRET` | `google_client_secret` | empty |
| `GOOGLE_REDIRECT_URI` | `google_redirect_uri` | `http://localhost:8000/api/v1/auth/google/callback` |
| `ULTRONSMS_BASE_URL` | `ultronsms_base_url` | `https://ultronsms.com` |
| `ULTRONSMS_USER` | `ultronsms_user` | empty |
| `ULTRONSMS_PASSWORD` | `ultronsms_password` | empty |
Frontend in Compose:
| Compose env var | Used by | Default |
|-----------------|---------|---------|
| `VITE_API_URL` | `frontend/src/lib/api.ts` | `http://localhost:8000` |
Postgres in Compose (not read by backend config today — fixed in compose):
| Compose env var | Value |
|-----------------|-------|
| `POSTGRES_DB` | `urmi` |
| `POSTGRES_USER` | `urmi` |
| `POSTGRES_PASSWORD` | `urmi` |
Host port mapping: Postgres is exposed on **5433** (not 5432) to avoid clashing with a local Postgres install.
### Local backend path (no Docker for API)
```text
backend/.env ──or── ../.env (repo root)
│
└─► pydantic Settings (backend/app/config.py)
```
When running from `backend/`:
```bash
uvicorn app.main:app --reload
```
Settings are loaded from environment variables. Pydantic also reads:
1. `backend/.env`
2. `../.env` (repo root)
**Important:** Use host port **5433** in `DATABASE_URL` when Postgres runs via Compose but the API runs on the host:
```text
DATABASE_URL=postgresql+psycopg://urmi:urmi@localhost:5433/urmi
```
Inside the backend container, use the internal service name and port 5432:
```text
DATABASE_URL=postgresql+psycopg://urmi:urmi@postgres:5432/urmi
```
### Local frontend path (no Docker for UI)
```text
frontend/.env
│
└─► VITE_API_URL ──► import.meta.env.VITE_API_URL
```
Vite bakes `VITE_*` variables at dev/build time. Restart the dev server after changing `frontend/.env`.
---
## Backend variables (reference)
Defined in `backend/app/config.py`.
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `APP_NAME` | No | `Urmi API` | OpenAPI title |
| `ENVIRONMENT` | No | `development` | Informational |
| `DATABASE_URL` | Yes* | localhost URL | *Required for real DB access |
| `FRONTEND_ORIGIN` | Yes | `http://localhost:5173` | CORS + OAuth redirect target |
| `AUTH_SECRET` | **Yes in prod** | weak dev default | Signs session JWT cookies |
| `AUTH_COOKIE_SECURE` | No | `false` | Set `true` when served over HTTPS |
| `ACCESS_TOKEN_MINUTES` | No | `10080` (7 days) | Session lifetime |
| `GOOGLE_CLIENT_ID` | For Google login | empty | OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | For Google login | empty | OAuth client secret |
| `GOOGLE_REDIRECT_URI` | For Google login | localhost callback | Must match Google Console |
Pydantic accepts both `SCREAMING_SNAKE` env names and lowercase field names.
---
## Frontend variables (reference)
| Variable | Required | Default | Notes |
|----------|----------|---------|-------|
| `VITE_API_URL` | No | `http://localhost:8000` | All API calls + Google OAuth start URL |
---
## Authentication env checklist
### Email / password only (works out of the box)
- [ ] Copy `.env.example` → `.env` at repo root (Compose) or `backend/.env.example` → `backend/.env` (local API)
- [ ] Set a strong `AUTH_SECRET` before any shared/staging deploy
- [ ] Ensure `FRONTEND_ORIGIN` matches the URL you open in the browser
### Google sign-in
- [ ] Create a **Web application** OAuth client in [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
- [ ] Authorized redirect URI:
```text
http://localhost:8000/api/v1/auth/google/callback
```
- [ ] Set in `.env` / `backend/.env`:
```text
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/callback
```
- [ ] Restart backend after changing auth env vars
- [ ] `FRONTEND_ORIGIN` must match where users land after OAuth (default `http://localhost:5173`)
If Google vars are empty, the **Continue with Google** button returns `503 Google sign-in is not configured`.
---
## Infrastructure services (Compose)
These are configured in `docker-compose.yml` today. The backend does **not** consume them yet — documented here for when they are wired.
| Service | Profile | Key env | Host ports |
|---------|---------|---------|------------|
| Postgres | core | `POSTGRES_*` | 5433 → 5432 |
| Redis | core | none | 6379 |
| Kafka | `data-plane` | `KAFKA_CFG_*` | 9092 |
| ClickHouse | `analytics` | none | 8123, 9000 |
| MinIO | `object-storage` | `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD` | 9001, 9002 |
Start optional profiles:
```bash
docker compose --profile data-plane --profile analytics --profile object-storage up
```
### Planned backend env (not implemented yet)
| Variable | Purpose |
|----------|---------|
| `REDIS_URL` | Rate limits, OTP state, cache |
| `KAFKA_BOOTSTRAP_SERVERS` | Event bus |
| `CLICKHOUSE_URL` | Analytics ingest |
| `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET` | Object storage (MinIO locally) |
---
## Production notes
1. **Never commit `.env`** — only `.env.example` templates.
2. **Rotate `AUTH_SECRET`** — invalidates all sessions when changed.
3. **Set `AUTH_COOKIE_SECURE=true`** behind HTTPS.
4. **Use strong Postgres credentials** — replace compose defaults.
5. **Update `FRONTEND_ORIGIN` and `GOOGLE_REDIRECT_URI`** for your production domain.
6. **Frontend build:** pass `VITE_API_URL` at build time for production API host.
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `/api/v1/auth/me` 401 after login | Cookie not sent | Same-site: frontend and API origins must work with `credentials: 'include'`; check CORS `FRONTEND_ORIGIN` |
| Google button 503 | Missing OAuth vars | Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`, restart backend |
| Google redirect error | Redirect URI mismatch | Match `GOOGLE_REDIRECT_URI` exactly in Google Console |
| Backend can't connect to DB (local API) | Wrong host/port | Use `localhost:5433` when Postgres runs in Docker |
| Backend can't connect to DB (container) | Wrong host | Use `postgres:5432`, not `localhost` |
| Frontend calls wrong API | Stale Vite env | Set `VITE_API_URL` in `frontend/.env`, restart `npm run dev` |
| Changes to root `.env` ignored | Compose not restarted | `docker compose up -d --build backend` |
---
## Maintenance log
| Date | Change |
|------|--------|
| 2026-08-30 | Initial env doc; added `backend/.env.example`, `frontend/.env.example`; expanded root `.env.example` |
When adding new env-dependent features, update:
1. `backend/app/config.py` (or relevant service config)
2. Matching `.env.example` file(s)
3. `docker-compose.yml` if Compose should pass the variable
4. This document

7
frontend/.env.example Normal file
View file

@ -0,0 +1,7 @@
# Copy to frontend/.env when running Vite outside Docker:
# cp .env.example .env
#
# Vite only exposes variables prefixed with VITE_ to the browser.
# Backend API base URL (no trailing slash)
VITE_API_URL=http://localhost:8000

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

9
frontend/Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

32
frontend/README.md Normal file
View file

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

15
frontend/index.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/urmi-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#071a36" />
<meta name="description" content="Urmi communications infrastructure console" />
<title>Urmi Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1635
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
frontend/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"recharts": "^3.10.1"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vite": "^8.2.0"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

184
frontend/src/App.css Normal file
View file

@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

77
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,77 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AppShell } from './components/AppShell'
import { AccountSettings } from './features/auth/AccountSettings'
import { AuthPage } from './features/auth/AuthPage'
import { ProtectedRoute } from './features/auth/ProtectedRoute'
import { Dashboard } from './features/dashboard/Dashboard'
import { LandingPage } from './features/landing/LandingPage'
import { MessageSettings } from './features/messaging/MessageSettings'
import { ComposeMessage } from './features/messaging/ComposeMessage'
import { TemplatesPage } from './features/messaging/TemplatesPage'
import { ApiKeysPage } from './features/develop/ApiKeysPage'
import { ApiExplorerPage } from './features/develop/ApiExplorerPage'
import { WebhooksSocketsPage } from './features/develop/WebhooksSocketsPage'
import { CampaignsPage } from './features/campaigns/CampaignsPage'
import { CampaignDetailPage } from './features/campaigns/CampaignDetailPage'
import { BuyNumbersPage, MyNumbersPage } from './features/numbers/NumbersPages'
import { VoicePage } from './features/voice/VoicePage'
import { TestingCenterPage } from './features/testing/TestingCenterPage'
import { LogsPage } from './features/logs/LogsPage'
import { AnalyticsPage } from './features/analytics/AnalyticsPage'
import { UrmiOtpPage } from './features/agent/UrmiOtpPage'
import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage'
import { KycPage } from './features/compliance/KycPage'
import { DltPage } from './features/compliance/DltPage'
import { ComplianceGate } from './features/compliance/ComplianceProvider'
import { AdminApp } from './features/admin/AdminPortal'
function Placeholder() {
return (
<section className="placeholder">
<span className="eyebrow">Urmi workspace</span>
<h1>Module workspace</h1>
<p>This product area is ready for the next iteration.</p>
</section>
)
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<AuthPage initialMode="login" />} />
<Route path="/register" element={<AuthPage initialMode="register" />} />
<Route path="/admin/*" element={<AdminApp />} />
<Route element={<ProtectedRoute />}>
<Route path="/console" element={<AppShell />}>
<Route path="kyc" element={<KycPage />} />
<Route path="dlt" element={<DltPage />} />
<Route element={<ComplianceGate />}>
<Route index element={<Dashboard />} />
<Route path="account" element={<AccountSettings />} />
<Route path="message-settings" element={<MessageSettings />} />
<Route path="templates" element={<TemplatesPage />} />
<Route path="messaging" element={<ComposeMessage />} />
<Route path="campaigns" element={<CampaignsPage />} />
<Route path="campaigns/:campaignId" element={<CampaignDetailPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
<Route path="api-explorer" element={<ApiExplorerPage />} />
<Route path="webhooks" element={<WebhooksSocketsPage />} />
<Route path="testing" element={<TestingCenterPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="numbers/buy" element={<BuyNumbersPage />} />
<Route path="numbers" element={<MyNumbersPage />} />
<Route path="voice" element={<VoicePage />} />
<Route path="agent/urmiotp" element={<UrmiOtpPage />} />
<Route path="agent/urmiotp/projects/:projectId/docs" element={<UrmiOtpDocsPage />} />
<Route path=":section/*" element={<Placeholder />} />
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,146 @@
import { useState } from 'react'
import {
Activity, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign,
Code2, CreditCard, Cpu, FileCheck2, Gauge, KeyRound, LayoutDashboard,
LogOut, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck,
SlidersHorizontal, TestTube2, Users, Webhook, X, Zap,
} from 'lucide-react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useAuth } from '../features/auth/auth-context'
import { ComplianceProvider } from '../features/compliance/ComplianceProvider'
import { useCompliance } from '../features/compliance/compliance-context'
import { COMPLIANCE_ONLY_PATHS } from '../features/compliance/compliance-context'
const groups = [
{ label: 'Compliance', links: [
{ label: 'KYC', path: '/console/kyc', icon: ShieldCheck },
{ label: 'DLT', path: '/console/dlt', icon: FileCheck2 },
{ label: 'Message settings', path: '/console/message-settings', icon: SlidersHorizontal },
]},
{ label: 'Workspace', links: [
{ label: 'Overview', path: '/console', icon: LayoutDashboard },
{ label: 'Activity', path: '/console/activity', icon: Activity },
]},
{ label: 'Agent', links: [
{ label: 'UrmiAI', path: '/console/agent/urmiai', icon: Bot },
{ label: 'UrmIOT', path: '/console/agent/urmiot', icon: Cpu },
{ label: 'UrmiOTP', path: '/console/agent/urmiotp', icon: KeyRound },
]},
{ label: 'Communicate', links: [
{ label: 'Messaging', path: '/console/messaging', icon: MessageSquareText },
{ label: 'Campaigns', path: '/console/campaigns', icon: Zap },
{ label: 'Templates', path: '/console/templates', icon: BookOpen },
]},
{ label: 'Numbers', links: [
{ label: 'Voice', path: '/console/voice', icon: Phone },
{ label: 'Buy Numbers', path: '/console/numbers/buy', icon: Search },
{ label: 'My Numbers', path: '/console/numbers', icon: Network, end: true },
]},
{ label: 'Develop', links: [
{ label: 'Webhooks & Sockets', path: '/console/webhooks', icon: Webhook },
{ label: 'API Keys', path: '/console/api-keys', icon: KeyRound },
{ label: 'API Explorer', path: '/console/api-explorer', icon: Code2 },
]},
{ label: 'Test & observe', links: [
{ label: 'Testing Center', path: '/console/testing', icon: TestTube2 },
{ label: 'Logs', path: '/console/logs', icon: Boxes },
{ label: 'Analytics', path: '/console/analytics', icon: Gauge },
]},
{ label: 'Manage', links: [
{ label: 'Billing', path: '/console/billing', icon: CreditCard },
{ label: 'Team', path: '/console/team', icon: Users },
{ label: 'Settings', path: '/console/settings', icon: Settings },
]},
]
function AppShellContent() {
const [open, setOpen] = useState(false)
const [accountOpen, setAccountOpen] = useState(false)
const { user, logout } = useAuth()
const { status } = useCompliance()
const navigate = useNavigate()
const unlocked = status?.console_unlocked ?? false
const visibleGroups = unlocked
? groups
: [{
label: 'Compliance',
links: groups[0].links.filter((link) => COMPLIANCE_ONLY_PATHS.includes(link.path)),
}]
async function signOut() {
await logout()
navigate('/')
}
return (
<div className="app-shell">
<aside className={`sidebar ${open ? 'is-open' : ''}`}>
<div className="brand">
<NavLink to={unlocked ? '/console' : '/console/dlt'} className="brand-mark" onClick={() => setOpen(false)}>
<img src="/urmi-logo.png" alt="Urmi" />
</NavLink>
<button className="icon-button sidebar-close" onClick={() => setOpen(false)}><X size={18} /></button>
</div>
<nav>
{visibleGroups.map((group) => (
<div className="nav-group" key={group.label}>
<p>{group.label}</p>
{group.links.map(({ label, path, icon: Icon, end }) => (
<NavLink key={path} to={path} end={end ?? path === '/console'} onClick={() => setOpen(false)}>
<Icon size={17} strokeWidth={1.8} />
<span>{label}</span>
</NavLink>
))}
</div>
))}
</nav>
{unlocked && (
<div className="sidebar-plan">
<div><CircleDollarSign size={17} /><span>Growth plan</span></div>
<strong>₹24,820.00</strong>
<small>Available balance</small>
<button>Add funds</button>
</div>
)}
</aside>
{open && <button className="sidebar-scrim" aria-label="Close navigation" onClick={() => setOpen(false)} />}
<div className="workspace">
<header className="topbar">
<button className="icon-button menu-button" onClick={() => setOpen(true)}><Menu size={20} /></button>
<button className="selector">Urmi Technologies <ChevronDown size={14} /></button>
<button className="selector hide-mobile">Production API <ChevronDown size={14} /></button>
<span className="environment"><i /> Production</span>
<label className="global-search">
<Search size={16} />
<input aria-label="Global search" placeholder="Search numbers, calls, messages..." />
<kbd>⌘ K</kbd>
</label>
<span className="system-status"><i /> Operational</span>
<button className="icon-button notification-button"><Bell size={19} /><b>3</b></button>
<div className="account-menu">
<button className="avatar" onClick={() => setAccountOpen(!accountOpen)}>
{user?.avatar_url ? <img src={user.avatar_url} alt="" /> : user?.name.slice(0, 2).toUpperCase()}
</button>
{accountOpen && <div className="account-popover">
<div><strong>{user?.name}</strong><span>{user?.email}</span></div>
<NavLink to="/console/account" onClick={() => setAccountOpen(false)}><Settings size={15} /> Sign-in & security</NavLink>
<button onClick={signOut}><LogOut size={15} /> Log out</button>
</div>}
</div>
</header>
<main><Outlet /></main>
</div>
</div>
)
}
export function AppShell() {
return (
<ComplianceProvider>
<AppShellContent />
</ComplianceProvider>
)
}

View file

@ -0,0 +1,417 @@
import { ChevronRight, LockKeyhole, MessageSquareText, Network, Phone, ShieldCheck } from 'lucide-react'
import { useEffect, useState } from 'react'
import type { FormEvent } from 'react'
import {
adminApi,
type AdminDltApplication,
type AdminKycApplication,
type AdminPhoneNumber,
type AdminSenderConfig,
type AdminTemplateConfig,
type AdminUrmiOtpProject,
} from '../../lib/api'
const KYC_DOC_LABELS: Record<string, string> = {
business_pan_card: 'Business PAN card',
business_documents: 'Business documents',
gst_certificate: 'GST certificate',
kyc_form: 'Signed KYC form',
}
const DLT_DOC_LABELS: Record<string, string> = {
proof_of_directorship: 'Proof of directorship',
pe_tm_chain_binding: 'PE TM chain binding status',
}
const PE_TM_CHAIN_BINDING_ID = '1302158098680292888'
function AdminLogin({ onSuccess }: { onSuccess: () => void }) {
const [username, setUsername] = useState('root')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
async function submit(event: FormEvent) {
event.preventDefault()
setError('')
try {
await adminApi.login(username, password)
onSuccess()
} catch {
setError('Invalid admin credentials')
}
}
return (
<div className="admin-login-page">
<form className="auth-card admin-login-card" onSubmit={submit}>
<div className="auth-heading">
<ShieldCheck size={28} />
<h2>Urmi Admin</h2>
<p>Global compliance portal</p>
</div>
<label><span>Username</span><input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" /></label>
<label><span>Password</span><div><LockKeyhole size={17} /><input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" placeholder="Admin password" /></div></label>
{error && <p className="auth-error">{error}</p>}
<button className="auth-submit">Sign in to admin <ChevronRight size={17} /></button>
</form>
</div>
)
}
function AdminCompliance() {
const [tab, setTab] = useState<'kyc' | 'dlt' | 'senders' | 'templates' | 'urmiotp' | 'numbers'>('kyc')
const [kycItems, setKycItems] = useState<AdminKycApplication[]>([])
const [dltItems, setDltItems] = useState<AdminDltApplication[]>([])
const [senderItems, setSenderItems] = useState<AdminSenderConfig[]>([])
const [templateItems, setTemplateItems] = useState<AdminTemplateConfig[]>([])
const [otpProjects, setOtpProjects] = useState<AdminUrmiOtpProject[]>([])
const [phoneNumbers, setPhoneNumbers] = useState<AdminPhoneNumber[]>([])
const [phoneNumber, setPhoneNumber] = useState('')
const [monthlyNumberPrice, setMonthlyNumberPrice] = useState('299')
const [inboundPrice, setInboundPrice] = useState('0.45')
const [outboundPrice, setOutboundPrice] = useState('0.60')
const [assignEmail, setAssignEmail] = useState('')
const [phoneError, setPhoneError] = useState('')
const [phoneSaving, setPhoneSaving] = useState(false)
const [loading, setLoading] = useState(true)
async function load() {
const [kyc, dlt, senders, templates, projects, numbers] = await Promise.all([
adminApi.listKyc(),
adminApi.listDlt(),
adminApi.listSenders(),
adminApi.listTemplates(),
adminApi.listUrmiOtpProjects(),
adminApi.listPhoneNumbers(),
])
setKycItems(kyc)
setDltItems(dlt)
setSenderItems(senders)
setTemplateItems(templates)
setOtpProjects(projects)
setPhoneNumbers(numbers)
}
useEffect(() => {
load().finally(() => setLoading(false))
}, [])
async function reviewKyc(applicationId: string, documentId: string, status: 'approved' | 'rejected') {
const note = status === 'rejected' ? 'Document rejected. Please re-upload a valid file.' : undefined
await adminApi.reviewDocument(applicationId, documentId, status, note)
await load()
}
async function reviewDlt(applicationId: string, documentId: string, status: 'approved' | 'rejected') {
const note = status === 'rejected' ? 'Document rejected. Please re-upload a valid file.' : undefined
await adminApi.reviewDltDocument(applicationId, documentId, status, note)
await load()
}
async function reviewSender(senderId: string, status: 'approved' | 'rejected') {
const note = status === 'rejected' ? 'Sender ID rejected. Update details and submit again.' : undefined
await adminApi.reviewSender(senderId, status, note)
await load()
}
async function reviewTemplate(templateId: string, status: 'approved' | 'rejected') {
const note = status === 'rejected' ? 'Template rejected. Update details and submit again.' : undefined
await adminApi.reviewTemplate(templateId, status, note)
await load()
}
async function reviewOtpProject(projectId: string, status: 'approved' | 'rejected') {
const note = status === 'rejected'
? 'Project registration rejected. Review the app details and register again.'
: undefined
await adminApi.reviewUrmiOtpProject(projectId, status, note)
await load()
}
async function createPhoneNumber(event: FormEvent) {
event.preventDefault()
setPhoneError('')
setPhoneSaving(true)
try {
await adminApi.createPhoneNumber({
phone_number: phoneNumber.trim(),
monthly_number_price: Number(monthlyNumberPrice),
inbound_price: Number(inboundPrice),
outbound_price: Number(outboundPrice),
user_email: assignEmail.trim() || null,
})
setPhoneNumber('')
setAssignEmail('')
await load()
} catch (reason) {
setPhoneError(reason instanceof Error ? reason.message : 'Unable to add phone number')
} finally {
setPhoneSaving(false)
}
}
async function releasePhoneNumber(numberId: string) {
await adminApi.updatePhoneNumber(numberId, { release: true })
await load()
}
if (loading) return <div className="auth-loading">Loading compliance queue…</div>
return (
<div className="admin-content">
<div className="account-heading">
<span className="eyebrow">Global compliance</span>
<h1>Compliance review</h1>
<p>Approve KYC, DLT, messaging configuration, and UrmiOTP projects before they go live.</p>
</div>
<div className="admin-tabs">
<button className={tab === 'kyc' ? 'active' : ''} onClick={() => setTab('kyc')}>KYC submissions</button>
<button className={tab === 'dlt' ? 'active' : ''} onClick={() => setTab('dlt')}>DLT submissions</button>
<button className={tab === 'senders' ? 'active' : ''} onClick={() => setTab('senders')}>Sender IDs</button>
<button className={tab === 'templates' ? 'active' : ''} onClick={() => setTab('templates')}>Templates</button>
<button className={tab === 'urmiotp' ? 'active' : ''} onClick={() => setTab('urmiotp')}>UrmiOTP projects</button>
<button className={tab === 'numbers' ? 'active' : ''} onClick={() => setTab('numbers')}>Phone numbers</button>
</div>
{tab === 'kyc' && (
kycItems.length === 0 ? <section className="panel placeholder"><p>No KYC submissions yet.</p></section> : kycItems.map((item) => (
<section className="panel admin-kyc-card" key={item.id}>
<div className="panel-heading">
<div><h2>{item.user.name}</h2><p>{item.user.email} · PAN {item.pan_number} · GST {item.gst_number}</p></div>
<span className={`doc-status ${item.completed ? 'approved' : item.status}`}>{item.completed ? 'KYC completed' : item.status}</span>
</div>
{item.documents.map((doc) => (
<div className="kyc-doc-row" key={doc.id}>
<div><strong>{KYC_DOC_LABELS[doc.document_type] ?? doc.document_type}</strong><span>{doc.original_filename ?? 'Missing'}</span></div>
<span className={`doc-status ${doc.status}`}>{doc.status}</span>
{doc.status === 'pending' && (
<div className="admin-actions">
<button className="primary-button" onClick={() => reviewKyc(item.id, doc.id, 'approved')}>Approve</button>
<button className="secondary-button" onClick={() => reviewKyc(item.id, doc.id, 'rejected')}>Reject</button>
</div>
)}
</div>
))}
</section>
))
)}
{tab === 'dlt' && (
dltItems.length === 0 ? <section className="panel placeholder"><p>No DLT submissions yet.</p></section> : dltItems.map((item) => (
<section className="panel admin-kyc-card" key={item.id}>
<div className="panel-heading">
<div><h2>{item.user.name}</h2><p>{item.user.email}</p></div>
<span className={`doc-status ${item.completed ? 'approved' : item.status}`}>{item.completed ? 'DLT completed' : item.status}</span>
</div>
{item.documents.map((doc) => (
<div className="kyc-doc-row" key={doc.id}>
<div>
<strong>{DLT_DOC_LABELS[doc.document_type] ?? doc.document_type}</strong>
<span>
{doc.document_type === 'pe_tm_chain_binding'
? `Chain ID ${PE_TM_CHAIN_BINDING_ID}`
: (doc.original_filename ?? 'Missing')}
</span>
</div>
<span className={`doc-status ${doc.status}`}>{doc.status}</span>
{doc.status === 'pending' && (
<div className="admin-actions">
<button className="primary-button" onClick={() => reviewDlt(item.id, doc.id, 'approved')}>Approve</button>
<button className="secondary-button" onClick={() => reviewDlt(item.id, doc.id, 'rejected')}>Reject</button>
</div>
)}
</div>
))}
</section>
))
)}
{tab === 'senders' && (
senderItems.length === 0 ? <section className="panel placeholder"><p>No sender ID requests yet.</p></section> : senderItems.map((item) => (
<section className="panel admin-kyc-card" key={item.id}>
<div className="panel-heading">
<div>
<h2>{item.sender_id}</h2>
<p>{item.user.name} · {item.user.email}</p>
</div>
<span className={`doc-status ${item.status}`}>{item.status}</span>
</div>
<div className="kyc-doc-row">
<div>
<strong><MessageSquareText size={14} /> {item.sender_type}</strong>
<span>DLT Entity {item.dlt_entity_id}</span>
<small>{item.purpose}</small>
</div>
{item.status === 'pending' && (
<div className="admin-actions">
<button className="primary-button" onClick={() => reviewSender(item.id, 'approved')}>Approve</button>
<button className="secondary-button" onClick={() => reviewSender(item.id, 'rejected')}>Reject</button>
</div>
)}
</div>
</section>
))
)}
{tab === 'templates' && (
templateItems.length === 0 ? <section className="panel placeholder"><p>No template requests yet.</p></section> : templateItems.map((item) => (
<section className="panel admin-kyc-card" key={item.id}>
<div className="panel-heading">
<div>
<h2>{item.template_name}</h2>
<p>{item.user.name} · {item.user.email} · Sender {item.sender_id}</p>
</div>
<span className={`doc-status ${item.status}`}>{item.status}</span>
</div>
<div className="kyc-doc-row">
<div>
<strong><ShieldCheck size={14} /> DLT {item.dlt_template_id}</strong>
<small>{item.template_text}</small>
</div>
{item.status === 'pending' && (
<div className="admin-actions">
<button className="primary-button" onClick={() => reviewTemplate(item.id, 'approved')}>Approve</button>
<button className="secondary-button" onClick={() => reviewTemplate(item.id, 'rejected')}>Reject</button>
</div>
)}
</div>
</section>
))
)}
{tab === 'urmiotp' && (
otpProjects.length === 0
? <section className="panel placeholder"><p>No UrmiOTP project requests yet.</p></section>
: otpProjects.map((project) => (
<section className="panel admin-kyc-card" key={project.id}>
<div className="panel-heading">
<div>
<h2>{project.project_name}</h2>
<p>{project.user.name} · {project.user.email} · {project.app_type} · label: {project.message_label}</p>
</div>
<span className={`doc-status ${project.status}`}>{project.status}</span>
</div>
<div className="admin-otp-project">
<a href={project.app_url} target="_blank" rel="noreferrer">{project.app_url}</a>
<p>{project.purpose}</p>
<small>Terms accepted {new Date(project.terms_accepted_at).toLocaleString()}</small>
{project.status === 'pending' && (
<div className="admin-actions">
<button className="primary-button" onClick={() => reviewOtpProject(project.id, 'approved')}>Approve</button>
<button className="secondary-button" onClick={() => reviewOtpProject(project.id, 'rejected')}>Reject</button>
</div>
)}
</div>
</section>
))
)}
{tab === 'numbers' && (
<>
<section className="panel admin-phone-form">
<div className="panel-heading">
<div>
<h2>Add phone number</h2>
<p>List in Buy Numbers, or assign directly to a user for My Numbers.</p>
</div>
<Phone size={19} />
</div>
<form className="admin-phone-form-grid" onSubmit={createPhoneNumber}>
<label className="admin-phone-number">
<span>Phone number</span>
<input
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/\D/g, '').slice(0, 10))}
placeholder="9876543210"
inputMode="numeric"
pattern="[6-9][0-9]{9}"
minLength={10}
maxLength={10}
required
/>
<small>10-digit mobile number without +91.</small>
</label>
<label>
<span>Number cost (monthly)</span>
<input type="number" min="0" step="1" value={monthlyNumberPrice} onChange={(e) => setMonthlyNumberPrice(e.target.value)} required />
</label>
<label>
<span>Inbound (per min)</span>
<input type="number" min="0" step="0.01" value={inboundPrice} onChange={(e) => setInboundPrice(e.target.value)} required />
</label>
<label>
<span>Outbound (per min)</span>
<input type="number" min="0" step="0.01" value={outboundPrice} onChange={(e) => setOutboundPrice(e.target.value)} required />
</label>
<label className="admin-phone-email">
<span>Assign to user email (optional)</span>
<input type="email" value={assignEmail} onChange={(e) => setAssignEmail(e.target.value)} placeholder="user@company.com" />
<small>Leave blank to list the number in Buy Numbers instead.</small>
</label>
{phoneError && <p className="auth-error">{phoneError}</p>}
<button type="submit" className="primary-button" disabled={phoneSaving}>
{phoneSaving ? 'Adding…' : 'Add number'}
</button>
</form>
</section>
{phoneNumbers.length === 0 ? (
<section className="panel placeholder"><p>No phone numbers yet.</p></section>
) : phoneNumbers.map((item) => (
<section className="panel admin-kyc-card" key={item.id}>
<div className="panel-heading">
<div>
<h2>{item.phone_number.length === 10 ? `${item.phone_number.slice(0, 5)} ${item.phone_number.slice(5)}` : item.phone_number}</h2>
<p>
Number ₹{item.monthly_number_price.toFixed(0)}/mo · Inbound ₹{item.inbound_price.toFixed(2)}/min · Outbound ₹{item.outbound_price.toFixed(2)}/min
{item.user ? ` · ${item.user.name} (${item.user.email})` : ' · Available in catalog'}
</p>
</div>
<span className={`doc-status ${item.status === 'assigned' ? 'approved' : 'pending'}`}>{item.status}</span>
</div>
<div className="kyc-doc-row">
<div>
<strong><Network size={14} /> {item.acquisition_type ?? 'catalog'}</strong>
<small>{item.assigned_at ? `Assigned ${new Date(item.assigned_at).toLocaleString()}` : 'Not assigned yet'}</small>
</div>
{item.status === 'assigned' && (
<div className="admin-actions">
<button className="secondary-button" onClick={() => releasePhoneNumber(item.id)}>Release to catalog</button>
</div>
)}
</div>
</section>
))}
</>
)}
</div>
)
}
export function AdminApp() {
const [authenticated, setAuthenticated] = useState<boolean | null>(null)
useEffect(() => {
adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false))
}, [])
async function logout() {
await adminApi.logout()
setAuthenticated(false)
}
if (authenticated === null) return <div className="auth-loading">Checking admin session…</div>
return (
<div className="admin-shell">
<header className="admin-topbar">
<div><img src="/urmi-logo.png" alt="Urmi" /><strong>Admin · Global compliance</strong></div>
{authenticated && <button className="secondary-button" onClick={logout}>Log out</button>}
</header>
<main>
{authenticated ? <AdminCompliance /> : <AdminLogin onSuccess={() => setAuthenticated(true)} />}
</main>
</div>
)
}

View file

@ -0,0 +1,100 @@
import { ArrowLeft, Check, Code2, Copy, KeyRound } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { urmiOtpApi, type UrmiOtpDocumentation } from '../../lib/api'
export function UrmiOtpDocsPage() {
const { projectId = '' } = useParams()
const navigate = useNavigate()
const [docs, setDocs] = useState<UrmiOtpDocumentation | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [copied, setCopied] = useState(false)
useEffect(() => {
urmiOtpApi.documentation(projectId)
.then(setDocs)
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load documentation'))
.finally(() => setLoading(false))
}, [projectId])
if (loading) return <div className="auth-loading">Loading API documentation…</div>
if (!docs) return <section className="panel"><p className="auth-error">{error}</p></section>
const requestExample = JSON.stringify(docs.api.example, null, 2)
async function copyExample() {
await navigator.clipboard.writeText(requestExample)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}
return (
<div className="urmiotp-docs-page">
<button className="text-button otp-back" onClick={() => navigate('/console/agent/urmiotp')}>
<ArrowLeft size={14} /> Back to projects
</button>
<div className="account-heading">
<span className="eyebrow">UrmiOTP · {docs.project.project_name}</span>
<h1>API documentation</h1>
<p className="otp-message-label">
Message label: <strong>{docs.project.message_label}</strong>
<span> — your product identification in each message. URMI branding remains unchanged.</span>
</p>
<p>Unified verification templates for login, recovery, account changes, transactions, and devices.</p>
</div>
<section className="panel otp-api-overview">
<div className="panel-heading">
<div><h2>Generate verification OTP</h2><p>{docs.api.summary}</p></div>
<span className="explorer-method">{docs.api.method}</span>
</div>
<div className="explorer-field-row"><span>Endpoint</span><code>{docs.api.path}</code></div>
<div className="otp-api-notice">
<Code2 size={16} />
<span>This is documentation only. The UrmiOTP API is not enabled yet.</span>
</div>
<div className="otp-parameter-list">
{Object.entries(docs.api.parameters).map(([name, description]) => (
<div key={name}><code>{name}</code><span>{description}</span></div>
))}
</div>
<div className="otp-code-heading">
<strong>Request body</strong>
<button className="secondary-button" onClick={copyExample}><Copy size={13} /> {copied ? 'Copied' : 'Copy'}</button>
</div>
<pre className="explorer-code">{requestExample}</pre>
</section>
<div className="otp-template-grid">
{docs.templates.map((template, index) => (
<section className="panel otp-template-card" key={template.type}>
<div className="otp-template-number">{String(index + 1).padStart(2, '0')}</div>
<div className="panel-heading">
<div><h2>{template.name}</h2><p><code>{template.type}</code></p></div>
<KeyRound size={17} />
</div>
<div className="otp-template-copy">
<span>Approved content</span>
<p>{template.content}</p>
</div>
<div className="otp-template-copy sample">
<span>Sample</span>
<p>{template.sample}</p>
</div>
<div className="otp-template-columns">
<div>
<strong>Use for</strong>
<ul>{template.uses.map((use) => <li key={use}><Check size={12} /> {use}</li>)}</ul>
</div>
<div>
<strong>Variables</strong>
<ol>{template.variables.map((variable) => <li key={variable}>{variable}</li>)}</ol>
</div>
</div>
</section>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,237 @@
import { ArrowRight, CheckCircle2, ExternalLink, KeyRound, Plus, ShieldCheck, Smartphone, X } from 'lucide-react'
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { urmiOtpApi, type UrmiOtpProject } from '../../lib/api'
const PROJECT_NAME_RE = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/
const MESSAGE_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9 -]{0,18}[A-Za-z0-9]$|^[A-Za-z0-9]$/
function ProjectModal({ onClose, onCreated }: {
onClose: () => void
onCreated: (project: UrmiOtpProject) => void
}) {
const [name, setName] = useState('')
const [messageLabel, setMessageLabel] = useState('')
const [appType, setAppType] = useState<'web' | 'mobile'>('web')
const [appUrl, setAppUrl] = useState('')
const [purpose, setPurpose] = useState('')
const [terms, setTerms] = useState(false)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
async function submit(event: FormEvent) {
event.preventDefault()
setError('')
if (!PROJECT_NAME_RE.test(name) || name.includes('--')) {
setError('Use 6–30 lowercase letters, numbers, or hyphens; start with a letter and end with a letter or number')
return
}
const trimmedLabel = messageLabel.trim()
if (!trimmedLabel || trimmedLabel.length > 20 || !MESSAGE_LABEL_RE.test(trimmedLabel)) {
setError('Message label must be 1–20 characters: letters, numbers, spaces, or hyphens; start and end with a letter or number')
return
}
if (purpose.trim().length < 20) {
setError('Describe your app and OTP use case in at least 20 characters')
return
}
if (!terms) {
setError('You must agree to the terms and conditions')
return
}
setSaving(true)
try {
const project = await urmiOtpApi.createProject({
project_name: name,
message_label: trimmedLabel,
app_type: appType,
app_url: appUrl.trim(),
purpose: purpose.trim(),
terms_accepted: terms,
})
onCreated(project)
onClose()
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to register project')
} finally {
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<form className="modal-card otp-project-modal" onSubmit={submit} onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h2>Register an UrmiOTP project</h2>
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
</div>
<div className="otp-project-form">
<label>
<span>Project name</span>
<input
value={name}
onChange={(event) => setName(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder="acme-login"
minLength={6}
maxLength={30}
required
/>
<small>Globally unique. Lowercase letters, numbers, and hyphens only.</small>
</label>
<label>
<span>Message label</span>
<input
value={messageLabel}
onChange={(event) => setMessageLabel(event.target.value.slice(0, 20))}
placeholder="your product identification in message"
maxLength={20}
required
/>
<small>Your product name in the message body; URMI stays as the platform identifier (max 20 characters).</small>
</label>
<fieldset>
<legend>Application type</legend>
<label><input type="radio" checked={appType === 'web'} onChange={() => setAppType('web')} /> Web application</label>
<label><input type="radio" checked={appType === 'mobile'} onChange={() => setAppType('mobile')} /> Mobile application</label>
</fieldset>
<label>
<span>{appType === 'web' ? 'Website or domain URL' : 'App store URL'}</span>
<input
type="url"
value={appUrl}
onChange={(event) => setAppUrl(event.target.value)}
placeholder={appType === 'web' ? 'https://app.example.com' : 'https://play.google.com/store/apps/details?id=...'}
required
/>
</label>
<label>
<span>Purpose and OTP use case</span>
<textarea
value={purpose}
onChange={(event) => setPurpose(event.target.value)}
placeholder="Describe your app, its users, and how verification codes will be used."
rows={4}
minLength={20}
maxLength={1000}
required
/>
</label>
<label className="otp-terms">
<input type="checkbox" checked={terms} onChange={(event) => setTerms(event.target.checked)} />
<span>I agree to the UrmiOTP terms, acceptable-use policy, and anti-abuse requirements.</span>
</label>
{error && <p className="auth-error">{error}</p>}
</div>
<div className="modal-actions">
<button className="primary-button" disabled={saving}>{saving ? 'Submitting…' : 'Submit for approval'}</button>
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
)
}
function OtpMap() {
return (
<section className="panel otp-map">
<div className="otp-map-root"><KeyRound size={20} /><strong>URMI OTP</strong></div>
<div className="otp-map-branches">
<div><strong>Verification</strong><span>Signup · KYC · Identity</span></div>
<div><strong>Login</strong><span>MFA · 2FA · Passwordless</span></div>
<div><strong>Recovery</strong><span>Password reset · Access recovery</span></div>
<div><strong>Account changes</strong><span>Email · Phone · Security</span></div>
<div><strong>Action authorization</strong><span>Sensitive operations</span></div>
<div><strong>Transactions</strong><span>Payments · Transfers</span></div>
<div><strong>Device verification</strong><span>Pairing · IoT · Activation</span></div>
</div>
</section>
)
}
export function UrmiOtpPage() {
const navigate = useNavigate()
const [projects, setProjects] = useState<UrmiOtpProject[]>([])
const [modalOpen, setModalOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
urmiOtpApi.listProjects()
.then(setProjects)
.catch(() => setError('Unable to load UrmiOTP projects'))
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="auth-loading">Loading UrmiOTP…</div>
return (
<div className="urmiotp-page">
<div className="account-heading otp-page-heading">
<div>
<span className="eyebrow">Agent · Unified OTP</span>
<h1>UrmiOTP</h1>
<p>One verification platform and a small set of generic templates for every app and OTP journey.</p>
</div>
<button className="primary-button" onClick={() => setModalOpen(true)}><Plus size={15} /> Add a project</button>
</div>
<OtpMap />
<section className="panel otp-projects-panel">
<div className="panel-heading">
<div><h2>Registered projects</h2><p>Each app must be approved before its API documentation becomes available.</p></div>
<Smartphone size={19} />
</div>
{projects.length === 0 ? (
<div className="otp-empty">
<ShieldCheck size={24} />
<strong>No projects registered</strong>
<span>Add your web or mobile app to begin compliance review.</span>
</div>
) : (
<div className="otp-project-table">
<div className="otp-project-table-head">
<span>Project</span><span>Application</span><span>Status</span><span />
</div>
{projects.map((project) => (
<div className="otp-project-row" key={project.id}>
<div>
<strong>{project.project_name}</strong>
<small>Label: {project.message_label}</small>
<small>{project.purpose}</small>
</div>
<a href={project.app_url} target="_blank" rel="noreferrer">
{project.app_type}<ExternalLink size={12} />
</a>
<div>
<span className={`doc-status ${project.status}`}>{project.status}</span>
{project.review_note && <small>{project.review_note}</small>}
</div>
{project.status === 'approved' ? (
<button
className="secondary-button"
onClick={() => navigate(`/console/agent/urmiotp/projects/${project.id}/docs`)}
>
API documentation <ArrowRight size={14} />
</button>
) : project.status === 'pending' ? (
<span className="otp-awaiting"><CheckCircle2 size={13} /> Awaiting admin review</span>
) : (
<button className="secondary-button" onClick={() => setModalOpen(true)}>Register another</button>
)}
</div>
))}
</div>
)}
{error && <p className="auth-error">{error}</p>}
</section>
{modalOpen && (
<ProjectModal
onClose={() => setModalOpen(false)}
onCreated={(project) => setProjects((current) => [project, ...current])}
/>
)}
</div>
)
}

View file

@ -0,0 +1,331 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import {
analyticsApi,
type AnalyticsGroupBy,
type AnalyticsPeriod,
type AnalyticsService,
type UsageAnalytics,
} from '../../lib/api'
const PERIODS: Array<{ value: AnalyticsPeriod; label: string; hint: string }> = [
{ value: 'weekly', label: 'Weekly', hint: 'Last 12 weeks' },
{ value: 'monthly', label: 'Monthly', hint: 'Last 12 months' },
{ value: 'quarterly', label: 'Quarterly', hint: 'Last 4 quarters' },
]
const SERVICES: Array<{ value: AnalyticsService; label: string; hint: string }> = [
{ value: 'sms', label: 'SMS', hint: 'DLT template messaging' },
{ value: 'inbound', label: 'Inbound', hint: 'Calls to your Urmi numbers' },
{ value: 'outbound', label: 'Outbound', hint: 'Calls placed from your numbers' },
{ value: 'otp', label: 'OTP', hint: 'UrmiOTP verification messages' },
]
const GROUP_BY: Record<AnalyticsService, Array<{ value: AnalyticsGroupBy; label: string; hint: string }>> = {
sms: [
{ value: 'dlt_sender', label: 'DLT sender', hint: 'Approved sender header' },
{ value: 'dlt_template', label: 'DLT template', hint: 'Template name' },
],
inbound: [
{ value: 'did', label: 'DID number', hint: 'Which Urmi number received the call' },
],
outbound: [
{ value: 'socket', label: 'Voice socket name', hint: 'Workspace name linked to a number' },
{ value: 'did', label: 'DID number', hint: 'Which Urmi number placed the call' },
],
otp: [
{ value: 'project', label: 'OTP project', hint: 'UrmiOTP project name' },
{ value: 'otp_type', label: 'OTP type', hint: 'Verification template type' },
],
}
const SERVICE_COLORS = {
sms: '#11b7c9',
inbound: '#8b5cf6',
outbound: '#1475ff',
otp: '#f59e0b',
} as const
const MAX_BAR_SIZE = 56
function formatCount(value: number) {
return value.toLocaleString('en-IN')
}
function formatPhone(value: string) {
const digits = value.replace(/\D/g, '')
if (digits.length === 10) {
return `+91 ${digits.slice(0, 5)} ${digits.slice(5)}`
}
if (digits.length === 12 && digits.startsWith('91')) {
return `+91 ${digits.slice(2, 7)} ${digits.slice(7)}`
}
return value
}
function barCategoryGap(count: number) {
if (count <= 1) return '72%'
if (count <= 3) return '45%'
return '22%'
}
function SummaryCard({ label, value, tone }: { label: string; value: number; tone: keyof typeof SERVICE_COLORS }) {
return (
<article className="analytics-stat">
<span>{label}</span>
<strong>{formatCount(value)}</strong>
<i style={{ background: SERVICE_COLORS[tone] }} />
</article>
)
}
function UsageTooltip({
active,
payload,
label,
}: {
active?: boolean
payload?: Array<{ name?: string; value?: number; color?: string; payload?: { detail?: string } }>
label?: string
}) {
if (!active || !payload?.length) return null
const detail = payload[0]?.payload?.detail
return (
<div className="analytics-tooltip">
<strong>{label}</strong>
{detail && <p>{detail}</p>}
{payload.map((entry) => (
<div key={entry.name}>
<span style={{ background: entry.color }} />
{entry.name}: {formatCount(entry.value ?? 0)}
</div>
))}
</div>
)
}
function breakdownTitle(service: AnalyticsService, groupBy: AnalyticsGroupBy) {
const serviceLabel = SERVICES.find((item) => item.value === service)?.label ?? service
const groupLabel = GROUP_BY[service].find((item) => item.value === groupBy)?.label ?? groupBy
return `${serviceLabel} by ${groupLabel.toLowerCase()}`
}
function formatBreakdownLabel(label: string, service: AnalyticsService, groupBy: AnalyticsGroupBy) {
if (groupBy === 'did' || (service === 'outbound' && groupBy === 'socket' && label !== 'No voice socket linked')) {
if (/^\d+$/.test(label.replace(/\D/g, ''))) {
return formatPhone(label)
}
}
return label
}
export function AnalyticsPage() {
const [period, setPeriod] = useState<AnalyticsPeriod>('weekly')
const [service, setService] = useState<AnalyticsService>('outbound')
const [groupBy, setGroupBy] = useState<AnalyticsGroupBy>('socket')
const [data, setData] = useState<UsageAnalytics | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const groupOptions = GROUP_BY[service]
useEffect(() => {
if (!groupOptions.some((item) => item.value === groupBy)) {
setGroupBy(groupOptions[0].value)
}
}, [groupBy, groupOptions])
const loadAnalytics = useCallback(async () => {
setLoading(true)
setError('')
try {
const response = await analyticsApi.usage({ period, service, group_by: groupBy })
setData(response)
} catch (reason) {
setData(null)
setError(reason instanceof Error ? reason.message : 'Unable to load analytics')
} finally {
setLoading(false)
}
}, [groupBy, period, service])
useEffect(() => {
loadAnalytics()
}, [loadAnalytics])
const breakdownChartData = useMemo(
() => (data?.breakdown ?? []).map((item) => ({
...item,
displayLabel: formatBreakdownLabel(item.label, service, groupBy),
})),
[data?.breakdown, groupBy, service],
)
const hasBreakdown = breakdownChartData.length > 0
const hasTimeline = (data?.timeline.length ?? 0) > 0
const breakdownGap = barCategoryGap(breakdownChartData.length)
const timelineGap = barCategoryGap(data?.timeline.length ?? 0)
function changeService(nextService: AnalyticsService) {
setService(nextService)
setGroupBy(GROUP_BY[nextService][0].value)
}
return (
<div className="analytics-page">
<div className="account-heading">
<span className="eyebrow">Test & observe</span>
<h1>Analytics</h1>
<p>Pick a service first, then choose how to break it down — by DLT sender, voice socket name, DID number, or OTP project.</p>
</div>
<section className="panel analytics-toolbar">
<div className="analytics-toolbar-row">
<div className="analytics-toggle-group">
<span className="analytics-toggle-label">Period</span>
<div className="analytics-segmented">
{PERIODS.map((item) => (
<button
key={item.value}
type="button"
className={period === item.value ? 'active' : ''}
onClick={() => setPeriod(item.value)}
>
{item.label}
</button>
))}
</div>
<small>{PERIODS.find((item) => item.value === period)?.hint}</small>
</div>
<div className="analytics-toggle-group">
<span className="analytics-toggle-label">Service</span>
<div className="analytics-segmented analytics-segmented-wrap">
{SERVICES.map((item) => (
<button
key={item.value}
type="button"
className={service === item.value ? 'active' : ''}
onClick={() => changeService(item.value)}
>
{item.label}
</button>
))}
</div>
<small>{SERVICES.find((item) => item.value === service)?.hint}</small>
</div>
<label className="logs-field analytics-group-field">
<span>Break down by</span>
<select value={groupBy} onChange={(event) => setGroupBy(event.target.value as AnalyticsGroupBy)}>
{groupOptions.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
<small>{groupOptions.find((item) => item.value === groupBy)?.hint}</small>
</label>
</div>
</section>
{error && <p className="auth-error">{error}</p>}
{loading ? (
<p className="explorer-loading">Loading analytics…</p>
) : data && (
<>
<section className="analytics-summary-grid">
<SummaryCard label="Total SMS" value={data.totals.sms} tone="sms" />
<SummaryCard label="Total inbound" value={data.totals.inbound} tone="inbound" />
<SummaryCard label="Total outbound" value={data.totals.outbound} tone="outbound" />
<SummaryCard label="Total OTPs" value={data.totals.otp} tone="otp" />
</section>
<div className="analytics-grid">
<section className="panel chart-panel analytics-chart-panel">
<div className="panel-heading">
<div>
<h2>All services over time</h2>
<p>Grouped bar chart for SMS, inbound, outbound, and OTP volumes</p>
</div>
<div className="legend">
<span><i style={{ background: SERVICE_COLORS.sms }} /> SMS</span>
<span><i style={{ background: SERVICE_COLORS.inbound }} /> Inbound</span>
<span><i style={{ background: SERVICE_COLORS.outbound }} /> Outbound</span>
<span><i style={{ background: SERVICE_COLORS.otp }} /> OTP</span>
</div>
</div>
<div className="chart-wrap analytics-chart-wrap">
{hasTimeline ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.timeline} barGap={4} barCategoryGap={timelineGap}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e9edf4" />
<XAxis dataKey="label" axisLine={false} tickLine={false} tick={{ fill: '#8a96a8', fontSize: 11 }} />
<YAxis allowDecimals={false} axisLine={false} tickLine={false} tick={{ fill: '#8a96a8', fontSize: 11 }} width={38} />
<Tooltip content={<UsageTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="sms" name="SMS" fill={SERVICE_COLORS.sms} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
<Bar dataKey="inbound" name="Inbound" fill={SERVICE_COLORS.inbound} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
<Bar dataKey="outbound" name="Outbound" fill={SERVICE_COLORS.outbound} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
<Bar dataKey="otp" name="OTP" fill={SERVICE_COLORS.otp} maxBarSize={MAX_BAR_SIZE} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="analytics-empty">No usage recorded for this period yet.</p>
)}
</div>
</section>
<section className="panel chart-panel analytics-chart-panel">
<div className="panel-heading">
<div>
<h2>{breakdownTitle(service, groupBy)}</h2>
<p>Top 10 by volume in the selected window</p>
</div>
</div>
<div className={`chart-wrap analytics-chart-wrap ${breakdownChartData.length <= 2 ? 'is-sparse' : ''}`}>
{hasBreakdown ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={breakdownChartData} barGap={4} barCategoryGap={breakdownGap}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e9edf4" />
<XAxis
dataKey="displayLabel"
axisLine={false}
tickLine={false}
tick={{ fill: '#8a96a8', fontSize: 11 }}
interval={0}
angle={breakdownChartData.length > 4 ? -18 : 0}
textAnchor={breakdownChartData.length > 4 ? 'end' : 'middle'}
height={breakdownChartData.length > 4 ? 58 : 32}
/>
<YAxis allowDecimals={false} axisLine={false} tickLine={false} tick={{ fill: '#8a96a8', fontSize: 11 }} width={38} />
<Tooltip content={<UsageTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar
dataKey="count"
name={SERVICES.find((item) => item.value === service)?.label ?? 'Count'}
fill={SERVICE_COLORS[service]}
maxBarSize={MAX_BAR_SIZE}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
) : (
<p className="analytics-empty">No {service} data for this breakdown in the selected period.</p>
)}
</div>
</section>
</div>
</>
)}
</div>
)
}

View file

@ -0,0 +1,57 @@
import { Check, KeyRound, ShieldCheck } from 'lucide-react'
import { useState } from 'react'
import type { FormEvent } from 'react'
import { authApi } from '../../lib/api'
import { useAuth } from './auth-context'
export function AccountSettings() {
const { user, setUser } = useAuth()
const [currentPassword, setCurrentPassword] = useState('')
const [password, setPassword] = useState('')
const [confirmation, setConfirmation] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
async function submit(event: FormEvent) {
event.preventDefault()
setError('')
setMessage('')
if (password !== confirmation) {
setError('Passwords do not match')
return
}
try {
const updated = await authApi.setPassword(password, currentPassword)
setUser(updated)
setCurrentPassword('')
setPassword('')
setConfirmation('')
setMessage(updated.has_password ? 'Your password has been saved.' : 'Password configured.')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to save password')
}
}
return (
<div className="account-page">
<div className="account-heading"><span className="eyebrow">Account</span><h1>Sign-in & security</h1><p>Manage how you access your Urmi workspace.</p></div>
<div className="account-grid">
<section className="panel account-profile">
<div className="account-avatar">{user?.avatar_url ? <img src={user.avatar_url} alt="" /> : user?.name.slice(0, 2).toUpperCase()}</div>
<div><h2>{user?.name}</h2><p>{user?.email}</p><span><ShieldCheck size={14} /> {user?.email_verified ? 'Verified email' : 'Email verification pending'}</span></div>
</section>
<section className="panel account-password">
<div className="panel-heading"><div><h2>{user?.has_password ? 'Change password' : 'Add a password'}</h2><p>{user?.has_password ? 'Update your first-party Urmi credential.' : 'You signed up with Google. Add a password to use either sign-in method.'}</p></div><KeyRound size={20} /></div>
<form onSubmit={submit}>
{user?.has_password && <label><span>Current password</span><input required type="password" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} placeholder="Your current password" /></label>}
<label><span>New password</span><input required minLength={10} type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="At least 10 characters" /></label>
<label><span>Confirm password</span><input required minLength={10} type="password" autoComplete="new-password" value={confirmation} onChange={(e) => setConfirmation(e.target.value)} placeholder="Repeat password" /></label>
{error && <p className="auth-error">{error}</p>}
{message && <p className="account-success"><Check size={14} /> {message}</p>}
<button className="primary-button" type="submit">Save password</button>
</form>
</section>
</div>
</div>
)
}

View file

@ -0,0 +1,25 @@
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { authApi, type AuthUser } from '../../lib/api'
import { AuthContext } from './auth-context'
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
authApi.me().then(setUser).catch(() => setUser(null)).finally(() => setLoading(false))
}, [])
const value = useMemo(() => ({
user,
loading,
setUser,
logout: async () => {
await authApi.logout()
setUser(null)
},
}), [user, loading])
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View file

@ -0,0 +1,89 @@
import { ArrowLeft, ArrowRight, Check, Eye, EyeOff, LockKeyhole, Mail, User } from 'lucide-react'
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { authApi } from '../../lib/api'
import { useAuth } from './auth-context'
export function AuthPage({ initialMode }: { initialMode: 'login' | 'register' }) {
const [mode, setMode] = useState(initialMode)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const { setUser } = useAuth()
const navigate = useNavigate()
const location = useLocation()
async function submit(event: FormEvent) {
event.preventDefault()
setError('')
setSubmitting(true)
try {
const user = mode === 'login'
? await authApi.login(email, password)
: await authApi.register(name, email, password)
setUser(user)
const destination = (location.state as { from?: string } | null)?.from ?? '/console'
navigate(destination, { replace: true })
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to continue')
} finally {
setSubmitting(false)
}
}
function switchMode(nextMode: 'login' | 'register') {
setMode(nextMode)
setError('')
navigate(nextMode === 'login' ? '/login' : '/register', { replace: true })
}
return (
<div className="auth-page">
<div className="auth-visual">
<Link to="/" className="auth-brand"><img src="/urmi-logo.png" alt="Urmi" /></Link>
<div className="auth-message">
<span>SMS FOR AI AGENTS</span>
<h1>From agent decision to human action.</h1>
<p>Secure messaging infrastructure with delivery, compliance, and observability built in.</p>
<div><span><Check size={15} /> Unified agent messaging API</span><span><Check size={15} /> India-ready compliance</span><span><Check size={15} /> Complete event visibility</span></div>
</div>
<small>Build → Test → Deploy → Observe</small>
</div>
<main className="auth-main">
<Link to="/" className="back-home"><ArrowLeft size={15} /> Back to Urmi</Link>
<section className="auth-card">
<div className="auth-tabs">
<button className={mode === 'login' ? 'active' : ''} onClick={() => switchMode('login')}>Log in</button>
<button className={mode === 'register' ? 'active' : ''} onClick={() => switchMode('register')}>Create account</button>
</div>
<div className="auth-heading">
<h2>{mode === 'login' ? 'Welcome back' : 'Start building with Urmi'}</h2>
<p>{mode === 'login' ? 'Access your agent messaging workspace.' : 'Create your workspace and send your first test message.'}</p>
</div>
<a className="google-button" href={authApi.googleUrl}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#4285F4" d="M21.6 12.23c0-.71-.06-1.39-.18-2.05H12v3.87h5.38a4.6 4.6 0 0 1-2 3.02v2.51h3.24c1.9-1.75 2.98-4.33 2.98-7.35Z"/><path fill="#34A853" d="M12 22c2.7 0 4.98-.9 6.63-2.42l-3.24-2.51c-.9.6-2.05.96-3.39.96-2.61 0-4.82-1.76-5.61-4.13H3.04v2.59A10 10 0 0 0 12 22Z"/><path fill="#FBBC05" d="M6.39 13.9A6.01 6.01 0 0 1 6.08 12c0-.66.11-1.3.31-1.9V7.51H3.04A10 10 0 0 0 2 12c0 1.61.38 3.14 1.04 4.49l3.35-2.59Z"/><path fill="#EA4335" d="M12 5.97c1.47 0 2.79.51 3.83 1.5l2.87-2.88A9.64 9.64 0 0 0 12 2a10 10 0 0 0-8.96 5.51l3.35 2.59C7.18 7.73 9.39 5.97 12 5.97Z"/></svg>
Continue with Google
</a>
<div className="auth-divider"><span>or continue with email</span></div>
<form onSubmit={submit}>
{mode === 'register' && <label><span>Name</span><div><User size={17} /><input required minLength={2} autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" /></div></label>}
<label><span>Email address</span><div><Mail size={17} /><input required type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@company.com" /></div></label>
<label><span>Password</span><div><LockKeyhole size={17} /><input required minLength={mode === 'register' ? 10 : 1} type={showPassword ? 'text' : 'password'} autoComplete={mode === 'login' ? 'current-password' : 'new-password'} value={password} onChange={(e) => setPassword(e.target.value)} placeholder={mode === 'register' ? 'At least 10 characters' : 'Your password'} /><button type="button" aria-label="Toggle password visibility" onClick={() => setShowPassword(!showPassword)}>{showPassword ? <EyeOff size={17} /> : <Eye size={17} />}</button></div></label>
{mode === 'login' && <div className="forgot-row"><span>Secure, encrypted sign in</span><button type="button">Forgot password?</button></div>}
{error && <p className="auth-error">{error}</p>}
<button className="auth-submit" disabled={submitting}>{submitting ? 'Please wait…' : mode === 'login' ? 'Log in to Urmi' : 'Create my account'} {!submitting && <ArrowRight size={17} />}</button>
</form>
<p className="auth-terms">By continuing, you agree to Urmi’s Terms of Service and Privacy Policy.</p>
</section>
</main>
</div>
)
}

View file

@ -0,0 +1,11 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from './auth-context'
export function ProtectedRoute() {
const { user, loading } = useAuth()
const location = useLocation()
if (loading) return <div className="auth-loading">Loading Urmi…</div>
if (!user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
return <Outlet />
}

View file

@ -0,0 +1,17 @@
import { createContext, useContext } from 'react'
import type { AuthUser } from '../../lib/api'
export type AuthContextValue = {
user: AuthUser | null
loading: boolean
setUser: (user: AuthUser | null) => void
logout: () => Promise<void>
}
export const AuthContext = createContext<AuthContextValue | null>(null)
export function useAuth() {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used inside AuthProvider')
return context
}

Some files were not shown because too many files have changed in this diff Show more