first commit
This commit is contained in:
commit
efa5080897
127 changed files with 18610 additions and 0 deletions
31
.env.example
Normal file
31
.env.example
Normal 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
16
.gitignore
vendored
Normal 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
|
||||
973
CPaaS_WEBSOCKET_RELAY_ARCHITECTURE.md
Normal file
973
CPaaS_WEBSOCKET_RELAY_ARCHITECTURE.md
Normal 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
387
GENERIC_TEMPLATE_SMS.md
Normal 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.
|
||||
BIN
KYC Form - Pvt Ltd Client.docx
Normal file
BIN
KYC Form - Pvt Ltd Client.docx
Normal file
Binary file not shown.
310
OTP_SMS_IMPLEMENTATION.md
Normal file
310
OTP_SMS_IMPLEMENTATION.md
Normal 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
63
README.md
Normal 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
BIN
Updated LOA.docx
Normal file
Binary file not shown.
45
backend/.env.example
Normal file
45
backend/.env.example
Normal 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
14
backend/Dockerfile
Normal 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
1
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Urmi control-plane API."""
|
||||
359
backend/app/analytics_service.py
Normal file
359
backend/app/analytics_service.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import OtpMessageLog, PhoneNumber, SmsMessageLog, UserAccount, VoiceCall, VoiceSocketEndpoint
|
||||
|
||||
Period = Literal["weekly", "monthly", "quarterly"]
|
||||
Service = Literal["sms", "inbound", "outbound", "otp"]
|
||||
GroupBy = Literal["dlt_sender", "dlt_template", "did", "socket", "project", "otp_type"]
|
||||
|
||||
PERIOD_BUCKETS = {
|
||||
"weekly": ("week", 12),
|
||||
"monthly": ("month", 12),
|
||||
"quarterly": ("quarter", 4),
|
||||
}
|
||||
|
||||
SERVICE_GROUP_BY: dict[str, frozenset[str]] = {
|
||||
"sms": frozenset({"dlt_sender", "dlt_template"}),
|
||||
"inbound": frozenset({"did"}),
|
||||
"outbound": frozenset({"socket", "did"}),
|
||||
"otp": frozenset({"project", "otp_type"}),
|
||||
}
|
||||
|
||||
DEFAULT_GROUP_BY: dict[str, str] = {
|
||||
"sms": "dlt_sender",
|
||||
"inbound": "did",
|
||||
"outbound": "socket",
|
||||
"otp": "project",
|
||||
}
|
||||
|
||||
|
||||
def _truncate_start(value: datetime, unit: str) -> datetime:
|
||||
value = value.astimezone(timezone.utc)
|
||||
if unit == "week":
|
||||
start = value - timedelta(days=value.weekday())
|
||||
return start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if unit == "month":
|
||||
return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
quarter_month = ((value.month - 1) // 3) * 3 + 1
|
||||
return value.replace(month=quarter_month, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _shift_bucket(start: datetime, unit: str, offset: int) -> datetime:
|
||||
if unit == "week":
|
||||
return start + timedelta(weeks=offset)
|
||||
if unit == "month":
|
||||
month = start.month + offset
|
||||
year = start.year + (month - 1) // 12
|
||||
month = ((month - 1) % 12) + 1
|
||||
return start.replace(year=year, month=month, day=1)
|
||||
month = start.month + (offset * 3)
|
||||
year = start.year + (month - 1) // 12
|
||||
month = ((month - 1) % 12) + 1
|
||||
return start.replace(year=year, month=month, day=1)
|
||||
|
||||
|
||||
def _format_bucket_label(start: datetime, unit: str) -> str:
|
||||
if unit == "week":
|
||||
return start.strftime("%d %b")
|
||||
if unit == "month":
|
||||
return start.strftime("%b %Y")
|
||||
quarter = ((start.month - 1) // 3) + 1
|
||||
return f"Q{quarter} {start.year}"
|
||||
|
||||
|
||||
def _build_bucket_keys(end: datetime, unit: str, count: int) -> list[tuple[datetime, str]]:
|
||||
current = _truncate_start(end, unit)
|
||||
keys: list[tuple[datetime, str]] = []
|
||||
for offset in range(-(count - 1), 1):
|
||||
start = _shift_bucket(current, unit, offset)
|
||||
keys.append((start, _format_bucket_label(start, unit)))
|
||||
return keys
|
||||
|
||||
|
||||
def _bucket_key(value: datetime, unit: str) -> str:
|
||||
return _truncate_start(value, unit).isoformat()
|
||||
|
||||
|
||||
def _bucket_map(rows: list[tuple[datetime, int]], unit: str) -> dict[str, int]:
|
||||
mapped: dict[str, int] = {}
|
||||
for bucket, count in rows:
|
||||
if bucket.tzinfo is None:
|
||||
bucket = bucket.replace(tzinfo=timezone.utc)
|
||||
mapped[_bucket_key(bucket.astimezone(timezone.utc), unit)] = int(count)
|
||||
return mapped
|
||||
|
||||
|
||||
def _breakdown_item(label: str, detail: str, count: int) -> dict:
|
||||
return {"label": label, "detail": detail, "count": int(count)}
|
||||
|
||||
|
||||
def _count_by_bucket(
|
||||
db: Session,
|
||||
*,
|
||||
model,
|
||||
user_id,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
unit: str,
|
||||
extra_filters=(),
|
||||
) -> dict[str, int]:
|
||||
bucket = func.date_trunc(unit, model.created_at).label("bucket")
|
||||
rows = db.execute(
|
||||
select(bucket, func.count())
|
||||
.where(
|
||||
model.user_id == user_id,
|
||||
model.created_at >= start,
|
||||
model.created_at <= end,
|
||||
*extra_filters,
|
||||
)
|
||||
.group_by(bucket)
|
||||
.order_by(bucket)
|
||||
).all()
|
||||
return _bucket_map(rows, unit)
|
||||
|
||||
|
||||
def _voice_bucket_map(
|
||||
db: Session,
|
||||
*,
|
||||
user_id,
|
||||
direction: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
unit: str,
|
||||
) -> dict[str, int]:
|
||||
bucket = func.date_trunc(unit, VoiceCall.created_at).label("bucket")
|
||||
rows = db.execute(
|
||||
select(bucket, func.count())
|
||||
.where(
|
||||
VoiceCall.user_id == user_id,
|
||||
VoiceCall.direction == direction,
|
||||
VoiceCall.created_at >= start,
|
||||
VoiceCall.created_at <= end,
|
||||
)
|
||||
.group_by(bucket)
|
||||
.order_by(bucket)
|
||||
).all()
|
||||
return _bucket_map(rows, unit)
|
||||
|
||||
|
||||
def _breakdown_sms_sender(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
|
||||
rows = db.execute(
|
||||
select(SmsMessageLog.sender_id, func.count())
|
||||
.where(
|
||||
SmsMessageLog.user_id == user_id,
|
||||
SmsMessageLog.created_at >= start,
|
||||
SmsMessageLog.created_at <= end,
|
||||
)
|
||||
.group_by(SmsMessageLog.sender_id)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
return [_breakdown_item(sender_id, "DLT sender header", count) for sender_id, count in rows]
|
||||
|
||||
|
||||
def _breakdown_sms_template(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
|
||||
rows = db.execute(
|
||||
select(SmsMessageLog.template_name, func.count())
|
||||
.where(
|
||||
SmsMessageLog.user_id == user_id,
|
||||
SmsMessageLog.created_at >= start,
|
||||
SmsMessageLog.created_at <= end,
|
||||
)
|
||||
.group_by(SmsMessageLog.template_name)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
return [_breakdown_item(template_name, "DLT template", count) for template_name, count in rows]
|
||||
|
||||
|
||||
def _breakdown_inbound_did(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
|
||||
rows = db.execute(
|
||||
select(VoiceCall.from_did, func.count())
|
||||
.where(
|
||||
VoiceCall.user_id == user_id,
|
||||
VoiceCall.direction == "inbound",
|
||||
VoiceCall.created_at >= start,
|
||||
VoiceCall.created_at <= end,
|
||||
)
|
||||
.group_by(VoiceCall.from_did)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
return [_breakdown_item(did, "Your Urmi number", count) for did, count in rows]
|
||||
|
||||
|
||||
def _breakdown_outbound_did(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
|
||||
rows = db.execute(
|
||||
select(VoiceCall.from_did, func.count())
|
||||
.where(
|
||||
VoiceCall.user_id == user_id,
|
||||
VoiceCall.direction == "outbound",
|
||||
VoiceCall.created_at >= start,
|
||||
VoiceCall.created_at <= end,
|
||||
)
|
||||
.group_by(VoiceCall.from_did)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
return [_breakdown_item(did, "Caller ID used", count) for did, count in rows]
|
||||
|
||||
|
||||
def _breakdown_outbound_socket(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
|
||||
socket_name = VoiceSocketEndpoint.name
|
||||
phone_number = PhoneNumber.phone_number
|
||||
label = case(
|
||||
(socket_name.isnot(None), socket_name),
|
||||
else_="No voice socket linked",
|
||||
).label("label")
|
||||
detail = phone_number.label("detail")
|
||||
|
||||
rows = db.execute(
|
||||
select(label, detail, func.count())
|
||||
.select_from(VoiceCall)
|
||||
.join(PhoneNumber, VoiceCall.phone_number_id == PhoneNumber.id)
|
||||
.outerjoin(
|
||||
VoiceSocketEndpoint,
|
||||
(VoiceSocketEndpoint.phone_number_id == VoiceCall.phone_number_id)
|
||||
& (VoiceSocketEndpoint.user_id == VoiceCall.user_id),
|
||||
)
|
||||
.where(
|
||||
VoiceCall.user_id == user_id,
|
||||
VoiceCall.direction == "outbound",
|
||||
VoiceCall.created_at >= start,
|
||||
VoiceCall.created_at <= end,
|
||||
)
|
||||
.group_by(label, detail)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
return [
|
||||
_breakdown_item(
|
||||
name,
|
||||
f"Linked to {number}" if name != "No voice socket linked" else f"Number {number}",
|
||||
count,
|
||||
)
|
||||
for name, number, count in rows
|
||||
]
|
||||
|
||||
|
||||
def _breakdown_otp_project(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
|
||||
rows = db.execute(
|
||||
select(OtpMessageLog.project_name, func.count())
|
||||
.where(
|
||||
OtpMessageLog.user_id == user_id,
|
||||
OtpMessageLog.created_at >= start,
|
||||
OtpMessageLog.created_at <= end,
|
||||
)
|
||||
.group_by(OtpMessageLog.project_name)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
return [_breakdown_item(project, "UrmiOTP project", count) for project, count in rows]
|
||||
|
||||
|
||||
def _breakdown_otp_type(db: Session, user_id, start: datetime, end: datetime) -> list[dict]:
|
||||
rows = db.execute(
|
||||
select(OtpMessageLog.template_type, func.count())
|
||||
.where(
|
||||
OtpMessageLog.user_id == user_id,
|
||||
OtpMessageLog.created_at >= start,
|
||||
OtpMessageLog.created_at <= end,
|
||||
)
|
||||
.group_by(OtpMessageLog.template_type)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
return [_breakdown_item(template_type.replace("_", " ").title(), "OTP template type", count) for template_type, count in rows]
|
||||
|
||||
|
||||
def _build_breakdown(
|
||||
db: Session,
|
||||
user_id,
|
||||
*,
|
||||
service: Service,
|
||||
group_by: GroupBy,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> list[dict]:
|
||||
builders = {
|
||||
("sms", "dlt_sender"): _breakdown_sms_sender,
|
||||
("sms", "dlt_template"): _breakdown_sms_template,
|
||||
("inbound", "did"): _breakdown_inbound_did,
|
||||
("outbound", "did"): _breakdown_outbound_did,
|
||||
("outbound", "socket"): _breakdown_outbound_socket,
|
||||
("otp", "project"): _breakdown_otp_project,
|
||||
("otp", "otp_type"): _breakdown_otp_type,
|
||||
}
|
||||
builder = builders.get((service, group_by))
|
||||
if not builder:
|
||||
return []
|
||||
return builder(db, user_id, start, end)
|
||||
|
||||
|
||||
def build_usage_analytics(
|
||||
db: Session,
|
||||
user: UserAccount,
|
||||
*,
|
||||
period: Period,
|
||||
service: Service,
|
||||
group_by: GroupBy,
|
||||
) -> dict:
|
||||
if group_by not in SERVICE_GROUP_BY[service]:
|
||||
group_by = DEFAULT_GROUP_BY[service] # type: ignore[assignment]
|
||||
|
||||
unit, bucket_count = PERIOD_BUCKETS[period]
|
||||
end = datetime.now(timezone.utc)
|
||||
first_bucket_start = _build_bucket_keys(end, unit, bucket_count)[0][0]
|
||||
start = first_bucket_start
|
||||
|
||||
sms_map = _count_by_bucket(db, model=SmsMessageLog, user_id=user.id, start=start, end=end, unit=unit)
|
||||
otp_map = _count_by_bucket(db, model=OtpMessageLog, user_id=user.id, start=start, end=end, unit=unit)
|
||||
inbound_map = _voice_bucket_map(db, user_id=user.id, direction="inbound", start=start, end=end, unit=unit)
|
||||
outbound_map = _voice_bucket_map(db, user_id=user.id, direction="outbound", start=start, end=end, unit=unit)
|
||||
|
||||
timeline = []
|
||||
for bucket_start, label in _build_bucket_keys(end, unit, bucket_count):
|
||||
key = _bucket_key(bucket_start, unit)
|
||||
timeline.append(
|
||||
{
|
||||
"label": label,
|
||||
"period_start": bucket_start,
|
||||
"sms": sms_map.get(key, 0),
|
||||
"inbound": inbound_map.get(key, 0),
|
||||
"outbound": outbound_map.get(key, 0),
|
||||
"otp": otp_map.get(key, 0),
|
||||
}
|
||||
)
|
||||
|
||||
totals = {
|
||||
"sms": sum(item["sms"] for item in timeline),
|
||||
"inbound": sum(item["inbound"] for item in timeline),
|
||||
"outbound": sum(item["outbound"] for item in timeline),
|
||||
"otp": sum(item["otp"] for item in timeline),
|
||||
}
|
||||
|
||||
breakdown = _build_breakdown(
|
||||
db,
|
||||
user.id,
|
||||
service=service,
|
||||
group_by=group_by,
|
||||
start=start,
|
||||
end=end,
|
||||
)
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"service": service,
|
||||
"group_by": group_by,
|
||||
"from": start,
|
||||
"to": end,
|
||||
"totals": totals,
|
||||
"timeline": timeline,
|
||||
"breakdown": breakdown,
|
||||
}
|
||||
63
backend/app/api_key_auth.py
Normal file
63
backend/app/api_key_auth.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from .api_keys_service import KEY_PREFIX, parse_api_token, verify_api_token
|
||||
from .database import get_db
|
||||
from .models import ApiKey, UserAccount
|
||||
|
||||
|
||||
def _extract_api_key(
|
||||
authorization: str | None,
|
||||
x_api_key: str | None,
|
||||
) -> str | None:
|
||||
if x_api_key:
|
||||
return x_api_key.strip()
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
return authorization[7:].strip()
|
||||
return None
|
||||
|
||||
|
||||
def current_api_key(
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-Api-Key"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ApiKey:
|
||||
token = _extract_api_key(authorization, x_api_key)
|
||||
if not token or not token.startswith(KEY_PREFIX):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Valid API key required")
|
||||
|
||||
parsed = parse_api_token(token)
|
||||
if parsed:
|
||||
key_id, _ = parsed
|
||||
api_key = db.scalar(
|
||||
select(ApiKey)
|
||||
.options(joinedload(ApiKey.user))
|
||||
.where(ApiKey.id == key_id, ApiKey.revoked_at.is_(None))
|
||||
)
|
||||
if api_key and verify_api_token(token, api_key.key_hash):
|
||||
api_key.last_used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return api_key
|
||||
|
||||
active_keys = db.scalars(
|
||||
select(ApiKey)
|
||||
.options(joinedload(ApiKey.user))
|
||||
.where(ApiKey.revoked_at.is_(None))
|
||||
).all()
|
||||
for api_key in active_keys:
|
||||
if verify_api_token(token, api_key.key_hash):
|
||||
api_key.last_used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return api_key
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
||||
|
||||
|
||||
def current_api_user(api_key: ApiKey = Depends(current_api_key)) -> UserAccount:
|
||||
if api_key.user.status != "active":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account unavailable")
|
||||
return api_key.user
|
||||
48
backend/app/api_keys_service.py
Normal file
48
backend/app/api_keys_service.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from pwdlib import PasswordHash
|
||||
|
||||
KEY_PREFIX = "urmi_live_"
|
||||
KEY_RANDOM_BYTES = 24
|
||||
NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 _-]{1,59}$")
|
||||
|
||||
password_hash = PasswordHash.recommended()
|
||||
|
||||
|
||||
def validate_key_name(value: str) -> str:
|
||||
name = value.strip()
|
||||
if not NAME_PATTERN.match(name):
|
||||
raise ValueError("Key name must be 2–60 characters using letters, numbers, spaces, _ or -")
|
||||
return name
|
||||
|
||||
|
||||
def generate_api_token(key_id: uuid.UUID) -> str:
|
||||
return f"{KEY_PREFIX}{key_id.hex}_{secrets.token_urlsafe(KEY_RANDOM_BYTES)}"
|
||||
|
||||
|
||||
def parse_api_token(token: str) -> tuple[uuid.UUID, str] | None:
|
||||
if not token.startswith(KEY_PREFIX):
|
||||
return None
|
||||
rest = token[len(KEY_PREFIX) :]
|
||||
if "_" not in rest:
|
||||
return None
|
||||
key_hex, _secret = rest.split("_", 1)
|
||||
try:
|
||||
return uuid.UUID(hex=key_hex), token
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def token_prefix(token: str) -> str:
|
||||
visible = token[:16]
|
||||
return f"{visible}…"
|
||||
|
||||
|
||||
def hash_api_token(token: str) -> str:
|
||||
return password_hash.hash(token)
|
||||
|
||||
|
||||
def verify_api_token(token: str, stored_hash: str) -> bool:
|
||||
return password_hash.verify(token, stored_hash)
|
||||
85
backend/app/auth.py
Normal file
85
backend/app/auth.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from fastapi import Cookie, Depends, HTTPException, status
|
||||
from pwdlib import PasswordHash
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
from .models import UserAccount
|
||||
|
||||
COOKIE_NAME = "urmi_session"
|
||||
ADMIN_COOKIE_NAME = "urmi_admin_session"
|
||||
password_hash = PasswordHash.recommended()
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
return email.strip().casefold()
|
||||
|
||||
|
||||
def create_token(user_id: uuid.UUID, purpose: str = "session", minutes: int | None = None) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": str(user_id),
|
||||
"purpose": purpose,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=minutes or settings.access_token_minutes),
|
||||
},
|
||||
settings.auth_secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str, purpose: str = "session") -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.auth_secret, algorithms=["HS256"])
|
||||
if payload.get("purpose") != purpose:
|
||||
raise ValueError("invalid token purpose")
|
||||
return payload
|
||||
except (jwt.PyJWTError, ValueError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session") from exc
|
||||
|
||||
|
||||
def current_user(
|
||||
urmi_session: str | None = Cookie(default=None, alias=COOKIE_NAME),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserAccount:
|
||||
if not urmi_session:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
payload = decode_token(urmi_session)
|
||||
try:
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session") from exc
|
||||
user = db.scalar(select(UserAccount).where(UserAccount.id == user_id))
|
||||
if not user or user.status != "active":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account unavailable")
|
||||
return user
|
||||
|
||||
|
||||
def create_admin_token(username: str) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": username,
|
||||
"purpose": "admin_session",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=settings.access_token_minutes),
|
||||
},
|
||||
settings.auth_secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
|
||||
def current_admin(urmi_admin_session: str | None = Cookie(default=None, alias=ADMIN_COOKIE_NAME)) -> str:
|
||||
if not urmi_admin_session:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin authentication required")
|
||||
payload = decode_token(urmi_admin_session, purpose="admin_session")
|
||||
username = payload.get("sub")
|
||||
if username != settings.admin_username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin session")
|
||||
return username
|
||||
171
backend/app/campaign_csv_service.py
Normal file
171
backend/app/campaign_csv_service.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import csv
|
||||
import io
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import settings
|
||||
from .models import Campaign, CampaignCsvUpload, TemplateConfig
|
||||
from .template_message_validator import PLACEHOLDER_PATTERN, message_matches_template
|
||||
|
||||
PHONE_RE = re.compile(r"^\d{10,15}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CsvValidationResult:
|
||||
total_rows: int
|
||||
headers: list[str]
|
||||
|
||||
|
||||
def template_variable_count(template_text: str) -> int:
|
||||
return len(PLACEHOLDER_PATTERN.split(template_text.strip())) - 1
|
||||
|
||||
|
||||
def build_message_from_row(template_text: str, variable_values: list[str]) -> str:
|
||||
parts = PLACEHOLDER_PATTERN.split(template_text.strip())
|
||||
message = ""
|
||||
for index, part in enumerate(parts):
|
||||
message += part
|
||||
if index < len(parts) - 1:
|
||||
message += variable_values[index] if index < len(variable_values) else ""
|
||||
return message
|
||||
|
||||
|
||||
def normalize_phone(value: str) -> str:
|
||||
return re.sub(r"\D", "", value.strip())
|
||||
|
||||
|
||||
def validate_campaign_csv(
|
||||
*,
|
||||
file_bytes: bytes,
|
||||
template: TemplateConfig,
|
||||
number_column: str,
|
||||
variable_columns: list[str],
|
||||
max_rows: int = 50_000,
|
||||
) -> tuple[CsvValidationResult, list[str]]:
|
||||
errors: list[str] = []
|
||||
expected_vars = template_variable_count(template.template_text)
|
||||
if len(variable_columns) != expected_vars:
|
||||
errors.append(
|
||||
f"Campaign expects {expected_vars} template variable column(s); "
|
||||
f"{len(variable_columns)} configured"
|
||||
)
|
||||
return CsvValidationResult(total_rows=0, headers=[]), errors
|
||||
|
||||
try:
|
||||
text = file_bytes.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
return CsvValidationResult(total_rows=0, headers=[]), ["CSV must be UTF-8 encoded"]
|
||||
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
if not reader.fieldnames:
|
||||
return CsvValidationResult(total_rows=0, headers=[]), ["CSV must include a header row"]
|
||||
|
||||
headers = [header.strip() for header in reader.fieldnames if header and header.strip()]
|
||||
required = [number_column, *variable_columns]
|
||||
missing = [column for column in required if column not in headers]
|
||||
if missing:
|
||||
return CsvValidationResult(total_rows=0, headers=headers), [
|
||||
f"Missing required column(s): {', '.join(missing)}"
|
||||
]
|
||||
|
||||
total_rows = 0
|
||||
for row_number, row in enumerate(reader, start=2):
|
||||
total_rows += 1
|
||||
if total_rows > max_rows:
|
||||
errors.append(f"CSV exceeds maximum of {max_rows} rows")
|
||||
break
|
||||
|
||||
phone = normalize_phone(row.get(number_column, "") or "")
|
||||
if not phone:
|
||||
errors.append(f"Row {row_number}: empty number in column '{number_column}'")
|
||||
continue
|
||||
if not PHONE_RE.fullmatch(phone):
|
||||
errors.append(f"Row {row_number}: invalid number '{row.get(number_column, '')}'")
|
||||
continue
|
||||
|
||||
variable_values = [(row.get(column, "") or "").strip() for column in variable_columns]
|
||||
if any(not value for value in variable_values):
|
||||
errors.append(f"Row {row_number}: all template variable columns must be filled")
|
||||
continue
|
||||
|
||||
message = build_message_from_row(template.template_text, variable_values)
|
||||
if not message_matches_template(template.template_text, message):
|
||||
errors.append(f"Row {row_number}: message does not match the approved template")
|
||||
|
||||
if len(errors) >= 25:
|
||||
errors.append("Too many validation errors; fix the CSV and re-upload")
|
||||
break
|
||||
|
||||
if total_rows == 0 and not errors:
|
||||
errors.append("CSV must contain at least one data row")
|
||||
|
||||
return CsvValidationResult(total_rows=total_rows, headers=headers), errors
|
||||
|
||||
|
||||
def save_campaign_csv(
|
||||
db: Session,
|
||||
campaign: Campaign,
|
||||
*,
|
||||
original_filename: str,
|
||||
file_bytes: bytes,
|
||||
auto_run: bool,
|
||||
) -> CampaignCsvUpload:
|
||||
result, errors = validate_campaign_csv(
|
||||
file_bytes=file_bytes,
|
||||
template=campaign.template,
|
||||
number_column=campaign.number_column,
|
||||
variable_columns=campaign.variable_columns,
|
||||
)
|
||||
if errors:
|
||||
raise ValueError("; ".join(errors))
|
||||
|
||||
upload_id = uuid.uuid4()
|
||||
campaign_dir = Path(settings.upload_dir) / "campaigns" / str(campaign.id)
|
||||
campaign_dir.mkdir(parents=True, exist_ok=True)
|
||||
stored_path = campaign_dir / f"{upload_id}.csv"
|
||||
stored_path.write_bytes(file_bytes)
|
||||
|
||||
upload = CampaignCsvUpload(
|
||||
id=upload_id,
|
||||
campaign_id=campaign.id,
|
||||
original_filename=original_filename,
|
||||
stored_path=str(stored_path),
|
||||
total_rows=result.total_rows,
|
||||
status="validated",
|
||||
)
|
||||
db.add(upload)
|
||||
db.commit()
|
||||
db.refresh(upload)
|
||||
|
||||
if auto_run:
|
||||
stub_run_csv_upload(db, upload)
|
||||
|
||||
return upload
|
||||
|
||||
|
||||
def stub_run_csv_upload(db: Session, upload: CampaignCsvUpload) -> CampaignCsvUpload:
|
||||
"""Placeholder until Redis/worker batch jobs are wired (see docs/BATCH_JOBS_TODO.md)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
upload.status = "running"
|
||||
upload.run_started_at = now
|
||||
db.commit()
|
||||
|
||||
upload.status = "completed"
|
||||
upload.run_completed_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(upload)
|
||||
return upload
|
||||
|
||||
|
||||
def stub_run_campaign_automation(db: Session, campaign: Campaign) -> list[CampaignCsvUpload]:
|
||||
pending = [
|
||||
upload
|
||||
for upload in campaign.csv_uploads
|
||||
if upload.status == "validated"
|
||||
]
|
||||
return [stub_run_csv_upload(db, upload) for upload in pending]
|
||||
38
backend/app/config.py
Normal file
38
backend/app/config.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "Urmi API"
|
||||
environment: str = "development"
|
||||
database_url: str = "postgresql+psycopg://urmi:urmi@localhost:5432/urmi"
|
||||
frontend_origin: str = "http://localhost:5173"
|
||||
auth_secret: str = "change-me-in-production"
|
||||
auth_cookie_secure: bool = False
|
||||
access_token_minutes: int = 60 * 24 * 7
|
||||
google_client_id: str = ""
|
||||
google_client_secret: str = ""
|
||||
google_redirect_uri: str = "http://localhost:8000/api/v1/auth/google/callback"
|
||||
admin_username: str = "root"
|
||||
admin_password: str = "Nishtha#01"
|
||||
upload_dir: str = "uploads"
|
||||
kyc_template_path: str = "assets/kyc-template.docx"
|
||||
dlt_loa_template_path: str = "assets/dlt-loa-template.docx"
|
||||
max_upload_bytes: int = 10 * 1024 * 1024
|
||||
ultronsms_base_url: str = "https://ultronsms.com"
|
||||
ultronsms_user: str = ""
|
||||
ultronsms_password: str = ""
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
voice_relay_public_url: str = "ws://localhost:8000/api/v1/voice"
|
||||
bonvoice_token: str = ""
|
||||
bonvoice_api_url: str = "https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/"
|
||||
bonvoice_media_allowlist: str = "103.131.25.5"
|
||||
voice_session_ttl_seconds: int = 600
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(".env", "../.env"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
19
backend/app/database.py
Normal file
19
backend/app/database.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
with SessionLocal() as session:
|
||||
yield session
|
||||
13
backend/app/dlt_constants.py
Normal file
13
backend/app/dlt_constants.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
PE_TM_CHAIN_BINDING_ID = "1302158098680292888"
|
||||
|
||||
DLT_DOCUMENT_TYPES = (
|
||||
"proof_of_directorship",
|
||||
"pe_tm_chain_binding",
|
||||
)
|
||||
|
||||
DLT_DOCUMENT_LABELS = {
|
||||
"proof_of_directorship": "Proof of directorship",
|
||||
"pe_tm_chain_binding": "PE TM chain binding status",
|
||||
}
|
||||
|
||||
DLT_UPLOAD_DOCUMENT_TYPES = ("proof_of_directorship",)
|
||||
57
backend/app/dlt_service.py
Normal file
57
backend/app/dlt_service.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dlt_constants import DLT_DOCUMENT_TYPES, PE_TM_CHAIN_BINDING_ID
|
||||
from .models import DltApplication, DltDocument, UserAccount
|
||||
|
||||
|
||||
def ensure_dlt_application(db: Session, user: UserAccount) -> DltApplication:
|
||||
if user.dlt_application:
|
||||
return user.dlt_application
|
||||
application = DltApplication(user_id=user.id, status="not_started")
|
||||
db.add(application)
|
||||
db.flush()
|
||||
for document_type in DLT_DOCUMENT_TYPES:
|
||||
db.add(DltDocument(application_id=application.id, document_type=document_type, status="missing"))
|
||||
db.commit()
|
||||
db.refresh(application)
|
||||
return application
|
||||
|
||||
|
||||
def serialize_dlt(application: DltApplication) -> dict:
|
||||
documents = sorted(application.documents, key=lambda doc: doc.document_type)
|
||||
all_approved = bool(documents) and all(doc.status == "approved" for doc in documents)
|
||||
return {
|
||||
"id": str(application.id),
|
||||
"status": "approved" if all_approved and application.status != "not_started" else application.status,
|
||||
"completed": all_approved,
|
||||
"submitted_at": application.submitted_at,
|
||||
"approved_at": application.approved_at,
|
||||
"pe_tm_chain_binding_id": PE_TM_CHAIN_BINDING_ID,
|
||||
"documents": [
|
||||
{
|
||||
"id": str(doc.id),
|
||||
"document_type": doc.document_type,
|
||||
"original_filename": doc.original_filename,
|
||||
"status": doc.status,
|
||||
"review_note": doc.review_note,
|
||||
"uploaded_at": doc.uploaded_at,
|
||||
"reviewed_at": doc.reviewed_at,
|
||||
}
|
||||
for doc in documents
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def refresh_dlt_application_status(application: DltApplication) -> None:
|
||||
documents = application.documents
|
||||
if not documents:
|
||||
return
|
||||
if all(doc.status == "approved" for doc in documents):
|
||||
application.status = "approved"
|
||||
application.approved_at = datetime.now(timezone.utc)
|
||||
elif any(doc.status == "rejected" for doc in documents):
|
||||
application.status = "rejected"
|
||||
elif application.submitted_at:
|
||||
application.status = "submitted"
|
||||
62
backend/app/endpoint_security.py
Normal file
62
backend/app/endpoint_security.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def validate_endpoint_url(value: str, *, websocket: bool) -> str:
|
||||
url = value.strip()
|
||||
parsed = urlsplit(url)
|
||||
allowed_schemes = {"wss"} if websocket else {"https"}
|
||||
if settings.environment == "development":
|
||||
allowed_schemes.add("ws" if websocket else "http")
|
||||
if parsed.scheme.lower() not in allowed_schemes:
|
||||
expected = "wss://" if websocket else "https://"
|
||||
raise ValueError(f"URL must start with {expected}")
|
||||
if not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
|
||||
raise ValueError("URL must contain a valid host and no credentials or fragment")
|
||||
try:
|
||||
address = ipaddress.ip_address(parsed.hostname)
|
||||
except ValueError:
|
||||
address = None
|
||||
if settings.environment != "development" and address and not address.is_global:
|
||||
raise ValueError("Private, loopback, and reserved network addresses are not allowed")
|
||||
if settings.environment != "development" and not address:
|
||||
try:
|
||||
resolved = {
|
||||
ipaddress.ip_address(item[4][0])
|
||||
for item in socket.getaddrinfo(parsed.hostname, parsed.port, type=socket.SOCK_STREAM)
|
||||
}
|
||||
except socket.gaierror as exc:
|
||||
raise ValueError("Endpoint host could not be resolved") from exc
|
||||
if not resolved or any(not item.is_global for item in resolved):
|
||||
raise ValueError("Endpoint host resolves to a private or reserved network address")
|
||||
return url
|
||||
|
||||
|
||||
def endpoint_signing_secret(kind: str, endpoint_id: object) -> str:
|
||||
message = f"{kind}:{endpoint_id}".encode()
|
||||
digest = hmac.new(settings.auth_secret.encode(), message, hashlib.sha256).hexdigest()
|
||||
return f"urmi_{kind}_{digest}"
|
||||
|
||||
|
||||
def signed_headers(kind: str, endpoint_id: object) -> dict[str, str]:
|
||||
timestamp = str(int(time.time()))
|
||||
secret = endpoint_signing_secret(kind, endpoint_id)
|
||||
message = f"{endpoint_id}.{timestamp}".encode()
|
||||
signature = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
|
||||
return {
|
||||
"X-Urmi-Endpoint-Id": str(endpoint_id),
|
||||
"X-Urmi-Timestamp": timestamp,
|
||||
"X-Urmi-Signature": f"sha256={signature}",
|
||||
"User-Agent": "Urmi-Relay/1.0",
|
||||
}
|
||||
|
||||
|
||||
def payload_signature(kind: str, endpoint_id: object, body: bytes) -> str:
|
||||
secret = endpoint_signing_secret(kind, endpoint_id)
|
||||
return f"sha256={hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()}"
|
||||
15
backend/app/kyc_constants.py
Normal file
15
backend/app/kyc_constants.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
KYC_DOCUMENT_TYPES = (
|
||||
"business_pan_card",
|
||||
"business_documents",
|
||||
"gst_certificate",
|
||||
"kyc_form",
|
||||
)
|
||||
|
||||
KYC_DOCUMENT_LABELS = {
|
||||
"business_pan_card": "Business PAN card",
|
||||
"business_documents": "Business documents",
|
||||
"gst_certificate": "GST certificate",
|
||||
"kyc_form": "Signed KYC form (Pvt Ltd)",
|
||||
}
|
||||
|
||||
ALLOWED_UPLOAD_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".doc", ".docx"}
|
||||
65
backend/app/kyc_service.py
Normal file
65
backend/app/kyc_service.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .kyc_constants import KYC_DOCUMENT_TYPES
|
||||
from .models import KycApplication, KycDocument, UserAccount
|
||||
|
||||
|
||||
def ensure_kyc_application(db: Session, user: UserAccount) -> KycApplication:
|
||||
if user.kyc_application:
|
||||
return user.kyc_application
|
||||
application = KycApplication(user_id=user.id, status="not_started")
|
||||
db.add(application)
|
||||
db.flush()
|
||||
for document_type in KYC_DOCUMENT_TYPES:
|
||||
db.add(KycDocument(application_id=application.id, document_type=document_type, status="missing"))
|
||||
db.commit()
|
||||
db.refresh(application)
|
||||
return application
|
||||
|
||||
|
||||
def serialize_kyc(application: KycApplication) -> dict:
|
||||
documents = sorted(application.documents, key=lambda doc: doc.document_type)
|
||||
all_approved = bool(documents) and all(doc.status == "approved" for doc in documents)
|
||||
return {
|
||||
"id": str(application.id),
|
||||
"pan_number": application.pan_number,
|
||||
"gst_number": application.gst_number,
|
||||
"status": "approved" if all_approved and application.status != "not_started" else application.status,
|
||||
"completed": all_approved and application.pan_number and application.gst_number,
|
||||
"submitted_at": application.submitted_at,
|
||||
"approved_at": application.approved_at,
|
||||
"documents": [
|
||||
{
|
||||
"id": str(doc.id),
|
||||
"document_type": doc.document_type,
|
||||
"original_filename": doc.original_filename,
|
||||
"status": doc.status,
|
||||
"review_note": doc.review_note,
|
||||
"uploaded_at": doc.uploaded_at,
|
||||
"reviewed_at": doc.reviewed_at,
|
||||
}
|
||||
for doc in documents
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def refresh_application_status(application: KycApplication) -> None:
|
||||
documents = application.documents
|
||||
if not documents:
|
||||
return
|
||||
if all(doc.status == "approved" for doc in documents) and application.pan_number and application.gst_number:
|
||||
application.status = "approved"
|
||||
application.approved_at = datetime.now(timezone.utc)
|
||||
elif any(doc.status == "rejected" for doc in documents):
|
||||
application.status = "rejected"
|
||||
elif application.submitted_at:
|
||||
application.status = "submitted"
|
||||
|
||||
|
||||
def upload_root(base_dir: str) -> Path:
|
||||
path = Path(base_dir)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
26
backend/app/kyc_validators.py
Normal file
26
backend/app/kyc_validators.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import re
|
||||
|
||||
PAN_PATTERN = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
|
||||
GST_PATTERN = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$")
|
||||
|
||||
|
||||
def normalize_pan(value: str) -> str:
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
def normalize_gst(value: str) -> str:
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
def validate_pan(value: str) -> str:
|
||||
pan = normalize_pan(value)
|
||||
if not PAN_PATTERN.match(pan):
|
||||
raise ValueError("PAN must match format ABCDE1234F")
|
||||
return pan
|
||||
|
||||
|
||||
def validate_gst(value: str) -> str:
|
||||
gst = normalize_gst(value)
|
||||
if not GST_PATTERN.match(gst):
|
||||
raise ValueError("GST must be a valid 15-character Indian GSTIN")
|
||||
return gst
|
||||
68
backend/app/log_service.py
Normal file
68
backend/app/log_service.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import OtpMessageLog, SmsMessageLog, UserAccount
|
||||
|
||||
|
||||
class SendResultLike(Protocol):
|
||||
to: str
|
||||
success: bool
|
||||
job_id: str | None
|
||||
message_id: str | None
|
||||
error: str | None
|
||||
|
||||
|
||||
def record_sms_logs(
|
||||
db: Session,
|
||||
user: UserAccount,
|
||||
*,
|
||||
sender_id: str,
|
||||
template_name: str,
|
||||
template_id: UUID,
|
||||
results: list[SendResultLike],
|
||||
) -> None:
|
||||
for item in results:
|
||||
db.add(
|
||||
SmsMessageLog(
|
||||
user_id=user.id,
|
||||
sender_id=sender_id,
|
||||
to_number=item.to,
|
||||
template_name=template_name,
|
||||
template_id=template_id,
|
||||
status="sent" if item.success else "failed",
|
||||
job_id=item.job_id,
|
||||
message_id=item.message_id,
|
||||
error_message=item.error,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def record_otp_log(
|
||||
db: Session,
|
||||
user: UserAccount,
|
||||
*,
|
||||
project_name: str,
|
||||
template_type: str,
|
||||
to_number: str,
|
||||
status: str,
|
||||
job_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> OtpMessageLog:
|
||||
entry = OtpMessageLog(
|
||||
user_id=user.id,
|
||||
project_name=project_name,
|
||||
template_type=template_type,
|
||||
to_number=to_number,
|
||||
status=status,
|
||||
job_id=job_id,
|
||||
message_id=message_id,
|
||||
error_message=error_message,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
125
backend/app/main.py
Normal file
125
backend/app/main.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import settings
|
||||
from .database import Base, engine, get_db
|
||||
from .auth import current_user
|
||||
from .models import ActivityEvent, UserAccount
|
||||
from .routes_admin import router as admin_router
|
||||
from .routes_api_keys import router as api_keys_router
|
||||
from .routes_auth import router as auth_router
|
||||
from .routes_compliance import router as compliance_router
|
||||
from .routes_dlt import router as dlt_router
|
||||
from .routes_kyc import router as kyc_router
|
||||
from .routes_analytics import router as analytics_router
|
||||
from .routes_logs import router as logs_router
|
||||
from .routes_otp import router as otp_router
|
||||
from .routes_messages import router as messages_router
|
||||
from .routes_messaging import router as messaging_router
|
||||
from .routes_campaigns import router as campaigns_router
|
||||
from .routes_integrations import router as integrations_router
|
||||
from .routes_numbers import router as numbers_router
|
||||
from .routes_urmiotp import router as urmiotp_router
|
||||
from .routes_voice import router as voice_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE urmiotp_projects "
|
||||
"ADD COLUMN IF NOT EXISTS message_label VARCHAR(20) NOT NULL DEFAULT 'URMI'"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE phone_numbers "
|
||||
"ADD COLUMN IF NOT EXISTS monthly_number_price NUMERIC(10,2) NOT NULL DEFAULT 0"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE phone_numbers "
|
||||
"ADD COLUMN IF NOT EXISTS monthly_inbound_price NUMERIC(10,2) NOT NULL DEFAULT 0"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE phone_numbers "
|
||||
"ADD COLUMN IF NOT EXISTS monthly_outbound_price NUMERIC(10,2) NOT NULL DEFAULT 0"
|
||||
)
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs",
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.frontend_origin],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(kyc_router)
|
||||
app.include_router(dlt_router)
|
||||
app.include_router(compliance_router)
|
||||
app.include_router(messaging_router)
|
||||
app.include_router(api_keys_router)
|
||||
app.include_router(messages_router)
|
||||
app.include_router(otp_router)
|
||||
app.include_router(logs_router)
|
||||
app.include_router(analytics_router)
|
||||
app.include_router(urmiotp_router)
|
||||
app.include_router(campaigns_router)
|
||||
app.include_router(numbers_router)
|
||||
app.include_router(integrations_router)
|
||||
app.include_router(voice_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health(db: Session = Depends(get_db)) -> dict[str, str]:
|
||||
db.execute(text("SELECT 1"))
|
||||
return {"status": "healthy", "service": settings.app_name}
|
||||
|
||||
|
||||
@app.get("/api/v1/dashboard")
|
||||
def dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
_: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
events = db.scalars(
|
||||
select(ActivityEvent).order_by(ActivityEvent.occurred_at.desc()).limit(8)
|
||||
).all()
|
||||
return {
|
||||
"metrics": {
|
||||
"voice_calls": 1243,
|
||||
"messages_sent": 45231,
|
||||
"delivery_rate": 98.7,
|
||||
"api_requests": 1_200_000,
|
||||
"balance": 24820,
|
||||
},
|
||||
"activity": [
|
||||
{
|
||||
"id": str(event.id),
|
||||
"type": event.event_type,
|
||||
"title": event.title,
|
||||
"detail": event.detail,
|
||||
"status": event.status,
|
||||
"occurred_at": event.occurred_at,
|
||||
}
|
||||
for event in events
|
||||
],
|
||||
}
|
||||
221
backend/app/message_send_service.py
Normal file
221
backend/app/message_send_service.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from .log_service import record_sms_logs
|
||||
from .models import SenderConfig, TemplateConfig, UserAccount
|
||||
from .template_message_validator import message_matches_template
|
||||
from .ultronsms_client import SendResult, UltronsmsClient
|
||||
|
||||
PHONE_DIGITS_RE = re.compile(r"^\d{10,15}$")
|
||||
|
||||
|
||||
class SendMessageError(Exception):
|
||||
def __init__(self, detail: str, status_code: int = 422) -> None:
|
||||
super().__init__(detail)
|
||||
self.detail = detail
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipientSendResult:
|
||||
to: str
|
||||
success: bool
|
||||
job_id: str | None = None
|
||||
message_id: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def normalize_recipients(raw_numbers: list[str]) -> list[str]:
|
||||
recipients: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in raw_numbers:
|
||||
digits = "".join(character for character in raw if character.isdigit())
|
||||
if len(digits) == 10:
|
||||
digits = f"91{digits}"
|
||||
if not PHONE_DIGITS_RE.match(digits):
|
||||
raise SendMessageError(f"Invalid recipient number: {raw.strip() or 'blank'}")
|
||||
if digits not in seen:
|
||||
seen.add(digits)
|
||||
recipients.append(digits)
|
||||
if not recipients:
|
||||
raise SendMessageError("At least one recipient number is required")
|
||||
if len(recipients) > 100:
|
||||
raise SendMessageError("A maximum of 100 recipient numbers is allowed per request")
|
||||
return recipients
|
||||
|
||||
|
||||
def parse_recipient_input(numbers: str | list[str]) -> list[str]:
|
||||
if isinstance(numbers, list):
|
||||
items = numbers
|
||||
else:
|
||||
items = [part.strip() for part in numbers.split(",")]
|
||||
return normalize_recipients([item for item in items if item.strip()])
|
||||
|
||||
|
||||
def _load_sender_and_template(
|
||||
db: Session,
|
||||
user: UserAccount,
|
||||
*,
|
||||
sender_config_id: UUID | None = None,
|
||||
sender_id: str | None = None,
|
||||
template_id: UUID,
|
||||
) -> tuple[SenderConfig, TemplateConfig]:
|
||||
sender_query = select(SenderConfig).where(
|
||||
SenderConfig.user_id == user.id,
|
||||
SenderConfig.status == "approved",
|
||||
)
|
||||
if sender_config_id is not None:
|
||||
sender_query = sender_query.where(SenderConfig.id == sender_config_id)
|
||||
elif sender_id is not None:
|
||||
sender_query = sender_query.where(SenderConfig.sender_id == sender_id.strip().upper())
|
||||
else:
|
||||
raise SendMessageError("Sender ID is required")
|
||||
|
||||
sender = db.scalar(sender_query)
|
||||
if not sender:
|
||||
raise SendMessageError("Approved sender ID not found", status_code=404)
|
||||
|
||||
template = db.scalar(
|
||||
select(TemplateConfig)
|
||||
.options(joinedload(TemplateConfig.sender_config))
|
||||
.where(
|
||||
TemplateConfig.id == template_id,
|
||||
TemplateConfig.user_id == user.id,
|
||||
TemplateConfig.status == "approved",
|
||||
)
|
||||
)
|
||||
if not template:
|
||||
raise SendMessageError("Approved template not found", status_code=404)
|
||||
if template.sender_config_id != sender.id:
|
||||
raise SendMessageError("Template is not linked to the selected sender ID")
|
||||
return sender, template
|
||||
|
||||
|
||||
def validate_message_for_template(template_text: str, message: str) -> str:
|
||||
normalized = message.strip()
|
||||
if not normalized:
|
||||
raise SendMessageError("Message is required")
|
||||
if len(normalized) > 1000:
|
||||
raise SendMessageError("Message must be 1000 characters or fewer")
|
||||
if not message_matches_template(template_text, normalized):
|
||||
raise SendMessageError("Message does not match the approved DLT template")
|
||||
return normalized
|
||||
|
||||
|
||||
def _dispatch_sms(
|
||||
*,
|
||||
recipient: str,
|
||||
message: str,
|
||||
sender: SenderConfig,
|
||||
template: TemplateConfig,
|
||||
client: UltronsmsClient,
|
||||
) -> RecipientSendResult:
|
||||
try:
|
||||
result: SendResult = client.send_template_sms(
|
||||
mobile=recipient,
|
||||
message=message,
|
||||
sender_id=sender.sender_id,
|
||||
pe_id=sender.dlt_entity_id,
|
||||
dlt_template_id=template.dlt_template_id,
|
||||
sender_type=sender.sender_type,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
return RecipientSendResult(to=recipient, success=False, error=str(exc))
|
||||
except Exception:
|
||||
return RecipientSendResult(to=recipient, success=False, error="SMS gateway request failed")
|
||||
|
||||
if not result.success:
|
||||
error_message = result.raw.get("ErrorMessage") or "SMS gateway rejected the request"
|
||||
return RecipientSendResult(
|
||||
to=recipient,
|
||||
success=False,
|
||||
error=error_message,
|
||||
)
|
||||
return RecipientSendResult(
|
||||
to=result.number or recipient,
|
||||
success=True,
|
||||
job_id=result.job_id,
|
||||
message_id=result.message_id,
|
||||
)
|
||||
|
||||
|
||||
def send_template_messages(
|
||||
db: Session,
|
||||
user: UserAccount,
|
||||
*,
|
||||
template_id: UUID,
|
||||
message: str,
|
||||
recipients: list[str],
|
||||
sender_config_id: UUID | None = None,
|
||||
sender_id: str | None = None,
|
||||
campaign_name: str | None = None,
|
||||
) -> dict:
|
||||
if campaign_name is not None and not campaign_name.strip():
|
||||
raise SendMessageError("Campaign name cannot be blank")
|
||||
if campaign_name and len(campaign_name.strip()) > 120:
|
||||
raise SendMessageError("Campaign name must be 120 characters or fewer")
|
||||
|
||||
sender, template = _load_sender_and_template(
|
||||
db,
|
||||
user,
|
||||
sender_config_id=sender_config_id,
|
||||
sender_id=sender_id,
|
||||
template_id=template_id,
|
||||
)
|
||||
normalized_message = validate_message_for_template(template.template_text, message)
|
||||
normalized_recipients = normalize_recipients(recipients)
|
||||
|
||||
client = UltronsmsClient()
|
||||
results: list[RecipientSendResult] = []
|
||||
for recipient in normalized_recipients:
|
||||
results.append(
|
||||
_dispatch_sms(
|
||||
recipient=recipient,
|
||||
message=normalized_message,
|
||||
sender=sender,
|
||||
template=template,
|
||||
client=client,
|
||||
)
|
||||
)
|
||||
|
||||
record_sms_logs(
|
||||
db,
|
||||
user,
|
||||
sender_id=sender.sender_id,
|
||||
template_name=template.template_name,
|
||||
template_id=template.id,
|
||||
results=results,
|
||||
)
|
||||
|
||||
sent = sum(1 for item in results if item.success)
|
||||
failed = len(results) - sent
|
||||
payload = {
|
||||
"success": failed == 0,
|
||||
"campaign_name": campaign_name.strip() if campaign_name else None,
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
"sender_id": sender.sender_id,
|
||||
"template_id": str(template.id),
|
||||
"template_name": template.template_name,
|
||||
"dlt_template_id": template.dlt_template_id,
|
||||
"results": [
|
||||
{
|
||||
"to": item.to,
|
||||
"success": item.success,
|
||||
"job_id": item.job_id,
|
||||
"message_id": item.message_id,
|
||||
"error": item.error,
|
||||
}
|
||||
for item in results
|
||||
],
|
||||
}
|
||||
if sent == 0:
|
||||
raise SendMessageError(
|
||||
payload["results"][0]["error"] if payload["results"] else "SMS send failed",
|
||||
status_code=422,
|
||||
)
|
||||
return payload
|
||||
493
backend/app/models.py
Normal file
493
backend/app/models.py
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Organization(Base):
|
||||
__tablename__ = "organizations"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
home_region: Mapped[str] = mapped_column(String(32), default="ap-south-1")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
projects: Mapped[list["Project"]] = relationship(back_populates="organization")
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("organizations.id"))
|
||||
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
environment: Mapped[str] = mapped_column(String(24), default="test")
|
||||
status: Mapped[str] = mapped_column(String(24), default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
organization: Mapped[Organization] = relationship(back_populates="projects")
|
||||
|
||||
|
||||
class ActivityEvent(Base):
|
||||
__tablename__ = "activity_events"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id"), index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
detail: Mapped[str] = mapped_column(String(500), default="")
|
||||
status: Mapped[str] = mapped_column(String(24), default="success")
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class UserAccount(Base):
|
||||
"""Canonical person record, independent of any login provider."""
|
||||
|
||||
__tablename__ = "user_accounts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(160))
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
status: Mapped[str] = mapped_column(String(24), default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
password_credential: Mapped["PasswordCredential | None"] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
oidc_identities: Mapped[list["OidcIdentity"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
kyc_application: Mapped["KycApplication | None"] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
dlt_application: Mapped["DltApplication | None"] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
sender_configs: Mapped[list["SenderConfig"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
template_configs: Mapped[list["TemplateConfig"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
api_keys: Mapped[list["ApiKey"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
urmiotp_projects: Mapped[list["UrmiOtpProject"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
campaigns: Mapped[list["Campaign"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
phone_numbers: Mapped[list["PhoneNumber"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
voice_calls: Mapped[list["VoiceCall"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
sms_message_logs: Mapped[list["SmsMessageLog"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
otp_message_logs: Mapped[list["OtpMessageLog"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
voice_socket_endpoints: Mapped[list["VoiceSocketEndpoint"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
sms_delivery_webhooks: Mapped[list["SmsDeliveryWebhook"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class PasswordCredential(Base):
|
||||
"""First-party credential kept separate from the canonical user."""
|
||||
|
||||
__tablename__ = "password_credentials"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(String(500))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="password_credential")
|
||||
|
||||
|
||||
class OidcIdentity(Base):
|
||||
"""Provider identity; no provider claims are stored on the user table."""
|
||||
|
||||
__tablename__ = "oidc_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("issuer", "subject", name="uq_oidc_issuer_subject"),
|
||||
UniqueConstraint("user_id", "issuer", name="uq_oidc_user_issuer"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
issuer: Mapped[str] = mapped_column(String(500))
|
||||
subject: Mapped[str] = mapped_column(String(500))
|
||||
provider_email: Mapped[str] = mapped_column(String(320))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
last_login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
user: Mapped[UserAccount] = relationship(back_populates="oidc_identities")
|
||||
|
||||
|
||||
class KycApplication(Base):
|
||||
__tablename__ = "kyc_applications"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
pan_number: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
gst_number: Mapped[str | None] = mapped_column(String(15), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="not_started", index=True)
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="kyc_application")
|
||||
documents: Mapped[list["KycDocument"]] = relationship(
|
||||
back_populates="application", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class KycDocument(Base):
|
||||
__tablename__ = "kyc_documents"
|
||||
__table_args__ = (UniqueConstraint("application_id", "document_type", name="uq_kyc_app_doc_type"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
application_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("kyc_applications.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
document_type: Mapped[str] = mapped_column(String(40))
|
||||
original_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
stored_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="missing")
|
||||
review_note: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
uploaded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
application: Mapped[KycApplication] = relationship(back_populates="documents")
|
||||
|
||||
|
||||
class DltApplication(Base):
|
||||
__tablename__ = "dlt_applications"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(24), default="not_started", index=True)
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="dlt_application")
|
||||
documents: Mapped[list["DltDocument"]] = relationship(
|
||||
back_populates="application", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class DltDocument(Base):
|
||||
__tablename__ = "dlt_documents"
|
||||
__table_args__ = (UniqueConstraint("application_id", "document_type", name="uq_dlt_app_doc_type"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
application_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("dlt_applications.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
document_type: Mapped[str] = mapped_column(String(40))
|
||||
original_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
stored_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="missing")
|
||||
review_note: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
uploaded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
application: Mapped[DltApplication] = relationship(back_populates="documents")
|
||||
|
||||
|
||||
class SenderConfig(Base):
|
||||
__tablename__ = "sender_configs"
|
||||
__table_args__ = (UniqueConstraint("user_id", "sender_id", name="uq_sender_user_sender_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
sender_id: Mapped[str] = mapped_column(String(11), index=True)
|
||||
sender_type: Mapped[str] = mapped_column(String(20))
|
||||
purpose: Mapped[str] = mapped_column(String(500))
|
||||
dlt_entity_id: Mapped[str] = mapped_column(String(50))
|
||||
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
|
||||
review_note: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="sender_configs")
|
||||
templates: Mapped[list["TemplateConfig"]] = relationship(
|
||||
back_populates="sender_config", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class TemplateConfig(Base):
|
||||
__tablename__ = "template_configs"
|
||||
__table_args__ = (UniqueConstraint("user_id", "template_name", name="uq_template_user_name"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
sender_config_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("sender_configs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
template_name: Mapped[str] = mapped_column(String(40), index=True)
|
||||
template_text: Mapped[str] = mapped_column(String(1000))
|
||||
dlt_template_id: Mapped[str] = mapped_column(String(50))
|
||||
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
|
||||
review_note: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="template_configs")
|
||||
sender_config: Mapped[SenderConfig] = relationship(back_populates="templates")
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(60))
|
||||
key_prefix: Mapped[str] = mapped_column(String(24))
|
||||
key_hash: Mapped[str] = mapped_column(String(500))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="api_keys")
|
||||
|
||||
|
||||
class UrmiOtpProject(Base):
|
||||
__tablename__ = "urmiotp_projects"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
project_name: Mapped[str] = mapped_column(String(30), unique=True, index=True)
|
||||
message_label: Mapped[str] = mapped_column(String(20), server_default="URMI")
|
||||
app_type: Mapped[str] = mapped_column(String(16))
|
||||
app_url: Mapped[str] = mapped_column(String(1000))
|
||||
purpose: Mapped[str] = mapped_column(String(1000))
|
||||
terms_accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
|
||||
review_note: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="urmiotp_projects")
|
||||
|
||||
|
||||
class Campaign(Base):
|
||||
__tablename__ = "campaigns"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(80))
|
||||
sender_config_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("sender_configs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
template_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("template_configs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
number_column: Mapped[str] = mapped_column(String(80))
|
||||
variable_columns: Mapped[list] = mapped_column(JSON)
|
||||
run_mode: Mapped[str] = mapped_column(String(20), index=True)
|
||||
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="active", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="campaigns")
|
||||
sender_config: Mapped["SenderConfig"] = relationship()
|
||||
template: Mapped["TemplateConfig"] = relationship()
|
||||
csv_uploads: Mapped[list["CampaignCsvUpload"]] = relationship(
|
||||
back_populates="campaign", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class CampaignCsvUpload(Base):
|
||||
__tablename__ = "campaign_csv_uploads"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
campaign_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
original_filename: Mapped[str] = mapped_column(String(255))
|
||||
stored_path: Mapped[str] = mapped_column(String(500))
|
||||
total_rows: Mapped[int] = mapped_column(default=0)
|
||||
status: Mapped[str] = mapped_column(String(24), default="validated", index=True)
|
||||
run_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
run_completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
campaign: Mapped[Campaign] = relationship(back_populates="csv_uploads")
|
||||
|
||||
|
||||
class PhoneNumber(Base):
|
||||
__tablename__ = "phone_numbers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
phone_number: Mapped[str] = mapped_column(String(20), unique=True, index=True)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="SET NULL"), index=True, nullable=True
|
||||
)
|
||||
inbound_price: Mapped[float] = mapped_column(Numeric(10, 4))
|
||||
outbound_price: Mapped[float] = mapped_column(Numeric(10, 4))
|
||||
monthly_number_price: Mapped[float] = mapped_column(Numeric(10, 2), server_default="0")
|
||||
currency: Mapped[str] = mapped_column(String(8), default="INR")
|
||||
status: Mapped[str] = mapped_column(String(20), default="available", index=True)
|
||||
acquisition_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
assigned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
user: Mapped[UserAccount | None] = relationship(back_populates="phone_numbers")
|
||||
voice_calls: Mapped[list["VoiceCall"]] = relationship(back_populates="phone_number")
|
||||
voice_socket_endpoint: Mapped["VoiceSocketEndpoint | None"] = relationship(
|
||||
back_populates="phone_number", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
sms_delivery_webhooks: Mapped[list["SmsDeliveryWebhook"]] = relationship(
|
||||
back_populates="phone_number", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class VoiceSocketEndpoint(Base):
|
||||
"""Customer WebSocket destination associated with one owned phone number."""
|
||||
|
||||
__tablename__ = "voice_socket_endpoints"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("phone_number_id", name="uq_voice_socket_phone_number"),
|
||||
UniqueConstraint("user_id", "name", name="uq_voice_socket_user_name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
phone_number_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("phone_numbers.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
url: Mapped[str] = mapped_column(String(500))
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="voice_socket_endpoints")
|
||||
phone_number: Mapped[PhoneNumber] = relationship(back_populates="voice_socket_endpoint")
|
||||
|
||||
|
||||
class SmsDeliveryWebhook(Base):
|
||||
"""Named SMS delivery-report destination associated with an owned number."""
|
||||
|
||||
__tablename__ = "sms_delivery_webhooks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "name", name="uq_sms_webhook_user_name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
phone_number_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("phone_numbers.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
url: Mapped[str] = mapped_column(String(500))
|
||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="sms_delivery_webhooks")
|
||||
phone_number: Mapped[PhoneNumber] = relationship(back_populates="sms_delivery_webhooks")
|
||||
|
||||
|
||||
class VoiceCall(Base):
|
||||
__tablename__ = "voice_calls"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
phone_number_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("phone_numbers.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
direction: Mapped[str] = mapped_column(String(16), index=True)
|
||||
from_did: Mapped[str] = mapped_column(String(20))
|
||||
to_number: Mapped[str] = mapped_column(String(20))
|
||||
status: Mapped[str] = mapped_column(String(24), default="initiated", index=True)
|
||||
provider_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
session_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
stream_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
call_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
bridge_mode: Mapped[str] = mapped_column(String(16), default="console")
|
||||
upstream_ws: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
answered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
user: Mapped[UserAccount] = relationship(back_populates="voice_calls")
|
||||
phone_number: Mapped[PhoneNumber] = relationship(back_populates="voice_calls")
|
||||
|
||||
|
||||
class SmsMessageLog(Base):
|
||||
__tablename__ = "sms_message_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
sender_id: Mapped[str] = mapped_column(String(11), index=True)
|
||||
to_number: Mapped[str] = mapped_column(String(20), index=True)
|
||||
template_name: Mapped[str] = mapped_column(String(40))
|
||||
template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="sent", index=True)
|
||||
job_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
message_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="sms_message_logs")
|
||||
|
||||
|
||||
class OtpMessageLog(Base):
|
||||
__tablename__ = "otp_message_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
project_name: Mapped[str] = mapped_column(String(30), index=True)
|
||||
template_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||
to_number: Mapped[str] = mapped_column(String(20), index=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="sent", index=True)
|
||||
job_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
message_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="otp_message_logs")
|
||||
90
backend/app/otp_service.py
Normal file
90
backend/app/otp_service.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from fastapi import HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .log_service import record_otp_log
|
||||
from .message_send_service import SendMessageError, normalize_recipients
|
||||
from .models import UrmiOtpProject, UserAccount
|
||||
from .urmiotp_catalog import OTP_TEMPLATES
|
||||
|
||||
TEMPLATE_TYPES = {template["type"] for template in OTP_TEMPLATES}
|
||||
|
||||
|
||||
class GenerateOtpPayload(BaseModel):
|
||||
project_name: str = Field(min_length=3, max_length=30)
|
||||
template_type: str = Field(min_length=3, max_length=40)
|
||||
to: str = Field(min_length=10, max_length=15)
|
||||
variables: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def _build_otp_message(template_type: str, message_label: str, variables: dict[str, str]) -> str:
|
||||
template = next((item for item in OTP_TEMPLATES if item["type"] == template_type), None)
|
||||
if not template:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unknown template type")
|
||||
|
||||
otp_value = variables.get("otp", "").strip()
|
||||
validity = variables.get("validity_minutes", "").strip()
|
||||
if not otp_value or not validity:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="variables.otp and variables.validity_minutes are required",
|
||||
)
|
||||
|
||||
content = template["content"].replace("{message_label}", message_label)
|
||||
for value in (otp_value, validity):
|
||||
content = content.replace("{#var#}", value, 1)
|
||||
return content
|
||||
|
||||
|
||||
def generate_otp_for_user(
|
||||
db: Session,
|
||||
user: UserAccount,
|
||||
payload: GenerateOtpPayload,
|
||||
) -> dict:
|
||||
if payload.template_type not in TEMPLATE_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"template_type must be one of: {', '.join(sorted(TEMPLATE_TYPES))}",
|
||||
)
|
||||
|
||||
project = db.scalar(
|
||||
select(UrmiOtpProject).where(
|
||||
UrmiOtpProject.user_id == user.id,
|
||||
UrmiOtpProject.project_name == payload.project_name.strip(),
|
||||
)
|
||||
)
|
||||
if not project:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UrmiOTP project not found")
|
||||
if project.status != "approved":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="UrmiOTP project is not approved yet",
|
||||
)
|
||||
|
||||
try:
|
||||
recipients = normalize_recipients([payload.to])
|
||||
except SendMessageError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
recipient = recipients[0]
|
||||
message = _build_otp_message(payload.template_type, project.message_label, payload.variables)
|
||||
|
||||
entry = record_otp_log(
|
||||
db,
|
||||
user,
|
||||
project_name=project.project_name,
|
||||
template_type=payload.template_type,
|
||||
to_number=recipient,
|
||||
status="accepted",
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"log_id": str(entry.id),
|
||||
"project_name": project.project_name,
|
||||
"template_type": payload.template_type,
|
||||
"to": recipient,
|
||||
"message_preview": message,
|
||||
"status": entry.status,
|
||||
}
|
||||
30
backend/app/phone_number_validators.py
Normal file
30
backend/app/phone_number_validators.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import re
|
||||
|
||||
INDIAN_MOBILE_RE = re.compile(r"^[6-9]\d{9}$")
|
||||
|
||||
|
||||
def normalize_phone_number(value: str) -> str:
|
||||
cleaned = re.sub(r"\D", "", value.strip())
|
||||
if cleaned.startswith("91") and len(cleaned) == 12:
|
||||
cleaned = cleaned[2:]
|
||||
if cleaned.startswith("0") and len(cleaned) == 11:
|
||||
cleaned = cleaned[1:]
|
||||
if not INDIAN_MOBILE_RE.fullmatch(cleaned):
|
||||
raise ValueError("Enter a 10-digit mobile number without +91, e.g. 9876543210")
|
||||
return cleaned
|
||||
|
||||
|
||||
def validate_price(value: float, label: str) -> float:
|
||||
if value < 0:
|
||||
raise ValueError(f"{label} must be zero or greater")
|
||||
if value > 999999:
|
||||
raise ValueError(f"{label} is too large")
|
||||
return round(float(value), 2)
|
||||
|
||||
|
||||
def validate_per_min_price(value: float, label: str) -> float:
|
||||
if value < 0:
|
||||
raise ValueError(f"{label} must be zero or greater")
|
||||
if value > 9999:
|
||||
raise ValueError(f"{label} is too large")
|
||||
return round(float(value), 4)
|
||||
518
backend/app/routes_admin.py
Normal file
518
backend/app/routes_admin.py
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from .auth import ADMIN_COOKIE_NAME, create_admin_token, current_admin
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
from .dlt_service import refresh_dlt_application_status, serialize_dlt
|
||||
from .kyc_service import refresh_application_status, serialize_kyc
|
||||
from .routes_messaging import serialize_sender, serialize_template
|
||||
from .models import (
|
||||
DltApplication,
|
||||
DltDocument,
|
||||
KycApplication,
|
||||
KycDocument,
|
||||
PhoneNumber,
|
||||
SenderConfig,
|
||||
TemplateConfig,
|
||||
UrmiOtpProject,
|
||||
UserAccount,
|
||||
)
|
||||
from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price
|
||||
from .routes_urmiotp import serialize_project
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class DocumentReviewRequest(BaseModel):
|
||||
status: str = Field(pattern="^(approved|rejected)$")
|
||||
review_note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ProjectReviewRequest(BaseModel):
|
||||
status: str = Field(pattern="^(approved|rejected)$")
|
||||
review_note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class CreatePhoneNumberRequest(BaseModel):
|
||||
phone_number: str = Field(min_length=10, max_length=10)
|
||||
monthly_number_price: float = Field(ge=0)
|
||||
inbound_price: float = Field(ge=0)
|
||||
outbound_price: float = Field(ge=0)
|
||||
user_email: str | None = Field(default=None, max_length=320)
|
||||
|
||||
|
||||
class UpdatePhoneNumberRequest(BaseModel):
|
||||
monthly_number_price: float | None = Field(default=None, ge=0)
|
||||
inbound_price: float | None = Field(default=None, ge=0)
|
||||
outbound_price: float | None = Field(default=None, ge=0)
|
||||
user_email: str | None = Field(default=None, max_length=320)
|
||||
release: bool = False
|
||||
|
||||
|
||||
def set_admin_session(response: Response) -> None:
|
||||
response.set_cookie(
|
||||
ADMIN_COOKIE_NAME,
|
||||
create_admin_token(settings.admin_username),
|
||||
httponly=True,
|
||||
secure=settings.auth_cookie_secure,
|
||||
samesite="lax",
|
||||
max_age=settings.access_token_minutes * 60,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def admin_login(payload: AdminLoginRequest, response: Response) -> dict[str, str]:
|
||||
if payload.username != settings.admin_username or payload.password != settings.admin_password:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin credentials")
|
||||
set_admin_session(response)
|
||||
return {"username": settings.admin_username}
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def admin_logout(response: Response) -> None:
|
||||
response.delete_cookie(ADMIN_COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def admin_me(username: str = Depends(current_admin)) -> dict[str, str]:
|
||||
return {"username": username}
|
||||
|
||||
|
||||
@router.get("/compliance/kyc")
|
||||
def list_kyc_applications(
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> list[dict]:
|
||||
applications = db.scalars(
|
||||
select(KycApplication)
|
||||
.options(joinedload(KycApplication.user), joinedload(KycApplication.documents))
|
||||
.order_by(KycApplication.updated_at.desc())
|
||||
).unique().all()
|
||||
return [
|
||||
{
|
||||
**serialize_kyc(app),
|
||||
"user": {
|
||||
"id": str(app.user.id),
|
||||
"name": app.user.display_name,
|
||||
"email": app.user.email,
|
||||
},
|
||||
}
|
||||
for app in applications
|
||||
if app.status != "not_started" or app.pan_number or any(doc.status != "missing" for doc in app.documents)
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/compliance/kyc/{application_id}/documents/{document_id}")
|
||||
def review_document(
|
||||
application_id: UUID,
|
||||
document_id: UUID,
|
||||
payload: DocumentReviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> dict:
|
||||
application = db.scalar(
|
||||
select(KycApplication)
|
||||
.options(joinedload(KycApplication.user), joinedload(KycApplication.documents))
|
||||
.where(KycApplication.id == application_id)
|
||||
)
|
||||
if not application:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="KYC application not found")
|
||||
document = db.get(KycDocument, document_id)
|
||||
if not document or document.application_id != application.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
if document.status == "missing":
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Document not uploaded yet")
|
||||
|
||||
document.status = payload.status
|
||||
document.review_note = payload.review_note
|
||||
document.reviewed_at = datetime.now(timezone.utc)
|
||||
refresh_application_status(application)
|
||||
db.commit()
|
||||
db.refresh(application)
|
||||
return {
|
||||
**serialize_kyc(application),
|
||||
"user": {
|
||||
"id": str(application.user.id),
|
||||
"name": application.user.display_name,
|
||||
"email": application.user.email,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compliance/dlt")
|
||||
def list_dlt_applications(
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> list[dict]:
|
||||
applications = db.scalars(
|
||||
select(DltApplication)
|
||||
.options(joinedload(DltApplication.user), joinedload(DltApplication.documents))
|
||||
.order_by(DltApplication.updated_at.desc())
|
||||
).unique().all()
|
||||
return [
|
||||
{
|
||||
**serialize_dlt(app),
|
||||
"user": {
|
||||
"id": str(app.user.id),
|
||||
"name": app.user.display_name,
|
||||
"email": app.user.email,
|
||||
},
|
||||
}
|
||||
for app in applications
|
||||
if app.status != "not_started" or any(doc.status != "missing" for doc in app.documents)
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/compliance/dlt/{application_id}/documents/{document_id}")
|
||||
def review_dlt_document(
|
||||
application_id: UUID,
|
||||
document_id: UUID,
|
||||
payload: DocumentReviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> dict:
|
||||
application = db.scalar(
|
||||
select(DltApplication)
|
||||
.options(joinedload(DltApplication.user), joinedload(DltApplication.documents))
|
||||
.where(DltApplication.id == application_id)
|
||||
)
|
||||
if not application:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="DLT application not found")
|
||||
document = db.get(DltDocument, document_id)
|
||||
if not document or document.application_id != application.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
if document.status == "missing":
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Document not uploaded yet")
|
||||
|
||||
document.status = payload.status
|
||||
document.review_note = payload.review_note
|
||||
document.reviewed_at = datetime.now(timezone.utc)
|
||||
refresh_dlt_application_status(application)
|
||||
db.commit()
|
||||
db.refresh(application)
|
||||
return {
|
||||
**serialize_dlt(application),
|
||||
"user": {
|
||||
"id": str(application.user.id),
|
||||
"name": application.user.display_name,
|
||||
"email": application.user.email,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compliance/senders")
|
||||
def list_sender_configs(
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> list[dict]:
|
||||
senders = db.scalars(
|
||||
select(SenderConfig)
|
||||
.options(joinedload(SenderConfig.user))
|
||||
.order_by(SenderConfig.created_at.desc())
|
||||
).unique().all()
|
||||
return [
|
||||
{
|
||||
**serialize_sender(sender),
|
||||
"user": {
|
||||
"id": str(sender.user.id),
|
||||
"name": sender.user.display_name,
|
||||
"email": sender.user.email,
|
||||
},
|
||||
}
|
||||
for sender in senders
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/compliance/senders/{sender_id}")
|
||||
def review_sender(
|
||||
sender_id: UUID,
|
||||
payload: DocumentReviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> dict:
|
||||
sender = db.scalar(
|
||||
select(SenderConfig)
|
||||
.options(joinedload(SenderConfig.user))
|
||||
.where(SenderConfig.id == sender_id)
|
||||
)
|
||||
if not sender:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sender config not found")
|
||||
if sender.status != "pending":
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Sender already reviewed")
|
||||
|
||||
sender.status = payload.status
|
||||
sender.review_note = payload.review_note
|
||||
sender.reviewed_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(sender)
|
||||
return {
|
||||
**serialize_sender(sender),
|
||||
"user": {
|
||||
"id": str(sender.user.id),
|
||||
"name": sender.user.display_name,
|
||||
"email": sender.user.email,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compliance/templates")
|
||||
def list_template_configs(
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> list[dict]:
|
||||
templates = db.scalars(
|
||||
select(TemplateConfig)
|
||||
.options(joinedload(TemplateConfig.user), joinedload(TemplateConfig.sender_config))
|
||||
.order_by(TemplateConfig.created_at.desc())
|
||||
).unique().all()
|
||||
return [
|
||||
{
|
||||
**serialize_template(template),
|
||||
"user": {
|
||||
"id": str(template.user.id),
|
||||
"name": template.user.display_name,
|
||||
"email": template.user.email,
|
||||
},
|
||||
}
|
||||
for template in templates
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/compliance/templates/{template_id}")
|
||||
def review_template(
|
||||
template_id: UUID,
|
||||
payload: DocumentReviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> dict:
|
||||
template = db.scalar(
|
||||
select(TemplateConfig)
|
||||
.options(joinedload(TemplateConfig.user), joinedload(TemplateConfig.sender_config))
|
||||
.where(TemplateConfig.id == template_id)
|
||||
)
|
||||
if not template:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template config not found")
|
||||
if template.status != "pending":
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Template already reviewed")
|
||||
|
||||
template.status = payload.status
|
||||
template.review_note = payload.review_note
|
||||
template.reviewed_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
return {
|
||||
**serialize_template(template),
|
||||
"user": {
|
||||
"id": str(template.user.id),
|
||||
"name": template.user.display_name,
|
||||
"email": template.user.email,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compliance/urmiotp-projects")
|
||||
def list_urmiotp_projects(
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> list[dict]:
|
||||
projects = db.scalars(
|
||||
select(UrmiOtpProject)
|
||||
.options(joinedload(UrmiOtpProject.user))
|
||||
.order_by(UrmiOtpProject.created_at.desc())
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
**serialize_project(project),
|
||||
"user": {
|
||||
"id": str(project.user.id),
|
||||
"name": project.user.display_name,
|
||||
"email": project.user.email,
|
||||
},
|
||||
}
|
||||
for project in projects
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/compliance/urmiotp-projects/{project_id}")
|
||||
def review_urmiotp_project(
|
||||
project_id: UUID,
|
||||
payload: ProjectReviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> dict:
|
||||
project = db.scalar(
|
||||
select(UrmiOtpProject)
|
||||
.options(joinedload(UrmiOtpProject.user))
|
||||
.where(UrmiOtpProject.id == project_id)
|
||||
)
|
||||
if not project:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UrmiOTP project not found")
|
||||
if project.status != "pending":
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Project already reviewed")
|
||||
|
||||
project.status = payload.status
|
||||
project.review_note = payload.review_note
|
||||
project.reviewed_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return {
|
||||
**serialize_project(project),
|
||||
"user": {
|
||||
"id": str(project.user.id),
|
||||
"name": project.user.display_name,
|
||||
"email": project.user.email,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def serialize_admin_phone_number(number: PhoneNumber) -> dict:
|
||||
return {
|
||||
"id": str(number.id),
|
||||
"phone_number": number.phone_number,
|
||||
"monthly_number_price": float(number.monthly_number_price),
|
||||
"inbound_price": float(number.inbound_price),
|
||||
"outbound_price": float(number.outbound_price),
|
||||
"currency": number.currency,
|
||||
"status": number.status,
|
||||
"acquisition_type": number.acquisition_type,
|
||||
"assigned_at": number.assigned_at,
|
||||
"created_at": number.created_at,
|
||||
"user": (
|
||||
{
|
||||
"id": str(number.user.id),
|
||||
"name": number.user.display_name,
|
||||
"email": number.user.email,
|
||||
}
|
||||
if number.user
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/phone-numbers")
|
||||
def list_phone_numbers(
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> list[dict]:
|
||||
numbers = db.scalars(
|
||||
select(PhoneNumber)
|
||||
.options(joinedload(PhoneNumber.user))
|
||||
.order_by(PhoneNumber.created_at.desc())
|
||||
).all()
|
||||
return [serialize_admin_phone_number(number) for number in numbers]
|
||||
|
||||
|
||||
@router.post("/phone-numbers", status_code=status.HTTP_201_CREATED)
|
||||
def create_phone_number(
|
||||
payload: CreatePhoneNumberRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> dict:
|
||||
try:
|
||||
phone_number = normalize_phone_number(payload.phone_number)
|
||||
monthly_number_price = validate_price(payload.monthly_number_price, "Number monthly price")
|
||||
inbound_price = validate_per_min_price(payload.inbound_price, "Inbound price per min")
|
||||
outbound_price = validate_per_min_price(payload.outbound_price, "Outbound price per min")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
existing = db.scalar(select(PhoneNumber).where(PhoneNumber.phone_number == phone_number))
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This phone number already exists")
|
||||
|
||||
user = None
|
||||
if payload.user_email:
|
||||
user = db.scalar(select(UserAccount).where(UserAccount.email == payload.user_email.strip().lower()))
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No user account found for that email",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
number = PhoneNumber(
|
||||
phone_number=phone_number,
|
||||
inbound_price=inbound_price,
|
||||
outbound_price=outbound_price,
|
||||
monthly_number_price=monthly_number_price,
|
||||
user_id=user.id if user else None,
|
||||
status="assigned" if user else "available",
|
||||
acquisition_type="admin_assigned" if user else None,
|
||||
assigned_at=now if user else None,
|
||||
)
|
||||
db.add(number)
|
||||
db.commit()
|
||||
db.refresh(number)
|
||||
if user:
|
||||
number.user = user
|
||||
return serialize_admin_phone_number(number)
|
||||
|
||||
|
||||
@router.patch("/phone-numbers/{number_id}")
|
||||
def update_phone_number(
|
||||
number_id: UUID,
|
||||
payload: UpdatePhoneNumberRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(current_admin),
|
||||
) -> dict:
|
||||
number = db.scalar(
|
||||
select(PhoneNumber)
|
||||
.options(joinedload(PhoneNumber.user))
|
||||
.where(PhoneNumber.id == number_id)
|
||||
)
|
||||
if not number:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found")
|
||||
|
||||
if payload.release:
|
||||
number.user_id = None
|
||||
number.status = "available"
|
||||
number.acquisition_type = None
|
||||
number.assigned_at = None
|
||||
number.user = None
|
||||
elif payload.user_email is not None:
|
||||
email = payload.user_email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="User email cannot be empty",
|
||||
)
|
||||
user = db.scalar(select(UserAccount).where(UserAccount.email == email))
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No user account found for that email")
|
||||
number.user_id = user.id
|
||||
number.status = "assigned"
|
||||
number.acquisition_type = "admin_assigned"
|
||||
number.assigned_at = datetime.now(timezone.utc)
|
||||
number.user = user
|
||||
|
||||
if payload.monthly_number_price is not None:
|
||||
try:
|
||||
number.monthly_number_price = validate_price(payload.monthly_number_price, "Number monthly price")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
if payload.inbound_price is not None:
|
||||
try:
|
||||
number.inbound_price = validate_per_min_price(payload.inbound_price, "Inbound price per min")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
if payload.outbound_price is not None:
|
||||
try:
|
||||
number.outbound_price = validate_per_min_price(payload.outbound_price, "Outbound price per min")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
db.commit()
|
||||
db.refresh(number)
|
||||
return serialize_admin_phone_number(number)
|
||||
50
backend/app/routes_analytics.py
Normal file
50
backend/app/routes_analytics.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .analytics_service import DEFAULT_GROUP_BY, SERVICE_GROUP_BY, build_usage_analytics
|
||||
from .auth import current_user
|
||||
from .database import get_db
|
||||
from .models import UserAccount
|
||||
|
||||
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
|
||||
|
||||
PERIODS = frozenset({"weekly", "monthly", "quarterly"})
|
||||
SERVICES = frozenset({"sms", "inbound", "outbound", "otp"})
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
def usage_analytics(
|
||||
period: str = Query(default="weekly"),
|
||||
service: str = Query(default="outbound"),
|
||||
group_by: str | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
normalized_period = period.strip().lower()
|
||||
normalized_service = service.strip().lower()
|
||||
normalized_group = (group_by or DEFAULT_GROUP_BY.get(normalized_service, "did")).strip().lower()
|
||||
|
||||
if normalized_period not in PERIODS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"period must be one of: {', '.join(sorted(PERIODS))}",
|
||||
)
|
||||
if normalized_service not in SERVICES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"service must be one of: {', '.join(sorted(SERVICES))}",
|
||||
)
|
||||
if normalized_group not in SERVICE_GROUP_BY[normalized_service]:
|
||||
allowed = ", ".join(sorted(SERVICE_GROUP_BY[normalized_service]))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"group_by for {normalized_service} must be one of: {allowed}",
|
||||
)
|
||||
|
||||
return build_usage_analytics(
|
||||
db,
|
||||
user,
|
||||
period=normalized_period, # type: ignore[arg-type]
|
||||
service=normalized_service, # type: ignore[arg-type]
|
||||
group_by=normalized_group, # type: ignore[arg-type]
|
||||
)
|
||||
127
backend/app/routes_api_keys.py
Normal file
127
backend/app/routes_api_keys.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .api_keys_service import generate_api_token, hash_api_token, token_prefix, validate_key_name
|
||||
from .auth import current_user
|
||||
from .database import get_db
|
||||
from .models import ApiKey, UserAccount
|
||||
|
||||
router = APIRouter(prefix="/api/v1/api-keys", tags=["api-keys"])
|
||||
|
||||
|
||||
class CreateApiKeyRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=60)
|
||||
|
||||
|
||||
def serialize_api_key(api_key: ApiKey, *, include_token: str | None = None) -> dict:
|
||||
payload = {
|
||||
"id": str(api_key.id),
|
||||
"name": api_key.name,
|
||||
"key_prefix": api_key.key_prefix,
|
||||
"created_at": api_key.created_at,
|
||||
"last_used_at": api_key.last_used_at,
|
||||
"revoked_at": api_key.revoked_at,
|
||||
"status": "revoked" if api_key.revoked_at else "active",
|
||||
}
|
||||
if include_token:
|
||||
payload["token"] = include_token
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_api_keys(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> list[dict]:
|
||||
keys = db.scalars(
|
||||
select(ApiKey)
|
||||
.where(ApiKey.user_id == user.id, ApiKey.revoked_at.is_(None))
|
||||
.order_by(ApiKey.created_at.desc())
|
||||
).all()
|
||||
return [serialize_api_key(key) for key in keys]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
def create_api_key(
|
||||
payload: CreateApiKeyRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
try:
|
||||
name = validate_key_name(payload.name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
existing = db.scalar(
|
||||
select(ApiKey).where(
|
||||
ApiKey.user_id == user.id,
|
||||
ApiKey.name == name,
|
||||
ApiKey.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="An active API key with this name already exists")
|
||||
|
||||
api_key = ApiKey(
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
key_prefix="pending",
|
||||
key_hash="pending",
|
||||
)
|
||||
db.add(api_key)
|
||||
db.flush()
|
||||
token = generate_api_token(api_key.id)
|
||||
api_key.key_prefix = token_prefix(token)
|
||||
api_key.key_hash = hash_api_token(token)
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
return serialize_api_key(api_key, include_token=token)
|
||||
|
||||
|
||||
@router.post("/{key_id}/regenerate")
|
||||
def regenerate_api_key(
|
||||
key_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
api_key = db.scalar(
|
||||
select(ApiKey).where(
|
||||
ApiKey.id == key_id,
|
||||
ApiKey.user_id == user.id,
|
||||
ApiKey.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
|
||||
|
||||
token = generate_api_token(api_key.id)
|
||||
api_key.key_prefix = token_prefix(token)
|
||||
api_key.key_hash = hash_api_token(token)
|
||||
api_key.last_used_at = None
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
return serialize_api_key(api_key, include_token=token)
|
||||
|
||||
|
||||
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def revoke_api_key(
|
||||
key_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> None:
|
||||
api_key = db.scalar(
|
||||
select(ApiKey).where(
|
||||
ApiKey.id == key_id,
|
||||
ApiKey.user_id == user.id,
|
||||
ApiKey.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
|
||||
api_key.revoked_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
222
backend/app/routes_auth.py
Normal file
222
backend/app/routes_auth.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from google.auth.transport import requests as google_requests
|
||||
from google.oauth2 import id_token
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth import COOKIE_NAME, create_token, current_user, normalize_email, password_hash
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
from .models import OidcIdentity, PasswordCredential, UserAccount
|
||||
from .schemas import LoginRequest, RegisterRequest, SetPasswordRequest, UserResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["authentication"])
|
||||
GOOGLE_ISSUER = "https://accounts.google.com"
|
||||
|
||||
|
||||
def public_user(user: UserAccount) -> UserResponse:
|
||||
return UserResponse(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
email_verified=user.email_verified,
|
||||
has_password=user.password_credential is not None,
|
||||
providers=["google" for identity in user.oidc_identities if identity.issuer == GOOGLE_ISSUER],
|
||||
)
|
||||
|
||||
|
||||
def set_session(response: Response, user: UserAccount) -> None:
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
create_token(user.id),
|
||||
httponly=True,
|
||||
secure=settings.auth_cookie_secure,
|
||||
samesite="lax",
|
||||
max_age=settings.access_token_minutes * 60,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def register(payload: RegisterRequest, response: Response, db: Session = Depends(get_db)) -> UserResponse:
|
||||
email = normalize_email(str(payload.email))
|
||||
user = db.scalar(select(UserAccount).where(UserAccount.email == email))
|
||||
if user:
|
||||
detail = (
|
||||
"Sign in with Google, then add a password from Sign-in & Security"
|
||||
if user.oidc_identities and not user.password_credential
|
||||
else "An account already exists"
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
||||
user = UserAccount(email=email, display_name=payload.name.strip(), email_verified=False)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
user.password_credential = PasswordCredential(password_hash=password_hash.hash(payload.password))
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
set_session(response, user)
|
||||
return public_user(user)
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserResponse)
|
||||
def login(payload: LoginRequest, response: Response, db: Session = Depends(get_db)) -> UserResponse:
|
||||
user = db.scalar(select(UserAccount).where(UserAccount.email == normalize_email(str(payload.email))))
|
||||
if not user or not user.password_credential:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
|
||||
if not password_hash.verify(payload.password, user.password_credential.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
|
||||
set_session(response, user)
|
||||
return public_user(user)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def logout(response: Response) -> None:
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def me(user: UserAccount = Depends(current_user)) -> UserResponse:
|
||||
return public_user(user)
|
||||
|
||||
|
||||
@router.post("/password", response_model=UserResponse)
|
||||
def configure_password(
|
||||
payload: SetPasswordRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> UserResponse:
|
||||
if user.password_credential:
|
||||
if not payload.current_password or not password_hash.verify(
|
||||
payload.current_password, user.password_credential.password_hash
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
user.password_credential.password_hash = password_hash.hash(payload.password)
|
||||
else:
|
||||
user.password_credential = PasswordCredential(password_hash=password_hash.hash(payload.password))
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return public_user(user)
|
||||
|
||||
|
||||
@router.get("/google/start")
|
||||
def google_start(next_path: str = Query(default="/console", pattern=r"^/")) -> RedirectResponse:
|
||||
if not settings.google_client_id or not settings.google_client_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Google sign-in is not configured",
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
nonce = secrets.token_urlsafe(24)
|
||||
state = jwt.encode(
|
||||
{
|
||||
"purpose": "google_oauth",
|
||||
"nonce": nonce,
|
||||
"next": next_path,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=10),
|
||||
},
|
||||
settings.auth_secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
query = urlencode(
|
||||
{
|
||||
"client_id": settings.google_client_id,
|
||||
"redirect_uri": settings.google_redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"prompt": "select_account",
|
||||
}
|
||||
)
|
||||
return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{query}")
|
||||
|
||||
|
||||
@router.get("/google/callback")
|
||||
def google_callback(
|
||||
code: str,
|
||||
state: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> RedirectResponse:
|
||||
try:
|
||||
state_data = jwt.decode(state, settings.auth_secret, algorithms=["HS256"])
|
||||
if state_data.get("purpose") != "google_oauth":
|
||||
raise ValueError("invalid state")
|
||||
except (jwt.PyJWTError, ValueError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state") from exc
|
||||
|
||||
token_response = httpx.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": settings.google_client_id,
|
||||
"client_secret": settings.google_client_secret,
|
||||
"redirect_uri": settings.google_redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
if token_response.is_error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Google authentication failed")
|
||||
|
||||
try:
|
||||
claims = id_token.verify_oauth2_token(
|
||||
token_response.json()["id_token"],
|
||||
google_requests.Request(),
|
||||
settings.google_client_id,
|
||||
)
|
||||
if claims.get("nonce") != state_data["nonce"] or not claims.get("email_verified"):
|
||||
raise ValueError("unverified Google identity")
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid Google identity") from exc
|
||||
|
||||
subject = str(claims["sub"])
|
||||
identity = db.scalar(
|
||||
select(OidcIdentity).where(
|
||||
OidcIdentity.issuer == GOOGLE_ISSUER,
|
||||
OidcIdentity.subject == subject,
|
||||
)
|
||||
)
|
||||
if identity:
|
||||
user = identity.user
|
||||
identity.last_login_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
email = normalize_email(str(claims["email"]))
|
||||
user = db.scalar(select(UserAccount).where(UserAccount.email == email))
|
||||
if not user:
|
||||
user = UserAccount(
|
||||
email=email,
|
||||
display_name=str(claims.get("name") or email.split("@")[0]),
|
||||
avatar_url=claims.get("picture"),
|
||||
email_verified=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
else:
|
||||
user.email_verified = True
|
||||
user.avatar_url = user.avatar_url or claims.get("picture")
|
||||
db.add(
|
||||
OidcIdentity(
|
||||
user_id=user.id,
|
||||
issuer=GOOGLE_ISSUER,
|
||||
subject=subject,
|
||||
provider_email=email,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
destination = str(state_data.get("next", "/console"))
|
||||
response = RedirectResponse(f"{settings.frontend_origin}{destination}")
|
||||
set_session(response, user)
|
||||
return response
|
||||
307
backend/app/routes_campaigns.py
Normal file
307
backend/app/routes_campaigns.py
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from .auth import current_user
|
||||
from .campaign_csv_service import (
|
||||
save_campaign_csv,
|
||||
stub_run_campaign_automation,
|
||||
stub_run_csv_upload,
|
||||
template_variable_count,
|
||||
)
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
from .models import Campaign, CampaignCsvUpload, SenderConfig, TemplateConfig, UserAccount
|
||||
|
||||
router = APIRouter(prefix="/api/v1/campaigns", tags=["campaigns"])
|
||||
|
||||
RUN_MODES = {"manual", "on_upload", "scheduled"}
|
||||
|
||||
|
||||
class CreateCampaignRequest(BaseModel):
|
||||
name: str = Field(min_length=3, max_length=80)
|
||||
sender_config_id: str
|
||||
template_id: str
|
||||
number_column: str = Field(min_length=1, max_length=80)
|
||||
variable_columns: list[str] = Field(min_length=0, max_length=10)
|
||||
run_mode: str
|
||||
scheduled_at: datetime | None = None
|
||||
|
||||
|
||||
def serialize_csv_upload(upload: CampaignCsvUpload) -> dict:
|
||||
return {
|
||||
"id": str(upload.id),
|
||||
"original_filename": upload.original_filename,
|
||||
"total_rows": upload.total_rows,
|
||||
"status": upload.status,
|
||||
"run_started_at": upload.run_started_at,
|
||||
"run_completed_at": upload.run_completed_at,
|
||||
"created_at": upload.created_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_campaign(campaign: Campaign) -> dict:
|
||||
return {
|
||||
"id": str(campaign.id),
|
||||
"name": campaign.name,
|
||||
"sender_config_id": str(campaign.sender_config_id),
|
||||
"sender_id": campaign.sender_config.sender_id,
|
||||
"sender_type": campaign.sender_config.sender_type,
|
||||
"template_id": str(campaign.template_id),
|
||||
"template_name": campaign.template.template_name,
|
||||
"template_text": campaign.template.template_text,
|
||||
"number_column": campaign.number_column,
|
||||
"variable_columns": campaign.variable_columns,
|
||||
"run_mode": campaign.run_mode,
|
||||
"scheduled_at": campaign.scheduled_at,
|
||||
"status": campaign.status,
|
||||
"created_at": campaign.created_at,
|
||||
"updated_at": campaign.updated_at,
|
||||
"csv_upload_count": len(campaign.csv_uploads),
|
||||
"csv_uploads": [serialize_csv_upload(upload) for upload in campaign.csv_uploads],
|
||||
}
|
||||
|
||||
|
||||
def get_owned_campaign(db: Session, user: UserAccount, campaign_id: UUID) -> Campaign:
|
||||
campaign = db.scalar(
|
||||
select(Campaign)
|
||||
.options(
|
||||
joinedload(Campaign.sender_config),
|
||||
joinedload(Campaign.template),
|
||||
joinedload(Campaign.csv_uploads),
|
||||
)
|
||||
.where(Campaign.id == campaign_id, Campaign.user_id == user.id)
|
||||
)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
|
||||
return campaign
|
||||
|
||||
|
||||
def validate_campaign_links(
|
||||
db: Session,
|
||||
user: UserAccount,
|
||||
*,
|
||||
sender_config_id: str,
|
||||
template_id: str,
|
||||
variable_columns: list[str],
|
||||
) -> tuple[SenderConfig, TemplateConfig]:
|
||||
sender = db.scalar(
|
||||
select(SenderConfig).where(
|
||||
SenderConfig.id == sender_config_id,
|
||||
SenderConfig.user_id == user.id,
|
||||
SenderConfig.status == "approved",
|
||||
)
|
||||
)
|
||||
if not sender:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Select an approved sender ID",
|
||||
)
|
||||
|
||||
template = db.scalar(
|
||||
select(TemplateConfig).where(
|
||||
TemplateConfig.id == template_id,
|
||||
TemplateConfig.user_id == user.id,
|
||||
TemplateConfig.sender_config_id == sender.id,
|
||||
TemplateConfig.status == "approved",
|
||||
)
|
||||
)
|
||||
if not template:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Select an approved template linked to the sender",
|
||||
)
|
||||
|
||||
expected = template_variable_count(template.template_text)
|
||||
if len(variable_columns) != expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Template requires {expected} variable column mapping(s)",
|
||||
)
|
||||
|
||||
cleaned_columns = [column.strip() for column in variable_columns]
|
||||
if any(not column for column in cleaned_columns):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Variable column names cannot be empty",
|
||||
)
|
||||
|
||||
return sender, template
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_campaigns(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> list[dict]:
|
||||
campaigns = db.scalars(
|
||||
select(Campaign)
|
||||
.options(
|
||||
joinedload(Campaign.sender_config),
|
||||
joinedload(Campaign.template),
|
||||
joinedload(Campaign.csv_uploads),
|
||||
)
|
||||
.where(Campaign.user_id == user.id)
|
||||
.order_by(Campaign.created_at.desc())
|
||||
).unique().all()
|
||||
return [serialize_campaign(campaign) for campaign in campaigns]
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
def create_campaign(
|
||||
payload: CreateCampaignRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
if payload.run_mode not in RUN_MODES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Run mode must be manual, on_upload, or scheduled",
|
||||
)
|
||||
if payload.run_mode == "scheduled" and payload.scheduled_at is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Scheduled campaigns require a scheduled_at timestamp",
|
||||
)
|
||||
|
||||
number_column = payload.number_column.strip()
|
||||
if not number_column:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Number column is required",
|
||||
)
|
||||
|
||||
sender, template = validate_campaign_links(
|
||||
db,
|
||||
user,
|
||||
sender_config_id=payload.sender_config_id,
|
||||
template_id=payload.template_id,
|
||||
variable_columns=payload.variable_columns,
|
||||
)
|
||||
|
||||
campaign = Campaign(
|
||||
user_id=user.id,
|
||||
name=payload.name.strip(),
|
||||
sender_config_id=sender.id,
|
||||
template_id=template.id,
|
||||
number_column=number_column,
|
||||
variable_columns=[column.strip() for column in payload.variable_columns],
|
||||
run_mode=payload.run_mode,
|
||||
scheduled_at=payload.scheduled_at,
|
||||
status="active",
|
||||
)
|
||||
db.add(campaign)
|
||||
db.commit()
|
||||
db.refresh(campaign)
|
||||
campaign.sender_config = sender
|
||||
campaign.template = template
|
||||
campaign.csv_uploads = []
|
||||
return serialize_campaign(campaign)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}")
|
||||
def get_campaign(
|
||||
campaign_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
campaign = get_owned_campaign(db, user, campaign_id)
|
||||
return serialize_campaign(campaign)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/csv")
|
||||
def list_campaign_csv_uploads(
|
||||
campaign_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> list[dict]:
|
||||
campaign = get_owned_campaign(db, user, campaign_id)
|
||||
uploads = sorted(campaign.csv_uploads, key=lambda item: item.created_at, reverse=True)
|
||||
return [serialize_csv_upload(upload) for upload in uploads]
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/csv", status_code=status.HTTP_201_CREATED)
|
||||
async def upload_campaign_csv(
|
||||
campaign_id: UUID,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
campaign = get_owned_campaign(db, user, campaign_id)
|
||||
if not file.filename or not file.filename.lower().endswith(".csv"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Upload a .csv file",
|
||||
)
|
||||
|
||||
file_bytes = await file.read()
|
||||
if len(file_bytes) > settings.max_upload_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="CSV file is too large",
|
||||
)
|
||||
if not file_bytes.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="CSV file is empty",
|
||||
)
|
||||
|
||||
try:
|
||||
upload = save_campaign_csv(
|
||||
db,
|
||||
campaign,
|
||||
original_filename=file.filename,
|
||||
file_bytes=file_bytes,
|
||||
auto_run=campaign.run_mode == "on_upload",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
return serialize_csv_upload(upload)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/run")
|
||||
def run_campaign_automation(
|
||||
campaign_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
campaign = get_owned_campaign(db, user, campaign_id)
|
||||
uploads = stub_run_campaign_automation(db, campaign)
|
||||
return {
|
||||
"campaign_id": str(campaign.id),
|
||||
"processed_uploads": len(uploads),
|
||||
"uploads": [serialize_csv_upload(upload) for upload in uploads],
|
||||
"note": "Send functionality is not enabled yet; runs are recorded as completed stubs.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/csv/{upload_id}/run")
|
||||
def run_campaign_csv(
|
||||
campaign_id: UUID,
|
||||
upload_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
campaign = get_owned_campaign(db, user, campaign_id)
|
||||
upload = next((item for item in campaign.csv_uploads if item.id == upload_id), None)
|
||||
if not upload:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="CSV upload not found")
|
||||
if upload.status not in {"validated", "completed"}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Cannot run CSV in status '{upload.status}'",
|
||||
)
|
||||
|
||||
upload = stub_run_csv_upload(db, upload)
|
||||
return {
|
||||
**serialize_csv_upload(upload),
|
||||
"note": "Send functionality is not enabled yet; run recorded as a completed stub.",
|
||||
}
|
||||
21
backend/app/routes_compliance.py
Normal file
21
backend/app/routes_compliance.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth import current_user
|
||||
from .database import get_db
|
||||
from .dlt_service import ensure_dlt_application, serialize_dlt
|
||||
from .kyc_service import ensure_kyc_application, serialize_kyc
|
||||
from .models import UserAccount
|
||||
|
||||
router = APIRouter(prefix="/api/v1/compliance", tags=["compliance"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def compliance_status(db: Session = Depends(get_db), user: UserAccount = Depends(current_user)) -> dict:
|
||||
kyc = serialize_kyc(ensure_kyc_application(db, user))
|
||||
dlt = serialize_dlt(ensure_dlt_application(db, user))
|
||||
return {
|
||||
"kyc_completed": kyc["completed"],
|
||||
"dlt_completed": dlt["completed"],
|
||||
"console_unlocked": dlt["completed"],
|
||||
}
|
||||
130
backend/app/routes_dlt.py
Normal file
130
backend/app/routes_dlt.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth import current_user
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
from .dlt_constants import DLT_DOCUMENT_TYPES, DLT_UPLOAD_DOCUMENT_TYPES, PE_TM_CHAIN_BINDING_ID
|
||||
from .dlt_service import ensure_dlt_application, serialize_dlt
|
||||
from .kyc_constants import ALLOWED_UPLOAD_EXTENSIONS
|
||||
from .kyc_service import upload_root
|
||||
from .models import UserAccount
|
||||
|
||||
router = APIRouter(prefix="/api/v1/dlt", tags=["dlt"])
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_my_dlt(db: Session = Depends(get_db), user: UserAccount = Depends(current_user)) -> dict:
|
||||
application = ensure_dlt_application(db, user)
|
||||
return serialize_dlt(application)
|
||||
|
||||
|
||||
@router.get("/loa")
|
||||
def download_loa(_: UserAccount = Depends(current_user)) -> FileResponse:
|
||||
template = Path(settings.dlt_loa_template_path)
|
||||
if not template.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="LOA template not found")
|
||||
return FileResponse(
|
||||
template,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
filename="Updated LOA.docx",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chain-binding/confirm")
|
||||
def confirm_chain_binding(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
application = ensure_dlt_application(db, user)
|
||||
if application.status == "approved":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Approved DLT cannot be edited")
|
||||
|
||||
document = next(doc for doc in application.documents if doc.document_type == "pe_tm_chain_binding")
|
||||
if document.status == "approved":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Chain binding already approved")
|
||||
|
||||
document.original_filename = f"Chain binding {PE_TM_CHAIN_BINDING_ID}"
|
||||
document.stored_path = None
|
||||
document.status = "pending"
|
||||
document.review_note = None
|
||||
document.uploaded_at = datetime.now(timezone.utc)
|
||||
document.reviewed_at = None
|
||||
if application.status in {"not_started", "in_progress", "rejected"}:
|
||||
application.status = "in_progress"
|
||||
db.commit()
|
||||
db.refresh(application)
|
||||
return serialize_dlt(application)
|
||||
|
||||
|
||||
@router.post("/documents/{document_type}")
|
||||
async def upload_document(
|
||||
document_type: str,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
if document_type not in DLT_DOCUMENT_TYPES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid document type")
|
||||
if document_type not in DLT_UPLOAD_DOCUMENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="This item uses chain binding confirmation instead of file upload",
|
||||
)
|
||||
application = ensure_dlt_application(db, user)
|
||||
if application.status == "approved":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Approved DLT cannot be edited")
|
||||
|
||||
filename = file.filename or ""
|
||||
extension = Path(filename).suffix.lower()
|
||||
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Allowed file types: PDF, PNG, JPG, DOC, DOCX",
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="File is empty")
|
||||
if len(content) > settings.max_upload_bytes:
|
||||
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="File exceeds 10 MB limit")
|
||||
|
||||
document = next(doc for doc in application.documents if doc.document_type == document_type)
|
||||
root = upload_root(settings.upload_dir)
|
||||
stored_name = f"{application.id}_{document_type}{extension}"
|
||||
stored_path = root / stored_name
|
||||
stored_path.write_bytes(content)
|
||||
|
||||
document.original_filename = filename
|
||||
document.stored_path = str(stored_path)
|
||||
document.status = "pending"
|
||||
document.review_note = None
|
||||
document.uploaded_at = datetime.now(timezone.utc)
|
||||
document.reviewed_at = None
|
||||
if application.status in {"not_started", "in_progress", "rejected"}:
|
||||
application.status = "in_progress"
|
||||
db.commit()
|
||||
db.refresh(application)
|
||||
return serialize_dlt(application)
|
||||
|
||||
|
||||
@router.post("/submit")
|
||||
def submit_dlt(db: Session = Depends(get_db), user: UserAccount = Depends(current_user)) -> dict:
|
||||
application = ensure_dlt_application(db, user)
|
||||
if application.status == "approved":
|
||||
return serialize_dlt(application)
|
||||
missing = [doc.document_type for doc in application.documents if doc.status == "missing"]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Complete all required DLT items before submitting",
|
||||
)
|
||||
application.status = "submitted"
|
||||
application.submitted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(application)
|
||||
return serialize_dlt(application)
|
||||
363
backend/app/routes_integrations.py
Normal file
363
backend/app/routes_integrations.py
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from .database import get_db
|
||||
from .endpoint_security import endpoint_signing_secret, validate_endpoint_url
|
||||
from .models import (
|
||||
PhoneNumber,
|
||||
SmsDeliveryWebhook,
|
||||
UserAccount,
|
||||
VoiceSocketEndpoint,
|
||||
)
|
||||
from .sms_webhooks import deliver_test_report
|
||||
from .voice_auth import current_voice_user
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||
|
||||
|
||||
class EndpointCreate(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
phone_number_id: UUID
|
||||
url: str = Field(min_length=8, max_length=500)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class EndpointUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=2, max_length=120)
|
||||
phone_number_id: UUID | None = None
|
||||
url: str | None = Field(default=None, min_length=8, max_length=500)
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
def _owned_number(db: Session, user: UserAccount, number_id: UUID) -> PhoneNumber:
|
||||
number = db.scalar(
|
||||
select(PhoneNumber).where(
|
||||
PhoneNumber.id == number_id,
|
||||
PhoneNumber.user_id == user.id,
|
||||
PhoneNumber.status == "assigned",
|
||||
)
|
||||
)
|
||||
if not number:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found")
|
||||
return number
|
||||
|
||||
|
||||
def _clean_name(value: str) -> str:
|
||||
name = " ".join(value.split())
|
||||
if len(name) < 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Name must contain at least 2 characters",
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _commit(db: Session, duplicate_detail: str) -> None:
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=duplicate_detail) from exc
|
||||
|
||||
|
||||
def _serialize_socket(endpoint: VoiceSocketEndpoint, *, include_secret: bool = False) -> dict:
|
||||
payload = {
|
||||
"id": str(endpoint.id),
|
||||
"name": endpoint.name,
|
||||
"phone_number_id": str(endpoint.phone_number_id),
|
||||
"phone_number": endpoint.phone_number.phone_number,
|
||||
"url": endpoint.url,
|
||||
"enabled": endpoint.enabled,
|
||||
"created_at": endpoint.created_at,
|
||||
"updated_at": endpoint.updated_at,
|
||||
}
|
||||
if include_secret:
|
||||
payload["signing_secret"] = endpoint_signing_secret("socket", endpoint.id)
|
||||
return payload
|
||||
|
||||
|
||||
def _serialize_webhook(endpoint: SmsDeliveryWebhook, *, include_secret: bool = False) -> dict:
|
||||
payload = {
|
||||
"id": str(endpoint.id),
|
||||
"name": endpoint.name,
|
||||
"phone_number_id": str(endpoint.phone_number_id),
|
||||
"phone_number": endpoint.phone_number.phone_number,
|
||||
"url": endpoint.url,
|
||||
"description": endpoint.description,
|
||||
"event": "message.delivery.updated",
|
||||
"enabled": endpoint.enabled,
|
||||
"created_at": endpoint.created_at,
|
||||
"updated_at": endpoint.updated_at,
|
||||
}
|
||||
if include_secret:
|
||||
payload["signing_secret"] = endpoint_signing_secret("webhook", endpoint.id)
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/docs")
|
||||
def integration_docs() -> dict:
|
||||
return {
|
||||
"title": "Webhooks & Sockets",
|
||||
"summary": "Connect each Urmi number to your application.",
|
||||
"relay": {
|
||||
"principle": "Urmi forwards raw WebSocket and HTTP payloads without decoding or modifying them. We authenticate requests and route traffic — all processing happens on your infrastructure.",
|
||||
"latency": "Voice media frames are relayed as they arrive to keep latency as low as possible.",
|
||||
},
|
||||
"socket": {
|
||||
"title": "Voice sockets",
|
||||
"description": "Link a secure WebSocket endpoint to a number. When a call connects, Urmi opens your endpoint and relays frames in both directions with no audio processing on our side.",
|
||||
"events": ["connected", "start", "media", "end"],
|
||||
"authentication": {
|
||||
"headers": ["X-Urmi-Endpoint-Id", "X-Urmi-Timestamp", "X-Urmi-Signature"],
|
||||
"signature": "HMAC-SHA256 of '<endpoint_id>.<timestamp>' using the signing secret.",
|
||||
"replay_protection": "Reject timestamps older than five minutes.",
|
||||
},
|
||||
},
|
||||
"webhook": {
|
||||
"title": "SMS delivery webhooks",
|
||||
"description": "Link a named HTTPS endpoint to a number to receive SMS delivery status updates.",
|
||||
"event": "message.delivery.updated",
|
||||
"statuses": ["queued", "sent", "delivered", "undelivered", "failed"],
|
||||
"authentication": {
|
||||
"headers": ["X-Urmi-Event", "X-Urmi-Endpoint-Id", "X-Urmi-Signature"],
|
||||
"signature": "HMAC-SHA256 of the raw request body using the signing secret.",
|
||||
},
|
||||
"example": {
|
||||
"id": "evt_01J8EXAMPLE",
|
||||
"event": "message.delivery.updated",
|
||||
"created_at": "2026-09-04T09:30:00Z",
|
||||
"data": {
|
||||
"message_id": "msg_01J8EXAMPLE",
|
||||
"to": "+919999999999",
|
||||
"from": "+917946350706",
|
||||
"status": "delivered",
|
||||
"updated_at": "2026-09-04T09:29:58Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
"api_authentication": {
|
||||
"header": "Authorization",
|
||||
"format": "Bearer urmi_live_<your_api_key>",
|
||||
"note": "Use API keys only from your server. Never expose them in browser code.",
|
||||
},
|
||||
"endpoints": [
|
||||
{"method": "GET", "path": "/api/v1/integrations/sockets", "description": "List voice sockets"},
|
||||
{"method": "POST", "path": "/api/v1/integrations/sockets", "description": "Create and link a voice socket"},
|
||||
{"method": "PATCH", "path": "/api/v1/integrations/sockets/{id}", "description": "Update or pause a voice socket"},
|
||||
{"method": "DELETE", "path": "/api/v1/integrations/sockets/{id}", "description": "Delete a voice socket"},
|
||||
{"method": "GET", "path": "/api/v1/integrations/webhooks", "description": "List SMS delivery webhooks"},
|
||||
{"method": "POST", "path": "/api/v1/integrations/webhooks", "description": "Create and link a delivery webhook"},
|
||||
{"method": "PATCH", "path": "/api/v1/integrations/webhooks/{id}", "description": "Update or pause a delivery webhook"},
|
||||
{"method": "DELETE", "path": "/api/v1/integrations/webhooks/{id}", "description": "Delete a delivery webhook"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sockets")
|
||||
def list_sockets(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> list[dict]:
|
||||
endpoints = db.scalars(
|
||||
select(VoiceSocketEndpoint)
|
||||
.options(joinedload(VoiceSocketEndpoint.phone_number))
|
||||
.where(VoiceSocketEndpoint.user_id == user.id)
|
||||
.order_by(VoiceSocketEndpoint.created_at.desc())
|
||||
).all()
|
||||
return [_serialize_socket(endpoint) for endpoint in endpoints]
|
||||
|
||||
|
||||
@router.post("/sockets", status_code=status.HTTP_201_CREATED)
|
||||
def create_socket(
|
||||
payload: EndpointCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
number = _owned_number(db, user, payload.phone_number_id)
|
||||
try:
|
||||
url = validate_endpoint_url(payload.url, websocket=True)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
endpoint = VoiceSocketEndpoint(
|
||||
user_id=user.id,
|
||||
phone_number_id=number.id,
|
||||
name=_clean_name(payload.name),
|
||||
url=url,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
db.add(endpoint)
|
||||
_commit(db, "This name or phone number already has a voice socket")
|
||||
db.refresh(endpoint)
|
||||
endpoint.phone_number = number
|
||||
return _serialize_socket(endpoint, include_secret=True)
|
||||
|
||||
|
||||
@router.patch("/sockets/{endpoint_id}")
|
||||
def update_socket(
|
||||
endpoint_id: UUID,
|
||||
payload: EndpointUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
endpoint = db.scalar(
|
||||
select(VoiceSocketEndpoint)
|
||||
.options(joinedload(VoiceSocketEndpoint.phone_number))
|
||||
.where(VoiceSocketEndpoint.id == endpoint_id, VoiceSocketEndpoint.user_id == user.id)
|
||||
)
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice socket not found")
|
||||
if payload.name is not None:
|
||||
endpoint.name = _clean_name(payload.name)
|
||||
if payload.phone_number_id is not None:
|
||||
endpoint.phone_number = _owned_number(db, user, payload.phone_number_id)
|
||||
endpoint.phone_number_id = payload.phone_number_id
|
||||
if payload.url is not None:
|
||||
try:
|
||||
endpoint.url = validate_endpoint_url(payload.url, websocket=True)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
if payload.enabled is not None:
|
||||
endpoint.enabled = payload.enabled
|
||||
_commit(db, "This name or phone number already has a voice socket")
|
||||
db.refresh(endpoint)
|
||||
return _serialize_socket(endpoint)
|
||||
|
||||
|
||||
@router.delete("/sockets/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_socket(
|
||||
endpoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> None:
|
||||
endpoint = db.scalar(
|
||||
select(VoiceSocketEndpoint).where(
|
||||
VoiceSocketEndpoint.id == endpoint_id,
|
||||
VoiceSocketEndpoint.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice socket not found")
|
||||
db.delete(endpoint)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/webhooks")
|
||||
def list_webhooks(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> list[dict]:
|
||||
endpoints = db.scalars(
|
||||
select(SmsDeliveryWebhook)
|
||||
.options(joinedload(SmsDeliveryWebhook.phone_number))
|
||||
.where(SmsDeliveryWebhook.user_id == user.id)
|
||||
.order_by(SmsDeliveryWebhook.created_at.desc())
|
||||
).all()
|
||||
return [_serialize_webhook(endpoint) for endpoint in endpoints]
|
||||
|
||||
|
||||
@router.post("/webhooks", status_code=status.HTTP_201_CREATED)
|
||||
def create_webhook(
|
||||
payload: EndpointCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
number = _owned_number(db, user, payload.phone_number_id)
|
||||
try:
|
||||
url = validate_endpoint_url(payload.url, websocket=False)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
endpoint = SmsDeliveryWebhook(
|
||||
user_id=user.id,
|
||||
phone_number_id=number.id,
|
||||
name=_clean_name(payload.name),
|
||||
url=url,
|
||||
description=payload.description.strip() if payload.description else None,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
db.add(endpoint)
|
||||
_commit(db, "A webhook with this name already exists")
|
||||
db.refresh(endpoint)
|
||||
endpoint.phone_number = number
|
||||
return _serialize_webhook(endpoint, include_secret=True)
|
||||
|
||||
|
||||
@router.patch("/webhooks/{endpoint_id}")
|
||||
def update_webhook(
|
||||
endpoint_id: UUID,
|
||||
payload: EndpointUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
endpoint = db.scalar(
|
||||
select(SmsDeliveryWebhook)
|
||||
.options(joinedload(SmsDeliveryWebhook.phone_number))
|
||||
.where(SmsDeliveryWebhook.id == endpoint_id, SmsDeliveryWebhook.user_id == user.id)
|
||||
)
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
||||
if payload.name is not None:
|
||||
endpoint.name = _clean_name(payload.name)
|
||||
if payload.phone_number_id is not None:
|
||||
endpoint.phone_number = _owned_number(db, user, payload.phone_number_id)
|
||||
endpoint.phone_number_id = payload.phone_number_id
|
||||
if payload.url is not None:
|
||||
try:
|
||||
endpoint.url = validate_endpoint_url(payload.url, websocket=False)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
if "description" in payload.model_fields_set:
|
||||
endpoint.description = payload.description.strip() if payload.description else None
|
||||
if payload.enabled is not None:
|
||||
endpoint.enabled = payload.enabled
|
||||
_commit(db, "A webhook with this name already exists")
|
||||
db.refresh(endpoint)
|
||||
return _serialize_webhook(endpoint)
|
||||
|
||||
|
||||
@router.post("/webhooks/{endpoint_id}/test")
|
||||
async def test_webhook(
|
||||
endpoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
endpoint = db.scalar(
|
||||
select(SmsDeliveryWebhook).where(
|
||||
SmsDeliveryWebhook.id == endpoint_id,
|
||||
SmsDeliveryWebhook.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
||||
try:
|
||||
response_code = await deliver_test_report(endpoint)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return {"delivered": True, "response_code": response_code}
|
||||
|
||||
|
||||
@router.delete("/webhooks/{endpoint_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_webhook(
|
||||
endpoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> None:
|
||||
endpoint = db.scalar(
|
||||
select(SmsDeliveryWebhook).where(
|
||||
SmsDeliveryWebhook.id == endpoint_id,
|
||||
SmsDeliveryWebhook.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
||||
db.delete(endpoint)
|
||||
db.commit()
|
||||
133
backend/app/routes_kyc.py
Normal file
133
backend/app/routes_kyc.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth import current_user
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
from .kyc_constants import ALLOWED_UPLOAD_EXTENSIONS, KYC_DOCUMENT_TYPES
|
||||
from .kyc_service import ensure_kyc_application, refresh_application_status, serialize_kyc, upload_root
|
||||
from .kyc_validators import validate_gst, validate_pan
|
||||
from .models import KycApplication, KycDocument, UserAccount
|
||||
|
||||
router = APIRouter(prefix="/api/v1/kyc", tags=["kyc"])
|
||||
|
||||
|
||||
class KycDetailsRequest(BaseModel):
|
||||
pan_number: str = Field(min_length=10, max_length=10)
|
||||
gst_number: str = Field(min_length=15, max_length=15)
|
||||
|
||||
|
||||
@router.get("/template")
|
||||
def download_template(_: UserAccount = Depends(current_user)) -> FileResponse:
|
||||
template = Path(settings.kyc_template_path)
|
||||
if not template.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="KYC template not found")
|
||||
return FileResponse(
|
||||
template,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
filename="KYC Form - Pvt Ltd Client.docx",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_my_kyc(db: Session = Depends(get_db), user: UserAccount = Depends(current_user)) -> dict:
|
||||
application = ensure_kyc_application(db, user)
|
||||
return serialize_kyc(application)
|
||||
|
||||
|
||||
@router.put("/me")
|
||||
def update_my_kyc(
|
||||
payload: KycDetailsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
application = ensure_kyc_application(db, user)
|
||||
if application.status == "approved":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Approved KYC cannot be edited")
|
||||
try:
|
||||
application.pan_number = validate_pan(payload.pan_number)
|
||||
application.gst_number = validate_gst(payload.gst_number)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
if application.status == "not_started":
|
||||
application.status = "in_progress"
|
||||
db.commit()
|
||||
db.refresh(application)
|
||||
return serialize_kyc(application)
|
||||
|
||||
|
||||
@router.post("/documents/{document_type}")
|
||||
async def upload_document(
|
||||
document_type: str,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
if document_type not in KYC_DOCUMENT_TYPES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid document type")
|
||||
application = ensure_kyc_application(db, user)
|
||||
if application.status == "approved":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Approved KYC cannot be edited")
|
||||
if not application.pan_number or not application.gst_number:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Enter PAN and GST before uploading documents",
|
||||
)
|
||||
|
||||
filename = file.filename or ""
|
||||
extension = Path(filename).suffix.lower()
|
||||
if extension not in ALLOWED_UPLOAD_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Allowed file types: PDF, PNG, JPG, DOC, DOCX",
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="File is empty")
|
||||
if len(content) > settings.max_upload_bytes:
|
||||
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="File exceeds 10 MB limit")
|
||||
|
||||
document = next(doc for doc in application.documents if doc.document_type == document_type)
|
||||
root = upload_root(settings.upload_dir)
|
||||
stored_name = f"{application.id}_{document_type}{extension}"
|
||||
stored_path = root / stored_name
|
||||
stored_path.write_bytes(content)
|
||||
|
||||
document.original_filename = filename
|
||||
document.stored_path = str(stored_path)
|
||||
document.status = "pending"
|
||||
document.review_note = None
|
||||
document.uploaded_at = datetime.now(timezone.utc)
|
||||
document.reviewed_at = None
|
||||
if application.status in {"not_started", "in_progress", "rejected"}:
|
||||
application.status = "in_progress"
|
||||
db.commit()
|
||||
db.refresh(application)
|
||||
return serialize_kyc(application)
|
||||
|
||||
|
||||
@router.post("/submit")
|
||||
def submit_kyc(db: Session = Depends(get_db), user: UserAccount = Depends(current_user)) -> dict:
|
||||
application = ensure_kyc_application(db, user)
|
||||
if application.status == "approved":
|
||||
return serialize_kyc(application)
|
||||
if not application.pan_number or not application.gst_number:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="PAN and GST are required")
|
||||
missing = [doc.document_type for doc in application.documents if doc.status == "missing"]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Upload all required documents before submitting",
|
||||
)
|
||||
application.status = "submitted"
|
||||
application.submitted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(application)
|
||||
return serialize_kyc(application)
|
||||
192
backend/app/routes_logs.py
Normal file
192
backend/app/routes_logs.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from .auth import current_user
|
||||
from .database import get_db
|
||||
from .models import OtpMessageLog, SmsMessageLog, UserAccount, VoiceCall, VoiceSocketEndpoint
|
||||
|
||||
router = APIRouter(prefix="/api/v1/logs", tags=["logs"])
|
||||
|
||||
LOG_TYPES = frozenset({"outbound", "inbound", "sms", "otp"})
|
||||
DEFAULT_RANGE_DAYS = 7
|
||||
MAX_RANGE_DAYS = 31
|
||||
PAGE_SIZE = 10
|
||||
|
||||
|
||||
def _parse_time(value: str | None, *, fallback: datetime) -> datetime:
|
||||
if not value:
|
||||
return fallback
|
||||
normalized = value.strip()
|
||||
if normalized.endswith("Z"):
|
||||
normalized = normalized[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid datetime format. Use ISO 8601.",
|
||||
) from exc
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _resolve_time_window(from_value: str | None, to_value: str | None) -> tuple[datetime, datetime]:
|
||||
now = datetime.now(timezone.utc)
|
||||
end = _parse_time(to_value, fallback=now)
|
||||
start = _parse_time(from_value, fallback=end - timedelta(days=DEFAULT_RANGE_DAYS))
|
||||
|
||||
if start > end:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="'from' must be earlier than 'to'.",
|
||||
)
|
||||
|
||||
if (end - start).days > MAX_RANGE_DAYS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Time range cannot exceed {MAX_RANGE_DAYS} days.",
|
||||
)
|
||||
return start, end
|
||||
|
||||
|
||||
def _serialize_voice_call(call: VoiceCall, *, socket_name: str | None) -> dict:
|
||||
if call.direction == "inbound":
|
||||
from_number = call.to_number
|
||||
to_number = call.from_did
|
||||
else:
|
||||
from_number = call.from_did
|
||||
to_number = call.to_number
|
||||
|
||||
payload = {
|
||||
"id": str(call.id),
|
||||
"from": from_number,
|
||||
"to": to_number,
|
||||
"status": call.status,
|
||||
"time": call.created_at,
|
||||
}
|
||||
if call.direction == "outbound":
|
||||
payload["socket_name"] = socket_name or "—"
|
||||
return payload
|
||||
|
||||
|
||||
def _serialize_sms_log(entry: SmsMessageLog) -> dict:
|
||||
return {
|
||||
"id": str(entry.id),
|
||||
"from": entry.sender_id,
|
||||
"to": entry.to_number,
|
||||
"template": entry.template_name,
|
||||
"status": entry.status,
|
||||
"time": entry.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_otp_log(entry: OtpMessageLog) -> dict:
|
||||
return {
|
||||
"id": str(entry.id),
|
||||
"to": entry.to_number,
|
||||
"otp_type": entry.template_type,
|
||||
"project": entry.project_name,
|
||||
"status": entry.status,
|
||||
"time": entry.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_logs(
|
||||
log_type: str = Query(..., alias="type"),
|
||||
from_value: str | None = Query(default=None, alias="from"),
|
||||
to_value: str | None = Query(default=None, alias="to"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
normalized_type = log_type.strip().lower()
|
||||
if normalized_type not in LOG_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"type must be one of: {', '.join(sorted(LOG_TYPES))}",
|
||||
)
|
||||
|
||||
start, end = _resolve_time_window(from_value, to_value)
|
||||
offset = (page - 1) * PAGE_SIZE
|
||||
|
||||
if normalized_type in {"outbound", "inbound"}:
|
||||
direction = normalized_type
|
||||
filters = [
|
||||
VoiceCall.user_id == user.id,
|
||||
VoiceCall.direction == direction,
|
||||
VoiceCall.created_at >= start,
|
||||
VoiceCall.created_at <= end,
|
||||
]
|
||||
total = db.scalar(select(func.count()).select_from(VoiceCall).where(*filters)) or 0
|
||||
calls = db.scalars(
|
||||
select(VoiceCall)
|
||||
.options(joinedload(VoiceCall.phone_number))
|
||||
.where(*filters)
|
||||
.order_by(VoiceCall.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(PAGE_SIZE)
|
||||
).unique().all()
|
||||
|
||||
socket_by_phone: dict = {}
|
||||
if direction == "outbound" and calls:
|
||||
phone_ids = [call.phone_number_id for call in calls]
|
||||
sockets = db.scalars(
|
||||
select(VoiceSocketEndpoint).where(
|
||||
VoiceSocketEndpoint.user_id == user.id,
|
||||
VoiceSocketEndpoint.phone_number_id.in_(phone_ids),
|
||||
)
|
||||
).all()
|
||||
socket_by_phone = {socket.phone_number_id: socket.name for socket in sockets}
|
||||
|
||||
items = [
|
||||
_serialize_voice_call(call, socket_name=socket_by_phone.get(call.phone_number_id))
|
||||
for call in calls
|
||||
]
|
||||
elif normalized_type == "sms":
|
||||
filters = [
|
||||
SmsMessageLog.user_id == user.id,
|
||||
SmsMessageLog.created_at >= start,
|
||||
SmsMessageLog.created_at <= end,
|
||||
]
|
||||
total = db.scalar(select(func.count()).select_from(SmsMessageLog).where(*filters)) or 0
|
||||
entries = db.scalars(
|
||||
select(SmsMessageLog)
|
||||
.where(*filters)
|
||||
.order_by(SmsMessageLog.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(PAGE_SIZE)
|
||||
).all()
|
||||
items = [_serialize_sms_log(entry) for entry in entries]
|
||||
else:
|
||||
filters = [
|
||||
OtpMessageLog.user_id == user.id,
|
||||
OtpMessageLog.created_at >= start,
|
||||
OtpMessageLog.created_at <= end,
|
||||
]
|
||||
total = db.scalar(select(func.count()).select_from(OtpMessageLog).where(*filters)) or 0
|
||||
entries = db.scalars(
|
||||
select(OtpMessageLog)
|
||||
.where(*filters)
|
||||
.order_by(OtpMessageLog.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(PAGE_SIZE)
|
||||
).all()
|
||||
items = [_serialize_otp_log(entry) for entry in entries]
|
||||
|
||||
total_pages = ceil(total / PAGE_SIZE) if total else 0
|
||||
return {
|
||||
"type": normalized_type,
|
||||
"page": page,
|
||||
"page_size": PAGE_SIZE,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
"from": start,
|
||||
"to": end,
|
||||
"items": items,
|
||||
}
|
||||
118
backend/app/routes_messages.py
Normal file
118
backend/app/routes_messages.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .api_key_auth import current_api_user
|
||||
from .database import get_db
|
||||
from .message_send_service import SendMessageError, send_template_messages
|
||||
from .models import UserAccount
|
||||
|
||||
router = APIRouter(prefix="/api/v1/messages", tags=["messages"])
|
||||
|
||||
SEND_SMS_EXPLORER = {
|
||||
"id": "send-sms",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/messages/send",
|
||||
"title": "Send template SMS",
|
||||
"summary": "Send a DLT-approved SMS using an API key, approved sender ID, and template.",
|
||||
"authentication": {
|
||||
"type": "api_key",
|
||||
"header": "Authorization",
|
||||
"format": "Bearer urmi_live_<key>",
|
||||
"alternate_header": "X-Api-Key",
|
||||
},
|
||||
"request_body": {
|
||||
"sender_id": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"description": "Approved DLT sender header, e.g. URMIAI",
|
||||
"example": "URMIAI",
|
||||
},
|
||||
"template_id": {
|
||||
"type": "uuid",
|
||||
"required": True,
|
||||
"description": "Approved template UUID from Message settings",
|
||||
},
|
||||
"to": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"description": "Recipient mobile number with country code, e.g. 919999999999",
|
||||
"example": "919999999999",
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"description": "Final SMS text with all {#var#} / {#num#} placeholders replaced",
|
||||
"example": "Your URMI app verification code is 482916. It is valid for 10 minutes. Do not share it to anyone - URMI AI",
|
||||
},
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "SMS accepted by the gateway",
|
||||
"example": {
|
||||
"success": True,
|
||||
"job_id": "123456789",
|
||||
"message_id": "987654321",
|
||||
"to": "919999999999",
|
||||
"sender_id": "URMIAI",
|
||||
"template_id": "00000000-0000-0000-0000-000000000001",
|
||||
"template_name": "URMI_OTP_VERIFICATION",
|
||||
"dlt_template_id": "1777178797989033323",
|
||||
},
|
||||
},
|
||||
"401": {"description": "Missing or invalid API key"},
|
||||
"404": {"description": "Sender or template not found for this account"},
|
||||
"422": {"description": "Template mismatch or gateway rejection"},
|
||||
"502": {"description": "SMS gateway request failed"},
|
||||
"503": {"description": "SMS gateway credentials are not configured"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
sender_id: str = Field(min_length=3, max_length=11)
|
||||
template_id: UUID
|
||||
to: str = Field(min_length=10, max_length=15)
|
||||
message: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
@router.get("/docs/send")
|
||||
def send_message_docs() -> dict:
|
||||
return SEND_SMS_EXPLORER
|
||||
|
||||
|
||||
def _raise_send_error(error: SendMessageError) -> None:
|
||||
raise HTTPException(status_code=error.status_code, detail=error.detail)
|
||||
|
||||
|
||||
@router.post("/send")
|
||||
def send_message(
|
||||
payload: SendMessageRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_api_user),
|
||||
) -> dict:
|
||||
try:
|
||||
result = send_template_messages(
|
||||
db,
|
||||
user,
|
||||
sender_id=payload.sender_id,
|
||||
template_id=payload.template_id,
|
||||
message=payload.message,
|
||||
recipients=[payload.to],
|
||||
)
|
||||
except SendMessageError as exc:
|
||||
_raise_send_error(exc)
|
||||
|
||||
first = result["results"][0]
|
||||
return {
|
||||
"success": result["success"],
|
||||
"job_id": first["job_id"],
|
||||
"message_id": first["message_id"],
|
||||
"to": first["to"],
|
||||
"sender_id": result["sender_id"],
|
||||
"template_id": result["template_id"],
|
||||
"template_name": result["template_name"],
|
||||
"dlt_template_id": result["dlt_template_id"],
|
||||
}
|
||||
211
backend/app/routes_messaging.py
Normal file
211
backend/app/routes_messaging.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from .auth import current_user
|
||||
from .database import get_db
|
||||
from .message_send_service import SendMessageError, send_template_messages
|
||||
from .models import SenderConfig, TemplateConfig, UserAccount
|
||||
from .sender_validators import (
|
||||
validate_dlt_entity_id,
|
||||
validate_purpose,
|
||||
validate_sender_id,
|
||||
validate_sender_type,
|
||||
)
|
||||
from .template_validators import (
|
||||
validate_dlt_template_id,
|
||||
validate_template_name,
|
||||
validate_template_text,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/messaging", tags=["messaging"])
|
||||
|
||||
|
||||
class CreateSenderRequest(BaseModel):
|
||||
sender_id: str = Field(min_length=3, max_length=11)
|
||||
sender_type: str
|
||||
purpose: str = Field(min_length=10, max_length=500)
|
||||
dlt_entity_id: str = Field(min_length=12, max_length=25)
|
||||
|
||||
|
||||
class CreateTemplateRequest(BaseModel):
|
||||
sender_config_id: str
|
||||
template_name: str = Field(min_length=3, max_length=40)
|
||||
template_text: str = Field(min_length=10, max_length=1000)
|
||||
dlt_template_id: str = Field(min_length=12, max_length=25)
|
||||
|
||||
|
||||
def serialize_sender(sender: SenderConfig) -> dict:
|
||||
return {
|
||||
"id": str(sender.id),
|
||||
"sender_id": sender.sender_id,
|
||||
"sender_type": sender.sender_type,
|
||||
"purpose": sender.purpose,
|
||||
"dlt_entity_id": sender.dlt_entity_id,
|
||||
"status": sender.status,
|
||||
"review_note": sender.review_note,
|
||||
"created_at": sender.created_at,
|
||||
"reviewed_at": sender.reviewed_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_template(template: TemplateConfig) -> dict:
|
||||
return {
|
||||
"id": str(template.id),
|
||||
"sender_config_id": str(template.sender_config_id),
|
||||
"sender_id": template.sender_config.sender_id,
|
||||
"template_name": template.template_name,
|
||||
"template_text": template.template_text,
|
||||
"dlt_template_id": template.dlt_template_id,
|
||||
"status": template.status,
|
||||
"review_note": template.review_note,
|
||||
"created_at": template.created_at,
|
||||
"reviewed_at": template.reviewed_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/senders")
|
||||
def list_senders(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> list[dict]:
|
||||
senders = db.scalars(
|
||||
select(SenderConfig)
|
||||
.where(SenderConfig.user_id == user.id)
|
||||
.order_by(SenderConfig.created_at.desc())
|
||||
).all()
|
||||
return [serialize_sender(sender) for sender in senders]
|
||||
|
||||
|
||||
@router.post("/senders", status_code=status.HTTP_201_CREATED)
|
||||
def create_sender(
|
||||
payload: CreateSenderRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
try:
|
||||
sender_id = validate_sender_id(payload.sender_id)
|
||||
sender_type = validate_sender_type(payload.sender_type)
|
||||
purpose = validate_purpose(payload.purpose)
|
||||
dlt_entity_id = validate_dlt_entity_id(payload.dlt_entity_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
existing = db.scalar(
|
||||
select(SenderConfig).where(
|
||||
SenderConfig.user_id == user.id,
|
||||
SenderConfig.sender_id == sender_id,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Sender ID already exists")
|
||||
|
||||
sender = SenderConfig(
|
||||
user_id=user.id,
|
||||
sender_id=sender_id,
|
||||
sender_type=sender_type,
|
||||
purpose=purpose,
|
||||
dlt_entity_id=dlt_entity_id,
|
||||
status="pending",
|
||||
)
|
||||
db.add(sender)
|
||||
db.commit()
|
||||
db.refresh(sender)
|
||||
return serialize_sender(sender)
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def list_templates(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> list[dict]:
|
||||
templates = db.scalars(
|
||||
select(TemplateConfig)
|
||||
.options(joinedload(TemplateConfig.sender_config))
|
||||
.where(TemplateConfig.user_id == user.id)
|
||||
.order_by(TemplateConfig.created_at.desc())
|
||||
).unique().all()
|
||||
return [serialize_template(template) for template in templates]
|
||||
|
||||
|
||||
@router.post("/templates", status_code=status.HTTP_201_CREATED)
|
||||
def create_template(
|
||||
payload: CreateTemplateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
try:
|
||||
template_name = validate_template_name(payload.template_name)
|
||||
template_text = validate_template_text(payload.template_text)
|
||||
dlt_template_id = validate_dlt_template_id(payload.dlt_template_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
sender = db.scalar(
|
||||
select(SenderConfig).where(
|
||||
SenderConfig.id == payload.sender_config_id,
|
||||
SenderConfig.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if not sender:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sender ID not found")
|
||||
if sender.status != "approved":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Template can only be linked to an approved sender ID",
|
||||
)
|
||||
|
||||
existing = db.scalar(
|
||||
select(TemplateConfig).where(
|
||||
TemplateConfig.user_id == user.id,
|
||||
TemplateConfig.template_name == template_name,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Template name already exists")
|
||||
|
||||
template = TemplateConfig(
|
||||
user_id=user.id,
|
||||
sender_config_id=sender.id,
|
||||
template_name=template_name,
|
||||
template_text=template_text,
|
||||
dlt_template_id=dlt_template_id,
|
||||
status="pending",
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
db.refresh(sender)
|
||||
template.sender_config = sender
|
||||
return serialize_template(template)
|
||||
|
||||
|
||||
class ComposeSendRequest(BaseModel):
|
||||
sender_config_id: UUID
|
||||
template_id: UUID
|
||||
numbers: str = Field(min_length=10, max_length=5000)
|
||||
message: str = Field(min_length=1, max_length=1000)
|
||||
campaign_name: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
@router.post("/send")
|
||||
def compose_send_message(
|
||||
payload: ComposeSendRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
try:
|
||||
return send_template_messages(
|
||||
db,
|
||||
user,
|
||||
sender_config_id=payload.sender_config_id,
|
||||
template_id=payload.template_id,
|
||||
message=payload.message,
|
||||
recipients=[part.strip() for part in payload.numbers.split(",") if part.strip()],
|
||||
campaign_name=payload.campaign_name,
|
||||
)
|
||||
except SendMessageError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
79
backend/app/routes_numbers.py
Normal file
79
backend/app/routes_numbers.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth import current_user
|
||||
from .database import get_db
|
||||
from .models import PhoneNumber, UserAccount
|
||||
|
||||
router = APIRouter(prefix="/api/v1/numbers", tags=["numbers"])
|
||||
|
||||
|
||||
def serialize_phone_number(number: PhoneNumber) -> dict:
|
||||
return {
|
||||
"id": str(number.id),
|
||||
"phone_number": number.phone_number,
|
||||
"monthly_number_price": float(number.monthly_number_price),
|
||||
"inbound_price": float(number.inbound_price),
|
||||
"outbound_price": float(number.outbound_price),
|
||||
"currency": number.currency,
|
||||
"status": number.status,
|
||||
"acquisition_type": number.acquisition_type,
|
||||
"assigned_at": number.assigned_at,
|
||||
"created_at": number.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
def list_catalog(
|
||||
db: Session = Depends(get_db),
|
||||
_: UserAccount = Depends(current_user),
|
||||
) -> list[dict]:
|
||||
numbers = db.scalars(
|
||||
select(PhoneNumber)
|
||||
.where(PhoneNumber.status == "available", PhoneNumber.user_id.is_(None))
|
||||
.order_by(PhoneNumber.created_at.desc())
|
||||
).all()
|
||||
return [serialize_phone_number(number) for number in numbers]
|
||||
|
||||
|
||||
@router.get("/mine")
|
||||
def list_my_numbers(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> list[dict]:
|
||||
numbers = db.scalars(
|
||||
select(PhoneNumber)
|
||||
.where(PhoneNumber.user_id == user.id, PhoneNumber.status == "assigned")
|
||||
.order_by(PhoneNumber.assigned_at.desc())
|
||||
).all()
|
||||
return [serialize_phone_number(number) for number in numbers]
|
||||
|
||||
|
||||
@router.post("/{number_id}/purchase")
|
||||
def purchase_number(
|
||||
number_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
number = db.scalar(
|
||||
select(PhoneNumber).where(
|
||||
PhoneNumber.id == number_id,
|
||||
PhoneNumber.status == "available",
|
||||
PhoneNumber.user_id.is_(None),
|
||||
)
|
||||
)
|
||||
if not number:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Number is not available")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
number.user_id = user.id
|
||||
number.status = "assigned"
|
||||
number.acquisition_type = "purchased"
|
||||
number.assigned_at = now
|
||||
db.commit()
|
||||
db.refresh(number)
|
||||
return serialize_phone_number(number)
|
||||
18
backend/app/routes_otp.py
Normal file
18
backend/app/routes_otp.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .api_key_auth import current_api_user
|
||||
from .database import get_db
|
||||
from .models import UserAccount
|
||||
from .otp_service import GenerateOtpPayload, generate_otp_for_user
|
||||
|
||||
router = APIRouter(prefix="/api/v1/otp", tags=["otp"])
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
def generate_otp(
|
||||
payload: GenerateOtpPayload,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_api_user),
|
||||
) -> dict:
|
||||
return generate_otp_for_user(db, user, payload)
|
||||
176
backend/app/routes_urmiotp.py
Normal file
176
backend/app/routes_urmiotp.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import re
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth import current_user
|
||||
from .database import get_db
|
||||
from .models import UrmiOtpProject, UserAccount
|
||||
from .otp_service import GenerateOtpPayload, generate_otp_for_user
|
||||
from .urmiotp_catalog import OTP_API_DOCUMENTATION, render_templates_for_project
|
||||
|
||||
router = APIRouter(prefix="/api/v1/urmiotp", tags=["urmiotp"])
|
||||
|
||||
PROJECT_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]{4,28}[a-z0-9]$")
|
||||
MESSAGE_LABEL_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 -]{0,18}[A-Za-z0-9]$|^[A-Za-z0-9]$")
|
||||
RESERVED_TERMS = ("google", "goog", "gcp", "ssl")
|
||||
|
||||
|
||||
class CreateUrmiOtpProjectRequest(BaseModel):
|
||||
project_name: str = Field(min_length=6, max_length=30)
|
||||
message_label: str = Field(min_length=1, max_length=20)
|
||||
app_type: str = Field(pattern="^(web|mobile)$")
|
||||
app_url: str = Field(min_length=8, max_length=1000)
|
||||
purpose: str = Field(min_length=20, max_length=1000)
|
||||
terms_accepted: bool
|
||||
|
||||
|
||||
def validate_project_name(value: str) -> str:
|
||||
project_name = value.strip().lower()
|
||||
if not PROJECT_ID_PATTERN.fullmatch(project_name):
|
||||
raise ValueError(
|
||||
"Project name must be 6–30 lowercase letters, numbers, or hyphens; "
|
||||
"start with a letter and end with a letter or number"
|
||||
)
|
||||
if "--" in project_name:
|
||||
raise ValueError("Project name cannot contain consecutive hyphens")
|
||||
if any(term in project_name for term in RESERVED_TERMS):
|
||||
raise ValueError("Project name contains a reserved term")
|
||||
return project_name
|
||||
|
||||
|
||||
def validate_message_label(value: str) -> str:
|
||||
message_label = value.strip()
|
||||
if not message_label or len(message_label) > 20:
|
||||
raise ValueError("Message label must be 1–20 characters")
|
||||
if not MESSAGE_LABEL_PATTERN.fullmatch(message_label):
|
||||
raise ValueError(
|
||||
"Message label may only contain letters, numbers, spaces, and hyphens; "
|
||||
"start and end with a letter or number"
|
||||
)
|
||||
return message_label
|
||||
|
||||
|
||||
def validate_app_url(value: str, app_type: str) -> str:
|
||||
app_url = value.strip()
|
||||
parsed = urlparse(app_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError("Enter a valid HTTPS website, domain, or app-store URL")
|
||||
if app_type == "mobile" and not any(
|
||||
host in parsed.netloc.lower()
|
||||
for host in ("play.google.com", "apps.apple.com", "appgallery.huawei.com")
|
||||
):
|
||||
raise ValueError("Mobile apps require a Google Play, Apple App Store, or AppGallery URL")
|
||||
return app_url
|
||||
|
||||
|
||||
def serialize_project(project: UrmiOtpProject) -> dict:
|
||||
return {
|
||||
"id": str(project.id),
|
||||
"project_name": project.project_name,
|
||||
"message_label": project.message_label,
|
||||
"app_type": project.app_type,
|
||||
"app_url": project.app_url,
|
||||
"purpose": project.purpose,
|
||||
"status": project.status,
|
||||
"review_note": project.review_note,
|
||||
"terms_accepted_at": project.terms_accepted_at,
|
||||
"created_at": project.created_at,
|
||||
"reviewed_at": project.reviewed_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
def list_projects(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> list[dict]:
|
||||
projects = db.scalars(
|
||||
select(UrmiOtpProject)
|
||||
.where(UrmiOtpProject.user_id == user.id)
|
||||
.order_by(UrmiOtpProject.created_at.desc())
|
||||
).all()
|
||||
return [serialize_project(project) for project in projects]
|
||||
|
||||
|
||||
@router.post("/projects", status_code=status.HTTP_201_CREATED)
|
||||
def create_project(
|
||||
payload: CreateUrmiOtpProjectRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
if not payload.terms_accepted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="You must agree to the UrmiOTP terms and conditions",
|
||||
)
|
||||
try:
|
||||
project_name = validate_project_name(payload.project_name)
|
||||
message_label = validate_message_label(payload.message_label)
|
||||
app_url = validate_app_url(payload.app_url, payload.app_type)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
project = UrmiOtpProject(
|
||||
user_id=user.id,
|
||||
project_name=project_name,
|
||||
message_label=message_label,
|
||||
app_type=payload.app_type,
|
||||
app_url=app_url,
|
||||
purpose=payload.purpose.strip(),
|
||||
terms_accepted_at=datetime.now(timezone.utc),
|
||||
status="pending",
|
||||
)
|
||||
db.add(project)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This project name is already in use",
|
||||
) from exc
|
||||
db.refresh(project)
|
||||
return serialize_project(project)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/documentation")
|
||||
def project_documentation(
|
||||
project_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
project = db.scalar(
|
||||
select(UrmiOtpProject).where(
|
||||
UrmiOtpProject.id == project_id,
|
||||
UrmiOtpProject.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if not project:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UrmiOTP project not found")
|
||||
if project.status != "approved":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="API documentation is available after project approval",
|
||||
)
|
||||
return {
|
||||
"project": serialize_project(project),
|
||||
"templates": render_templates_for_project(project.message_label),
|
||||
"api": OTP_API_DOCUMENTATION,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
def generate_otp_console(
|
||||
payload: GenerateOtpPayload,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
"""Console/session OTP generate — writes to otp_message_logs for Logs & Analytics."""
|
||||
return generate_otp_for_user(db, user, payload)
|
||||
435
backend/app/routes_voice.py
Normal file
435
backend/app/routes_voice.py
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth import COOKIE_NAME, decode_token
|
||||
from .config import settings
|
||||
from .database import SessionLocal, get_db
|
||||
from .models import UserAccount, VoiceCall
|
||||
from .voice.bonvoice_client import BonvoiceError
|
||||
from .voice.call_service import (
|
||||
create_inbound_call_record,
|
||||
create_outbound_call,
|
||||
finalize_call_async,
|
||||
refresh_call_lifecycle,
|
||||
serialize_call,
|
||||
)
|
||||
from .voice.bonvoice_media import extract_start_metadata, parse_event, run_console_raw_relay
|
||||
from .voice.call_resolver import find_inbound_by_did, find_pending_outbound_call
|
||||
from .voice.presence import console_presence
|
||||
from .voice.public_url import unified_voicebot_url
|
||||
from .endpoint_security import signed_headers
|
||||
from .voice.relay_config import resolve_voice_socket
|
||||
from .voice.session_store import update_session
|
||||
from .voice.tunnel import relay_provider_to_upstream
|
||||
from .voice.validators import normalize_phone_digits, require_owned_did
|
||||
from .voice_auth import current_voice_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/voice", tags=["voice"])
|
||||
|
||||
VOICE_API_DOCS = {
|
||||
"title": "Initiate outbound call",
|
||||
"summary": "Place an outbound call from your Urmi number. Live audio is relayed to the voice socket linked to that number.",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/voice/calls",
|
||||
"authentication": {
|
||||
"header": "Authorization",
|
||||
"format": "Bearer urmi_live_<key>",
|
||||
"alternate_header": "X-Api-Key",
|
||||
},
|
||||
"request_body": {
|
||||
"to": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"example": "9149154337",
|
||||
"description": "Destination number with country code",
|
||||
},
|
||||
"from": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"example": "7946350706",
|
||||
"description": "Your Urmi number (must be assigned to your account)",
|
||||
},
|
||||
"bridge_mode": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"example": "upstream",
|
||||
"description": "Use upstream for production. Console mode is for internal testing only.",
|
||||
},
|
||||
},
|
||||
"response_example": {
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"status": "initiated",
|
||||
"direction": "outbound",
|
||||
"from": "7946350706",
|
||||
"to": "9149154337",
|
||||
},
|
||||
"relay": {
|
||||
"principle": "Urmi is a transparent relay and authentication gateway. We validate your API key, place the call, and forward raw WebSocket frames. We do not decode, transcode, buffer, or inject audio.",
|
||||
"latency": "Frames are forwarded as they arrive to keep latency as low as possible. All speech processing happens on your server.",
|
||||
},
|
||||
"outbound_flow": [
|
||||
"Link your Urmi number to a voice socket under Webhooks & Sockets.",
|
||||
"POST /api/v1/voice/calls with from, to, and bridge_mode upstream.",
|
||||
"Urmi validates the number belongs to your account and places the call.",
|
||||
"When the callee answers, Urmi opens your voice socket and relays connected, start, media, and end frames unchanged.",
|
||||
"Poll GET /api/v1/voice/calls/{call_id} for call status.",
|
||||
],
|
||||
"endpoints": [
|
||||
{"method": "POST", "path": "/api/v1/voice/calls", "description": "Place an outbound call"},
|
||||
{"method": "GET", "path": "/api/v1/voice/calls/{call_id}", "description": "Get call status"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class CreateCallRequest(BaseModel):
|
||||
to: str = Field(min_length=10, max_length=15)
|
||||
from_number: str = Field(alias="from", min_length=10, max_length=15)
|
||||
bridge_mode: str = Field(default="upstream", pattern="^(upstream|console)$")
|
||||
metadata: dict | None = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
def _provider_ip_allowed(client_host: str | None) -> bool:
|
||||
if settings.environment == "development":
|
||||
return True
|
||||
allowlist = [item.strip() for item in settings.bonvoice_media_allowlist.split(",") if item.strip()]
|
||||
if not allowlist:
|
||||
return False
|
||||
if not client_host:
|
||||
return False
|
||||
return client_host in allowlist
|
||||
|
||||
|
||||
def _websocket_user(db: Session, websocket: WebSocket) -> UserAccount | None:
|
||||
token = websocket.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
user = db.scalar(select(UserAccount).where(UserAccount.id == user_id))
|
||||
if user and user.status == "active":
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/docs")
|
||||
def voice_docs() -> dict:
|
||||
return VOICE_API_DOCS
|
||||
|
||||
|
||||
@router.post("/calls")
|
||||
async def initiate_call(
|
||||
payload: CreateCallRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
bridge_mode = payload.bridge_mode
|
||||
if bridge_mode == "console":
|
||||
if not await console_presence.is_present(user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Open /console/voice with an active connection before placing a console-bridged call",
|
||||
)
|
||||
upstream = None
|
||||
else:
|
||||
number = require_owned_did(db, user, payload.from_number)
|
||||
endpoint = resolve_voice_socket(db, phone_number=number)
|
||||
if not endpoint:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Link this number to an enabled voice socket under Webhooks & Sockets",
|
||||
)
|
||||
upstream = endpoint.url
|
||||
|
||||
try:
|
||||
call = await create_outbound_call(
|
||||
db,
|
||||
user=user,
|
||||
from_value=payload.from_number,
|
||||
to_value=payload.to,
|
||||
upstream=upstream,
|
||||
bridge_mode=bridge_mode,
|
||||
metadata=payload.metadata,
|
||||
)
|
||||
except BonvoiceError as exc:
|
||||
raise HTTPException(status_code=exc.status_code or 502, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
body = serialize_call(call)
|
||||
return body
|
||||
|
||||
|
||||
@router.get("/calls/{call_id}")
|
||||
async def get_call(
|
||||
call_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_voice_user),
|
||||
) -> dict:
|
||||
call = db.scalar(
|
||||
select(VoiceCall).where(VoiceCall.id == call_id, VoiceCall.user_id == user.id)
|
||||
)
|
||||
if not call:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Call not found")
|
||||
call = await refresh_call_lifecycle(db, call)
|
||||
body = serialize_call(call)
|
||||
return body
|
||||
|
||||
|
||||
@router.websocket("/console")
|
||||
async def voice_console(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
db = SessionLocal()
|
||||
user = _websocket_user(db, websocket)
|
||||
if not user:
|
||||
await websocket.close(code=4401, reason="Authentication required")
|
||||
db.close()
|
||||
return
|
||||
|
||||
await console_presence.register(user.id, websocket)
|
||||
await websocket.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "console.ready",
|
||||
"message": "Console audio bridge active for outbound calls.",
|
||||
"media_url": unified_voicebot_url(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
if message["type"] != "websocket.receive":
|
||||
continue
|
||||
text = message.get("text")
|
||||
if text:
|
||||
await console_presence.forward_from_console(user.id, text)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
await console_presence.unregister(user.id, websocket)
|
||||
db.close()
|
||||
|
||||
|
||||
async def _run_bonvoice_media_stream(websocket: WebSocket) -> None:
|
||||
"""Match Bonvoice media to outbound or inbound, then raw-relay to upstream or console."""
|
||||
await websocket.accept()
|
||||
db = SessionLocal()
|
||||
call: VoiceCall | None = None
|
||||
console_ws: WebSocket | None = None
|
||||
buffered: list[str] = []
|
||||
|
||||
async def on_end() -> None:
|
||||
if call:
|
||||
await update_session(call.session_token, status="ended")
|
||||
await finalize_call_async(db, call)
|
||||
if console_ws:
|
||||
try:
|
||||
await console_ws.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "call.ended",
|
||||
"call_id": str(call.id),
|
||||
"direction": call.direction,
|
||||
}
|
||||
)
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
buffered.append(raw)
|
||||
message = parse_event(raw)
|
||||
if not message or message.get("event") != "start":
|
||||
continue
|
||||
|
||||
meta = extract_start_metadata(message)
|
||||
logger.info(
|
||||
"Bonvoice media start stream_id=%s call_id=%s from=%s to=%s",
|
||||
meta["stream_id"],
|
||||
meta["call_id"],
|
||||
meta["from"],
|
||||
meta["to"],
|
||||
)
|
||||
|
||||
call = find_pending_outbound_call(db, from_value=meta["from"], to_value=meta["to"])
|
||||
if call:
|
||||
call.status = "active"
|
||||
call.answered_at = datetime.now(timezone.utc)
|
||||
call.stream_id = meta["stream_id_str"] or None
|
||||
call.call_id = meta["call_id"] or None
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
await update_session(call.session_token, status="active", stream_id=meta["stream_id_str"])
|
||||
|
||||
if call.bridge_mode == "upstream":
|
||||
endpoint = resolve_voice_socket(db, phone_number=call.phone_number)
|
||||
if not endpoint:
|
||||
await websocket.close(code=4403, reason="No upstream URL")
|
||||
break
|
||||
try:
|
||||
await relay_provider_to_upstream(
|
||||
websocket,
|
||||
endpoint.url,
|
||||
initial_frames=buffered,
|
||||
additional_headers=signed_headers("socket", endpoint.id),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Upstream tunnel failed for call %s: %s", call.id, exc)
|
||||
call.status = "failed"
|
||||
call.error_message = f"Upstream connection failed: {exc}"
|
||||
call.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
break
|
||||
|
||||
if call.bridge_mode == "console":
|
||||
console_ws = await console_presence.get_connection(call.user_id)
|
||||
if not console_ws:
|
||||
logger.warning("Console not connected for outbound call %s", call.id)
|
||||
await websocket.close(code=4403, reason="Console not active")
|
||||
break
|
||||
await console_ws.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "call.outbound",
|
||||
"call_id": str(call.id),
|
||||
"from": call.from_did,
|
||||
"to": call.to_number,
|
||||
"stream_id": meta["stream_id"],
|
||||
}
|
||||
)
|
||||
)
|
||||
await run_console_raw_relay(
|
||||
websocket,
|
||||
call.user_id,
|
||||
console_ws,
|
||||
initial_frames=buffered,
|
||||
on_end=on_end,
|
||||
)
|
||||
call = None
|
||||
break
|
||||
|
||||
await websocket.close(code=4403, reason="Unsupported bridge mode")
|
||||
break
|
||||
|
||||
phone_number = find_inbound_by_did(db, meta["to"])
|
||||
if not phone_number:
|
||||
logger.warning(
|
||||
"No outbound or inbound match for from=%s to=%s",
|
||||
meta["from"],
|
||||
meta["to"],
|
||||
)
|
||||
did = normalize_phone_digits(meta["to"])
|
||||
if len(did) == 10:
|
||||
stale = db.scalar(
|
||||
select(VoiceCall)
|
||||
.where(
|
||||
VoiceCall.direction == "outbound",
|
||||
VoiceCall.status == "initiated",
|
||||
VoiceCall.from_did == did,
|
||||
)
|
||||
.order_by(VoiceCall.created_at.desc())
|
||||
)
|
||||
if stale:
|
||||
stale.status = "failed"
|
||||
stale.error_message = "Media session could not be matched to pending call"
|
||||
stale.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
await websocket.close(code=4403, reason="No matching call or DID")
|
||||
break
|
||||
|
||||
endpoint = resolve_voice_socket(db, phone_number=phone_number)
|
||||
if not endpoint:
|
||||
logger.warning("No upstream configured for inbound DID %s", phone_number.phone_number)
|
||||
await websocket.close(code=4403, reason="No upstream configured")
|
||||
break
|
||||
|
||||
caller = normalize_phone_digits(meta["from"])
|
||||
call = create_inbound_call_record(
|
||||
db,
|
||||
phone_number=phone_number,
|
||||
caller=caller,
|
||||
upstream=endpoint.url,
|
||||
stream_id=meta["stream_id_str"] or None,
|
||||
provider_call_id=meta["call_id"] or None,
|
||||
)
|
||||
|
||||
try:
|
||||
await relay_provider_to_upstream(
|
||||
websocket,
|
||||
endpoint.url,
|
||||
initial_frames=buffered,
|
||||
additional_headers=signed_headers("socket", endpoint.id),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Inbound upstream tunnel failed for call %s: %s", call.id, exc)
|
||||
call.status = "failed"
|
||||
call.error_message = f"Upstream connection failed: {exc}"
|
||||
call.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Bonvoice disconnected call=%s", call.id if call else None)
|
||||
except RuntimeError as exc:
|
||||
if "WebSocket is not connected" not in str(exc):
|
||||
raise
|
||||
finally:
|
||||
if call:
|
||||
await on_end()
|
||||
db.close()
|
||||
|
||||
|
||||
async def _run_outbound_media_stream(websocket: WebSocket) -> None:
|
||||
await _run_bonvoice_media_stream(websocket)
|
||||
|
||||
|
||||
@router.websocket("/ws/media")
|
||||
async def bonvoice_ws_media(websocket: WebSocket) -> None:
|
||||
client_host = websocket.client.host if websocket.client else None
|
||||
if not _provider_ip_allowed(client_host):
|
||||
await websocket.close(code=4403, reason="Provider IP not allowed")
|
||||
return
|
||||
logger.info("Bonvoice WebSocket connect /ws/media from %s", client_host)
|
||||
await _run_outbound_media_stream(websocket)
|
||||
|
||||
|
||||
@router.websocket("/media/{session_token}")
|
||||
async def provider_outbound_media(websocket: WebSocket, session_token: str) -> None:
|
||||
client_host = websocket.client.host if websocket.client else None
|
||||
if not _provider_ip_allowed(client_host):
|
||||
await websocket.close(code=4403, reason="Provider IP not allowed")
|
||||
return
|
||||
logger.info("Bonvoice WebSocket connect /media/%s from %s", session_token[:8], client_host)
|
||||
await _run_outbound_media_stream(websocket)
|
||||
|
||||
|
||||
@router.post("/ws/media")
|
||||
async def ws_media_post_debug(request: Request) -> dict:
|
||||
body = await request.body()
|
||||
text = body.decode("utf-8", errors="replace")
|
||||
logger.warning("POST /ws/media (not WebSocket). Body: %s", text[:500])
|
||||
return {
|
||||
"error": "WebSocket required",
|
||||
"hint": f"Use wss://<host>/api/v1/voice/ws/media (not https://)",
|
||||
"received": text[:200] if text else None,
|
||||
}
|
||||
27
backend/app/schemas.py
Normal file
27
backend/app/schemas.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=160)
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=10, max_length=128)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class SetPasswordRequest(BaseModel):
|
||||
password: str = Field(min_length=10, max_length=128)
|
||||
current_password: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
name: str
|
||||
avatar_url: str | None
|
||||
email_verified: bool
|
||||
has_password: bool
|
||||
providers: list[str]
|
||||
38
backend/app/sender_validators.py
Normal file
38
backend/app/sender_validators.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import re
|
||||
|
||||
SENDER_ID_PATTERN = re.compile(r"^[A-Z0-9]{3,11}$")
|
||||
DLT_ENTITY_PATTERN = re.compile(r"^\d{12,25}$")
|
||||
|
||||
|
||||
def normalize_sender_id(value: str) -> str:
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
def validate_sender_id(value: str) -> str:
|
||||
sender_id = normalize_sender_id(value)
|
||||
if not SENDER_ID_PATTERN.match(sender_id):
|
||||
raise ValueError("Sender ID must be 3–11 uppercase letters or numbers")
|
||||
return sender_id
|
||||
|
||||
|
||||
def validate_sender_type(value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"promotional", "transactional"}:
|
||||
raise ValueError("Type must be promotional or transactional")
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_purpose(value: str) -> str:
|
||||
purpose = value.strip()
|
||||
if len(purpose) < 10:
|
||||
raise ValueError("Purpose must be at least 10 characters")
|
||||
if len(purpose) > 500:
|
||||
raise ValueError("Purpose must be 500 characters or fewer")
|
||||
return purpose
|
||||
|
||||
|
||||
def validate_dlt_entity_id(value: str) -> str:
|
||||
entity_id = value.strip()
|
||||
if not DLT_ENTITY_PATTERN.match(entity_id):
|
||||
raise ValueError("DLT Entity ID must be 12–25 digits")
|
||||
return entity_id
|
||||
68
backend/app/sms_webhooks.py
Normal file
68
backend/app/sms_webhooks.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from .endpoint_security import payload_signature
|
||||
from .models import SmsDeliveryWebhook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _event_body(data: dict) -> bytes:
|
||||
payload = {
|
||||
"id": f"evt_{uuid.uuid4().hex}",
|
||||
"event": "message.delivery.updated",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"data": data,
|
||||
}
|
||||
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
|
||||
|
||||
async def deliver_report(
|
||||
endpoint: SmsDeliveryWebhook,
|
||||
data: dict,
|
||||
*,
|
||||
attempts: int = 3,
|
||||
) -> int:
|
||||
body = _event_body(data)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Urmi-Webhooks/1.0",
|
||||
"X-Urmi-Event": "message.delivery.updated",
|
||||
"X-Urmi-Endpoint-Id": str(endpoint.id),
|
||||
"X-Urmi-Signature": payload_signature("webhook", endpoint.id, body),
|
||||
}
|
||||
last_error: Exception | None = None
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client:
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
response = await client.post(endpoint.url, content=body, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.status_code
|
||||
except (httpx.HTTPError, httpx.TimeoutException) as exc:
|
||||
last_error = exc
|
||||
if attempt + 1 < attempts:
|
||||
await asyncio.sleep(2**attempt)
|
||||
logger.warning("SMS delivery webhook failed endpoint=%s: %s", endpoint.id, last_error)
|
||||
raise RuntimeError("Webhook endpoint did not return a successful response") from last_error
|
||||
|
||||
|
||||
async def deliver_test_report(endpoint: SmsDeliveryWebhook) -> int:
|
||||
if not endpoint.enabled:
|
||||
raise RuntimeError("Webhook is disabled")
|
||||
return await deliver_report(
|
||||
endpoint,
|
||||
{
|
||||
"message_id": f"msg_test_{uuid.uuid4().hex[:16]}",
|
||||
"to": "+919999999999",
|
||||
"from": endpoint.phone_number.phone_number if endpoint.phone_number else "test",
|
||||
"status": "delivered",
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"test": True,
|
||||
},
|
||||
attempts=1,
|
||||
)
|
||||
16
backend/app/template_message_validator.py
Normal file
16
backend/app/template_message_validator.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import re
|
||||
|
||||
PLACEHOLDER_PATTERN = re.compile(r"\{#(?:var|num)#\}")
|
||||
|
||||
|
||||
def message_matches_template(template_text: str, message: str) -> bool:
|
||||
message = message.strip()
|
||||
if not message:
|
||||
return False
|
||||
parts = PLACEHOLDER_PATTERN.split(template_text.strip())
|
||||
pattern = ""
|
||||
for index, part in enumerate(parts):
|
||||
pattern += re.escape(part)
|
||||
if index < len(parts) - 1:
|
||||
pattern += r".+?"
|
||||
return re.fullmatch(pattern, message, flags=re.DOTALL) is not None
|
||||
32
backend/app/template_validators.py
Normal file
32
backend/app/template_validators.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import re
|
||||
|
||||
TEMPLATE_NAME_PATTERN = re.compile(r"^[A-Z0-9_]{3,40}$")
|
||||
DLT_TEMPLATE_ID_PATTERN = re.compile(r"^\d{12,25}$")
|
||||
VARIABLE_TOKEN = "{#var#}"
|
||||
|
||||
|
||||
def normalize_template_name(value: str) -> str:
|
||||
return value.strip().upper().replace(" ", "_")
|
||||
|
||||
|
||||
def validate_template_name(value: str) -> str:
|
||||
name = normalize_template_name(value)
|
||||
if not TEMPLATE_NAME_PATTERN.match(name):
|
||||
raise ValueError("Template name must be 3–40 uppercase letters, numbers, or underscores")
|
||||
return name
|
||||
|
||||
|
||||
def validate_template_text(value: str) -> str:
|
||||
text = value.strip()
|
||||
if len(text) < 10:
|
||||
raise ValueError("Template must be at least 10 characters")
|
||||
if len(text) > 1000:
|
||||
raise ValueError("Template must be 1000 characters or fewer")
|
||||
return text
|
||||
|
||||
|
||||
def validate_dlt_template_id(value: str) -> str:
|
||||
template_id = value.strip()
|
||||
if not DLT_TEMPLATE_ID_PATTERN.match(template_id):
|
||||
raise ValueError("DLT Template ID must be 12–25 digits")
|
||||
return template_id
|
||||
92
backend/app/ultronsms_client.py
Normal file
92
backend/app/ultronsms_client.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
class SmsChannel(str, Enum):
|
||||
TRANSACTIONAL = "Trans"
|
||||
PROMOTIONAL = "Promo"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendResult:
|
||||
success: bool
|
||||
job_id: str | None
|
||||
message_id: str | None
|
||||
number: str | None
|
||||
raw: dict[str, Any]
|
||||
|
||||
|
||||
class UltronsmsClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
user: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> None:
|
||||
self.base_url = (base_url or settings.ultronsms_base_url).rstrip("/")
|
||||
self.user = user or settings.ultronsms_user
|
||||
self.password = password or settings.ultronsms_password
|
||||
|
||||
@staticmethod
|
||||
def normalize_phone(number: str) -> str:
|
||||
digits = "".join(character for character in number if character.isdigit())
|
||||
if len(digits) == 10:
|
||||
return f"91{digits}"
|
||||
return digits
|
||||
|
||||
@staticmethod
|
||||
def route_for_channel(channel: SmsChannel) -> str:
|
||||
return "2" if channel == SmsChannel.TRANSACTIONAL else "1"
|
||||
|
||||
@staticmethod
|
||||
def channel_for_sender_type(sender_type: str) -> SmsChannel:
|
||||
return SmsChannel.PROMOTIONAL if sender_type == "promotional" else SmsChannel.TRANSACTIONAL
|
||||
|
||||
def _get(self, path: str, params: dict[str, str]) -> dict[str, Any]:
|
||||
if not self.user or not self.password:
|
||||
raise RuntimeError("Ultronsms credentials are not configured")
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
||||
response = client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def send_template_sms(
|
||||
self,
|
||||
mobile: str,
|
||||
message: str,
|
||||
*,
|
||||
sender_id: str,
|
||||
pe_id: str,
|
||||
dlt_template_id: str,
|
||||
sender_type: str,
|
||||
dcs: str = "0",
|
||||
flash_sms: str = "0",
|
||||
) -> SendResult:
|
||||
channel = self.channel_for_sender_type(sender_type)
|
||||
params = {
|
||||
"user": self.user,
|
||||
"password": self.password,
|
||||
"senderid": sender_id,
|
||||
"channel": channel.value,
|
||||
"route": self.route_for_channel(channel),
|
||||
"DCS": dcs,
|
||||
"flashsms": flash_sms,
|
||||
"number": self.normalize_phone(mobile),
|
||||
"text": message,
|
||||
"peid": pe_id,
|
||||
"dlttemplateid": dlt_template_id,
|
||||
}
|
||||
raw = self._get("/api/mt/SendSMS", params)
|
||||
message_data = (raw.get("MessageData") or [{}])[0]
|
||||
return SendResult(
|
||||
success=raw.get("ErrorCode") == "000",
|
||||
job_id=raw.get("JobId"),
|
||||
message_id=message_data.get("MessageId"),
|
||||
number=message_data.get("Number"),
|
||||
raw=raw,
|
||||
)
|
||||
93
backend/app/urmiotp_catalog.py
Normal file
93
backend/app/urmiotp_catalog.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
OTP_TEMPLATES = (
|
||||
{
|
||||
"type": "general_verification",
|
||||
"name": "URMI General Verification OTP",
|
||||
"content": "Your URMI verification code for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
|
||||
"sample": "Your URMI verification code for {message_label} is 583921. It is valid for 10 minutes. - URMI",
|
||||
"uses": [
|
||||
"Phone or email verification", "Signup and registration", "KYC or identity verification",
|
||||
"Invitation acceptance", "General verification",
|
||||
],
|
||||
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
|
||||
},
|
||||
{
|
||||
"type": "login_authentication",
|
||||
"name": "URMI Login Authentication OTP",
|
||||
"content": "Your URMI login verification code for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
|
||||
"sample": "Your URMI login verification code for {message_label} is 583921. It is valid for 10 minutes. - URMI",
|
||||
"uses": ["Normal or passwordless login", "MFA and 2FA", "Admin login", "Authentication"],
|
||||
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
|
||||
},
|
||||
{
|
||||
"type": "account_recovery",
|
||||
"name": "URMI Account Recovery OTP",
|
||||
"content": "Your URMI account recovery code for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
|
||||
"sample": "Your URMI account recovery code for {message_label} is 583921. It is valid for 10 minutes. - URMI",
|
||||
"uses": ["Forgot password", "Password reset", "Account or locked-account recovery"],
|
||||
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
|
||||
},
|
||||
{
|
||||
"type": "account_change",
|
||||
"name": "URMI Account Change OTP",
|
||||
"content": "Your URMI verification code to update your account for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
|
||||
"sample": "Your URMI verification code to update your account for {message_label} is 583921. It is valid for 10 minutes. - URMI",
|
||||
"uses": ["Change mobile number or email", "Change password", "MFA or security setting changes"],
|
||||
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
|
||||
},
|
||||
{
|
||||
"type": "action_authorization",
|
||||
"name": "URMI Action Authorization OTP",
|
||||
"content": "Your URMI authorization code for this action on {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
|
||||
"sample": "Your URMI authorization code for this action on {message_label} is 583921. It is valid for 10 minutes. - URMI",
|
||||
"uses": ["Sensitive or high-risk operations", "API key changes", "Account deletion", "Ownership changes"],
|
||||
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
|
||||
},
|
||||
{
|
||||
"type": "transaction_verification",
|
||||
"name": "URMI Transaction Verification OTP",
|
||||
"content": "Your URMI transaction verification code for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
|
||||
"sample": "Your URMI transaction verification code for {message_label} is 583921. It is valid for 10 minutes. - URMI",
|
||||
"uses": ["Payment authorization", "Transfers and withdrawals", "Financial transaction confirmation"],
|
||||
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
|
||||
},
|
||||
{
|
||||
"type": "device_verification",
|
||||
"name": "URMI Device Verification OTP",
|
||||
"content": "Your URMI device verification code for {message_label} is {#var#}. It is valid for {#var#} minutes. - URMI",
|
||||
"sample": "Your URMI device verification code for {message_label} is 583921. It is valid for 10 minutes. - URMI",
|
||||
"uses": ["Device registration or pairing", "Device activation", "IoT ownership verification", "Device transfer"],
|
||||
"variables": ["Product identification ({message_label})", "OTP ({#var#})", "Validity duration in minutes ({#var#})"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def render_templates_for_project(message_label: str) -> list[dict]:
|
||||
rendered: list[dict] = []
|
||||
for template in OTP_TEMPLATES:
|
||||
rendered.append(
|
||||
{
|
||||
**template,
|
||||
"content": template["content"].replace("{message_label}", message_label),
|
||||
"sample": template["sample"].replace("{message_label}", message_label),
|
||||
}
|
||||
)
|
||||
return rendered
|
||||
|
||||
|
||||
OTP_API_DOCUMENTATION = {
|
||||
"method": "POST",
|
||||
"path": "/api/v1/otp/generate",
|
||||
"implemented": True,
|
||||
"summary": "Generate and deliver an OTP using one of Urmi's unified verification templates.",
|
||||
"parameters": {
|
||||
"project_name": "Your approved UrmiOTP project name.",
|
||||
"template_type": "One of the supported template type identifiers.",
|
||||
"variables": "Values for OTP and validity duration ({#var#} placeholders), in template order.",
|
||||
"message_label": "Registered with your project; fills the {message_label} slot as your product identification. URMI branding stays fixed.",
|
||||
},
|
||||
"example": {
|
||||
"project_name": "acme-login",
|
||||
"template_type": "login_authentication",
|
||||
"variables": {"otp": "583921", "validity_minutes": "10"},
|
||||
},
|
||||
}
|
||||
1
backend/app/voice/__init__.py
Normal file
1
backend/app/voice/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
40
backend/app/voice/audio_utils.py
Normal file
40
backend/app/voice/audio_utils.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""PCM helpers for Bonvoice media (from test_bonvoice/audio_utils.py)."""
|
||||
|
||||
import audioop
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
TARGET_RATE = 8000
|
||||
CHUNK_SIZE = 320
|
||||
|
||||
|
||||
def wav_to_pcm8000_mono(path: str | Path) -> bytes:
|
||||
path = Path(path)
|
||||
with wave.open(str(path), "rb") as wf:
|
||||
channels = wf.getnchannels()
|
||||
sample_width = wf.getsampwidth()
|
||||
frame_rate = wf.getframerate()
|
||||
pcm = wf.readframes(wf.getnframes())
|
||||
|
||||
if sample_width != 2:
|
||||
raise ValueError(f"Expected 16-bit audio, got {sample_width * 8}-bit")
|
||||
|
||||
if channels == 2:
|
||||
pcm = audioop.tomono(pcm, 2, 0.5, 0.5)
|
||||
elif channels != 1:
|
||||
raise ValueError(f"Unsupported channel count: {channels}")
|
||||
|
||||
if frame_rate != TARGET_RATE:
|
||||
pcm, _ = audioop.ratecv(pcm, 2, 1, frame_rate, TARGET_RATE, None)
|
||||
|
||||
return pcm
|
||||
|
||||
|
||||
def chunk_pcm(pcm: bytes, chunk_size: int = CHUNK_SIZE) -> list[bytes]:
|
||||
chunks: list[bytes] = []
|
||||
for i in range(0, len(pcm), chunk_size):
|
||||
chunk = pcm[i : i + chunk_size]
|
||||
if len(chunk) < chunk_size:
|
||||
chunk = chunk + b"\x00" * (chunk_size - len(chunk))
|
||||
chunks.append(chunk)
|
||||
return chunks
|
||||
51
backend/app/voice/bonvoice_client.py
Normal file
51
backend/app/voice/bonvoice_client.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import settings
|
||||
|
||||
|
||||
class BonvoiceError(Exception):
|
||||
def __init__(self, message: str, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
async def initiate_outbound_call(
|
||||
*,
|
||||
destination: str,
|
||||
leg_a_caller_id: str,
|
||||
voicebot_url: str,
|
||||
event_id: str | None = None,
|
||||
) -> dict:
|
||||
if not settings.bonvoice_token:
|
||||
raise BonvoiceError("Bonvoice credentials are not configured", status_code=503)
|
||||
|
||||
payload = {
|
||||
"autocallType": "5",
|
||||
"destination": destination,
|
||||
"legACallerID": leg_a_caller_id,
|
||||
"eventID": event_id or uuid.uuid4().hex.upper(),
|
||||
"voicebotProvider": "BONVOICE",
|
||||
"voicebotURL": voicebot_url,
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Token {settings.bonvoice_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(settings.bonvoice_api_url, headers=headers, json=payload)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise BonvoiceError(
|
||||
f"Bonvoice API rejected the call ({response.status_code})",
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
body = response.json()
|
||||
if body.get("responseCode") not in (200, "200"):
|
||||
raise BonvoiceError(
|
||||
body.get("responseDescription") or "Bonvoice API returned an error",
|
||||
status_code=502,
|
||||
)
|
||||
return body
|
||||
76
backend/app/voice/bonvoice_media.py
Normal file
76
backend/app/voice/bonvoice_media.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Bonvoice media — raw frame relay only (no PCM processing on the API)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from .presence import console_presence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OnEndCallback = Callable[[], Awaitable[None]]
|
||||
|
||||
|
||||
def parse_event(raw: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def extract_start_metadata(message: dict[str, Any]) -> dict[str, Any]:
|
||||
data = message.get("data") or message.get("start") or {}
|
||||
stream_id = data.get("stream_id") if data.get("stream_id") is not None else message.get("stream_id")
|
||||
return {
|
||||
"stream_id": stream_id,
|
||||
"stream_id_str": str(stream_id) if stream_id is not None else "",
|
||||
"call_id": str(data.get("call_id") or message.get("call_id") or ""),
|
||||
"from": str(data.get("from") or message.get("from") or ""),
|
||||
"to": str(data.get("to") or message.get("to") or ""),
|
||||
}
|
||||
|
||||
|
||||
async def run_console_raw_relay(
|
||||
provider: WebSocket,
|
||||
user_id: UUID,
|
||||
console_ws: WebSocket,
|
||||
*,
|
||||
initial_frames: list[str] | None = None,
|
||||
on_end: OnEndCallback | None = None,
|
||||
) -> None:
|
||||
"""Console test bridge — forward raw JSON frames both ways (no server audio processing)."""
|
||||
stop = asyncio.Event()
|
||||
relay_queue = await console_presence.start_relay(user_id)
|
||||
|
||||
for raw in initial_frames or []:
|
||||
await console_ws.send_text(raw)
|
||||
|
||||
async def console_to_provider() -> None:
|
||||
try:
|
||||
while not stop.is_set():
|
||||
raw = await relay_queue.get()
|
||||
await provider.send_text(raw)
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
stop.set()
|
||||
|
||||
async def provider_to_console() -> None:
|
||||
try:
|
||||
while not stop.is_set():
|
||||
raw = await provider.receive_text()
|
||||
await console_ws.send_text(raw)
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
stop.set()
|
||||
|
||||
try:
|
||||
await asyncio.gather(console_to_provider(), provider_to_console())
|
||||
finally:
|
||||
stop.set()
|
||||
await console_presence.stop_relay(user_id)
|
||||
if on_end:
|
||||
await on_end()
|
||||
65
backend/app/voice/call_resolver.py
Normal file
65
backend/app/voice/call_resolver.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import PhoneNumber, VoiceCall
|
||||
from .validators import format_bonvoice_destination, normalize_phone_digits
|
||||
|
||||
|
||||
def find_inbound_by_did(db: Session, did_value: str) -> PhoneNumber | None:
|
||||
did = normalize_phone_digits(did_value)
|
||||
if len(did) != 10:
|
||||
return None
|
||||
return db.scalar(
|
||||
select(PhoneNumber).where(
|
||||
PhoneNumber.phone_number == did,
|
||||
PhoneNumber.status == "assigned",
|
||||
PhoneNumber.user_id.is_not(None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _phone_variants(value: str) -> set[str]:
|
||||
raw = value.strip()
|
||||
normalized = normalize_phone_digits(raw)
|
||||
variants = {raw, normalized, format_bonvoice_destination(raw)}
|
||||
if len(normalized) == 10:
|
||||
variants.add(f"91{normalized}")
|
||||
return {item for item in variants if item}
|
||||
|
||||
|
||||
def find_pending_outbound_call(db: Session, *, from_value: str, to_value: str) -> VoiceCall | None:
|
||||
"""
|
||||
Match a pending outbound call to Bonvoice media `start`.
|
||||
|
||||
Outbound Bonvoice streams report:
|
||||
- `to` = leg-A caller ID (purchased DID, e.g. 7946350706)
|
||||
- `from` = destination/callee (e.g. 919149154337)
|
||||
"""
|
||||
did_candidates = _phone_variants(to_value)
|
||||
owned_dids = {
|
||||
normalize_phone_digits(value)
|
||||
for value in did_candidates
|
||||
if len(normalize_phone_digits(value)) == 10
|
||||
}
|
||||
if not owned_dids:
|
||||
return None
|
||||
|
||||
candidates = db.scalars(
|
||||
select(VoiceCall)
|
||||
.where(
|
||||
VoiceCall.direction == "outbound",
|
||||
VoiceCall.status == "initiated",
|
||||
VoiceCall.from_did.in_(owned_dids),
|
||||
)
|
||||
.order_by(VoiceCall.created_at.desc())
|
||||
.limit(5)
|
||||
).all()
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
callee_variants = _phone_variants(from_value)
|
||||
for call in candidates:
|
||||
if _phone_variants(call.to_number) & callee_variants:
|
||||
return call
|
||||
|
||||
return None
|
||||
199
backend/app/voice/call_service.py
Normal file
199
backend/app/voice/call_service.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import settings
|
||||
from ..models import PhoneNumber, UserAccount, VoiceCall
|
||||
from .bonvoice_client import BonvoiceError, initiate_outbound_call
|
||||
from .public_url import unified_voicebot_url
|
||||
from .session_store import delete_session, get_session, save_session, update_session
|
||||
from .validators import format_bonvoice_destination, require_owned_did
|
||||
|
||||
TERMINAL_STATUSES = frozenset({"ended", "failed", "no_answer"})
|
||||
|
||||
|
||||
def media_url_for_session(session_token: str) -> str:
|
||||
return unified_voicebot_url()
|
||||
|
||||
|
||||
def voice_media_ws_url() -> str:
|
||||
return unified_voicebot_url()
|
||||
|
||||
|
||||
def serialize_call(call: VoiceCall) -> dict:
|
||||
return {
|
||||
"id": str(call.id),
|
||||
"direction": call.direction,
|
||||
"from": call.from_did,
|
||||
"to": call.to_number,
|
||||
"status": call.status,
|
||||
"stream_id": call.stream_id,
|
||||
"call_id": call.call_id,
|
||||
"bridge_mode": call.bridge_mode,
|
||||
"upstream": call.upstream_ws,
|
||||
"error_message": call.error_message,
|
||||
"answered_at": call.answered_at,
|
||||
"ended_at": call.ended_at,
|
||||
"created_at": call.created_at,
|
||||
}
|
||||
|
||||
|
||||
async def create_outbound_call(
|
||||
db: Session,
|
||||
*,
|
||||
user: UserAccount,
|
||||
from_value: str,
|
||||
to_value: str,
|
||||
upstream: str | None,
|
||||
bridge_mode: str,
|
||||
metadata: dict | None = None,
|
||||
) -> VoiceCall:
|
||||
owned_number = require_owned_did(db, user, from_value)
|
||||
destination = format_bonvoice_destination(to_value)
|
||||
if len(destination) < 10:
|
||||
raise ValueError("to must be a valid phone number")
|
||||
|
||||
session_token = secrets.token_urlsafe(24)
|
||||
provider_event_id = uuid.uuid4().hex.upper()
|
||||
call = VoiceCall(
|
||||
user_id=user.id,
|
||||
phone_number_id=owned_number.id,
|
||||
direction="outbound",
|
||||
from_did=owned_number.phone_number,
|
||||
to_number=destination,
|
||||
status="initiated",
|
||||
provider_event_id=provider_event_id,
|
||||
session_token=session_token,
|
||||
bridge_mode=bridge_mode,
|
||||
upstream_ws=upstream,
|
||||
)
|
||||
db.add(call)
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
|
||||
await save_session(
|
||||
session_token,
|
||||
{
|
||||
"call_id": str(call.id),
|
||||
"user_id": str(user.id),
|
||||
"direction": "outbound",
|
||||
"from": owned_number.phone_number,
|
||||
"to": destination,
|
||||
"bridge_mode": bridge_mode,
|
||||
"upstream_ws": upstream,
|
||||
"status": "pending",
|
||||
"metadata": metadata or {},
|
||||
},
|
||||
)
|
||||
|
||||
voicebot_url = unified_voicebot_url()
|
||||
try:
|
||||
await initiate_outbound_call(
|
||||
destination=destination,
|
||||
leg_a_caller_id=owned_number.phone_number,
|
||||
voicebot_url=voicebot_url,
|
||||
event_id=provider_event_id,
|
||||
)
|
||||
except BonvoiceError as exc:
|
||||
call.status = "failed"
|
||||
call.error_message = str(exc)
|
||||
call.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
raise
|
||||
|
||||
return call
|
||||
|
||||
|
||||
def create_inbound_call_record(
|
||||
db: Session,
|
||||
*,
|
||||
phone_number: PhoneNumber,
|
||||
caller: str,
|
||||
upstream: str,
|
||||
stream_id: str | None,
|
||||
provider_call_id: str | None,
|
||||
) -> VoiceCall:
|
||||
session_token = secrets.token_urlsafe(24)
|
||||
call = VoiceCall(
|
||||
user_id=phone_number.user_id,
|
||||
phone_number_id=phone_number.id,
|
||||
direction="inbound",
|
||||
from_did=phone_number.phone_number,
|
||||
to_number=caller,
|
||||
status="active",
|
||||
session_token=session_token,
|
||||
stream_id=stream_id,
|
||||
call_id=provider_call_id,
|
||||
bridge_mode="upstream",
|
||||
upstream_ws=upstream,
|
||||
answered_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(call)
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
return call
|
||||
|
||||
|
||||
def finalize_call(db: Session, call: VoiceCall | None, *, status: str = "ended") -> None:
|
||||
if not call:
|
||||
return
|
||||
db.refresh(call)
|
||||
if call.status in TERMINAL_STATUSES:
|
||||
return
|
||||
call.status = status
|
||||
if not call.ended_at:
|
||||
call.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
async def finalize_call_async(db: Session, call: VoiceCall | None, *, status: str = "ended") -> None:
|
||||
if not call:
|
||||
return
|
||||
finalize_call(db, call, status=status)
|
||||
if call.session_token:
|
||||
await delete_session(call.session_token)
|
||||
|
||||
|
||||
async def refresh_call_lifecycle(db: Session, call: VoiceCall) -> VoiceCall:
|
||||
if call.status in TERMINAL_STATUSES:
|
||||
return call
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
age_seconds = (now - call.created_at).total_seconds()
|
||||
|
||||
if call.status == "initiated":
|
||||
if age_seconds >= 45:
|
||||
call.status = "no_answer"
|
||||
call.ended_at = now
|
||||
if not call.error_message:
|
||||
call.error_message = "Call ended before media session connected"
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
await delete_session(call.session_token)
|
||||
|
||||
if call.status == "active":
|
||||
session = await get_session(call.session_token)
|
||||
if not session:
|
||||
call.status = "ended"
|
||||
call.ended_at = now
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
elif session.get("status") == "ended":
|
||||
call.status = "ended"
|
||||
if not call.ended_at:
|
||||
call.ended_at = now
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
await delete_session(call.session_token)
|
||||
elif age_seconds >= 600:
|
||||
call.status = "ended"
|
||||
call.ended_at = now
|
||||
if not call.error_message:
|
||||
call.error_message = "Call exceeded maximum duration"
|
||||
db.commit()
|
||||
db.refresh(call)
|
||||
await delete_session(call.session_token)
|
||||
|
||||
return call
|
||||
50
backend/app/voice/presence.py
Normal file
50
backend/app/voice/presence.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import asyncio
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConsolePresenceManager:
|
||||
def __init__(self) -> None:
|
||||
self._connections: dict[UUID, WebSocket] = {}
|
||||
self._relay_queues: dict[UUID, asyncio.Queue[str]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def register(self, user_id: UUID, websocket: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
self._connections[user_id] = websocket
|
||||
|
||||
async def unregister(self, user_id: UUID, websocket: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
if self._connections.get(user_id) is websocket:
|
||||
self._connections.pop(user_id, None)
|
||||
self._relay_queues.pop(user_id, None)
|
||||
|
||||
async def is_present(self, user_id: UUID) -> bool:
|
||||
async with self._lock:
|
||||
return user_id in self._connections
|
||||
|
||||
async def get_connection(self, user_id: UUID) -> WebSocket | None:
|
||||
async with self._lock:
|
||||
return self._connections.get(user_id)
|
||||
|
||||
async def start_relay(self, user_id: UUID) -> asyncio.Queue[str]:
|
||||
async with self._lock:
|
||||
queue: asyncio.Queue[str] = asyncio.Queue()
|
||||
self._relay_queues[user_id] = queue
|
||||
return queue
|
||||
|
||||
async def stop_relay(self, user_id: UUID) -> None:
|
||||
async with self._lock:
|
||||
self._relay_queues.pop(user_id, None)
|
||||
|
||||
async def forward_from_console(self, user_id: UUID, text: str) -> bool:
|
||||
async with self._lock:
|
||||
queue = self._relay_queues.get(user_id)
|
||||
if not queue:
|
||||
return False
|
||||
await queue.put(text)
|
||||
return True
|
||||
|
||||
|
||||
console_presence = ConsolePresenceManager()
|
||||
40
backend/app/voice/public_url.py
Normal file
40
backend/app/voice/public_url.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ngrok_wss_base() -> str | None:
|
||||
"""Detect ngrok public WSS base (test_bonvoice/outbound_call.py pattern)."""
|
||||
tunnel_hosts = ("127.0.0.1:4040", "host.docker.internal:4040")
|
||||
for host in tunnel_hosts:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://{host}/api/tunnels", timeout=2) as resp:
|
||||
data = json.load(resp)
|
||||
for tunnel in data.get("tunnels", []):
|
||||
public = tunnel.get("public_url", "")
|
||||
if public.startswith("https://"):
|
||||
ngrok_host = public.removeprefix("https://")
|
||||
return f"wss://{ngrok_host}/api/v1/voice"
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def resolve_voice_relay_public_base() -> str:
|
||||
configured = settings.voice_relay_public_url.rstrip("/")
|
||||
if "localhost" in configured or "127.0.0.1" in configured:
|
||||
ngrok_base = get_ngrok_wss_base()
|
||||
if ngrok_base:
|
||||
logger.info("Using ngrok voice relay base: %s", ngrok_base)
|
||||
return ngrok_base
|
||||
return configured
|
||||
|
||||
|
||||
def unified_voicebot_url() -> str:
|
||||
"""Bonvoice outbound media URL (voicebotURL)."""
|
||||
return f"{resolve_voice_relay_public_base()}/ws/media"
|
||||
18
backend/app/voice/relay_config.py
Normal file
18
backend/app/voice/relay_config.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import PhoneNumber, VoiceSocketEndpoint
|
||||
|
||||
|
||||
def resolve_voice_socket(
|
||||
db: Session,
|
||||
*,
|
||||
phone_number: PhoneNumber,
|
||||
) -> VoiceSocketEndpoint | None:
|
||||
return db.scalar(
|
||||
select(VoiceSocketEndpoint).where(
|
||||
VoiceSocketEndpoint.phone_number_id == phone_number.id,
|
||||
VoiceSocketEndpoint.user_id == phone_number.user_id,
|
||||
VoiceSocketEndpoint.enabled.is_(True),
|
||||
)
|
||||
)
|
||||
50
backend/app/voice/session_store.py
Normal file
50
backend/app/voice/session_store.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from ..config import settings
|
||||
|
||||
_redis_client: redis.Redis | None = None
|
||||
|
||||
|
||||
def _session_key(session_token: str) -> str:
|
||||
return f"voice:session:{session_token}"
|
||||
|
||||
|
||||
async def get_redis() -> redis.Redis:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = redis.from_url(settings.redis_url, decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
async def save_session(session_token: str, data: dict[str, Any]) -> None:
|
||||
client = await get_redis()
|
||||
payload = dict(data)
|
||||
payload["session_token"] = session_token
|
||||
payload.setdefault("created_at", datetime.now(timezone.utc).isoformat())
|
||||
await client.set(_session_key(session_token), json.dumps(payload), ex=settings.voice_session_ttl_seconds)
|
||||
|
||||
|
||||
async def get_session(session_token: str) -> dict[str, Any] | None:
|
||||
client = await get_redis()
|
||||
raw = await client.get(_session_key(session_token))
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
async def update_session(session_token: str, **fields: Any) -> dict[str, Any] | None:
|
||||
session = await get_session(session_token)
|
||||
if not session:
|
||||
return None
|
||||
session.update(fields)
|
||||
await save_session(session_token, session)
|
||||
return session
|
||||
|
||||
|
||||
async def delete_session(session_token: str) -> None:
|
||||
client = await get_redis()
|
||||
await client.delete(_session_key(session_token))
|
||||
66
backend/app/voice/tunnel.py
Normal file
66
backend/app/voice/tunnel.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Transparent WebSocket frame relay — no parse, no audio processing."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import websockets
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def relay_text_websockets(left: WebSocket, right: WebSocket) -> None:
|
||||
stop = asyncio.Event()
|
||||
|
||||
async def forward(source: WebSocket, destination: WebSocket) -> None:
|
||||
try:
|
||||
while not stop.is_set():
|
||||
message = await source.receive_text()
|
||||
await destination.send_text(message)
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
stop.set()
|
||||
|
||||
await asyncio.gather(forward(left, right), forward(right, left))
|
||||
|
||||
|
||||
async def relay_provider_to_upstream(
|
||||
provider: WebSocket,
|
||||
upstream_url: str,
|
||||
*,
|
||||
initial_frames: list[str] | None = None,
|
||||
additional_headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Raw carrier ↔ customer upstream tunnel."""
|
||||
logger.info("Opening upstream tunnel to %s", upstream_url)
|
||||
async with websockets.connect(
|
||||
upstream_url,
|
||||
open_timeout=10,
|
||||
close_timeout=5,
|
||||
additional_headers=additional_headers,
|
||||
ping_interval=20,
|
||||
ping_timeout=20,
|
||||
max_queue=16,
|
||||
) as upstream:
|
||||
for frame in initial_frames or []:
|
||||
await upstream.send(frame)
|
||||
|
||||
async def provider_to_upstream() -> None:
|
||||
try:
|
||||
while True:
|
||||
raw = await provider.receive_text()
|
||||
await upstream.send(raw)
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
return
|
||||
|
||||
async def upstream_to_provider() -> None:
|
||||
try:
|
||||
while True:
|
||||
raw = await upstream.recv()
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8")
|
||||
await provider.send_text(raw)
|
||||
except (ConnectionClosed, WebSocketDisconnect, RuntimeError):
|
||||
return
|
||||
|
||||
await asyncio.gather(provider_to_upstream(), upstream_to_provider())
|
||||
52
backend/app/voice/validators.py
Normal file
52
backend/app/voice/validators.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import re
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import PhoneNumber, UserAccount
|
||||
|
||||
|
||||
def normalize_phone_digits(value: str) -> str:
|
||||
digits = re.sub(r"\D", "", value.strip())
|
||||
if len(digits) > 10 and digits.startswith("91"):
|
||||
digits = digits[2:]
|
||||
if len(digits) > 10:
|
||||
digits = digits[-10:]
|
||||
return digits
|
||||
|
||||
|
||||
def format_bonvoice_destination(value: str) -> str:
|
||||
digits = re.sub(r"\D", "", value.strip())
|
||||
if len(digits) == 10:
|
||||
return f"91{digits}"
|
||||
return digits
|
||||
|
||||
|
||||
def require_owned_did(db: Session, user: UserAccount, from_value: str) -> PhoneNumber:
|
||||
normalized = normalize_phone_digits(from_value)
|
||||
if len(normalized) != 10:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="from must be a valid 10-digit purchased number",
|
||||
)
|
||||
number = db.scalar(
|
||||
select(PhoneNumber).where(
|
||||
PhoneNumber.user_id == user.id,
|
||||
PhoneNumber.status == "assigned",
|
||||
PhoneNumber.phone_number == normalized,
|
||||
)
|
||||
)
|
||||
if not number:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="from must be a purchased number assigned to your account",
|
||||
)
|
||||
return number
|
||||
|
||||
def parse_user_id(value: str) -> UUID:
|
||||
try:
|
||||
return UUID(value)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
|
||||
52
backend/app/voice_auth.py
Normal file
52
backend/app/voice_auth.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import Cookie, Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .api_key_auth import _extract_api_key, parse_api_token, verify_api_token
|
||||
from .api_keys_service import KEY_PREFIX
|
||||
from .auth import COOKIE_NAME, decode_token
|
||||
from .database import get_db
|
||||
from .models import ApiKey, UserAccount
|
||||
|
||||
|
||||
def current_voice_user(
|
||||
urmi_session: str | None = Cookie(default=None, alias=COOKIE_NAME),
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-Api-Key"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserAccount:
|
||||
if urmi_session:
|
||||
payload = decode_token(urmi_session)
|
||||
try:
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session") from exc
|
||||
user = db.scalar(select(UserAccount).where(UserAccount.id == user_id))
|
||||
if user and user.status == "active":
|
||||
return user
|
||||
|
||||
token = _extract_api_key(authorization, x_api_key)
|
||||
if not token or not token.startswith(KEY_PREFIX):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
|
||||
parsed = parse_api_token(token)
|
||||
if parsed:
|
||||
key_id, _ = parsed
|
||||
api_key = db.scalar(
|
||||
select(ApiKey).where(ApiKey.id == key_id, ApiKey.revoked_at.is_(None))
|
||||
)
|
||||
if api_key and verify_api_token(token, api_key.key_hash):
|
||||
user = db.scalar(select(UserAccount).where(UserAccount.id == api_key.user_id))
|
||||
if user and user.status == "active":
|
||||
return user
|
||||
|
||||
active_keys = db.scalars(select(ApiKey).where(ApiKey.revoked_at.is_(None))).all()
|
||||
for api_key in active_keys:
|
||||
if verify_api_token(token, api_key.key_hash):
|
||||
user = db.scalar(select(UserAccount).where(UserAccount.id == api_key.user_id))
|
||||
if user and user.status == "active":
|
||||
return user
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
BIN
backend/assets/dlt-loa-template.docx
Normal file
BIN
backend/assets/dlt-loa-template.docx
Normal file
Binary file not shown.
BIN
backend/assets/kyc-template.docx
Normal file
BIN
backend/assets/kyc-template.docx
Normal file
Binary file not shown.
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal 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
|
||||
100
backend/scripts/verify_logging.py
Normal file
100
backend/scripts/verify_logging.py
Normal 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()
|
||||
86
database/init/001_bootstrap.sql
Normal file
86
database/init/001_bootstrap.sql
Normal 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
127
docker-compose.yml
Normal 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
54
docs/BATCH_JOBS_TODO.md
Normal 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
260
docs/ENVIRONMENT.md
Normal 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
7
frontend/.env.example
Normal 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
24
frontend/.gitignore
vendored
Normal 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
8
frontend/.oxlintrc.json
Normal 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
9
frontend/Dockerfile
Normal 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
32
frontend/README.md
Normal 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
15
frontend/index.html
Normal 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
1635
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
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
24
frontend/public/icons.svg
Normal 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 |
BIN
frontend/public/urmi-logo.png
Normal file
BIN
frontend/public/urmi-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 MiB |
184
frontend/src/App.css
Normal file
184
frontend/src/App.css
Normal 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
77
frontend/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
146
frontend/src/components/AppShell.tsx
Normal file
146
frontend/src/components/AppShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
417
frontend/src/features/admin/AdminPortal.tsx
Normal file
417
frontend/src/features/admin/AdminPortal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
frontend/src/features/agent/UrmiOtpDocsPage.tsx
Normal file
100
frontend/src/features/agent/UrmiOtpDocsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
237
frontend/src/features/agent/UrmiOtpPage.tsx
Normal file
237
frontend/src/features/agent/UrmiOtpPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
331
frontend/src/features/analytics/AnalyticsPage.tsx
Normal file
331
frontend/src/features/analytics/AnalyticsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
frontend/src/features/auth/AccountSettings.tsx
Normal file
57
frontend/src/features/auth/AccountSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
frontend/src/features/auth/AuthContext.tsx
Normal file
25
frontend/src/features/auth/AuthContext.tsx
Normal 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>
|
||||
}
|
||||
89
frontend/src/features/auth/AuthPage.tsx
Normal file
89
frontend/src/features/auth/AuthPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
frontend/src/features/auth/ProtectedRoute.tsx
Normal file
11
frontend/src/features/auth/ProtectedRoute.tsx
Normal 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 />
|
||||
}
|
||||
17
frontend/src/features/auth/auth-context.ts
Normal file
17
frontend/src/features/auth/auth-context.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue