23 Incheckningar

Upphovsman SHA1 Meddelande Datum
Sravan 73b40a5d9f feat(mobile): Android prep — icons/splash assets, permissions, FCM setup
- resources/: 1024 icon.png + 2732 splash.png/splash-dark.png generated from the
  PWA icon + brand blue; wired @capacitor/assets (npm run assets) + splash-screen plugin.
- ANDROID_SETUP.md: end-to-end guide (SDK setup, cap add android, manifest permissions,
  Firebase/google-services.json + Gradle, run, Play AAB build) for package com.bizgaze.connect.
- android-permissions.xml: paste-ready POST_NOTIFICATIONS + camera/mic/WebRTC perms.
- mobile/README links the guide; setup adds `npm run assets`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:18:11 +05:30
Sravan 7ae0cacf74 feat(push): wire Capacitor native push into the web UI
home.html: in a Capacitor app shell, setupPush() now uses the native FCM/APNs path
instead of Web Push — requests permission, registers, POSTs the OS device token to
/api/v1/devices, deep-links on notification tap (selectChat), and unregisters the
token on logout. Web Notification prompts are suppressed on native. Fully inert in a
normal browser (Web Push unchanged). build batch15.

CLIENTS.md Phase B push items checked off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:56:59 +05:30
Sravan 4c75db2029 feat(push): native device-token registration + FCM/APNs senders
- /api/v1/devices (register) + /api/v1/devices/remove — auth-required, validates
  platform (ios|android), upserts by token; e2e covers register/validation/auth/remove.
- db device_tokens table + deviceTokens repo.
- push.js: FCM HTTP v1 (Android) and APNs token-based over HTTP/2 (iOS) folded into
  the single push.sendToUser path alongside Web Push; each transport independently
  config-gated and a silent no-op without creds. Dead tokens pruned on 404/410.
- docs: CLIENTS.md Phase B updated; DEPLOY.md env table adds FCM/APNs vars.

e2e 117/117.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:23:10 +05:30
Sravan 593a4677b6 feat(clients): scaffold mobile (Capacitor) + desktop (Electron) shells
Plan + decisions in CLIENTS.md (parallel mobile+desktop; desktop = technician
client + existing remote-control agent host; mobile = Capacitor wrap).

- desktop/: Electron technician client — loads the live Connect UI, native
  screen capture via setDisplayMediaRequestHandler, persisted session, external
  links to browser; electron-builder config for Win/Mac/Linux installers.
- mobile/: Capacitor project — server.url loads Connect UI, push/camera/status-bar
  plugins declared, www splash fallback; iOS/Android added via `cap add`.
- Reuses the existing /api/v1 + Bearer auth backend; no web-code changes.
- .gitignore: ignore generated mobile/android, mobile/ios platform dirs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:49:41 +05:30
Sravan f517c153c1 docs(deploy): add operational guardrails + env/verification checklist for IT
Single-instance requirement, ALLOW_LOCAL_LOGIN-off, server-side directory token,
no-store HTML, Node>=22.5/web-push, required env vars (SSO/VAPID/TURN), and the
window.__BUILD per-release verification step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:06:08 +05:30
Sravan 06f0b08a18 feat(chat): rich shared-media view, status selector, drag-drop upload + fixes
Chat / shared media:
- Media/Docs/Links: clean underline tabs (green active), audio & video now
  classified as Media and rendered as tiles (download + headphone/play +
  duration) instead of broken-image glyphs; image thumbnails -> lightbox
- Drag-and-drop a file/video/image onto a conversation to send it
- Fix: removed #chatPanel{position:relative} override that collapsed the
  conversation pane (messages spilled into a clipped right-edge strip)
- "Media, links & docs" row cleaned up (no folder/placeholder icon); media
  popup keeps the back arrow, drops the redundant close button

Presence / status:
- Single current-status row with an arrow that expands Available/Away/On leave
- On leave = circle with minus, In a call = solid red indicators
- Fix: selected-status tick now follows the chosen option

Icons: added headphones + play; bumped icons.js cache-bust to v4

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:01:15 +05:30
Sravan e9e5c7f406 fix(pwa): white icon tile for contrast + cache-bust icon URLs (v2)
Logo was dark-on-blue (low contrast); now centered on a white tile like the
header treatment. Icon URLs versioned (?v=2) so browsers/installs fetch the new
ones. Build marker -> pwa2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:02:11 +05:30
Sravan a427be9b6f fix(cache): send Cache-Control: no-store on all JSON/404 responses
Prevents a 404 (e.g. /manifest.json fetched before deploy) from being cached on
a device and persisting after the file exists — the cause of the manifest 404
on mobile but not desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:58:23 +05:30
Sravan b576ed372a feat(pwa): installable app (Add to Home Screen) for Android + iOS
- manifest.json (standalone display, theme color, maskable icons 192/512).
- generated square icons + apple-touch-icon (180) from the logo.
- apple-mobile-web-app + theme-color meta in home.html.
- sw.js gets a no-op fetch handler so it meets installability criteria (still
  no caching). static.js serves .json/.webmanifest with correct MIME.
- Installing as a PWA also unlocks Web Push on iOS (Apple requires Add to Home Screen).
Build marker -> pwa1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:13:01 +05:30
Sravan f4a23ae805 fix(cache): serve HTML with no-store so deploys reach browsers without a hard refresh
Browsers were serving a cached old home.html on normal reloads (only incognito/
hard-refresh got the new one). HTML now sends Cache-Control: no-store; versioned
assets keep ETag revalidation. Bumps build marker to push4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:30:17 +05:30
Sravan f7ddb2e7ae fix(push): wait for active SW before subscribe + log every step (subscribe was failing silently)
subscribePush() swallowed all errors, so if pushManager.subscribe() failed
(e.g. called before the service worker was active) nobody ever subscribed and
there was no trace. Now: await serviceWorker.ready before subscribing, and
console.log/warn each step so the real failure is visible. Server send path
verified independently (web-push builds valid VAPID requests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:57:03 +05:30
Sravan 5edb3fa241 fix(chat): dedup sent message in sendMessage too (WS echo can beat the POST response)
The server echoes the sender's own message over WS before returning the HTTP
response, so onChatMessage could append it before sendMessage's await resolved,
then sendMessage appended again -> double. Both append paths now dedup by id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:39:42 +05:30
Sravan 88d7657364 chore: add build marker (window.__BUILD) to home.html for deploy verification
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:38:35 +05:30
Sravan 1272b81cee feat(push): Web Push notifications for backgrounded/closed/mobile tabs
Page-level Notifications can't fire when a tab is frozen/closed (and never on
mobile), which is why recipients on another tab/app got nothing. Adds a
notification-only service worker (sw.js, no caching) + Web Push:

- push.js: optional web-push wrapper (no-op unless web-push installed AND
  VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY set -> app unaffected if unconfigured).
- push_subscriptions table + R.pushSubs repo (upsert by endpoint, prune dead).
- /api/push/vapid|subscribe|unsubscribe; DM + group message routes also send a
  Web Push to recipients.
- Client registers /sw.js, subscribes when permission granted; hidden-tab popups
  are left to push to avoid double-notifying (pushActive flag); SW suppresses the
  OS popup when a tab is visible. Removes the old code that unregistered SWs.

Requires (prod, once): npm install + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:58:49 +05:30
Sravan d50d4bde47 fix(icons): proper end-call (hang-up) glyph + cache-bust icons.js (v3)
- callEnd is now a rotated-handset hang-up icon (was a phone-off placeholder).
- All pages reference /icons.js?v=3 so browsers/proxies fetch the corrected
  file instead of a stale cached copy (fixes 'old end icon' + icons not
  appearing until a re-render when an old/404 icons.js was cached).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:47:24 +05:30
Sravan 1f4516d69b fix(chat): dedup own echoed message so sent messages don't show twice
Server echoes your own message back over WS (multi-tab/device sync) and
sendMessage already appended it optimistically; onChatMessage now skips the
append if the id is already in the thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:06:00 +05:30
Sravan fcd6a60baa fix(prod): add missing public/icons.js (was untracked -> 404 in prod)
icons.js was never committed (untracked, lost from disk), so every page
404'd /icons.js and stalled at Loading. Restored from commit e05a788 and
added 16 icons referenced by current code but absent in that snapshot
(bell, bold, italic, strikethrough, code, list, listOrdered, type, crown,
checkCheck, calendarX, calendarClock, fileText, record, callEnd, settings).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:41:44 +05:30
Sravan bda63b6f0a Merge origin/master (TURN/coturn + BizGaze-only login) into feature tree
Resolved conflicts in routes.js and share.html: kept the dev tree's superset
(ALLOW_LOCAL_LOGIN dev escape, avatar sync, richer login errors) which already
includes the incoming production BizGaze-only behavior; took the more descriptive
incoming comments. Restored 5 untracked modules (chat, calls, directory,
reminders, webhooks) that were missing from disk — required by routes/signaling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:27:59 +05:30
Sravan 27355cec76 BizGaze Connect: chat, meetings, recordings, mobile, directory + UI fixes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:15:29 +05:30
Sravan 0a739ee2fd Merge branch 'hotfix/bizgaze-only-login' of Sravan/BizGaze_Remote into master 2026-06-16 09:21:35 +00:00
Sravan 54b74d5db1 feat(turn): self-hosted coturn support + time-limited creds + failure UX
- /api/ice: when TURN_SECRET is set, mint short-lived HMAC credentials
  (coturn use-auth-secret) so no permanent password is exposed and the relay
  can't be abused. Static TURN_USERNAME/CREDENTIAL still supported.
- share.html: connection watchdog + clear "couldn't connect on this network"
  message instead of a blank screen when no path can be established.
- deploy/coturn: ready-to-run turnserver.conf + docker-compose + README for
  hosting our own TURN on a VM we own (flat cost, no per-GB billing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:36:05 +05:30
Sravan 6ac280f178 fix(webrtc): use TURN on desktop too (screen share blank/disconnect)
TURN relay candidates were applied only when IS_MOBILE, leaving desktop
clients STUN-only. Customers behind symmetric NAT / corporate firewalls /
VPNs then couldn't establish the peer connection -> connectionState 'failed'
-> "connection lost" -> blank screen right after granting permissions. This
hit only some users (those needing a relay).

Apply the /api/ice config (STUN + managed TURN) regardless of device, in both
the customer (share.html) and agent (connect.html) flows. Requires TURN_URLS /
TURN_USERNAME / TURN_CREDENTIAL to be set in the production environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:15:06 +05:30
Sravan caba3b3a21 Merge branch 'hotfix/bizgaze-only-login' of Sravan/BizGaze_Remote into master 2026-06-16 05:24:28 +00:00
55 ändrade filer med 6086 tillägg och 262 borttagningar
+5
Visa fil
@@ -29,9 +29,14 @@ dist/
build/
out/
# Native client generated/build artifacts (Capacitor adds platform dirs; Electron builds to dist)
mobile/android/
mobile/ios/
# Runtime media (created at startup by config.js)
server/recordings/
server/transcripts/
server/uploads/
# OS files
.DS_Store
+13 -4
Visa fil
@@ -133,10 +133,19 @@ viewer CONTROLS the remote screen** (reuses the WebRTC `inputChannel` + OS input
- [x] **`Authorization: Bearer <token>`** accepted in `currentUser()` across HTTP + WS, alongside the
cookie (session.js `tokenFromReq`); `/api/login` now also returns the `token` for native clients.
WS upgrades carry the token in the Authorization header (native) or `?access_token=` (browser fallback).
- [ ] **Refresh tokens** (short access token + long refresh) so native apps stay signed in safely.
- [ ] **API keys** table + middleware (scoped per *tenant*, hashed at rest).
- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile.
- [ ] **OIDC/JWT** SSO; per-tenant **webhook subscriptions** with retries.
- [x] **Refresh tokens** `/api/v1/auth/refresh` exchanges a long-lived (90d) refresh token for a
fresh access token, with **rotation** (old token revoked on use) + replay rejection. Stored as a
SHA-256 hash (`refresh_tokens` table). Login returns one; logout/deactivate/reset/delete revoke them.
Web/cookie path unchanged.
- [x] **API keys** — admin-managed (`POST/GET /api/v1/keys`, `POST /api/v1/keys/revoke`), per-tenant,
scoped (`report:read`, `audit:read`), `bzc_`-prefixed, SHA-256 hashed at rest, shown once. Accepted via
`X-API-Key` or `Authorization: Bearer bzc_…` on `/api/v1/report` + `/api/v1/audit` with scope enforcement.
- [x] **Per-tenant webhook subscriptions** — admin-managed (`/api/v1/webhooks` create/list/delete +
`/webhooks/events`), each with its own signing secret; events `session.started`/`session.ended`
delivered HMAC-SHA256-signed (`X-BizGaze-Signature`) with in-memory retries. Legacy global
`BIZGAZE_WEBHOOK_URL` still works. (webhooks.js + signaling emits.)
- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile — needs Apple/Google creds to test.
- [ ] **OIDC/JWT** SSO — needs an identity provider to test against.
### Phase 3 — Licensing + scale (last, per priority)
- [ ] Elevate **tenant → `organization`** (additive migration: add `organizations`, keep `team_id`
+6 -2
Visa fil
@@ -111,8 +111,12 @@ First registered user becomes admin; registration then closes (unless ALLOW_REGI
the dev team for: shared secret, token format (JWT preferred), SSO start URL,
signup URL, role mapping. (See BizGaze-Connect-SSO-SPEC.md.)
2. **New post-login home (NEXT TASK)** — see below.
3. **Persistent chat** (Slack-style 1:1 + group messaging between registered users) — large new system.
4. **Meetings** (multi-party video) — large new system (needs SFU or mesh).
3. **Persistent chat** — 1:1 messaging is BUILT (messages table, `/api/v1/messages/*`, live delivery
over `/ws` via `chat-hello`/`chat-message`, wired into home.html). Group chat is the remaining part.
4. **Meetings** (multi-party video) — **mesh (P2P) MVP BUILT**: in-memory rooms + signaling
(`meeting-create/join/signal/leave` in signaling.js), video-grid UI in home.html's Meeting tab
(start/join by 6-digit code, mic/cam toggles, leave). Good for small groups; **SFU upgrade** is
the next step for larger rooms.
5. **Downloadable Android app** — the only way to support phone screen-sharing.
## NEXT TASK: new post-login home (start with a mockup)
+116
Visa fil
@@ -0,0 +1,116 @@
# Biz Connect — Mobile & Desktop clients
Native clients for Biz Connect. **The web app is the single source of truth for the UI**;
each native client is a thin shell that loads that UI and adds the capabilities a browser
can't provide (background push, native screen capture, OS input injection, store presence).
Decisions (set by the user 2026-06-30):
- **Build mobile and desktop in parallel.**
- **Desktop = both pieces:** the remote-control **host** (existing `agent/`) *and* a
**technician desktop client** (`desktop/` — Connect in a window).
- **Mobile = Capacitor wrap** of the existing web UI (one codebase), not a native rewrite.
Backend is already client-ready (see [ARCHITECTURE.md](ARCHITECTURE.md) Phase 2): `/api/v1`,
`Authorization: Bearer` + refresh tokens, API keys, per-tenant webhooks. The one remaining
backend gap is **APNs/FCM push** for native mobile (needs Apple/Google credentials).
---
## Components
| Dir | Client | Tech | What it adds over the browser |
|-----|--------|------|-------------------------------|
| `mobile/` | iOS + Android app | **Capacitor** loading the Connect UI | Native push (FCM/APNs), camera/mic perms, store distribution, screen capture (ReplayKit / MediaProjection) |
| `desktop/` | Technician client | **Electron** loading the Connect UI | Native full-screen capture for screen-share; windowed app; later: tray, auto-update |
| `agent/` | Remote-control **host** (customer) | **Electron + nut-js** *(exists, v0.2.0)* | Screen capture + **OS input injection** so a technician can control the machine |
All three authenticate through the same `/api/v1` access layer (Bearer token for mobile/desktop,
the existing consent/enroll flow for the agent).
---
## Why "wrap the web UI" (not rewrite)
The Connect UI is already an installable PWA built from server-rendered single-file HTML.
Pointing a native webview at the server origin means **every relative `/api` and `/ws` URL
keeps working unchanged** — zero web-code changes — while native plugins augment it through
the JS bridge. One codebase, three shells.
Trade-off: a server-URL shell needs network at launch (fine for a remote-support tool) and
some stores scrutinise "just a website". We mitigate by shipping real native capabilities
(push, screen capture, deep links), and can later switch to **bundled** web assets + an
absolute API base if offline-launch or store policy requires it.
---
## Phased plan
### Phase A — Shells that load the live app ← start here
- [ ] `desktop/` Electron client: `loadURL(server)`, `setDisplayMediaRequestHandler` so
screen-share works natively, external links → browser, persisted session.
- [ ] `mobile/` Capacitor project: `server.url` → Connect, app icons/splash, status bar.
- [ ] Inject `window.__NATIVE__ = 'desktop' | 'ios' | 'android'` so the web UI can adapt
(e.g. hide the PWA "install" prompt, enable native push instead of Web Push).
### Phase B — Native capabilities
- [x] **Push backend:** device-token registration (`POST /api/v1/devices`,
`/api/v1/devices/remove`) + native senders folded into the single `push.sendToUser`
path — **FCM v1** (Android) and **APNs** token-based over HTTP/2 (iOS), each
config-gated and a no-op until creds are set. Web Push (VAPID) unchanged. Dead
tokens are pruned on 404/410/UNREGISTERED. (db `device_tokens`, repo `deviceTokens`,
`push.js`.) *Mobile app still needs the Capacitor push plugin wired + FCM/APNs creds
to deliver end-to-end.*
- [x] **Capacitor push plugin wired** in the web UI (`setupNativePush` in home.html):
inside the app it requests permission, registers, and `POST`s the FCM/APNs token to
`/api/v1/devices`; notification taps deep-link via `selectChat`; logout unregisters the
token. Web Push is skipped when running natively. Inert in a normal browser. *Activates
once the Capacitor Android/iOS app is built and FCM/APNs creds are set.*
- [ ] **Mobile screen capture** for "Share Screen" from a phone (ReplayKit / MediaProjection plugin).
- [ ] **Deep links / universal links** so a session/meeting link opens the app.
### Phase C — Packaging & distribution *(needs external accounts)*
- [ ] Desktop installers via **electron-builder** (Win NSIS + Mac dmg); **code-signing**
(Win EV cert, Apple Developer ID + notarization).
- [ ] Mobile store builds: **Apple Developer** ($99/yr) + **Google Play** ($25 once);
signing keys; store listings & privacy disclosures.
- [ ] Agent host installer (signed) for customers.
- [ ] Auto-update channels.
---
## Build & run
### Desktop (technician client)
```bash
cd desktop
npm install
SERVER_URL=https://remote.bizgaze.com npm start # or http://localhost:8090 in dev
npm run dist # build installers (needs electron-builder + certs)
```
### Mobile (Capacitor)
```bash
cd mobile
npm install
npx cap add android # needs Android Studio + SDK
npx cap add ios # needs macOS + Xcode
npx cap sync
npx cap open android # build/run from Android Studio
npx cap open ios # build/run from Xcode
```
Set the server origin in `capacitor.config.json` (`server.url`).
### Agent host (existing)
```bash
cd agent
npm install
SERVER_URL=https://remote.bizgaze.com AGENT_ENROLL_TOKEN=<token> npm start
```
---
## What's gated on you (external, can't be done from code alone)
- **Apple Developer** + **Google Play** accounts (mobile store builds & push).
- **Code-signing certificates** (Windows EV, Apple Developer ID) for trusted installers.
- **FCM/APNs credentials** for native push.
Everything else — the shells, the device/push backend, native plugin wiring — is built here.
+46
Visa fil
@@ -21,6 +21,52 @@ Server facts:
---
## Operational guardrails (read before every deploy)
These are correctness/security invariants, not preferences. Breaking one degrades
or breaks the app even if the container starts fine.
- **Single instance only.** Chat, presence, and meeting (WebRTC) signaling use an
**in-process** registry. Do **not** scale to multiple replicas or place several
instances behind a round-robin load balancer — users on different processes
can't see each other's messages/calls. One container, one process.
- **`ALLOW_LOCAL_LOGIN` must NOT be set in production.** It's a dev-only escape
hatch that bypasses BizGaze SSO and the local-password lockout. Production logs
in via BizGaze only.
- **`BIZGAZE_DIRECTORY_TOKEN` is server-side only** — it's used by the server to
proxy directory lookups and must never be exposed to the browser/client.
- **HTML is served `Cache-Control: no-store` by design** so new builds land
immediately. Do not add an HTTP/CDN cache layer that caches `.html`. Static JS
(`icons.js`) is cache-busted with a `?v=` query, currently `?v=4`.
- **Node ≥ 22.5** (the image uses `node:24-alpine`) — required for the built-in
`node:sqlite` that `db.js` relies on. `deploy.sh` rebuilds the image, so
`npm install` (incl. `web-push`) happens automatically; no manual install.
- **No DB migration is required** for routine UI/chat releases. The `data.db`
volume persists across rebuilds; schema changes (when present) auto-apply on boot.
### Env vars to confirm in `.env`
`.env` lives only on the server (never in git) and must contain, beyond the TURN
secrets already documented:
| Group | Vars | Needed for |
|-------|------|-----------|
| Login / SSO | `BIZGAZE_LOGIN_URL`, `BIZGAZE_DIRECTORY_URL`, `BIZGAZE_DIRECTORY_TOKEN`, `SSO_SECRET` | BizGaze sign-in + directory search |
| Web Push | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | Background push for browsers / installed PWA |
| Native push — Android | `FCM_SERVICE_ACCOUNT` (path to / inline Firebase service-account JSON) | FCM push to the Android app |
| Native push — iOS | `APNS_KEY` (path/inline `.p8`), `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_BUNDLE_ID`, `APNS_PRODUCTION=1` | APNs push to the iOS app |
| Calls | `TURN_URLS` / `TURN_SECRET` (or `TURN_USERNAME`+`TURN_CREDENTIAL`) | Audio/video across NATs & mobile networks |
If the VAPID keys are missing, push silently no-ops (the app still runs). Push on
iOS additionally requires the user to **Add to Home Screen** (iOS 16.4+) — an
end-user step, not ops.
### Per-release verification
After deploy, open the app and check the browser console logs the expected build,
e.g. `Biz Connect build 2026-06-30-batch14`. That confirms the new HTML is being
served (not a stale cache).
---
## One-time bootstrap (server → git clone)
Run **once** to convert the existing folder into a git checkout without losing the
+44
Visa fil
@@ -0,0 +1,44 @@
# Self-hosted TURN (coturn) for BizGaze Connect
Why: customers behind symmetric NAT / corporate firewalls / VPNs can't form a direct
WebRTC path, so screen share blanks out and disconnects. A TURN relay fixes it. We host
our own coturn on a VM we already own — flat cost, no per-GB billing.
## 1. VM prerequisites
- A VM with a **public IP** (your data-center VM is fine).
- A DNS A record, e.g. `turn.yourdomain.com` -> that public IP.
- A TLS cert for that name (Let's Encrypt): `certbot certonly --standalone -d turn.yourdomain.com`
## 2. Open firewall ports (on the VM and any edge firewall)
- `3478/udp` and `3478/tcp` (STUN/TURN)
- `5349/tcp` (TURN over TLS) — and `443/tcp` if you enable alt-tls
- `49152-65535/udp` (relay range)
## 3. Configure
Edit `turnserver.conf`:
- `external-ip=` your VM's public IP
- `static-auth-secret=` a long random string (e.g. `openssl rand -hex 32`)
- `realm=` your domain
- `cert=` / `pkey=` paths to your Let's Encrypt cert
## 4. Run
```
docker compose up -d # uses docker-compose.yml here
# or native: apt install coturn; copy this file to /etc/turnserver.conf; enable in /etc/default/coturn; systemctl enable --now coturn
```
## 5. Point the app at it (production env)
```
TURN_URLS=turn:turn.yourdomain.com:3478,turn:turn.yourdomain.com:3478?transport=tcp,turns:turn.yourdomain.com:5349?transport=tcp
TURN_SECRET=<the same static-auth-secret from turnserver.conf>
TURN_TTL=86400
```
The app's `/api/ice` mints short-lived credentials from `TURN_SECRET` automatically — no
permanent password is exposed, and outsiders can't reuse your relay. Restart the app.
## 6. Verify
- `GET https://<app>/api/ice` should return a `turn:`/`turns:` entry with a username + credential.
- Test page: https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
Add your `turns:turn.yourdomain.com:5349?transport=tcp` with the username/credential from
`/api/ice`; you should see a candidate of type **relay**. If you do, restrictive networks
are covered.
+12
Visa fil
@@ -0,0 +1,12 @@
# Run coturn on your VM: docker compose up -d
# host networking is required so the UDP relay port range works without per-port mapping.
services:
coturn:
image: coturn/coturn:latest
container_name: coturn
restart: unless-stopped
network_mode: host
volumes:
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro # TLS cert for turns:
command: ["-c", "/etc/coturn/turnserver.conf"]
+45
Visa fil
@@ -0,0 +1,45 @@
# coturn config for BizGaze Connect self-hosted TURN.
# Put this on your VM (public IP) and run via Docker (see docker-compose.yml) or
# native coturn (apt install coturn). Replace every CHANGE_ME / placeholder.
# --- listening ---
listening-port=3478
tls-listening-port=5349
# If this VM has a spare 443, also exposing TURNS on 443 gives the best traversal
# through strict corporate firewalls (uncomment + ensure nothing else uses 443):
# alt-tls-listening-port=443
# Public address clients reach. If the VM has a 1:1 NAT, use external-ip=PUBLIC/PRIVATE.
external-ip=CHANGE_ME_PUBLIC_IP
# Relay port range (open these UDP ports in the firewall too).
min-port=49152
max-port=65535
# --- auth: time-limited shared-secret credentials (matches the app's TURN_SECRET) ---
use-auth-secret
static-auth-secret=CHANGE_ME_LONG_RANDOM_SECRET
realm=connect.yourdomain.com
# --- TLS (needed for turns: on 5349/443). Use a real cert for turn.yourdomain.com ---
cert=/etc/letsencrypt/live/turn.yourdomain.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.yourdomain.com/privkey.pem
# --- hardening ---
fingerprint
no-cli
no-multicast-peers
no-tcp-relay
# Block relaying to private/internal ranges (prevents your relay being used to reach
# your own LAN / cloud metadata — important SSRF protection):
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=::1
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
# Optional: cap per-session bandwidth (bytes/sec) to protect the VM, e.g. 700000 = ~5.6 Mbps
# bps-capacity=0
# total-quota=100
+26
Visa fil
@@ -0,0 +1,26 @@
# Biz Connect — Desktop client
Electron shell that loads the live Connect web UI and adds native screen capture. See the
overall plan in [../CLIENTS.md](../CLIENTS.md).
## Run (dev)
```bash
npm install
SERVER_URL=http://localhost:8090 npm start # default is https://remote.bizgaze.com
```
## Build installers
```bash
npm run dist # electron-builder → Win NSIS / Mac dmg / Linux AppImage
```
Signed, trusted installers need certificates:
- **Windows:** an EV (or OV) code-signing certificate.
- **macOS:** Apple Developer ID cert + notarization (`CSC_LINK`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`).
## Notes
- The window loads `${SERVER_URL}/home`; relative `/api` and `/ws` URLs work because the
origin is the server itself — no web-code changes.
- `setDisplayMediaRequestHandler` in `main.js` is what makes "Share Screen" work in Electron;
it currently defaults to the primary display. Swap in a source-picker for production.
- The session is persisted (`persist:bizconnect`) so login survives restarts.
- `window.__NATIVE__ === 'desktop'` is exposed for the web UI to feature-detect.
+62
Visa fil
@@ -0,0 +1,62 @@
// Biz Connect — technician desktop client (Electron main process).
//
// This is a thin shell: it loads the live Connect web UI from the server origin, so every
// relative /api and /ws URL in the web app keeps working unchanged. What it adds over a
// browser tab:
// - native full-screen capture for "Share Screen" (setDisplayMediaRequestHandler)
// - a real desktop window (no browser chrome), persisted login session
// - external links open in the user's browser, not inside the app
//
// Server origin is configurable so the same build works against prod or a dev server.
const { app, BrowserWindow, session, desktopCapturer, shell, Menu } = require('electron');
const path = require('path');
const SERVER_URL = (process.env.SERVER_URL || 'https://remote.bizgaze.com').replace(/\/+$/, '');
let win;
function createWindow() {
win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 880,
minHeight: 600,
title: 'Biz Connect',
backgroundColor: '#0f1830',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
// Persist cookies/localStorage so the technician stays logged in between launches.
partition: 'persist:bizconnect',
},
});
win.loadURL(SERVER_URL + '/home');
// Open target=_blank / external links in the system browser instead of a new Electron window.
win.webContents.setWindowOpenHandler(({ url }) => {
if (!url.startsWith(SERVER_URL)) { shell.openExternal(url); return { action: 'deny' }; }
return { action: 'allow' };
});
}
// Electron requires an explicit handler for getDisplayMedia(); without it the web UI's
// "Share Screen" silently fails. Default to the primary display with loopback audio.
// A production build can swap this for a source-picker window.
function registerDisplayMediaHandler() {
session.fromPartition('persist:bizconnect').setDisplayMediaRequestHandler((request, callback) => {
desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => {
callback(sources.length ? { video: sources[0], audio: 'loopback' } : {});
}).catch(() => callback({}));
});
}
app.whenReady().then(() => {
registerDisplayMediaHandler();
createWindow();
Menu.setApplicationMenu(null); // hide the default menu bar; the web UI is the chrome
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
});
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
+22
Visa fil
@@ -0,0 +1,22 @@
{
"name": "biz-connect-desktop",
"version": "0.1.0",
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
"main": "main.js",
"scripts": {
"start": "electron .",
"dist": "electron-builder"
},
"devDependencies": {
"electron": "^31.0.0",
"electron-builder": "^24.13.3"
},
"build": {
"appId": "com.bizgaze.connect.desktop",
"productName": "Biz Connect",
"files": ["main.js", "preload.js", "assets/**"],
"win": { "target": "nsis" },
"mac": { "target": "dmg", "category": "public.app-category.business" },
"linux": { "target": "AppImage" }
}
}
+10
Visa fil
@@ -0,0 +1,10 @@
// Minimal, safe bridge into the web UI. Runs with contextIsolation, so it only exposes a
// frozen marker the web app can feature-detect against (e.g. to hide the PWA install prompt
// or prefer native push). No Node APIs are exposed to page JS.
const { contextBridge } = require('electron');
contextBridge.exposeInMainWorld('__NATIVE__', 'desktop');
contextBridge.exposeInMainWorld('bizConnectNative', Object.freeze({
platform: 'desktop',
version: process.env.npm_package_version || '0.1.0',
}));
+110
Visa fil
@@ -0,0 +1,110 @@
# Biz Connect — Android build & push setup
Step-by-step to go from this repo to a running Android app with working FCM push.
You have **Android Studio (Quail 2026.1.1)** installed — that bundles the Android SDK and a
JDK, so no separate Java install is needed.
> The app is a Capacitor shell that loads the live Connect UI (`server.url` in
> `capacitor.config.json`, default `https://remote.bizgaze.com`). The native side only adds
> push, camera/mic, status bar, and store packaging. App id / Android package:
> **`com.bizgaze.connect`** — this must match the Firebase app you create below.
---
## 1. One-time Android Studio setup
1. Launch Android Studio once and let it finish "SDK Components Setup" (downloads the
Android SDK + platform-tools).
2. **More Actions → SDK Manager** → install **Android SDK Platform 34** (or latest) and
**Android SDK Build-Tools**.
3. To test on an emulator: **More Actions → Virtual Device Manager** → create a Pixel device
(any recent API ≥ 33 so you can test the notification permission prompt). Or enable
**USB debugging** on a physical phone and plug it in.
## 2. Generate the native Android project
```bash
cd mobile
npm install
npm run assets # builds icons/splash from resources/ (already provided)
npx cap add android # creates mobile/android/ (gitignored)
npx cap sync # copies config + web assets + plugins into the project
```
## 3. App permissions
Capacitor adds `INTERNET` automatically. Add the rest to
`mobile/android/app/src/main/AndroidManifest.xml` (inside `<manifest>`, above `<application>`).
A ready-to-paste copy is in [`android-permissions.xml`](android-permissions.xml):
```xml
<!-- Push (Android 13+ runtime prompt) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Voice / video calls + camera from the web UI -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
```
> WebRTC in the WebView: Capacitor grants `getUserMedia` to the page when the app holds the
> CAMERA/RECORD_AUDIO permissions, so the existing call/screen-share UI works once these are
> present and the user accepts the runtime prompts.
## 4. Firebase / FCM (push)
1. [Firebase console](https://console.firebase.google.com) → **Add project** (or reuse one).
2. **Add app → Android**. Package name: **`com.bizgaze.connect`**. Register.
3. Download **`google-services.json`** → place it in **`mobile/android/app/`**.
4. Add the Google Services Gradle plugin (Capacitor 6 template):
- `mobile/android/build.gradle``buildscript { dependencies { ... } }`:
```gradle
classpath 'com.google.gms:google-services:4.4.2'
```
- **bottom** of `mobile/android/app/build.gradle`:
```gradle
apply plugin: 'com.google.gms.google-services'
```
5. `npx cap sync` again.
That's the **client** half. The **server** half (already built) needs the matching credential:
- Firebase console → **Project settings → Service accounts → Generate new private key** →
download the JSON.
- On the production server, set **`FCM_SERVICE_ACCOUNT`** to that file's path (or its inline
JSON) and restart. See [../DEPLOY.md](../DEPLOY.md). With that set, `push.sendToUser`
delivers to Android devices automatically; the app already registers its token via
`POST /api/v1/devices` on launch (see `setupNativePush` in `server/public/home.html`).
## 5. Run it
```bash
npx cap open android # opens the project in Android Studio → press Run ▶
# or headless:
npx cap run android
```
First launch will prompt for notifications (Android 13+); accept it, then check the server log
/ DB `device_tokens` shows a row for your user.
### Testing against a LOCAL dev server (optional)
The app points at `https://remote.bizgaze.com` by default. To hit your laptop instead, edit
`capacitor.config.json`:
```json
"server": { "url": "http://<your-LAN-IP>:8090", "cleartext": true, "androidScheme": "https" }
```
then `npx cap sync`. (`cleartext` is required for plain `http`.) Revert before shipping.
## 6. Build for the Play Store
1. Create an upload keystore (once):
```bash
keytool -genkey -v -keystore biz-connect.keystore -alias bizconnect -keyalg RSA -keysize 2048 -validity 10000
```
2. Android Studio → **Build → Generate Signed App Bundle** → AAB → select the keystore.
(Or `cd android && ./gradlew bundleRelease`.)
3. Upload the `.aab` to **Google Play Console** (one-time $25 developer account). Fill in the
store listing, data-safety form (declare camera/mic/notifications), and roll out to
internal testing first.
---
## Checklist
- [ ] Android Studio SDK + an emulator/device ready
- [ ] `npm install` → `npm run assets` → `npx cap add android` → `npx cap sync`
- [ ] Permissions added to AndroidManifest
- [ ] `google-services.json` in `android/app/` + Gradle plugin lines + `cap sync`
- [ ] App runs; notification permission accepted; `device_tokens` row appears
- [ ] Server `FCM_SERVICE_ACCOUNT` set in prod → end-to-end push works
- [ ] Signed AAB built and uploaded to Play (internal testing)
+43
Visa fil
@@ -0,0 +1,43 @@
# Biz Connect — Mobile app (Capacitor)
A Capacitor shell that loads the live Connect web UI (`server.url` in
`capacitor.config.json`) and adds native push, camera/mic, and store distribution. See the
overall plan in [../CLIENTS.md](../CLIENTS.md).
> **Android:** follow the step-by-step in **[ANDROID_SETUP.md](ANDROID_SETUP.md)** (project
> generation, icons/splash, permissions, Firebase/FCM, run, and Play Store build).
## Prerequisites
- Node + `npm install` here.
- **Android:** Android Studio + SDK.
- **iOS:** macOS + Xcode (+ an Apple Developer account to run on device / ship).
## Setup
```bash
npm install
npm run assets # generate app icons + splash from resources/
npx cap add android
npx cap add ios # macOS only
npx cap sync
```
## Run / build
```bash
npx cap open android # build & run from Android Studio
npx cap open ios # build & run from Xcode
```
## Server origin
The app loads `server.url` from `capacitor.config.json` (default
`https://remote.bizgaze.com`). For a local device test against a dev server, set it to your
machine's LAN URL (and allow cleartext for plain http).
## Native push (next step)
Native push uses the Capacitor Push Notifications plugin (FCM on Android, APNs on iOS) and a
server endpoint to register device tokens — tracked in [../CLIENTS.md](../CLIENTS.md) Phase B.
This is separate from the existing Web Push (VAPID) the PWA already uses. Needs Google/Apple
credentials to test end-to-end.
## Shipping (gated on accounts)
- **Google Play:** one-time $25; upload an AAB; signing key.
- **App Store:** Apple Developer $99/yr; archive via Xcode; App Store Connect listing.
+15
Visa fil
@@ -0,0 +1,15 @@
{
"appId": "com.bizgaze.connect",
"appName": "Biz Connect",
"webDir": "www",
"server": {
"url": "https://remote.bizgaze.com",
"cleartext": false,
"androidScheme": "https"
},
"plugins": {
"PushNotifications": {
"presentationOptions": ["badge", "sound", "alert"]
}
}
}
+26
Visa fil
@@ -0,0 +1,26 @@
{
"name": "biz-connect-mobile",
"version": "0.1.0",
"description": "Biz Connect mobile app — Capacitor shell loading the Connect web UI",
"scripts": {
"sync": "cap sync",
"assets": "capacitor-assets generate --android",
"assets:all": "capacitor-assets generate",
"android": "cap open android",
"ios": "cap open ios"
},
"dependencies": {
"@capacitor/android": "^6.1.0",
"@capacitor/app": "^6.0.0",
"@capacitor/camera": "^6.0.0",
"@capacitor/core": "^6.1.0",
"@capacitor/ios": "^6.1.0",
"@capacitor/push-notifications": "^6.0.0",
"@capacitor/splash-screen": "^6.0.0",
"@capacitor/status-bar": "^6.0.0"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^6.1.0"
}
}
+21
Visa fil
@@ -0,0 +1,21 @@
# App icon & splash source images
`@capacitor/assets` generates every Android (and iOS) icon/splash density from these masters.
| File | Size | Purpose |
|------|------|---------|
| `icon.png` | 1024×1024 | App icon (all densities + Android adaptive icon) |
| `splash.png` | 2732×2732 | Launch splash (light) |
| `splash-dark.png` | 2732×2732 | Launch splash (dark mode) |
These were generated from the existing PWA icon (`server/public/icon-512.png`) + brand blue
`#1F3B73`. To rebrand, replace these three files (keep the sizes) and re-run:
```bash
cd mobile
npm run assets # Android only (npm run assets:all for iOS too)
npx cap sync
```
Tip: for the sharpest result, drop a true 1024×1024 `icon.png` (and a 2732×2732 `splash.png`)
exported from the design source rather than an upscale.
+19
Visa fil
@@ -0,0 +1,19 @@
<!-- Paste these into mobile/android/app/src/main/AndroidManifest.xml, inside <manifest> and
directly above the <application> element. INTERNET is already added by Capacitor.
See ANDROID_SETUP.md §3. -->
<!-- Push notifications: Android 13 (API 33)+ shows a runtime prompt -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Voice / video calls + camera, used by the WebRTC features in the web UI -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Camera is optional hardware (tablets without one can still install) -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Later, for screen-sharing FROM the phone (needs a screen-capture plugin):
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
-->
Binary file not shown.

Efter

Bredd:  |  Höjd:  |  Storlek: 157 KiB

Binary file not shown.

Efter

Bredd:  |  Höjd:  |  Storlek: 211 KiB

Binary file not shown.

Efter

Bredd:  |  Höjd:  |  Storlek: 211 KiB

+25
Visa fil
@@ -0,0 +1,25 @@
<!doctype html>
<!-- Capacitor requires a webDir with an index. At runtime the app loads the live Connect UI
via server.url in capacitor.config.json, so this is only a launch splash / offline
fallback. To ship fully-bundled (offline-launch) later, copy ../server/public here and
drop server.url. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Biz Connect</title>
<style>
html,body{height:100%;margin:0;background:#0f1830;color:#fff;font-family:system-ui,Segoe UI,Roboto,sans-serif;}
.wrap{height:100%;display:grid;place-items:center;text-align:center;padding:2rem;}
h1{font-size:1.4rem;margin:.4rem 0;} p{opacity:.7;font-size:.9rem;}
</style>
</head>
<body>
<div class="wrap">
<div>
<h1>Biz <span style="color:#f5b301">Connect</span></h1>
<p>Connecting…</p>
</div>
</div>
</body>
</html>
+3 -1
Visa fil
@@ -15,6 +15,8 @@ function verifyPassword(password, salt, expectedHash) {
// ---- Random tokens ----
const token = (bytes = 24) => crypto.randomBytes(bytes).toString('hex');
const id = () => crypto.randomBytes(8).toString('hex');
// Deterministic hash for storing high-value tokens (e.g. refresh tokens) at rest.
const hashToken = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
const numericCode = (digits = 6) =>
String(crypto.randomInt(0, 10 ** digits)).padStart(digits, '0');
@@ -70,6 +72,6 @@ function otpauthUrl(secret, email, issuer = 'RemoteAccess') {
}
module.exports = {
hashPassword, verifyPassword, token, id, numericCode,
hashPassword, verifyPassword, token, id, hashToken, numericCode,
newMfaSecret, totp, verifyTotp, otpauthUrl,
};
+15
Visa fil
@@ -9,6 +9,20 @@
function loginUrl() { return process.env.BIZGAZE_LOGIN_URL || ''; }
const isEnabled = () => !!loginUrl();
// Origin of the BizGaze app (e.g. https://c02.bizgaze.app), derived from the login URL.
function loginOrigin() { try { return new URL(loginUrl()).origin; } catch { return ''; } }
// Build an absolute profile-photo URL from the session payload. BizGaze returns a
// relative path like "_files/documents/.../x.jpg" plus an asset/app base; we try the
// asset host first, then the app host, then the login origin. Absolute URLs pass through.
function photoUrlFrom(s) {
const raw = s.photoUrl || s.PhotoUrl || s.photo || s.profilePic || s.imageUrl || '';
if (!raw || typeof raw !== 'string') return null;
if (/^https?:\/\//i.test(raw)) return raw;
const base = String(s.assetUrl || s.appUrl || loginOrigin() || '').replace(/\/+$/, '');
return base ? base + '/' + raw.replace(/^\/+/, '') : null;
}
async function validateLogin(username, password) {
const url = loginUrl();
if (!url) return { ok: false, configured: false };
@@ -30,6 +44,7 @@ async function validateLogin(username, password) {
return {
ok: true, configured: true,
name: s.name || null,
avatarUrl: photoUrlFrom(s),
isAdmin: !!s.isAdmin,
tenantRef: s.tenantId != null ? String(s.tenantId) : null, // BizGaze tenant (org) id
bizgazeUserId: s.userId != null ? String(s.userId) : null,
+151
Visa fil
@@ -0,0 +1,151 @@
// Shared group calls: one live call per group. Members join without a code; the call
// ends (with a duration line in the chat) when the last participant's mesh room empties.
const fs = require('fs');
const path = require('path');
const R = require('./repos');
const A = require('./auth');
const CHAT = require('./chat');
const { TRANS_DIR } = require('./config');
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
const now = () => Date.now();
const pairKey = (a, b) => [a, b].sort().join('|');
// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings.
function meetingContext(room) {
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
try {
const sched = R.scheduledMeetings.byCode(room);
if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
} catch (_) {}
if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
if (ctx.groupId && ctx.title === 'Meeting') { try { const g = R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} }
if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
return ctx;
}
// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId
// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end).
// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on).
function finalizeTranscript(room, onlyUserId) {
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
const buf = transcriptBuffers.get(room) || [];
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
if (ids.length && buf.length) {
const ctx = meetingContext(room);
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
for (const uid of ids) {
let user = null; try { user = R.users.byId(uid); } catch (_) {}
if (!user) { subs.delete(uid); continue; }
const id = A.id(); const file = 'm_' + id + '.txt';
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
// groupId null → private to its creator (see canSeeRec / /mrec auth).
R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
subs.delete(uid);
}
} else { ids.forEach((uid) => subs.delete(uid)); }
if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
}
function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); }
function broadcast(group, evt) { try { for (const mid of R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }
// Post a centered activity line into the group (system sender → no ping on clients).
function postSystem(group, teamId, text) {
const id = A.id();
R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
const m = R.messages.byId(id);
broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } });
}
function startGroupCall(group, teamId, user) {
const existing = groupCalls.get(group);
if (existing) return { room: existing.room, active: true, already: true };
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map());
const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call');
let gName = 'Group'; try { const g = R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
return { room, active: true };
}
// Called from signaling when a mesh room empties — ends the group call if this room was one.
function endGroupCallByRoom(room) {
const group = roomToGroupCall.get(room);
if (!group) return;
const call = groupCalls.get(group);
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
if (call) {
let teamId = call.teamId; try { const g = R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)); } } catch (_) {}
if (call.historyId && teamId) { try { R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
broadcast(group, { type: 'group-call', group, active: false, room });
}
}
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
function startDmCall(me, otherId, teamId) {
const key = pairKey(me.id, otherId);
const existing = dmCalls.get(key);
if (existing) return { room: existing.room, active: true, already: true };
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map());
const byName = me.name || me.email;
const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId };
// Log to history (both participants) so the call shows under Past meetings with its transcript.
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
const mid = A.id();
R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
const m = R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {}
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {}
return { room, active: true };
}
function endDmCallByRoom(room, silent) {
const key = roomToDmCall.get(room); if (!key) return;
const call = dmCalls.get(key);
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
if (!call) return;
if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
// "Call ended · duration" activity line in the DM (shown to both) — skipped on decline.
if (!silent) try {
const mid = A.id(); const body = '📞 Call ended · ' + fmtDur(now() - call.startedAt);
R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
} catch (_) {}
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
}
// Called from signaling when any mesh room empties.
function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); }
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
function declineDmCall(room, byUser) {
const key = roomToDmCall.get(room); if (!key) return { ok: false };
const call = dmCalls.get(key); if (!call) return { ok: false };
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
try {
const mid = A.id();
R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
const mm = R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' };
CHAT.pushToUser(callerId, { type: 'chat-message', message: dto });
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
} catch (_) {}
// Drop the caller who's still waiting in the (otherwise empty) mesh room.
const peers = meetingRooms.get(room);
if (peers) { for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } } meetingRooms.delete(room); }
endDmCallByRoom(room, true); // silent: we already posted "Call declined"
return { ok: true };
}
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, finalizeTranscript, meetingContext, fmtDur, pairKey };
+31
Visa fil
@@ -0,0 +1,31 @@
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends
// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP
// (routes.js) and pushed live to the recipient's sockets via pushToUser().
const { chatClients } = require('./presence');
function register(userId, ws) {
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
chatClients.get(userId).add(ws);
ws._chatUserId = userId;
}
function unregister(ws) {
const id = ws && ws._chatUserId;
if (!id) return;
const set = chatClients.get(id);
if (set) { set.delete(ws); if (!set.size) chatClients.delete(id); }
}
function isOnline(userId) {
const s = chatClients.get(userId);
return !!(s && s.size);
}
function pushToUser(userId, obj) {
const s = chatClients.get(userId);
if (!s) return;
const data = JSON.stringify(obj);
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
}
module.exports = { register, unregister, isOnline, pushToUser };
+5 -1
Visa fil
@@ -5,8 +5,10 @@ const path = require('path');
const PUBLIC_DIR = path.join(__dirname, 'public');
const REC_DIR = path.join(__dirname, 'recordings');
const TRANS_DIR = path.join(__dirname, 'transcripts');
const UPLOADS_DIR = path.join(__dirname, 'uploads');
try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {}
module.exports = {
PORT: process.env.PORT || 8090,
@@ -14,5 +16,7 @@ module.exports = {
PUBLIC_DIR,
REC_DIR,
TRANS_DIR,
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h auto-logout
UPLOADS_DIR,
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h access-token / cookie lifetime
REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients)
};
+250
Visa fil
@@ -81,4 +81,254 @@ CREATE TABLE IF NOT EXISTS sessions_log (
try { db.exec('ALTER TABLE sessions_log ADD COLUMN recording TEXT'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE sessions_log ADD COLUMN transcript TEXT'); } catch (e) { /* exists */ }
// Refresh tokens for native (desktop/mobile) clients: long-lived, rotated on use,
// stored as a SHA-256 hash so a DB leak doesn't expose usable tokens.
db.exec(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
);
`);
// API keys for third-party / system integrations (machine-to-machine, no human login).
// Scoped per tenant; the key is stored as a SHA-256 hash (plaintext shown once at creation).
db.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
name TEXT,
key_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL DEFAULT '',
created_by TEXT,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked INTEGER NOT NULL DEFAULT 0
);
`);
// Outbound webhook subscriptions: per-tenant endpoints that receive signed event
// callbacks (session.started / session.ended). Each has its own signing secret.
db.exec(`
CREATE TABLE IF NOT EXISTS webhooks (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
url TEXT NOT NULL,
secret TEXT NOT NULL,
events TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_by TEXT,
created_at INTEGER NOT NULL,
last_status INTEGER,
last_error TEXT,
last_at INTEGER
);
`);
// Persistent 1:1 chat between users in the same team.
db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
recipient_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
read_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_messages_pair ON messages(team_id, sender_id, recipient_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(team_id, recipient_id, sender_id, read_at);
`);
// Migration: a message can quote/reply to another message.
try { db.exec('ALTER TABLE messages ADD COLUMN reply_to TEXT'); } catch (e) { /* exists */ }
// Emoji reactions on messages (one row per user+message+emoji; toggling adds/removes).
db.exec(`
CREATE TABLE IF NOT EXISTS message_reactions (
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
emoji TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (message_id, user_id, emoji)
);
`);
// File attachments for chat messages (file bytes stored on disk at uploads/<id>).
db.exec(`
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
uploader_id TEXT NOT NULL,
name TEXT NOT NULL,
mime TEXT,
size INTEGER,
created_at INTEGER NOT NULL
);
`);
try { db.exec('ALTER TABLE messages ADD COLUMN attachment_id TEXT'); } catch (e) { /* exists */ }
// Group conversations + membership. (1:1 DMs keep using sender_id/recipient_id directly;
// group messages set conversation_id instead, with recipient_id left blank.)
db.exec(`
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'group',
name TEXT,
created_by TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS conversation_members (
conversation_id TEXT NOT NULL,
user_id TEXT NOT NULL,
last_read_at INTEGER NOT NULL DEFAULT 0,
joined_at INTEGER NOT NULL,
PRIMARY KEY (conversation_id, user_id)
);
`);
try { db.exec('ALTER TABLE messages ADD COLUMN conversation_id TEXT'); } catch (e) { /* exists */ }
try { db.exec('CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at)'); } catch (e) {}
// Group admins: 1 = this member is an admin (multiple admins allowed). Creator seeded as admin.
try { db.exec('ALTER TABLE conversation_members ADD COLUMN admin INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
try { db.exec('UPDATE conversation_members SET admin=1 WHERE user_id IN (SELECT created_by FROM conversations WHERE conversations.id=conversation_members.conversation_id) AND admin=0'); } catch (e) {}
// Avatars: a user's profile picture (BizGaze photo URL) and a group's uploaded image
// (an attachment id, served via /files/<id> with group-membership auth).
try { db.exec('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE conversations ADD COLUMN avatar_id TEXT'); } catch (e) { /* exists */ }
// @mentions on a (group) message: JSON array of mentioned user ids, and/or the literal
// "everyone" for @everyone/@all. Used to highlight and notify mentioned members.
try { db.exec('ALTER TABLE messages ADD COLUMN mentions TEXT'); } catch (e) { /* exists */ }
// Delivered receipt for DMs (double tick): set when the recipient's client acknowledges.
try { db.exec('ALTER TABLE messages ADD COLUMN delivered_at INTEGER'); } catch (e) { /* exists */ }
// Group setting: when 1, only the creator can add/remove members.
try { db.exec('ALTER TABLE conversations ADD COLUMN admin_only INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
// Polls live within a group conversation, attached to a message (the poll's question is
// the message body). options is a JSON array of option strings; votes are one row each.
try { db.exec('ALTER TABLE messages ADD COLUMN poll_id TEXT'); } catch (e) { /* exists */ }
// Activity/event lines (e.g. 'call-start','call-end') render as centered system messages.
try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /* exists */ }
// Deleted ("delete for everyone"): the row stays so threads/ordering hold, but body+attachment
// are cleared and clients render a "This message was deleted" placeholder.
try { db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
// User-set presence status: 'active' | 'away' | 'onleave'. ('incall' is derived live, not stored.)
try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch (e) { /* exists */ }
db.exec(`
CREATE TABLE IF NOT EXISTS polls (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
message_id TEXT,
question TEXT NOT NULL,
options TEXT NOT NULL,
multi INTEGER NOT NULL DEFAULT 0,
closed INTEGER NOT NULL DEFAULT 0,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS poll_votes (
poll_id TEXT NOT NULL,
user_id TEXT NOT NULL,
option_idx INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (poll_id, user_id, option_idx)
);
`);
// Scheduled meetings/calls. Each carries a stable room_code so a scheduled call can be
// joined later (the live mesh room is created on first join). group_id is optional — a
// scheduled meeting may target a specific group conversation or be standalone.
db.exec(`
CREATE TABLE IF NOT EXISTS scheduled_meetings (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
group_id TEXT,
room_code TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
scheduled_at INTEGER NOT NULL,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
ended_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_sched_team ON scheduled_meetings(team_id, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_sched_code ON scheduled_meetings(room_code);
`);
// Invited participants (JSON array of user ids) + a one-shot "10-min reminder sent" flag.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN participants TEXT'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN reminded INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
// Cancelled meetings are kept (shown as "Cancelled"), not deleted.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN duration_mins INTEGER'); } catch (e) { /* exists */ }
// Weekly recurrence: JSON array of weekdays (0=Sun..6=Sat), or null for a one-off.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN recurrence TEXT'); } catch (e) { /* exists */ }
// Meeting recordings & transcripts. Video bytes live in recordings/m_<id>.webm, transcript text
// in transcripts/m_<id>.txt. Tied to a room (and group/scheduled meeting when applicable) so they
// surface under "Past meetings". kind = 'video' | 'transcript'.
db.exec(`
CREATE TABLE IF NOT EXISTS recordings (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
room TEXT,
group_id TEXT,
meeting_id TEXT,
title TEXT,
kind TEXT NOT NULL,
file TEXT,
mime TEXT,
size INTEGER,
duration_ms INTEGER,
created_by TEXT,
created_by_name TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rec_team ON recordings(team_id, created_at);
CREATE INDEX IF NOT EXISTS idx_rec_room ON recordings(room);
`);
// Web Push subscriptions (one per browser/device per user) for background/closed-tab
// notifications. endpoint is unique; p256dh+auth are the encryption keys from the browser.
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
`);
// Native device push tokens (FCM for Android, APNs for iOS) registered by the mobile app.
// Distinct from push_subscriptions (Web Push): a native token is just an opaque string + platform.
db.exec(`
CREATE TABLE IF NOT EXISTS device_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
tenant_id TEXT,
platform TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_seen INTEGER
);
CREATE INDEX IF NOT EXISTS idx_devtok_user ON device_tokens(user_id);
`);
// Favourite conversations (per user). target = 'dm:<userId>' or 'group:<groupId>'.
db.exec(`
CREATE TABLE IF NOT EXISTS favorites (
user_id TEXT NOT NULL,
target TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, target)
);
`);
module.exports = db;
+57
Visa fil
@@ -0,0 +1,57 @@
// BizGaze user-directory search (cross-tenant). The auth token is kept SERVER-SIDE only — the
// browser calls /api/directory/search and never sees the token. Configure via env in production:
// BIZGAZE_DIRECTORY_URL (base, the search term is appended url-encoded)
// BIZGAZE_DIRECTORY_TOKEN (the "stat ..." Authorization header value)
const DEFAULT_URL = 'https://app.bizgaze.com/apis/v4/bizgaze/integrations/users_chatsearch/get_usersforchatsearch/searchterm/';
const DEFAULT_TOKEN = 'stat 3cd2e190b4db448496ae316b155d2441';
function baseUrl() { return process.env.BIZGAZE_DIRECTORY_URL || DEFAULT_URL; }
function token() { return process.env.BIZGAZE_DIRECTORY_TOKEN || DEFAULT_TOKEN; }
function enabled() { return !!(baseUrl() && token()); }
// Pull a field from an object by any of several case-insensitive key names.
function field(o, names) {
const keys = Object.keys(o || {});
for (const want of names) { for (const k of keys) { if (k.toLowerCase() === want) { const v = o[k]; if (v != null && v !== '') return String(v); } } }
return '';
}
// BizGaze responses vary (raw array, or wrapped in Result/data, sometimes a JSON string). Normalize.
function toArray(data) {
let d = data;
if (typeof d === 'string') { try { d = JSON.parse(d); } catch (_) { return []; } }
if (Array.isArray(d)) return d;
if (d && typeof d === 'object') {
for (const key of ['Result', 'result', 'data', 'Data', 'records', 'Records', 'items', 'Items']) {
if (d[key] != null) { let v = d[key]; if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_) {} } if (Array.isArray(v)) return v; }
}
}
return [];
}
function pick(o) {
return {
id: field(o, ['userid', 'id', 'contactid', 'partyid', 'recordid']),
name: field(o, ['fullname', 'name', 'displayname', 'username', 'contactname', 'firstname']),
email: field(o, ['email', 'emailaddress', 'emailid', 'mail']),
phone: field(o, ['mobile', 'mobilenumber', 'phone', 'phonenumber', 'contactno', 'contactnumber']),
avatar: field(o, ['photourl', 'photo', 'avatar', 'imageurl', 'profilepic', 'profileimage']),
org: field(o, ['organization', 'organisation', 'company', 'tenantname', 'orgname']),
};
}
async function search(term) {
if (!enabled() || !term || term.trim().length < 2) return [];
const url = baseUrl() + encodeURIComponent(term.trim());
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), 8000);
try {
const r = await fetch(url, { headers: { Authorization: token(), Accept: 'application/json' }, signal: ctrl.signal });
if (!r.ok) return [];
const data = await r.json().catch(() => null);
return toArray(data).map(pick).filter((x) => x.name || x.email || x.phone).slice(0, 25);
} catch (_) { return []; }
finally { clearTimeout(to); }
}
module.exports = { search, enabled };
+3 -1
Visa fil
@@ -2,7 +2,9 @@
const now = () => Date.now();
const json = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
// no-store: API/JSON responses (and 404s) must never be cached — a cached 404 for an asset
// like /manifest.json would otherwise persist on a device even after the file is deployed.
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
};
+192 -4
Visa fil
@@ -1,17 +1,205 @@
{
"name": "remote-access-server",
"version": "0.2.0",
"name": "bizgaze-support-server",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remote-access-server",
"version": "0.2.0",
"name": "bizgaze-support-server",
"version": "2.0.0",
"dependencies": {
"web-push": "^3.6.7",
"ws": "^8.18.0"
},
"engines": {
"node": ">=22.5.0"
},
"optionalDependencies": {
"nodemailer": "^6.9.14"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"optional": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/ws": {
+13 -4
Visa fil
@@ -3,8 +3,17 @@
"version": "2.0.0",
"description": "BizGaze Support — remote screen sharing: public landing, agent console, sessions, SSO + webhook for BizGaze integration",
"main": "server.js",
"scripts": { "start": "node server.js" },
"engines": { "node": ">=22.5.0" },
"dependencies": { "ws": "^8.18.0" },
"optionalDependencies": { "nodemailer": "^6.9.14" }
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=22.5.0"
},
"dependencies": {
"web-push": "^3.6.7",
"ws": "^8.18.0"
},
"optionalDependencies": {
"nodemailer": "^6.9.14"
}
}
+9
Visa fil
@@ -4,4 +4,13 @@ module.exports = {
onlineAgents: new Map(), // machineId -> { ws, machine }
liveSessions: new Map(), // sessionId -> { agentWs, viewerWs, machine, user }
pendingShares: new Map(), // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
chatClients: new Map(), // userId -> Set<ws> (a user may have several tabs/devices open)
meetingRooms: new Map(), // roomCode -> Map(peerId -> { ws, name }) (mesh meetings MVP)
groupCalls: new Map(), // groupId -> { room, startedAt, startedBy, startedByName } (shared group calls)
roomToGroupCall: new Map(),// roomCode -> groupId (end a group call when its room empties)
dmCalls: new Map(), // pairKey "a|b" -> { room, startedAt, startedBy, startedByName, users:[a,b] }
roomToDmCall: new Map(), // roomCode -> pairKey (end a 1:1 call when its room empties)
roomHost: new Map(), // roomCode -> userId (the meeting creator = host; transferable in-call)
transcriptBuffers: new Map(),// roomCode -> [{ t, speaker, text }] (shared full-conversation buffer)
transcriptSubs: new Map(), // roomCode -> Set(userId) (who wants a private copy of the transcript)
};
Binary file not shown.

Efter

Bredd:  |  Höjd:  |  Storlek: 10 KiB

+21 -11
Visa fil
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Support — Agent Console</title>
<title>Biz Connect — Agent Console</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; }
*{box-sizing:border-box;}
@@ -31,6 +31,10 @@
.topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);}
#endBtn{padding:.45rem 1rem;background:#fee2e2;color:#b91c1c;border:none;border-radius:8px;font-weight:600;cursor:pointer;}
#video{width:100vw;height:calc(100vh - 46px);background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;}
/* Control bar sits vertically on the RIGHT; reserve a thin right strip so the screen uses the full
height/width otherwise (maximised view) and the icons never overlay the shared content. */
body.has-bar #video{width:calc(100vw - 76px);}
body.has-bar{background:#0b1220;}
.profile{position:relative}
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
@@ -53,12 +57,13 @@
html.embed #homeLink{display:none!important;}
html.embed #video{height:100vh!important;}
</style>
<script src="/icons.js?v=3"></script>
</head>
<body>
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
<a href="/home" id="homeLink">&#8592; Home</a>
<a href="/home" id="homeLink"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
<div class="topbar" id="topbar">
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span>Support</span></div></div>
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">Biz <span>Connect</span></div></div>
<div class="agentchip" id="agentChip"></div>
</div>
<div class="topbar2" id="bar"><span id="barStatus"></span><button id="endBtn">End session</button></div>
@@ -68,7 +73,7 @@
<script>
let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
// When embedded in the home shell, tell the parent when a session is live so the
@@ -163,7 +168,8 @@ function connectWS(){
case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
case 'session-ready': if(statusEl)statusEl.textContent='Allowed — connecting…'; break;
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){}
// Acquire the agent mic once; on renegotiation (e.g. customer unmutes) just answer.
if(!window.__mic){ try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){} }
const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
@@ -191,6 +197,8 @@ function renderEnded(msg){
bzcSession(false);
try{ stopRecording(); }catch(_){}
removeSessionUI();
document.body.classList.remove('has-bar');
if(window.__mic){ try{ window.__mic.getTracks().forEach(t=>t.stop()); }catch(_){} window.__mic=null; } // release mic so the tab's recording dot clears
if(pc){ try{pc.close();}catch(e){} pc=null; }
video.style.display='none'; bar.classList.remove('show');
topbar.style.display='flex'; wrap.style.display='grid';
@@ -207,7 +215,7 @@ let chatOpen=false;
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
const SVG_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const SVG_END='<svg viewBox="0 0 24 24" width="17" height="17" fill="#fff"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>';
const SVG_END='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g></svg>';
const SVG_REC='<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="7" fill="#ef4444"/></svg>';
const SVG_RECSTOP='<svg viewBox="0 0 24 24" width="15" height="15" fill="#fff"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
let mediaRecorder=null, recChunks=[], recCtx=null;
@@ -246,7 +254,7 @@ function stopTranscription(){ recogActive=false; if(recog){ try{recog.stop();}ca
function buildTranscriptText(){
const lines=transcriptLines.slice().sort((a,b)=>a.t-b.t);
const pad=(n)=>String(n).padStart(2,'0');
const head='BizGaze Support — Session transcript\nSession: '+sessionId+'\nGenerated: '+new Date().toLocaleString()+'\n'+('-'.repeat(48))+'\n';
const head='Biz Connect — Session transcript\nSession: '+sessionId+'\nGenerated: '+new Date().toLocaleString()+'\n'+('-'.repeat(48))+'\n';
const body=lines.map(l=>{ const d=new Date(l.t); const ts='['+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds())+']'; const who=(l.role==='agent'?'Agent':'Customer')+(l.name?' ('+l.name+')':'')+(l.chat?' [chat]':''); return ts+' '+who+': '+l.text; }).join('\n');
return head+(body||'(no speech captured)')+'\n';
}
@@ -296,20 +304,21 @@ function stopRecording(){
showRecTimer(false);
try{ws.send(JSON.stringify({type:'recording',sessionId,on:false}));}catch(_){}
}
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML='<span style="display:inline-flex">'+svg+'</span><span>'+label+'</span>';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;}
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.title=label;b.setAttribute('aria-label',label);b.innerHTML='<span style="display:inline-flex">'+svg+'</span>';b.style.cssText='display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border:none;border-radius:50%;cursor:pointer;color:#fff;background:'+bg+';box-shadow:0 2px 6px rgba(0,0,0,.25);transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-2px)';b.onmouseleave=()=>b.style.transform='none';return b;}
function buildBar(){
if(document.getElementById('sessionBar'))return;
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
bzcSession(true);
const bar=document.createElement('div'); bar.id='sessionBar';
bar.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
bar.style.cssText='position:fixed;right:14px;top:50%;transform:translateY(-50%);z-index:2147483000;display:flex;flex-direction:column;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:12px 8px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
const rec=_btn('recBtn',SVG_REC,'','#0ea5e9'); rec.title='Record'; rec.querySelectorAll('span').forEach((s,i)=>{ if(i>0) s.remove(); });
const end=_btn('endBtn2',SVG_END,'End','#dc2626');
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end);
document.body.appendChild(bar);
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span><span>'+(t.enabled?'Mic':'Muted')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
document.body.classList.add('has-bar'); // reserve space so the bar never overlays the shared screen
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.title=t.enabled?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
chat.onclick=toggleChat;
rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
@@ -321,7 +330,7 @@ function buildBar(){
function buildChatPanel(){
if(document.getElementById('chatPanel'))return;
const p=document.createElement('div'); p.id='chatPanel';
p.style.cssText='position:fixed;right:18px;bottom:84px;width:300px;max-width:92vw;height:360px;max-height:62vh;z-index:2147483001;background:#fff;border:1px solid #e6e9ef;border-radius:16px;box-shadow:0 14px 34px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden';
p.style.cssText='position:fixed;right:88px;bottom:18px;width:300px;max-width:80vw;height:360px;max-height:62vh;z-index:2147483001;background:#fff;border:1px solid #e6e9ef;border-radius:16px;box-shadow:0 14px 34px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden';
p.innerHTML='<div style="background:#1F3B73;color:#fff;padding:.6rem .85rem;font-weight:600;font-size:.92rem;display:flex;justify-content:space-between;align-items:center">Chat <span id="chatClose" style="cursor:pointer;opacity:.85">&#10005;</span></div><div id="chatMsgs" style="flex:1;overflow-y:auto;padding:.6rem;display:flex;flex-direction:column;gap:.4rem;font-size:.85rem"></div><div style="display:flex;gap:.4rem;padding:.5rem;border-top:1px solid #e6e9ef"><input id="chatInput" placeholder="Type a message..." style="flex:1;padding:.5rem;border:1px solid #e6e9ef;border-radius:8px;font-size:.85rem;outline:none"><button id="chatSend" style="background:#FFC708;border:none;border-radius:8px;padding:.5rem .85rem;font-weight:700;cursor:pointer">Send</button></div>';
document.body.appendChild(p);
document.getElementById('chatSend').onclick=sendChat;
@@ -368,5 +377,6 @@ video.addEventListener('keyup',e=>{e.preventDefault();send({kind:'keyup',key:e.k
document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));};
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
</script>
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
</body>
</html>
+111 -13
Visa fil
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Connect — Dashboard</title>
<title>Biz Connect — Dashboard</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
*{box-sizing:border-box;}
@@ -28,6 +28,11 @@
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
.pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
.pill.on{background:#ecfdf3;color:#15803d;}
.pill.off{background:#fee2e2;color:var(--red);}
.reveal{margin-top:1rem;background:#f1f7ec;border:1px solid #cfe8bf;border-radius:10px;padding:.8rem 1rem;}
.reveal code{flex:1;word-break:break-all;background:#fff;border:1px solid var(--line);border-radius:8px;padding:.5rem .6rem;font-size:.85rem;}
.chk{display:flex;align-items:center;gap:.4rem;font-size:.85rem;}
.chk input{width:16px;height:16px;margin:0;accent-color:var(--blue);}
.hidden{display:none;}
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
.tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
@@ -55,6 +60,10 @@
.profile{position:relative}
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.profile .pbtn.icon-only{padding:.25rem;gap:0;border-radius:50%;background:transparent;border:none}
/* #12: report table scrolls horizontally on small screens instead of overflowing. */
.table-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%;}
.table-scroll table{min-width:560px;}
.profile .pbtn .pav{width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem}
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
.profile .pmenu.open{display:block}
@@ -64,11 +73,13 @@
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
.profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
.ic{display:inline-block;vertical-align:middle}
</style>
<script src="/icons.js?v=3"></script>
</head>
<body>
<header>
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span class="y">Connect</span> <span class="tag">· Dashboard</span></div></div>
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">Biz <span class="y">Connect</span> <span class="tag">· Dashboard</span></div></div>
<div class="row" id="hdrRight"></div>
</header>
<main id="app"></main>
@@ -82,9 +93,8 @@ function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;',
function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();}
function profileHTML(u){
const display=u.name||u.email;
return '<div class="profile"><button class="pbtn" id="pbtn">'
+ '<span class="pav">'+pEsc(initials(display))+'</span>'
+ pEsc(display)+' <span style="font-size:.65rem">&#9662;</span></button>'
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="'+pEsc(display)+'">'
+ '<span class="pav">'+pEsc(initials(display))+'</span></button>'
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
+ '<a href="/home">Home</a>'
@@ -128,7 +138,7 @@ async function authView() {
</div>
${regOpen ? `<div id="regForm" class="hidden">
<span class="lbl">Team name</span>
<input id="rg_team" placeholder="e.g. BizGaze Support">
<input id="rg_team" placeholder="e.g. Acme Inc">
<span class="lbl">Email</span>
<input id="rg_email" placeholder="you@bizgaze.com" type="email">
<span class="lbl">Password</span>
@@ -188,20 +198,108 @@ async function dashboard(me) {
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
<div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
<button id="fApply">Apply</button>
<button id="fExcel" class="mini" style="padding:.6rem .9rem"> Excel</button>
<button id="fPdf" class="mini" style="padding:.6rem .9rem"> PDF</button>
<button id="fExcel" class="mini" style="padding:.6rem .9rem">${ic('download',15)} Excel</button>
<button id="fPdf" class="mini" style="padding:.6rem .9rem">${ic('download',15)} PDF</button>
</div>
${IS_ADMIN ? '<input id="repSearch" class="srch" placeholder="Search by agent or ticket">' : ''}
<table id="report"><thead><tr><th>Date</th><th>Start time</th>${IS_ADMIN ? '<th>Agent</th>' : ''}<th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table>
<div class="table-scroll"><table id="report"><thead><tr><th>Date</th><th>Start time</th>${IS_ADMIN ? '<th>Agent</th>' : ''}<th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table></div>
<div id="repPager" class="pager"></div>
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
</div>`);
</div>
${IS_ADMIN ? `
<div class="card" id="keysCard">
<h2>API keys <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— let other systems read your data programmatically</span></h2>
<table id="keys"><thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody></tbody></table>
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
<div><span class="lbl">Name</span><input id="kName" placeholder="e.g. Partner X" style="max-width:200px"></div>
<label class="chk"><input type="checkbox" id="kReport" checked> report:read</label>
<label class="chk"><input type="checkbox" id="kAudit"> audit:read</label>
<button id="kAdd">Generate key</button>
</div>
<div id="kOut"></div>
</div>
<div class="card" id="hooksCard">
<h2>Webhooks <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— signed event callbacks to your systems</span></h2>
<table id="hooks"><thead><tr><th>Endpoint</th><th>Events</th><th>Status</th><th>Last delivery</th><th></th></tr></thead><tbody></tbody></table>
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
<div style="flex:1;min-width:240px"><span class="lbl">Endpoint URL</span><input id="hUrl" placeholder="https://your-system.example.com/webhook"></div>
<label class="chk"><input type="checkbox" id="hStarted" checked> session.started</label>
<label class="chk"><input type="checkbox" id="hEnded" checked> session.ended</label>
<button id="hAdd">Add webhook</button>
</div>
<div id="hOut"></div>
</div>` : ''}`);
document.getElementById('fApply').onclick = loadReport;
document.getElementById('fExcel').onclick = exportExcel;
document.getElementById('fPdf').onclick = exportPdf;
if (IS_ADMIN) await populateAgentFilter();
await loadReport();
if (IS_ADMIN) {
document.getElementById('kAdd').onclick = createKey;
document.getElementById('hAdd').onclick = createHook;
await loadKeys();
await loadHooks();
}
}
// ---------- Integrations: API keys + webhooks (admin) ----------
function fmtTs(ms){ return ms ? new Date(ms).toLocaleString() : '—'; }
function revealBox(label, value, note){
return '<div class="reveal"><div class="lbl" style="margin:0 0 .3rem">'+esc(label)+' — copy now</div>'
+ '<div style="display:flex;gap:.5rem;align-items:center"><code id="revealVal">'+esc(value)+'</code>'
+ '<button class="mini" id="copyReveal">Copy</button></div>'
+ '<div class="muted" style="margin-top:.4rem;font-size:.78rem">'+esc(note)+'</div></div>';
}
function wireCopy(){ const b=document.getElementById('copyReveal'); if(!b)return; b.onclick=async()=>{ try{ await navigator.clipboard.writeText(document.getElementById('revealVal').textContent); }catch(_){} b.textContent='Copied'; setTimeout(()=>{b.textContent='Copy';},1500); }; }
async function loadKeys(){
let rows=[]; try{ rows = await api('/api/keys', null, 'GET'); }catch(e){ return; }
document.querySelector('#keys tbody').innerHTML = rows.length ? rows.map(k=>`
<tr style="${k.revoked?'opacity:.5':''}">
<td>${esc(k.name||'—')}</td>
<td class="muted">${esc(k.scopes||'')}</td>
<td>${fmtTs(k.created_at)}</td>
<td>${fmtTs(k.last_used_at)}</td>
<td>${k.revoked?'<span class="pill off">revoked</span>':'<span class="pill on">active</span>'}</td>
<td>${k.revoked?'':`<button class="mini danger" onclick="revokeKey('${k.id}')">Revoke</button>`}</td>
</tr>`).join('') : '<tr><td colspan=6 class="muted">No API keys yet.</td></tr>';
}
async function createKey(){
const scopes=[]; if(document.getElementById('kReport').checked)scopes.push('report:read'); if(document.getElementById('kAudit').checked)scopes.push('audit:read');
if(!scopes.length){ document.getElementById('kOut').innerHTML='<p class="muted">Select at least one scope.</p>'; return; }
try{
const r = await api('/api/keys', { name: document.getElementById('kName').value, scopes }, 'POST');
document.getElementById('kName').value='';
document.getElementById('kOut').innerHTML = revealBox('API key', r.key, "Send this to the integrator. It won't be shown again — revoke and re-issue if lost.");
wireCopy(); loadKeys();
}catch(e){ document.getElementById('kOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
}
window.revokeKey = async (id)=>{ if(!confirm('Revoke this API key? Integrations using it will stop working.'))return; try{ await api('/api/keys/revoke',{id},'POST'); loadKeys(); }catch(e){} };
async function loadHooks(){
let rows=[]; try{ rows = await api('/api/webhooks', null, 'GET'); }catch(e){ return; }
document.querySelector('#hooks tbody').innerHTML = rows.length ? rows.map(h=>`
<tr style="${h.active?'':'opacity:.5'}">
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis" class="muted">${esc(h.url)}</td>
<td class="muted">${esc(h.events||'')}</td>
<td>${h.last_status==null?'<span class="muted">—</span>':(h.last_status?'<span class="pill on">ok</span>':'<span class="pill off">failing</span>')}</td>
<td>${fmtTs(h.last_at)}${h.last_error?' <span class="muted" title="'+esc(h.last_error)+'">'+ic('alertTriangle',13)+'</span>':''}</td>
<td><button class="mini danger" onclick="deleteHook('${h.id}')">Delete</button></td>
</tr>`).join('') : '<tr><td colspan=5 class="muted">No webhooks yet.</td></tr>';
}
async function createHook(){
const events=[]; if(document.getElementById('hStarted').checked)events.push('session.started'); if(document.getElementById('hEnded').checked)events.push('session.ended');
const url=document.getElementById('hUrl').value.trim();
if(!/^https?:\/\//i.test(url)){ document.getElementById('hOut').innerHTML='<p class="muted">Enter a valid http(s) URL.</p>'; return; }
if(!events.length){ document.getElementById('hOut').innerHTML='<p class="muted">Select at least one event.</p>'; return; }
try{
const r = await api('/api/webhooks', { url, events }, 'POST');
document.getElementById('hUrl').value='';
document.getElementById('hOut').innerHTML = revealBox('Signing secret', r.secret, 'Verify the X-BizGaze-Signature header (HMAC-SHA256 of the body) with this. Shown once.');
wireCopy(); loadHooks();
}catch(e){ document.getElementById('hOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
}
window.deleteHook = async (id)=>{ if(!confirm('Delete this webhook?'))return; try{ await api('/api/webhooks/delete',{id},'POST'); loadHooks(); }catch(e){} };
const PER_PAGE = 5;
function pagerHTML(page, pages, total, fn){
@@ -241,8 +339,8 @@ function reportRowHTML(r){
<td>${esc(r.ticket || 'Direct session')}</td>
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
<td>${[
r.recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/${esc(r.recording)}" download> Video</a>` : '',
r.transcript ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/transcripts/${esc(r.transcript)}" download> Text</a>` : ''
r.recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/${esc(r.recording)}" download>${ic('download',14)} Video</a>` : '',
r.transcript ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/transcripts/${esc(r.transcript)}" download>${ic('download',14)} Text</a>` : ''
].join('') || '<span class="muted">—</span>'}</td>
</tr>`;
}
@@ -326,7 +424,7 @@ function exportPdf() {
'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
'</style></head><body>' +
'<h1>BizGaze Connect — Connection report</h1>' +
'<h1>Biz Connect — Connection report</h1>' +
'<div class="meta">' + esc(IS_ADMIN ? 'Agent: ' + agentSel : 'Agent: ' + agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
'<table><tr>' + headCells.map(h => '<th>' + esc(h) + '</th>').join('') + '</tr>' +
rows.map(r => '<tr><td>' + [r.date, r.start].concat(IS_ADMIN ? [esc(r.agent)] : []).concat([esc(r.ticket), r.spent]).join('</td><td>') + '</td></tr>').join('') +
+2387 -118
Visa fil
Filskillnaden har hållits tillbaka eftersom den är för stor Load Diff
+3 -1
Visa fil
@@ -19,10 +19,11 @@
.indicator { position:fixed; bottom:0; left:0; right:0; background:#b91c1c; color:#fff; text-align:center; padding:.4rem; font-size:.85rem; display:none; }
.indicator.show { display:block; }
</style>
<script src="/icons.js?v=3"></script>
</head>
<body>
<div class="card">
<h1>🖥️ Browser Host (no install)</h1>
<h1><span data-ic="monitor" data-sz="22"></span> Browser Host (no install)</h1>
<p class="muted">Shares this screen with a technician. Paste the enroll token from the console and click Go online.</p>
<input id="token" placeholder="enroll token">
<button id="goBtn">Go online</button>
@@ -98,5 +99,6 @@ function teardown() {
}
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
</script>
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
</body>
</html>
Binary file not shown.

Efter

Bredd:  |  Höjd:  |  Storlek: 11 KiB

Binary file not shown.

Efter

Bredd:  |  Höjd:  |  Storlek: 44 KiB

+73
Visa fil
@@ -0,0 +1,73 @@
// Shared icon set (Lucide — modern line icons). Use ic('name', size) for any UI icon.
// Add new icons here so the whole app stays visually consistent.
(function () {
const P = {
chat: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
screenShare: '<path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m17 8 5-5"/><path d="M17 3h5v5"/>',
wifi: '<path d="M12 20h.01"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 14 0"/><path d="M2 8.8a15 15 0 0 1 20 0"/>',
video: '<path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/>',
videoOff: '<path d="M10.66 6H14a2 2 0 0 1 2 2v2.34l1 1L22 8v8"/><path d="M16 16a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2l10 10Z"/><line x1="2" x2="22" y1="2" y2="22"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
headphones: '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>',
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
smile: '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>',
smilePlus: '<path d="M22 11v1a10 10 0 1 1-9-10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/><path d="M16 5h6"/><path d="M19 2v6"/>',
reply: '<polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
arrowLeft: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
send: '<path d="M14.54 21.69a.5.5 0 0 0 .94-.03l6.5-19a.5.5 0 0 0-.64-.63l-19 6.5a.5.5 0 0 0-.02.93l7.93 3.18a2 2 0 0 1 1.1 1.11z"/><path d="m21.85 2.15-10.94 10.94"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
micOff: '<line x1="2" x2="22" y1="2" y2="22"/><path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2"/><path d="M5 10v2a7 7 0 0 0 12 5"/><path d="M15 9.34V5a3 3 0 0 0-5.68-1.33"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12"/><line x1="12" x2="12" y1="19" y2="22"/>',
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
cameraOff: '<line x1="2" x2="22" y1="2" y2="22"/><path d="M7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12"/><path d="M9.5 4h5L17 7h3a2 2 0 0 1 2 2v7.5"/>',
phoneOff: '<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="2" x2="22" y1="2" y2="22"/>',
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
pencil: '<path d="M21.17 6.83a2.83 2.83 0 0 0-4-4L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/><path d="m15 5 4 4"/>',
chevronDown: '<path d="m6 9 6 6 6-6"/>',
chevronUp: '<path d="m18 15-6-6-6 6"/>',
star: '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
layoutDashboard:'<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
alertTriangle:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
monitor: '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
barChart: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><rect x="7" y="11" width="3" height="6" rx="1"/><rect x="12" y="7" width="3" height="10" rx="1"/><rect x="17" y="13" width="3" height="4" rx="1"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
bold: '<path d="M14 12a4 4 0 0 0 0-8H6v8"/><path d="M15 20a4 4 0 0 0 0-8H6v8Z"/>',
italic: '<line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/>',
strikethrough:'<path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/>',
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
list: '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>',
listOrdered: '<line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/>',
type: '<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/>',
crown: '<path d="m2 4 3 12h14l3-12-6 7-4-7-4 7-6-7z"/><path d="M5 20h14"/>',
checkCheck: '<path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/>',
calendarX: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m14 14-4 4"/><path d="m10 14 4 4"/>',
calendarClock:'<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><circle cx="16" cy="16" r="6"/><path d="M16 14v2l1.5 1"/>',
fileText: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
record: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/>',
callEnd: '<g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
};
window.ICON = P;
window.ic = function (name, size) {
const s = size || 18;
return '<svg class="ic" width="' + s + '" height="' + s + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (P[name] || '') + '</svg>';
};
})();
+18 -18
Visa fil
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Support</title>
<title>Biz Connect</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --line:#e6e9ef; }
*{box-sizing:border-box;}
@@ -17,9 +17,9 @@
.inner{max-width:780px;width:100%;text-align:center;}
h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;}
.sub{color:var(--muted);margin-bottom:2.2rem;}
.ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--blue);color:#fff;text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(31,59,115,.28);transition:transform .12s,box-shadow .12s,background .12s;}
.ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(31,59,115,.34);background:var(--blue-d);}
.ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.9rem;}
.ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--brand);color:var(--blue);text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(224,172,0,.32);transition:transform .12s,box-shadow .12s,background .12s;}
.ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(224,172,0,.4);background:var(--brand-d);}
.ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--blue);color:var(--brand);display:grid;place-items:center;font-weight:800;font-size:.9rem;}
.divider{display:flex;align-items:center;gap:1rem;color:var(--muted);font-size:.85rem;max-width:360px;margin:1.8rem auto;}
.divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--line);}
.choices{display:flex;gap:1.4rem;flex-wrap:wrap;justify-content:center;}
@@ -41,29 +41,32 @@
.profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
</style>
<script src="/icons.js?v=3"></script>
</head>
<body>
<header>
<div class="brandrow">
<img src="/logo.png" alt="" style="height:40px;width:auto;max-width:170px;border-radius:8px;object-fit:contain;background:#fff;padding:4px 10px" onerror="this.style.display='none'">
<div class="brand">BizGaze <span>Support</span></div>
<div class="brand">Biz <span>Connect</span></div>
</div>
<div id="authArea"></div>
</header>
<div class="wrap">
<div class="inner">
<h1>Welcome to BizGaze Connect</h1>
<h1>Welcome to Biz Connect</h1>
<div class="sub">Chat, meetings and secure remote support — for the BizGaze ecosystem.</div>
<!-- Stub SSO: routes to staff login for now; swap href to /sso once BizGaze SSO is wired. -->
<a class="ssobtn" id="ssoBtn" href="/home"><span class="bmark">B</span> Log in with BizGaze</a>
<div class="divider">need support? no account required</div>
<!-- Customer path FIRST (no account needed): share your screen for support. -->
<div class="divider">Need support? — no account needed</div>
<div class="choices" style="max-width:400px;margin:0 auto">
<a class="choice" href="/share">
<div class="icon share"><svg viewBox="0 0 24 24" fill="none" stroke="#BA7515" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div>
<div><h3>Share my screen</h3><p>Get a one-time code and show your screen to a BizGaze support agent — no login, no download.</p></div>
<div><h3>Share my screen</h3><p>Get a one-time code and show your screen to a Biz Connect support agent — no login, no download.</p></div>
</a>
</div>
<div class="foot">🔒 Screen sharing only starts after you approve it, and can be stopped anytime.</div>
<div class="foot" style="margin:.7rem 0 0"><span data-ic="lock" data-sz="14"></span> Screen sharing only starts after you approve it, and can be stopped anytime.</div>
<!-- Team member path BELOW: log in to the full app. Stub SSO -> /home for now. -->
<div class="divider" style="margin-top:1.6rem">BizGaze team member?</div>
<a class="ssobtn" id="ssoBtn" href="/home"><span class="bmark">B</span> Log in with BizGaze</a>
</div>
</div>
<footer>© BizGaze · Remote Support</footer>
@@ -73,13 +76,10 @@ function profileHTML(name){return '<div class="profile"><button class="pbtn" id=
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
makeBrandClickable();
(async function(){try{const r=await fetch('/api/me');if(r.ok){const me=await r.json();
document.getElementById('authArea').innerHTML=profileHTML(me.name||me.email);wireProfile();
// Already signed in: swap the login CTA for an "enter app" CTA.
const b=document.getElementById('ssoBtn'); if(b){ b.innerHTML='Open BizGaze Connect &rarr;'; b.href='/home'; }
const h=document.querySelector('.inner h1'); if(h){ const fn=String(me.name||'').trim().split(/\s+/)[0]; h.textContent='Welcome back'+(fn?', '+fn:'')+'!'; }
const dv=document.querySelector('.divider'); if(dv) dv.textContent='need to help someone? share your screen';
}}catch(_){}})();
// Already signed in -> skip this landing entirely and go straight to the app (no redundant
// "Open Biz Connect" page). This landing only shows when logged out.
(async function(){try{const r=await fetch('/api/me');if(r.ok){ location.replace('/home'); return; }}catch(_){}})();
</script>
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
</body>
</html>
+16
Visa fil
@@ -0,0 +1,16 @@
{
"name": "Biz Connect",
"short_name": "Connect",
"description": "Chat, screen share, and video meetings for the BizGaze ecosystem.",
"start_url": "/home",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#1F3B73",
"theme_color": "#1F3B73",
"icons": [
{ "src": "/icon-192.png?v=2", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+35 -18
Visa fil
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Support — Share your screen</title>
<title>Biz Connect — Share your screen</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#ffffff; --line:#e6e9ef; }
*{box-sizing:border-box;}
@@ -20,7 +20,7 @@
.sub{color:var(--muted);font-size:.97rem;line-height:1.5;margin-bottom:1.6rem;}
.codewrap{background:#fffdf2;border:2px dashed var(--brand);border-radius:14px;padding:1.2rem;}
.codelabel{font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:.3rem;}
.code{font-size:clamp(1.9rem,12vw,3rem);letter-spacing:clamp(.18rem,3vw,.5rem);font-weight:800;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:clip;}
.code{font-size:clamp(1.6rem,9vw,2.6rem);letter-spacing:clamp(.1rem,2vw,.4rem);padding-left:clamp(.1rem,2vw,.4rem);font-weight:800;color:var(--ink);white-space:nowrap;}
.status{margin-top:1.3rem;padding:.7rem 1rem;border-radius:10px;background:#f1f5f9;color:#475569;font-size:.92rem;}
.status.on{background:#ecfdf3;color:#15803d;}
.consent{margin-top:1.3rem;border:1px solid #c7d6f0;background:var(--blue-soft);border-left:5px solid var(--blue);border-radius:12px;padding:1.3rem;text-align:left;color:var(--blue-d);}
@@ -46,15 +46,16 @@
.profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
</style>
<script src="/icons.js?v=3"></script>
</head>
<body>
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
<div class="indicator" id="indicator">● Your screen is being shared — close this tab anytime to stop</div>
<a href="/" id="homeLink" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)">&#8592; Home</a>
<a href="/" id="homeLink" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
<div class="stage">
<div class="brandpanel">
<img src="/logo.png" alt="" style="width:88px;height:88px;border-radius:22px;object-fit:contain;margin-bottom:1.2rem;background:#fff;padding:8px;box-shadow:0 12px 30px rgba(0,0,0,.25)" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'mark',textContent:'B'}))">
<div class="wordmark">BizGaze <span>Support</span></div>
<div class="wordmark">Biz <span>Connect</span></div>
<div class="tagline">Secure, instant remote support — no downloads, you stay in control.</div>
</div>
<div class="panelside">
@@ -65,12 +66,12 @@
<div class="codelabel">Your session code</div>
<div style="display:flex;align-items:center;justify-content:center;gap:.7rem">
<div class="code" id="code">······</div>
<button id="copyBtn" title="Click to copy" aria-label="Click to copy" style="flex:0 0 auto;width:38px;height:38px;padding:0;background:#fff;border:1px solid var(--brand);color:var(--blue);border-radius:8px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center"><svg id="copyIcon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
<button id="copyBtn" title="Click to copy" aria-label="Click to copy" style="flex:0 0 auto;width:28px;height:28px;padding:0;background:#fff;border:1px solid var(--brand);color:var(--blue);border-radius:7px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center"><svg id="copyIcon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
</div>
</div>
<div id="status" class="status">Preparing your code…</div>
<div id="consentBox"></div>
<div class="foot">🔒 You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.</div>
<div class="foot"><span data-ic="lock" data-sz="14"></span> You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.</div>
</div>
</div>
</div>
@@ -79,7 +80,7 @@ let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
let SHARER_NAME='Customer';
try{fetch('/api/me').then(r=>r.ok?r.json():null).then(m=>{if(m&&(m.name||m.email))SHARER_NAME=m.name||m.email;}).catch(()=>{});}catch(_){}
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
// When embedded in the home shell, tell the parent when a session is live so the
@@ -98,7 +99,7 @@ document.getElementById('copyBtn').onclick=async()=>{
try{ await navigator.clipboard.writeText(code); }
catch(e){ const ta=document.createElement('textarea');ta.value=code;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove(); }
const b=document.getElementById('copyBtn'); const old=b.innerHTML;
b.innerHTML='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
b.innerHTML='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
setTimeout(()=>{b.innerHTML=old;},1500);
};
let ws,pc,localStream,chatChannel,sessionId;
@@ -144,7 +145,9 @@ function showConsent(m){
async function beginCapture(){
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
catch(err){ return false; }
try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>localStream.addTrack(t)); }catch(e){}
// Mic is OFF by default — we do NOT prompt for it here. Asking for the screen and the
// mic at once confused customers and silently cancelled the share. The mic permission
// is requested only when the customer taps Unmute (see the bar's mic button).
try{ ensureIce(); }catch(_){}
return true;
}
@@ -152,11 +155,10 @@ async function startStreaming(){
// If the Allow tap already captured the screen (mobile path), reuse it.
if(!localStream){
await ensureIce();
let mic=null; try{ mic=await navigator.mediaDevices.getUserMedia({audio:true}); }catch(e){ mic=null; }
setStatus('In the popup: choose your screen, then tap Share / Start.','on');
// Screen only — mic stays off until the customer taps Unmute (avoids the dual prompt).
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
catch(err){ if(mic){try{mic.getTracks().forEach(t=>t.stop());}catch(_){}} try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'share-cancelled'}));}catch(e){} setStatus('Screen share was cancelled. Refresh the page to try again.'); return; }
if(mic){ window.__mic=mic; try{mic.getAudioTracks().forEach(t=>localStream.addTrack(t));}catch(_){} }
catch(err){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'share-cancelled'}));}catch(e){} setStatus('Screen share was cancelled. Refresh the page to try again.'); return; }
}
await ensureIce();
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true);
@@ -168,11 +170,15 @@ async function startStreaming(){
pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};};
pc.ontrack=(ev)=>{ if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; } };
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
pc.onconnectionstatechange=()=>{ if(pc&&pc.connectionState==='failed'){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession('The connection was lost. Tap below for a new code if you still need help.'); } };
pc.onconnectionstatechange=()=>{ if(!pc) return; if(pc.connectionState==='connected'){ clearTimeout(window.__connWatch); } if(pc.connectionState==='failed'){ clearTimeout(window.__connWatch); try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } };
chatChannel=pc.createDataChannel('chat',{ordered:true});
chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}};
const offer=await pc.createOffer(); await pc.setLocalDescription(offer);
ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription}));
// Watchdog: if we can't establish the peer connection in time, show a clear reason
// instead of a blank screen (covers networks with no usable path even via TURN).
clearTimeout(window.__connWatch);
window.__connWatch=setTimeout(()=>{ if(pc && pc.connectionState!=='connected' && !sessionOver){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } }, 20000);
localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();};
}
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
@@ -221,18 +227,28 @@ let chatOpen=false;
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
const SVG_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const SVG_END='<svg viewBox="0 0 24 24" width="17" height="17" fill="#fff"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>';
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML='<span style="display:inline-flex">'+svg+'</span><span>'+label+'</span>';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;}
const SVG_END='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g></svg>';
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.title=label;b.setAttribute('aria-label',label);b.innerHTML='<span style="display:inline-flex">'+svg+'</span>';b.style.cssText='display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border:none;border-radius:50%;cursor:pointer;color:#fff;background:'+bg+';box-shadow:0 2px 6px rgba(0,0,0,.25);transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-2px)';b.onmouseleave=()=>b.style.transform='none';return b;}
function buildBar(){
if(document.getElementById('sessionBar'))return;
const bar=document.createElement('div'); bar.id='sessionBar';
bar.style.cssText='position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
const mic=_btn('micBtn',SVG_MICOFF,'Muted','#6b7280');
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
const end=_btn('endBtn2',SVG_END,'Stop','#dc2626');
const end=_btn('endBtn2',SVG_END,'End','#dc2626');
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end);
document.body.appendChild(bar);
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span><span>'+(t.enabled?'Mic':'Muted')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+(on?SVG_MIC:SVG_MICOFF)+'</span>';mic.style.background=on?'#2563eb':'#6b7280';};
mic.onclick=async()=>{
if(!window.__mic){
// First unmute: NOW request mic permission, add the track, and renegotiate.
let m; try{ m=await navigator.mediaDevices.getUserMedia({audio:true}); }catch(e){ setStatus('Microphone permission was blocked. Allow it in the browser to talk.'); return; }
window.__mic=m; const t=m.getAudioTracks()[0];
try{ localStream.addTrack(t); if(pc){ pc.addTrack(t,localStream); const offer=await pc.createOffer(); await pc.setLocalDescription(offer); ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription})); } }catch(_){}
setMic(true); return;
}
const t=window.__mic.getAudioTracks()[0]; if(!t) return; t.enabled=!t.enabled; setMic(t.enabled);
};
chat.onclick=toggleChat;
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} teardown(); };
buildChatPanel();
@@ -263,5 +279,6 @@ function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','ms
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
</script>
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
</body>
</html>
+46
Visa fil
@@ -0,0 +1,46 @@
// Biz Connect service worker — NOTIFICATIONS ONLY.
// Intentionally has NO 'fetch' handler and NO caching, so it can never serve a stale
// version of the app. Its only job is to show push notifications when the page is in
// the background / frozen / closed, and to open the right chat when one is clicked.
self.addEventListener('install', (e) => { self.skipWaiting(); });
self.addEventListener('activate', (e) => { e.waitUntil(self.clients.claim()); });
// No-op fetch handler: present only so the app meets PWA installability criteria. It never
// calls respondWith(), so the browser performs its normal network fetch — NO caching, so this
// can never serve a stale app.
self.addEventListener('fetch', () => {});
self.addEventListener('push', (event) => {
let d = {};
try { d = event.data ? event.data.json() : {}; } catch (_) {}
const title = d.title || 'Biz Connect';
const options = {
body: d.body || '',
icon: '/logo.png',
badge: '/logo.png',
tag: d.tag || undefined, // collapse repeats from the same chat
renotify: !!d.tag,
data: { kind: d.kind || '', id: d.id || '' },
};
event.waitUntil((async () => {
// If a BizGaze tab is currently VISIBLE, the page itself alerts the user (ping / in-page
// popup) — skip the OS popup to avoid a double. Only show when no tab is visible
// (another tab/app, minimized, or closed) — exactly when the page can't alert.
const clientsArr = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const visible = clientsArr.some((c) => c.visibilityState === 'visible');
if (visible) return;
await self.registration.showNotification(title, options);
})());
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { kind, id } = event.notification.data || {};
const url = '/home' + (id ? ('?openKind=' + encodeURIComponent(kind || 'dm') + '&openId=' + encodeURIComponent(id)) : '');
event.waitUntil((async () => {
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const c of all) {
if (c.url.includes('/home')) { try { await c.focus(); c.postMessage({ type: 'open-chat', kind, id }); return; } catch (_) {} }
}
try { await self.clients.openWindow(url); } catch (_) {}
})());
});
+3 -1
Visa fil
@@ -11,12 +11,13 @@
button { padding: 0.5rem 1rem; background: #334155; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
a { color: #3b82f6; }
</style>
<script src="/icons.js?v=3"></script>
</head>
<body>
<header>
<div id="status">Connecting…</div>
<div>
<a href="/"> Console</a>
<a href="/"><span data-ic="arrowLeft" data-sz="16"></span> Console</a>
<button id="endBtn">End session</button>
</div>
</header>
@@ -103,5 +104,6 @@ document.getElementById('endBtn').onclick = () => {
setTimeout(() => (location.href = '/'), 300);
};
</script>
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
</body>
</html>
+135
Visa fil
@@ -0,0 +1,135 @@
// Push notifications — Web Push (browsers/PWA) + native FCM (Android) / APNs (iOS).
// Fully optional and additive: each transport is independently config-gated, and every
// call is a silent best-effort no-op when its credentials aren't set. The app is unaffected
// if none are configured.
//
// Web Push: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (npx web-push generate-vapid-keys)
// Android/FCM: FCM_SERVICE_ACCOUNT = path to (or inline JSON of) a Firebase service account
// iOS/APNs: APNS_KEY (path/inline .p8), APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID,
// APNS_PRODUCTION=1 (use api.push.apple.com instead of the sandbox)
const crypto = require('crypto');
const fs = require('fs');
const http2 = require('http2');
const R = require('./repos');
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
const strData = (o) => { const d = {}; for (const k of Object.keys(o || {})) d[k] = typeof o[k] === 'string' ? o[k] : JSON.stringify(o[k]); return d; };
// ---------------- Web Push (VAPID) ----------------
let webpush = null;
try { webpush = require('web-push'); } catch (_) { /* package not installed -> web push disabled */ }
const PUBLIC = process.env.VAPID_PUBLIC_KEY || '';
const PRIVATE = process.env.VAPID_PRIVATE_KEY || '';
const SUBJECT = process.env.VAPID_SUBJECT || 'mailto:support@bizgaze.com';
let webReady = false;
if (webpush && PUBLIC && PRIVATE) {
try { webpush.setVapidDetails(SUBJECT, PUBLIC, PRIVATE); webReady = true; }
catch (e) { console.warn('[push] invalid VAPID config:', e.message); }
}
// ---------------- FCM (Android), HTTP v1 ----------------
let fcmSA = null; // { client_email, private_key, project_id }
(function loadFcm() {
const raw = process.env.FCM_SERVICE_ACCOUNT;
if (!raw) return;
try { fcmSA = JSON.parse(raw.trim().startsWith('{') ? raw : fs.readFileSync(raw, 'utf8')); }
catch (e) { console.warn('[push] FCM service account unreadable:', e.message); }
})();
let fcmTok = { value: '', exp: 0 };
async function fcmAccessToken() {
if (fcmTok.value && Date.now() < fcmTok.exp - 60000) return fcmTok.value;
const now = Math.floor(Date.now() / 1000);
const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const claim = b64url(JSON.stringify({ iss: fcmSA.client_email, scope: 'https://www.googleapis.com/auth/firebase.messaging', aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600 }));
const sig = b64url(crypto.sign('RSA-SHA256', Buffer.from(head + '.' + claim), fcmSA.private_key));
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: head + '.' + claim + '.' + sig }),
});
const j = await res.json();
if (!j.access_token) throw new Error('FCM token exchange failed');
fcmTok = { value: j.access_token, exp: Date.now() + (j.expires_in || 3600) * 1000 };
return fcmTok.value;
}
async function sendFcm(token, payload) {
const at = await fcmAccessToken();
const msg = { message: { token, notification: { title: payload.title || 'Biz Connect', body: payload.body || '' }, data: strData(payload.data || {}) } };
const res = await fetch('https://fcm.googleapis.com/v1/projects/' + fcmSA.project_id + '/messages:send', {
method: 'POST', headers: { Authorization: 'Bearer ' + at, 'Content-Type': 'application/json' }, body: JSON.stringify(msg),
});
return { ok: res.ok, dead: res.status === 404 || res.status === 410 }; // UNREGISTERED tokens
}
// ---------------- APNs (iOS), token-based over HTTP/2 ----------------
let apnsKey = null, apnsCfg = null;
(function loadApns() {
const raw = process.env.APNS_KEY;
if (!raw || !process.env.APNS_KEY_ID || !process.env.APNS_TEAM_ID || !process.env.APNS_BUNDLE_ID) return;
try {
apnsKey = raw.trim().startsWith('-----') ? raw : fs.readFileSync(raw, 'utf8');
apnsCfg = { keyId: process.env.APNS_KEY_ID, teamId: process.env.APNS_TEAM_ID, bundle: process.env.APNS_BUNDLE_ID, host: process.env.APNS_PRODUCTION === '1' ? 'https://api.push.apple.com' : 'https://api.sandbox.push.apple.com' };
} catch (e) { console.warn('[push] APNs key unreadable:', e.message); }
})();
let apnsJwt = { value: '', iat: 0 };
function apnsToken() {
const now = Math.floor(Date.now() / 1000);
if (apnsJwt.value && now - apnsJwt.iat < 3000) return apnsJwt.value; // refresh < 50 min
const head = b64url(JSON.stringify({ alg: 'ES256', kid: apnsCfg.keyId }));
const claim = b64url(JSON.stringify({ iss: apnsCfg.teamId, iat: now }));
const sig = b64url(crypto.sign('SHA256', Buffer.from(head + '.' + claim), { key: apnsKey, dsaEncoding: 'ieee-p1363' }));
apnsJwt = { value: head + '.' + claim + '.' + sig, iat: now };
return apnsJwt.value;
}
// One short-lived HTTP/2 connection per send — simple and fine at low volume; pool for scale.
function sendApns(token, payload) {
return new Promise((resolve) => {
let client;
try { client = http2.connect(apnsCfg.host); } catch (_) { return resolve({}); }
client.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
const body = JSON.stringify({ aps: { alert: { title: payload.title || 'Biz Connect', body: payload.body || '' }, sound: 'default' }, ...(payload.data || {}) });
const req = client.request({ ':method': 'POST', ':path': '/3/device/' + token, authorization: 'bearer ' + apnsToken(), 'apns-topic': apnsCfg.bundle, 'apns-push-type': 'alert' });
let status = 0;
req.on('response', (h) => { status = h[':status']; });
req.on('data', () => {});
req.on('end', () => { try { client.close(); } catch (_) {} resolve({ ok: status >= 200 && status < 300, dead: status === 410 }); });
req.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
req.end(body);
});
}
// ---------------- public API ----------------
const nativeReady = !!(fcmSA || apnsCfg);
const enabled = [webReady && 'WebPush', fcmSA && 'FCM', apnsCfg && 'APNs'].filter(Boolean);
console.log(enabled.length ? '[push] enabled: ' + enabled.join(', ') : '[push] disabled (no VAPID / FCM / APNs configured)');
function isEnabled() { return webReady; } // Web Push specifically (drives /api/push/vapid)
function publicKey() { return webReady ? PUBLIC : ''; }
// Fire-and-forget to every channel the user has: Web Push subscriptions + native device
// tokens. Dead endpoints/tokens are pruned. Never throws.
async function sendToUser(userId, payload) {
if (!webReady && !nativeReady) return;
const data = JSON.stringify(payload || {});
if (webReady) {
let subs = [];
try { subs = R.pushSubs.byUser(userId); } catch (_) { subs = []; }
for (const s of subs) {
try { await webpush.sendNotification({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, data, { TTL: 600 }); }
catch (err) { const c = err && err.statusCode; if (c === 404 || c === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } }
}
}
if (nativeReady) {
let toks = [];
try { toks = R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
for (const t of toks) {
try {
let r = null;
if (t.platform === 'android' && fcmSA) r = await sendFcm(t.token, payload);
else if (t.platform === 'ios' && apnsCfg) r = await sendApns(t.token, payload);
if (r && r.dead) { try { R.deviceTokens.removeByToken(t.token); } catch (_) {} }
} catch (_) { /* best-effort */ }
}
}
}
module.exports = { isEnabled, publicKey, sendToUser };
+25
Visa fil
@@ -0,0 +1,25 @@
// Fires a one-shot "starts in ~10 minutes" reminder to a scheduled meeting's host,
// group members, and invited participants. Runs on a 60s tick; marks each meeting reminded.
const R = require('./repos');
const CHAT = require('./chat');
function tick() {
try {
const now = Date.now();
const due = R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min
for (const s of due) {
const recipients = new Set([s.created_by]);
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
invited.forEach((id) => recipients.add(id));
if (s.group_id) { try { R.conversations.members(s.group_id).forEach((m) => recipients.add(m)); } catch (_) {} }
const evt = { type: 'meeting-reminder', meeting: { id: s.id, title: s.title, scheduledAt: s.scheduled_at, room: s.room_code } };
recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} });
R.scheduledMeetings.markReminded(s.id);
}
} catch (_) { /* never let the timer die */ }
}
let timer = null;
function start() { if (!timer) timer = setInterval(tick, 60 * 1000); }
start();
module.exports = { start, tick };
+200 -2
Visa fil
@@ -25,7 +25,7 @@ const users = {
byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email),
emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email),
listByTenant: (tenantId) =>
db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(tenantId),
db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId),
inTenant: (id, tenantId) =>
db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId),
create: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => {
@@ -40,6 +40,8 @@ const users = {
setRole: (id, role) => db.prepare('UPDATE users SET role=? WHERE id=?').run(role, id),
setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id),
setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id),
setAvatar: (id, url) => db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(url || null, id),
setStatus: (id, status) => db.prepare('UPDATE users SET status=? WHERE id=?').run(status, id),
remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id),
};
@@ -101,4 +103,200 @@ const sessionsLog = {
},
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog };
const refreshTokens = {
create: ({ userId, tokenHash, ttl }) =>
db.prepare('INSERT INTO refresh_tokens (token_hash,user_id,created_at,expires_at,revoked) VALUES (?,?,?,?,0)')
.run(tokenHash, userId, now(), now() + ttl),
byHash: (h) => db.prepare('SELECT * FROM refresh_tokens WHERE token_hash=?').get(h),
revoke: (h) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE token_hash=?').run(h),
revokeByUser: (userId) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE user_id=?').run(userId),
};
const apiKeys = {
create: ({ id, tenantId, name, keyHash, scopes, createdBy }) =>
db.prepare('INSERT INTO api_keys (id,team_id,name,key_hash,scopes,created_by,created_at,revoked) VALUES (?,?,?,?,?,?,?,0)')
.run(id, tenantId, name || null, keyHash, scopes || '', createdBy || null, now()),
byHash: (h) => db.prepare('SELECT * FROM api_keys WHERE key_hash=?').get(h),
listByTenant: (tenantId) =>
db.prepare('SELECT id,name,scopes,created_by,created_at,last_used_at,revoked FROM api_keys WHERE team_id=? ORDER BY created_at DESC').all(tenantId),
revoke: (id, tenantId) => db.prepare('UPDATE api_keys SET revoked=1 WHERE id=? AND team_id=?').run(id, tenantId),
touch: (id) => db.prepare('UPDATE api_keys SET last_used_at=? WHERE id=?').run(now(), id),
};
const webhooks = {
create: ({ id, tenantId, url, secret, events, createdBy }) =>
db.prepare('INSERT INTO webhooks (id,team_id,url,secret,events,active,created_by,created_at) VALUES (?,?,?,?,?,1,?,?)')
.run(id, tenantId, url, secret, events || '', createdBy || null, now()),
activeForTenant: (tenantId) => db.prepare('SELECT * FROM webhooks WHERE team_id=? AND active=1').all(tenantId),
listByTenant: (tenantId) =>
db.prepare('SELECT id,url,events,active,created_by,created_at,last_status,last_error,last_at FROM webhooks WHERE team_id=? ORDER BY created_at DESC').all(tenantId),
remove: (id, tenantId) => db.prepare('DELETE FROM webhooks WHERE id=? AND team_id=?').run(id, tenantId),
setStatus: (id, status, err) => db.prepare('UPDATE webhooks SET last_status=?, last_error=?, last_at=? WHERE id=?').run(status, err || null, now(), id),
};
const messages = {
send: ({ id, teamId, senderId, recipientId, body, replyTo, attachmentId, conversationId, mentions, msgType }) =>
db.prepare('INSERT INTO messages (id,team_id,sender_id,recipient_id,body,created_at,reply_to,attachment_id,conversation_id,mentions,msg_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, senderId, recipientId || '', body, now(), replyTo || null, attachmentId || null, conversationId || null, (mentions && mentions.length) ? JSON.stringify(mentions) : null, msgType || null),
byId: (id) => db.prepare('SELECT * FROM messages WHERE id=?').get(id),
byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId),
setPoll: (messageId, pollId) => db.prepare('UPDATE messages SET poll_id=? WHERE id=?').run(pollId, messageId),
markDelivered: (id) => db.prepare('UPDATE messages SET delivered_at=? WHERE id=? AND delivered_at IS NULL').run(now(), id),
// Delete-for-everyone: clear the content but keep the row (renders as a placeholder).
markDeleted: (id) => db.prepare("UPDATE messages SET deleted=1, body='', attachment_id=NULL, poll_id=NULL WHERE id=?").run(id),
// Shared media/files in a conversation (group) or DM — newest first.
attachmentsForConversation: (teamId, conversationId) => db.prepare(`SELECT a.id, a.name, a.mime, a.size, m.created_at FROM messages m JOIN attachments a ON a.id=m.attachment_id WHERE m.team_id=? AND m.conversation_id=? AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, conversationId),
attachmentsForDm: (teamId, a, b) => db.prepare(`SELECT at.id, at.name, at.mime, at.size, m.created_at FROM messages m JOIN attachments at ON at.id=m.attachment_id WHERE m.team_id=? AND m.conversation_id IS NULL AND ((m.sender_id=? AND m.recipient_id=?) OR (m.sender_id=? AND m.recipient_id=?)) AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, a, b, b, a),
linksForConversation: (teamId, conversationId) => db.prepare("SELECT body, created_at FROM messages WHERE team_id=? AND conversation_id=? AND deleted=0 AND body LIKE '%http%' ORDER BY created_at DESC").all(teamId, conversationId),
linksForDm: (teamId, a, b) => db.prepare("SELECT body, created_at FROM messages WHERE team_id=? AND conversation_id IS NULL AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) AND deleted=0 AND body LIKE '%http%' ORDER BY created_at DESC").all(teamId, a, b, b, a),
// Full 1:1 (DM) thread between two users (both directions), oldest first.
thread: (teamId, a, b, limit = 300) =>
db.prepare(`SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
ORDER BY created_at ASC LIMIT ?`).all(teamId, a, b, b, a, limit),
markRead: (teamId, recipientId, senderId) =>
db.prepare('UPDATE messages SET read_at=? WHERE team_id=? AND conversation_id IS NULL AND recipient_id=? AND sender_id=? AND read_at IS NULL')
.run(now(), teamId, recipientId, senderId),
// Recent DM messages involving a user (newest first) — reduced into per-contact conversations.
recentFor: (teamId, userId, limit = 1000) =>
db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND (sender_id=? OR recipient_id=?) ORDER BY created_at DESC LIMIT ?')
.all(teamId, userId, userId, limit),
// Group conversation helpers.
threadByConversation: (conversationId, limit = 300) =>
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at ASC LIMIT ?').all(conversationId, limit),
lastInConversation: (conversationId) =>
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId),
unreadInConversation: (conversationId, userId, since) =>
db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since).c,
};
const reactions = {
// Toggle with ONE reaction per user per message: picking an emoji replaces any prior
// reaction by that user; picking the same one again removes it. Returns true if added.
toggle: (messageId, userId, emoji) => {
const had = db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji);
db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId);
if (had) return false;
db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now());
return true;
},
forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId),
// All reactions on the messages in a 1:1 thread.
forPair: (teamId, a, b) =>
db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r
JOIN messages m ON r.message_id = m.id
WHERE m.team_id=? AND m.conversation_id IS NULL AND ((m.sender_id=? AND m.recipient_id=?) OR (m.sender_id=? AND m.recipient_id=?))`).all(teamId, a, b, b, a),
// All reactions on the messages in a group conversation.
forConversation: (conversationId) =>
db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r
JOIN messages m ON r.message_id = m.id WHERE m.conversation_id=?`).all(conversationId),
};
const conversations = {
create: ({ id, teamId, name, createdBy }) =>
db.prepare('INSERT INTO conversations (id,team_id,type,name,created_by,created_at) VALUES (?,?,?,?,?,?)').run(id, teamId, 'group', name || null, createdBy || null, now()),
byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id),
addMember: (conversationId, userId, admin) =>
db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?)').run(conversationId, userId, 0, now(), admin ? 1 : 0),
members: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId).map((r) => r.user_id),
isMember: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId),
// ---- Group admins (multiple allowed) ----
isAdmin: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId),
admins: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId).map((r) => r.user_id),
setMemberAdmin: (conversationId, userId, v) => db.prepare('UPDATE conversation_members SET admin=? WHERE conversation_id=? AND user_id=?').run(v ? 1 : 0, conversationId, userId),
oldestMember: (conversationId) => { const r = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; },
listForUser: (teamId, userId) =>
db.prepare('SELECT c.* FROM conversations c JOIN conversation_members m ON m.conversation_id=c.id WHERE c.team_id=? AND m.user_id=?').all(teamId, userId),
lastReadAt: (conversationId, userId) => { const r = db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; },
memberReads: (conversationId) => db.prepare('SELECT user_id, last_read_at FROM conversation_members WHERE conversation_id=?').all(conversationId),
setAdminOnly: (id, v) => db.prepare('UPDATE conversations SET admin_only=? WHERE id=?').run(v ? 1 : 0, id),
markRead: (conversationId, userId) => db.prepare('UPDATE conversation_members SET last_read_at=? WHERE conversation_id=? AND user_id=?').run(now(), conversationId, userId),
rename: (id, name) => db.prepare('UPDATE conversations SET name=? WHERE id=?').run(name, id),
setAvatar: (id, attachmentId) => db.prepare('UPDATE conversations SET avatar_id=? WHERE id=?').run(attachmentId || null, id),
byAvatar: (attachmentId) => db.prepare('SELECT * FROM conversations WHERE avatar_id=? LIMIT 1').get(attachmentId),
removeMember: (conversationId, userId) => db.prepare('DELETE FROM conversation_members WHERE conversation_id=? AND user_id=?').run(conversationId, userId),
remove: (id) => { db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); db.prepare('DELETE FROM conversations WHERE id=?').run(id); },
};
const attachments = {
create: ({ id, teamId, uploaderId, name, mime, size }) =>
db.prepare('INSERT INTO attachments (id,team_id,uploader_id,name,mime,size,created_at) VALUES (?,?,?,?,?,?,?)')
.run(id, teamId, uploaderId, name, mime || null, size || 0, now()),
byId: (id) => db.prepare('SELECT * FROM attachments WHERE id=?').get(id),
};
const scheduledMeetings = {
create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence }) =>
db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null),
byId: (id) => db.prepare('SELECT * FROM scheduled_meetings WHERE id=?').get(id),
byCode: (code) => db.prepare('SELECT * FROM scheduled_meetings WHERE room_code=? ORDER BY created_at DESC LIMIT 1').get(code),
// Meetings a user can see: created by them, a member of the group, or an invited participant.
listForUser: (teamId, userId) =>
db.prepare(`SELECT s.* FROM scheduled_meetings s
WHERE s.team_id=? AND (
s.created_by=? OR
(s.group_id IS NOT NULL AND EXISTS (SELECT 1 FROM conversation_members cm WHERE cm.conversation_id=s.group_id AND cm.user_id=?)) OR
(s.participants IS NOT NULL AND s.participants LIKE '%'||?||'%'))
ORDER BY s.scheduled_at ASC`).all(teamId, userId, userId, '"' + userId + '"'),
dueForReminder: (fromTs, toTs) => db.prepare('SELECT * FROM scheduled_meetings WHERE reminded=0 AND ended_at IS NULL AND scheduled_at>=? AND scheduled_at<=?').all(fromTs, toTs),
markReminded: (id) => db.prepare('UPDATE scheduled_meetings SET reminded=1 WHERE id=?').run(id),
end: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId),
cancel: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET cancelled=1, ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId),
reschedule: (id, teamId, ts) => db.prepare('UPDATE scheduled_meetings SET scheduled_at=?, reminded=0 WHERE id=? AND team_id=?').run(ts, id, teamId), // recurrence: roll to next occurrence
update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence }) =>
db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, reminded=0 WHERE id=? AND team_id=?')
.run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, id, teamId),
remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId),
};
const recordings = {
create: ({ id, teamId, room, groupId, meetingId, title, kind, file, mime, size, durationMs, createdBy, createdByName }) =>
db.prepare('INSERT INTO recordings (id,team_id,room,group_id,meeting_id,title,kind,file,mime,size,duration_ms,created_by,created_by_name,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, room || null, groupId || null, meetingId || null, title || null, kind, file || null, mime || null, size || null, durationMs || null, createdBy || null, createdByName || null, now()),
byId: (id) => db.prepare('SELECT * FROM recordings WHERE id=?').get(id),
forTeam: (teamId) => db.prepare('SELECT * FROM recordings WHERE team_id=? ORDER BY created_at DESC').all(teamId),
};
const polls = {
create: ({ id, teamId, conversationId, messageId, question, options, multi, createdBy }) =>
db.prepare('INSERT INTO polls (id,team_id,conversation_id,message_id,question,options,multi,closed,created_by,created_at) VALUES (?,?,?,?,?,?,?,0,?,?)')
.run(id, teamId, conversationId, messageId || null, question, JSON.stringify(options), multi ? 1 : 0, createdBy, now()),
byId: (id) => db.prepare('SELECT * FROM polls WHERE id=?').get(id),
close: (id) => db.prepare('UPDATE polls SET closed=1 WHERE id=?').run(id),
};
const pollVotes = {
forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId),
hasVoted: (pollId, userId, idx) => !!db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx),
add: (pollId, userId, idx) => db.prepare('INSERT OR IGNORE INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?)').run(pollId, userId, idx, now()),
remove: (pollId, userId, idx) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').run(pollId, userId, idx),
clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId),
};
const pushSubs = {
// Upsert by endpoint: re-subscribing the same browser updates its keys/owner.
add: ({ id, userId, endpoint, p256dh, auth }) =>
db.prepare('INSERT INTO push_subscriptions (id,user_id,endpoint,p256dh,auth,created_at) VALUES (?,?,?,?,?,?) ON CONFLICT(endpoint) DO UPDATE SET user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth')
.run(id, userId, endpoint, p256dh, auth, now()),
byUser: (userId) => db.prepare('SELECT * FROM push_subscriptions WHERE user_id=?').all(userId),
removeByEndpoint: (endpoint) => db.prepare('DELETE FROM push_subscriptions WHERE endpoint=?').run(endpoint),
};
const favorites = {
set: (userId, target, on) => on
? db.prepare('INSERT OR IGNORE INTO favorites (user_id,target,created_at) VALUES (?,?,?)').run(userId, target, now())
: db.prepare('DELETE FROM favorites WHERE user_id=? AND target=?').run(userId, target),
forUser: (userId) => db.prepare('SELECT target FROM favorites WHERE user_id=?').all(userId).map((r) => r.target),
};
const deviceTokens = {
// Upsert by token: re-registering the same device refreshes its owner/platform/last_seen.
register: ({ id, userId, tenantId, platform, token }) =>
db.prepare('INSERT INTO device_tokens (id,user_id,tenant_id,platform,token,created_at,last_seen) VALUES (?,?,?,?,?,?,?) ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, tenant_id=excluded.tenant_id, platform=excluded.platform, last_seen=excluded.last_seen')
.run(id, userId, tenantId || null, platform, token, now(), now()),
byUser: (userId) => db.prepare('SELECT * FROM device_tokens WHERE user_id=?').all(userId),
removeByToken: (token) => db.prepare('DELETE FROM device_tokens WHERE token=?').run(token),
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens };
+1017 -35
Visa fil
Filskillnaden har hållits tillbaka eftersom den är för stor Load Diff
+17 -1
Visa fil
@@ -1,5 +1,6 @@
// Session/auth helpers: resolve the current user from the cookie, write audit rows.
const R = require('./repos');
const A = require('./auth');
const { parseCookies, now } = require('./lib');
function audit(entry) {
@@ -35,4 +36,19 @@ function currentUser(req, { requireMfa = true } = {}) {
return { ...u, _session: s };
}
module.exports = { audit, currentUser, tokenFromReq };
// Resolve a third-party API key from `X-API-Key` or `Authorization: Bearer bzc_...`.
// Returns { id, teamId, scopes:[], name } or null. Keys are prefixed `bzc_` and stored hashed.
function apiKeyFromReq(req) {
let raw = req.headers && req.headers['x-api-key'];
if (!raw) {
const h = req.headers && (req.headers.authorization || req.headers.Authorization);
if (h && /^Bearer\s+bzc_/i.test(h)) raw = h.replace(/^Bearer\s+/i, '').trim();
}
if (!raw || !/^bzc_/.test(raw)) return null;
const row = R.apiKeys.byHash(A.hashToken(raw));
if (!row || row.revoked) return null;
return { id: row.id, teamId: row.team_id, scopes: String(row.scopes || '').split(',').map((s) => s.trim()).filter(Boolean), name: row.name };
}
function keyHasScope(key, scope) { return !!key && (key.scopes.includes(scope) || key.scopes.includes('*')); }
module.exports = { audit, currentUser, tokenFromReq, apiKeyFromReq, keyHasScope };
+166 -17
Visa fil
@@ -5,7 +5,9 @@
const R = require('./repos');
const A = require('./auth');
const { currentUser, audit } = require('./session');
const { onlineAgents, liveSessions, pendingShares } = require('./presence');
const { onlineAgents, liveSessions, pendingShares, meetingRooms, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
const W = require('./webhooks');
const CHAT = require('./chat');
function onConnection(ws, req) {
const hb = setInterval(() => {
@@ -20,6 +22,133 @@ function onConnection(ws, req) {
function handle(ws, m, req) {
switch (m.type) {
// --- Logged-in user registers this socket for live chat delivery ---
case 'chat-hello': {
const u = currentUser(req); // identity from the cookie/Bearer on the WS upgrade
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
CHAT.register(u.id, ws);
ws.send(JSON.stringify({ type: 'chat-ready' }));
break;
}
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.
case 'chat-delivered': {
if (!ws._chatUserId || !m.id) break;
const msg = R.messages.byId(m.id);
if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only
if (msg.recipient_id !== ws._chatUserId) break; // only the recipient can ack
if (!msg.delivered_at) { R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id }); } catch (_) {} }
break;
}
// --- Meetings (mesh): create a room, join by code, relay SDP/ICE peer-to-peer ---
case 'meeting-create': {
let code; do { code = A.numericCode(6); } while (meetingRooms.has(code));
meetingRooms.set(code, new Map());
const cu = currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host
ws.send(JSON.stringify({ type: 'meeting-created', room: code }));
break;
}
case 'meeting-join': {
const room = String(m.room || '').trim();
let peers = meetingRooms.get(room);
// A scheduled meeting's room is created lazily on first join (its code lives in the DB).
if (!peers) {
const sched = R.scheduledMeetings.byCode(room);
if (sched && !sched.ended_at) { peers = new Map(); meetingRooms.set(room, peers); }
}
if (!peers) return ws.send(JSON.stringify({ type: 'error', message: 'Meeting not found' }));
const peerId = A.token(6);
const name = String(m.name || 'Guest').slice(0, 60);
ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name;
// Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by.
let hostUserId = roomHost.get(room);
if (hostUserId === undefined) { try { const s = R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }
const ju = currentUser(req);
ws._meetingUserId = ju ? ju.id : null; // for per-user transcript ownership
const avatar = (ju && ju.avatar_url) ? ju.avatar_url : null; // for participant-tile profile pics
const isHost = !!(ju && hostUserId && ju.id === hostUserId);
// Tell the newcomer who's already here (they initiate offers to existing peers)…
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name, avatar: p.avatar || null, uid: p.uid || null })) }));
// …and tell existing peers a newcomer arrived.
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); }
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null });
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true })); // catch up: already transcribing
break;
}
case 'meeting-signal': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
const target = peers.get(m.to);
if (target && target.ws.readyState === 1) target.ws.send(JSON.stringify({ type: 'meeting-signal', from: ws._peerId, data: m.data }));
break;
}
// Relay a peer's mic/cam state to everyone else in the room (for the tile mute icon).
case 'meeting-state': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-state', peerId: ws._peerId, muted: !!m.muted, camOff: !!m.camOff })); }
break;
}
// Relay a peer's screen-share on/off to everyone else (for the tile badge + single-share rule).
case 'meeting-screen': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-screen', from: ws._peerId, on: !!m.on })); }
break;
}
// Host: set whether multiple people may share their screen at once.
case 'meeting-sharemode': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-sharemode', multi: !!m.multi })); }
break;
}
// Host starts/stops recording → tell everyone so they see (and hear) the "being recorded" notice.
case 'meeting-recording': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-recording', on: !!m.on, by: ws._peerName || 'The host' })); }
break;
}
// A participant subscribes/unsubscribes to a transcript copy. While ≥1 subscriber, EVERY client
// transcribes its own mic (full conversation); each subscriber gets their own private copy.
// Unsubscribing only drops YOUR copy — it never stops anyone else's.
case 'meeting-transcribe': {
const room = ws._meetingRoom; const peers = room && meetingRooms.get(room);
if (!peers) return; const uid = ws._meetingUserId; if (!uid) return;
let subs = transcriptSubs.get(room); if (!subs) { subs = new Set(); transcriptSubs.set(room, subs); }
if (m.on) subs.add(uid); else { try { require('./calls').finalizeTranscript(room, uid); } catch (_) {} } // finalize writes + removes the sub
const active = subs.size > 0;
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active })); }
break;
}
// A participant's recognized speech segment → appended to the room's shared transcript buffer.
case 'meeting-transcript': {
const room = ws._meetingRoom; if (!room || !meetingRooms.get(room)) return;
const text = String(m.text || '').slice(0, 1000).trim(); if (!text) return;
let buf = transcriptBuffers.get(room); if (!buf) { buf = []; transcriptBuffers.set(room, buf); }
buf.push({ t: Date.now(), speaker: ws._peerName || 'Guest', text });
if (buf.length > 8000) buf.shift();
break;
}
// Host: mute everyone else in the room.
case 'meeting-muteall': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers) return;
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-muteall', by: ws._peerId })); }
break;
}
// Host: transfer host to another peer (broadcast the new host to the room).
case 'meeting-host': {
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
if (!peers || !m.to) return;
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-host', hostPeerId: m.to })); }
break;
}
case 'meeting-leave': {
leaveMeeting(ws);
break;
}
// --- Agent comes online ---
case 'agent-hello': {
const machine = R.machines.byEnrollToken(m.enrollToken);
@@ -61,6 +190,7 @@ function handle(ws, m, req) {
try {
R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null });
} catch (e) { /* duplicate consent */ }
try { W.emit('session.started', sess.machine.team_id, { sessionId: m.sessionId, agent_email: sess.user.email, agent_name: sess.agentName || sess.user.email, ticket: sess.ticket || null, started_at: Date.now() }); } catch (_) {}
sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
} else {
@@ -133,26 +263,16 @@ function handle(ws, m, req) {
}
}
function notifyBizGaze(sessionId) {
const url = process.env.BIZGAZE_WEBHOOK_URL;
if (!url) return;
try {
const row = R.sessionsLog.byId(sessionId);
if (!row) return;
const body = JSON.stringify({ event: 'session.ended', sessionId: row.id, agent_email: row.agent_email,
agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
const crypto = require('crypto');
const sig = process.env.SSO_SECRET ? crypto.createHmac('sha256', process.env.SSO_SECRET).update(body).digest('base64url') : '';
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sig }, body }).catch(()=>{});
} catch (e) {}
}
function endSession(sessionId, reason) {
const sess = liveSessions.get(sessionId);
if (!sess) return;
try { R.sessionsLog.end(sessionId); } catch (e) {}
notifyBizGaze(sessionId);
try {
const row = R.sessionsLog.byId(sessionId);
if (row) W.emit('session.ended', sess.machine.team_id, { sessionId: row.id, agent_email: row.agent_email,
agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
} catch (e) {}
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'session_ended', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
[sess.agentWs, sess.viewerWs].forEach((p) => {
if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
@@ -160,7 +280,36 @@ function endSession(sessionId, reason) {
liveSessions.delete(sessionId);
}
function leaveMeeting(ws) {
const room = ws._meetingRoom;
if (!room) return;
const peers = meetingRooms.get(room);
ws._meetingRoom = null;
const pid = ws._peerId;
if (!peers) return;
try { require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript
peers.delete(pid);
// 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call).
if (roomToDmCall.has(room)) {
for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } }
meetingRooms.delete(room);
try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
roomHost.delete(room);
try { require('./calls').endCallByRoom(room); } catch (_) {}
return;
}
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); }
if (peers.size === 0) {
meetingRooms.delete(room);
try { require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps
roomHost.delete(room);
try { require('./calls').endCallByRoom(room); } catch (_) {}
}
}
function cleanup(ws) {
CHAT.unregister(ws);
leaveMeeting(ws);
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
if (ws.sessionId) {
+78 -4
Visa fil
@@ -5,9 +5,9 @@ const path = require('path');
const R = require('./repos');
const { json } = require('./lib');
const { currentUser } = require('./session');
const { PUBLIC_DIR, REC_DIR, TRANS_DIR } = require('./config');
const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR } = require('./config');
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
function serveStatic(req, res) {
let p = req.url.split('?')[0];
@@ -19,12 +19,29 @@ function serveStatic(req, res) {
if (p === '/connect') p = '/connect.html';
const fp = path.join(PUBLIC_DIR, path.normalize(p));
if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
// ETag + revalidation: the browser keeps the file cached and we answer repeat loads with a
// tiny 304 (no re-download) when nothing changed — fast reloads, but always fresh on edits.
fs.stat(fp, (serr, st) => {
if (serr || !st.isFile()) return json(res, 404, { error: 'not found' });
const ext = path.extname(fp);
const ct = MIME[ext] || 'application/octet-stream';
// HTML entry pages are NEVER cached (no-store) so a deploy reaches every browser on the
// next load — no hard-refresh needed. Versioned assets (e.g. icons.js?v=) still revalidate
// cheaply via ETag/304.
const isHtml = ext === '.html';
const etag = '"' + st.size.toString(16) + '-' + Math.round(st.mtimeMs).toString(16) + '"';
if (!isHtml && req.headers['if-none-match'] === etag) {
res.writeHead(304, { ETag: etag, 'Cache-Control': 'no-cache' });
return res.end();
}
fs.readFile(fp, (err, data) => {
if (err) return json(res, 404, { error: 'not found' });
const ct = MIME[path.extname(fp)] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' });
const headers = { 'Content-Type': ct, 'Content-Length': st.size, 'Cache-Control': isHtml ? 'no-store, must-revalidate' : 'no-cache' };
if (!isHtml) headers.ETag = etag;
res.writeHead(200, headers);
res.end(data);
});
});
}
// GET fallback: authenticated transcript/recording downloads, else static files.
@@ -66,6 +83,63 @@ function handleGet(req, res) {
rs.pipe(res);
});
}
// Meeting recordings & transcripts (/mrec/<id>). Visible to the creator, group members, or those
// who can see the scheduled meeting it belongs to.
if (pathOnly.startsWith('/mrec/')) {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const r = R.recordings.byId(id);
if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' });
let allowed = r.created_by === u.id;
if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner
else {
if (!allowed && r.group_id) allowed = R.conversations.isMember(r.group_id, u.id);
if (!allowed && r.meeting_id) { const s = R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); }
}
if (!allowed) return json(res, 403, { error: 'forbidden' });
const isVideo = r.kind === 'video';
const dir = isVideo ? REC_DIR : TRANS_DIR;
const fp = path.join(dir, r.file);
if (!fp.startsWith(dir)) return json(res, 403, { error: 'forbidden' });
const ext = isVideo ? 'webm' : 'txt';
const fname = String(r.title || 'meeting').replace(/[^a-z0-9 _-]/gi, '').trim().slice(0, 40) || 'meeting';
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': r.mime || (isVideo ? 'video/webm' : 'text/plain; charset=utf-8'), 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="' + fname + '-' + (isVideo ? 'recording' : 'transcript') + '.' + ext + '"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);
});
}
if (pathOnly.startsWith('/files/')) {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const id = path.basename(decodeURIComponent(pathOnly));
const a = R.attachments.byId(id);
if (!a || a.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
// Authorize: the uploader, a participant of the message carrying this attachment,
// or a member of the group that uses this attachment as its image.
const msg = R.messages.byAttachment(id);
const avatarGroup = R.conversations.byAvatar(id);
const allowed = a.uploader_id === u.id
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id))
|| (msg && (
msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id)
: (msg.sender_id === u.id || msg.recipient_id === u.id)));
if (!allowed) return json(res, 403, { error: 'forbidden' });
const fp = path.join(UPLOADS_DIR, id);
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
const isImage = /^image\//.test(a.mime || '');
const safeName = String(a.name || 'file').replace(/[\r\n"]/g, '');
return fs.stat(fp, (err, st) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': a.mime || 'application/octet-stream', 'Content-Length': st.size, 'Content-Disposition': (isImage ? 'inline' : 'attachment') + '; filename="' + safeName + '"', 'Cache-Control': 'private, max-age=86400' });
const rs = fs.createReadStream(fp);
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
rs.pipe(res);
});
}
return serveStatic(req, res);
}
+280
Visa fil
@@ -18,6 +18,8 @@ process.env.HTTPS_PORT = 8444; // avoid clashing with a running dev server on 84
const { server } = require('../server');
const A = require('../auth');
const WebSocket = require('ws');
const http = require('http');
const crypto = require('crypto');
const BASE = `http://localhost:${PORT}`;
let passed = 0, failed = 0;
@@ -64,6 +66,12 @@ function nextMsg(ws, type, timeout = 3000) {
await wait(300); // let server bind
console.log('E2E backend tests:');
// Local receiver to capture outbound webhook deliveries.
const webhookHits = [];
const hookSrv = http.createServer((rq, rs) => { let b = ''; rq.on('data', (c) => (b += c)); rq.on('end', () => { webhookHits.push({ sig: rq.headers['x-bizgaze-signature'], body: b }); rs.writeHead(200); rs.end('ok'); }); });
await new Promise((r) => hookSrv.listen(8077, r));
const HOOK_URL = 'http://localhost:8077/hook';
// 1. Register (first user becomes admin)
const email = 'tech@example.com';
const reg = await call('/api/register', { email, password: 'supersecret', teamName: 'Acme IT' });
@@ -78,6 +86,268 @@ function nextMsg(ws, type, timeout = 3000) {
const me = await get('/api/me', cookie);
check('me works after login, role=admin', me.status === 200 && me.data.role === 'admin');
// 3b. Native client path: access+refresh tokens, refresh exchange (rotated), Bearer auth
check('login returns access + refresh tokens', !!login.data.token && !!login.data.refreshToken);
const refreshed = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken });
check('refresh issues a new access token', refreshed.status === 200 && !!refreshed.data.token && !!refreshed.data.refreshToken);
const meBearer = await fetch(BASE + '/api/v1/me', { headers: { Authorization: 'Bearer ' + refreshed.data.token } });
check('new access token authorizes /api/v1/me (Bearer)', meBearer.status === 200);
const reuse = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken });
check('rotated (old) refresh token is rejected', reuse.status === 401);
// 3b'. Native device push tokens (FCM/APNs registration; delivery no-ops until creds set)
const devReg = await call('/api/v1/devices', { platform: 'android', token: 'fcm-tok-123' }, cookie);
check('device token registered', devReg.status === 200 && devReg.data.ok === true);
const devBad = await call('/api/v1/devices', { platform: 'windows', token: 'x' }, cookie);
check('device register rejects invalid platform', devBad.status === 400);
const devNoAuth = await call('/api/v1/devices', { platform: 'ios', token: 'y' });
check('device register requires auth', devNoAuth.status === 401);
const devRm = await call('/api/v1/devices/remove', { token: 'fcm-tok-123' }, cookie);
check('device token removed', devRm.status === 200);
// 3c. API keys (machine-to-machine integration), scoped + revocable
const mkKey = await call('/api/v1/keys', { name: 'ci', scopes: ['report:read'] }, cookie);
check('admin creates API key (bzc_ prefix)', mkKey.status === 200 && /^bzc_/.test(mkKey.data.key || ''));
const apiKey = mkKey.data.key;
const repKey = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } });
check('API key with report:read reads /api/v1/report', repKey.status === 200);
const repNone = await fetch(BASE + '/api/v1/report');
check('no credential -> /api/v1/report 401', repNone.status === 401);
const audKey = await fetch(BASE + '/api/v1/audit', { headers: { 'X-API-Key': apiKey } });
check('report-only key cannot read audit (scope enforced)', audKey.status === 401);
const revKey = await call('/api/v1/keys/revoke', { id: mkKey.data.id }, cookie);
check('admin revokes API key', revKey.status === 200);
const repRevoked = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } });
check('revoked API key -> 401', repRevoked.status === 401);
// 3d. Register a webhook (delivery is asserted after the session flow below)
const mkHook = await call('/api/v1/webhooks', { url: HOOK_URL, events: ['*'] }, cookie);
check('admin registers a webhook', mkHook.status === 200 && !!mkHook.data.secret);
const hookSecret = mkHook.data.secret;
// 3e. Chat (persistent 1:1 messaging) — two users in the same team
const adminId = me.data.id;
await call('/api/v1/users', { email: 'bob@example.com', password: 'supersecret', name: 'Bob' }, cookie);
const bobLogin = await call('/api/v1/login', { email: 'bob@example.com', password: 'supersecret' });
const bobCookie = bobLogin.cookie;
const contacts = await get('/api/v1/messages/contacts', cookie);
check('contacts list includes the other user', contacts.status === 200 && contacts.data.some((c) => c.email === 'bob@example.com'));
const bobId = (contacts.data.find((c) => c.email === 'bob@example.com') || {}).id;
const bobWs = new WebSocket(`ws://localhost:${PORT}/ws`, { headers: { Cookie: bobCookie } });
bobWs.q = []; bobWs.on('message', (d) => bobWs.q.push(JSON.parse(d)));
await new Promise((r) => bobWs.on('open', r));
bobWs.send(JSON.stringify({ type: 'chat-hello' }));
await nextMsg(bobWs, 'chat-ready');
check('chat-hello -> chat-ready', true);
const sent = await call('/api/v1/messages', { to: bobId, body: 'hello bob' }, cookie);
check('message sent', sent.status === 200 && !!sent.data.id);
const pushed = await nextMsg(bobWs, 'chat-message');
check('recipient receives the message live over WS', pushed.message && pushed.message.body === 'hello bob');
const bobConvos = await get('/api/v1/messages/conversations', bobCookie);
check('recipient conversation shows unread', bobConvos.data.some((c) => c.contactId === adminId && c.unread >= 1));
const bobThread = await get('/api/v1/messages/thread?with=' + adminId, bobCookie);
check('thread returns the message', bobThread.status === 200 && bobThread.data.some((m) => m.body === 'hello bob'));
const bobConvos2 = await get('/api/v1/messages/conversations', bobCookie);
check('reading the thread clears unread', !bobConvos2.data.some((c) => c.contactId === adminId && c.unread >= 1));
// read receipt: after bob read the thread, admin's sent message shows read_at
const adminTh = await get('/api/v1/messages/thread?with=' + bobId, cookie);
const seenMsg = adminTh.data.find((x) => x.body === 'hello bob');
check('read receipt: sent message marked read after recipient reads', !!seenMsg && !!seenMsg.read_at);
// reply / quote
const reply = await call('/api/v1/messages', { to: bobId, body: 'replying to you', replyTo: sent.data.id }, cookie);
check('reply accepts replyTo', reply.status === 200 && reply.data.reply_to === sent.data.id);
const adminThread = await get('/api/v1/messages/thread?with=' + bobId, cookie);
check('thread carries the quoted preview', adminThread.data.some((x) => x.reply && x.reply.body === 'hello bob'));
// reactions
const react1 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '👍' }, cookie);
check('reaction toggles on', react1.status === 200 && react1.data.added === true);
const rpush = await nextMsg(bobWs, 'chat-reaction');
check('reaction pushed live (full set, with who)', rpush.messageId === sent.data.id && Array.isArray(rpush.reactions) && rpush.reactions.some((r) => r.emoji === '👍' && r.count === 1 && Array.isArray(r.who) && r.who.length === 1));
const th2 = await get('/api/v1/messages/thread?with=' + bobId, cookie);
const rmsg = th2.data.find((x) => x.id === sent.data.id);
check('thread aggregates the reaction', !!rmsg && rmsg.reactions.some((r) => r.emoji === '👍' && r.count === 1 && r.mine === true && r.who.length === 1));
// one reaction per user: a different emoji REPLACES the previous one (no stacking)
const react1b = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie);
check('switching emoji replaces (one reaction per user)', react1b.data.added === true && react1b.data.reactions.length === 1 && react1b.data.reactions[0].emoji === '❤️');
const react2 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie);
check('reaction toggles off', react2.data.added === false && react2.data.reactions.length === 0);
// file sharing
const up = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'text/plain', 'X-Filename': encodeURIComponent('note.txt') }, body: 'hello file' });
const upd = await up.json();
check('file upload returns an id', up.status === 200 && !!upd.id);
const fmsg = await call('/api/v1/messages', { to: bobId, attachmentId: upd.id }, cookie);
check('message with attachment (no text) accepted', fmsg.status === 200 && fmsg.data.attachment && fmsg.data.attachment.id === upd.id);
const dl = await fetch(BASE + '/files/' + upd.id, { headers: { Cookie: cookie } });
const dlBody = await dl.text();
check('attachment downloads for a participant', dl.status === 200 && dlBody === 'hello file');
const dlNo = await fetch(BASE + '/files/' + upd.id);
check('attachment download denied without auth', dlNo.status === 401);
// 3g. Group chat
const mkGroup = await call('/api/v1/groups', { name: 'Team Huddle', memberIds: [bobId] }, cookie);
check('group created with members', mkGroup.status === 200 && !!mkGroup.data.id && mkGroup.data.members === 2);
const gid = mkGroup.data.id;
bobWs.q.length = 0; // drain prior pushes so we catch the group message
const gsend = await call('/api/v1/messages', { group: gid, body: 'hi team' }, cookie);
check('group message sent', gsend.status === 200 && gsend.data.conversation_id === gid);
const gpush = await nextMsg(bobWs, 'chat-message');
check('group message delivered to a member', gpush.message && gpush.message.conversation_id === gid && gpush.message.body === 'hi team');
const gconv = await get('/api/v1/messages/conversations', bobCookie);
const gItem = gconv.data.find((c) => c.kind === 'group' && c.id === gid);
check('group appears in member conversations with unread', !!gItem && gItem.unread >= 1);
const gthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
check('group thread returns messages with sender name', gthread.status === 200 && gthread.data.some((m) => m.body === 'hi team' && m.fromName));
const gconv2 = await get('/api/v1/messages/conversations', bobCookie);
const gItem2 = gconv2.data.find((c) => c.kind === 'group' && c.id === gid);
check('reading group thread clears unread', !!gItem2 && gItem2.unread === 0);
// group "seen by": bob read the thread above, so admin's message lists him as a reader
const gSeen = await get('/api/v1/messages/thread?group=' + gid, cookie);
const hiTeam = gSeen.data.find((x) => x.body === 'hi team');
check('group message shows seen-by readers', !!hiTeam && Array.isArray(hiTeam.seenBy) && hiTeam.seenBy.length >= 1);
const gmembers = await get('/api/v1/groups/members?group=' + gid, cookie);
check('group members listed', gmembers.status === 200 && gmembers.data.length === 2);
// @mentions: members + @everyone are stored; non-members are filtered out
const ment = await call('/api/v1/messages', { group: gid, body: 'ping @Bob and @everyone', mentions: [bobId, 'everyone', 'not-a-member'] }, cookie);
check('group message accepts mentions', ment.status === 200);
check('mentions filtered to members + everyone', Array.isArray(ment.data.mentions) && ment.data.mentions.includes(bobId) && ment.data.mentions.includes('everyone') && !ment.data.mentions.includes('not-a-member'));
const mthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
const mEntry = mthread.data.find((x) => x.id === ment.data.id);
check('thread carries mentions', !!mEntry && mEntry.mentions.includes(bobId) && mEntry.mentions.includes('everyone'));
// 3j. Polls (single + multi, vote/switch, close, inline in thread)
const mkPoll = await call('/api/v1/polls', { group: gid, question: 'Lunch?', options: ['Pizza', 'Sushi'], multi: false }, cookie);
check('poll created', mkPoll.status === 200 && mkPoll.data.options.length === 2 && mkPoll.data.totalVotes === 0);
const pid = mkPoll.data.id;
const needTwo = await call('/api/v1/polls', { group: gid, question: 'X?', options: ['only one'] }, cookie);
check('poll requires >=2 options', needTwo.status === 400);
const v1 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie);
check('vote recorded', v1.status === 200 && v1.data.options[0].votes === 1 && v1.data.options[0].mine === true);
const v2 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 1 }, bobCookie); // single-choice -> switch
check('single-choice switches the vote', v2.data.options[0].votes === 0 && v2.data.options[1].votes === 1);
const av = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, cookie);
check('second voter counted', av.data.totalVotes === 2 && av.data.voters === 2);
const mkPoll2 = await call('/api/v1/polls', { group: gid, question: 'Toppings?', options: ['Cheese', 'Olives', 'Mushroom'], multi: true }, cookie);
const pid2 = mkPoll2.data.id;
await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 0 }, bobCookie);
const mv = await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 1 }, bobCookie);
check('multi-choice keeps multiple votes', mv.data.options[0].votes === 1 && mv.data.options[1].votes === 1 && mv.data.voters === 1);
const badClose = await call('/api/v1/polls/close', { pollId: pid }, bobCookie);
check('non-creator cannot close a poll', badClose.status === 403);
const closed = await call('/api/v1/polls/close', { pollId: pid }, cookie);
check('creator closes the poll', closed.status === 200 && closed.data.closed === true);
const voteClosed = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie);
check('cannot vote on a closed poll', voteClosed.status === 400);
const pthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
const pmsg = pthread.data.find((x) => x.poll && x.poll.id === pid);
check('poll renders inline in the thread', !!pmsg && pmsg.poll.options.length === 2 && pmsg.poll.closed === true);
// group management: rename, info, remove(leave)
const ren = await call('/api/v1/groups/rename', { group: gid, name: 'Renamed Huddle' }, cookie);
check('group renamed', ren.status === 200 && ren.data.name === 'Renamed Huddle');
const gthrRen = await get('/api/v1/messages/thread?group=' + gid, cookie);
check('rename posts an activity message', gthrRen.data.some((x) => x.system && /renamed/.test(x.body)));
// admin-only toggle: only the creator can change it / manage members when on
const ao1 = await call('/api/v1/groups/admin-only', { group: gid, value: true }, cookie);
check('admin enables admin-only', ao1.status === 200 && ao1.data.adminOnly === true);
const aoBobToggle = await call('/api/v1/groups/admin-only', { group: gid, value: false }, bobCookie);
check('non-admin cannot change admin-only', aoBobToggle.status === 403);
const aoBobRemove = await call('/api/v1/groups/remove', { group: gid, userId: adminId }, bobCookie);
check('admin-only blocks non-admin removing others', aoBobRemove.status === 403);
const ao0 = await call('/api/v1/groups/admin-only', { group: gid, value: false }, cookie);
check('admin disables admin-only', ao0.status === 200 && ao0.data.adminOnly === false);
// shared group call: start returns a room; a second start joins the same live call
const call1 = await call('/api/v1/groups/call/start', { group: gid }, cookie);
check('group call starts with a room', call1.status === 200 && /^\d{6}$/.test(call1.data.room || '') && call1.data.active === true);
const call2 = await call('/api/v1/groups/call/start', { group: gid }, bobCookie);
check('second start joins the same call (no code)', call2.status === 200 && call2.data.room === call1.data.room && call2.data.already === true);
const convCall = await get('/api/v1/messages/conversations', bobCookie);
const gcRow = convCall.data.find((c) => c.kind === 'group' && c.id === gid);
check('conversations expose the active call', !!gcRow && gcRow.callActive === true && gcRow.callRoom === call1.data.room);
// 1:1 call + add-participant invite
const dmCall1 = await call('/api/v1/calls/dm/start', { to: bobId }, cookie);
check('DM call starts with a room', dmCall1.status === 200 && /^\d{6}$/.test(dmCall1.data.room || '') && dmCall1.data.active === true);
const dmCall2 = await call('/api/v1/calls/dm/start', { to: adminId }, bobCookie);
check('DM call join returns the same room', dmCall2.status === 200 && dmCall2.data.room === dmCall1.data.room && dmCall2.data.already === true);
const inv = await call('/api/v1/calls/invite', { room: dmCall1.data.room, userIds: [bobId] }, cookie);
check('invite to a live call accepted', inv.status === 200 && inv.data.invited === 1);
const invBad = await call('/api/v1/calls/invite', { room: '000000', userIds: [bobId] }, cookie);
check('invite to a non-existent call rejected', invBad.status === 404);
const ginfo = await get('/api/v1/groups/info?group=' + gid, cookie);
check('group info returns name + members + isCreator', ginfo.status === 200 && ginfo.data.name === 'Renamed Huddle' && ginfo.data.isCreator === true && ginfo.data.members.length === 2);
// 3h. Scheduled meetings (schedule -> announce -> list buckets -> lazy join -> cancel)
const future = Date.now() + 24 * 60 * 60 * 1000;
bobWs.q.length = 0;
const sched = await call('/api/v1/meetings/schedule', { group: gid, title: 'Sprint Review', description: 'Demo + retro', scheduledAt: future, whenText: 'Tomorrow' }, cookie);
check('meeting scheduled, returns 6-digit room code', sched.status === 200 && /^\d{6}$/.test(sched.data.roomCode || ''));
const schP = await call('/api/v1/meetings/schedule', { title: 'Synced', scheduledAt: Date.now() + 3600000, participants: [bobId] }, cookie);
check('meeting scheduled with participants', schP.status === 200 && Array.isArray(schP.data.participants) && schP.data.participants.includes(bobId));
const bobMeetings = await get('/api/v1/meetings', bobCookie);
const bm = bobMeetings.data.find((m) => m.id === schP.data.id);
check('invited participant sees the meeting', !!bm && bm.isHost === false);
const schPush = await nextMsg(bobWs, 'chat-message');
check('schedule announced in the group chat', schPush.message && /Scheduled a call/.test(schPush.message.body));
const pastM = await call('/api/v1/meetings/schedule', { group: gid, title: 'Old Standup', scheduledAt: Date.now() - 2 * 60 * 60 * 1000 }, cookie);
check('past meeting scheduling is rejected', pastM.status === 400); // can't schedule in the past (#1)
const mlist = await get('/api/v1/meetings', cookie);
const schUp = mlist.data.find((m) => m.id === sched.data.id);
check('meetings list buckets upcoming', mlist.status === 200 && schUp && schUp.status === 'upcoming');
const sm = wsClient(); await new Promise((r) => sm.on('open', r));
sm.send(JSON.stringify({ type: 'meeting-join', room: sched.data.roomCode, name: 'Admin' }));
const smJoined = await nextMsg(sm, 'meeting-joined');
check('scheduled meeting joinable by code (room created lazily)', !!smJoined.peerId && smJoined.room === sched.data.roomCode);
const mlist2 = await get('/api/v1/meetings', cookie);
const upRun = mlist2.data.find((m) => m.id === sched.data.id);
check('scheduled meeting shows running while a peer is connected', !!upRun && upRun.status === 'running' && upRun.inCall >= 1);
sm.close();
const cancelBob = await call('/api/v1/meetings/cancel', { id: sched.data.id }, bobCookie);
check('non-organizer cannot cancel a meeting', cancelBob.status === 403);
const cancelOk = await call('/api/v1/meetings/cancel', { id: sched.data.id }, cookie);
check('organizer cancels the meeting', cancelOk.status === 200);
// 3i. Group image (upload -> set as group avatar -> visible to members)
const imgUp = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-Filename': encodeURIComponent('logo.png') }, body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) });
const imgUpd = await imgUp.json();
check('group image uploaded', imgUp.status === 200 && !!imgUpd.id);
const badAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: upd.id }, cookie); // upd.id is text/plain
check('non-image rejected as group photo', badAv.status === 400);
const setAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: imgUpd.id }, cookie);
check('group photo set', setAv.status === 200 && setAv.data.avatar === '/files/' + imgUpd.id);
const ginfoAv = await get('/api/v1/groups/info?group=' + gid, cookie);
check('group info exposes the photo', ginfoAv.status === 200 && ginfoAv.data.avatar === '/files/' + imgUpd.id);
const memberFetch = await fetch(BASE + '/files/' + imgUpd.id, { headers: { Cookie: bobCookie } });
check('group member can fetch the group photo', memberFetch.status === 200);
const bobLeave = await call('/api/v1/groups/remove', { group: gid }, bobCookie); // bob leaves
check('member can leave the group', bobLeave.status === 200 && bobLeave.data.left === true);
const gthrLeft = await get('/api/v1/messages/thread?group=' + gid, cookie);
check('leaving posts an activity message', gthrLeft.data.some((x) => x.system && /left/.test(x.body)));
const ginfo2 = await get('/api/v1/groups/info?group=' + gid, cookie);
check('member count drops after leave', ginfo2.status === 200 && ginfo2.data.members.length === 1);
bobWs.close();
// 3f. Meetings (mesh) signaling: create room, two peers join, relay signal, leave
const alice = wsClient(); await new Promise((r) => alice.on('open', r));
alice.send(JSON.stringify({ type: 'meeting-create' }));
const created = await nextMsg(alice, 'meeting-created');
check('meeting-create returns a 6-digit room code', /^\d{6}$/.test(created.room || ''));
alice.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Alice' }));
const aJoined = await nextMsg(alice, 'meeting-joined');
check('first peer joins (room empty)', !!aJoined.peerId && aJoined.peers.length === 0);
const carol = wsClient(); await new Promise((r) => carol.on('open', r));
carol.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Carol' }));
const cJoined = await nextMsg(carol, 'meeting-joined');
check('second peer sees the first in the room', cJoined.peers.some((p) => p.peerId === aJoined.peerId));
const aPeerJoined = await nextMsg(alice, 'meeting-peer-joined');
check('existing peer notified of newcomer', aPeerJoined.peerId === cJoined.peerId);
alice.send(JSON.stringify({ type: 'meeting-signal', to: cJoined.peerId, data: { fake: 'offer' } }));
const relayed = await nextMsg(carol, 'meeting-signal');
check('meeting signal relayed peer->peer', relayed.from === aJoined.peerId && relayed.data.fake === 'offer');
carol.close();
const aPeerLeft = await nextMsg(alice, 'meeting-peer-left');
check('peer-left delivered on disconnect', aPeerLeft.peerId === cJoined.peerId);
alice.close();
// 4. Wrong password rejected
const badLogin = await call('/api/login', { email, password: 'wrong' });
check('wrong password rejected', badLogin.status === 401);
@@ -130,6 +400,15 @@ function nextMsg(ws, type, timeout = 3000) {
await nextMsg(agent, 'session-ended');
check('session-ended delivered to agent', true);
// 14b. Outbound webhook delivery (session.started + session.ended) with valid signatures
await wait(900);
const parse = (h) => { try { return JSON.parse(h.body); } catch { return {}; } };
const hookStarted = webhookHits.find((h) => parse(h).event === 'session.started');
const hookEnded = webhookHits.find((h) => parse(h).event === 'session.ended');
check('webhook received session.started', !!hookStarted);
check('webhook received session.ended', !!hookEnded);
check('webhook signature is valid (HMAC-SHA256)', !!hookEnded && hookEnded.sig === crypto.createHmac('sha256', hookSecret).update(hookEnded.body).digest('base64url'));
// 15. Audit log captured the full flow
const audit = await get('/api/audit', cookie);
const actions = audit.data.map((a) => a.action);
@@ -146,6 +425,7 @@ function nextMsg(ws, type, timeout = 3000) {
check('consent denial -> viewer session-denied', !!denied);
agent.close(); viewer.close();
hookSrv.close();
console.log(`\n${passed} passed, ${failed} failed.`);
server.close();
process.exit(failed ? 1 : 0);
+54
Visa fil
@@ -0,0 +1,54 @@
// Outbound webhook delivery. emit(event, tenantId, payload) fans the event out to every
// active per-tenant subscription registered for that event, plus the legacy global
// BIZGAZE_WEBHOOK_URL (back-compat). Each delivery is HMAC-signed and retried on failure.
//
// NOTE (roadmap): retries are in-memory/best-effort. For guaranteed delivery this should
// move to a persistent queue when the app scales to multiple instances (see ARCHITECTURE.md).
const R = require('./repos');
const crypto = require('crypto');
const EVENTS = ['session.started', 'session.ended'];
function sign(secret, body) {
return crypto.createHmac('sha256', secret || '').update(body).digest('base64url');
}
const RETRY_DELAYS = [2000, 10000, 30000]; // after the first attempt
function deliver(url, secret, body, onDone) {
let attempt = 0;
const go = async () => {
attempt++;
let ok = false, status = 0, err = null;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sign(secret, body), 'X-BizGaze-Event': (() => { try { return JSON.parse(body).event; } catch { return ''; } })() },
body,
signal: AbortSignal.timeout(10000),
});
status = res.status; ok = res.ok;
} catch (e) { err = (e && e.message) || 'delivery failed'; }
if (ok || attempt > RETRY_DELAYS.length) { if (onDone) onDone({ ok, status, err }); return; }
setTimeout(go, RETRY_DELAYS[attempt - 1]);
};
go();
}
function emit(event, tenantId, payload) {
const body = JSON.stringify({ event, ...payload });
// Per-tenant subscriptions
try {
for (const h of R.webhooks.activeForTenant(tenantId)) {
const subs = String(h.events || '').split(',').map((s) => s.trim());
if (subs.includes('*') || subs.includes(event)) {
deliver(h.url, h.secret, body, (r) => { try { R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} });
}
}
} catch (_) {}
// Legacy global webhook (back-compat): session.ended → BIZGAZE_WEBHOOK_URL, signed with SSO_SECRET.
if (event === 'session.ended' && process.env.BIZGAZE_WEBHOOK_URL) {
deliver(process.env.BIZGAZE_WEBHOOK_URL, process.env.SSO_SECRET || '', body);
}
}
module.exports = { emit, sign, EVENTS };