commit efa5080897fa0e38cd8faec7c7f239e744f537da Author: debu-2 Date: Fri Oct 2 11:22:40 2026 +0530 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f5f81fe --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0792118 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CPaaS_WEBSOCKET_RELAY_ARCHITECTURE.md b/CPaaS_WEBSOCKET_RELAY_ARCHITECTURE.md new file mode 100644 index 0000000..35b54ec --- /dev/null +++ b/CPaaS_WEBSOCKET_RELAY_ARCHITECTURE.md @@ -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": "", + "call_id": "", + "from": "", + "to": "" + } +} +``` + +**Media (bidirectional)** + +```json +{ + "event": "media", + "stream_id": "", + "media": { + "packet_id": 1, + "timestamp": 0, + "payload": "" + } +} +``` + +- 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": "" +} +``` + +**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 +``` + +```json +{ + "autocallType": "5", + "destination": "", + "legACallerID": "", + "eventID": "", + "voicebotProvider": "BONVOICE", + "voicebotURL": "wss:///v1/media/" +} +``` + +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 ` | +| **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:///v1/media/inbound` | +| `voicebotURL` in outbound API | `wss:///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 ` 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:///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:///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:///api/v1/voice/ws/media` | Bonvoice outbound voicebotURL | IP allowlist + pending call match on `start` | +| `wss:///api/v1/voice/media/{session_token}` | Legacy alias | Same handler | + +Configure Bonvoice outbound API `voicebotURL`: + +``` +wss:///api/v1/voice/ws/media +``` + +Set `VOICE_RELAY_PUBLIC_URL=wss:///api/v1/voice` in production. + +### 24.3 Console audio bridge (outbound testing) + +| WebSocket | Purpose | +|-----------|---------| +| `wss:///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.* diff --git a/GENERIC_TEMPLATE_SMS.md b/GENERIC_TEMPLATE_SMS.md new file mode 100644 index 0000000..bc72056 --- /dev/null +++ b/GENERIC_TEMPLATE_SMS.md @@ -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. diff --git a/KYC Form - Pvt Ltd Client.docx b/KYC Form - Pvt Ltd Client.docx new file mode 100644 index 0000000..9ce8c55 Binary files /dev/null and b/KYC Form - Pvt Ltd Client.docx differ diff --git a/OTP_SMS_IMPLEMENTATION.md b/OTP_SMS_IMPLEMENTATION.md new file mode 100644 index 0000000..9c521f1 --- /dev/null +++ b/OTP_SMS_IMPLEMENTATION.md @@ -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` | diff --git a/README.md b/README.md new file mode 100644 index 0000000..5af74d4 --- /dev/null +++ b/README.md @@ -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. diff --git a/Updated LOA.docx b/Updated LOA.docx new file mode 100644 index 0000000..e63079f Binary files /dev/null and b/Updated LOA.docx differ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..1766d19 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5dedc48 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..a0e45e6 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""Urmi control-plane API.""" diff --git a/backend/app/analytics_service.py b/backend/app/analytics_service.py new file mode 100644 index 0000000..802521a --- /dev/null +++ b/backend/app/analytics_service.py @@ -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, + } diff --git a/backend/app/api_key_auth.py b/backend/app/api_key_auth.py new file mode 100644 index 0000000..3791011 --- /dev/null +++ b/backend/app/api_key_auth.py @@ -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 diff --git a/backend/app/api_keys_service.py b/backend/app/api_keys_service.py new file mode 100644 index 0000000..c46a66f --- /dev/null +++ b/backend/app/api_keys_service.py @@ -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) diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..96e9f5e --- /dev/null +++ b/backend/app/auth.py @@ -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 diff --git a/backend/app/campaign_csv_service.py b/backend/app/campaign_csv_service.py new file mode 100644 index 0000000..455c769 --- /dev/null +++ b/backend/app/campaign_csv_service.py @@ -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] diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..4570cdb --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..6bb57e0 --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/dlt_constants.py b/backend/app/dlt_constants.py new file mode 100644 index 0000000..719419c --- /dev/null +++ b/backend/app/dlt_constants.py @@ -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",) diff --git a/backend/app/dlt_service.py b/backend/app/dlt_service.py new file mode 100644 index 0000000..efa483b --- /dev/null +++ b/backend/app/dlt_service.py @@ -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" diff --git a/backend/app/endpoint_security.py b/backend/app/endpoint_security.py new file mode 100644 index 0000000..2f78c3c --- /dev/null +++ b/backend/app/endpoint_security.py @@ -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()}" diff --git a/backend/app/kyc_constants.py b/backend/app/kyc_constants.py new file mode 100644 index 0000000..2e97478 --- /dev/null +++ b/backend/app/kyc_constants.py @@ -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"} diff --git a/backend/app/kyc_service.py b/backend/app/kyc_service.py new file mode 100644 index 0000000..6f50c80 --- /dev/null +++ b/backend/app/kyc_service.py @@ -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 diff --git a/backend/app/kyc_validators.py b/backend/app/kyc_validators.py new file mode 100644 index 0000000..866c0c7 --- /dev/null +++ b/backend/app/kyc_validators.py @@ -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 diff --git a/backend/app/log_service.py b/backend/app/log_service.py new file mode 100644 index 0000000..46bd245 --- /dev/null +++ b/backend/app/log_service.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..af021d5 --- /dev/null +++ b/backend/app/main.py @@ -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 + ], + } diff --git a/backend/app/message_send_service.py b/backend/app/message_send_service.py new file mode 100644 index 0000000..ce051d4 --- /dev/null +++ b/backend/app/message_send_service.py @@ -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 diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..2d7d26b --- /dev/null +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/otp_service.py b/backend/app/otp_service.py new file mode 100644 index 0000000..ebbd32f --- /dev/null +++ b/backend/app/otp_service.py @@ -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, + } diff --git a/backend/app/phone_number_validators.py b/backend/app/phone_number_validators.py new file mode 100644 index 0000000..f6617e6 --- /dev/null +++ b/backend/app/phone_number_validators.py @@ -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) diff --git a/backend/app/routes_admin.py b/backend/app/routes_admin.py new file mode 100644 index 0000000..871cd8d --- /dev/null +++ b/backend/app/routes_admin.py @@ -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) diff --git a/backend/app/routes_analytics.py b/backend/app/routes_analytics.py new file mode 100644 index 0000000..30a5d59 --- /dev/null +++ b/backend/app/routes_analytics.py @@ -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] + ) diff --git a/backend/app/routes_api_keys.py b/backend/app/routes_api_keys.py new file mode 100644 index 0000000..97b09b4 --- /dev/null +++ b/backend/app/routes_api_keys.py @@ -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() diff --git a/backend/app/routes_auth.py b/backend/app/routes_auth.py new file mode 100644 index 0000000..4a08cc6 --- /dev/null +++ b/backend/app/routes_auth.py @@ -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 diff --git a/backend/app/routes_campaigns.py b/backend/app/routes_campaigns.py new file mode 100644 index 0000000..0da07f5 --- /dev/null +++ b/backend/app/routes_campaigns.py @@ -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.", + } diff --git a/backend/app/routes_compliance.py b/backend/app/routes_compliance.py new file mode 100644 index 0000000..0f618f8 --- /dev/null +++ b/backend/app/routes_compliance.py @@ -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"], + } diff --git a/backend/app/routes_dlt.py b/backend/app/routes_dlt.py new file mode 100644 index 0000000..2e750ac --- /dev/null +++ b/backend/app/routes_dlt.py @@ -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) diff --git a/backend/app/routes_integrations.py b/backend/app/routes_integrations.py new file mode 100644 index 0000000..dd25968 --- /dev/null +++ b/backend/app/routes_integrations.py @@ -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 '.' 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_", + "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() diff --git a/backend/app/routes_kyc.py b/backend/app/routes_kyc.py new file mode 100644 index 0000000..9315c32 --- /dev/null +++ b/backend/app/routes_kyc.py @@ -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) diff --git a/backend/app/routes_logs.py b/backend/app/routes_logs.py new file mode 100644 index 0000000..b81a075 --- /dev/null +++ b/backend/app/routes_logs.py @@ -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, + } diff --git a/backend/app/routes_messages.py b/backend/app/routes_messages.py new file mode 100644 index 0000000..89ce331 --- /dev/null +++ b/backend/app/routes_messages.py @@ -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_", + "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"], + } diff --git a/backend/app/routes_messaging.py b/backend/app/routes_messaging.py new file mode 100644 index 0000000..8dd9ec3 --- /dev/null +++ b/backend/app/routes_messaging.py @@ -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 diff --git a/backend/app/routes_numbers.py b/backend/app/routes_numbers.py new file mode 100644 index 0000000..e43a541 --- /dev/null +++ b/backend/app/routes_numbers.py @@ -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) diff --git a/backend/app/routes_otp.py b/backend/app/routes_otp.py new file mode 100644 index 0000000..f7d01ec --- /dev/null +++ b/backend/app/routes_otp.py @@ -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) diff --git a/backend/app/routes_urmiotp.py b/backend/app/routes_urmiotp.py new file mode 100644 index 0000000..6374f86 --- /dev/null +++ b/backend/app/routes_urmiotp.py @@ -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) diff --git a/backend/app/routes_voice.py b/backend/app/routes_voice.py new file mode 100644 index 0000000..595261e --- /dev/null +++ b/backend/app/routes_voice.py @@ -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_", + "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:///api/v1/voice/ws/media (not https://)", + "received": text[:200] if text else None, + } diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..360143e --- /dev/null +++ b/backend/app/schemas.py @@ -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] diff --git a/backend/app/sender_validators.py b/backend/app/sender_validators.py new file mode 100644 index 0000000..4b2d8c1 --- /dev/null +++ b/backend/app/sender_validators.py @@ -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 diff --git a/backend/app/sms_webhooks.py b/backend/app/sms_webhooks.py new file mode 100644 index 0000000..7d777ec --- /dev/null +++ b/backend/app/sms_webhooks.py @@ -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, + ) diff --git a/backend/app/template_message_validator.py b/backend/app/template_message_validator.py new file mode 100644 index 0000000..0c87622 --- /dev/null +++ b/backend/app/template_message_validator.py @@ -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 diff --git a/backend/app/template_validators.py b/backend/app/template_validators.py new file mode 100644 index 0000000..a19431f --- /dev/null +++ b/backend/app/template_validators.py @@ -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 diff --git a/backend/app/ultronsms_client.py b/backend/app/ultronsms_client.py new file mode 100644 index 0000000..5b9ae4e --- /dev/null +++ b/backend/app/ultronsms_client.py @@ -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, + ) diff --git a/backend/app/urmiotp_catalog.py b/backend/app/urmiotp_catalog.py new file mode 100644 index 0000000..0377b86 --- /dev/null +++ b/backend/app/urmiotp_catalog.py @@ -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"}, + }, +} diff --git a/backend/app/voice/__init__.py b/backend/app/voice/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/voice/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/voice/audio_utils.py b/backend/app/voice/audio_utils.py new file mode 100644 index 0000000..b2f1a0f --- /dev/null +++ b/backend/app/voice/audio_utils.py @@ -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 diff --git a/backend/app/voice/bonvoice_client.py b/backend/app/voice/bonvoice_client.py new file mode 100644 index 0000000..58d9b90 --- /dev/null +++ b/backend/app/voice/bonvoice_client.py @@ -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 diff --git a/backend/app/voice/bonvoice_media.py b/backend/app/voice/bonvoice_media.py new file mode 100644 index 0000000..bd0a611 --- /dev/null +++ b/backend/app/voice/bonvoice_media.py @@ -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() diff --git a/backend/app/voice/call_resolver.py b/backend/app/voice/call_resolver.py new file mode 100644 index 0000000..ea75b80 --- /dev/null +++ b/backend/app/voice/call_resolver.py @@ -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 diff --git a/backend/app/voice/call_service.py b/backend/app/voice/call_service.py new file mode 100644 index 0000000..7fee894 --- /dev/null +++ b/backend/app/voice/call_service.py @@ -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 diff --git a/backend/app/voice/presence.py b/backend/app/voice/presence.py new file mode 100644 index 0000000..a90ad64 --- /dev/null +++ b/backend/app/voice/presence.py @@ -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() diff --git a/backend/app/voice/public_url.py b/backend/app/voice/public_url.py new file mode 100644 index 0000000..9a5849d --- /dev/null +++ b/backend/app/voice/public_url.py @@ -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" diff --git a/backend/app/voice/relay_config.py b/backend/app/voice/relay_config.py new file mode 100644 index 0000000..e5580d8 --- /dev/null +++ b/backend/app/voice/relay_config.py @@ -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), + ) + ) diff --git a/backend/app/voice/session_store.py b/backend/app/voice/session_store.py new file mode 100644 index 0000000..4cb5b1c --- /dev/null +++ b/backend/app/voice/session_store.py @@ -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)) diff --git a/backend/app/voice/tunnel.py b/backend/app/voice/tunnel.py new file mode 100644 index 0000000..9a70fcb --- /dev/null +++ b/backend/app/voice/tunnel.py @@ -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()) diff --git a/backend/app/voice/validators.py b/backend/app/voice/validators.py new file mode 100644 index 0000000..c201395 --- /dev/null +++ b/backend/app/voice/validators.py @@ -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 diff --git a/backend/app/voice_auth.py b/backend/app/voice_auth.py new file mode 100644 index 0000000..b34e090 --- /dev/null +++ b/backend/app/voice_auth.py @@ -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") diff --git a/backend/assets/dlt-loa-template.docx b/backend/assets/dlt-loa-template.docx new file mode 100644 index 0000000..e63079f Binary files /dev/null and b/backend/assets/dlt-loa-template.docx differ diff --git a/backend/assets/kyc-template.docx b/backend/assets/kyc-template.docx new file mode 100644 index 0000000..9ce8c55 Binary files /dev/null and b/backend/assets/kyc-template.docx differ diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6e6e083 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/scripts/verify_logging.py b/backend/scripts/verify_logging.py new file mode 100644 index 0000000..a193f2f --- /dev/null +++ b/backend/scripts/verify_logging.py @@ -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() diff --git a/database/init/001_bootstrap.sql b/database/init/001_bootstrap.sql new file mode 100644 index 0000000..943920c --- /dev/null +++ b/database/init/001_bootstrap.sql @@ -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); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5eacec3 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/BATCH_JOBS_TODO.md b/docs/BATCH_JOBS_TODO.md new file mode 100644 index 0000000..c709091 --- /dev/null +++ b/docs/BATCH_JOBS_TODO.md @@ -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 diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md new file mode 100644 index 0000000..f25b281 --- /dev/null +++ b/docs/ENVIRONMENT.md @@ -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 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..3e5f4ef --- /dev/null +++ b/frontend/.env.example @@ -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 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -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 }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e5448c4 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..43bf259 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Urmi Console + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..51ef12c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1635 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.79.0.tgz", + "integrity": "sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.79.0.tgz", + "integrity": "sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.79.0.tgz", + "integrity": "sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.79.0.tgz", + "integrity": "sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.79.0.tgz", + "integrity": "sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.79.0.tgz", + "integrity": "sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.79.0.tgz", + "integrity": "sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.79.0.tgz", + "integrity": "sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.79.0.tgz", + "integrity": "sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.79.0.tgz", + "integrity": "sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.79.0.tgz", + "integrity": "sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.79.0.tgz", + "integrity": "sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.79.0.tgz", + "integrity": "sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.79.0.tgz", + "integrity": "sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.79.0.tgz", + "integrity": "sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.79.0.tgz", + "integrity": "sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.79.0.tgz", + "integrity": "sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.79.0.tgz", + "integrity": "sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.79.0.tgz", + "integrity": "sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-toolkit": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz", + "integrity": "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==" + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.33.0.tgz", + "integrity": "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.79.0.tgz", + "integrity": "sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==", + "dev": true, + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.79.0", + "@oxlint/binding-android-arm64": "1.79.0", + "@oxlint/binding-darwin-arm64": "1.79.0", + "@oxlint/binding-darwin-x64": "1.79.0", + "@oxlint/binding-freebsd-x64": "1.79.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.79.0", + "@oxlint/binding-linux-arm-musleabihf": "1.79.0", + "@oxlint/binding-linux-arm64-gnu": "1.79.0", + "@oxlint/binding-linux-arm64-musl": "1.79.0", + "@oxlint/binding-linux-ppc64-gnu": "1.79.0", + "@oxlint/binding-linux-riscv64-gnu": "1.79.0", + "@oxlint/binding-linux-riscv64-musl": "1.79.0", + "@oxlint/binding-linux-s390x-gnu": "1.79.0", + "@oxlint/binding-linux-x64-gnu": "1.79.0", + "@oxlint/binding-linux-x64-musl": "1.79.0", + "@oxlint/binding-openharmony-arm64": "1.79.0", + "@oxlint/binding-win32-arm64-msvc": "1.79.0", + "@oxlint/binding-win32-ia32-msvc": "1.79.0", + "@oxlint/binding-win32-x64-msvc": "1.79.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==" + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "dev": true, + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e9c5cf8 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/urmi-logo.png b/frontend/public/urmi-logo.png new file mode 100644 index 0000000..1a1d5db Binary files /dev/null and b/frontend/public/urmi-logo.png differ diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -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); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b2fb2bf --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( +
+ Urmi workspace +

Module workspace

+

This product area is ready for the next iteration.

+
+ ) +} + +export default function App() { + return ( + + + } /> + } /> + } /> + } /> + }> + }> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } /> + + + ) +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..3cd3187 --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -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 ( +
+ + + {open && + + + Production + + Operational + +
+ + {accountOpen &&
+
{user?.name}{user?.email}
+ setAccountOpen(false)}> Sign-in & security + +
} +
+ +
+
+ + ) +} + +export function AppShell() { + return ( + + + + ) +} diff --git a/frontend/src/features/admin/AdminPortal.tsx b/frontend/src/features/admin/AdminPortal.tsx new file mode 100644 index 0000000..3b9d807 --- /dev/null +++ b/frontend/src/features/admin/AdminPortal.tsx @@ -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 = { + business_pan_card: 'Business PAN card', + business_documents: 'Business documents', + gst_certificate: 'GST certificate', + kyc_form: 'Signed KYC form', +} + +const DLT_DOC_LABELS: Record = { + 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 ( +
+
+
+ +

Urmi Admin

+

Global compliance portal

+
+ + + {error &&

{error}

} + +
+
+ ) +} + +function AdminCompliance() { + const [tab, setTab] = useState<'kyc' | 'dlt' | 'senders' | 'templates' | 'urmiotp' | 'numbers'>('kyc') + const [kycItems, setKycItems] = useState([]) + const [dltItems, setDltItems] = useState([]) + const [senderItems, setSenderItems] = useState([]) + const [templateItems, setTemplateItems] = useState([]) + const [otpProjects, setOtpProjects] = useState([]) + const [phoneNumbers, setPhoneNumbers] = useState([]) + 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
Loading compliance queue…
+ + return ( +
+
+ Global compliance +

Compliance review

+

Approve KYC, DLT, messaging configuration, and UrmiOTP projects before they go live.

+
+ +
+ + + + + + +
+ + {tab === 'kyc' && ( + kycItems.length === 0 ?

No KYC submissions yet.

: kycItems.map((item) => ( +
+
+

{item.user.name}

{item.user.email} · PAN {item.pan_number} · GST {item.gst_number}

+ {item.completed ? 'KYC completed' : item.status} +
+ {item.documents.map((doc) => ( +
+
{KYC_DOC_LABELS[doc.document_type] ?? doc.document_type}{doc.original_filename ?? 'Missing'}
+ {doc.status} + {doc.status === 'pending' && ( +
+ + +
+ )} +
+ ))} +
+ )) + )} + + {tab === 'dlt' && ( + dltItems.length === 0 ?

No DLT submissions yet.

: dltItems.map((item) => ( +
+
+

{item.user.name}

{item.user.email}

+ {item.completed ? 'DLT completed' : item.status} +
+ {item.documents.map((doc) => ( +
+
+ {DLT_DOC_LABELS[doc.document_type] ?? doc.document_type} + + {doc.document_type === 'pe_tm_chain_binding' + ? `Chain ID ${PE_TM_CHAIN_BINDING_ID}` + : (doc.original_filename ?? 'Missing')} + +
+ {doc.status} + {doc.status === 'pending' && ( +
+ + +
+ )} +
+ ))} +
+ )) + )} + + {tab === 'senders' && ( + senderItems.length === 0 ?

No sender ID requests yet.

: senderItems.map((item) => ( +
+
+
+

{item.sender_id}

+

{item.user.name} · {item.user.email}

+
+ {item.status} +
+
+
+ {item.sender_type} + DLT Entity {item.dlt_entity_id} + {item.purpose} +
+ {item.status === 'pending' && ( +
+ + +
+ )} +
+
+ )) + )} + + {tab === 'templates' && ( + templateItems.length === 0 ?

No template requests yet.

: templateItems.map((item) => ( +
+
+
+

{item.template_name}

+

{item.user.name} · {item.user.email} · Sender {item.sender_id}

+
+ {item.status} +
+
+
+ DLT {item.dlt_template_id} + {item.template_text} +
+ {item.status === 'pending' && ( +
+ + +
+ )} +
+
+ )) + )} + + {tab === 'urmiotp' && ( + otpProjects.length === 0 + ?

No UrmiOTP project requests yet.

+ : otpProjects.map((project) => ( +
+
+
+

{project.project_name}

+

{project.user.name} · {project.user.email} · {project.app_type} · label: {project.message_label}

+
+ {project.status} +
+
+ {project.app_url} +

{project.purpose}

+ Terms accepted {new Date(project.terms_accepted_at).toLocaleString()} + {project.status === 'pending' && ( +
+ + +
+ )} +
+
+ )) + )} + + {tab === 'numbers' && ( + <> +
+
+
+

Add phone number

+

List in Buy Numbers, or assign directly to a user for My Numbers.

+
+ +
+
+ + + + + + {phoneError &&

{phoneError}

} + +
+
+ + {phoneNumbers.length === 0 ? ( +

No phone numbers yet.

+ ) : phoneNumbers.map((item) => ( +
+
+
+

{item.phone_number.length === 10 ? `${item.phone_number.slice(0, 5)} ${item.phone_number.slice(5)}` : item.phone_number}

+

+ 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'} +

+
+ {item.status} +
+
+
+ {item.acquisition_type ?? 'catalog'} + {item.assigned_at ? `Assigned ${new Date(item.assigned_at).toLocaleString()}` : 'Not assigned yet'} +
+ {item.status === 'assigned' && ( +
+ +
+ )} +
+
+ ))} + + )} +
+ ) +} + +export function AdminApp() { + const [authenticated, setAuthenticated] = useState(null) + + useEffect(() => { + adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false)) + }, []) + + async function logout() { + await adminApi.logout() + setAuthenticated(false) + } + + if (authenticated === null) return
Checking admin session…
+ + return ( +
+
+
UrmiAdmin · Global compliance
+ {authenticated && } +
+
+ {authenticated ? : setAuthenticated(true)} />} +
+
+ ) +} diff --git a/frontend/src/features/agent/UrmiOtpDocsPage.tsx b/frontend/src/features/agent/UrmiOtpDocsPage.tsx new file mode 100644 index 0000000..1870103 --- /dev/null +++ b/frontend/src/features/agent/UrmiOtpDocsPage.tsx @@ -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(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
Loading API documentation…
+ if (!docs) return

{error}

+ + 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 ( +
+ +
+ UrmiOTP · {docs.project.project_name} +

API documentation

+

+ Message label: {docs.project.message_label} + — your product identification in each message. URMI branding remains unchanged. +

+

Unified verification templates for login, recovery, account changes, transactions, and devices.

+
+ +
+
+

Generate verification OTP

{docs.api.summary}

+ {docs.api.method} +
+
Endpoint{docs.api.path}
+
+ + This is documentation only. The UrmiOTP API is not enabled yet. +
+
+ {Object.entries(docs.api.parameters).map(([name, description]) => ( +
{name}{description}
+ ))} +
+
+ Request body + +
+
{requestExample}
+
+ +
+ {docs.templates.map((template, index) => ( +
+
{String(index + 1).padStart(2, '0')}
+
+

{template.name}

{template.type}

+ +
+
+ Approved content +

{template.content}

+
+
+ Sample +

{template.sample}

+
+
+
+ Use for +
    {template.uses.map((use) =>
  • {use}
  • )}
+
+
+ Variables +
    {template.variables.map((variable) =>
  1. {variable}
  2. )}
+
+
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/features/agent/UrmiOtpPage.tsx b/frontend/src/features/agent/UrmiOtpPage.tsx new file mode 100644 index 0000000..d7bf4e0 --- /dev/null +++ b/frontend/src/features/agent/UrmiOtpPage.tsx @@ -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 ( +
+
event.stopPropagation()}> +
+

Register an UrmiOTP project

+ +
+
+ + +
+ Application type + + +
+ +