Compare commits
78 Commits
166bea4314
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ece8e1061 | |||
| 03de639e1a | |||
| 133208ae3f | |||
| 703cf1fcf0 | |||
| 9d5af66a0e | |||
| ab6d8161ee | |||
| c013536e2b | |||
| ba75dc8daa | |||
| a4726dd10c | |||
| b03a32b10d | |||
| e39828ed56 | |||
| 35ce641046 | |||
| 1efb2e4314 | |||
| 677d418ed0 | |||
| 07c728bf6a | |||
| 948eae249f | |||
| 88a6b6a21e | |||
| 620039a2ff | |||
| 224a2800c5 | |||
| ba51a3c5d7 | |||
| 1a089c0349 | |||
| eb685b42ca | |||
| 467a8b9c6e | |||
| 14eb99edaf | |||
| 2948e0cfcc | |||
| a9d95ed8c6 | |||
| 18edc5dbfb | |||
| 928119725e | |||
| 10b2251efe | |||
| e4d361f298 | |||
| 7e3a94b04c | |||
| 343724df0f | |||
| 3f41ca857a | |||
| 87a76c09f3 | |||
| e46ac1e7cc | |||
| 68ff3a878b | |||
| c322448774 | |||
| 354e65acdf | |||
| 785eeb7fbc | |||
| 25b0b39c49 | |||
| 6ee424db16 | |||
| c3c4178227 | |||
| a677675b8f | |||
| eaea1ccc3f | |||
| 971a6fdf22 | |||
| ad48829337 | |||
| f3b6e67c19 | |||
| d0863351d2 | |||
| 9017d2ff25 | |||
| c1b67d38d3 | |||
| 276c5e0929 | |||
| ecc8be3dba | |||
| e359271157 | |||
| 9e80aee1c6 | |||
| a0f9ab10f2 | |||
| 100a092ff9 | |||
| 0cee72e73c | |||
| c24d5d828a | |||
| d9ea104b73 | |||
| c79d78485b | |||
| bdd0058c5c | |||
| f971908d80 | |||
| f2efff394b | |||
| 5a140e45a1 | |||
| b35c95a5be | |||
| a561852067 | |||
| 27c582bceb | |||
| aed3d22675 | |||
| e6d3d2d66a | |||
| a5e2ed8a9d | |||
| 34bdd8ea16 | |||
| 4415225407 | |||
| 149e32b8d8 | |||
| f27b7af9e4 | |||
| 2c1e1c7ca5 | |||
| b2c2acbbc7 | |||
| b68ba94d2a | |||
| 7f54182186 |
@@ -50,3 +50,7 @@ Thumbs.db
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Firebase service-account keys (SECRET private key — NEVER commit)
|
||||
**/*firebase-adminsdk*.json
|
||||
**/*service-account*.json
|
||||
|
||||
@@ -12,8 +12,11 @@ Roadmap: grow into a communication platform (meetings + persistent chat) for
|
||||
registered BizGaze users.
|
||||
|
||||
## Tech stack (intentionally minimal — keep it this way)
|
||||
- **Node.js >= 22.5**, single npm dependency: `ws` (WebSocket).
|
||||
- **Built-in `node:sqlite`** (no native modules). DB file: `server/data.db`.
|
||||
- **Node.js >= 22.5**. npm deps: `ws`, `pg`, `redis`, `web-push` (+ optional `nodemailer`).
|
||||
- **PostgreSQL** via the async adapter (`server/dbx.js` → `server/db/pg.js`). SQLite was RETIRED 2026-08-12:
|
||||
there is now ONE schema source of truth, **`server/db/schema.pg.sql`** — every schema change goes there
|
||||
(and post-cutover COLUMNS need an explicit `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, since the file is
|
||||
applied idempotently on every boot and `CREATE TABLE IF NOT EXISTS` won't alter an existing table).
|
||||
- **WebRTC** peer-to-peer for media (screen video + voice + data channels).
|
||||
- **No build step, no framework.** Each page is a single self-contained HTML file
|
||||
with inline `<style>` and `<script>`. Do not introduce React/bundlers.
|
||||
@@ -30,9 +33,10 @@ server/
|
||||
routes.js # HTTP JSON API (/api/*, /sso) -> { "METHOD /path": handler } map
|
||||
static.js # static file serving + authenticated recording/transcript downloads
|
||||
signaling.js # WebSocket signaling (consent + SDP/ICE relay)
|
||||
repos.js # data-access layer — ALL SQL lives here (tenant-scoped)
|
||||
repos.js # data-access layer — ALL SQL lives here (tenant-scoped, async)
|
||||
bizgaze.js # BizGaze identity provider (validate login, env-gated)
|
||||
db.js # node:sqlite schema + idempotent migrations
|
||||
dbx.js # async DB adapter facade -> db/pg.js (Postgres; only backend)
|
||||
db/pg.js # Postgres backend; db/schema.pg.sql = the single schema source of truth
|
||||
auth.js # scrypt hashing, token/id generation, TOTP helpers
|
||||
package.json # { "dependencies": { "ws": "^8.18" }, engines node>=22.5 }
|
||||
test/e2e.js # 21-check backend e2e (register->login->session->signaling->audit)
|
||||
@@ -48,11 +52,16 @@ server/
|
||||
transcripts/ # saved transcripts (.txt) [created at runtime]
|
||||
```
|
||||
Architecture/roadmap detail lives in `ARCHITECTURE.md`. Backend SQL must go through
|
||||
`repos.js` (never inline in routes/signaling). Run `node test/e2e.js` after backend edits.
|
||||
`repos.js` (never inline in routes/signaling). ANY schema change goes in `db/schema.pg.sql` (see stack note).
|
||||
After backend edits, run the tests against a DISPOSABLE Postgres (they no longer bundle SQLite):
|
||||
`DATABASE_URL=postgres://…/bizgaze_test node test/db-smoke.js`.
|
||||
|
||||
## Run locally
|
||||
```
|
||||
cd server && npm install && node server.js
|
||||
# Start a local Postgres (or use the compose one), then:
|
||||
docker compose up -d bizgazepg
|
||||
cd server && npm install
|
||||
DB_BACKEND=pg DATABASE_URL=postgres://bizgaze:bizgaze_local@localhost:5432/bizgaze node server.js
|
||||
# HTTP on :8090 (HTTPS on :8443 only if cert.pem + key.pem exist in server/)
|
||||
# Env: ALLOW_REGISTRATION=1 opens the first-team registration
|
||||
```
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# BizGaze Support — server image
|
||||
# Node 24 ships node:sqlite as a stable built-in (no flag), which db.js relies on.
|
||||
# Data store is PostgreSQL (DB_BACKEND=pg; the `pg` client is a prod dependency installed below). SQLite was
|
||||
# retired 2026-08-12 — one schema source of truth (db/schema.pg.sql), no dual-maintenance drift.
|
||||
FROM node:24-alpine
|
||||
|
||||
# ffmpeg: server-side video poster thumbnails (see static.js /thumbs/<id>). Small on Alpine.
|
||||
@@ -15,9 +16,9 @@ RUN npm install --omit=dev --no-audit --no-fund
|
||||
# App source
|
||||
COPY server/ ./
|
||||
|
||||
# Served HTTP port (NPM terminates TLS and proxies here). DB lives on a volume.
|
||||
# Served HTTP port (NPM terminates TLS and proxies here). The DB is Postgres (DATABASE_URL, set via .env);
|
||||
# the /data volume holds uploads / recordings / transcripts / downloads (see docker-compose.yml).
|
||||
ENV PORT=8090
|
||||
ENV DB_PATH=/data/data.db
|
||||
EXPOSE 8090
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -27,7 +27,7 @@ workflows:
|
||||
- ios_signing # ← Codemagic variable group holding CERTIFICATE_PRIVATE_KEY (secure). See below.
|
||||
vars:
|
||||
BUNDLE_ID: "com.bizgaze.connect"
|
||||
XCODE_WORKSPACE: "mobile/ios/App/App.xcworkspace"
|
||||
XCODE_PROJECT: "mobile/ios/App/App.xcodeproj"
|
||||
XCODE_SCHEME: "App"
|
||||
node: 22
|
||||
xcode: latest
|
||||
@@ -44,15 +44,33 @@ workflows:
|
||||
script: |
|
||||
cd mobile
|
||||
# `cap add ios` scaffolds ios/App; safe to re-run — it no-ops if it already exists.
|
||||
# Capacitor 8 DEFAULTS to Swift Package Manager, but our whole iOS pipeline (LiveKit Podfile pins,
|
||||
# share-extension injection, AppDelegate patches) is CocoaPods-based — so force CocoaPods.
|
||||
if [ ! -d "ios" ]; then npx cap add ios --packagemanager Cocoapods; fi
|
||||
# Capacitor 8 Swift Package Manager: generate an SPM project (no Podfile). LiveKit is pulled via the
|
||||
# native-call plugin's Package.swift; `cap sync` wires all local plugins into the CapApp-SPM package.
|
||||
if [ ! -d "ios" ]; then npx cap add ios --packagemanager SPM; fi
|
||||
npx cap sync ios
|
||||
# Fail LOUDLY if no Podfile was generated (flag ignored / SPM project) rather than fail later with a
|
||||
# confusing error — the CocoaPods pipeline downstream needs ios/App/Podfile to exist.
|
||||
test -f ios/App/Podfile || { echo "ERROR: no ios/App/Podfile — 'cap add' did not use CocoaPods"; exit 1; }
|
||||
# Sanity: the Xcode project must exist. Print the iOS dir so the log shows the SPM layout (CapApp-SPM
|
||||
# present, NO Podfile) — if a Podfile appears, the SPM flag didn't take and we'd need to fix it.
|
||||
test -d ios/App/App.xcodeproj || { echo "ERROR: iOS Xcode project not generated"; ls -la ios/App || true; exit 1; }
|
||||
echo "iOS project layout:"; ls -la ios/App
|
||||
# ── Diagnose local-plugin SPM wiring (the regression: our file: plugins didn't function at runtime) ──
|
||||
echo "=== local plugins in node_modules — symlink vs copy, and is Package.swift present? ==="
|
||||
for p in native-call media-library audio-route share-inbox file-opener; do
|
||||
echo "-- $p --"; ls -ld "node_modules/$p" 2>/dev/null || echo " (dir missing)"
|
||||
( ls "node_modules/$p/Package.swift" >/dev/null 2>&1 && echo " Package.swift PRESENT" ) || echo " Package.swift MISSING"
|
||||
done
|
||||
echo "=== CapApp-SPM Package.swift — are our local plugins + LiveKit wired in? ==="
|
||||
CAPSPM=$(find ios -path "*CapApp-SPM*Package.swift" 2>/dev/null | head -1)
|
||||
if [ -n "$CAPSPM" ]; then echo "found: $CAPSPM"; cat "$CAPSPM"; else echo " CapApp-SPM/Package.swift NOT FOUND"; find ios -name Package.swift 2>/dev/null; fi
|
||||
# App icon + splash from resources/icon.png & resources/splash*.png (1024x1024 icon, 2732² splash).
|
||||
npx capacitor-assets generate --ios || echo "asset generation skipped"
|
||||
npx capacitor-assets generate --ios || echo "capacitor-assets returned non-zero (see above)"
|
||||
# HARD-VERIFY the splash + icon actually landed in the iOS project. Previously this step swallowed
|
||||
# failures with `|| echo skipped`, so a broken generation shipped Capacitor's BLANK default splash
|
||||
# (looked unbranded on launch). Fail the build loudly instead of shipping an empty splash.
|
||||
SPLASH_PNG=$(find ios -path "*Assets.xcassets/Splash.imageset*" -name "*.png" 2>/dev/null | head -1)
|
||||
ICON_PNG=$(find ios -path "*Assets.xcassets/AppIcon.appiconset*" -name "*.png" 2>/dev/null | head -1)
|
||||
if [ -z "$SPLASH_PNG" ]; then echo "ERROR: iOS Splash.imageset not generated — the app would launch with a blank splash"; exit 1; fi
|
||||
echo "iOS splash asset OK -> $SPLASH_PNG"
|
||||
echo "iOS app icon asset -> ${ICON_PNG:-MISSING}"
|
||||
|
||||
- name: Patch Info.plist (App-Review privacy strings) + bundle id
|
||||
script: |
|
||||
@@ -66,6 +84,15 @@ workflows:
|
||||
# generates already contains the new target.
|
||||
ruby mobile/scripts/add-share-extension.rb
|
||||
|
||||
- name: Add the Broadcast (screen-share) Extension target
|
||||
script: |
|
||||
# Inject the ReplayKit broadcast upload extension (lets the user share their iPhone screen).
|
||||
# It links the LiveKit Swift package (LKSampleHandler). Runs after cap sync so the SPM project
|
||||
# + App.entitlements already exist. A build failure here is most likely the SPM product-link or
|
||||
# the missing App Group capability on the com.bizgaze.connect.broadcast App ID (a one-time manual
|
||||
# step in the Apple Developer portal — see mobile/IOS_SETUP.md).
|
||||
ruby mobile/scripts/add-broadcast-extension.rb
|
||||
|
||||
- name: Set up code signing
|
||||
script: |
|
||||
# Create the distribution certificate + provisioning profile from the ASC API key and add the
|
||||
@@ -93,15 +120,17 @@ workflows:
|
||||
--type IOS_APP_STORE \
|
||||
--certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \
|
||||
--create
|
||||
# THIRD bundle id: the broadcast (screen-share) extension. Same story as .share — its App Group
|
||||
# capability (group.com.bizgaze.connect) must be enabled MANUALLY on the App ID in the Apple
|
||||
# Developer portal (fetch-signing-files registers the id + profile but does NOT toggle App Group).
|
||||
app-store-connect fetch-signing-files "${BUNDLE_ID}.broadcast" \
|
||||
--type IOS_APP_STORE \
|
||||
--certificate-key="@env:CERTIFICATE_PRIVATE_KEY" \
|
||||
--create
|
||||
keychain add-certificates
|
||||
|
||||
- name: Install CocoaPods
|
||||
script: |
|
||||
cd mobile/ios/App
|
||||
# --repo-update refreshes the cached spec repo so it knows LiveKit 2.15.3's transitive deps
|
||||
# (LiveKitUniFFI 0.0.6 + LiveKitWebRTC 144.7559.11) — they ARE on the trunk but the build box's
|
||||
# cached repo was stale ("Unable to find a specification for LiveKitUniFFI (= 0.0.6)").
|
||||
pod install --repo-update
|
||||
# (No "Install CocoaPods" step under SPM — there is no Podfile. Xcode resolves the Swift packages
|
||||
# (Capacitor, plugins, LiveKit + its WebRTC/UniFFI/SwiftProtobuf) during the archive below.)
|
||||
|
||||
- name: Build the signed IPA
|
||||
script: |
|
||||
@@ -112,7 +141,7 @@ workflows:
|
||||
# `xcode-project build-ipa` prints a PRETTIFIED summary and swallows the raw xcodebuild "error:"
|
||||
# lines — a failed archive shows only "Failed to archive" with no reason. On failure, surface the
|
||||
# actual errors from the raw log so we don't have to dig through the artifact.
|
||||
if ! xcode-project build-ipa --workspace "$XCODE_WORKSPACE" --scheme "$XCODE_SCHEME"; then
|
||||
if ! xcode-project build-ipa --project "$XCODE_PROJECT" --scheme "$XCODE_SCHEME"; then
|
||||
echo "======================= xcodebuild errors ======================="
|
||||
grep -h -E "error:|errSec|Provisioning profile|entitlement|Code ?Sign|does not (support|contain)|requires a provisioning|No profile|No signing|doesn't (include|match)|Command .* failed" /tmp/xcodebuild_logs/*.log 2>/dev/null | grep -vi "warning:" | tail -60 || echo "(no matching lines — open the xcodebuild_logs artifact)"
|
||||
echo "================================================================="
|
||||
@@ -136,3 +165,102 @@ workflows:
|
||||
# submit_to_app_store: false
|
||||
# No email recipients here on purpose — build status is watched on the Codemagic dashboard. Add
|
||||
# per-user notifications in the Codemagic UI (or a `publishing.email` block) later if you want them.
|
||||
|
||||
# ── Android test build ──────────────────────────────────────────────────────────────────────────
|
||||
# Builds an INSTALLABLE debug APK of the same Capacitor shell (loads https://remote.bizgaze.com). This is
|
||||
# the Android equivalent of the iOS TestFlight loop, but simpler — no Google Play account or signing is
|
||||
# needed to test: download the APK artifact from the Codemagic build page (or wire an email/Slack in the
|
||||
# UI), sideload it on a phone (enable "Install unknown apps"), and run.
|
||||
#
|
||||
# Runs on the SAME macOS instance as the iOS workflow (mac_mini_m2) — Codemagic's macOS images ship the
|
||||
# Android SDK + JDK too. (A Linux instance would be cheaper/faster but linux_x2 isn't on every billing
|
||||
# plan; mac_mini_m2 is the one this account already uses for iOS.)
|
||||
#
|
||||
# Firebase push (FCM) is OPTIONAL for this test build: app/build.gradle only applies the google-services
|
||||
# plugin when google-services.json is present, so the APK builds fine WITHOUT it (push/call-wake just
|
||||
# won't fire). To enable push, base64 the google-services.json and store it as a secure Codemagic env var
|
||||
# GOOGLE_SERVICES_JSON (group `android_config`); the step below decodes it into place.
|
||||
android-apk:
|
||||
name: Biz Connect Android → test APK
|
||||
max_build_duration: 45
|
||||
instance_type: mac_mini_m2
|
||||
environment:
|
||||
# To enable FCM push later, create a Codemagic variable group holding GOOGLE_SERVICES_JSON (base64 of
|
||||
# google-services.json, marked secure) and uncomment the two lines below. Left out for now so the first
|
||||
# test build needs ZERO Codemagic setup.
|
||||
# groups:
|
||||
# - android_config
|
||||
vars:
|
||||
PACKAGE_NAME: "com.bizgaze.connect"
|
||||
node: 22
|
||||
java: 21 # Capacitor 8's Android build (AGP 8.7 / Gradle 8.11) requires JDK 21 — JDK 17 fails the build.
|
||||
scripts:
|
||||
- name: Install JS dependencies
|
||||
script: |
|
||||
cd mobile
|
||||
npm install
|
||||
|
||||
- name: Generate the Android project (Capacitor)
|
||||
script: |
|
||||
cd mobile
|
||||
# `cap add android` scaffolds android/; safe to re-run — it no-ops if it already exists (our committed
|
||||
# project has custom manifest permissions, which cap sync preserves).
|
||||
if [ ! -d "android" ]; then npx cap add android; fi
|
||||
npx cap sync android
|
||||
# App icon + splash from resources/icon.png & resources/splash*.png.
|
||||
npx capacitor-assets generate --android || echo "capacitor-assets returned non-zero (see above)"
|
||||
# The generated project points sdk.dir at wherever it was made; overwrite it with the CI SDK path so
|
||||
# Gradle finds the SDK. Codemagic exports ANDROID_SDK_ROOT (fall back to ANDROID_HOME on macOS images).
|
||||
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
|
||||
echo "sdk.dir=$SDK" > android/local.properties
|
||||
echo "Android SDK -> $SDK"
|
||||
# android/ is gitignored and regenerated fresh in CI, so its manifest only has INTERNET. Inject the
|
||||
# camera/mic/notification permissions the web UI needs (mirrors ios-patch.sh for iOS). Tolerant + idempotent.
|
||||
node scripts/android-patch.js android/app/src/main/AndroidManifest.xml
|
||||
|
||||
- name: Firebase google-services.json for FCM push
|
||||
script: |
|
||||
# Place the client Firebase config into android/app/ so the google-services Gradle plugin applies and
|
||||
# FirebaseApp initializes at runtime — WITHOUT it, PushNotifications.register() throws
|
||||
# "Default FirebaseApp is not initialized" and crashes the app on Android. Prefer the committed file;
|
||||
# fall back to a base64 GOOGLE_SERVICES_JSON env var. (Runs from the repo root; the Android project was
|
||||
# generated in the previous step.)
|
||||
DEST=mobile/android/app/google-services.json
|
||||
if [ -f mobile/FirebaseAccount_Google/google-services.json ]; then
|
||||
cp mobile/FirebaseAccount_Google/google-services.json "$DEST"
|
||||
echo "google-services.json copied from repo → FCM enabled"
|
||||
elif [ -n "$GOOGLE_SERVICES_JSON" ]; then
|
||||
echo "$GOOGLE_SERVICES_JSON" | { base64 --decode 2>/dev/null || base64 -D; } > "$DEST"
|
||||
echo "google-services.json written from env → FCM enabled"
|
||||
else
|
||||
echo "No google-services.json found — building WITHOUT FCM push"
|
||||
fi
|
||||
ls -l "$DEST" 2>/dev/null || echo "(no google-services.json placed)"
|
||||
|
||||
- name: Build the debug APK
|
||||
script: |
|
||||
set -e
|
||||
cd mobile/android
|
||||
chmod +x ./gradlew
|
||||
# Debug build type is auto-signed with the Android debug keystore → directly installable, no Play
|
||||
# account or upload key needed. (A signed release AAB for the Play Store is a later, separate step.)
|
||||
# Capture the output so that, on failure, we surface Gradle's actual "What went wrong" block instead
|
||||
# of a wall of internal stack frames — and FAIL the step (a trailing `find` used to exit 0 and mask it).
|
||||
set +e
|
||||
./gradlew assembleDebug --stacktrace 2>&1 | tee /tmp/gradle.log
|
||||
STATUS=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$STATUS" != "0" ]; then
|
||||
echo "======================= GRADLE FAILURE ======================="
|
||||
grep -n -A 25 "What went wrong" /tmp/gradle.log || true
|
||||
grep -n -A 3 "FAILURE:" /tmp/gradle.log || true
|
||||
echo "=============================================================="
|
||||
exit 1
|
||||
fi
|
||||
echo "APK(s):"; find app/build/outputs -name "*.apk"
|
||||
test -n "$(find app/build/outputs -name '*.apk' -print -quit)" || { echo "ERROR: no APK produced"; exit 1; }
|
||||
artifacts:
|
||||
- mobile/android/app/build/outputs/**/*.apk
|
||||
# Download the APK from the build page. To get it emailed like TestFlight, add a `publishing.email` block
|
||||
# here (or notifications in the Codemagic UI). A signed release AAB → Google Play internal testing is a
|
||||
# separate workflow we can add once the shell is verified on a device.
|
||||
|
||||
@@ -10,7 +10,6 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=8090
|
||||
- DB_PATH=/data/data.db
|
||||
# Desktop installers + auto-update feed live on the persistent volume so uploaded
|
||||
# builds survive image rebuilds (a plain image path would be wiped on every deploy).
|
||||
- DOWNLOADS_DIR=/data/downloads
|
||||
@@ -31,16 +30,16 @@ services:
|
||||
- path: .env
|
||||
required: false
|
||||
volumes:
|
||||
- bizgaze_support_data:/data # persists data.db across rebuilds
|
||||
- bizgaze_support_data:/data # persists uploads / recordings / transcripts / downloads across rebuilds
|
||||
networks:
|
||||
- npm
|
||||
# Wait for Postgres to be healthy before starting. Only matters when DB_BACKEND=pg (else the app uses
|
||||
# the local SQLite file and ignores pg), but it's harmless on SQLite — pg comes up in a second or two.
|
||||
# The DB is Postgres (SQLite retired 2026-08-12), so wait for it to be healthy before the app starts —
|
||||
# otherwise the first queries race the DB coming up.
|
||||
depends_on:
|
||||
bizgazepg:
|
||||
condition: service_healthy
|
||||
|
||||
# PostgreSQL — the app's data store when DB_BACKEND=pg (default is the local SQLite file). Distinct
|
||||
# PostgreSQL — the app's data store (DB_BACKEND=pg; the only backend since SQLite was retired). Distinct
|
||||
# service/container name so it never collides with the other postgres containers on the shared NPM
|
||||
# network; the app reaches it as `bizgaze-postgres`. Data on its own named volume.
|
||||
bizgazepg:
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Biz Connect — App Store submission pack
|
||||
|
||||
Everything App Store Connect asks for, drafted. Fill the **`<< … >>`** placeholders (they're
|
||||
account-specific or secret and must NOT be committed). Order below ≈ the order App Store Connect walks you through.
|
||||
|
||||
---
|
||||
|
||||
## 0. Before you submit (gates that cause rejection)
|
||||
|
||||
- [x] **Verified build** uploaded from Codemagic to App Store Connect (splash build; multi-device tiles, transcripts, calls ring; moderation + meeting-push are live web-side).
|
||||
- [x] **Reviewer demo account** — connect@bizgaze.com / Qwerty@789 (works; non-admin so it sees Report/Block but not the admin Reports view).
|
||||
- [x] **Privacy Policy URL** — https://remote.bizgaze.com/privacy (live, public).
|
||||
- [x] **Support URL** — https://remote.bizgaze.com/support (live, public).
|
||||
- [x] **Screenshots** — 6.9" set (1320×2868) in mobile/appstore-screenshots/ (01-chats, 02-conversation, 03-meetings).
|
||||
- [x] **Export compliance** — ITSAppUsesNonExemptEncryption=false baked into the build (App Store Connect won't ask).
|
||||
|
||||
---
|
||||
|
||||
## 1. App information
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **App name** | BizGaze Connect (App Store listing name; renamed 2026-08-21) |
|
||||
| **Subtitle** (30 char max) | Team chat, calls & meetings |
|
||||
| **Primary category** | Business |
|
||||
| **Secondary category** | Productivity |
|
||||
| **Bundle ID** | com.bizgaze.connect |
|
||||
| **Privacy Policy URL** | https://remote.bizgaze.com/privacy |
|
||||
| **Support URL** | https://remote.bizgaze.com/support |
|
||||
| **Marketing URL** (optional) | leave blank, or your product page |
|
||||
| **Age rating** | 4+ (answer all content questions "None". Note: user-generated content via chat — see §8) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Description
|
||||
|
||||
> Biz Connect keeps your team connected — chat, voice and video calls, and meetings, in one place.
|
||||
>
|
||||
> **Chat that works the way your team does**
|
||||
> • Direct messages and group conversations
|
||||
> • Reactions, replies, mentions, pinned messages, and polls
|
||||
> • Share photos, videos, and files
|
||||
> • Read receipts and typing indicators
|
||||
>
|
||||
> **Calls that ring like a real phone**
|
||||
> • One-to-one and group voice & video calls
|
||||
> • Full-screen incoming call ringing, even when the app is closed
|
||||
> • Calls keep working when you switch apps or lock your phone
|
||||
>
|
||||
> **Meetings, built in**
|
||||
> • Start instantly or schedule ahead
|
||||
> • Screen sharing and camera, front or back
|
||||
> • Live transcripts you can save and download
|
||||
> • Meeting recordings for later
|
||||
>
|
||||
> **Everywhere you are**
|
||||
> Your conversations stay in sync across iPhone, desktop, and the web.
|
||||
>
|
||||
> Biz Connect is for organizations using the BizGaze platform. Sign in with your BizGaze account to get started.
|
||||
|
||||
**Keywords** (100 char max, comma-separated, no spaces after commas):
|
||||
`team chat,business messaging,video call,voice call,meetings,screen share,transcript,collaboration,work`
|
||||
|
||||
**Promotional text** (170 char, editable without a new build):
|
||||
> Chat, call, and meet with your team — with real-phone-style ringing, screen sharing, and live meeting transcripts.
|
||||
|
||||
---
|
||||
|
||||
## 3. What's New (release notes for this version)
|
||||
|
||||
> • Live meeting transcripts on iPhone — for both calls and scheduled meetings
|
||||
> • Join the same meeting from two devices at once, each as its own participant
|
||||
> • Stability and audio-routing improvements
|
||||
|
||||
---
|
||||
|
||||
## 4. App Privacy ("nutrition label")
|
||||
|
||||
Answer these in App Store Connect → App Privacy. **Verify each against what the BizGaze backend actually stores**
|
||||
before publishing — this is a legal declaration. Sensible defaults for a business comms app:
|
||||
|
||||
**Data used to identify the user (Linked to identity):**
|
||||
- **Contact Info → Name, Email address** — App Functionality, Account management. (BizGaze login.)
|
||||
- **User Content → Photos or Videos, Other User Content (messages, files)** — App Functionality. (Chat/meeting content stored on your server.)
|
||||
- **Identifiers → User ID** — App Functionality.
|
||||
|
||||
**Diagnostics / Usage:** declare only if you actually collect analytics/crash data. If not, mark **"Data Not Collected"** for those.
|
||||
|
||||
**Important clarifications to make in the notes:**
|
||||
- **Microphone & Camera** audio/video for calls is transmitted between participants (via your LiveKit server) but is only *recorded/stored* when a user explicitly starts a recording or transcript. Say so.
|
||||
- **Speech recognition** for transcripts runs **on-device** (Apple's `SFSpeechRecognizer`, on-device mode) — the audio is not sent to Apple, and only the finished text is added to the meeting transcript. This is a good thing to state explicitly; it reassures review.
|
||||
- **Third-party:** if BizGaze/LiveKit are your own infrastructure, no third-party SDK data-sharing to declare. Confirm you have no analytics/ad SDKs.
|
||||
|
||||
**Privacy usage strings** (already in the build via `ios-patch.sh` — for reference):
|
||||
- Camera: "Biz Connect uses the camera for video calls and to share photos and your screen."
|
||||
- Microphone: "Biz Connect uses the microphone for voice and video calls."
|
||||
- Speech Recognition: "Biz Connect uses speech recognition to create live meeting transcripts from your microphone."
|
||||
- Photo Library / Add: send/save images.
|
||||
|
||||
---
|
||||
|
||||
## 5. Screenshots
|
||||
|
||||
Required (App Store Connect accepts one size and scales, but do at least these two):
|
||||
- [ ] **6.9" iPhone** (1320 × 2868) — iPhone 16 Pro Max class
|
||||
- [ ] **6.5" iPhone** (1242 × 2688) — fallback for older devices
|
||||
- [ ] (Optional) iPad if you enable iPad support
|
||||
|
||||
Ready-made set in `mobile/appstore-screenshots/` (1320×2868, anonymized). Upload in this order:
|
||||
`01-chats → 02-conversation → 04-group → 03-meetings → 06-screenshare → 05-schedule`
|
||||
|
||||
---
|
||||
|
||||
## 6. App Review notes (paste into "Notes")
|
||||
|
||||
> Biz Connect requires a BizGaze account to sign in.
|
||||
>
|
||||
> Demo account for review:
|
||||
> Email: << demo@yourdomain >>
|
||||
> Password: << demo password >>
|
||||
>
|
||||
> How to test:
|
||||
> 1. Open the app and sign in with the demo account above.
|
||||
> 2. Chat tab: open a conversation to see messaging.
|
||||
> 3. Start a call from a conversation, or the Meetings tab to start/join a meeting.
|
||||
> 4. In a meeting, tap "Live transcript" to see on-device speech-to-text.
|
||||
>
|
||||
> Notes on permissions:
|
||||
> • Microphone/Camera — used for voice and video calls.
|
||||
> • Speech Recognition — used only to generate live meeting transcripts; recognition runs on-device.
|
||||
> • Screen recording (broadcast) — used only when the user chooses to share their screen in a meeting.
|
||||
> • VoIP push (PushKit) + CallKit — used to ring incoming calls like a normal phone call.
|
||||
|
||||
**Create the demo account now** and confirm it can actually log in and start a call. A dead demo login is the #1 rejection cause for account-gated apps.
|
||||
|
||||
---
|
||||
|
||||
## 7. Export compliance
|
||||
|
||||
The app uses only standard encryption (HTTPS/TLS, WebRTC/DTLS-SRTP) — no proprietary/custom crypto.
|
||||
- In App Store Connect: **"Does your app use encryption?" → Yes**, then **"only … standard encryption algorithms" → Yes** → qualifies for the exemption (no CCATS/year-end self-classification report needed for standard encryption).
|
||||
- Optional: set `ITSAppUsesNonExemptEncryption = NO` in Info.plist to skip the question each submission (add to `ios-patch.sh` if you want it permanent — say the word and I'll add it).
|
||||
|
||||
---
|
||||
|
||||
## 8. Likely review questions / risks (and answers)
|
||||
|
||||
- **Account-gated app** → mitigated by the demo account (§6). Also fine per guideline 3.1.1 since it's a business tool, not gating features behind sign-in for a consumer app.
|
||||
- **User-generated content (chat)** → guideline 1.2 satisfied (shipped 2026-08-19): every message has **Report** (long-press / ⋮ → Report, canned reasons) and **Block user**; blocked users can't message or call you (server-enforced). A **Blocked users** manager lives in the profile menu (unblock anytime), and workspace **admins** get a **Reported messages** review screen (delete content / block / resolve). Reports are org-internal (routed to the workspace's own admins). Reviewer note suggestion: "Report and Block are available on any message via long-press; Blocked users are managed from the profile menu."
|
||||
- **CallKit + VoIP push** → legitimate; the demo/reviewer flow should show a real incoming call if possible.
|
||||
- **Background modes** (audio, voip) → justified by calls; the review notes cover it.
|
||||
|
||||
---
|
||||
|
||||
## 9. Nice-to-haves (not blockers)
|
||||
|
||||
- App Store promotional/preview **video** (optional).
|
||||
- Localized metadata if you target non-English regions.
|
||||
- A short **"in-app account deletion"** path — Apple requires apps with account creation to offer account deletion (guideline 5.1.1(v)). If BizGaze accounts are created/managed externally (admin-provisioned, not self-signup in the app), note that in review; if users *can* self-register in the app, an in-app "delete my account" (or a clear link to do so) is required.
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "860472998800",
|
||||
"project_id": "bizgaze-connect",
|
||||
"storage_bucket": "bizgaze-connect.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:860472998800:android:107cc0fc6936d6898dd115",
|
||||
"android_client_info": {
|
||||
"package_name": "com.bizgaze.connect"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyB6gSqIDYr21GKYA4c1e7plUBQMXIcX1Dw"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -125,3 +125,22 @@ to the Files folder, the Photos "Connect" album, Manage storage — works withou
|
||||
Also enabled by this change (main app Info.plist, done automatically by `ios-patch.sh`):
|
||||
- `UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace` → the **Biz Connect** folder in Files.
|
||||
- `CFBundleURLTypes` scheme **`bizconnect`** → lets the extension bounce back into the app after staging.
|
||||
|
||||
## Broadcast Extension (share your iPhone SCREEN in a call) — one-time Apple portal setup
|
||||
|
||||
Screen sharing from iOS uses a **Broadcast Upload Extension** (`com.bizgaze.connect.broadcast`) — the same
|
||||
pattern as the Share Extension. The Codemagic build injects the target, links LiveKit into it, and fetches a
|
||||
profile automatically, but the **App Group capability can only be toggled by hand** in the Apple portal:
|
||||
|
||||
1. Reuse the SAME App Group as the Share Extension: **`group.com.bizgaze.connect`** (no new group needed).
|
||||
2. **Enable the App Groups capability on the broadcast App ID** and assign it to that group:
|
||||
- `com.bizgaze.connect.broadcast` (create this App ID if the first build hasn't yet — `fetch-signing-files
|
||||
--create` registers it, then edit it to add App Groups). The app (`com.bizgaze.connect`) already has the
|
||||
group from the Share Extension setup above.
|
||||
After enabling it, re-run the Codemagic build so `fetch-signing-files` regenerates the profile.
|
||||
|
||||
Until the App Group is on the broadcast App ID, the **archive fails code-signing** (entitlement mismatch) —
|
||||
that's the expected first-build failure. The shared App Group is how the extension (ReplayKit capture) hands
|
||||
screen frames to the app over LiveKit's IPC socket. Receiving OTHERS' shared screens needs none of this — it
|
||||
already works. `ios-patch.sh` sets `RTCScreenSharingExtension` + `RTCAppGroupIdentifier` in the app Info.plist
|
||||
so LiveKit finds the extension + group.
|
||||
|
||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 252 KiB |
@@ -14,7 +14,12 @@
|
||||
"SafeArea": {
|
||||
"detectViewportFitCoverChanges": true,
|
||||
"initialViewportFitCover": true,
|
||||
"offsetForKeyboardInsetBug": false
|
||||
"offsetForKeyboardInsetBug": true,
|
||||
"statusBarStyle": "DARK",
|
||||
"navigationBarStyle": "DARK"
|
||||
},
|
||||
"SystemBars": {
|
||||
"insetsHandling": "disable"
|
||||
},
|
||||
"Keyboard": {
|
||||
"resize": "none"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.bizgaze.connect</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Biz Connect Screen</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.broadcast-services-upload</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SampleHandler</string>
|
||||
<key>RPBroadcastProcessMode</key>
|
||||
<string>RPBroadcastProcessModeSampleBuffer</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,16 @@
|
||||
import ReplayKit
|
||||
import LiveKit
|
||||
|
||||
// Principal class of the Broadcast Upload Extension (ReplayKit) that lets the iOS user share their screen.
|
||||
//
|
||||
// HOW IT WORKS: when the user starts a system broadcast, iOS launches THIS extension. LiveKit's LKSampleHandler
|
||||
// does everything — on broadcastStarted it opens an IPC socket in the shared App Group (group.com.bizgaze.connect)
|
||||
// and streams the ReplayKit sample buffers to the MAIN app, whose LiveKit connection publishes them as a
|
||||
// screen-share track. The extension itself never creates a Room / initialises WebRTC, so it stays well under the
|
||||
// 50 MB extension memory limit. So this subclass can be empty.
|
||||
//
|
||||
// The extension finds the app group + reports state to the app via LiveKit's Darwin-notification/socket
|
||||
// convention, keyed off this extension's bundle id (com.bizgaze.connect.broadcast) and the default app group
|
||||
// group.<appBundleId>. Both are also set explicitly on the app side (RTCScreenSharingExtension /
|
||||
// RTCAppGroupIdentifier in Info.plist) so there's no ambiguity.
|
||||
final class SampleHandler: LKSampleHandler, @unchecked Sendable {}
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"audio-route": "file:plugins/audio-route",
|
||||
"file-opener": "file:plugins/file-opener",
|
||||
"media-library": "file:plugins/media-library",
|
||||
"native-call": "file:plugins/native-call",
|
||||
"share-inbox": "file:plugins/share-inbox",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "AudioRoute",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(name: "AudioRoute", targets: ["AudioRoutePlugin"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "AudioRoutePlugin",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||
],
|
||||
path: "ios/Sources/AudioRoutePlugin")
|
||||
]
|
||||
)
|
||||
@@ -10,7 +10,8 @@
|
||||
"files": [
|
||||
"dist/",
|
||||
"ios/",
|
||||
"AudioRoute.podspec"
|
||||
"AudioRoute.podspec",
|
||||
"Package.swift"
|
||||
],
|
||||
"capacitor": {
|
||||
"ios": {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
require 'json'
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
# NOTE: the pod name MUST be 'FileOpener' (PascalCase of the npm package name 'file-opener').
|
||||
# `cap sync` writes `pod 'FileOpener', :path => '../../plugins/file-opener'` into the generated Podfile,
|
||||
# and CocoaPods then looks for a file literally named FileOpener.podspec whose s.name is 'FileOpener'.
|
||||
# Any other name → "No podspec found for `FileOpener`" and pod install fails. (Same trap that broke the
|
||||
# AudioRoute build — see mobile/plugins/audio-route/AudioRoute.podspec.)
|
||||
s.name = 'FileOpener'
|
||||
s.version = package['version']
|
||||
s.summary = package['description']
|
||||
s.license = package['license']
|
||||
s.homepage = 'https://bizgaze.com'
|
||||
s.author = 'BizGaze'
|
||||
s.source = { :git => 'https://bizgaze.com/file-opener.git', :tag => s.version.to_s }
|
||||
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||
s.ios.deployment_target = '15.0'
|
||||
s.dependency 'Capacitor'
|
||||
s.swift_version = '5.1'
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "FileOpener",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(name: "FileOpener", targets: ["FileOpenerPlugin"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "FileOpenerPlugin",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||
],
|
||||
path: "ios/Sources/FileOpenerPlugin")
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import QuickLook
|
||||
|
||||
// Previews an already-downloaded file with iOS Quick Look — the native "look at this file" surface: swipe,
|
||||
// pinch-zoom, print, and its own share button. Registered by cap sync as window.Capacitor.Plugins.FileOpener.
|
||||
//
|
||||
// WHY A PLUGIN: the app loads its UI from a REMOTE origin (remote.bizgaze.com), so it cannot open a local
|
||||
// file:// URL in the WebView (cross-origin / capacitor local-serving isn't on this origin). @capacitor/share
|
||||
// only offers the share SHEET ("open in another app"), not a preview. Only QLPreviewController, presented
|
||||
// from native code, gives a real in-app preview. Quick Look picks the renderer from the file extension, which
|
||||
// is why the web layer now saves downloads with a correct extension (see bzEnsureExt in home.html).
|
||||
@objc(FileOpenerPlugin)
|
||||
public class FileOpenerPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "FileOpenerPlugin"
|
||||
public let jsName = "FileOpener"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "preview", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
// QLPreviewController holds its dataSource weakly, so we must keep a strong reference alive for the
|
||||
// lifetime of the presented preview — otherwise it deallocates and the preview shows blank.
|
||||
private var dataSource: QLDataSource?
|
||||
|
||||
@objc func preview(_ call: CAPPluginCall) {
|
||||
guard let raw = call.getString("path"), !raw.isEmpty else {
|
||||
call.reject("path is required"); return
|
||||
}
|
||||
let url = FileOpenerPlugin.fileURL(from: raw)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
call.reject("file not found: \(url.path)"); return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
let ds = QLDataSource(url: url)
|
||||
self.dataSource = ds
|
||||
let controller = QLPreviewController()
|
||||
controller.dataSource = ds
|
||||
controller.modalPresentationStyle = .fullScreen
|
||||
guard let base = self.bridge?.viewController else {
|
||||
call.reject("no view controller to present from"); return
|
||||
}
|
||||
// Present on top of whatever is already showing (a modal, another sheet) so it never fails silently.
|
||||
var presenter = base
|
||||
while let top = presenter.presentedViewController { presenter = top }
|
||||
presenter.present(controller, animated: true) {
|
||||
call.resolve(["ok": true])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accepts either a file:// URI (what Filesystem.writeFile returns) or a bare absolute path.
|
||||
private static func fileURL(from raw: String) -> URL {
|
||||
if raw.hasPrefix("file://") {
|
||||
if let u = URL(string: raw) { return u }
|
||||
// Un-encoded spaces make URL(string:) fail — percent-encode and retry before giving up.
|
||||
let encoded = raw.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? raw
|
||||
if let u = URL(string: encoded) { return u }
|
||||
}
|
||||
return URL(fileURLWithPath: raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Single-item data source. NSURL already conforms to QLPreviewItem.
|
||||
final class QLDataSource: NSObject, QLPreviewControllerDataSource {
|
||||
private let url: URL
|
||||
init(url: URL) { self.url = url }
|
||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
|
||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
||||
return url as NSURL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "file-opener",
|
||||
"version": "1.0.0",
|
||||
"description": "Preview a downloaded file with native iOS Quick Look for Biz Connect",
|
||||
"main": "dist/plugin.cjs.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"author": "BizGaze",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist/",
|
||||
"ios/",
|
||||
"FileOpener.podspec",
|
||||
"Package.swift"
|
||||
],
|
||||
"capacitor": {
|
||||
"ios": {
|
||||
"src": "ios"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/core": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "MediaLibrary",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(name: "MediaLibrary", targets: ["MediaLibraryPlugin"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "MediaLibraryPlugin",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||
],
|
||||
path: "ios/Sources/MediaLibraryPlugin")
|
||||
]
|
||||
)
|
||||
@@ -10,7 +10,8 @@
|
||||
"files": [
|
||||
"dist/",
|
||||
"ios/",
|
||||
"MediaLibrary.podspec"
|
||||
"MediaLibrary.podspec",
|
||||
"Package.swift"
|
||||
],
|
||||
"capacitor": {
|
||||
"ios": {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
// SPM manifest for the native-call Capacitor plugin (Capacitor 8 uses SPM). LiveKit is declared here as a
|
||||
// REAL SPM dependency — LiveKit 2.1+ is SPM-native, so this replaces the CocoaPods git-tag pin hack entirely.
|
||||
// SPM resolves LiveKit + its LiveKitWebRTC / LiveKitUniFFI / SwiftProtobuf sub-packages directly.
|
||||
let package = Package(
|
||||
name: "NativeCall",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(name: "NativeCall", targets: ["NativeCallPlugin"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
|
||||
.package(url: "https://github.com/livekit/client-sdk-swift.git", exact: "2.15.3")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "NativeCallPlugin",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "LiveKit", package: "client-sdk-swift")
|
||||
],
|
||||
path: "ios/Sources/NativeCallPlugin")
|
||||
]
|
||||
)
|
||||
@@ -1,9 +1,12 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import WebKit
|
||||
import Capacitor
|
||||
import PushKit
|
||||
import CallKit
|
||||
import AVFoundation
|
||||
import LiveKitClient // the CocoaPod is 'LiveKitClient' (no module_name), so the Swift module is LiveKitClient — NOT LiveKit
|
||||
import Speech // #5 native transcript: SFSpeechRecognizer (iOS on-device speech-to-text; WKWebView has no Web Speech API)
|
||||
import LiveKit // SPM product name is 'LiveKit' (Package.swift). (The old CocoaPods module was 'LiveKitClient'.)
|
||||
|
||||
// Native calling for Biz Connect (iOS). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall.
|
||||
//
|
||||
@@ -18,14 +21,27 @@ import LiveKitClient // the CocoaPod is 'LiveKitClient' (no module_name), so the
|
||||
// the token from the WebView via reportOutgoingCall(). The WebView is UI only for native calls (it must
|
||||
// NOT also join the room — LiveKit allows one connection per identity).
|
||||
@objc(NativeCallPlugin)
|
||||
public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelegate, CXProviderDelegate {
|
||||
public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelegate, CXProviderDelegate, BroadcastManagerDelegate {
|
||||
public let identifier = "NativeCallPlugin"
|
||||
public let jsName = "NativeCall"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "reconnectRoom", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setCamera", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "switchCamera", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "startScreenShare", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "stopScreenShare", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "startMeetingScreenShare", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "stopMeetingScreenShare", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setHolePunch", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setTileZoom", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "syncVideoTiles", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "startTranscription", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "stopTranscription", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "feedAudio", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
@@ -39,6 +55,17 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
||||
private var endedCalls = Set<UUID>()
|
||||
private var room: Room?
|
||||
private var activeUUID: UUID?
|
||||
// Native video: one native video view per visible participant (key "__local" or the remote user id),
|
||||
// drawn over the WebView and positioned to match the web meeting tiles (see syncVideoTiles). UIKit views —
|
||||
// only ever touched on the main thread. TileVideoView adds pinch-zoom/pan for shared screens.
|
||||
private var tileViews: [String: TileVideoView] = [:]
|
||||
// Hole-punch: draw the native video BEHIND a transparent WebView so all web UI (bar, menus, panels) floats
|
||||
// on top. Saved so we can restore the WebView when the call ends.
|
||||
private var holePunchOn = false
|
||||
private var savedVCBg: UIColor? // the WebView parent's original background, restored when the call ends
|
||||
// #5 native transcript: transcribes THIS device's mic (WKWebView has no Web Speech API). Fed by a LiveKit
|
||||
// AudioRenderer on the local mic track, so it reuses the call's already-open mic (no 2nd audio engine).
|
||||
private let transcriber = SpeechTranscriber()
|
||||
|
||||
override public func load() {
|
||||
let config = CXProviderConfiguration(localizedName: "Biz Connect")
|
||||
@@ -60,6 +87,41 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
||||
// engine OFF; we configure the session and enable the engine ONLY in didActivate.
|
||||
AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
|
||||
try? AudioManager.shared.setEngineAvailability(.none)
|
||||
|
||||
// Re-assert the LOUDSPEAKER whenever iOS routes call audio back to the quiet earpiece. The LiveKit
|
||||
// audio engine starting up right after CallKit activates the session flips the route to the built-in
|
||||
// receiver — that's the "sound is on the earpiece until I tap something" bug (tapping mic/cam re-ran
|
||||
// preferSpeaker and fixed it). Listening for route changes makes that self-healing.
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(audioRouteChanged(_:)),
|
||||
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||
|
||||
// Screen sharing (ReplayKit broadcast extension). LiveKit tells us when a broadcast starts/stops and
|
||||
// (with shouldPublishTrack=true, the default) auto-publishes/unpublishes the screen-share track.
|
||||
BroadcastManager.shared.delegate = self
|
||||
|
||||
// #5 native transcript: each finalized speech segment → the web (meeting-transcript over the WS), which
|
||||
// merges it into the shared meeting transcript (same path as desktop's Web Speech API).
|
||||
transcriber.onFinal = { [weak self] text in self?.notifyListeners("transcript", data: ["text": text]) }
|
||||
|
||||
// Make the WebView transparent at STARTUP. WKWebView can IGNORE isOpaque=false when it's flipped after
|
||||
// the page has already rendered — the likely reason the hole-punch showed no video. Doing it once, up
|
||||
// front, makes the transparent-meeting areas actually reveal the native video behind the WebView. The
|
||||
// web body is opaque, so the app looks normal outside a call.
|
||||
DispatchQueue.main.async { [weak self] in self?.makeWebViewTransparent() }
|
||||
}
|
||||
|
||||
private func makeWebViewTransparent() {
|
||||
guard let web = bridge?.webView else { return }
|
||||
web.isOpaque = false
|
||||
web.backgroundColor = .clear
|
||||
web.scrollView.isOpaque = false // the scrollView being opaque can occlude native content behind the WebView
|
||||
web.scrollView.backgroundColor = .clear
|
||||
}
|
||||
|
||||
@objc private func audioRouteChanged(_ note: Notification) {
|
||||
guard room != nil else { return } // only steer the route during an active native call
|
||||
// Let the engine's own route change settle first, then override if we landed on the earpiece.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in self?.preferSpeaker() }
|
||||
}
|
||||
|
||||
// MARK: - LiveKit media
|
||||
@@ -70,7 +132,10 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
||||
// engine in didActivate can block on first use — determining it up front avoids that.
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { _ in }
|
||||
let old = room
|
||||
let r = Room()
|
||||
// Route screen-share through the ReplayKit broadcast extension (so the user can share their screen
|
||||
// even when the app is backgrounded, and it captures the whole phone, not just the WebView).
|
||||
let opts = RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true))
|
||||
let r = Room(roomOptions: opts)
|
||||
room = r
|
||||
Task { [weak self] in
|
||||
await old?.disconnect() // drop any previous/stale connection so we never leave DUPLICATE participants
|
||||
@@ -90,7 +155,103 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
||||
private func disconnectRoom() {
|
||||
let r = room
|
||||
room = nil
|
||||
transcriber.stop() // #5: end any live transcription with the call
|
||||
Task { await r?.disconnect() }
|
||||
removeAllTileViews()
|
||||
DispatchQueue.main.async { [weak self] in guard let self = self, let web = self.bridge?.webView else { return }; self.applyHolePunchRestore(web) }
|
||||
}
|
||||
|
||||
// MARK: - Native video (tile rendering, Increment 2b)
|
||||
//
|
||||
// The WebView owns the meeting UI (grid, roster, controls) but — for a native call — has NO LiveKit
|
||||
// connection, so it can't render any video. The video lives only in the plugin's LiveKit connection.
|
||||
// So we draw native VideoViews on top of the WebView, positioned to match each web tile: the web reports
|
||||
// each tile's on-screen rect + the participant's user id (syncVideoTiles), and we place/size a VideoView
|
||||
// for whichever participants currently have a live camera track. Views are subviews of the WKWebView, so
|
||||
// their frames use the SAME coordinate space as getBoundingClientRect (CSS px == points, both
|
||||
// viewport-relative) and stay aligned as the page scrolls.
|
||||
|
||||
// Find the live (unmuted, subscribed) camera track for a user id — nil when the camera is off, so the web
|
||||
// tile's avatar shows through instead.
|
||||
private func cameraTrack(forUid uid: String, isLocal: Bool) -> VideoTrack? {
|
||||
guard let room = room else { return nil }
|
||||
let pubs: [TrackPublication]
|
||||
if isLocal {
|
||||
pubs = room.localParticipant.videoTracks
|
||||
} else {
|
||||
guard let p = room.remoteParticipants[Participant.Identity(from: uid)] else { return nil }
|
||||
pubs = p.videoTracks
|
||||
}
|
||||
guard let pub = pubs.first(where: { $0.source == .camera && !$0.isMuted && $0.track != nil }) else { return nil }
|
||||
return pub.track as? VideoTrack
|
||||
}
|
||||
|
||||
// The remote screen-share track for a user id — nil if they aren't sharing (or it isn't subscribed yet).
|
||||
// (Local screen-share isn't supported on iOS — no ReplayKit broadcast extension — so this is remote-only.)
|
||||
private func screenTrack(forUid uid: String) -> VideoTrack? {
|
||||
guard let room = room, let p = room.remoteParticipants[Participant.Identity(from: uid)] else { return nil }
|
||||
guard let pub = p.videoTracks.first(where: { $0.source == .screenShareVideo && !$0.isMuted && $0.track != nil }) else { return nil }
|
||||
return pub.track as? VideoTrack
|
||||
}
|
||||
|
||||
private func tileKey(uid: String, isLocal: Bool) -> String { isLocal ? "__local" : uid }
|
||||
|
||||
private func removeAllTileViews() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
for (_, vv) in self.tileViews { vv.removeFromSuperview() }
|
||||
self.tileViews.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// Create a tile view BEHIND the whole WebView (in its superview). WKWebView does NOT composite native
|
||||
// subviews placed under its scrollView through transparent web content, so the video must sit behind the
|
||||
// (transparent) WebView itself; the web UI then paints on top. No native overlays — the web tile draws them.
|
||||
private func makeTileView(host: WKWebView, key: String) -> TileVideoView {
|
||||
let v = TileVideoView(frame: .zero)
|
||||
if let sup = host.superview { sup.insertSubview(v, belowSubview: host) } else { host.addSubview(v) }
|
||||
tileViews[key] = v
|
||||
return v
|
||||
}
|
||||
|
||||
// Turn hole-punch on/off. Transparency is already set at startup (makeWebViewTransparent); here we just add
|
||||
// a BLACK backing to the WebView's parent (the layer directly behind the video tiles) during the call, and
|
||||
// remove it + the tiles afterwards.
|
||||
@objc func setHolePunch(_ call: CAPPluginCall) {
|
||||
let on = call.getBool("on") ?? false
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, let web = self.bridge?.webView else { call.resolve(); return }
|
||||
self.makeWebViewTransparent() // belt-and-braces
|
||||
let parent = web.superview
|
||||
if on {
|
||||
if !self.holePunchOn { self.savedVCBg = parent?.backgroundColor }
|
||||
parent?.backgroundColor = .black
|
||||
self.holePunchOn = true
|
||||
} else {
|
||||
self.applyHolePunchRestore(web)
|
||||
for (_, vv) in self.tileViews { vv.removeFromSuperview() }
|
||||
self.tileViews.removeAll()
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the black backing (leave the WebView transparent — the web body is opaque, so it looks normal).
|
||||
private func applyHolePunchRestore(_ web: WKWebView) {
|
||||
guard holePunchOn else { return }
|
||||
web.superview?.backgroundColor = savedVCBg
|
||||
holePunchOn = false
|
||||
}
|
||||
|
||||
// Web-forwarded zoom: touches land on the WebView (on top), so the web captures pinch/pan on the shared
|
||||
// screen and forwards the transform here; we apply it to that tile's inner video.
|
||||
@objc func setTileZoom(_ call: CAPPluginCall) {
|
||||
let key = tileKey(uid: call.getString("uid") ?? "", isLocal: call.getBool("local") ?? false)
|
||||
let scale = CGFloat(call.getDouble("scale") ?? 1)
|
||||
let tx = CGFloat(call.getDouble("tx") ?? 0)
|
||||
let ty = CGFloat(call.getDouble("ty") ?? 0)
|
||||
DispatchQueue.main.async { [weak self] in self?.tileViews[key]?.applyZoom(scale: scale, tx: tx, ty: ty) }
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// Route call audio to the LOUDSPEAKER when we'd otherwise be on the quiet earpiece. Guarded so a connected
|
||||
@@ -157,6 +318,22 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// #12 Multi-device: swap the media connection to a token whose identity = this device's mesh peerId (unique
|
||||
// per connection). The web calls this once the native WebView has joined the mesh and has a peerId. On
|
||||
// answer we connected INSTANTLY with the push token (identity=userId) for zero-latency audio; this re-homes
|
||||
// the media onto the unique peerId identity so two devices of the same user are distinct LiveKit
|
||||
// participants (LiveKit allows one connection per identity — otherwise the older device is kicked and
|
||||
// "audio jumps to whichever joined last"). connectRoom disconnects the old room first; the CallKit call and
|
||||
// its audio session stay active, so audio just re-attaches. callConnected fires on success → web re-applies
|
||||
// mic/cam. Guarded to only run during an active call.
|
||||
@objc func reconnectRoom(_ call: CAPPluginCall) {
|
||||
let url = call.getString("url") ?? ""
|
||||
let token = call.getString("token") ?? ""
|
||||
guard room != nil, !url.isEmpty, !token.isEmpty else { call.resolve(); return }
|
||||
connectRoom(url: url, token: token)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func setMuted(_ call: CAPPluginCall) {
|
||||
let muted = call.getBool("muted") ?? false
|
||||
// Drive mute THROUGH CallKit so the system call screen and the in-app meeting UI stay in sync (the
|
||||
@@ -171,6 +348,169 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// Enable/disable the local camera. Publishing it makes this user's video appear for everyone else (their
|
||||
// web/desktop clients render it via their own SFU subscription); locally it's drawn on the __local tile by
|
||||
// syncVideoTiles (the web triggers a sync right after this resolves). Front camera only for now.
|
||||
@objc func setCamera(_ call: CAPPluginCall) {
|
||||
guard let r = room else { call.reject("no active call"); return }
|
||||
let on = call.getBool("on") ?? false
|
||||
Task {
|
||||
do {
|
||||
try await r.localParticipant.setCamera(
|
||||
enabled: on,
|
||||
captureOptions: CameraCaptureOptions(position: .front))
|
||||
call.resolve(["on": on])
|
||||
} catch {
|
||||
call.reject("camera failed: \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flip the local camera between front and back.
|
||||
@objc func switchCamera(_ call: CAPPluginCall) {
|
||||
guard let r = room else { call.reject("no active call"); return }
|
||||
let pub = r.localParticipant.videoTracks.first(where: { $0.source == .camera })
|
||||
guard let track = pub?.track as? LocalVideoTrack, let cam = track.capturer as? CameraCapturer else {
|
||||
call.reject("camera not active"); return
|
||||
}
|
||||
Task {
|
||||
do { _ = try await cam.switchCameraPosition(); call.resolve() }
|
||||
catch { call.reject("switch failed: \(String(describing: error))") }
|
||||
}
|
||||
}
|
||||
|
||||
// Screen sharing from iOS: show the system broadcast picker. When the user starts the broadcast, the
|
||||
// extension streams the screen to us over IPC and LiveKit publishes it (BroadcastManager.shouldPublishTrack
|
||||
// defaults true). broadcastManager(didChangeState:) fires screenShareState back to the web either way.
|
||||
@objc func startScreenShare(_ call: CAPPluginCall) {
|
||||
guard room != nil else { call.reject("no active call"); return }
|
||||
DispatchQueue.main.async { BroadcastManager.shared.requestActivation() } // presents RPSystemBroadcastPickerView
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func stopScreenShare(_ call: CAPPluginCall) {
|
||||
BroadcastManager.shared.requestStop()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// Screen share into a SCHEDULED / code-joined SFU meeting. The WebView already holds the meeting
|
||||
// connection (identity = peerId) and WKWebView has no getDisplayMedia — so we open a SECOND, screen-ONLY
|
||||
// native LiveKit connection under a distinct `<peerId>-screen` identity and publish the ReplayKit
|
||||
// broadcast into the SAME room. The WebView maps that identity back onto the sharer's tile.
|
||||
@objc func startMeetingScreenShare(_ call: CAPPluginCall) {
|
||||
let url = call.getString("url") ?? ""
|
||||
let token = call.getString("token") ?? ""
|
||||
guard !url.isEmpty, !token.isEmpty else { call.reject("url/token required"); return }
|
||||
connectScreenRoom(url: url, token: token)
|
||||
DispatchQueue.main.async { BroadcastManager.shared.requestActivation() } // system broadcast picker
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func stopMeetingScreenShare(_ call: CAPPluginCall) {
|
||||
BroadcastManager.shared.requestStop()
|
||||
let r = room; room = nil
|
||||
Task { await r?.disconnect() } // drop the screen-only connection (no call/tile/transcriber side effects)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// A screen-ONLY LiveKit connection (mic off, no camera) used purely to publish the ReplayKit broadcast
|
||||
// into an SFU meeting. Unlike connectRoom() it enables no mic and fires no callConnected — this is not a call.
|
||||
private func connectScreenRoom(url: String, token: String) {
|
||||
guard !url.isEmpty, !token.isEmpty else { return }
|
||||
let old = room
|
||||
let opts = RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true))
|
||||
let r = Room(roomOptions: opts)
|
||||
room = r
|
||||
Task { [weak self] in
|
||||
await old?.disconnect() // never leave a duplicate connection
|
||||
do { try await r.connect(url: url, token: token) }
|
||||
catch { self?.notifyListeners("screenShareState", data: ["sharing": false, "error": String(describing: error)]) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - #5 Live transcript (native SFSpeechRecognizer)
|
||||
|
||||
// The current published local mic track — the source we tap for transcription. nil until the user has
|
||||
// unmuted at least once (the mic is published on unmute), or between reconnects.
|
||||
private func localAudioTrack() -> LocalAudioTrack? {
|
||||
return room?.localParticipant.audioTracks.first?.track as? LocalAudioTrack
|
||||
}
|
||||
|
||||
// Start transcribing this device's mic (WKWebView has no Web Speech API). Two audio sources:
|
||||
// * NATIVE CALL (default): the plugin owns the LiveKit room, so we tap the local mic track with a LiveKit
|
||||
// AudioRenderer (reuses the call's open mic — reliable, no 2nd capturer). Re-attaches on unmute.
|
||||
// * SCHEDULED/WEB MEETING ({external:true}): the WebView owns the mic (the plugin has no room), so the web
|
||||
// reads its own mic PCM via Web Audio and pushes it here with feedAudio() — no second mic capture on iOS
|
||||
// (two input units would fight and yield silence).
|
||||
@objc func startTranscription(_ call: CAPPluginCall) {
|
||||
if call.getBool("external") == true {
|
||||
transcriber.startExternal()
|
||||
} else {
|
||||
transcriber.start(track: localAudioTrack())
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func stopTranscription(_ call: CAPPluginCall) {
|
||||
transcriber.stop()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// Web-forwarded mic PCM for the {external:true} path (scheduled/web meetings). `pcm` = base64 little-endian
|
||||
// Int16 mono at `rate` Hz (the web downsamples to 16 kHz). Fed straight into the recognizer.
|
||||
@objc func feedAudio(_ call: CAPPluginCall) {
|
||||
guard let b64 = call.getString("pcm"), let data = Data(base64Encoded: b64) else { call.resolve(); return }
|
||||
let rate = call.getDouble("rate") ?? 16000
|
||||
transcriber.appendPCM(int16: data, sampleRate: rate)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
// MARK: - BroadcastManagerDelegate
|
||||
|
||||
public func broadcastManager(didChangeState isBroadcasting: Bool) {
|
||||
notifyListeners("screenShareState", data: ["sharing": isBroadcasting])
|
||||
}
|
||||
|
||||
// Position native video views to match the web meeting tiles. `tiles` = [{uid, local, x, y, w, h}] in
|
||||
// CSS px (== points; getBoundingClientRect coords). We create/move a VideoView for each participant that
|
||||
// has a live camera track, and remove views for tiles that are gone or whose camera is off (so the web
|
||||
// avatar shows). Called on a short poll by the web while a native call is on screen, plus on demand.
|
||||
@objc func syncVideoTiles(_ call: CAPPluginCall) {
|
||||
let tiles = (call.getArray("tiles") as? [[String: Any]]) ?? []
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { call.resolve(); return }
|
||||
guard let host = self.bridge?.webView else { call.resolve(); return }
|
||||
var wanted = Set<String>()
|
||||
for t in tiles {
|
||||
guard let uid = t["uid"] as? String, !uid.isEmpty else { continue }
|
||||
let isLocal = (t["local"] as? Bool) ?? false
|
||||
func num(_ k: String) -> CGFloat { CGFloat((t[k] as? NSNumber)?.doubleValue ?? 0) }
|
||||
let x = num("x"), y = num("y"), w = num("w"), h = num("h")
|
||||
if w < 2 || h < 2 { continue }
|
||||
let key = self.tileKey(uid: uid, isLocal: isLocal)
|
||||
// A tile flagged `screen` is a sharer's stage tile → show their screen-share track (fit, so it
|
||||
// isn't cropped); otherwise the camera (fill). Either is nil when off → web avatar shows.
|
||||
let wantScreen = (t["screen"] as? Bool) ?? false
|
||||
guard let track = wantScreen ? self.screenTrack(forUid: uid)
|
||||
: self.cameraTrack(forUid: uid, isLocal: isLocal) else { continue }
|
||||
wanted.insert(key)
|
||||
let vv = self.tileViews[key] ?? self.makeTileView(host: host, key: key)
|
||||
if vv.superview !== host.superview, let sup = host.superview { sup.insertSubview(vv, belowSubview: host) } // keep BEHIND the transparent WebView
|
||||
vv.layoutMode = wantScreen ? .fit : .fill
|
||||
if vv.track !== track { vv.track = track }
|
||||
// The container tracks the tile rect. getBoundingClientRect is in the WebView's coordinate space;
|
||||
// convert it into the superview (where the tiles live) — robust to any WebView offset/inset. The
|
||||
// zoom transform (web-forwarded via setTileZoom) lives on the INNER video, so this never fights it.
|
||||
vv.frame = host.convert(CGRect(x: x, y: y, width: w, height: h), to: host.superview)
|
||||
}
|
||||
// Drop views for participants no longer present / camera turned off.
|
||||
for (key, vv) in self.tileViews where !wanted.contains(key) {
|
||||
vv.removeFromSuperview(); self.tileViews.removeValue(forKey: key)
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// End the CallKit call (remote hung up / user ended from the web UI). No callUUID -> end all.
|
||||
@objc func endCall(_ call: CAPPluginCall) {
|
||||
if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) {
|
||||
@@ -292,6 +632,8 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
||||
Task { [weak self] in
|
||||
try? await r?.localParticipant.setMicrophone(enabled: !action.isMuted)
|
||||
self?.preferSpeaker() // toggling the mic can flip the route back to the earpiece — re-assert speaker
|
||||
// #5: unmuting publishes the mic track — (re)attach the transcriber's renderer to it if transcript is on.
|
||||
if !action.isMuted { self?.transcriber.refresh(track: self?.localAudioTrack()) }
|
||||
}
|
||||
notifyListeners("setMuted", data: ["callUUID": action.callUUID.uuidString, "muted": action.isMuted])
|
||||
action.fulfill()
|
||||
@@ -319,3 +661,169 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
||||
notifyListeners("audioDeactivated", data: ["ok": true])
|
||||
}
|
||||
}
|
||||
|
||||
// A tile view = a container holding a LiveKit VideoView. We CANNOT subclass VideoView (it's `public`, not
|
||||
// `open`, so subclassing outside its module is illegal), so we compose. Under hole-punch the video sits BEHIND
|
||||
// the (transparent) WebView, so touches never reach it — pinch-zoom is captured by the web and forwarded via
|
||||
// applyZoom(). The container stays frame-synced to the web tile rect; the zoom transform lives on the inner
|
||||
// video, so the two never fight.
|
||||
final class TileVideoView: UIView {
|
||||
let video = VideoView()
|
||||
|
||||
// Forward the two properties the plugin sets so call sites read like a VideoView.
|
||||
var track: VideoTrack? { get { video.track } set { video.track = newValue } }
|
||||
var layoutMode: VideoView.LayoutMode { get { video.layoutMode } set { video.layoutMode = newValue } }
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear // the web tile draws its own frame; gaps show the WebView's black bg
|
||||
clipsToBounds = true
|
||||
layer.cornerRadius = 12 // match .meet-tile's border-radius so corners don't poke past the web border
|
||||
video.layoutMode = .fill
|
||||
addSubview(video)
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
// Position via bounds+center (not frame) so it coexists with the zoom transform.
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
video.bounds = CGRect(origin: .zero, size: bounds.size)
|
||||
video.center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
}
|
||||
|
||||
// Web-forwarded zoom (scale + translation in points). scale<=1 clears the transform.
|
||||
func applyZoom(scale: CGFloat, tx: CGFloat, ty: CGFloat) {
|
||||
if scale <= 1.001 { if !video.transform.isIdentity { video.transform = .identity } }
|
||||
else { video.transform = CGAffineTransform(translationX: tx, y: ty).scaledBy(x: scale, y: scale) }
|
||||
}
|
||||
}
|
||||
|
||||
// #5 Live transcript on iOS. WKWebView has no Web Speech API, so an iOS participant's speech was never captured
|
||||
// into the meeting transcript (desktop Chrome/Edge already works). This transcribes THIS device's mic with
|
||||
// SFSpeechRecognizer, fed by a LiveKit `AudioRenderer` attached to the local mic track — so it reuses the call's
|
||||
// already-open capture (no second AVAudioEngine fighting WebRTC for the audio session). Each finished utterance
|
||||
// fires `onFinal`; the plugin relays it to the web, which sends it over the meeting WS exactly like the desktop
|
||||
// path. Segments are cut on a short silence gap (and on the recognizer's own isFinal), and the request is
|
||||
// restarted per segment so text flows continuously and stays within the recognizer's limits. On-device
|
||||
// recognition is used when available (offline, continuous, no ~1-minute cap). All state is main-confined except
|
||||
// the locked `request` that the audio-thread renderer appends to.
|
||||
final class SpeechTranscriber: NSObject, AudioRenderer, @unchecked Sendable {
|
||||
var onFinal: ((String) -> Void)?
|
||||
|
||||
private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
|
||||
private let lock = NSLock()
|
||||
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var task: SFSpeechRecognitionTask?
|
||||
private weak var track: LocalAudioTrack?
|
||||
private var running = false
|
||||
private var latest = ""
|
||||
private var lastEmitted = ""
|
||||
private var silenceTimer: DispatchWorkItem?
|
||||
|
||||
// Begin transcription tapping a LiveKit local mic track (native calls). Idempotent; asks for Speech
|
||||
// authorization once. Safe to call before the mic exists — `refresh` re-attaches when it publishes (unmute).
|
||||
func start(track: LocalAudioTrack?) { begin { self.attach(track) } }
|
||||
|
||||
// Begin transcription in EXTERNAL mode (scheduled/web meetings): no LiveKit track to tap — PCM arrives via
|
||||
// appendPCM() from the web. Same recognition pipeline.
|
||||
func startExternal() { begin { } }
|
||||
|
||||
private func begin(_ afterStart: @escaping () -> Void) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.running { afterStart(); return }
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
DispatchQueue.main.async {
|
||||
guard status == .authorized, !self.running else { return }
|
||||
self.running = true
|
||||
self.beginRequest()
|
||||
afterStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Web-forwarded PCM (external mode): little-endian Int16 mono → Float32 buffer → recognizer.
|
||||
func appendPCM(int16 data: Data, sampleRate: Double) {
|
||||
let count = data.count / 2
|
||||
guard count > 0,
|
||||
let fmt = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false),
|
||||
let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: AVAudioFrameCount(count)) else { return }
|
||||
buf.frameLength = AVAudioFrameCount(count)
|
||||
guard let dst = buf.floatChannelData?[0] else { return }
|
||||
data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
|
||||
let src = raw.bindMemory(to: Int16.self)
|
||||
for i in 0..<count { dst[i] = Float(src[i]) / 32768.0 }
|
||||
}
|
||||
lock.lock(); let r = request; lock.unlock()
|
||||
r?.append(buf)
|
||||
}
|
||||
|
||||
// (Re)attach to the current local mic track — called on start and whenever the mic (re)publishes on unmute.
|
||||
func refresh(track: LocalAudioTrack?) { DispatchQueue.main.async { [weak self] in self?.attach(track) } }
|
||||
|
||||
func stop() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.running = false
|
||||
self.silenceTimer?.cancel(); self.silenceTimer = nil
|
||||
self.track?.remove(audioRenderer: self); self.track = nil
|
||||
self.lock.lock(); let r = self.request; self.request = nil; self.lock.unlock()
|
||||
r?.endAudio(); self.task?.cancel(); self.task = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func attach(_ t: LocalAudioTrack?) {
|
||||
guard running, let t = t, track !== t else { return }
|
||||
track?.remove(audioRenderer: self)
|
||||
t.add(audioRenderer: self)
|
||||
track = t
|
||||
}
|
||||
|
||||
private func beginRequest() {
|
||||
guard let recognizer = recognizer, recognizer.isAvailable else { return }
|
||||
let req = SFSpeechAudioBufferRecognitionRequest()
|
||||
req.shouldReportPartialResults = true // stream results; we only EMIT a segment on silence / isFinal
|
||||
if recognizer.supportsOnDeviceRecognition { req.requiresOnDeviceRecognition = true }
|
||||
lock.lock(); request = req; lock.unlock()
|
||||
latest = ""; lastEmitted = ""
|
||||
task = recognizer.recognitionTask(with: req) { [weak self] result, error in
|
||||
guard let self = self else { return }
|
||||
if let result = result {
|
||||
let text = result.bestTranscription.formattedString
|
||||
let isFinal = result.isFinal
|
||||
DispatchQueue.main.async {
|
||||
guard self.running else { return }
|
||||
self.latest = text
|
||||
if isFinal { self.flushAndRestart() } else { self.armSilenceTimer() }
|
||||
}
|
||||
} else if error != nil {
|
||||
DispatchQueue.main.async { if self.running { self.flushAndRestart() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A short pause = end of an utterance → emit it and start a fresh request for the next one.
|
||||
private func armSilenceTimer() {
|
||||
silenceTimer?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in guard let self = self, self.running else { return }; self.flushAndRestart() }
|
||||
silenceTimer = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4, execute: work)
|
||||
}
|
||||
|
||||
private func flushAndRestart() {
|
||||
silenceTimer?.cancel(); silenceTimer = nil
|
||||
let text = latest.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty && text != lastEmitted { lastEmitted = text; onFinal?(text) }
|
||||
guard running else { return }
|
||||
lock.lock(); let r = request; request = nil; lock.unlock()
|
||||
r?.endAudio(); task?.cancel(); task = nil
|
||||
beginRequest()
|
||||
}
|
||||
|
||||
// MARK: AudioRenderer — receives the local mic PCM from LiveKit (audio thread).
|
||||
func render(pcmBuffer: AVAudioPCMBuffer) {
|
||||
lock.lock(); let r = request; lock.unlock()
|
||||
r?.append(pcmBuffer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"files": [
|
||||
"dist/",
|
||||
"ios/",
|
||||
"NativeCall.podspec"
|
||||
"NativeCall.podspec",
|
||||
"Package.swift"
|
||||
],
|
||||
"capacitor": {
|
||||
"ios": {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ShareInbox",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(name: "ShareInbox", targets: ["ShareInboxPlugin"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "ShareInboxPlugin",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||
],
|
||||
path: "ios/Sources/ShareInboxPlugin")
|
||||
]
|
||||
)
|
||||
@@ -10,7 +10,8 @@
|
||||
"files": [
|
||||
"dist/",
|
||||
"ios/",
|
||||
"ShareInbox.podspec"
|
||||
"ShareInbox.podspec",
|
||||
"Package.swift"
|
||||
],
|
||||
"capacitor": {
|
||||
"ios": {
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
#
|
||||
# Inject the Broadcast Upload Extension (ReplayKit screen sharing) into the Capacitor-generated Xcode project.
|
||||
# Mirrors add-share-extension.rb, with the extra step that this extension LINKS the LiveKit Swift package
|
||||
# (LKSampleHandler lives in the LiveKit product), so it adds a package product dependency to the new target.
|
||||
#
|
||||
# WHAT IT WIRES:
|
||||
# * a new app-extension target "BroadcastExtension" (bundle id <app>.broadcast) whose source is our
|
||||
# SampleHandler.swift (subclass of LiveKit's LKSampleHandler) + Info.plist + entitlements, copied from
|
||||
# mobile/ios-broadcast/
|
||||
# * the App Group entitlement (group.com.bizgaze.connect) on the extension (LiveKit's IPC socket lives there)
|
||||
# * a Swift Package product dependency on LiveKit (github.com/livekit/client-sdk-swift 2.15.3) so the
|
||||
# extension can subclass LKSampleHandler
|
||||
# * the extension embedded into the app ("Embed App Extensions") + set as a build dependency
|
||||
#
|
||||
# Idempotent: if the target already exists it is removed and rebuilt.
|
||||
|
||||
require 'xcodeproj'
|
||||
require 'fileutils'
|
||||
|
||||
ROOT = File.expand_path('..', __dir__)
|
||||
PROJECT = File.join(ROOT, 'ios', 'App', 'App.xcodeproj')
|
||||
SRC_DIR = File.join(ROOT, 'ios-broadcast')
|
||||
APP_DIR = File.join(ROOT, 'ios', 'App')
|
||||
EXT_NAME = 'BroadcastExtension'
|
||||
EXT_DIR = File.join(APP_DIR, EXT_NAME)
|
||||
APP_TARGET = 'App'
|
||||
APP_BUNDLE = ENV.fetch('BUNDLE_ID', 'com.bizgaze.connect')
|
||||
EXT_BUNDLE = "#{APP_BUNDLE}.broadcast"
|
||||
APP_GROUP = 'group.com.bizgaze.connect'
|
||||
LK_URL = 'https://github.com/livekit/client-sdk-swift.git'
|
||||
LK_VERSION = '2.15.3'
|
||||
|
||||
abort "xcodeproj not found at #{PROJECT}" unless File.directory?(PROJECT)
|
||||
|
||||
project = Xcodeproj::Project.open(PROJECT)
|
||||
app = project.targets.find { |t| t.name == APP_TARGET }
|
||||
abort "App target not found" unless app
|
||||
|
||||
# ── Clean any previous injection so this is idempotent ──────────────────────────────────────────────
|
||||
project.targets.select { |t| t.name == EXT_NAME }.each do |t|
|
||||
t.build_configuration_list&.build_configurations&.each { |c| c.remove_from_project }
|
||||
t.remove_from_project
|
||||
end
|
||||
if (grp = project.main_group.children.find { |g| g.respond_to?(:display_name) && g.display_name == EXT_NAME })
|
||||
grp.remove_from_project
|
||||
end
|
||||
|
||||
# ── Copy our sources into the app project so paths inside the .xcodeproj are stable ──────────────────
|
||||
FileUtils.mkdir_p(EXT_DIR)
|
||||
%w[SampleHandler.swift Info.plist BroadcastExtension.entitlements].each do |f|
|
||||
FileUtils.cp(File.join(SRC_DIR, f), File.join(EXT_DIR, f))
|
||||
end
|
||||
|
||||
# ── Create the extension target ──────────────────────────────────────────────────────────────────────
|
||||
deployment = app.deployment_target || project.build_configurations.first&.build_settings&.[]('IPHONEOS_DEPLOYMENT_TARGET') || '15.0'
|
||||
ext = project.new_target(:app_extension, EXT_NAME, :ios, deployment, project.products_group, :swift)
|
||||
|
||||
group = project.main_group.new_group(EXT_NAME, EXT_NAME.to_s)
|
||||
swift_ref = group.new_reference(File.join(EXT_DIR, 'SampleHandler.swift'))
|
||||
ext.add_file_references([swift_ref])
|
||||
|
||||
ext.build_configurations.each do |cfg|
|
||||
s = cfg.build_settings
|
||||
s['PRODUCT_BUNDLE_IDENTIFIER'] = EXT_BUNDLE
|
||||
s['PRODUCT_NAME'] = '$(TARGET_NAME)'
|
||||
s['INFOPLIST_FILE'] = "#{EXT_NAME}/Info.plist"
|
||||
s['CODE_SIGN_ENTITLEMENTS'] = "#{EXT_NAME}/BroadcastExtension.entitlements"
|
||||
s['IPHONEOS_DEPLOYMENT_TARGET'] = deployment
|
||||
s['SWIFT_VERSION'] = '5.0'
|
||||
s['TARGETED_DEVICE_FAMILY'] = '1,2'
|
||||
s['GENERATE_INFOPLIST_FILE'] = 'NO'
|
||||
s['SKIP_INSTALL'] = 'YES'
|
||||
s['CODE_SIGN_STYLE'] = 'Manual'
|
||||
s['MARKETING_VERSION'] = '1.0'
|
||||
s['CURRENT_PROJECT_VERSION'] = ENV.fetch('BUILD_NUMBER', '1')
|
||||
s['LD_RUNPATH_SEARCH_PATHS'] = '$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks'
|
||||
end
|
||||
|
||||
# ── Link LiveKit (Swift Package product) so the extension can subclass LKSampleHandler ────────────────
|
||||
# The app already resolves client-sdk-swift 2.15.3 (via the native-call plugin's Package.swift). Add a
|
||||
# project-level remote package reference to the SAME repo+version (SPM dedupes it) and attach the "LiveKit"
|
||||
# product to this extension target.
|
||||
root = project.root_object
|
||||
pkg = root.package_references.find { |r| r.respond_to?(:repositoryURL) && r.repositoryURL == LK_URL }
|
||||
unless pkg
|
||||
pkg = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference)
|
||||
pkg.repositoryURL = LK_URL
|
||||
pkg.requirement = { 'kind' => 'exactVersion', 'version' => LK_VERSION }
|
||||
root.package_references << pkg
|
||||
end
|
||||
prod = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)
|
||||
prod.package = pkg
|
||||
prod.product_name = 'LiveKit'
|
||||
ext.package_product_dependencies << prod
|
||||
bf = project.new(Xcodeproj::Project::Object::PBXBuildFile)
|
||||
bf.product_ref = prod
|
||||
ext.frameworks_build_phase.files << bf
|
||||
|
||||
# ── App Group entitlement on the MAIN app target too (merge — keep aps-environment etc.) ──────────────
|
||||
app_ent_path = File.join(APP_DIR, 'App', 'App.entitlements')
|
||||
app_ent = File.exist?(app_ent_path) ? (Xcodeproj::Plist.read_from_path(app_ent_path) || {}) : {}
|
||||
groups = app_ent['com.apple.security.application-groups'] || []
|
||||
groups << APP_GROUP unless groups.include?(APP_GROUP)
|
||||
app_ent['com.apple.security.application-groups'] = groups
|
||||
Xcodeproj::Plist.write_to_path(app_ent, app_ent_path)
|
||||
app.build_configurations.each { |cfg| cfg.build_settings['CODE_SIGN_ENTITLEMENTS'] = 'App/App.entitlements' }
|
||||
|
||||
# ── Embed the extension into the app + depend on it ──────────────────────────────────────────────────
|
||||
app.add_dependency(ext)
|
||||
embed = app.build_phases.find { |p| p.respond_to?(:symbol_dst_subfolder_spec) && p.symbol_dst_subfolder_spec == :plug_ins }
|
||||
embed ||= begin
|
||||
phase = app.new_copy_files_build_phase('Embed App Extensions')
|
||||
phase.symbol_dst_subfolder_spec = :plug_ins
|
||||
phase
|
||||
end
|
||||
build_file = embed.add_file_reference(ext.product_reference)
|
||||
build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] }
|
||||
|
||||
project.save
|
||||
puts "Broadcast Extension injected: #{EXT_NAME} (#{EXT_BUNDLE}) linked to LiveKit #{LK_VERSION}, embedded in #{APP_TARGET}"
|
||||
@@ -0,0 +1,43 @@
|
||||
// Inject the runtime permissions the web UI needs into the Capacitor-generated AndroidManifest.xml.
|
||||
// Run on Codemagic from the Android workflow:
|
||||
// node mobile/scripts/android-patch.js mobile/android/app/src/main/AndroidManifest.xml
|
||||
//
|
||||
// WHY: mobile/android/ is gitignored (regenerated in CI by `cap add android`, same as iOS ios/). The
|
||||
// freshly generated manifest only declares INTERNET, so without this the WebRTC calls in the web UI can't
|
||||
// get camera/mic and Android 13+ never prompts for notifications. This adds the same permissions listed in
|
||||
// mobile/resources/android-permissions.xml. (When the native-call Android plugin lands, it will contribute
|
||||
// its own manifest entries via Capacitor manifest-merging; this only covers the app-level WebView perms.)
|
||||
//
|
||||
// TOLERANT: exits 0 and no-ops if anything is off, so it can NEVER fail the build. Idempotent (keyed on
|
||||
// the bzcAndroidPerms marker), so re-runs never duplicate the block.
|
||||
const fs = require('fs');
|
||||
const p = process.argv[2];
|
||||
if (!p || !fs.existsSync(p)) { console.log(' (AndroidManifest.xml not found — permission patch skipped)'); process.exit(0); }
|
||||
try {
|
||||
let s = fs.readFileSync(p, 'utf8');
|
||||
if (s.includes('bzcAndroidPerms') || s.includes('android.permission.RECORD_AUDIO')) {
|
||||
console.log(' Android permissions already present'); process.exit(0);
|
||||
}
|
||||
const block = [
|
||||
'',
|
||||
' <!-- bzcAndroidPerms: permissions the Biz Connect web UI needs (see mobile/resources/android-permissions.xml) -->',
|
||||
' <!-- 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" />',
|
||||
'',
|
||||
].join('\n');
|
||||
const orig = s;
|
||||
// Insert directly before the closing </manifest> tag.
|
||||
s = s.replace(/<\/manifest>\s*$/, block + '</manifest>\n');
|
||||
if (s === orig) { console.log(' could not find </manifest> — permission patch skipped'); process.exit(0); }
|
||||
fs.writeFileSync(p, s);
|
||||
console.log(' Android permissions injected into ' + p);
|
||||
} catch (e) {
|
||||
console.log(' Android permission patch skipped:', e.message);
|
||||
}
|
||||
process.exit(0);
|
||||
@@ -17,6 +17,7 @@ set_str NSCameraUsageDescription "Biz Connect uses the camera for video cal
|
||||
set_str NSMicrophoneUsageDescription "Biz Connect uses the microphone for voice and video calls."
|
||||
set_str NSPhotoLibraryUsageDescription "Biz Connect needs photo access so you can send images in chat."
|
||||
set_str NSPhotoLibraryAddUsageDescription "Biz Connect saves images and recordings you download to your photos."
|
||||
set_str NSSpeechRecognitionUsageDescription "Biz Connect uses speech recognition to create live meeting transcripts from your microphone."
|
||||
|
||||
# Human-readable display name on the home screen.
|
||||
set_str CFBundleDisplayName "Biz Connect"
|
||||
@@ -91,6 +92,14 @@ fi
|
||||
"$PB" -c "Add :aps-environment string production" "$ENT" 2>/dev/null || "$PB" -c "Set :aps-environment production" "$ENT"
|
||||
echo "Entitlements: aps-environment=production ensured in $ENT"
|
||||
|
||||
# ── Screen sharing from iOS (ReplayKit broadcast extension + LiveKit) ───────────────────────────────
|
||||
# LiveKit's BroadcastManager finds the broadcast upload extension + the shared App Group via these two keys
|
||||
# (BroadcastBundleInfo reads RTCScreenSharingExtension / RTCAppGroupIdentifier). They match the extension
|
||||
# added by add-broadcast-extension.rb (bundle id <app>.broadcast) and the App Group used by the share
|
||||
# extension. Set explicitly so there's no reliance on the default-derivation.
|
||||
set_str RTCScreenSharingExtension "com.bizgaze.connect.broadcast"
|
||||
set_str RTCAppGroupIdentifier "group.com.bizgaze.connect"
|
||||
|
||||
# We use only standard encryption (HTTPS/TLS), which is exempt — declaring this up front stops App Store
|
||||
# Connect from asking the "export compliance" question on every single build/TestFlight upload.
|
||||
"$PB" -c "Add :ITSAppUsesNonExemptEncryption bool false" "$PLIST" 2>/dev/null \
|
||||
@@ -123,47 +132,13 @@ if [ -f "$AD" ]; then
|
||||
node "$(dirname "$0")/inject-push.js" "$AD" || echo " (push forwarding patch skipped — non-fatal)"
|
||||
fi
|
||||
|
||||
# ── Pin LiveKit to 2.15.3 via its Git tag (stay on CocoaPods; no SPM migration) ─────────────────────
|
||||
# The CallKit audio-session coordination API (AudioManager.audioSession / setEngineAvailability) exists only
|
||||
# in LiveKit 2.1+, but the LiveKitClient CocoaPod PUBLISHED to trunk caps at 2.0.18 (2.1+ is SPM-only). The
|
||||
# repo still ships a VALID podspec at tag 2.15.3, and its deps (LiveKitWebRTC 144.7559.11 / LiveKitUniFFI
|
||||
# 0.0.6 / SwiftProtobuf) ARE on trunk — so we point the Podfile straight at the git tag. The NativeCall
|
||||
# podspec's `LiveKitClient ~> 2.0` is satisfied by 2.15.3 (2.15.3 ∈ [2.0, 3.0)). Idempotent (grep guard).
|
||||
PODFILE="mobile/ios/App/Podfile"
|
||||
if [ -f "$PODFILE" ]; then
|
||||
if grep -q "client-sdk-swift.git" "$PODFILE"; then
|
||||
echo "Podfile: LiveKitClient git pin already present"
|
||||
else
|
||||
ruby -e '
|
||||
p = "mobile/ios/App/Podfile"
|
||||
s = File.read(p)
|
||||
# LiveKit 2.15.3 pulls two binary deps that are SPM-only (NOT on the CocoaPods CDN), so we pin each to
|
||||
# its own repo podspec (neither has further deps):
|
||||
# * LiveKitUniFFI 0.0.6 — podspec has a :git source + prepare_command that downloads its xcframework, so :git works.
|
||||
# * LiveKitWebRTC 144.7559.11 — podspec has an :http source (release-zip), NOT in the git tree, so we
|
||||
# point at the podspec URL with :podspec (using :git would clone a repo with no xcframework and fail).
|
||||
# SwiftProtobuf (the only other dep) resolves from the CDN normally.
|
||||
pin = " pod \x27LiveKitClient\x27, :git => \x27https://github.com/livekit/client-sdk-swift.git\x27, :tag => \x272.15.3\x27\n" +
|
||||
" pod \x27LiveKitUniFFI\x27, :git => \x27https://github.com/livekit/livekit-uniffi-xcframework.git\x27, :tag => \x270.0.6\x27\n" +
|
||||
" pod \x27LiveKitWebRTC\x27, :podspec => \x27https://raw.githubusercontent.com/livekit/webrtc-xcframework/144.7559.11/LiveKitWebRTC.podspec\x27\n"
|
||||
if s =~ /target ["\x27]App["\x27] do\n/
|
||||
s = s.sub(/target ["\x27]App["\x27] do\n/) { |m| m + pin }
|
||||
File.write(p, s)
|
||||
puts "Podfile: pinned LiveKitClient 2.15.3 + LiveKitUniFFI 0.0.6 + LiveKitWebRTC 144.7559.11"
|
||||
else
|
||||
STDERR.puts "WARN: could not find \"target App do\" in Podfile — LiveKit pin NOT applied"
|
||||
exit 1
|
||||
end
|
||||
'
|
||||
fi
|
||||
grep -n "LiveKitClient" "$PODFILE" || true
|
||||
# `npx cap sync` already ran `pod install` with the PRE-pin Podfile, leaving a Podfile.lock that pins
|
||||
# LiveKitClient = 2.0.18 — which conflicts with the git-tag source we just injected ("could not find
|
||||
# compatible versions … In snapshot (Podfile.lock): LiveKitClient (= 2.0.18)"). Drop the lock so the
|
||||
# later "Install CocoaPods" step re-resolves cleanly against tag 2.15.3.
|
||||
rm -f "mobile/ios/App/Podfile.lock" && echo "Removed stale Podfile.lock (cap-sync pinned 2.0.18)"
|
||||
fi
|
||||
# ── LiveKit under SPM (Capacitor 8) ─────────────────────────────────────────────────────────────────
|
||||
# LiveKit is now a proper Swift Package Manager dependency declared in the native-call plugin's Package.swift
|
||||
# (github.com/livekit/client-sdk-swift, exact 2.15.3) — SPM resolves it + its WebRTC/UniFFI/SwiftProtobuf
|
||||
# sub-packages at build time. So there is NO Podfile to patch here anymore (the old CocoaPods git-tag pin
|
||||
# hack is gone).
|
||||
|
||||
echo "Info.plist patched:"
|
||||
"$PB" -c "Print :NSCameraUsageDescription" "$PLIST"
|
||||
"$PB" -c "Print :NSMicrophoneUsageDescription" "$PLIST"
|
||||
"$PB" -c "Print :NSSpeechRecognitionUsageDescription" "$PLIST"
|
||||
|
||||
@@ -32,21 +32,25 @@ async function meetingContext(room) {
|
||||
async 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];
|
||||
// CLAIM the subscriber(s) SYNCHRONOUSLY (before any await). Two concurrent finalize calls for the SAME uid —
|
||||
// e.g. the same user's two devices both leaving at once (#12 multi-device) — would otherwise both pass the
|
||||
// membership check during the awaits below and write the transcript TWICE (the "transcript shows two times"
|
||||
// bug). subs.delete() returns true only for the first caller, so the loser claims nothing and writes nothing.
|
||||
const candidates = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
|
||||
const ids = candidates.filter((uid) => subs.delete(uid));
|
||||
if (ids.length && buf.length) {
|
||||
const ctx = await 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 = await R.users.byId(uid); } catch (_) {}
|
||||
if (!user) { subs.delete(uid); continue; }
|
||||
if (!user) 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).
|
||||
await 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
|
||||
}
|
||||
|
||||
@@ -67,7 +71,7 @@ async function startGroupCall(group, teamId, user) {
|
||||
if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true };
|
||||
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
||||
meetingRooms.set(room, new Map());
|
||||
const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
|
||||
const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: user.id, startedByName: user.name || user.email, left: new Set() };
|
||||
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
|
||||
try { const hid = A.id(); await 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
|
||||
@@ -103,7 +107,7 @@ async function startDmCall(me, otherId, teamId) {
|
||||
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, uuid: crypto.randomUUID(), startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false };
|
||||
const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false, left: new Set() };
|
||||
// Log to history (both participants) so the call shows under Past meetings with its transcript.
|
||||
try { const hid = A.id(); await 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
|
||||
@@ -192,7 +196,9 @@ async function replayActiveCalls(userId, ws) {
|
||||
for (const [, call] of dmCalls) {
|
||||
if (call.answered) continue;
|
||||
if (call.users.includes(userId) && call.startedBy !== userId) {
|
||||
try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, uuid: call.uuid, with: call.startedBy, by: call.startedBy, byName: call.startedByName })); } catch (_) {}
|
||||
// #New1: if this user already LEFT the call, don't ring them back in (noRing) — just refresh the "Join" state.
|
||||
const noRing = !!(call.left && call.left.has(userId));
|
||||
try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, uuid: call.uuid, with: call.startedBy, by: call.startedBy, byName: call.startedByName, noRing })); } catch (_) {}
|
||||
}
|
||||
}
|
||||
for (const [group, call] of groupCalls) {
|
||||
@@ -200,11 +206,24 @@ async function replayActiveCalls(userId, ws) {
|
||||
let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {}
|
||||
if (!member) continue;
|
||||
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
|
||||
try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, uuid: call.uuid, by: call.startedBy, startedByName: call.startedByName, groupName: gName })); } catch (_) {}
|
||||
const noRing = !!(call.left && call.left.has(userId)); // #New1: left already → refresh Join, don't re-ring
|
||||
try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, uuid: call.uuid, by: call.startedBy, startedByName: call.startedByName, groupName: gName, noRing })); } catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// #New1: a participant who EXPLICITLY leaves an active (still-running) call must not be auto-rung back into
|
||||
// it. We remember who left per call; replayActiveCalls (on their next socket reconnect) then sends the call
|
||||
// state with noRing:true so their client refreshes the passive "Join" affordance without ringing / popping
|
||||
// CallKit again. Before this, every reconnect (constant on mobile) re-rang the leaver until the call ended.
|
||||
function callForRoom(room) {
|
||||
const gid = roomToGroupCall.get(room); if (gid) { const c = groupCalls.get(gid); if (c) return c; }
|
||||
const key = roomToDmCall.get(room); if (key) { const c = dmCalls.get(key); if (c) return c; }
|
||||
return null;
|
||||
}
|
||||
function markLeft(room, userId) { if (!userId) return; const c = callForRoom(room); if (c) { if (!c.left) c.left = new Set(); c.left.add(userId); } }
|
||||
function clearLeft(room, ids) { const c = callForRoom(room); if (c && c.left) { for (const id of (ids || [])) c.left.delete(id); } } // an explicit re-invite should ring again
|
||||
|
||||
// Called from signaling when any mesh room empties.
|
||||
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
|
||||
|
||||
@@ -235,4 +254,40 @@ async function declineDmCall(room, byUser) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, finalizeTranscript, meetingContext, fmtDur, pairKey };
|
||||
// #15: adding a 3rd person to a 1:1 (DM) call turns it into a real, PERSISTENT group call. Two wins:
|
||||
// (1) the call now survives anyone leaving (a group call only ends when the room empties), and
|
||||
// (2) an added person who drops can rejoin — they're a group member, so the group's active-call banner
|
||||
// (replayActiveCalls / group-call) reappears for them.
|
||||
// The SAME room/uuid/startedAt are kept, so live media + the transcript continue uninterrupted; we just move
|
||||
// the room's bookkeeping from dmCalls → groupCalls and create the backing group conversation. Returns the new
|
||||
// group id, or null when `room` isn't a DM call (a group/ad-hoc call needs no promotion — caller invites as usual).
|
||||
async function promoteDmToGroup(room, inviter, inviteeIds) {
|
||||
const key = roomToDmCall.get(room); if (!key) return null;
|
||||
const call = dmCalls.get(key); if (!call) return null;
|
||||
const teamId = call.teamId || (inviter && inviter.team_id);
|
||||
const memberIds = [...new Set([...(call.users || []), ...(inviteeIds || [])])].filter(Boolean);
|
||||
if (memberIds.length < 3) return null; // nothing new actually added → stay a 1:1
|
||||
// Friendly name from participant first-names (e.g. "Ravi, Sara, Alex"), capped so it doesn't run long.
|
||||
const names = [];
|
||||
for (const uid of memberIds) { let usr = null; try { usr = await R.users.byId(uid); } catch (_) {} if (usr) names.push(((usr.name || usr.email || '').trim().split(/\s+/)[0]) || usr.email || 'Someone'); }
|
||||
const groupName = (names.slice(0, 4).join(', ') + (names.length > 4 ? ' +' + (names.length - 4) : '')) || 'Group call';
|
||||
const owner = call.startedBy || (inviter && inviter.id);
|
||||
const gid = A.id();
|
||||
await R.conversations.create({ id: gid, teamId, name: groupName, createdBy: owner });
|
||||
for (const uid of memberIds) { try { await R.conversations.addMember(gid, uid, uid === owner); } catch (_) {} }
|
||||
// Migrate the LIVE call: DM → group (same room/uuid/history so media, transcript and Past-meetings all continue).
|
||||
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; }
|
||||
dmCalls.delete(key); roomToDmCall.delete(room);
|
||||
groupCalls.set(gid, { room, uuid: call.uuid, startedAt: call.startedAt, startedBy: owner, startedByName: call.startedByName, teamId, historyId: call.historyId, left: new Set() });
|
||||
roomToGroupCall.set(room, gid);
|
||||
postSystem(gid, teamId, '📞 ' + (call.startedByName || 'Someone') + ' turned this into a group call').catch(() => {});
|
||||
// Tell every member's client: refresh the sidebar (the new group appears) and mark the call active (banner
|
||||
// + ring the people not already in the room). Those already in the call ignore the ring (same room).
|
||||
for (const uid of memberIds) {
|
||||
try { CHAT.pushToUser(uid, { type: 'group-update', group: gid }); } catch (_) {}
|
||||
try { CHAT.pushToUser(uid, { type: 'group-call', group: gid, active: true, room, uuid: call.uuid, by: owner, startedByName: call.startedByName, groupName }); } catch (_) {}
|
||||
}
|
||||
return gid;
|
||||
}
|
||||
|
||||
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, promoteDmToGroup, markLeft, clearLeft, finalizeTranscript, meetingContext, fmtDur, pairKey };
|
||||
|
||||
@@ -70,9 +70,14 @@ async function broadcastPresence(userId) {
|
||||
if (!userId) return;
|
||||
// Carry last_seen too, so a contact going offline immediately reads "Last seen just now" instead of a
|
||||
// bare "Offline" until the next sidebar reload (#2).
|
||||
const online = isOnline(userId);
|
||||
let lastSeen = null;
|
||||
try { const u = await repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
|
||||
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: await effectiveStatus(userId), lastSeen });
|
||||
// #8: never broadcast a NULL last-seen for someone who's offline — touchSeen() is fire-and-forget on
|
||||
// disconnect, so the DB write can lag this broadcast and the client would flap to a bare "Offline". They're
|
||||
// leaving now, so "just now" is accurate.
|
||||
if (!lastSeen && !online) lastSeen = Date.now();
|
||||
const payload = JSON.stringify({ type: 'presence', userId, online, status: await effectiveStatus(userId), lastSeen });
|
||||
deliverPresenceLocal(userId, payload); // this instance
|
||||
pubsub.publish('presence', { userId, payload }); // other instances (no-op on memory)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ module.exports = {
|
||||
TRANS_DIR,
|
||||
UPLOADS_DIR,
|
||||
DOWNLOADS_DIR,
|
||||
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h access-token / cookie lifetime
|
||||
// Access-token / web-cookie lifetime. Long by design + SLID FORWARD on every /api/me (app load / focus /
|
||||
// heartbeat), so an actively-used session never lapses — you only get logged out by choosing to log out.
|
||||
// (Was 24h, which logged people out overnight.)
|
||||
SESSION_TTL: 1000 * 60 * 60 * 24 * 90, // 90d
|
||||
REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients)
|
||||
};
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
// SQLite data layer + schema.
|
||||
// Uses Node's built-in node:sqlite (no native compilation needed).
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
|
||||
const db = new DatabaseSync(process.env.DB_PATH || path.join(__dirname, 'data.db'));
|
||||
// WAL is preferred but unsupported on some mounted/network filesystems; fall back quietly.
|
||||
try { db.exec('PRAGMA journal_mode = WAL'); } catch { /* default rollback journal is fine */ }
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL REFERENCES teams(id),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
pw_hash TEXT NOT NULL,
|
||||
pw_salt TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'technician',
|
||||
mfa_secret TEXT,
|
||||
mfa_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions_auth (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
mfa_passed INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS machines (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL REFERENCES teams(id),
|
||||
name TEXT NOT NULL,
|
||||
enroll_token TEXT NOT NULL UNIQUE,
|
||||
unattended INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
team_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
user_email TEXT,
|
||||
machine_id TEXT,
|
||||
machine_name TEXT,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: optional display name for agents (shown to customers on consent)
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN name TEXT'); } catch (e) { /* already exists */ }
|
||||
|
||||
// Migration: agent active flag (deactivate without deleting)
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1'); } catch (e) { /* exists */ }
|
||||
|
||||
// Session report: one row per support session with duration
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
agent_email TEXT,
|
||||
agent_name TEXT,
|
||||
ticket TEXT,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: stored recording filename for a session (null if not recorded)
|
||||
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 */ }
|
||||
// A message can be edited by its sender; edited_at marks it (shows an "edited" label).
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN edited_at INTEGER'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN fwd_from TEXT'); } catch (e) { /* exists — original sender name when a message was forwarded (#5) */ }
|
||||
// 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 */ }
|
||||
// BizGaze person-id (s.userId): the SAME value whether the person signs in with their email or
|
||||
// their mobile number, so this — not the typed login identifier — is the stable identity key.
|
||||
// Provisioning matches on it to keep one Biz Connect account per person (#2 account merge).
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) { /* exists */ }
|
||||
// #2: when this user was last connected (stamped on connect + on their last socket closing), so contacts
|
||||
// can show "last seen 10 minutes ago" instead of a bare "Offline".
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN last_seen INTEGER'); } catch (e) { /* exists */ }
|
||||
// Backfill: the column is new, so every existing user was NULL and therefore still read as a bare
|
||||
// "Offline" until they happened to reconnect. Seed it from the last message they sent — the best
|
||||
// evidence we already have of when they were last around. Only fills rows that are still NULL.
|
||||
try {
|
||||
db.exec(`UPDATE users SET last_seen = (
|
||||
SELECT MAX(created_at) FROM messages WHERE messages.sender_id = users.id
|
||||
) WHERE last_seen IS NULL AND EXISTS (SELECT 1 FROM messages WHERE messages.sender_id = users.id)`);
|
||||
} catch (e) { /* messages table may not exist yet on a fresh db */ }
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id)'); } catch (e) { /* exists */ }
|
||||
// When two accounts merge (#2), the merged-away row is deleted. This records old_id -> survivor so
|
||||
// any lingering reference to the old id (a cached contact, an in-flight DM) resolves to the survivor
|
||||
// instead of hitting a deleted user (which made messages to merged contacts silently vanish).
|
||||
// #7: a log of finished CALLS (ad-hoc / group / 1:1). Scheduled meetings already have their own row, so
|
||||
// they're not duplicated here. `peak` is the most people who were in the room at once — that's what lets
|
||||
// "Past meetings" show a call that grew past 2 people while hiding plain 1:1s.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS call_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
group_id TEXT,
|
||||
kind TEXT,
|
||||
title TEXT,
|
||||
peak INTEGER NOT NULL DEFAULT 0,
|
||||
participants TEXT,
|
||||
uids TEXT,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER NOT NULL
|
||||
)`);
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_call_history_team ON call_history(team_id, ended_at)'); } catch (e) {}
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_aliases (
|
||||
old_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
team_id TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
)`);
|
||||
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 */ }
|
||||
// External (non-Connect) invitee emails on a scheduled meeting (#4) — JSON array. They get an emailed
|
||||
// guest join link instead of an in-app invite. (Must come AFTER the CREATE above — on a fresh DB these
|
||||
// ALTERs previously ran before the table existed and were silently lost.)
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN guest_emails TEXT'); } catch (e) { /* exists */ }
|
||||
// Lobby (#4): 1 = guests joining by link must be admitted by the host; 0 = they join directly. NULL is
|
||||
// treated as "require approval" (safe default) by the signaling layer.
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN lobby INTEGER'); } 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);
|
||||
`);
|
||||
|
||||
// App installs (desktop/mobile clients): one row per install, associated with the user once
|
||||
// they sign in. Lets admins see who installed the app, which version, and when it was last used.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS app_installs (
|
||||
id TEXT PRIMARY KEY,
|
||||
install_id TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT,
|
||||
user_email TEXT,
|
||||
tenant_id TEXT,
|
||||
platform TEXT,
|
||||
app_version TEXT,
|
||||
os TEXT,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_installs_tenant ON app_installs(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_installs_user ON app_installs(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;
|
||||
@@ -1,56 +0,0 @@
|
||||
// One-shot data migration: copy every row from the SQLite data.db into Postgres. Run ONCE at cutover,
|
||||
// with the app stopped, BEFORE switching DB_BACKEND to pg.
|
||||
//
|
||||
// DB_PATH=/data/data.db DATABASE_URL=postgres://user:pass@host/db node db/migrate-sqlite-to-pg.js
|
||||
//
|
||||
// It applies the Postgres schema first, TRUNCATEs the target tables (so a re-run re-copies cleanly), then
|
||||
// bulk-inserts in FK-dependency order. audit_log.id is a GENERATED identity, so its id is not copied (PG
|
||||
// assigns fresh ones — nothing references audit_log.id). Timestamps/flags are plain integers on both sides.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const SQLITE = process.env.DB_PATH || path.join(__dirname, '..', 'data.db');
|
||||
if (!process.env.DATABASE_URL) { console.error('DATABASE_URL is required'); process.exit(1); }
|
||||
|
||||
const src = new DatabaseSync(SQLITE);
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
|
||||
|
||||
// Parents before children (users→teams, sessions_auth→users, machines→teams); the rest have no FKs.
|
||||
const ORDER = [
|
||||
'teams', 'users', 'machines', 'sessions_auth', 'audit_log', 'sessions_log', 'refresh_tokens',
|
||||
'api_keys', 'webhooks', 'messages', 'message_reactions', 'attachments', 'conversations',
|
||||
'conversation_members', 'call_history', 'user_aliases', 'polls', 'poll_votes', 'scheduled_meetings',
|
||||
'recordings', 'push_subscriptions', 'device_tokens', 'app_installs', 'favorites',
|
||||
];
|
||||
|
||||
async function main() {
|
||||
await pool.query(fs.readFileSync(path.join(__dirname, 'schema.pg.sql'), 'utf8')); // ensure schema exists
|
||||
await pool.query('TRUNCATE ' + ORDER.map((t) => '"' + t + '"').join(', ') + ' RESTART IDENTITY CASCADE');
|
||||
|
||||
const totals = {};
|
||||
for (const table of ORDER) {
|
||||
let rows = [];
|
||||
try { rows = src.prepare('SELECT * FROM ' + table).all(); } catch (e) { totals[table] = 'skip(' + e.message + ')'; continue; }
|
||||
if (!rows.length) { totals[table] = 0; continue; }
|
||||
let cols = Object.keys(rows[0]);
|
||||
if (table === 'audit_log') cols = cols.filter((c) => c !== 'id'); // GENERATED — let PG assign
|
||||
const colList = cols.map((c) => '"' + c + '"').join(',');
|
||||
const CHUNK = 400; // keep param count well under Postgres' 65535 limit even for wide tables
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const batch = rows.slice(i, i + CHUNK);
|
||||
const values = []; const params = [];
|
||||
batch.forEach((r, ri) => {
|
||||
values.push('(' + cols.map((c, ci) => '$' + (ri * cols.length + ci + 1)).join(',') + ')');
|
||||
cols.forEach((c) => params.push(r[c] === undefined ? null : r[c]));
|
||||
});
|
||||
await pool.query('INSERT INTO "' + table + '" (' + colList + ') VALUES ' + values.join(','), params);
|
||||
}
|
||||
totals[table] = rows.length;
|
||||
}
|
||||
console.log('MIGRATED rows:', JSON.stringify(totals, null, 0));
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('MIGRATION FAILED:', e && e.message); process.exit(1); });
|
||||
@@ -1,6 +1,6 @@
|
||||
// PostgreSQL backend for the async DB adapter. Same interface as db/sqlite.js — prepare(sql).{get,all,run},
|
||||
// exec(sql), tx(fn), init() — so repos and app code are engine-agnostic. Selected by DB_BACKEND=pg;
|
||||
// connection string from DATABASE_URL.
|
||||
// PostgreSQL backend for the async DB adapter — the ONLY backend (SQLite retired 2026-08-12). Implements
|
||||
// prepare(sql).{get,all,run}, exec(sql), tx(fn), init() so repos/app code stay engine-agnostic (the facade
|
||||
// in dbx.js keeps the door open for future backends). Connection string from DATABASE_URL.
|
||||
const { Pool, types } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -129,7 +129,9 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
msg_type TEXT,
|
||||
deleted SMALLINT NOT NULL DEFAULT 0,
|
||||
edited_at BIGINT,
|
||||
fwd_from TEXT
|
||||
fwd_from TEXT,
|
||||
pinned_at BIGINT, -- #13 pin a message
|
||||
pinned_by TEXT -- #13
|
||||
);
|
||||
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);
|
||||
@@ -137,6 +139,21 @@ CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, crea
|
||||
-- New: attachment lookups drove the /files auth scan (see static.js authAttachment). Index it so the
|
||||
-- per-Range playback auth is a keyed lookup, not a table scan (the auth cache stays as a second line).
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_attachment ON messages(attachment_id);
|
||||
-- Columns/tables added AFTER the initial PG cutover. The CREATE TABLE above only applies to a FRESH
|
||||
-- database (IF NOT EXISTS is a no-op once the table exists), so add these idempotently for the existing
|
||||
-- production table too. Safe to run on every boot. (Unlike the up-front rule at the top of this file, a
|
||||
-- post-cutover column MUST also be ALTER-ed in — otherwise it silently never lands on the live DB.)
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned_at BIGINT; -- #13
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned_by TEXT; -- #13
|
||||
|
||||
-- #18 "Delete for me": a per-user hide. The message row is untouched (everyone else still sees it); this
|
||||
-- records that THIS user removed it from their own threads + sidebar.
|
||||
CREATE TABLE IF NOT EXISTS message_hidden (
|
||||
message_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
hidden_at BIGINT,
|
||||
PRIMARY KEY (message_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
message_id TEXT NOT NULL,
|
||||
@@ -146,6 +163,31 @@ CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
PRIMARY KEY (message_id, user_id, emoji)
|
||||
);
|
||||
|
||||
-- UGC moderation (App Store Review guideline 1.2): report a message + block a user.
|
||||
-- Reports are workspace-internal — surfaced to the tenant's admins, who can delete the message / act.
|
||||
CREATE TABLE IF NOT EXISTS message_reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
reporter_id TEXT NOT NULL,
|
||||
reported_id TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
snippet TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_team ON message_reports(team_id, created_at);
|
||||
|
||||
-- A one-directional block: blocker no longer receives the blocked user's messages or calls.
|
||||
CREATE TABLE IF NOT EXISTS user_blocks (
|
||||
blocker_id TEXT NOT NULL,
|
||||
blocked_id TEXT NOT NULL,
|
||||
team_id TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (blocker_id, blocked_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_blocker ON user_blocks(blocker_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// SQLite backend for the async DB adapter (dev + tests; also the current prod engine until pg cutover).
|
||||
//
|
||||
// Wraps the synchronous node:sqlite instance (schema applied at load in ../db.js) in the async interface
|
||||
// the repos call. Results are returned via resolved Promises, so the SAME repo code runs unchanged on this
|
||||
// synchronous engine and on asynchronous Postgres — the app never sees the difference.
|
||||
const raw = require('../db'); // DatabaseSync instance with the full schema already applied
|
||||
|
||||
// node:sqlite re-prepares cheaply, but caching by SQL text avoids re-parsing on hot paths.
|
||||
const cache = new Map();
|
||||
function stmt(sql) {
|
||||
let s = cache.get(sql);
|
||||
if (!s) { s = raw.prepare(sql); cache.set(sql, s); }
|
||||
return s;
|
||||
}
|
||||
const num = (v) => (typeof v === 'bigint' ? Number(v) : v);
|
||||
const runResult = (r) => ({ changes: num(r.changes), lastInsertRowid: num(r.lastInsertRowid) });
|
||||
|
||||
function prepare(sql) {
|
||||
return {
|
||||
get: (...p) => Promise.resolve(stmt(sql).get(...p)),
|
||||
all: (...p) => Promise.resolve(stmt(sql).all(...p)),
|
||||
run: (...p) => Promise.resolve(runResult(stmt(sql).run(...p))),
|
||||
};
|
||||
}
|
||||
|
||||
function exec(sql) { raw.exec(sql); return Promise.resolve(); }
|
||||
|
||||
// Transaction primitive. SQLite is single-connection, so BEGIN/COMMIT/ROLLBACK on `raw` is safe; the
|
||||
// callback gets a runner with the same async run/get shape. (The pg backend implements this on ONE pooled
|
||||
// client — the reason repos must use tx() rather than bare exec('BEGIN') for multi-statement atomicity.)
|
||||
async function tx(fn) {
|
||||
raw.exec('BEGIN');
|
||||
try {
|
||||
const t = {
|
||||
run: (sql, ...p) => Promise.resolve(runResult(stmt(sql).run(...p))),
|
||||
get: (sql, ...p) => Promise.resolve(stmt(sql).get(...p)),
|
||||
all: (sql, ...p) => Promise.resolve(stmt(sql).all(...p)),
|
||||
};
|
||||
const out = await fn(t);
|
||||
raw.exec('COMMIT');
|
||||
return out;
|
||||
} catch (e) {
|
||||
try { raw.exec('ROLLBACK'); } catch (_) {}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function init() { return Promise.resolve(); } // schema already applied synchronously in ../db.js
|
||||
|
||||
module.exports = { prepare, exec, tx, init, name: 'sqlite', _raw: raw };
|
||||
@@ -1,6 +1,10 @@
|
||||
// Async DB adapter facade. The backend is chosen by DB_BACKEND (default 'sqlite'); 'pg' is added at
|
||||
// cutover. Every backend implements the same async interface — prepare(sql).{get,all,run}, exec(sql),
|
||||
// tx(fn), init() — so repos and app code are engine-agnostic. Swapping engines is one backend file, no
|
||||
// repo changes. (This is the same "never hardwire the engine" principle we'll apply to the pub/sub layer.)
|
||||
const name = process.env.DB_BACKEND || 'sqlite';
|
||||
// Async DB adapter facade. Production runs PostgreSQL. SQLite was RETIRED on 2026-08-12 so there is exactly
|
||||
// ONE schema source of truth (db/schema.pg.sql) — no more dual-maintenance drift between a SQLite migration
|
||||
// list and the PG schema (that gap once made a column land on SQLite only and 500'd every read on prod).
|
||||
//
|
||||
// DB_BACKEND is kept for future swappable backends but defaults to 'pg', and only 'pg' ships today. An
|
||||
// unknown value fails LOUDLY here at require time (module-not-found) rather than silently selecting a stale
|
||||
// or non-existent engine. Every backend implements the same async interface: prepare(sql).{get,all,run},
|
||||
// exec(sql), tx(fn), init().
|
||||
const name = process.env.DB_BACKEND || 'pg';
|
||||
module.exports = require('./db/' + name);
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"name": "bizgaze-support-server",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"pg": "^8.13.1",
|
||||
"redis": "^4.7.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
@@ -18,6 +20,65 @@
|
||||
"nodemailer": "^6.9.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
||||
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/client": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
"yallist": "4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/graph": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
|
||||
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/json": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
|
||||
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/search": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
|
||||
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/time-series": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
|
||||
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -51,6 +112,15 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -77,6 +147,15 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/generic-pool": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
||||
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
@@ -157,6 +236,151 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.23.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.16.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
||||
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
||||
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@redis/bloom": "1.2.0",
|
||||
"@redis/client": "1.6.1",
|
||||
"@redis/graph": "1.1.1",
|
||||
"@redis/json": "1.0.7",
|
||||
"@redis/search": "1.2.0",
|
||||
"@redis/time-series": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -183,6 +407,15 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
@@ -220,6 +453,21 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,26 @@ const card=document.getElementById('card'), wrap=document.getElementById('wrap')
|
||||
agentChip=document.getElementById('agentChip'), bar=document.getElementById('bar'),
|
||||
topbar=document.getElementById('topbar'), video=document.getElementById('video'), barStatus=document.getElementById('barStatus');
|
||||
let ws,pc,inputChannel,chatChannel,sessionId,me=null;
|
||||
let RS_LK=false, lkRoom=null; // iOS-shared session: view the screen over LiveKit instead of P2P
|
||||
// Load the LiveKit browser SDK on demand (same vendored build the meeting UI uses).
|
||||
function sfuLoadLib(){ return new Promise((res,rej)=>{ if(window.LivekitClient) return res(window.LivekitClient); const s=document.createElement('script'); s.src='/vendor/livekit-client.umd.min.js'; s.onload=()=>res(window.LivekitClient); s.onerror=()=>rej(new Error('livekit sdk failed to load')); document.head.appendChild(s); }); }
|
||||
// The customer is on iPhone (WKWebView can't getDisplayMedia), so they publish their screen over LiveKit.
|
||||
// Join that room and show the screen in the SAME viewer we use for P2P (recording/chat/controls unchanged).
|
||||
async function startLiveKitView(room){
|
||||
const statusEl=document.getElementById('status');
|
||||
if(statusEl){ statusEl.className='status'; statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Connecting to the shared screen…'; }
|
||||
try{
|
||||
const LK=await sfuLoadLib();
|
||||
const tk=await fetch('/api/meetings/token',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({room})}).then(r=>r.json());
|
||||
if(!tk||!tk.token) throw new Error('no token');
|
||||
const r=new LK.Room({adaptiveStream:false,dynacast:false}); lkRoom=r;
|
||||
const showVideo=(mst)=>{ video.srcObject=new MediaStream([mst]); if(typeof wrap!=='undefined'&&wrap) wrap.style.display='none'; if(typeof topbar!=='undefined'&&topbar) topbar.style.display='none'; video.style.display='block'; try{ video.play(); }catch(_){}; try{ video.focus(); }catch(_){}; buildBar(); };
|
||||
const attach=(track)=>{ if(!track) return; const mst=track.mediaStreamTrack; if(track.kind==='video'){ showVideo(mst); } else { let a=document.getElementById('remoteAudio'); if(!a){ a=document.createElement('audio'); a.id='remoteAudio'; a.autoplay=true; document.body.appendChild(a); } a.srcObject=new MediaStream([mst]); } };
|
||||
r.on(LK.RoomEvent.TrackSubscribed,(track)=>attach(track));
|
||||
await r.connect(tk.url, tk.token);
|
||||
try{ r.remoteParticipants.forEach((p)=>{ p.trackPublications.forEach((pub)=>{ if(pub.track) attach(pub.track); }); }); }catch(_){}
|
||||
}catch(e){ if(statusEl){ statusEl.className='status err'; statusEl.textContent='Could not connect to the shared screen.'; } }
|
||||
}
|
||||
|
||||
async function api(path,body,method='POST'){
|
||||
const opt={method,headers:{'Content-Type':'application/json'}};
|
||||
@@ -191,6 +211,8 @@ function connectWS(){
|
||||
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;
|
||||
case 'rs-livekit': RS_LK=true; startLiveKitView(m.room); break; // iOS customer shares over LiveKit — view it there
|
||||
case 'rs-chat': if(m.msg) addChat({from:'other',name:m.msg.name||'Customer',text:m.msg.text}); break; // chat over the socket in LiveKit mode
|
||||
case 'transcript': if(recogActive&&m.text) addLine('customer', m.name||'Customer', m.text, !!m.chat); break;
|
||||
case 'session-denied': renderEnded('The customer declined the request.'); break;
|
||||
case 'session-ended': {
|
||||
@@ -213,6 +235,7 @@ function renderWaiting(){
|
||||
|
||||
function renderEnded(msg){
|
||||
bzcSession(false);
|
||||
try{ if(lkRoom){ lkRoom.disconnect(); lkRoom=null; } }catch(_){} // tear down the LiveKit view (symmetric disconnect)
|
||||
try{ stopRecording(); }catch(_){}
|
||||
removeSessionUI();
|
||||
document.body.classList.remove('has-bar');
|
||||
@@ -385,7 +408,10 @@ let __ac=null;
|
||||
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
|
||||
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
|
||||
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
|
||||
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';}
|
||||
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;
|
||||
if(RS_LK){ try{ ws.send(JSON.stringify({type:'rs-chat',sessionId,msg:{name:(me&&(me.name||me.email))||'Support agent',text:t}})); }catch(_){} }
|
||||
else if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}
|
||||
addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';}
|
||||
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
|
||||
|
||||
async function setupPeer(){
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
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"/>',
|
||||
eyeOff: '<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><path d="m2 2 20 20"/>',
|
||||
pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
|
||||
pinOff: '<path d="M12 17v5"/><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89"/><path d="m2 2 20 20"/><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11"/>',
|
||||
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"/>',
|
||||
@@ -63,6 +66,8 @@
|
||||
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"/>',
|
||||
externalLink:'<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6"/>',
|
||||
switchCamera:'<path d="M11 19H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/><path d="M13 5h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-5"/><circle cx="12" cy="12" r="3"/><path d="m18 22-3-3 3-3"/><path d="m6 2 3 3-3 3"/>',
|
||||
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"/>',
|
||||
@@ -70,6 +75,8 @@
|
||||
bluetooth: '<path d="m7 7 10 10-5 5V2l5 5L7 17"/>',
|
||||
speaker: '<path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a9 9 0 0 1 0 14"/>',
|
||||
speakerOff: '<path d="M11 5 6 9H2v6h4l5 4z"/><line x1="22" y1="9" x2="16" y2="15"/><line x1="16" y1="9" x2="22" y2="15"/>',
|
||||
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/>',
|
||||
ban: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
|
||||
};
|
||||
window.ICON = P;
|
||||
window.ic = function (name, size) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Privacy Policy — Biz Connect</title>
|
||||
<style>
|
||||
:root{--blue:#1F3B73;--blue-d:#16294f;--brand:#FFC708;--ink:#1f2430;--muted:#5b6472;--line:#e6e9ef;--bg:#f6f8fb;}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--ink);line-height:1.65;}
|
||||
.top{background:linear-gradient(180deg,#20396f,#16294f);color:#fff;padding:2.2rem 1.2rem;}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:0 1.2rem;}
|
||||
.brand{display:flex;align-items:center;gap:.6rem;font-weight:700;font-size:1.25rem;}
|
||||
.brand svg{width:34px;height:34px;flex:0 0 auto}
|
||||
.brand .b{color:#fff}.brand .c{color:var(--brand)}
|
||||
h1{font-size:1.5rem;margin:1rem 0 .2rem;color:#fff;}
|
||||
.top .upd{color:#c9d4ec;font-size:.9rem;margin:0;}
|
||||
main{max-width:760px;margin:0 auto;padding:1.8rem 1.2rem 3rem;}
|
||||
h2{font-size:1.12rem;color:var(--blue);margin:1.8rem 0 .5rem;border-bottom:2px solid var(--line);padding-bottom:.3rem;}
|
||||
p,li{color:#333a45;}
|
||||
ul{padding-left:1.2rem;margin:.4rem 0;}
|
||||
li{margin:.28rem 0;}
|
||||
a{color:var(--blue);}
|
||||
.note{background:#fff;border:1px solid var(--line);border-left:4px solid var(--brand);border-radius:10px;padding:.9rem 1rem;margin:1rem 0;font-size:.95rem;}
|
||||
.foot{max-width:760px;margin:0 auto;padding:1rem 1.2rem 3rem;color:var(--muted);font-size:.85rem;border-top:1px solid var(--line);}
|
||||
strong{color:var(--ink);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div class="wrap">
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><circle cx="50" cy="50" r="34" fill="none" stroke="#fff" stroke-width="9" stroke-linecap="round" stroke-dasharray="165 300" transform="rotate(38 50 50)"/><circle cx="66" cy="50" r="8.6" fill="#FFC708"/></svg>
|
||||
<span><span class="b">Biz</span> <span class="c">Connect</span></span>
|
||||
</div>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="upd">Last updated: 21 August 2026</p>
|
||||
</div>
|
||||
</div>
|
||||
<main>
|
||||
<p>Biz Connect is a business communication app — team chat, voice and video calls, and meetings — for organizations that use the BizGaze platform. This policy explains what information the app handles, how it is used, and the choices you have. By using Biz Connect you agree to this policy.</p>
|
||||
|
||||
<h2>1. Information we collect</h2>
|
||||
<ul>
|
||||
<li><strong>Account information.</strong> When you sign in with your BizGaze account, we receive your name and email address to identify you and your organization. Accounts are provisioned by your organization's administrator; Biz Connect does not offer public self-signup.</li>
|
||||
<li><strong>Content you create.</strong> Messages, group conversations, photos, videos, and files you send; and any meeting recordings or transcripts you choose to create. This content is stored on our servers so it can be delivered and shown to the people you send it to.</li>
|
||||
<li><strong>Call and meeting media.</strong> Audio and video during a call or meeting are transmitted between participants to run the call. This media is <strong>not recorded or stored</strong> unless a participant explicitly starts a recording or transcript.</li>
|
||||
<li><strong>Technical and device data.</strong> A device push token (so we can deliver notifications), and basic operational logs needed to run and secure the service.</li>
|
||||
</ul>
|
||||
|
||||
<h2>2. How we use information</h2>
|
||||
<ul>
|
||||
<li>To deliver your messages, calls, and meetings and keep them in sync across your devices.</li>
|
||||
<li>To send notifications you have enabled (new messages, incoming calls, meeting reminders).</li>
|
||||
<li>To operate, secure, troubleshoot, and improve the service.</li>
|
||||
</ul>
|
||||
<p>We do <strong>not</strong> sell your personal information, and we do <strong>not</strong> use it for advertising.</p>
|
||||
|
||||
<h2>3. Live meeting transcripts (on-device)</h2>
|
||||
<div class="note">Speech-to-text for live meeting transcripts runs <strong>on your device</strong> using Apple's on-device speech recognition. The audio is <strong>not sent to Apple or to us</strong> for that purpose — only the resulting text is added to the meeting transcript, and only when you choose to turn transcription on.</div>
|
||||
|
||||
<h2>4. Notifications</h2>
|
||||
<p>To notify you when the app is in the background or closed, we send push notifications through Apple Push Notification service (iOS) and Google Firebase Cloud Messaging (Android). We keep sensitive content out of notification text where practical.</p>
|
||||
|
||||
<h2>5. How information is shared</h2>
|
||||
<p>We share information only with service providers that help us run Biz Connect, and only as needed to provide the service:</p>
|
||||
<ul>
|
||||
<li><strong>Hosting</strong> — our application servers and database.</li>
|
||||
<li><strong>Real-time media (LiveKit)</strong> — carries call and meeting audio/video between participants.</li>
|
||||
<li><strong>Push delivery (Apple, Google)</strong> — delivers notifications to your device.</li>
|
||||
</ul>
|
||||
<p>We may also disclose information if required by law or to protect the rights, safety, and security of our users and service.</p>
|
||||
|
||||
<h2>6. Data retention</h2>
|
||||
<p>Messages and content are retained so your conversations remain available to you and your organization. Recordings and transcripts are kept until deleted. You or your organization's administrator can delete content; when an account is removed, its access ends.</p>
|
||||
|
||||
<h2>7. Security</h2>
|
||||
<p>Data is encrypted in transit using TLS, and calls use encrypted real-time transport. No method of transmission or storage is perfectly secure, but we work to protect your information with appropriate technical and organizational measures.</p>
|
||||
|
||||
<h2>8. Your rights and choices</h2>
|
||||
<p>You can request access to, correction of, or deletion of your personal data. Because accounts are managed by your organization, some requests are handled through your organization's administrator. Depending on where you live (for example, the EU/EEA under the GDPR), you may have additional rights, including the right to object to or restrict processing and to lodge a complaint with a supervisory authority. To exercise any right, contact us using the details below.</p>
|
||||
|
||||
<h2>9. Children</h2>
|
||||
<p>Biz Connect is a workplace tool intended for business use and is not directed to children.</p>
|
||||
|
||||
<h2>10. International processing</h2>
|
||||
<p>Your information may be processed in the country where our servers and service providers operate. Where required, we put appropriate safeguards in place for cross-border transfers.</p>
|
||||
|
||||
<h2>11. Changes to this policy</h2>
|
||||
<p>We may update this policy from time to time. Material changes will be reflected by updating the "Last updated" date above.</p>
|
||||
|
||||
<h2>12. Contact us</h2>
|
||||
<p>Questions or requests about privacy: <a href="mailto:support@bizgaze.com">support@bizgaze.com</a>.</p>
|
||||
</main>
|
||||
<div class="foot">© 2026 BizGaze. Biz Connect. All rights reserved.</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -106,6 +106,10 @@ 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||'');
|
||||
// iOS app: WKWebView can't getDisplayMedia. Ask the top frame (home.html) if it can publish the screen
|
||||
// natively (ReplayKit -> LiveKit). If so, we run this session over LiveKit instead of P2P.
|
||||
let NATIVE_IOS=false, RS_ROOM=null;
|
||||
try{ if(window.parent && window.parent!==window){ window.addEventListener('message',(e)=>{ if(e.origin!==location.origin) return; const d=e.data||{}; if(d.type==='bzc-native'){ NATIVE_IOS=!!d.ok; } }); window.parent.postMessage({type:'bzc-native-ping'}, location.origin); } }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=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
@@ -138,6 +142,7 @@ ws.onmessage=async(e)=>{const m=JSON.parse(e.data);switch(m.type){
|
||||
case 'answer': if(pc) await pc.setRemoteDescription(new RTCSessionDescription(m.sdp)); break;
|
||||
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
|
||||
case 'recording': recNotice(m.on); if(m.on) startCustTranscription(); else stopCustTranscription(); break;
|
||||
case 'rs-chat': if(m.msg) addChat({from:'other', name:m.msg.name||'Agent', text:m.msg.text}); break; // chat over the socket in LiveKit mode (no P2P data channel)
|
||||
case 'session-ended': endShareSession('Your support agent ended the session. Tap below for a new code if you still need help.'); break;
|
||||
case 'error': setStatus(m.message,''); break;
|
||||
}};
|
||||
@@ -169,6 +174,7 @@ function showConsent(m){
|
||||
// getDisplayMedia unless it is called from a user gesture, so this must not run
|
||||
// after a server round-trip. getDisplayMedia is called first to keep the gesture.
|
||||
async function beginCapture(){
|
||||
if(NATIVE_IOS){ return true; } // iOS: the app captures via ReplayKit on start-stream (no getDisplayMedia in WKWebView)
|
||||
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
|
||||
catch(err){ return false; }
|
||||
// Mic is OFF by default — we do NOT prompt for it here. Asking for the screen and the
|
||||
@@ -178,6 +184,18 @@ async function beginCapture(){
|
||||
return true;
|
||||
}
|
||||
async function startStreaming(){
|
||||
// iOS: publish the screen NATIVELY over LiveKit (WKWebView can't getDisplayMedia). Tell the app to start the
|
||||
// ReplayKit broadcast into a room derived from this session, and tell the agent to view over LiveKit.
|
||||
if(NATIVE_IOS){
|
||||
RS_ROOM='rs'+String(sessionId||'').replace(/[^A-Za-z0-9]/g,'').slice(0,60);
|
||||
try{ window.parent.postMessage({type:'rs-native-share', room:RS_ROOM}, location.origin); }catch(_){}
|
||||
try{ ws.send(JSON.stringify({type:'rs-livekit', sessionId, room:RS_ROOM})); }catch(_){}
|
||||
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true);
|
||||
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
|
||||
window.onbeforeunload=function(){ if(!sessionOver){ return 'Leaving this page will end your screen sharing session.'; } };
|
||||
buildBar();
|
||||
return;
|
||||
}
|
||||
// If the Allow tap already captured the screen (mobile path), reuse it.
|
||||
if(!localStream){
|
||||
await ensureIce();
|
||||
@@ -320,6 +338,7 @@ function recNotice(on){
|
||||
} else { clearInterval(recTimerInt); recTimerInt=null; if(n) n.remove(); }
|
||||
}
|
||||
function endShareSession(msgText){
|
||||
if(NATIVE_IOS){ try{ window.parent.postMessage({type:'rs-native-stop'}, location.origin); }catch(_){} } // stop the native ReplayKit broadcast + LiveKit
|
||||
try{ rcStopControl(); }catch(_){} // release remote control when the session ends
|
||||
sessionOver=true; window.onbeforeunload=null; bzcSession(false); { const hl=document.getElementById('homeLink'); if(hl) hl.style.display=''; } try{recNotice(false);stopCustTranscription();}catch(_){}
|
||||
removeSessionUI();
|
||||
@@ -330,7 +349,7 @@ function endShareSession(msgText){
|
||||
var card=document.querySelector('.panelside .card');
|
||||
if(card){ card.innerHTML='<h1 style="color:var(--blue)">Session ended</h1><div class="sub">'+esc(msgText||'The session has ended.')+'</div><button onclick="location.reload()" style="width:100%;margin-top:.4rem">Get a new code</button>'; }
|
||||
}
|
||||
function teardown(){try{rcStopControl();}catch(_){}sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
|
||||
function teardown(){if(NATIVE_IOS){try{window.parent.postMessage({type:'rs-native-stop'},location.origin);}catch(_){}}try{rcStopControl();}catch(_){}sessionOver=true;window.onbeforeunload=null;bzcSession(false);{const hl=document.getElementById('homeLink');if(hl)hl.style.display='';}try{recNotice(false);stopCustTranscription();}catch(_){}indicator.classList.remove('show');removeSessionUI();if(window.__mic){window.__mic.getTracks().forEach(t=>t.stop());window.__mic=null;}if(localStream){localStream.getTracks().forEach(t=>t.stop());localStream=null;}if(pc){pc.close();pc=null;}consentBox.innerHTML='';setStatus('Session ended. Refresh this page to get a new code.');}
|
||||
|
||||
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>';
|
||||
@@ -349,10 +368,10 @@ function buildBar(){
|
||||
const rcb=_btn('rcBtn',I('monitor'),'Remote control is OFF','#6b7280');
|
||||
const chat=_btn('chatBtn',I('chat'),'Chat','#475569');
|
||||
const end=_btn('endBtn2',I('callEnd'),'End','#dc2626');
|
||||
bar.appendChild(mic);bar.appendChild(rcb);bar.appendChild(chat);bar.appendChild(end);
|
||||
if(!NATIVE_IOS){ bar.appendChild(mic); bar.appendChild(rcb); } // iOS: two-way voice is a follow-up; remote control is impossible on iOS
|
||||
bar.appendChild(chat);bar.appendChild(end);
|
||||
document.body.appendChild(bar);
|
||||
rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); };
|
||||
updateRcBtn();
|
||||
if(!NATIVE_IOS){ rcb.onclick=()=>{ if(rcAllowed) rcStopControl(); else rcGrantControl(); }; updateRcBtn(); }
|
||||
makeBarDraggable(bar,'bzc_sharebar_pos'); // new #4: let the customer move the bar off their content
|
||||
const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+I(on?'mic':'micOff')+'</span>';mic.style.background=on?'#2563eb':'#6b7280';};
|
||||
mic.onclick=async()=>{
|
||||
@@ -391,7 +410,10 @@ let __ac=null;
|
||||
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
|
||||
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
|
||||
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
|
||||
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:SHARER_NAME,text:t}));}addChat({from:'__self',name:'You',text:t});i.value='';}
|
||||
function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;
|
||||
if(NATIVE_IOS){ try{ ws.send(JSON.stringify({type:'rs-chat',sessionId,msg:{name:SHARER_NAME,text:t}})); }catch(_){} }
|
||||
else if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:SHARER_NAME,text:t}));}
|
||||
addChat({from:'__self',name:'You',text:t});i.value='';}
|
||||
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
|
||||
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Support — Biz Connect</title>
|
||||
<style>
|
||||
:root{--blue:#1F3B73;--blue-d:#16294f;--brand:#FFC708;--ink:#1f2430;--muted:#5b6472;--line:#e6e9ef;--bg:#f6f8fb;}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--ink);line-height:1.65;}
|
||||
.top{background:linear-gradient(180deg,#20396f,#16294f);color:#fff;padding:2.2rem 1.2rem;}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:0 1.2rem;}
|
||||
.brand{display:flex;align-items:center;gap:.6rem;font-weight:700;font-size:1.25rem;}
|
||||
.brand svg{width:34px;height:34px;flex:0 0 auto}
|
||||
.brand .b{color:#fff}.brand .c{color:var(--brand)}
|
||||
h1{font-size:1.5rem;margin:1rem 0 .2rem;color:#fff;}
|
||||
.top .sub{color:#c9d4ec;font-size:.95rem;margin:0;}
|
||||
main{max-width:760px;margin:0 auto;padding:1.8rem 1.2rem 3rem;}
|
||||
h2{font-size:1.12rem;color:var(--blue);margin:1.8rem 0 .5rem;}
|
||||
p,li{color:#333a45;}
|
||||
a{color:var(--blue);}
|
||||
.card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:1.1rem 1.2rem;margin:1rem 0;box-shadow:0 4px 14px rgba(20,30,60,.05);}
|
||||
.cta{display:inline-block;background:var(--blue);color:#fff;text-decoration:none;font-weight:600;padding:.7rem 1.1rem;border-radius:10px;margin-top:.4rem;}
|
||||
.faq b{display:block;color:var(--ink);margin-top:.8rem;}
|
||||
.foot{max-width:760px;margin:0 auto;padding:1rem 1.2rem 3rem;color:var(--muted);font-size:.85rem;border-top:1px solid var(--line);}
|
||||
ul{padding-left:1.2rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div class="wrap">
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><circle cx="50" cy="50" r="34" fill="none" stroke="#fff" stroke-width="9" stroke-linecap="round" stroke-dasharray="165 300" transform="rotate(38 50 50)"/><circle cx="66" cy="50" r="8.6" fill="#FFC708"/></svg>
|
||||
<span><span class="b">Biz</span> <span class="c">Connect</span></span>
|
||||
</div>
|
||||
<h1>Support</h1>
|
||||
<p class="sub">Help with team chat, calls, and meetings on Biz Connect.</p>
|
||||
</div>
|
||||
</div>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Contact us</h2>
|
||||
<p>Need help, found a bug, or have a question? Email our team and we'll get back to you.</p>
|
||||
<a class="cta" href="mailto:support@bizgaze.com">Email support@bizgaze.com</a>
|
||||
</div>
|
||||
|
||||
<h2>About Biz Connect</h2>
|
||||
<p>Biz Connect keeps your team connected with chat, voice and video calls, and meetings in one place. It's for organizations that use the BizGaze platform — sign in with your BizGaze account to get started.</p>
|
||||
|
||||
<h2>Frequently asked</h2>
|
||||
<div class="faq">
|
||||
<b>How do I sign in?</b>
|
||||
<p>Open the app and sign in with your BizGaze account. Accounts are created by your organization's administrator; if you can't sign in, contact your admin or email us above.</p>
|
||||
|
||||
<b>How do I start a call or meeting?</b>
|
||||
<p>Open a conversation and tap the call button, or use the Meetings tab to start instantly or join a scheduled meeting by its code.</p>
|
||||
|
||||
<b>How do live transcripts work?</b>
|
||||
<p>In a meeting, turn on the live transcript. Speech-to-text runs on your device; only the resulting text is saved to the transcript.</p>
|
||||
|
||||
<b>How do I report a message or block someone?</b>
|
||||
<p>Press and hold (or use the ⋮ menu on) any message to Report it or Block the sender. You can also block a contact from their profile, and manage blocked users from your profile menu.</p>
|
||||
|
||||
<b>How do I delete my account or data?</b>
|
||||
<p>Accounts are managed by your organization. To delete your account or data, contact your organization's administrator or email <a href="mailto:support@bizgaze.com">support@bizgaze.com</a>.</p>
|
||||
</div>
|
||||
|
||||
<h2>Privacy</h2>
|
||||
<p>Read how we handle your data in our <a href="/privacy">Privacy Policy</a>.</p>
|
||||
</main>
|
||||
<div class="foot">© 2026 BizGaze. Biz Connect. All rights reserved.</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -30,7 +30,14 @@ if (webpush && PUBLIC && PRIVATE) {
|
||||
// ---------------- FCM (Android), HTTP v1 ----------------
|
||||
let fcmSA = null; // { client_email, private_key, project_id }
|
||||
(function loadFcm() {
|
||||
const raw = process.env.FCM_SERVICE_ACCOUNT;
|
||||
// FCM_SERVICE_ACCOUNT = inline JSON or a file path. FCM_SERVICE_ACCOUNT_B64 = base64 of the JSON — the
|
||||
// preferred way to put the service-account key in .env, since it's a single env-safe token (no quotes,
|
||||
// spaces, or newlines to break env_file/compose interpolation).
|
||||
let raw = process.env.FCM_SERVICE_ACCOUNT || '';
|
||||
if (!raw && process.env.FCM_SERVICE_ACCOUNT_B64) {
|
||||
try { raw = Buffer.from(process.env.FCM_SERVICE_ACCOUNT_B64, 'base64').toString('utf8'); }
|
||||
catch (e) { console.warn('[push] FCM_SERVICE_ACCOUNT_B64 decode failed:', e.message); }
|
||||
}
|
||||
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); }
|
||||
@@ -179,6 +186,10 @@ console.log(enabled.length ? '[push] enabled: ' + enabled.join(', ') : '[push] d
|
||||
|
||||
function isEnabled() { return webReady; } // Web Push specifically (drives /api/push/vapid)
|
||||
function publicKey() { return webReady ? PUBLIC : ''; }
|
||||
// Whether Android FCM is configured on the server. The Android app must NOT call PushNotifications.register()
|
||||
// unless Firebase is set up (client google-services.json + this) — otherwise it throws "Default FirebaseApp is
|
||||
// not initialized", an uncaught NATIVE crash. The web uses this flag to gate Android push registration.
|
||||
function fcmReady() { return !!fcmSA; }
|
||||
|
||||
// Fire-and-forget to every channel the user has: Web Push subscriptions + native device
|
||||
// tokens. Dead endpoints/tokens are pruned. Never throws.
|
||||
@@ -207,4 +218,4 @@ async function sendToUser(userId, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification, sendCallCancel };
|
||||
module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification, sendCallCancel, fcmReady };
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// group members, and invited participants. Runs on a 60s tick; marks each meeting reminded.
|
||||
const R = require('./repos');
|
||||
const CHAT = require('./chat');
|
||||
const PUSH = require('./push'); // native/web background push so a CLOSED app still gets the reminder
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
@@ -13,7 +14,8 @@ async function tick() {
|
||||
invited.forEach((id) => recipients.add(id));
|
||||
if (s.group_id) { try { (await 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 (_) {} });
|
||||
recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} }); // open tab
|
||||
recipients.forEach((uid) => { try { PUSH.sendToUser(uid, { title: 'Meeting starting soon', body: (s.title || 'Your meeting') + ' starts in ~10 minutes', kind: 'meeting', id: s.room_code, tag: 'meet:' + s.room_code }); } catch (_) {} }); // closed app (iOS APNs etc.)
|
||||
await R.scheduledMeetings.markReminded(s.id);
|
||||
}
|
||||
} catch (_) { /* never let the timer die */ }
|
||||
|
||||
@@ -38,7 +38,10 @@ const users = {
|
||||
byBizgazeId: (bizId) => (bizId ? db.prepare('SELECT * FROM users WHERE bizgaze_user_id=?').get(String(bizId)) : undefined),
|
||||
setBizgazeId: (id, bizId) => db.prepare('UPDATE users SET bizgaze_user_id=? WHERE id=?').run(bizId != null ? String(bizId) : null, id),
|
||||
listByTenant: (tenantId) =>
|
||||
db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId),
|
||||
// last_seen + status MUST be selected: the contacts/conversations DTOs read x.last_seen / x.status. Omitting
|
||||
// them made every list payload carry lastSeen:null (and status:'active'), so a fresh load showed a bare
|
||||
// "Offline" with no time — last-seen only appeared via live presence events (which read the full row).
|
||||
db.prepare('SELECT id,email,name,role,active,avatar_url,created_at,last_seen,status FROM users WHERE team_id=?').all(tenantId),
|
||||
inTenant: (id, tenantId) =>
|
||||
db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId),
|
||||
create: async ({ tenantId, email, hash, salt, role, name, mfaSecret }) => {
|
||||
@@ -51,6 +54,8 @@ const users = {
|
||||
enableMfa: (id) => db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(id),
|
||||
setName: (id, name) => db.prepare('UPDATE users SET name=? WHERE id=?').run(name, id),
|
||||
setRole: (id, role) => db.prepare('UPDATE users SET role=? WHERE id=?').run(role, id),
|
||||
// Workspace admins (for routing UGC reports to a moderator).
|
||||
adminsOf: async (tenantId) => (await db.prepare("SELECT id FROM users WHERE team_id=? AND role='admin'").all(tenantId)).map((r) => r.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),
|
||||
@@ -113,6 +118,7 @@ const authSessions = {
|
||||
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,?,?,?)')
|
||||
.run(token, userId, mfaPassed ? 1 : 0, now(), now() + ttl),
|
||||
markMfaPassed: (token) => db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(token),
|
||||
touch: (token, ttl) => db.prepare('UPDATE sessions_auth SET expires_at=? WHERE token=?').run(now() + ttl, token), // slide the expiry forward on activity
|
||||
deleteByToken: (token) => db.prepare('DELETE FROM sessions_auth WHERE token=?').run(token),
|
||||
deleteByUser: (userId) => db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(userId),
|
||||
};
|
||||
@@ -205,6 +211,35 @@ const messages = {
|
||||
editBody: (id, body) => db.prepare('UPDATE messages SET body=?, edited_at=? WHERE id=?').run(body, 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),
|
||||
// #18 Delete-for-me: hide a message from ONE user's view (the row + everyone else are untouched).
|
||||
hideForUser: (messageId, userId) => db.prepare('INSERT INTO message_hidden (message_id,user_id,hidden_at) VALUES (?,?,?) ON CONFLICT(message_id,user_id) DO NOTHING').run(messageId, userId, now()),
|
||||
// "Delete chat" (self-only): hide the ENTIRE DM thread with one peer from a single user's view. Bulk-inserts
|
||||
// message_hidden rows for every message of the pair, reusing the exact filters the thread + conversation list
|
||||
// already apply (they skip message_hidden), so the chat disappears for this user while the peer keeps their
|
||||
// copy. New messages afterwards start a fresh thread (they aren't hidden) — WhatsApp-style.
|
||||
hideThreadForUser: (teamId, userId, peerId) => db.prepare(
|
||||
`INSERT INTO message_hidden (message_id,user_id,hidden_at)
|
||||
SELECT id, ?, ? FROM messages
|
||||
WHERE team_id=? AND conversation_id IS NULL
|
||||
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
|
||||
ON CONFLICT (message_id,user_id) DO NOTHING`
|
||||
).run(userId, now(), teamId, userId, peerId, peerId, userId),
|
||||
// "Delete chat" (self-only) for a GROUP conversation: hide every message from one member's view (they stay a
|
||||
// member; new messages arrive as a fresh thread). The peer/other members are untouched.
|
||||
hideConversationForUser: (conversationId, userId) => db.prepare(
|
||||
`INSERT INTO message_hidden (message_id,user_id,hidden_at)
|
||||
SELECT id, ?, ? FROM messages WHERE conversation_id=?
|
||||
ON CONFLICT (message_id,user_id) DO NOTHING`
|
||||
).run(userId, now(), conversationId),
|
||||
hiddenForUser: async (userId) => (await db.prepare('SELECT message_id FROM message_hidden WHERE user_id=?').all(userId)).map((r) => r.message_id),
|
||||
// Last message in a group that THIS user hasn't hidden (so a "delete for me" on the last message
|
||||
// rolls the sidebar preview back to the previous one, instead of showing what they just removed).
|
||||
lastInConversationForUser: (conversationId, userId) => db.prepare('SELECT * FROM messages WHERE conversation_id=? AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?) ORDER BY created_at DESC LIMIT 1').get(conversationId, userId),
|
||||
// #13 Pin a message: set/clear pinned_at + pinned_by.
|
||||
setPinned: (id, pinnedAt, pinnedBy) => db.prepare('UPDATE messages SET pinned_at=?, pinned_by=? WHERE id=?').run(pinnedAt, pinnedBy, id),
|
||||
// Pinned messages in a group / DM (newest pin first, deleted excluded).
|
||||
pinnedInConversation: (conversationId) => db.prepare('SELECT * FROM messages WHERE conversation_id=? AND pinned_at IS NOT NULL AND deleted=0 ORDER BY pinned_at DESC').all(conversationId),
|
||||
pinnedInDm: (teamId, a, b) => 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=?)) AND pinned_at IS NOT NULL AND deleted=0 ORDER BY pinned_at DESC').all(teamId, a, b, b, a),
|
||||
// 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),
|
||||
@@ -215,12 +250,20 @@ const messages = {
|
||||
// and silently dropped everything newer once a thread passed 300 — so new messages "disappeared".
|
||||
// The `before` cursor is added CONDITIONALLY (not as `? IS NULL OR …`): an all-NULL param has no type
|
||||
// for Postgres to infer. The subquery also needs an alias (`t`) — Postgres requires it. Both portable.
|
||||
// `a` is the VIEWER (u.id). Exclude the viewer's "deleted for me" (message_hidden) rows in SQL — not in
|
||||
// JS afterwards — so the LIMIT counts only VISIBLE messages. Filtering after the LIMIT returned < PAGE rows
|
||||
// whenever a recent message had been hidden, and the client read that as "no older history" and stopped
|
||||
// paginating (a chat with a deleted recent message wouldn't scroll back).
|
||||
// `a` is the VIEWER. Exclude messages from users the viewer has blocked (in SQL, like message_hidden,
|
||||
// so the LIMIT counts only VISIBLE messages and pagination doesn't stall).
|
||||
thread: (teamId, a, b, limit = 500, before = null) => {
|
||||
const cond = before != null ? ' AND created_at < ?' : '';
|
||||
const args = before != null ? [teamId, a, b, b, a, before, limit] : [teamId, a, b, b, a, limit];
|
||||
const args = before != null ? [teamId, a, b, b, a, a, a, before, limit] : [teamId, a, b, b, a, a, a, limit];
|
||||
return db.prepare(`SELECT * FROM (
|
||||
SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
|
||||
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))${cond}
|
||||
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
|
||||
AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?)
|
||||
AND sender_id NOT IN (SELECT blocked_id FROM user_blocks WHERE blocker_id=?)${cond}
|
||||
ORDER BY created_at DESC LIMIT ?
|
||||
) t ORDER BY created_at ASC`).all(...args);
|
||||
},
|
||||
@@ -237,11 +280,14 @@ const messages = {
|
||||
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 = 500, before = null) => {
|
||||
threadByConversation: (conversationId, userId, limit = 500, before = null) => {
|
||||
const cond = before != null ? ' AND created_at < ?' : '';
|
||||
const args = before != null ? [conversationId, before, limit] : [conversationId, limit];
|
||||
const args = before != null ? [conversationId, userId, userId, before, limit] : [conversationId, userId, userId, limit];
|
||||
return db.prepare(`SELECT * FROM (
|
||||
SELECT * FROM messages WHERE conversation_id=?${cond} ORDER BY created_at DESC LIMIT ?
|
||||
SELECT * FROM messages WHERE conversation_id=?
|
||||
AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?)
|
||||
AND sender_id NOT IN (SELECT blocked_id FROM user_blocks WHERE blocker_id=?)${cond}
|
||||
ORDER BY created_at DESC LIMIT ?
|
||||
) t ORDER BY created_at ASC`).all(...args);
|
||||
},
|
||||
searchConversation: (conversationId, like, limit = 300) =>
|
||||
@@ -409,4 +455,23 @@ const appInstalls = {
|
||||
listForTenant: (tenantId) => db.prepare('SELECT * FROM app_installs WHERE tenant_id=? ORDER BY last_seen DESC').all(tenantId),
|
||||
};
|
||||
|
||||
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, callHistory, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens, appInstalls };
|
||||
// UGC moderation (App Store guideline 1.2).
|
||||
const reports = {
|
||||
add: ({ id, teamId, messageId, reporterId, reportedId, reason, snippet }) =>
|
||||
db.prepare('INSERT INTO message_reports (id,team_id,message_id,reporter_id,reported_id,reason,snippet,created_at,status) VALUES (?,?,?,?,?,?,?,?,?)')
|
||||
.run(id, teamId, messageId, reporterId, reportedId, reason || null, snippet || null, now(), 'open'),
|
||||
byId: (id) => db.prepare('SELECT * FROM message_reports WHERE id=?').get(id),
|
||||
listForTeam: (teamId, limit = 200) => db.prepare('SELECT * FROM message_reports WHERE team_id=? ORDER BY created_at DESC LIMIT ?').all(teamId, limit),
|
||||
setStatus: (id, status) => db.prepare('UPDATE message_reports SET status=? WHERE id=?').run(status, id),
|
||||
openCountForTeam: async (teamId) => (await db.prepare("SELECT COUNT(*) AS c FROM message_reports WHERE team_id=? AND status='open'").get(teamId)).c,
|
||||
};
|
||||
|
||||
const blocks = {
|
||||
add: (blockerId, blockedId, teamId) =>
|
||||
db.prepare('INSERT INTO user_blocks (blocker_id,blocked_id,team_id,created_at) VALUES (?,?,?,?) ON CONFLICT(blocker_id,blocked_id) DO NOTHING').run(blockerId, blockedId, teamId, now()),
|
||||
remove: (blockerId, blockedId) => db.prepare('DELETE FROM user_blocks WHERE blocker_id=? AND blocked_id=?').run(blockerId, blockedId),
|
||||
has: async (blockerId, blockedId) => !!(await db.prepare('SELECT 1 FROM user_blocks WHERE blocker_id=? AND blocked_id=?').get(blockerId, blockedId)),
|
||||
listFor: async (blockerId) => (await db.prepare('SELECT blocked_id FROM user_blocks WHERE blocker_id=? ORDER BY created_at DESC').all(blockerId)).map((r) => r.blocked_id),
|
||||
};
|
||||
|
||||
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, callHistory, recordings, polls, pollVotes, pushSubs, favorites, deviceTokens, appInstalls, reports, blocks };
|
||||
|
||||
@@ -11,7 +11,7 @@ const PUSH = require('./push');
|
||||
const MSG_MAX = 4000;
|
||||
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const SYSTEM_SENDER = '__system__';
|
||||
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
|
||||
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, pinned: !!m.pinned_at, edited_at: m.deleted ? null : (m.edited_at || null), system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
|
||||
async function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
|
||||
// Await-aware filter: keep items whose async predicate resolves truthy (these predicates hit the DB, and a
|
||||
// plain .filter() can't await). Sequential so per-item DB order is deterministic.
|
||||
@@ -267,7 +267,7 @@ route('POST', '/api/login', async (req, res) => {
|
||||
}
|
||||
|
||||
const tok = A.token();
|
||||
const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
|
||||
const ttl = SESSION_TTL; // long-lived (90d) and slid forward on /api/me — no more 24h overnight logout (remember-me is now moot)
|
||||
await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl });
|
||||
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
|
||||
@@ -359,6 +359,17 @@ route('GET', '/api/ice', async (req, res) => {
|
||||
route('GET', '/api/me', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
// Sliding session: a web (cookie) client hitting /api/me — on app load, focus, or the periodic heartbeat —
|
||||
// pushes its expiry out to a fresh full window and re-stamps the cookie. So any regular use keeps you logged
|
||||
// in indefinitely; you only lapse after SESSION_TTL of NO use at all, or by logging out. (Native clients use
|
||||
// the refresh-token flow, so we only renew here when the request actually carried the sid cookie.)
|
||||
try {
|
||||
const tok = parseCookies(req).sid;
|
||||
if (tok && u._session && u._session.token === tok) {
|
||||
await R.authSessions.touch(tok, SESSION_TTL);
|
||||
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`);
|
||||
}
|
||||
} catch (_) {}
|
||||
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null, status: u.status || 'active' });
|
||||
});
|
||||
// Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable).
|
||||
@@ -412,18 +423,6 @@ route('POST', '/api/devices/remove', async (req, res) => {
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
// --- Push diagnostics (temporary): the native app reports each step of push setup here so we can see
|
||||
// WHERE iOS registration fails without a Mac/device console. Best-effort; logs and returns 200. ---
|
||||
route('POST', '/api/push-debug', async (req, res) => {
|
||||
try {
|
||||
const b = await readBody(req);
|
||||
let uid = 'anon'; try { const u = await currentUser(req); if (u) uid = u.id; } catch (_) {}
|
||||
const line = (typeof b === 'object' ? JSON.stringify(b) : String(b)).slice(0, 800);
|
||||
console.log('[push-debug] user=' + uid + ' ' + line);
|
||||
} catch (_) {}
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
// --- App install telemetry: records each install and, once the user signs in, who's using it. ---
|
||||
route('POST', '/api/telemetry/install', async (req, res) => {
|
||||
const { installId, platform, appVersion, os } = await readBody(req);
|
||||
@@ -781,9 +780,11 @@ route('GET', '/api/messages/conversations', async (req, res) => {
|
||||
const favs = new Set(await R.favorites.forUser(u.id));
|
||||
const inCall = new Set();
|
||||
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } }
|
||||
const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: skip messages this user "deleted for me"
|
||||
// DMs
|
||||
const byOther = new Map();
|
||||
for (const m of await R.messages.recentFor(u.team_id, u.id)) {
|
||||
if (hidden.has(m.id)) continue; // #18
|
||||
const raw = m.sender_id === u.id ? m.recipient_id : m.sender_id;
|
||||
if (!raw) continue;
|
||||
// If this counterparty was merged away, key the row by the SURVIVING account, so the thread carries
|
||||
@@ -798,11 +799,12 @@ route('GET', '/api/messages/conversations', async (req, res) => {
|
||||
kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null, lastSeen: seen[c.other] || null,
|
||||
callActive: !!dc, callRoom: dc ? dc.room : null, favorite: favs.has('dm:' + c.other), status: inCall.has(c.other) ? 'incall' : (statuses[c.other] || 'active'),
|
||||
last_body: c.last.body || (c.last.attachment_id ? '📎 Attachment' : ''), last_at: c.last.created_at, last_from_me: c.last.sender_id === u.id, unread: c.unread,
|
||||
last_deleted: !!c.last.deleted, // #10: a deleted last message must still read "message deleted", not "No messages yet"
|
||||
last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message
|
||||
}; });
|
||||
// Groups
|
||||
const groupItems = await Promise.all((await R.conversations.listForUser(u.team_id, u.id)).map(async (g) => {
|
||||
const last = await R.messages.lastInConversation(g.id);
|
||||
const last = await R.messages.lastInConversationForUser(g.id, u.id); // #18: last message this user hasn't hidden
|
||||
const since = await R.conversations.lastReadAt(g.id, u.id);
|
||||
const members = await R.conversations.members(g.id);
|
||||
// Group read tick for MY last message: read = every other member has read it, delivered = some
|
||||
@@ -818,6 +820,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
|
||||
callActive: groupCalls.has(g.id), callRoom: (groupCalls.get(g.id) || {}).room || null,
|
||||
last_body: last ? (last.body || (last.attachment_id ? '📎 Attachment' : '')) : '', last_at: last ? last.created_at : g.created_at,
|
||||
last_from_me: last ? last.sender_id === u.id : false, unread: last ? await R.messages.unreadInConversation(g.id, u.id, since) : 0,
|
||||
last_deleted: !!(last && last.deleted), // #10: deleted last message still reads "message deleted"
|
||||
last_status: gStatus,
|
||||
};
|
||||
}));
|
||||
@@ -833,9 +836,10 @@ route('GET', '/api/messages/thread', async (req, res) => {
|
||||
const before = parseInt(q.get('before') || '', 10) || null; // pagination cursor: fetch messages OLDER than this created_at
|
||||
const names = await namesFor(u.team_id);
|
||||
const group = q.get('group');
|
||||
const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: messages this user "deleted for me"
|
||||
if (group) {
|
||||
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
|
||||
const rows = await R.messages.threadByConversation(group, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load
|
||||
const rows = (await R.messages.threadByConversation(group, u.id, 40, before)).filter((m) => !hidden.has(m.id)); // #18 (hidden now excluded in SQL too, so the 40-row page counts only visible messages)
|
||||
if (!peek && !before) {
|
||||
await R.conversations.markRead(group, u.id);
|
||||
const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() };
|
||||
@@ -854,7 +858,7 @@ route('GET', '/api/messages/thread', async (req, res) => {
|
||||
const other = await R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread
|
||||
if (!other) return json(res, 400, { error: 'with or group required' });
|
||||
if (!await R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' });
|
||||
const rows = await R.messages.thread(u.team_id, u.id, other, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load
|
||||
const rows = (await R.messages.thread(u.team_id, u.id, other, 40, before)).filter((m) => !hidden.has(m.id)); // #18
|
||||
if (!peek && !before) { await R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} } // #13
|
||||
const rxBy = groupReactions(await R.reactions.forPair(u.team_id, u.id, other), u.id, names);
|
||||
return json(res, 200, await Promise.all(rows.map(async (m) => { const d = await buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; })));
|
||||
@@ -964,6 +968,7 @@ route('POST', '/api/calls/dm/start', async (req, res) => {
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { to } = await readBody(req);
|
||||
if (!to || !await R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' });
|
||||
if (await R.blocks.has(to, u.id)) return json(res, 403, { error: 'This user is unavailable.' }); // callee blocked the caller → don't ring
|
||||
json(res, 200, await CALLS.startDmCall(u, to, u.team_id));
|
||||
});
|
||||
|
||||
@@ -974,15 +979,21 @@ route('POST', '/api/calls/invite', async (req, res) => {
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { room, userIds } = await readBody(req);
|
||||
if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' });
|
||||
const ids = await asyncFilter((Array.isArray(userIds) ? userIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id));
|
||||
for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} }
|
||||
json(res, 200, { ok: true, invited: ids.length });
|
||||
const ids = await asyncFilter((Array.isArray(userIds) ? userIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id) && !(await R.blocks.has(x, u.id))); // skip anyone who blocked the caller
|
||||
// #15: adding people to a 1:1 call promotes it to a persistent GROUP call (survives leaves + lets an added
|
||||
// person rejoin after dropping). When that happens promoteDmToGroup's own group-call broadcast already rings
|
||||
// the invitees in, so we only send the plain call-invite for a call that WASN'T promoted (group/ad-hoc).
|
||||
try { CALLS.clearLeft(String(room), ids); } catch (_) {} // #New1: an explicit re-invite should ring again, even if they left earlier
|
||||
let groupId = null;
|
||||
try { groupId = await CALLS.promoteDmToGroup(String(room), u, ids); } catch (_) {}
|
||||
if (!groupId) { for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} } }
|
||||
json(res, 200, { ok: true, invited: ids.length, group: groupId || undefined });
|
||||
});
|
||||
|
||||
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
|
||||
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
|
||||
route('GET', '/api/meetings/config', (req, res) => {
|
||||
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED });
|
||||
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED, fcm: PUSH.fcmReady() });
|
||||
});
|
||||
|
||||
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
|
||||
@@ -1028,12 +1039,31 @@ route('POST', '/api/meetings/token', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
|
||||
const { room } = await readBody(req);
|
||||
const rm = String(room || '').trim();
|
||||
const body = await readBody(req);
|
||||
const rm = String(body.room || '').trim();
|
||||
if (!/^[A-Za-z0-9._-]{4,64}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
|
||||
const metadata = JSON.stringify({ avatarUrl: u.avatar_url || '' });
|
||||
const token = livekitToken(u.id, u.name || u.email, rm, metadata);
|
||||
json(res, 200, { token, url: LIVEKIT_URL, identity: u.id, name: u.name || u.email });
|
||||
// #12 Multi-device: mint the token with identity = the caller's mesh peerId (unique per connection) when it's
|
||||
// supplied, so the SAME user joining from two devices no longer collides on LiveKit — which allows exactly
|
||||
// one connection per identity, so with identity=userId the older device was kicked ("audio jumps to whichever
|
||||
// joined last"). Falls back to the user id when no peerId is passed (e.g. a native OUTGOING token fetched
|
||||
// before the WebView has joined the mesh; the plugin reconnects with a peerId token once it has one). The
|
||||
// client got its peerId from `meeting-joined`. Anti-hijack: refuse a peerId that's a DIFFERENT live user's.
|
||||
let identity = u.id;
|
||||
const pid = (typeof body.peerId === 'string') ? body.peerId.trim() : '';
|
||||
if (/^[A-Za-z0-9_-]{4,64}$/.test(pid)) {
|
||||
let ok = true;
|
||||
try { const peers = require('./presence').meetingRooms.get(rm); const pr = peers && peers.get(pid); if (pr && pr.uid && pr.uid !== u.id) ok = false; } catch (_) {}
|
||||
if (ok) identity = pid;
|
||||
}
|
||||
// Screen-share publisher (iOS): the WebView already holds the meeting connection under `identity`, and
|
||||
// LiveKit allows one connection per identity — so the native ReplayKit publisher joins the SAME room under
|
||||
// a DISTINCT `<identity>-screen` id. The client maps that suffix back onto the sharer's tile. WKWebView has
|
||||
// no getDisplayMedia, so this native second connection is the only way to screen-share in an SFU meeting.
|
||||
const screen = body.screen === true || body.screen === 1 || body.screen === '1';
|
||||
if (screen) identity = identity + '-screen';
|
||||
const metadata = JSON.stringify({ avatarUrl: u.avatar_url || '', screen });
|
||||
const token = livekitToken(identity, u.name || u.email, rm, metadata);
|
||||
json(res, 200, { token, url: LIVEKIT_URL, identity, name: u.name || u.email });
|
||||
});
|
||||
|
||||
// GUEST (no-login) LiveKit token — lets an external person invited by link join a meeting without a
|
||||
@@ -1041,7 +1071,8 @@ route('POST', '/api/meetings/token', async (req, res) => {
|
||||
// meeting, so a token can't be created for an arbitrary/expired code. Identity is a throwaway guest id.
|
||||
route('POST', '/api/meetings/guest-token', async (req, res) => {
|
||||
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
|
||||
const { room, name, identity } = await readBody(req);
|
||||
const body = await readBody(req);
|
||||
const { room, name, identity } = body;
|
||||
const rm = String(room || '').trim();
|
||||
if (!/^\d{6}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
|
||||
const live = (() => { try { return require('./presence').meetingRooms.has(rm); } catch (_) { return false; } })();
|
||||
@@ -1061,8 +1092,17 @@ route('POST', '/api/meetings/guest-token', async (req, res) => {
|
||||
// signaling (meeting-join guestId) — that mapping is how their media attaches to their tile.
|
||||
const gid = (typeof identity === 'string' && /^guest-[a-z0-9]+$/i.test(identity)) ? identity.slice(0, 64) : ('guest-' + crypto.randomBytes(8).toString('hex'));
|
||||
const gname = String(name || 'Guest').slice(0, 60);
|
||||
const token = livekitToken(gid, gname, rm, JSON.stringify({ guest: true }));
|
||||
json(res, 200, { token, url: LIVEKIT_URL, identity: gid, name: gname });
|
||||
// #12 Multi-device (same as /api/meetings/token): prefer the guest's per-connection mesh peerId as the
|
||||
// LiveKit identity so two devices don't collide; fall back to the throwaway guest id. Anti-hijack guarded.
|
||||
let lkid = gid;
|
||||
const pid = (typeof body.peerId === 'string') ? body.peerId.trim() : '';
|
||||
if (/^[A-Za-z0-9_-]{4,64}$/.test(pid)) {
|
||||
let ok = true;
|
||||
try { const peers = require('./presence').meetingRooms.get(rm); const pr = peers && peers.get(pid); if (pr && pr.uid && pr.uid !== gid) ok = false; } catch (_) {}
|
||||
if (ok) lkid = pid;
|
||||
}
|
||||
const token = livekitToken(lkid, gname, rm, JSON.stringify({ guest: true }));
|
||||
json(res, 200, { token, url: LIVEKIT_URL, identity: lkid, name: gname });
|
||||
});
|
||||
|
||||
// Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call.
|
||||
@@ -1233,6 +1273,13 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
|
||||
// Invitation notification to each invited participant.
|
||||
const inviteEvt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: code, by: u.name || u.email } };
|
||||
for (const pid of invited) { try { CHAT.pushToUser(pid, inviteEvt); } catch (_) {} }
|
||||
// Background/native push (iOS APNs, Android FCM, web push) so a CLOSED app still gets the invite. The
|
||||
// CHAT.pushToUser above only reaches an OPEN tab with a live socket — which is exactly why scheduled-
|
||||
// meeting notices never arrived on iOS (the webview is suspended in the background). Mirror the chat path.
|
||||
const notifyIds = new Set(invited);
|
||||
if (groupId) { try { (await R.conversations.members(groupId)).forEach((m) => notifyIds.add(m)); } catch (_) {} }
|
||||
notifyIds.delete(u.id);
|
||||
for (const pid of notifyIds) { try { PUSH.sendToUser(pid, { title: (u.name || u.email) + ' scheduled a meeting', body: t + ' · ' + label, kind: 'meeting', id: code, tag: 'meet:' + code }); } catch (_) {} }
|
||||
// Email invites (#4): the guest join link goes to external invitees, plus any invited Connect users
|
||||
// who have an email on file. Fire-and-forget — a mail outage never fails scheduling. No-op if SMTP off.
|
||||
try {
|
||||
@@ -1287,7 +1334,7 @@ route('GET', '/api/meetings', async (req, res) => {
|
||||
// Attach recordings/transcripts. A recording is visible to its creator, group members, or people
|
||||
// who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become
|
||||
// their own "Past meeting" entry (group calls show the group name).
|
||||
const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name });
|
||||
const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, mime: r.mime || '', createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name });
|
||||
const canSeeRec = async (r) => {
|
||||
if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner
|
||||
if (r.created_by === u.id) return true;
|
||||
@@ -1554,6 +1601,7 @@ route('POST', '/api/messages', async (req, res) => {
|
||||
const conv = await R.conversations.byId(group); const gname = (conv && conv.name) || 'Group';
|
||||
const pushBody = (u.name || u.email) + ': ' + (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment');
|
||||
for (const mid of await R.conversations.members(group)) {
|
||||
if (mid !== u.id && await R.blocks.has(mid, u.id)) continue; // member blocked the sender → deliver nothing to them
|
||||
try { CHAT.pushToUser(mid, push); } catch (_) {} // includes sender's other tabs
|
||||
if (mid !== u.id) PUSH.sendToUser(mid, { title: gname, body: pushBody, kind: 'group', id: group, tag: 'group:' + group });
|
||||
}
|
||||
@@ -1567,10 +1615,13 @@ route('POST', '/api/messages', async (req, res) => {
|
||||
await R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: toId, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null });
|
||||
const dto = await buildMsgDTO(await R.messages.byId(id), await namesFor(u.team_id), u.id);
|
||||
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
|
||||
try { CHAT.pushToUser(toId, push); } catch (_) {}
|
||||
// If the recipient has blocked the sender, persist the message but deliver nothing to them (no live
|
||||
// push, no background notification). The sender's own devices still sync it, so from their side it looks sent.
|
||||
const blockedByRcpt = (toId !== u.id) && await R.blocks.has(toId, u.id);
|
||||
if (!blockedByRcpt) try { CHAT.pushToUser(toId, push); } catch (_) {}
|
||||
if (toId !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes)
|
||||
// Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self.
|
||||
if (toId !== u.id) PUSH.sendToUser(toId, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined });
|
||||
// Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self, not if blocked.
|
||||
if (toId !== u.id && !blockedByRcpt) PUSH.sendToUser(toId, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined });
|
||||
json(res, 200, dto);
|
||||
});
|
||||
|
||||
@@ -1620,13 +1671,167 @@ route('POST', '/api/messages/delete', async (req, res) => {
|
||||
if (!id) return json(res, 400, { error: 'id required' });
|
||||
const m = await R.messages.byId(id);
|
||||
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
||||
if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only delete your own messages' });
|
||||
if (m.sender_id !== u.id && u.role !== 'admin') return json(res, 403, { error: 'you can only delete your own messages' }); // admins can remove reported content (guideline 1.2)
|
||||
await R.messages.markDeleted(id);
|
||||
const evt = { type: 'chat-deleted', id, conversation_id: m.conversation_id || null };
|
||||
if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
|
||||
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
// ── UGC moderation (App Store Review guideline 1.2) ────────────────────────────────────────────────
|
||||
// Report a message. Stored + surfaced to the workspace admins (who can delete it / act). Internal only.
|
||||
route('POST', '/api/messages/report', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { id, reason } = await readBody(req);
|
||||
if (!id) return json(res, 400, { error: 'id required' });
|
||||
const m = await R.messages.byId(id);
|
||||
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
||||
const canSee = m.conversation_id ? await R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id);
|
||||
if (!canSee) return json(res, 403, { error: 'not allowed' });
|
||||
const snippet = String(m.body || (m.attachment_id ? '[attachment]' : '')).slice(0, 160);
|
||||
await R.reports.add({ id: A.id(), teamId: u.team_id, messageId: m.id, reporterId: u.id, reportedId: m.sender_id, reason: String(reason || '').slice(0, 200), snippet });
|
||||
try { for (const aid of await R.users.adminsOf(u.team_id)) { if (aid !== u.id) { try { CHAT.pushToUser(aid, { type: 'report-new' }); } catch (_) {} } } } catch (_) {}
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
// Block a user: I stop receiving their messages and calls (one-directional).
|
||||
route('POST', '/api/users/block', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { userId } = await readBody(req);
|
||||
const target = await R.users.resolve(userId);
|
||||
if (!target || target === u.id) return json(res, 400, { error: 'invalid user' });
|
||||
if (!await R.users.inTenant(target, u.team_id)) return json(res, 404, { error: 'no such user' });
|
||||
await R.blocks.add(u.id, target, u.team_id);
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
route('POST', '/api/users/unblock', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { userId } = await readBody(req);
|
||||
if (!userId) return json(res, 400, { error: 'userId required' });
|
||||
await R.blocks.remove(u.id, await R.users.resolve(userId));
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
// My block list (ids + names) — powers the "Blocked users" manager and the client-side hide.
|
||||
route('GET', '/api/users/blocked', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const ids = await R.blocks.listFor(u.id);
|
||||
const names = await namesFor(u.team_id);
|
||||
json(res, 200, { ids, users: ids.map((id) => ({ id, name: names[id] || 'Unknown' })) });
|
||||
});
|
||||
|
||||
// Admin: list the workspace's reports + resolve them.
|
||||
route('GET', '/api/reports', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
if (u.role !== 'admin') return json(res, 403, { error: 'admins only' });
|
||||
const names = await namesFor(u.team_id);
|
||||
const roster = await R.users.listByTenant(u.team_id);
|
||||
const activeById = {}; roster.forEach((x) => { activeById[x.id] = x.active !== 0; }); // is the reported user still able to log in?
|
||||
const rows = await R.reports.listForTeam(u.team_id);
|
||||
json(res, 200, rows.map((r) => ({ id: r.id, messageId: r.message_id, reporter: names[r.reporter_id] || 'Unknown', reported: names[r.reported_id] || 'Unknown', reportedId: r.reported_id, reportedActive: activeById[r.reported_id] !== false, reason: r.reason || '', snippet: r.snippet || '', at: r.created_at, status: r.status })));
|
||||
});
|
||||
|
||||
route('POST', '/api/reports/resolve', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
if (u.role !== 'admin') return json(res, 403, { error: 'admins only' });
|
||||
const { id, status } = await readBody(req);
|
||||
if (!id) return json(res, 400, { error: 'id required' });
|
||||
const rep = await R.reports.byId(id);
|
||||
await R.reports.setStatus(id, status === 'open' ? 'open' : 'resolved');
|
||||
// Close the loop: tell the reporter their report was reviewed (live + background push).
|
||||
if (rep && status !== 'open' && rep.reporter_id && rep.reporter_id !== u.id) {
|
||||
try { CHAT.pushToUser(rep.reporter_id, { type: 'report-resolved' }); } catch (_) {}
|
||||
try { PUSH.sendToUser(rep.reporter_id, { title: 'Report reviewed', body: 'An admin reviewed the message you reported.' }); } catch (_) {}
|
||||
}
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
|
||||
// #18 Delete-for-me: hide a message from MY view only (any message I can see, mine or not). The row and
|
||||
// everyone else are untouched. Echoed to my OTHER devices so it disappears there too.
|
||||
route('POST', '/api/messages/hide', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { id } = await readBody(req);
|
||||
if (!id) return json(res, 400, { error: 'id required' });
|
||||
const m = await R.messages.byId(id);
|
||||
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
||||
const canSee = m.conversation_id ? await R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id);
|
||||
if (!canSee) return json(res, 403, { error: 'not allowed' });
|
||||
await R.messages.hideForUser(id, u.id);
|
||||
try { CHAT.pushToUser(u.id, { type: 'chat-hidden', id, conversation_id: m.conversation_id || null }); } catch (_) {} // my other devices
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
// "Delete chat" — SELF-ONLY. Hides the whole conversation from MY view (synced to my other devices); the
|
||||
// other party keeps their copy entirely. DM via {with:peerId}; group via {group:conversationId} (I stay a
|
||||
// member — new messages will start a fresh thread). Reuses the per-user "deleted for me" hide mechanism.
|
||||
route('POST', '/api/messages/clear', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { with: withRaw, group } = await readBody(req);
|
||||
if (group) {
|
||||
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
|
||||
await R.messages.hideConversationForUser(group, u.id);
|
||||
try { CHAT.pushToUser(u.id, { type: 'chat-cleared', kind: 'group', id: group }); } catch (_) {} // my other devices
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
const peer = await R.users.resolve(withRaw);
|
||||
if (!peer) return json(res, 400, { error: 'with or group required' });
|
||||
await R.messages.hideThreadForUser(u.team_id, u.id, peer);
|
||||
try { CHAT.pushToUser(u.id, { type: 'chat-cleared', kind: 'dm', id: peer }); } catch (_) {} // my other devices
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
// #13 Pin / unpin a message for the whole conversation (any participant may pin/unpin). Broadcast so every
|
||||
// participant's pinned strip updates live.
|
||||
route('POST', '/api/messages/pin', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { id, on } = await readBody(req);
|
||||
if (!id) return json(res, 400, { error: 'id required' });
|
||||
const m = await R.messages.byId(id);
|
||||
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
||||
if (m.deleted) return json(res, 400, { error: 'cannot pin a deleted message' });
|
||||
const canSee = m.conversation_id ? await R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id);
|
||||
if (!canSee) return json(res, 403, { error: 'not allowed' });
|
||||
const pin = on !== false; // default true
|
||||
await R.messages.setPinned(id, pin ? now() : null, pin ? u.id : null);
|
||||
// #13: log every pin/unpin so there's an accountable trail — anyone can unpin anyone's pin, but who did it
|
||||
// (and, for an unpin, whose pin they removed) is now recorded in the audit log.
|
||||
try {
|
||||
const where = m.conversation_id ? ('group ' + m.conversation_id) : ('dm with ' + (m.sender_id === u.id ? m.recipient_id : m.sender_id));
|
||||
const whose = (!pin && m.pinned_by && m.pinned_by !== u.id) ? (' (originally pinned by ' + m.pinned_by + ')') : '';
|
||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: pin ? 'message.pin' : 'message.unpin', detail: where + ' · message ' + id + whose });
|
||||
} catch (_) {}
|
||||
const evt = { type: 'chat-pinned', id, on: pin, by: u.name || u.email, conversation_id: m.conversation_id || null };
|
||||
if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
|
||||
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(m.sender_id, evt); } catch (_) {} }
|
||||
json(res, 200, { ok: true, pinned: pin });
|
||||
});
|
||||
// #13 The pinned messages for a conversation (?with=userId) or group (?group=id), newest pin first.
|
||||
route('GET', '/api/messages/pinned', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const q = new URLSearchParams(req.url.split('?')[1] || '');
|
||||
const names = await namesFor(u.team_id);
|
||||
const group = q.get('group');
|
||||
let rows;
|
||||
if (group) {
|
||||
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
|
||||
rows = await R.messages.pinnedInConversation(group);
|
||||
} else {
|
||||
const other = await R.users.resolve(q.get('with'));
|
||||
if (!other) return json(res, 400, { error: 'with or group required' });
|
||||
rows = await R.messages.pinnedInDm(u.team_id, u.id, other);
|
||||
}
|
||||
const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: don't surface a message you deleted-for-me
|
||||
json(res, 200, await Promise.all(rows.filter((m) => !hidden.has(m.id)).map(async (m) => { const d = await buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.pinnedBy = names[m.pinned_by] || ''; return d; }))); // #13: who pinned it (shown in the pinned bar)
|
||||
});
|
||||
// Edit a message (sender only, text only) — updates the body + marks it edited, and pushes the
|
||||
// change live to the other side / other tabs (mirrors the delete broadcast).
|
||||
route('POST', '/api/messages/edit', async (req, res) => {
|
||||
@@ -1719,6 +1924,18 @@ route('POST', '/api/messages/react', async (req, res) => {
|
||||
try { CHAT.pushToUser(other, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, other, names), ...meta }); } catch (_) {}
|
||||
try { CHAT.pushToUser(u.id, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, u.id, names), ...meta }); } catch (_) {}
|
||||
}
|
||||
// #3: notify the message OWNER that someone reacted — a native/web push so a CLOSED app is alerted too
|
||||
// (previously reactions only pushed over the live socket, so a backgrounded owner got nothing). Only when
|
||||
// the reaction was ADDED (not removed) and by someone other than the owner.
|
||||
if (added && msg.sender_id && msg.sender_id !== u.id) {
|
||||
const reactor = u.name || u.email;
|
||||
if (msg.conversation_id) {
|
||||
const conv = await R.conversations.byId(msg.conversation_id); const gname = (conv && conv.name) || 'Group';
|
||||
try { PUSH.sendToUser(msg.sender_id, { title: gname, body: reactor + ' reacted ' + e + ' to your message', kind: 'group', id: msg.conversation_id, tag: 'react:' + messageId, icon: u.avatar_url || undefined }); } catch (_) {}
|
||||
} else {
|
||||
try { PUSH.sendToUser(msg.sender_id, { title: reactor, body: 'reacted ' + e + ' to your message', kind: 'dm', id: u.id, tag: 'react:' + messageId, icon: u.avatar_url || undefined }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
json(res, 200, { ok: true, messageId, added, reactions: await reactionsForMessage(messageId, u.id, names) });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// One-time PRODUCTION migration for "BizGaze-only logins".
|
||||
//
|
||||
// Deletes the in-app (pre-BizGaze) local accounts. Combined with the BizGaze-only login
|
||||
// change, every user then signs in through BizGaze and is provisioned into the same
|
||||
// tenant — which restores the admin's "see all sessions" report.
|
||||
//
|
||||
// A "pre-BizGaze" account = a user with NO 'sso_user_created' audit entry for its email
|
||||
// (i.e. created locally via register/console, not provisioned by a BizGaze login).
|
||||
//
|
||||
// SAFE BY DEFAULT: dry-run unless you pass --apply. BACK UP THE DB FIRST.
|
||||
// Dry run : node scripts/migrate-bizgaze-only.js
|
||||
// Apply : node scripts/migrate-bizgaze-only.js --apply
|
||||
// Honors DB_PATH (same env var the server uses).
|
||||
|
||||
const db = require('../db');
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
||||
|
||||
const ssoEmails = new Set(
|
||||
db.prepare("SELECT DISTINCT lower(user_email) AS e FROM audit_log WHERE action='sso_user_created' AND user_email IS NOT NULL")
|
||||
.all().map((r) => r.e),
|
||||
);
|
||||
const users = db.prepare('SELECT id,email,name,role,team_id,active FROM users').all();
|
||||
const keep = users.filter((u) => ssoEmails.has(String(u.email).toLowerCase()));
|
||||
const remove = users.filter((u) => !ssoEmails.has(String(u.email).toLowerCase()));
|
||||
|
||||
console.log('=== Teams ===');
|
||||
for (const t of db.prepare('SELECT id,name FROM teams').all()) {
|
||||
const uc = db.prepare('SELECT COUNT(*) AS c FROM users WHERE team_id=?').get(t.id).c;
|
||||
console.log(` ${t.id} ${t.name} (${uc} users)`);
|
||||
}
|
||||
console.log('\n=== Users ===');
|
||||
console.log(` total: ${users.length} | BizGaze-provisioned (keep): ${keep.length} | local pre-BizGaze (delete): ${remove.length}`);
|
||||
console.log('\n KEEP (already BizGaze-provisioned):');
|
||||
keep.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
|
||||
console.log('\n DELETE (local / pre-BizGaze):');
|
||||
remove.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
|
||||
|
||||
if (!remove.length) { console.log('\nNothing to delete. Done.'); process.exit(0); }
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('\nDRY RUN — no changes made. Re-run with --apply to delete the local accounts above.');
|
||||
console.log('After deletion, those users sign in via BizGaze and are recreated automatically.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const delAuth = db.prepare('DELETE FROM sessions_auth WHERE user_id=?');
|
||||
const delRefresh = tableExists('refresh_tokens') ? db.prepare('DELETE FROM refresh_tokens WHERE user_id=?') : null;
|
||||
const delUser = db.prepare('DELETE FROM users WHERE id=?');
|
||||
let deleted = 0;
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const u of remove) {
|
||||
delAuth.run(u.id); // clear active sessions (FK) — also logs them out
|
||||
if (delRefresh) delRefresh.run(u.id);
|
||||
delUser.run(u.id);
|
||||
deleted++;
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
console.error('FAILED — rolled back, no changes applied:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nDONE. Deleted ${deleted} local account(s). They are recreated via BizGaze on next sign-in.`);
|
||||
@@ -58,9 +58,9 @@ function finishMeetingJoin(ws, room, peers) {
|
||||
const hostUserId = roomHost.get(room);
|
||||
const avatar = ws._meetingAvatar || null;
|
||||
const isHost = !!(ws._meetingUserId && hostUserId && ws._meetingUserId === hostUserId);
|
||||
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 })) }));
|
||||
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 });
|
||||
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, clientId: p.clientId || null })) }));
|
||||
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, clientId: ws._clientId || null })); }
|
||||
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null, clientId: ws._clientId || null });
|
||||
noteRoomStat(room, ws, peers.size); // #7: track the high-water participant count for the call log
|
||||
if (ws._meetingUserId) { try { require('./calls').markDmAnswered(room, ws._meetingUserId); } catch (_) {} CHAT.broadcastPresence(ws._meetingUserId); }
|
||||
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true }));
|
||||
@@ -142,6 +142,7 @@ async function handle(ws, m, req) {
|
||||
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;
|
||||
ws._clientId = (typeof m.clientId === 'string' && m.clientId) ? m.clientId.slice(0, 64) : null; // #12: stable per-device id → dedup a reconnecting device without collapsing a real 2nd device
|
||||
// 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 = await R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }
|
||||
@@ -372,6 +373,16 @@ async function handle(ws, m, req) {
|
||||
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
|
||||
break;
|
||||
}
|
||||
// iOS remote-support over LiveKit: 'rs-livekit' tells the OTHER end this session's media runs over a
|
||||
// LiveKit room (WKWebView can't getDisplayMedia); 'rs-chat' carries chat since there's no P2P data
|
||||
// channel in that mode. Relayed between the two ends exactly like offer/answer/transcript.
|
||||
case 'rs-livekit': case 'rs-chat': {
|
||||
const sess = liveSessions.get(m.sessionId || ws.sessionId);
|
||||
if (!sess) return;
|
||||
const peer = ws === sess.agentWs ? sess.viewerWs : sess.agentWs;
|
||||
if (peer && peer.readyState === 1) peer.send(JSON.stringify(m));
|
||||
break;
|
||||
}
|
||||
case 'end-session': {
|
||||
await endSession(ws.sessionId, m.reason || null);
|
||||
break;
|
||||
@@ -406,8 +417,15 @@ async function leaveMeeting(ws) {
|
||||
if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; }
|
||||
try { await 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)) {
|
||||
// #New1: remember this user LEFT, so a later socket reconnect doesn't auto-ring them back into a call that's
|
||||
// still running for the others (replayActiveCalls sends them noRing state instead). Harmless on a call that
|
||||
// then ends. Only meaningful for a group/promoted call that survives one person leaving.
|
||||
try { if (leaverId) require('./calls').markLeft(room, leaverId); } catch (_) {}
|
||||
// 1:1 call: end it for everyone ONLY when fewer than two people would remain. A DM call that had
|
||||
// extra people ADDED (via "Add people") is effectively a group now — one participant closing their
|
||||
// app must NOT hang up the call for the rest (#11). We only tear the whole thing down when ≤1 person
|
||||
// is left (nobody to talk to). With 2+ remaining we fall through to the normal peer-left path below.
|
||||
if (roomToDmCall.has(room) && peers.size < 2) {
|
||||
const others = [...peers.values()].map((p) => p.ws && p.ws._meetingUserId).filter(Boolean);
|
||||
for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } }
|
||||
await persistCallHistory(room); // #7: log the call BEFORE endCallByRoom clears roomToDmCall/roomToGroupCall
|
||||
|
||||
@@ -77,6 +77,8 @@ function serveStatic(req, res) {
|
||||
if (p === '/console' || p === '/dashboard') p = '/dashboard.html';
|
||||
if (p === '/share') p = '/share.html';
|
||||
if (p === '/connect') p = '/connect.html';
|
||||
if (p === '/privacy') p = '/privacy.html'; // App Store Privacy Policy URL (public, no login)
|
||||
if (p === '/support') p = '/support.html'; // App Store Support URL (public, no login)
|
||||
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
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
//
|
||||
// Run: node test/db-smoke.js (uses a throwaway temp DB; DB_BACKEND env selects sqlite|pg)
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const DB = path.join(os.tmpdir(), 'bzc-smoke.db');
|
||||
process.env.DB_PATH = DB;
|
||||
for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} }
|
||||
// SQLite was retired 2026-08-12 — this suite runs against Postgres now. Point DATABASE_URL at a DISPOSABLE
|
||||
// test database (NEVER production — the suite creates + mutates rows), e.g.:
|
||||
// DATABASE_URL=postgres://bizgaze:PW@localhost:5432/bizgaze_test node test/db-smoke.js
|
||||
process.env.DB_BACKEND = process.env.DB_BACKEND || 'pg';
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.log('SKIP db-smoke: set DATABASE_URL to a disposable Postgres test DB (SQLite retired 2026-08-12).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const PORT = 8097;
|
||||
process.env.PORT = PORT;
|
||||
@@ -39,8 +41,10 @@ async function get(p, cookie) {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await wait(300);
|
||||
console.log('DB smoke tests (backend=' + (process.env.DB_BACKEND || 'sqlite') + '):');
|
||||
// Wait for the server to actually be LISTENING — a cold Postgres boot (connect + apply the full schema)
|
||||
// takes a few seconds, well past the fixed 300ms that was fine for SQLite's instant in-memory init.
|
||||
for (let i = 0; i < 150; i++) { try { await fetch(BASE + '/'); break; } catch (_) { await wait(200); } }
|
||||
console.log('DB smoke tests (backend=' + process.env.DB_BACKEND + '):');
|
||||
|
||||
// Auth
|
||||
const reg = await post('/api/register', { email: 'admin@smoke.test', password: 'supersecret', teamName: 'Smoke Co' });
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
// (Login currently marks the session MFA-passed directly, so there is no separate
|
||||
// TOTP step in the product flow; the MFA endpoints still exist but aren't exercised here.)
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const DB = path.join(os.tmpdir(), 'ra-e2e.db');
|
||||
process.env.DB_PATH = DB;
|
||||
for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} }
|
||||
// SQLite was retired 2026-08-12 — the backend is Postgres now. Point DATABASE_URL at a DISPOSABLE test DB
|
||||
// (never production — this creates + mutates rows), e.g.:
|
||||
// DATABASE_URL=postgres://bizgaze:PW@localhost:5432/bizgaze_test node test/e2e.js
|
||||
process.env.DB_BACKEND = process.env.DB_BACKEND || 'pg';
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.log('SKIP e2e: set DATABASE_URL to a disposable Postgres test DB (SQLite retired 2026-08-12).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const PORT = 8099;
|
||||
process.env.PORT = PORT;
|
||||
@@ -63,7 +65,9 @@ function nextMsg(ws, type, timeout = 3000) {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await wait(300); // let server bind
|
||||
// Wait for the server to actually be LISTENING — a cold Postgres boot (connect + apply the full schema)
|
||||
// takes a few seconds, past the fixed 300ms that was fine for SQLite's instant in-memory init.
|
||||
for (let i = 0; i < 150; i++) { try { await fetch(BASE + '/'); break; } catch (_) { await wait(200); } }
|
||||
console.log('E2E backend tests:');
|
||||
|
||||
// Local receiver to capture outbound webhook deliveries.
|
||||
|
||||