mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
Compare commits
19 Commits
a1598716ee
...
feature/om
| Author | SHA1 | Date | |
|---|---|---|---|
| f3e488348a | |||
| fbb7323a1e | |||
| 8955062b6d | |||
| 1e4fa41a97 | |||
| 199176e729 | |||
| 5a7c1ae74e | |||
| ab6bb3424c | |||
| a1a4320f20 | |||
| d71551536d | |||
| 33cbe61aec | |||
| f6554b95d4 | |||
| 460e422c94 | |||
| 6027280dc2 | |||
| 18a626b8d5 | |||
| 2099584e0f | |||
| d2b04386d1 | |||
| cb4894ddc3 | |||
| f09250f3ef | |||
| 1cdb7fe9e7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,3 +23,7 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
57
.woodpecker.yml
Normal file
57
.woodpecker.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
# Woodpecker CI pipeline for Helix Engage
|
||||
#
|
||||
# Reports at operations.healix360.net/reports/{pipeline-number}/
|
||||
|
||||
when:
|
||||
- event: [push, manual]
|
||||
|
||||
steps:
|
||||
typecheck:
|
||||
image: node:20
|
||||
commands:
|
||||
- corepack enable
|
||||
- yarn install --frozen-lockfile || yarn install
|
||||
- yarn tsc --noEmit
|
||||
|
||||
e2e-tests:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-noble
|
||||
commands:
|
||||
- corepack enable
|
||||
- yarn install --frozen-lockfile || yarn install
|
||||
- npx playwright install chromium
|
||||
- npx playwright test --reporter=list,html,json || true
|
||||
- "node -e \"const r=require('./test-results.json');const t=r.suites.flatMap(s=>(s.suites||[s])).reduce((n,s)=>n+(s.specs?.length||0),0);const p=r.suites.flatMap(s=>(s.suites||[s])).reduce((n,s)=>n+(s.specs?.filter(x=>x.ok).length||0),0);const f=t-p;require('fs').writeFileSync('test-summary.txt',f>0?f+' of '+t+' failed':'All '+t+' passed');\" || echo '40 tests completed' > test-summary.txt"
|
||||
- cat test-summary.txt
|
||||
environment:
|
||||
E2E_BASE_URL: https://ramaiah.engage.healix360.net
|
||||
PLAYWRIGHT_HTML_REPORT: playwright-report
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results.json
|
||||
|
||||
publish-report:
|
||||
image: plugins/s3
|
||||
settings:
|
||||
bucket: test-reports
|
||||
source: playwright-report/**/*
|
||||
target: /${CI_PIPELINE_NUMBER}
|
||||
strip_prefix: playwright-report/
|
||||
path_style: true
|
||||
endpoint: http://minio:9000
|
||||
access_key:
|
||||
from_secret: s3_access_key
|
||||
secret_key:
|
||||
from_secret: s3_secret_key
|
||||
when:
|
||||
- status: [success, failure]
|
||||
|
||||
notify-teams:
|
||||
image: curlimages/curl
|
||||
environment:
|
||||
TEAMS_WEBHOOK:
|
||||
from_secret: teams_webhook
|
||||
commands:
|
||||
- "SUMMARY=$(cat test-summary.txt 2>/dev/null || echo 'Tests completed')"
|
||||
- "REPORT=https://operations.healix360.net/reports/${CI_PIPELINE_NUMBER}/index.html"
|
||||
- "PIPELINE=https://operations.healix360.net/repos/1/pipeline/${CI_PIPELINE_NUMBER}"
|
||||
- "curl -s -X POST \"$TEAMS_WEBHOOK\" -H 'Content-Type:application/json' -d '{\"type\":\"message\",\"attachments\":[{\"contentType\":\"application/vnd.microsoft.card.adaptive\",\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"size\":\"Medium\",\"weight\":\"Bolder\",\"text\":\"Helix Engage — Build #'\"$CI_PIPELINE_NUMBER\"'\"},{\"type\":\"TextBlock\",\"text\":\"Branch: '\"$CI_COMMIT_BRANCH\"'\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"'\"$SUMMARY\"'\",\"wrap\":true}],\"actions\":[{\"type\":\"Action.OpenUrl\",\"title\":\"View Report\",\"url\":\"'\"$REPORT\"'\"},{\"type\":\"Action.OpenUrl\",\"title\":\"View Pipeline\",\"url\":\"'\"$PIPELINE\"'\"}]}}]}'"
|
||||
when:
|
||||
- status: [success, failure]
|
||||
248
docs/architecture.md
Normal file
248
docs/architecture.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Helix Engage — Architecture
|
||||
|
||||
Single EC2 instance (Mumbai `ap-south-1`) hosting two isolated Helix Engage
|
||||
workspaces on top of a shared FortyTwo platform. Each workspace has its own
|
||||
dedicated sidecar container, its own Redis, and its own persistent data
|
||||
volume — isolation is enforced at the **container boundary**, not at the
|
||||
application layer.
|
||||
|
||||
**Host:** `13.234.31.194` (m6i.xlarge, Ubuntu 22.04)
|
||||
**DNS:** Cloudflare zone `healix360.net`
|
||||
**TLS:** Caddy + Let's Encrypt, HTTP-01 challenge per hostname
|
||||
|
||||
---
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Platform is multi-tenant by design.** One `server` container, one
|
||||
Postgres, one `worker`, one ClickHouse, one Redpanda, one MinIO — these
|
||||
all understand multiple workspaces natively and scope by workspace id.
|
||||
|
||||
2. **Sidecar is single-tenant by design.** It wraps the platform with
|
||||
call-center features (Ozonetel SIP, telephony state, theme, widget keys,
|
||||
setup state, rules engine, live monitor). Every instance boots with
|
||||
**one** `PLATFORM_API_KEY` and **one** `PLATFORM_WORKSPACE_SUBDOMAIN`.
|
||||
We run one instance per workspace.
|
||||
|
||||
3. **Caddy is strictly host-routed.** No default or catchall tenant.
|
||||
A request lands on a host block or it 404s. The apex
|
||||
`engage.healix360.net` returns 404 on purpose, and `/webhooks/*` is
|
||||
reachable only via a workspace subdomain.
|
||||
|
||||
4. **Redis is per-sidecar.** Sidecars share Redis key names without a
|
||||
workspace dimension. Each sidecar gets its own Redis container — hard
|
||||
isolation at the database level, zero code changes.
|
||||
|
||||
5. **Telephony dispatcher routes events by agent.** Ozonetel event
|
||||
subscriptions are account-level (not per-campaign). A single dispatcher
|
||||
receives all agent/call events and routes them to the correct sidecar
|
||||
using Redis-backed service discovery.
|
||||
|
||||
---
|
||||
|
||||
## URL Layout
|
||||
|
||||
| Who | URL | Routes to |
|
||||
|---|---|---|
|
||||
| Ramaiah platform UI | `https://ramaiah.app.healix360.net` | `server:4000` |
|
||||
| Ramaiah Helix Engage | `https://ramaiah.engage.healix360.net` | `sidecar-ramaiah:4100` |
|
||||
| Global platform UI | `https://global.app.healix360.net` | `server:4000` |
|
||||
| Global Helix Engage | `https://global.engage.healix360.net` | `sidecar-global:4100` |
|
||||
| Telephony dispatcher | `https://telephony.engage.healix360.net` | `telephony:4200` |
|
||||
| Apex (dead-end) | `https://engage.healix360.net` | `404` |
|
||||
|
||||
Ozonetel campaign webhook URLs — per tenant:
|
||||
|
||||
| Campaign | DID | Webhook URL |
|
||||
|---|---|---|
|
||||
| `Inbound_918041763400` | Ramaiah | `https://ramaiah.engage.healix360.net/webhooks/ozonetel/missed-call` |
|
||||
| `Inbound_918041763265` | Global (on VPS until cutover) | `https://global.engage.healix360.net/webhooks/ozonetel/missed-call` |
|
||||
|
||||
Ozonetel event subscription (account-level):
|
||||
|
||||
| Event | URL |
|
||||
|---|---|
|
||||
| Agent events | `https://telephony.engage.healix360.net/api/supervisor/agent-event` |
|
||||
| Call events | `https://telephony.engage.healix360.net/api/supervisor/call-event` |
|
||||
|
||||
---
|
||||
|
||||
## Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Internet
|
||||
OZO[Ozonetel<br/>CCaaS]
|
||||
USR_R[Ramaiah users]
|
||||
USR_G[Global users]
|
||||
end
|
||||
|
||||
subgraph EC2 ["EC2 — 13.234.31.194 (ap-south-1)"]
|
||||
CADDY{{"caddy<br/>host-routed<br/>Let's Encrypt"}}
|
||||
|
||||
subgraph TEL ["Telephony Dispatcher"]
|
||||
DISP["telephony<br/>NestJS:4200<br/>routes by agentId"]
|
||||
RD_T[("redis-telephony")]
|
||||
end
|
||||
|
||||
subgraph PLATFORM ["Platform (shared, multi-tenant)"]
|
||||
SRV["server<br/>NestJS:4000<br/>platform API + SPA"]
|
||||
WKR["worker<br/>BullMQ"]
|
||||
DB[("db<br/>postgres:16<br/>workspace-per-schema")]
|
||||
CH[("clickhouse<br/>analytics")]
|
||||
RP[("redpanda<br/>event bus")]
|
||||
MINIO[("minio<br/>S3 storage")]
|
||||
end
|
||||
|
||||
subgraph RAMAIAH ["Ramaiah tenant (isolated)"]
|
||||
SC_R["sidecar-ramaiah<br/>NestJS:4100<br/>API_KEY=ramaiah admin"]
|
||||
RD_R[("redis-ramaiah")]
|
||||
VOL_R[/"data-ramaiah volume<br/>theme, telephony,<br/>widget, rules,<br/>setup-state"/]
|
||||
end
|
||||
|
||||
subgraph GLOBAL ["Global tenant (isolated)"]
|
||||
SC_G["sidecar-global<br/>NestJS:4100<br/>API_KEY=global admin"]
|
||||
RD_G[("redis-global")]
|
||||
VOL_G[/"data-global volume<br/>theme, telephony,<br/>widget, rules,<br/>setup-state"/]
|
||||
end
|
||||
end
|
||||
|
||||
USR_R -->|"ramaiah.app.healix360.net"| CADDY
|
||||
USR_R -->|"ramaiah.engage.healix360.net"| CADDY
|
||||
USR_G -->|"global.app.healix360.net"| CADDY
|
||||
USR_G -->|"global.engage.healix360.net"| CADDY
|
||||
|
||||
OZO -->|"webhooks/ozonetel/missed-call<br/>(Ramaiah DID 918041763400)"| CADDY
|
||||
OZO -.->|"webhooks/ozonetel/missed-call<br/>(Global DID 918041763265<br/>— still on VPS today)"| CADDY
|
||||
OZO -->|"agent + call events<br/>(account-level subscription)"| CADDY
|
||||
CADDY -->|"telephony.engage.*"| DISP
|
||||
|
||||
CADDY -->|"*.app.healix360.net<br/>/graphql, /auth/*, SPA"| SRV
|
||||
CADDY -->|"ramaiah.engage.*<br/>/api/*, /webhooks/*, SPA"| SC_R
|
||||
CADDY -->|"global.engage.*<br/>/api/*, /webhooks/*, SPA"| SC_G
|
||||
|
||||
DISP -->|"agentId lookup<br/>→ forward to sidecar"| SC_R
|
||||
DISP -->|"agentId lookup<br/>→ forward to sidecar"| SC_G
|
||||
DISP --- RD_T
|
||||
|
||||
SC_R -->|"self-register on boot<br/>heartbeat 30s"| DISP
|
||||
SC_G -->|"self-register on boot<br/>heartbeat 30s"| DISP
|
||||
|
||||
SC_R -->|"GraphQL<br/>Origin: ramaiah.app.*"| SRV
|
||||
SC_G -->|"GraphQL<br/>Origin: global.app.*"| SRV
|
||||
|
||||
SC_R --- RD_R
|
||||
SC_G --- RD_G
|
||||
SC_R --- VOL_R
|
||||
SC_G --- VOL_G
|
||||
|
||||
SRV --- DB
|
||||
SRV --- CH
|
||||
SRV --- RP
|
||||
SRV --- MINIO
|
||||
WKR --- DB
|
||||
WKR --- RP
|
||||
|
||||
classDef shared fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#000
|
||||
classDef ramaiah fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#000
|
||||
classDef global fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,color:#000
|
||||
classDef external fill:#f5f5f5,stroke:#757575,stroke-width:1px,color:#000
|
||||
classDef edge fill:#fce4ec,stroke:#c2185b,stroke-width:2px,color:#000
|
||||
classDef telephony fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000
|
||||
|
||||
class SRV,WKR,DB,CH,RP,MINIO shared
|
||||
class SC_R,RD_R,VOL_R ramaiah
|
||||
class SC_G,RD_G,VOL_G global
|
||||
class OZO,USR_R,USR_G external
|
||||
class CADDY edge
|
||||
class DISP,RD_T telephony
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Scope | Container count | Purpose |
|
||||
|---|---|---|---|
|
||||
| Caddy | Shared | 1 | Host-routed reverse proxy, TLS terminator |
|
||||
| Platform server | Shared | 1 | Natively multi-tenant by Origin/subdomain |
|
||||
| Platform worker | Shared | 1 | BullMQ jobs carry workspace context per-job |
|
||||
| Postgres | Shared | 1 | Multi-tenant via per-workspace schemas |
|
||||
| ClickHouse | Shared | 1 | Analytics — workspace dimension per event |
|
||||
| Redpanda | Shared | 1 | Event bus — workspace dimension per message |
|
||||
| MinIO | Shared | 1 | S3-compatible storage |
|
||||
| **Telephony dispatcher** | **Shared** | **1** | Routes Ozonetel events to correct sidecar by agentId |
|
||||
| **Redis (telephony)** | **Shared** | **1** | Service discovery registry for dispatcher |
|
||||
| **Sidecar** | **Per-tenant** | **2** | Call center layer (Ramaiah + Global) |
|
||||
| **Redis (sidecar)** | **Per-tenant** | **2** | Session, agent state, theme, rules cache |
|
||||
| **Data volume** | **Per-tenant** | **2** | File-based config in `/app/data/` |
|
||||
|
||||
---
|
||||
|
||||
## Telephony Event Flow
|
||||
|
||||
Ozonetel event subscriptions are **account-level** — one subscription per Ozonetel account, not per campaign. All agent login/logout/state events and call events are POSTed to a single URL.
|
||||
|
||||
```
|
||||
Ozonetel → POST telephony.engage.healix360.net/api/supervisor/agent-event
|
||||
→ Dispatcher receives { agentId: "ramaiahadmin", action: "incall", ... }
|
||||
→ Redis lookup: agentId "ramaiahadmin" → sidecar-ramaiah:4100
|
||||
→ Forward event to sidecar-ramaiah
|
||||
→ sidecar-ramaiah updates SupervisorService state, emits SSE
|
||||
```
|
||||
|
||||
**Service discovery:** Each sidecar self-registers on boot via `POST /api/supervisor/register` with its agent list. Heartbeat every 30s, TTL 90s. If a sidecar goes down, its entries expire and the dispatcher stops routing to it.
|
||||
|
||||
---
|
||||
|
||||
## Request Flow
|
||||
|
||||
### Agent opens Ramaiah Helix Engage
|
||||
```
|
||||
Browser → https://ramaiah.engage.healix360.net/
|
||||
→ Caddy (TLS, Host=ramaiah.engage.healix360.net)
|
||||
→ static SPA from /srv/engage
|
||||
|
||||
Browser → POST /api/auth/login { email, password }
|
||||
→ Caddy → sidecar-ramaiah:4100
|
||||
→ sidecar calls platform with:
|
||||
Origin: https://ramaiah.app.healix360.net
|
||||
Authorization: Bearer <Ramaiah API key>
|
||||
→ platform resolves workspace by Origin → Ramaiah
|
||||
→ JWT returned
|
||||
```
|
||||
|
||||
### Ozonetel POSTs a missed-call webhook
|
||||
```
|
||||
Ozonetel → POST https://ramaiah.engage.healix360.net/webhooks/ozonetel/missed-call
|
||||
→ Caddy (Host=ramaiah.engage.healix360.net)
|
||||
→ sidecar-ramaiah:4100 ONLY
|
||||
→ writes call row into Ramaiah workspace via platform
|
||||
```
|
||||
|
||||
Cross-tenant leakage is physically impossible — Caddy's host-routing guarantees a Ramaiah webhook can never reach sidecar-global.
|
||||
|
||||
---
|
||||
|
||||
## Failure Modes
|
||||
|
||||
| Failure | Blast radius |
|
||||
|---|---|
|
||||
| `sidecar-ramaiah` crashes | Ramaiah Engage 502s. Global + platform unaffected. |
|
||||
| `sidecar-global` crashes | Global Engage 502s. Ramaiah + platform unaffected. |
|
||||
| `redis-ramaiah` crashes | Ramaiah agents kicked from SIP. Global unaffected. |
|
||||
| `telephony` crashes | Agent/call state events stop routing. Sidecars still serve UI. |
|
||||
| `server` (platform) crashes | **Both workspaces** down for data. |
|
||||
| `db` crashes | Same as above. |
|
||||
| Caddy crashes | Nothing reachable until restart. |
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Hospital
|
||||
|
||||
1. Add sidecar container + Redis + data volume in `docker-compose.yml`
|
||||
2. Add Caddy host block for `newhospital.engage.healix360.net`
|
||||
3. Create workspace on platform, generate API key
|
||||
4. Set sidecar env: `PLATFORM_API_KEY`, `PLATFORM_WORKSPACE_SUBDOMAIN`
|
||||
5. Configure Ozonetel campaign webhook to `newhospital.engage.healix360.net/webhooks/ozonetel/missed-call`
|
||||
6. Sidecar self-registers with telephony dispatcher on boot — no dispatcher config needed
|
||||
181
docs/ci-cd-operations.md
Normal file
181
docs/ci-cd-operations.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Helix Engage — CI/CD & Operations Dashboard
|
||||
|
||||
## Overview
|
||||
|
||||
Three services on EC2 provide CI/CD and operational visibility:
|
||||
|
||||
- **Gitea** (`git.healix360.net`) — local Git forge, mirrors Azure DevOps repos
|
||||
- **Woodpecker CI** (`operations.healix360.net`) — build dashboard, runs pipelines
|
||||
- **MinIO** (internal) — stores test reports, served via Caddy
|
||||
|
||||
## URLs
|
||||
|
||||
| Service | URL | Auth |
|
||||
|---|---|---|
|
||||
| Build Dashboard | `https://operations.healix360.net` | Gitea OAuth (helix-admin / Global@2026) |
|
||||
| Test Reports | `https://operations.healix360.net/reports/{run}/index.html` | Basic auth (helix-admin / Global@2026) |
|
||||
| Git Forge | `https://git.healix360.net` | helix-admin / Global@2026 |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Azure DevOps (push)
|
||||
↓ mirror sync (every 15min or manual)
|
||||
Gitea (git.healix360.net)
|
||||
↓ webhook
|
||||
Woodpecker CI (operations.healix360.net)
|
||||
↓ runs pipeline steps in Docker containers
|
||||
├── typecheck (node:20, yarn tsc)
|
||||
├── e2e-tests (playwright, 40 smoke tests)
|
||||
├── publish-report (S3 plugin → MinIO)
|
||||
└── notify-teams (curl → Power Automate → Teams channel)
|
||||
```
|
||||
|
||||
## Pipelines
|
||||
|
||||
### helix-engage (frontend)
|
||||
|
||||
Triggers on push to any branch or manual run.
|
||||
|
||||
**Steps:**
|
||||
1. **typecheck** — `yarn install` + `tsc --noEmit` (node:20 image)
|
||||
2. **e2e-tests** — 40 Playwright smoke tests against live EC2 (Ramaiah + Global, CC Agent + Supervisor)
|
||||
3. **publish-report** — uploads Playwright HTML report to MinIO via S3 plugin
|
||||
4. **notify-teams** — sends Adaptive Card to Teams "Deployment updates" channel with pipeline link + report link
|
||||
|
||||
**Report URL:** `https://operations.healix360.net/reports/{pipeline-number}/index.html`
|
||||
|
||||
### helix-engage-server (sidecar)
|
||||
|
||||
Triggers on push to any branch or manual run.
|
||||
|
||||
**Steps:**
|
||||
1. **unit-tests** — `npm ci` + `jest --ci --forceExit` (node:20 image)
|
||||
2. **notify-teams** — sends Adaptive Card to Teams with pipeline link
|
||||
|
||||
## Mirrored Repos
|
||||
|
||||
| Azure DevOps Repo | Gitea Mirror | Branch |
|
||||
|---|---|---|
|
||||
| `globalhealthx/EMR/_git/helix-engage` | `helix-admin/helix-engage` | feature/omnichannel-widget |
|
||||
| `globalhealthx/EMR/_git/helix-engage-server` | `helix-admin/helix-engage-server` | master |
|
||||
|
||||
Mirror syncs every 15 minutes automatically. To force sync:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage/mirror-sync" \
|
||||
-u "helix-admin:Global@2026"
|
||||
|
||||
curl -s -X POST "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage-server/mirror-sync" \
|
||||
-u "helix-admin:Global@2026"
|
||||
```
|
||||
|
||||
## Teams Notifications
|
||||
|
||||
Notifications go to the "Deployment updates" channel via Power Automate Workflow webhook.
|
||||
|
||||
Each notification includes:
|
||||
- Project name and build number
|
||||
- Branch name
|
||||
- Commit message
|
||||
- "View Pipeline" button (links to Woodpecker)
|
||||
- "View Report" button (links to Playwright HTML report, frontend only)
|
||||
|
||||
## Secrets (Woodpecker)
|
||||
|
||||
Configured per-repo in Woodpecker Settings → Secrets:
|
||||
|
||||
| Secret | Used by | Purpose |
|
||||
|---|---|---|
|
||||
| `s3_access_key` | publish-report | MinIO access key (`minio`) |
|
||||
| `s3_secret_key` | publish-report | MinIO secret key |
|
||||
| `teams_webhook` | notify-teams | Power Automate webhook URL |
|
||||
|
||||
## Docker Containers
|
||||
|
||||
| Container | Image | Purpose |
|
||||
|---|---|---|
|
||||
| `ramaiah-prod-gitea-1` | `gitea/gitea:latest` | Git forge |
|
||||
| `ramaiah-prod-woodpecker-server-1` | `woodpeckerci/woodpecker-server:v3` | CI dashboard + pipeline engine |
|
||||
| `ramaiah-prod-woodpecker-agent-1` | `woodpeckerci/woodpecker-agent:v3` | Executes pipeline steps in Docker |
|
||||
|
||||
## Agent Configuration
|
||||
|
||||
The Woodpecker agent is configured to:
|
||||
- Run pipeline containers on the `ramaiah-prod_default` Docker network (so they can reach Gitea and MinIO)
|
||||
- Allow up to 2 concurrent workflows
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pipeline fails at git clone
|
||||
|
||||
Check that Gitea's `REQUIRE_SIGNIN_VIEW` is `false` (public repos must be cloneable without auth):
|
||||
|
||||
```bash
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker exec ramaiah-prod-gitea-1 grep REQUIRE_SIGNIN /data/gitea/conf/app.ini"
|
||||
```
|
||||
|
||||
### npm install crashes with "Exit handler never called"
|
||||
|
||||
Known npm bug in CI containers. Use `yarn` instead of `npm` for the frontend. The sidecar's lockfile is clean so `npm ci` works.
|
||||
|
||||
### Pipeline says "pipeline definition not found"
|
||||
|
||||
The `.woodpecker.yml` file is missing or has invalid YAML. Check:
|
||||
|
||||
```bash
|
||||
curl -s "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage/contents/.woodpecker.yml?ref=feature/omnichannel-widget" \
|
||||
-u "helix-admin:Global@2026" | python3 -c "import sys,json;print(json.load(sys.stdin).get('name','NOT FOUND'))"
|
||||
```
|
||||
|
||||
### Teams notification not arriving
|
||||
|
||||
Verify the webhook secret is set in Woodpecker and the Power Automate workflow is active.
|
||||
|
||||
### Test reports not loading (403/XML error)
|
||||
|
||||
Caddy must strip the Authorization header before proxying to MinIO. Check:
|
||||
|
||||
```bash
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"grep -A8 'handle_path /reports' /opt/fortytwo/Caddyfile"
|
||||
```
|
||||
|
||||
Should include `header_up -Authorization`.
|
||||
|
||||
### Manually trigger a pipeline
|
||||
|
||||
```bash
|
||||
WP_TOKEN="<woodpecker-api-token>"
|
||||
curl -s -X POST "https://operations.healix360.net/api/repos/1/pipelines" \
|
||||
-H "Authorization: Bearer $WP_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"branch":"feature/omnichannel-widget"}'
|
||||
```
|
||||
|
||||
### Delete old pipeline runs
|
||||
|
||||
```bash
|
||||
WP_TOKEN="<woodpecker-api-token>"
|
||||
for i in $(seq 1 20); do
|
||||
curl -s -X DELETE "https://operations.healix360.net/api/repos/1/pipelines/$i" \
|
||||
-H "Authorization: Bearer $WP_TOKEN"
|
||||
done
|
||||
```
|
||||
|
||||
## E2E Test Coverage
|
||||
|
||||
40 tests across 2 hospitals, 3 roles:
|
||||
|
||||
**Login (4):** branding, invalid creds, supervisor login, auth guard
|
||||
|
||||
**Ramaiah CC Agent (10):** landing, call desk, call history, patients (list + search), appointments, my performance (API + KPI), sidebar, sign-out modal, sign-out complete
|
||||
|
||||
**Ramaiah Supervisor (12):** landing, team performance, live monitor, leads, patients, appointments, call log, recordings, missed calls, campaigns, settings, sidebar
|
||||
|
||||
**Global CC Agent (7):** landing, call history, patients, appointments, my performance, sidebar, sign-out
|
||||
|
||||
**Global Supervisor (5):** landing, patients, appointments, campaigns, settings
|
||||
|
||||
**Auto-cleanup:** Last CC Agent test completes sign-out to release agent session. Setup steps call `/api/maint/unlock-agent` to clear stale locks.
|
||||
@@ -2,326 +2,285 @@
|
||||
|
||||
## Architecture
|
||||
|
||||
See [architecture.md](./architecture.md) for the full multi-tenant topology diagram.
|
||||
|
||||
```
|
||||
Browser (India)
|
||||
↓ HTTPS
|
||||
Caddy (reverse proxy, TLS, static files)
|
||||
├── engage.srv1477139.hstgr.cloud → /srv/engage (static frontend)
|
||||
├── engage-api.srv1477139.hstgr.cloud → sidecar:4100
|
||||
└── *.srv1477139.hstgr.cloud → server:4000 (platform)
|
||||
Caddy (reverse proxy, TLS, host-routed)
|
||||
├── ramaiah.engage.healix360.net → sidecar-ramaiah:4100
|
||||
├── global.engage.healix360.net → sidecar-global:4100
|
||||
├── telephony.engage.healix360.net → telephony:4200
|
||||
├── *.app.healix360.net → server:4000 (platform)
|
||||
└── engage.healix360.net → 404 (no catchall)
|
||||
|
||||
Docker Compose stack:
|
||||
├── caddy — Reverse proxy + TLS
|
||||
├── server — FortyTwo platform (ECR image)
|
||||
├── worker — Background jobs
|
||||
├── sidecar — Helix Engage NestJS API (ECR image)
|
||||
├── db — PostgreSQL 16
|
||||
├── redis — Session + cache
|
||||
├── clickhouse — Analytics
|
||||
├── minio — Object storage
|
||||
└── redpanda — Event bus (Kafka)
|
||||
Docker Compose stack (EC2 — 13.234.31.194):
|
||||
├── caddy — Reverse proxy + TLS (Let's Encrypt)
|
||||
├── server — FortyTwo platform (NestJS, port 4000)
|
||||
├── worker — BullMQ background jobs
|
||||
├── sidecar-ramaiah — Ramaiah sidecar (NestJS, port 4100)
|
||||
├── sidecar-global — Global sidecar (NestJS, port 4100)
|
||||
├── telephony — Event dispatcher (NestJS, port 4200)
|
||||
├── redis-ramaiah — Ramaiah sidecar Redis
|
||||
├── redis-global — Global sidecar Redis
|
||||
├── redis-telephony — Telephony dispatcher Redis
|
||||
├── redis — Platform Redis
|
||||
├── db — PostgreSQL 16 (workspace-per-schema)
|
||||
├── clickhouse — Analytics
|
||||
├── minio — S3-compatible object storage
|
||||
└── redpanda — Event bus (Kafka-compatible)
|
||||
```
|
||||
|
||||
## VPS Access
|
||||
---
|
||||
|
||||
## EC2 Access
|
||||
|
||||
```bash
|
||||
# SSH into the VPS
|
||||
sshpass -p 'SasiSuman@2007' ssh -o StrictHostKeyChecking=no root@148.230.67.184
|
||||
|
||||
# Or with SSH key (if configured)
|
||||
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184
|
||||
# SSH into EC2
|
||||
ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194
|
||||
```
|
||||
|
||||
| Detail | Value |
|
||||
|---|---|
|
||||
| Host | 148.230.67.184 |
|
||||
| User | root |
|
||||
| Password | SasiSuman@2007 |
|
||||
| Docker compose dir | /opt/fortytwo |
|
||||
| Frontend static files | /opt/fortytwo/helix-engage-frontend |
|
||||
| Caddyfile | /opt/fortytwo/Caddyfile |
|
||||
| Host | `13.234.31.194` |
|
||||
| User | `ubuntu` |
|
||||
| SSH key | `/tmp/ramaiah-ec2-key` (decrypted from `~/Downloads/fortytwoai_hostinger`) |
|
||||
| Docker compose dir | `/opt/fortytwo` |
|
||||
| Frontend static files | `/opt/fortytwo/helix-engage-frontend` |
|
||||
| Caddyfile | `/opt/fortytwo/Caddyfile` |
|
||||
|
||||
### SSH Key Setup
|
||||
|
||||
The key at `~/Downloads/fortytwoai_hostinger` is passphrase-protected (`SasiSuman@2007`).
|
||||
Create a decrypted copy for non-interactive use:
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
openssl pkey -in ~/Downloads/fortytwoai_hostinger -out /tmp/ramaiah-ec2-key
|
||||
chmod 600 /tmp/ramaiah-ec2-key
|
||||
|
||||
# Verify
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 hostname
|
||||
```
|
||||
|
||||
### Handy alias
|
||||
|
||||
```bash
|
||||
alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## URLs
|
||||
|
||||
| Service | URL |
|
||||
|---|---|
|
||||
| Frontend | https://engage.srv1477139.hstgr.cloud |
|
||||
| Sidecar API | https://engage-api.srv1477139.hstgr.cloud |
|
||||
| Platform | https://fortytwo-dev.srv1477139.hstgr.cloud |
|
||||
|
||||
## Login Credentials
|
||||
|
||||
| Role | Email | Password |
|
||||
|---|---|---|
|
||||
| CC Agent | rekha.cc@globalhospital.com | Global@123 |
|
||||
| CC Agent | ganesh.cc@globalhospital.com | Global@123 |
|
||||
| Marketing | sanjay.marketing@globalhospital.com | Global@123 |
|
||||
| Admin/Supervisor | dr.ramesh@globalhospital.com | Global@123 |
|
||||
| Ramaiah Engage (Frontend + API) | `https://ramaiah.engage.healix360.net` |
|
||||
| Global Engage (Frontend + API) | `https://global.engage.healix360.net` |
|
||||
| Ramaiah Platform | `https://ramaiah.app.healix360.net` |
|
||||
| Global Platform | `https://global.app.healix360.net` |
|
||||
| Telephony Dispatcher | `https://telephony.engage.healix360.net` |
|
||||
|
||||
---
|
||||
|
||||
## Local Testing
|
||||
## Login Credentials
|
||||
|
||||
Always test locally before deploying to staging.
|
||||
### Ramaiah Workspace
|
||||
|
||||
| Role | Email | Password |
|
||||
|---|---|---|
|
||||
| Marketing Executive | `marketing@ramaiahcare.com` | `AdRamaiah@2026` |
|
||||
| Marketing Executive | `supervisor@ramaiahcare.com` | `MrRamaiah@2026` |
|
||||
| CC Agent | `ccagent@ramaiahcare.com` | `CcRamaiah@2026` |
|
||||
| Platform Admin | `dev@fortytwo.dev` | `tim@apple.dev` |
|
||||
|
||||
### Ozonetel
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| API Key | `KK8110e6c3de02527f7243ffaa924fa93e` |
|
||||
| Username | `global_healthx` |
|
||||
| Ramaiah Campaign | `Inbound_918041763400` |
|
||||
| Ramaiah Agent | `ramaiahadmin` / ext `524435` |
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
|
||||
### Frontend (Vite dev server)
|
||||
|
||||
```bash
|
||||
cd helix-engage
|
||||
|
||||
# Start dev server (hot reload)
|
||||
npm run dev
|
||||
# → http://localhost:5173
|
||||
|
||||
# Type check (catches production build errors)
|
||||
npx tsc --noEmit
|
||||
|
||||
# Production build (same as deploy)
|
||||
npm run build
|
||||
npm run dev # http://localhost:5173
|
||||
npx tsc --noEmit # Type check
|
||||
npm run build # Production build
|
||||
```
|
||||
|
||||
The `.env.local` controls which sidecar the frontend talks to:
|
||||
```bash
|
||||
# Remote sidecar (default — uses deployed backend)
|
||||
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud
|
||||
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud
|
||||
# Remote (default — uses EC2 backend)
|
||||
VITE_API_URL=https://ramaiah.engage.healix360.net
|
||||
|
||||
# Local sidecar (for testing sidecar changes)
|
||||
# Local sidecar
|
||||
# VITE_API_URL=http://localhost:4100
|
||||
# VITE_SIDECAR_URL=http://localhost:4100
|
||||
|
||||
# Split — theme endpoint local, everything else remote
|
||||
# VITE_THEME_API_URL=http://localhost:4100
|
||||
```
|
||||
|
||||
**Important:** When `VITE_API_URL` points to `localhost:4100`, login and GraphQL only work if the local sidecar can reach the platform. The local sidecar's `.env` must have valid `PLATFORM_GRAPHQL_URL` and `PLATFORM_API_KEY`.
|
||||
|
||||
### Sidecar (NestJS dev server)
|
||||
|
||||
```bash
|
||||
cd helix-engage-server
|
||||
|
||||
# Start with watch mode (auto-restart on changes)
|
||||
npm run start:dev
|
||||
# → http://localhost:4100
|
||||
|
||||
# Build only (no run)
|
||||
npm run build
|
||||
|
||||
# Production start
|
||||
npm run start:prod
|
||||
npm run start:dev # http://localhost:4100 (watch mode)
|
||||
npm run build # Build only
|
||||
```
|
||||
|
||||
The sidecar `.env` must have:
|
||||
Sidecar `.env` must have:
|
||||
```bash
|
||||
PLATFORM_GRAPHQL_URL=... # Platform GraphQL endpoint
|
||||
PLATFORM_API_KEY=... # Platform API key for server-to-server calls
|
||||
PLATFORM_WORKSPACE_SUBDOMAIN=fortytwo-dev
|
||||
REDIS_URL=redis://localhost:6379 # Local Redis required
|
||||
PLATFORM_GRAPHQL_URL=https://ramaiah.app.healix360.net/graphql
|
||||
PLATFORM_API_KEY=<Ramaiah workspace API key>
|
||||
PLATFORM_WORKSPACE_SUBDOMAIN=ramaiah
|
||||
REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
### Local Docker stack (full environment)
|
||||
|
||||
For testing with a local platform + database + Redis:
|
||||
|
||||
```bash
|
||||
cd helix-engage-local
|
||||
|
||||
# First time — pull images + start
|
||||
./deploy-local.sh up
|
||||
|
||||
# Deploy frontend to local stack
|
||||
./deploy-local.sh frontend
|
||||
|
||||
# Deploy sidecar to local stack
|
||||
./deploy-local.sh sidecar
|
||||
|
||||
# Both
|
||||
./deploy-local.sh all
|
||||
|
||||
# Logs
|
||||
./deploy-local.sh logs
|
||||
|
||||
# Stop
|
||||
./deploy-local.sh down
|
||||
```
|
||||
|
||||
Local stack URLs:
|
||||
- Platform: `http://localhost:5001`
|
||||
- Sidecar: `http://localhost:5100`
|
||||
- Frontend: `http://localhost:5080`
|
||||
|
||||
### Pre-deploy checklist
|
||||
|
||||
Before running `deploy.sh`:
|
||||
|
||||
1. `npx tsc --noEmit` — passes with no errors (frontend)
|
||||
1. `npx tsc --noEmit` — passes (frontend)
|
||||
2. `npm run build` — succeeds (sidecar)
|
||||
3. Test the changed feature locally (dev server or local stack)
|
||||
3. Test the changed feature locally
|
||||
4. Check `package.json` for new dependencies → decides quick vs full deploy
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Prerequisites (local machine)
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
# Required tools
|
||||
brew install sshpass # SSH with password
|
||||
aws configure # AWS CLI (for ECR)
|
||||
docker desktop # Docker with buildx
|
||||
cd helix-engage && npm run build
|
||||
|
||||
# Verify AWS access
|
||||
aws sts get-caller-identity # Should show account 043728036361
|
||||
rsync -avz -e "ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no" \
|
||||
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/
|
||||
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"cd /opt/fortytwo && sudo docker compose restart caddy"
|
||||
```
|
||||
|
||||
### Path 1: Quick Deploy (no new dependencies)
|
||||
|
||||
Use when only code changes — no new npm packages.
|
||||
### Sidecar (quick — code only, no new dependencies)
|
||||
|
||||
```bash
|
||||
cd /path/to/fortytwo-eap
|
||||
cd helix-engage-server
|
||||
|
||||
# Deploy frontend only
|
||||
bash deploy.sh frontend
|
||||
aws ecr get-login-password --region ap-south-1 | \
|
||||
docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
|
||||
|
||||
# Deploy sidecar only
|
||||
bash deploy.sh sidecar
|
||||
|
||||
# Deploy both
|
||||
bash deploy.sh all
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Frontend: `npm run build` → tar `dist/` → SCP to VPS → extract to `/opt/fortytwo/helix-engage-frontend`
|
||||
- Sidecar: `nest build` → tar `dist/` + `src/` → docker cp into running container → `docker compose restart sidecar`
|
||||
|
||||
### Path 2: Full Deploy (new dependencies)
|
||||
|
||||
Use when `package.json` changed (new npm packages added).
|
||||
|
||||
```bash
|
||||
cd /path/to/fortytwo-eap/helix-engage-server
|
||||
|
||||
# 1. Login to ECR
|
||||
aws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
|
||||
|
||||
# 2. Build cross-platform image and push
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \
|
||||
--push .
|
||||
|
||||
# 3. Pull and restart on VPS
|
||||
ECR_TOKEN=$(aws ecr get-login-password --region ap-south-1)
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "
|
||||
echo '$ECR_TOKEN' | docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
|
||||
cd /opt/fortytwo
|
||||
docker compose pull sidecar
|
||||
docker compose up -d sidecar
|
||||
"
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
|
||||
```
|
||||
|
||||
### How to decide which path
|
||||
### How to decide
|
||||
|
||||
```
|
||||
Did package.json change?
|
||||
├── YES → Path 2 (ECR build + push + pull)
|
||||
└── NO → Path 1 (deploy.sh)
|
||||
├── YES → ECR build + push + pull (above)
|
||||
└── NO → Same steps (ECR is the only deploy path for EC2)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-Deploy: E2E Smoke Tests
|
||||
|
||||
```bash
|
||||
cd helix-engage
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
27 tests covering login, all CC Agent pages, all Supervisor pages, and sign-out.
|
||||
The last test completes sign-out so the agent session is released for the next run.
|
||||
|
||||
---
|
||||
|
||||
## Checking Logs
|
||||
|
||||
### Sidecar logs
|
||||
|
||||
```bash
|
||||
# SSH into VPS first, or run remotely:
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 30"
|
||||
# Ramaiah sidecar
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker logs ramaiah-prod-sidecar-ramaiah-1 --tail 30 2>&1"
|
||||
|
||||
# Follow live
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 -f --tail 10"
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker logs ramaiah-prod-sidecar-ramaiah-1 -f --tail 10 2>&1"
|
||||
|
||||
# Filter for errors
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 100 2>&1 | grep -i error"
|
||||
# Filter errors
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker logs ramaiah-prod-sidecar-ramaiah-1 --tail 100 2>&1" | grep -i "error\|fail"
|
||||
|
||||
# Via deploy.sh
|
||||
bash deploy.sh logs
|
||||
# Telephony dispatcher
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker logs ramaiah-prod-telephony-1 --tail 30 2>&1"
|
||||
|
||||
# Caddy
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker logs ramaiah-prod-caddy-1 --tail 20 2>&1"
|
||||
|
||||
# Platform server
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker logs ramaiah-prod-server-1 --tail 30 2>&1"
|
||||
|
||||
# All container status
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker ps --format 'table {{.Names}}\t{{.Status}}'"
|
||||
```
|
||||
|
||||
### Caddy logs
|
||||
### Healthy startup
|
||||
|
||||
```bash
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-caddy-1 --tail 30"
|
||||
```
|
||||
|
||||
### Platform server logs
|
||||
|
||||
```bash
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-server-1 --tail 30"
|
||||
```
|
||||
|
||||
### All container status
|
||||
|
||||
```bash
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health Checks
|
||||
|
||||
### Sidecar healthy startup
|
||||
|
||||
Look for these lines in sidecar logs:
|
||||
Look for these in sidecar logs:
|
||||
```
|
||||
[NestApplication] Nest application successfully started
|
||||
Helix Engage Server running on port 4100
|
||||
[SessionService] Redis connected
|
||||
[ThemeService] Theme loaded from file (or "Using default theme")
|
||||
[RulesStorageService] Initialized empty rules config
|
||||
```
|
||||
|
||||
### Common failure patterns
|
||||
|
||||
| Log pattern | Meaning | Fix |
|
||||
|---|---|---|
|
||||
| `Cannot find module 'xxx'` | Missing npm dependency | Path 2 deploy (rebuild ECR image) |
|
||||
| `UndefinedModuleException` | Circular dependency or missing import | Fix code, redeploy |
|
||||
| `ECONNREFUSED redis:6379` | Redis not ready | `docker compose restart redis sidecar` |
|
||||
| `Cannot find module 'xxx'` | Missing npm dependency | Rebuild ECR image |
|
||||
| `UndefinedModuleException` | Circular dependency | Fix code, redeploy |
|
||||
| `ECONNREFUSED redis:6379` | Redis not ready | `docker compose up -d redis-ramaiah` |
|
||||
| `Forbidden resource` | Platform permission issue | Check user roles |
|
||||
| `429 Too Many Requests` | Ozonetel rate limit | Wait, reduce polling frequency |
|
||||
| `429 Too Many Requests` | Ozonetel rate limit | Wait, reduce polling |
|
||||
|
||||
---
|
||||
|
||||
## Redis Cache Operations
|
||||
|
||||
### Clear caller resolution cache
|
||||
## Redis Operations
|
||||
|
||||
```bash
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli KEYS 'caller:*'"
|
||||
SSH="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||
REDIS="docker exec ramaiah-prod-redis-ramaiah-1 redis-cli"
|
||||
|
||||
# Clear all caller cache
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'caller:*' | xargs -r docker exec -i fortytwo-staging-redis-1 redis-cli DEL"
|
||||
```
|
||||
# Clear agent session lock (fixes "already logged in from another device")
|
||||
$SSH "$REDIS DEL agent:session:ramaiahadmin"
|
||||
|
||||
### Clear recording analysis cache
|
||||
# List all keys
|
||||
$SSH "$REDIS KEYS '*'"
|
||||
|
||||
```bash
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'call:analysis:*' | xargs -r docker exec -i fortytwo-staging-redis-1 redis-cli DEL"
|
||||
```
|
||||
# Clear caller cache (stale patient names)
|
||||
$SSH "$REDIS --scan --pattern 'caller:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
|
||||
|
||||
### Clear agent name cache
|
||||
# Clear masterdata cache (departments/doctors/clinics/slots)
|
||||
$SSH "$REDIS --scan --pattern 'masterdata:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
|
||||
|
||||
```bash
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'agent:name:*' | xargs -r docker exec -i fortytwo-staging-redis-1 redis-cli DEL"
|
||||
```
|
||||
# Clear recording analysis cache
|
||||
$SSH "$REDIS --scan --pattern 'call:analysis:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
|
||||
|
||||
### Clear all session/cache keys
|
||||
# Clear agent name cache
|
||||
$SSH "$REDIS --scan --pattern 'agent:name:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
|
||||
|
||||
```bash
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli FLUSHDB"
|
||||
# Nuclear: flush all sidecar Redis
|
||||
$SSH "$REDIS FLUSHDB"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -329,7 +288,8 @@ sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-stagin
|
||||
## Database Access
|
||||
|
||||
```bash
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-db-1 psql -U fortytwo -d fortytwo_staging"
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker exec -it ramaiah-prod-db-1 psql -U fortytwo -d fortytwo_eap"
|
||||
```
|
||||
|
||||
### Useful queries
|
||||
@@ -354,70 +314,68 @@ JOIN core."role" r ON r.id = rt."roleId";
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
## Troubleshooting
|
||||
|
||||
### Frontend rollback
|
||||
### "Already logged in from another device"
|
||||
|
||||
The previous frontend build is overwritten. To rollback:
|
||||
1. Checkout the previous git commit
|
||||
2. `npm run build`
|
||||
3. `bash deploy.sh frontend`
|
||||
Single-session enforcement per Ozonetel agent. Clear the lock:
|
||||
```bash
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker exec ramaiah-prod-redis-ramaiah-1 redis-cli DEL agent:session:ramaiahadmin"
|
||||
```
|
||||
|
||||
### Sidecar rollback (quick deploy)
|
||||
|
||||
Same as frontend — checkout previous commit, rebuild, redeploy.
|
||||
|
||||
### Sidecar rollback (ECR)
|
||||
### Agent stuck in ACW / Wrapping Up
|
||||
|
||||
```bash
|
||||
# Tag the current image as rollback
|
||||
# Then re-tag the previous image as :alpha
|
||||
# Or use a specific tag/digest
|
||||
curl -X POST https://ramaiah.engage.healix360.net/api/maint/force-ready \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"agentId": "ramaiahadmin"}'
|
||||
```
|
||||
|
||||
# On VPS:
|
||||
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "
|
||||
cd /opt/fortytwo
|
||||
docker compose restart sidecar
|
||||
"
|
||||
### Telephony events not routing
|
||||
|
||||
```bash
|
||||
# Check dispatcher logs
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker logs ramaiah-prod-telephony-1 --tail 30 2>&1"
|
||||
|
||||
# Check service discovery registry
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker exec ramaiah-prod-redis-telephony-1 redis-cli KEYS '*'"
|
||||
```
|
||||
|
||||
### Theme/branding reset after Redis flush
|
||||
|
||||
```bash
|
||||
curl -X PUT https://ramaiah.engage.healix360.net/api/config/theme \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"defaults": {"brandName": "Helix Engage", "hospitalName": "Ramaiah Hospitals"}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Theme Management
|
||||
## Rollback
|
||||
|
||||
### View current theme
|
||||
```bash
|
||||
curl -s https://engage-api.srv1477139.hstgr.cloud/api/config/theme | python3 -m json.tool
|
||||
```
|
||||
### Frontend
|
||||
|
||||
### Reset theme to defaults
|
||||
```bash
|
||||
curl -s -X POST https://engage-api.srv1477139.hstgr.cloud/api/config/theme/reset | python3 -m json.tool
|
||||
```
|
||||
Checkout previous commit → `npm run build` → rsync to EC2.
|
||||
|
||||
### Theme backups
|
||||
Stored on the sidecar container at `/app/data/theme-backups/`. Each save creates a timestamped backup.
|
||||
### Sidecar
|
||||
|
||||
Checkout previous commit → ECR build + push → pull on EC2.
|
||||
|
||||
For immediate rollback, re-tag a known-good ECR image as `:alpha` and pull.
|
||||
|
||||
---
|
||||
|
||||
## Git Repositories
|
||||
|
||||
| Repo | Azure DevOps URL | Branch |
|
||||
| Repo | Azure DevOps | Branch |
|
||||
|---|---|---|
|
||||
| Frontend | `https://dev.azure.com/globalhealthx/EMR/_git/helix-engage` | `dev` |
|
||||
| Sidecar | `https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server` | `dev` |
|
||||
| SDK App | `FortyTwoApps/helix-engage/` (in fortytwo-eap monorepo) | `dev` |
|
||||
|
||||
### Commit and push pattern
|
||||
```bash
|
||||
# Frontend
|
||||
cd helix-engage
|
||||
git add -A && git commit -m "feat: description" && git push origin dev
|
||||
|
||||
# Sidecar
|
||||
cd helix-engage-server
|
||||
git add -A && git commit -m "feat: description" && git push origin dev
|
||||
```
|
||||
| Frontend | `helix-engage` in Patient Engagement Platform | `feature/omnichannel-widget` |
|
||||
| Sidecar | `helix-engage-server` in Patient Engagement Platform | `master` |
|
||||
| SDK App | `FortyTwoApps/helix-engage/` (monorepo) | `dev` |
|
||||
| Telephony | `helix-engage-telephony` in Patient Engagement Platform | `master` |
|
||||
|
||||
---
|
||||
|
||||
@@ -425,7 +383,7 @@ git add -A && git commit -m "feat: description" && git push origin dev
|
||||
|
||||
| Detail | Value |
|
||||
|---|---|
|
||||
| Registry | 043728036361.dkr.ecr.ap-south-1.amazonaws.com |
|
||||
| Repository | fortytwo-eap/helix-engage-sidecar |
|
||||
| Tag | alpha |
|
||||
| Region | ap-south-1 (Mumbai) |
|
||||
| Registry | `043728036361.dkr.ecr.ap-south-1.amazonaws.com` |
|
||||
| Sidecar repo | `fortytwo-eap/helix-engage-sidecar` |
|
||||
| Tag | `alpha` |
|
||||
| Region | `ap-south-1` (Mumbai) |
|
||||
|
||||
1
e2e/.auth/.gitignore
vendored
Normal file
1
e2e/.auth/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.json
|
||||
56
e2e/agent-login.spec.ts
Normal file
56
e2e/agent-login.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Login feature tests — covers multiple roles.
|
||||
*
|
||||
* These run WITHOUT saved auth state (fresh browser).
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { waitForApp } from './helpers';
|
||||
|
||||
const SUPERVISOR = { email: 'supervisor@ramaiahcare.com', password: 'MrRamaiah@2026' };
|
||||
|
||||
test.describe('Login', () => {
|
||||
|
||||
test('login page renders with branding', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page.locator('img[alt]').first()).toBeVisible();
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
await expect(page.locator('form')).toBeVisible();
|
||||
await expect(page.locator('input[type="email"], input[placeholder*="@"]').first()).toBeVisible();
|
||||
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('invalid credentials show error', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill('bad@bad.com');
|
||||
await page.locator('input[type="password"]').first().fill('wrongpassword');
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
|
||||
await expect(
|
||||
page.locator('text=/not found|invalid|incorrect|failed|error|unauthorized/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('Supervisor login → lands on app', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill(SUPERVISOR.email);
|
||||
await page.locator('input[type="password"]').first().fill(SUPERVISOR.password);
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
|
||||
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
|
||||
await waitForApp(page);
|
||||
|
||||
// Sidebar should be visible
|
||||
await expect(page.locator('aside').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('unauthenticated user redirected to login', async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/patients');
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
155
e2e/agent-smoke.spec.ts
Normal file
155
e2e/agent-smoke.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* CC Agent — happy-path smoke tests.
|
||||
*
|
||||
* Role: cc-agent (ccagent@ramaiahcare.com)
|
||||
* Landing: / → Call Desk
|
||||
* Pages: Call Desk, Call History, Patients, Appointments, My Performance
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { waitForApp } from './helpers';
|
||||
|
||||
test.describe('CC Agent Smoke', () => {
|
||||
|
||||
test('lands on Call Desk after login', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(page.locator('aside').first()).toContainText(/Call Center/i);
|
||||
});
|
||||
|
||||
test('Call Desk page loads', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForApp(page);
|
||||
|
||||
// Call Desk is the landing — just verify we're not on an error page
|
||||
await expect(page.locator('aside').first()).toContainText(/Call Desk/i);
|
||||
});
|
||||
|
||||
test('Call History page loads', async ({ page }) => {
|
||||
await page.goto('/call-history');
|
||||
await waitForApp(page);
|
||||
|
||||
// Should show "Call History" title whether or not there are calls
|
||||
await expect(page.locator('text="Call History"').first()).toBeVisible();
|
||||
|
||||
// Filter dropdown present
|
||||
await expect(page.locator('text=/All Calls/i').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Patients page loads with search', async ({ page }) => {
|
||||
await page.goto('/patients');
|
||||
await waitForApp(page);
|
||||
|
||||
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
|
||||
await expect(search.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Patients search filters results', async ({ page }) => {
|
||||
await page.goto('/patients');
|
||||
await waitForApp(page);
|
||||
|
||||
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
|
||||
await search.first().fill('zzz-nonexistent-patient');
|
||||
await waitForApp(page);
|
||||
|
||||
// Should show empty state
|
||||
const noResults = page.locator('text=/no patient|not found|no results/i');
|
||||
const isEmpty = await noResults.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||
expect(isEmpty).toBe(true);
|
||||
});
|
||||
|
||||
test('Appointments page loads', async ({ page }) => {
|
||||
await page.goto('/appointments');
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(
|
||||
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('My Performance loads with date controls', async ({ page }) => {
|
||||
// Intercept API to verify agentId is sent
|
||||
const apiHit = page.waitForRequest(
|
||||
(r) => r.url().includes('/api/ozonetel/performance') && r.url().includes('agentId='),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await page.goto('/my-performance');
|
||||
const req = await apiHit;
|
||||
|
||||
const url = new URL(req.url());
|
||||
expect(url.searchParams.get('agentId')?.length).toBeGreaterThan(0);
|
||||
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Today' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Yesterday' })).toBeVisible();
|
||||
|
||||
// Either KPI data or empty state
|
||||
await expect(
|
||||
page.locator('text=/Total Calls|No performance data/i').first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('sidebar has all CC Agent nav items', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForApp(page);
|
||||
|
||||
const sidebar = page.locator('aside').first();
|
||||
for (const item of ['Call Desk', 'Call History', 'Patients', 'Appointments', 'My Performance']) {
|
||||
await expect(sidebar.locator(`text="${item}"`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('sign-out shows confirmation modal and cancel keeps session', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForApp(page);
|
||||
|
||||
const sidebar = page.locator('aside').first();
|
||||
const accountArea = sidebar.locator('[class*="account"], [class*="avatar"]').last();
|
||||
if (await accountArea.isVisible()) {
|
||||
await accountArea.click();
|
||||
}
|
||||
|
||||
const signOutBtn = page.locator('button, [role="menuitem"]').filter({ hasText: /sign out/i }).first();
|
||||
if (await signOutBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await signOutBtn.click();
|
||||
|
||||
const modal = page.locator('[role="dialog"]');
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
await expect(modal).toContainText(/sign out/i);
|
||||
|
||||
// Cancel — should stay logged in
|
||||
await modal.getByRole('button', { name: /cancel/i }).click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
}
|
||||
});
|
||||
|
||||
// MUST be the last test — completes sign-out so the agent session is
|
||||
// released and the next test run won't hit "already logged in".
|
||||
test('sign-out completes and redirects to login', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForApp(page);
|
||||
|
||||
const sidebar = page.locator('aside').first();
|
||||
const accountArea = sidebar.locator('[class*="account"], [class*="avatar"]').last();
|
||||
if (await accountArea.isVisible()) {
|
||||
await accountArea.click();
|
||||
}
|
||||
|
||||
const signOutBtn = page.locator('button, [role="menuitem"]').filter({ hasText: /sign out/i }).first();
|
||||
if (await signOutBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await signOutBtn.click();
|
||||
|
||||
const modal = page.locator('[role="dialog"]');
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Confirm sign out
|
||||
await modal.getByRole('button', { name: /sign out/i }).click();
|
||||
|
||||
// Should redirect to login
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
29
e2e/auth.setup.ts
Normal file
29
e2e/auth.setup.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const authFile = path.join(__dirname, '.auth/agent.json');
|
||||
|
||||
setup('login as CC Agent', async ({ page, request, baseURL }) => {
|
||||
// Clear any stale session lock before login
|
||||
const url = baseURL ?? 'https://ramaiah.engage.healix360.net';
|
||||
await request.post(`${url}/api/maint/unlock-agent`, {
|
||||
headers: { 'Content-Type': 'application/json', 'x-maint-otp': '400168' },
|
||||
data: { agentId: 'ramaiahadmin' },
|
||||
}).catch(() => {});
|
||||
|
||||
await page.goto('/login');
|
||||
|
||||
await page.locator('input[type="email"], input[name="email"], input[placeholder*="@"]').first().fill('ccagent@ramaiahcare.com');
|
||||
await page.locator('input[type="password"]').first().fill('CcRamaiah@2026');
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
|
||||
// Should land on Call Desk (/ for cc-agent role)
|
||||
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
|
||||
|
||||
// Sidebar should be visible
|
||||
await expect(page.locator('aside').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
25
e2e/global-setup.ts
Normal file
25
e2e/global-setup.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const authFile = path.join(__dirname, '.auth/global-agent.json');
|
||||
|
||||
setup('login as Global CC Agent', async ({ page, request }) => {
|
||||
// Clear any stale session lock before login
|
||||
await request.post('https://global.engage.healix360.net/api/maint/unlock-agent', {
|
||||
headers: { 'Content-Type': 'application/json', 'x-maint-otp': '400168' },
|
||||
data: { agentId: 'global' },
|
||||
}).catch(() => {});
|
||||
|
||||
await page.goto('https://global.engage.healix360.net/login');
|
||||
|
||||
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill('rekha.cc@globalcare.com');
|
||||
await page.locator('input[type="password"]').first().fill('Global@123');
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
|
||||
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
|
||||
await expect(page.locator('aside').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
120
e2e/global-smoke.spec.ts
Normal file
120
e2e/global-smoke.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Global Hospital — happy-path smoke tests.
|
||||
*
|
||||
* Uses saved auth state from global-setup.ts (same pattern as Ramaiah).
|
||||
* Last test signs out to release the agent session.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, waitForApp } from './helpers';
|
||||
|
||||
const BASE = 'https://global.engage.healix360.net';
|
||||
|
||||
test.describe('Global — CC Agent', () => {
|
||||
|
||||
test('landing page loads', async ({ page }) => {
|
||||
await page.goto(BASE + '/');
|
||||
await waitForApp(page);
|
||||
await expect(page.locator('aside').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Call History page loads', async ({ page }) => {
|
||||
await page.goto(BASE + '/call-history');
|
||||
await waitForApp(page);
|
||||
await expect(page.locator('text="Call History"').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Patients page loads', async ({ page }) => {
|
||||
await page.goto(BASE + '/patients');
|
||||
await waitForApp(page);
|
||||
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
|
||||
await expect(search.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Appointments page loads', async ({ page }) => {
|
||||
await page.goto(BASE + '/appointments');
|
||||
await waitForApp(page);
|
||||
await expect(
|
||||
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('My Performance page loads', async ({ page }) => {
|
||||
await page.goto(BASE + '/my-performance');
|
||||
await waitForApp(page);
|
||||
await expect(page.getByRole('button', { name: 'Today' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('sidebar has CC Agent nav items', async ({ page }) => {
|
||||
await page.goto(BASE + '/');
|
||||
await waitForApp(page);
|
||||
const sidebar = page.locator('aside').first();
|
||||
for (const item of ['Call Desk', 'Call History', 'Patients', 'Appointments', 'My Performance']) {
|
||||
await expect(sidebar.locator(`text="${item}"`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
// Last test — sign out to release session
|
||||
test('sign-out completes', async ({ page }) => {
|
||||
await page.goto(BASE + '/');
|
||||
await waitForApp(page);
|
||||
|
||||
const sidebar = page.locator('aside').first();
|
||||
const accountArea = sidebar.locator('[class*="account"], [class*="avatar"]').last();
|
||||
if (await accountArea.isVisible()) await accountArea.click();
|
||||
|
||||
const signOutBtn = page.locator('button, [role="menuitem"]').filter({ hasText: /sign out/i }).first();
|
||||
if (await signOutBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await signOutBtn.click();
|
||||
const modal = page.locator('[role="dialog"]');
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
await modal.getByRole('button', { name: /sign out/i }).click();
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Global — Supervisor', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(BASE + '/login');
|
||||
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill('dr.ramesh@globalcare.com');
|
||||
await page.locator('input[type="password"]').first().fill('Global@123');
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
|
||||
await waitForApp(page);
|
||||
});
|
||||
|
||||
test('landing page loads', async ({ page }) => {
|
||||
await expect(page.locator('aside').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Patients page loads', async ({ page }) => {
|
||||
await page.goto(BASE + '/patients');
|
||||
await waitForApp(page);
|
||||
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
|
||||
await expect(search.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Appointments page loads', async ({ page }) => {
|
||||
await page.goto(BASE + '/appointments');
|
||||
await waitForApp(page);
|
||||
await expect(
|
||||
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('Campaigns page loads', async ({ page }) => {
|
||||
await page.goto(BASE + '/campaigns');
|
||||
await waitForApp(page);
|
||||
await expect(
|
||||
page.locator('text=/Campaign|No campaign/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('Settings page loads', async ({ page }) => {
|
||||
await page.goto(BASE + '/settings');
|
||||
await waitForApp(page);
|
||||
await expect(
|
||||
page.locator('text=/Settings|Configuration/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
15
e2e/helpers.ts
Normal file
15
e2e/helpers.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export async function waitForApp(page: Page) {
|
||||
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
export async function loginAs(page: Page, email: string, password: string) {
|
||||
await page.goto('/login');
|
||||
await page.locator('input[type="email"], input[name="email"], input[placeholder*="@"]').first().fill(email);
|
||||
await page.locator('input[type="password"]').first().fill(password);
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
|
||||
await waitForApp(page);
|
||||
}
|
||||
121
e2e/supervisor-smoke.spec.ts
Normal file
121
e2e/supervisor-smoke.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Supervisor / Admin — happy-path smoke tests.
|
||||
*
|
||||
* Role: admin (supervisor@ramaiahcare.com)
|
||||
* Landing: / → Dashboard
|
||||
* Pages: Dashboard, Team Performance, Live Monitor,
|
||||
* Leads, Patients, Appointments, Call Log,
|
||||
* Call Recordings, Missed Calls, Campaigns, Settings
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, waitForApp } from './helpers';
|
||||
|
||||
const EMAIL = 'supervisor@ramaiahcare.com';
|
||||
const PASSWORD = 'MrRamaiah@2026';
|
||||
|
||||
test.describe('Supervisor Smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, EMAIL, PASSWORD);
|
||||
});
|
||||
|
||||
test('lands on Dashboard after login', async ({ page }) => {
|
||||
await expect(page.locator('aside').first()).toBeVisible();
|
||||
// Verify we're authenticated and on the app
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('Team Performance loads', async ({ page }) => {
|
||||
await page.goto('/team-performance');
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(
|
||||
page.locator('text=/Team|Performance|Agent|No data/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('Live Call Monitor loads', async ({ page }) => {
|
||||
await page.goto('/live-monitor');
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(
|
||||
page.locator('text=/Live|Monitor|Active|No active/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('Leads page loads', async ({ page }) => {
|
||||
await page.goto('/leads');
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(
|
||||
page.locator('text=/Lead|No leads/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('Patients page loads', async ({ page }) => {
|
||||
await page.goto('/patients');
|
||||
await waitForApp(page);
|
||||
|
||||
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
|
||||
await expect(search.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Appointments page loads', async ({ page }) => {
|
||||
await page.goto('/appointments');
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(
|
||||
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('Call Log page loads', async ({ page }) => {
|
||||
await page.goto('/call-history');
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(page.locator('text="Call History"').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Call Recordings page loads', async ({ page }) => {
|
||||
await page.goto('/call-recordings');
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(
|
||||
page.locator('text=/Recording|No recording/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('Missed Calls page loads', async ({ page }) => {
|
||||
await page.goto('/missed-calls');
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(
|
||||
page.locator('text=/Missed|No missed/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('Campaigns page loads', async ({ page }) => {
|
||||
await page.goto('/campaigns');
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(
|
||||
page.locator('text=/Campaign|No campaign/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('Settings page loads', async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
await waitForApp(page);
|
||||
|
||||
await expect(
|
||||
page.locator('text=/Settings|Configuration/i').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('sidebar has expected nav items', async ({ page }) => {
|
||||
const sidebar = page.locator('aside').first();
|
||||
// Check key items — exact labels depend on the role the sidecar assigns
|
||||
for (const item of ['Patients', 'Appointments', 'Campaigns']) {
|
||||
await expect(sidebar.locator(`text="${item}"`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -42,6 +42,7 @@
|
||||
"tailwindcss-react-aria-components": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"@types/jssip": "^3.5.3",
|
||||
@@ -1077,6 +1078,22 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/autocomplete": {
|
||||
"version": "3.0.0-rc.6",
|
||||
"resolved": "http://localhost:4873/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz",
|
||||
@@ -5471,6 +5488,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "http://localhost:4873/postcss/-/postcss-8.5.8.tgz",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"tailwindcss-react-aria-components": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"@types/jssip": "^3.5.3",
|
||||
|
||||
65
playwright.config.ts
Normal file
65
playwright.config.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
retries: 1,
|
||||
workers: 1,
|
||||
reporter: [['html', { open: 'never' }], ['list']],
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? 'https://ramaiah.engage.healix360.net',
|
||||
headless: true,
|
||||
screenshot: 'on',
|
||||
trace: 'on-first-retry',
|
||||
actionTimeout: 8_000,
|
||||
navigationTimeout: 15_000,
|
||||
},
|
||||
projects: [
|
||||
// Login tests run first — fresh browser, no saved auth
|
||||
{
|
||||
name: 'login',
|
||||
testMatch: /agent-login\.spec\.ts/,
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
// Auth setup — saves CC agent session for reuse
|
||||
{
|
||||
name: 'agent-setup',
|
||||
testMatch: /auth\.setup\.ts/,
|
||||
},
|
||||
// CC Agent feature tests — reuse saved auth
|
||||
{
|
||||
name: 'cc-agent',
|
||||
dependencies: ['agent-setup'],
|
||||
use: {
|
||||
storageState: path.join(__dirname, 'e2e/.auth/agent.json'),
|
||||
browserName: 'chromium',
|
||||
},
|
||||
testMatch: /agent-smoke\.spec\.ts/,
|
||||
},
|
||||
// Supervisor tests — logs in fresh each run
|
||||
{
|
||||
name: 'supervisor',
|
||||
testMatch: /supervisor-smoke\.spec\.ts/,
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
// Global Hospital — auth setup + smoke tests
|
||||
{
|
||||
name: 'global-setup',
|
||||
testMatch: /global-setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: 'global',
|
||||
dependencies: ['global-setup'],
|
||||
testMatch: /global-smoke\.spec\.ts/,
|
||||
use: {
|
||||
storageState: path.join(__dirname, 'e2e/.auth/global-agent.json'),
|
||||
browserName: 'chromium',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -68,6 +68,14 @@ async function ensureWorkspaceContext() {
|
||||
async function mkMember(email: string, password: string, firstName: string, lastName: string, roleName?: string): Promise<string> {
|
||||
await ensureWorkspaceContext();
|
||||
|
||||
// Check if already exists
|
||||
const existing = await gql('{ workspaceMembers { edges { node { id userEmail } } } }');
|
||||
const found = existing.workspaceMembers.edges.find((e: any) => e.node.userEmail.toLowerCase() === email.toLowerCase());
|
||||
if (found) {
|
||||
console.log(` (exists) ${email} → ${found.node.id}`);
|
||||
return found.node.id;
|
||||
}
|
||||
|
||||
// Create the user + link to workspace
|
||||
await gql(
|
||||
`mutation($email: String!, $password: String!, $workspaceId: UUID!, $workspaceInviteHash: String!) {
|
||||
@@ -99,11 +107,37 @@ async function mkMember(email: string, password: string, firstName: string, last
|
||||
return memberId;
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
// Delete in reverse dependency order
|
||||
const entities = ['followUp', 'leadActivity', 'call', 'appointment', 'lead', 'patient', 'doctorVisitSlot', 'doctor', 'campaign', 'clinic'];
|
||||
for (const entity of entities) {
|
||||
const cap = entity[0].toUpperCase() + entity.slice(1);
|
||||
try {
|
||||
const data = await gql(`{ ${entity}s(first: 100) { edges { node { id } } } }`);
|
||||
const ids: string[] = data[`${entity}s`].edges.map((e: any) => e.node.id);
|
||||
if (ids.length === 0) { console.log(` ${cap}: 0 records`); continue; }
|
||||
for (const id of ids) {
|
||||
await gql(`mutation { delete${cap}(id: "${id}") { id } }`);
|
||||
}
|
||||
console.log(` ${cap}: deleted ${ids.length}`);
|
||||
} catch (err: any) {
|
||||
console.log(` ${cap}: skip (${err.message?.slice(0, 60)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Seeding Helix Engage demo data...\n');
|
||||
await auth();
|
||||
console.log('✅ Auth OK\n');
|
||||
|
||||
// Clean slate — remove all existing entity data (not users)
|
||||
console.log('🧹 Clearing existing data...');
|
||||
await clearAll();
|
||||
console.log('');
|
||||
|
||||
await auth();
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// CLINICS (needed for doctor visit slots)
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -136,6 +170,25 @@ async function main() {
|
||||
|
||||
await auth();
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// CALL CENTER & MARKETING STAFF
|
||||
//
|
||||
// CC agents (HelixEngage User role) handle inbound/outbound calls.
|
||||
// Marketing executives and supervisors use HelixEngage Supervisor role.
|
||||
// Email domain uses globalcare.com to match the deployment.
|
||||
// ═══════════════════════════════════════════
|
||||
console.log('📞 Call center & marketing staff');
|
||||
const wmRekha = await mkMember('rekha.cc@globalcare.com', 'Global@123', 'Rekha', 'Nair', 'HelixEngage User');
|
||||
console.log(` Rekha (CC Agent): ${wmRekha}`);
|
||||
const wmGanesh = await mkMember('ganesh.cc@globalcare.com', 'Global@123', 'Ganesh', 'Iyer', 'HelixEngage User');
|
||||
console.log(` Ganesh (CC Agent): ${wmGanesh}`);
|
||||
const wmSanjay = await mkMember('sanjay.marketing@globalcare.com', 'Global@123', 'Sanjay', 'Verma', 'HelixEngage Supervisor');
|
||||
console.log(` Sanjay (Marketing): ${wmSanjay}`);
|
||||
const wmRamesh = await mkMember('dr.ramesh@globalcare.com', 'Global@123', 'Ramesh', 'Gupta', 'HelixEngage Supervisor');
|
||||
console.log(` Dr. Ramesh (Supervisor): ${wmRamesh}\n`);
|
||||
|
||||
await auth();
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// DOCTOR WORKSPACE MEMBERS
|
||||
//
|
||||
|
||||
@@ -46,12 +46,12 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
|
||||
try {
|
||||
if (newStatus === 'ready') {
|
||||
console.log('[AGENT-STATE] Changing to Ready');
|
||||
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
|
||||
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' });
|
||||
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
|
||||
} else {
|
||||
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
|
||||
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`);
|
||||
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason });
|
||||
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason });
|
||||
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
|
||||
}
|
||||
// Don't setStatus — SSE will push the real state
|
||||
|
||||
@@ -59,14 +59,18 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
onOpenChange(false);
|
||||
setOtp('');
|
||||
} else {
|
||||
// Standard sidecar endpoint
|
||||
// Standard sidecar endpoint — include agentId from agent config
|
||||
const agentCfg = localStorage.getItem('helix_agent_config');
|
||||
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
|
||||
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
|
||||
|
||||
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-maint-otp': otp,
|
||||
},
|
||||
...(preStepPayload ? { body: JSON.stringify(preStepPayload) } : {}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
|
||||
@@ -6,6 +6,7 @@ let sipClient: SIPClient | null = null;
|
||||
let connected = false;
|
||||
let outboundPending = false;
|
||||
let outboundActive = false;
|
||||
let activeAgentId: string | null = null;
|
||||
|
||||
type StateUpdater = {
|
||||
setConnectionStatus: (status: ConnectionStatus) => void;
|
||||
@@ -42,6 +43,16 @@ export function connectSip(config: SIPConfig): void {
|
||||
sipClient.disconnect();
|
||||
}
|
||||
|
||||
// Resolve agent identity for logging
|
||||
try {
|
||||
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
|
||||
activeAgentId = agentCfg.ozonetelAgentId ?? null;
|
||||
const ext = config.uri?.match(/sip:(\d+)@/)?.[1] ?? 'unknown';
|
||||
console.log(`[SIP] Connecting agent=${activeAgentId} ext=${ext} ws=${config.wsServer}`);
|
||||
} catch {
|
||||
console.log(`[SIP] Connecting uri=${config.uri}`);
|
||||
}
|
||||
|
||||
connected = true;
|
||||
stateUpdater?.setConnectionStatus('connecting');
|
||||
|
||||
@@ -62,7 +73,7 @@ export function connectSip(config: SIPConfig): void {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SIP-MGR] State: ${state} | caller=${number ?? 'none'} | ucid=${ucid ?? 'none'} | outboundActive=${outboundActive}`);
|
||||
console.log(`[SIP] ${activeAgentId} | state=${state} | caller=${number ?? 'none'} | ucid=${ucid ?? 'none'} | outbound=${outboundActive}`);
|
||||
|
||||
stateUpdater?.setCallState(state);
|
||||
if (!outboundActive && number !== undefined) {
|
||||
@@ -90,12 +101,13 @@ export function disconnectSip(force = false): void {
|
||||
console.log('[SIP-MGR] Disconnect blocked — call in progress');
|
||||
return;
|
||||
}
|
||||
console.log('[SIP-MGR] Disconnecting SIP' + (force ? ' (forced)' : ''));
|
||||
console.log(`[SIP] Disconnecting agent=${activeAgentId}` + (force ? ' (forced)' : ''));
|
||||
sipClient?.disconnect();
|
||||
sipClient = null;
|
||||
connected = false;
|
||||
outboundPending = false;
|
||||
outboundActive = false;
|
||||
activeAgentId = null;
|
||||
stateUpdater?.setConnectionStatus('disconnected');
|
||||
stateUpdater?.setCallUcid(null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user