Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9e5c7f406 | |||
| a427be9b6f | |||
| b576ed372a | |||
| f4a23ae805 | |||
| f7ddb2e7ae | |||
| 5edb3fa241 | |||
| 88d7657364 | |||
| 1272b81cee | |||
| d50d4bde47 | |||
| 1f4516d69b | |||
| fcd6a60baa | |||
| bda63b6f0a | |||
| 27355cec76 | |||
| 0a739ee2fd | |||
| caba3b3a21 |
@@ -32,6 +32,7 @@ out/
|
||||
# Runtime media (created at startup by config.js)
|
||||
server/recordings/
|
||||
server/transcripts/
|
||||
server/uploads/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
|
||||
+13
-4
@@ -133,10 +133,19 @@ viewer CONTROLS the remote screen** (reuses the WebRTC `inputChannel` + OS input
|
||||
- [x] **`Authorization: Bearer <token>`** accepted in `currentUser()` across HTTP + WS, alongside the
|
||||
cookie (session.js `tokenFromReq`); `/api/login` now also returns the `token` for native clients.
|
||||
WS upgrades carry the token in the Authorization header (native) or `?access_token=` (browser fallback).
|
||||
- [ ] **Refresh tokens** (short access token + long refresh) so native apps stay signed in safely.
|
||||
- [ ] **API keys** table + middleware (scoped per *tenant*, hashed at rest).
|
||||
- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile.
|
||||
- [ ] **OIDC/JWT** SSO; per-tenant **webhook subscriptions** with retries.
|
||||
- [x] **Refresh tokens** — `/api/v1/auth/refresh` exchanges a long-lived (90d) refresh token for a
|
||||
fresh access token, with **rotation** (old token revoked on use) + replay rejection. Stored as a
|
||||
SHA-256 hash (`refresh_tokens` table). Login returns one; logout/deactivate/reset/delete revoke them.
|
||||
Web/cookie path unchanged.
|
||||
- [x] **API keys** — admin-managed (`POST/GET /api/v1/keys`, `POST /api/v1/keys/revoke`), per-tenant,
|
||||
scoped (`report:read`, `audit:read`), `bzc_`-prefixed, SHA-256 hashed at rest, shown once. Accepted via
|
||||
`X-API-Key` or `Authorization: Bearer bzc_…` on `/api/v1/report` + `/api/v1/audit` with scope enforcement.
|
||||
- [x] **Per-tenant webhook subscriptions** — admin-managed (`/api/v1/webhooks` create/list/delete +
|
||||
`/webhooks/events`), each with its own signing secret; events `session.started`/`session.ended`
|
||||
delivered HMAC-SHA256-signed (`X-BizGaze-Signature`) with in-memory retries. Legacy global
|
||||
`BIZGAZE_WEBHOOK_URL` still works. (webhooks.js + signaling emits.)
|
||||
- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile — needs Apple/Google creds to test.
|
||||
- [ ] **OIDC/JWT** SSO — needs an identity provider to test against.
|
||||
|
||||
### Phase 3 — Licensing + scale (last, per priority)
|
||||
- [ ] Elevate **tenant → `organization`** (additive migration: add `organizations`, keep `team_id`
|
||||
|
||||
@@ -111,8 +111,12 @@ First registered user becomes admin; registration then closes (unless ALLOW_REGI
|
||||
the dev team for: shared secret, token format (JWT preferred), SSO start URL,
|
||||
signup URL, role mapping. (See BizGaze-Connect-SSO-SPEC.md.)
|
||||
2. **New post-login home (NEXT TASK)** — see below.
|
||||
3. **Persistent chat** (Slack-style 1:1 + group messaging between registered users) — large new system.
|
||||
4. **Meetings** (multi-party video) — large new system (needs SFU or mesh).
|
||||
3. **Persistent chat** — 1:1 messaging is BUILT (messages table, `/api/v1/messages/*`, live delivery
|
||||
over `/ws` via `chat-hello`/`chat-message`, wired into home.html). Group chat is the remaining part.
|
||||
4. **Meetings** (multi-party video) — **mesh (P2P) MVP BUILT**: in-memory rooms + signaling
|
||||
(`meeting-create/join/signal/leave` in signaling.js), video-grid UI in home.html's Meeting tab
|
||||
(start/join by 6-digit code, mic/cam toggles, leave). Good for small groups; **SFU upgrade** is
|
||||
the next step for larger rooms.
|
||||
5. **Downloadable Android app** — the only way to support phone screen-sharing.
|
||||
|
||||
## NEXT TASK: new post-login home (start with a mockup)
|
||||
|
||||
+3
-1
@@ -15,6 +15,8 @@ function verifyPassword(password, salt, expectedHash) {
|
||||
// ---- Random tokens ----
|
||||
const token = (bytes = 24) => crypto.randomBytes(bytes).toString('hex');
|
||||
const id = () => crypto.randomBytes(8).toString('hex');
|
||||
// Deterministic hash for storing high-value tokens (e.g. refresh tokens) at rest.
|
||||
const hashToken = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
|
||||
const numericCode = (digits = 6) =>
|
||||
String(crypto.randomInt(0, 10 ** digits)).padStart(digits, '0');
|
||||
|
||||
@@ -70,6 +72,6 @@ function otpauthUrl(secret, email, issuer = 'RemoteAccess') {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword, verifyPassword, token, id, numericCode,
|
||||
hashPassword, verifyPassword, token, id, hashToken, numericCode,
|
||||
newMfaSecret, totp, verifyTotp, otpauthUrl,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
function loginUrl() { return process.env.BIZGAZE_LOGIN_URL || ''; }
|
||||
const isEnabled = () => !!loginUrl();
|
||||
|
||||
// Origin of the BizGaze app (e.g. https://c02.bizgaze.app), derived from the login URL.
|
||||
function loginOrigin() { try { return new URL(loginUrl()).origin; } catch { return ''; } }
|
||||
|
||||
// Build an absolute profile-photo URL from the session payload. BizGaze returns a
|
||||
// relative path like "_files/documents/.../x.jpg" plus an asset/app base; we try the
|
||||
// asset host first, then the app host, then the login origin. Absolute URLs pass through.
|
||||
function photoUrlFrom(s) {
|
||||
const raw = s.photoUrl || s.PhotoUrl || s.photo || s.profilePic || s.imageUrl || '';
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
if (/^https?:\/\//i.test(raw)) return raw;
|
||||
const base = String(s.assetUrl || s.appUrl || loginOrigin() || '').replace(/\/+$/, '');
|
||||
return base ? base + '/' + raw.replace(/^\/+/, '') : null;
|
||||
}
|
||||
|
||||
async function validateLogin(username, password) {
|
||||
const url = loginUrl();
|
||||
if (!url) return { ok: false, configured: false };
|
||||
@@ -30,6 +44,7 @@ async function validateLogin(username, password) {
|
||||
return {
|
||||
ok: true, configured: true,
|
||||
name: s.name || null,
|
||||
avatarUrl: photoUrlFrom(s),
|
||||
isAdmin: !!s.isAdmin,
|
||||
tenantRef: s.tenantId != null ? String(s.tenantId) : null, // BizGaze tenant (org) id
|
||||
bizgazeUserId: s.userId != null ? String(s.userId) : null,
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
// Shared group calls: one live call per group. Members join without a code; the call
|
||||
// ends (with a duration line in the chat) when the last participant's mesh room empties.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const R = require('./repos');
|
||||
const A = require('./auth');
|
||||
const CHAT = require('./chat');
|
||||
const { TRANS_DIR } = require('./config');
|
||||
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
|
||||
const now = () => Date.now();
|
||||
const pairKey = (a, b) => [a, b].sort().join('|');
|
||||
|
||||
// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings.
|
||||
function meetingContext(room) {
|
||||
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
|
||||
try {
|
||||
const sched = R.scheduledMeetings.byCode(room);
|
||||
if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
|
||||
} catch (_) {}
|
||||
if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
|
||||
if (ctx.groupId && ctx.title === 'Meeting') { try { const g = R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} }
|
||||
if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId
|
||||
// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end).
|
||||
// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on).
|
||||
function finalizeTranscript(room, onlyUserId) {
|
||||
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
|
||||
const buf = transcriptBuffers.get(room) || [];
|
||||
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
|
||||
if (ids.length && buf.length) {
|
||||
const ctx = meetingContext(room);
|
||||
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
|
||||
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
|
||||
for (const uid of ids) {
|
||||
let user = null; try { user = R.users.byId(uid); } catch (_) {}
|
||||
if (!user) { subs.delete(uid); continue; }
|
||||
const id = A.id(); const file = 'm_' + id + '.txt';
|
||||
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
|
||||
// groupId null → private to its creator (see canSeeRec / /mrec auth).
|
||||
R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
|
||||
subs.delete(uid);
|
||||
}
|
||||
} else { ids.forEach((uid) => subs.delete(uid)); }
|
||||
if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
|
||||
}
|
||||
|
||||
function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); }
|
||||
|
||||
function broadcast(group, evt) { try { for (const mid of R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }
|
||||
|
||||
// Post a centered activity line into the group (system sender → no ping on clients).
|
||||
function postSystem(group, teamId, text) {
|
||||
const id = A.id();
|
||||
R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
|
||||
const m = R.messages.byId(id);
|
||||
broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } });
|
||||
}
|
||||
|
||||
function startGroupCall(group, teamId, user) {
|
||||
const existing = groupCalls.get(group);
|
||||
if (existing) return { room: existing.room, active: true, already: true };
|
||||
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
||||
meetingRooms.set(room, new Map());
|
||||
const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
|
||||
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
|
||||
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
|
||||
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
|
||||
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call');
|
||||
let gName = 'Group'; try { const g = R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
|
||||
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
|
||||
return { room, active: true };
|
||||
}
|
||||
|
||||
// Called from signaling when a mesh room empties — ends the group call if this room was one.
|
||||
function endGroupCallByRoom(room) {
|
||||
const group = roomToGroupCall.get(room);
|
||||
if (!group) return;
|
||||
const call = groupCalls.get(group);
|
||||
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
|
||||
if (call) {
|
||||
let teamId = call.teamId; try { const g = R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)); } } catch (_) {}
|
||||
if (call.historyId && teamId) { try { R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
|
||||
broadcast(group, { type: 'group-call', group, active: false, room });
|
||||
}
|
||||
}
|
||||
|
||||
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
|
||||
function startDmCall(me, otherId, teamId) {
|
||||
const key = pairKey(me.id, otherId);
|
||||
const existing = dmCalls.get(key);
|
||||
if (existing) return { room: existing.room, active: true, already: true };
|
||||
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
|
||||
meetingRooms.set(room, new Map());
|
||||
const byName = me.name || me.email;
|
||||
const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId };
|
||||
// Log to history (both participants) so the call shows under Past meetings with its transcript.
|
||||
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
|
||||
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
|
||||
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
|
||||
const mid = A.id();
|
||||
R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
|
||||
const m = R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
|
||||
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
|
||||
try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
|
||||
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {}
|
||||
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {}
|
||||
return { room, active: true };
|
||||
}
|
||||
|
||||
function endDmCallByRoom(room, silent) {
|
||||
const key = roomToDmCall.get(room); if (!key) return;
|
||||
const call = dmCalls.get(key);
|
||||
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
|
||||
if (!call) return;
|
||||
if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
|
||||
// "Call ended · duration" activity line in the DM (shown to both) — skipped on decline.
|
||||
if (!silent) try {
|
||||
const mid = A.id(); const body = '📞 Call ended · ' + fmtDur(now() - call.startedAt);
|
||||
R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
|
||||
const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
|
||||
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
|
||||
} catch (_) {}
|
||||
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
|
||||
}
|
||||
|
||||
// Called from signaling when any mesh room empties.
|
||||
function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); }
|
||||
|
||||
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
|
||||
function declineDmCall(room, byUser) {
|
||||
const key = roomToDmCall.get(room); if (!key) return { ok: false };
|
||||
const call = dmCalls.get(key); if (!call) return { ok: false };
|
||||
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
|
||||
try {
|
||||
const mid = A.id();
|
||||
R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
|
||||
const mm = R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' };
|
||||
CHAT.pushToUser(callerId, { type: 'chat-message', message: dto });
|
||||
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
|
||||
} catch (_) {}
|
||||
// Drop the caller who's still waiting in the (otherwise empty) mesh room.
|
||||
const peers = meetingRooms.get(room);
|
||||
if (peers) { for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } } meetingRooms.delete(room); }
|
||||
endDmCallByRoom(room, true); // silent: we already posted "Call declined"
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, finalizeTranscript, meetingContext, fmtDur, pairKey };
|
||||
@@ -0,0 +1,31 @@
|
||||
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends
|
||||
// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP
|
||||
// (routes.js) and pushed live to the recipient's sockets via pushToUser().
|
||||
const { chatClients } = require('./presence');
|
||||
|
||||
function register(userId, ws) {
|
||||
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
|
||||
chatClients.get(userId).add(ws);
|
||||
ws._chatUserId = userId;
|
||||
}
|
||||
|
||||
function unregister(ws) {
|
||||
const id = ws && ws._chatUserId;
|
||||
if (!id) return;
|
||||
const set = chatClients.get(id);
|
||||
if (set) { set.delete(ws); if (!set.size) chatClients.delete(id); }
|
||||
}
|
||||
|
||||
function isOnline(userId) {
|
||||
const s = chatClients.get(userId);
|
||||
return !!(s && s.size);
|
||||
}
|
||||
|
||||
function pushToUser(userId, obj) {
|
||||
const s = chatClients.get(userId);
|
||||
if (!s) return;
|
||||
const data = JSON.stringify(obj);
|
||||
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
|
||||
}
|
||||
|
||||
module.exports = { register, unregister, isOnline, pushToUser };
|
||||
+5
-1
@@ -5,8 +5,10 @@ const path = require('path');
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
const REC_DIR = path.join(__dirname, 'recordings');
|
||||
const TRANS_DIR = path.join(__dirname, 'transcripts');
|
||||
const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
||||
try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {}
|
||||
try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
|
||||
try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {}
|
||||
|
||||
module.exports = {
|
||||
PORT: process.env.PORT || 8090,
|
||||
@@ -14,5 +16,7 @@ module.exports = {
|
||||
PUBLIC_DIR,
|
||||
REC_DIR,
|
||||
TRANS_DIR,
|
||||
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h auto-logout
|
||||
UPLOADS_DIR,
|
||||
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h access-token / cookie lifetime
|
||||
REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients)
|
||||
};
|
||||
|
||||
+220
@@ -81,4 +81,224 @@ CREATE TABLE IF NOT EXISTS sessions_log (
|
||||
try { db.exec('ALTER TABLE sessions_log ADD COLUMN recording TEXT'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE sessions_log ADD COLUMN transcript TEXT'); } catch (e) { /* exists */ }
|
||||
|
||||
// Refresh tokens for native (desktop/mobile) clients: long-lived, rotated on use,
|
||||
// stored as a SHA-256 hash so a DB leak doesn't expose usable tokens.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// API keys for third-party / system integrations (machine-to-machine, no human login).
|
||||
// Scoped per tenant; the key is stored as a SHA-256 hash (plaintext shown once at creation).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
scopes TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Outbound webhook subscriptions: per-tenant endpoints that receive signed event
|
||||
// callbacks (session.started / session.ended). Each has its own signing secret.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
events TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_status INTEGER,
|
||||
last_error TEXT,
|
||||
last_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// Persistent 1:1 chat between users in the same team.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
sender_id TEXT NOT NULL,
|
||||
recipient_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
read_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_pair ON messages(team_id, sender_id, recipient_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(team_id, recipient_id, sender_id, read_at);
|
||||
`);
|
||||
// Migration: a message can quote/reply to another message.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN reply_to TEXT'); } catch (e) { /* exists */ }
|
||||
|
||||
// Emoji reactions on messages (one row per user+message+emoji; toggling adds/removes).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
message_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (message_id, user_id, emoji)
|
||||
);
|
||||
`);
|
||||
|
||||
// File attachments for chat messages (file bytes stored on disk at uploads/<id>).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
uploader_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
size INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN attachment_id TEXT'); } catch (e) { /* exists */ }
|
||||
|
||||
// Group conversations + membership. (1:1 DMs keep using sender_id/recipient_id directly;
|
||||
// group messages set conversation_id instead, with recipient_id left blank.)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'group',
|
||||
name TEXT,
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||
conversation_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
last_read_at INTEGER NOT NULL DEFAULT 0,
|
||||
joined_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
`);
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN conversation_id TEXT'); } catch (e) { /* exists */ }
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at)'); } catch (e) {}
|
||||
// Group admins: 1 = this member is an admin (multiple admins allowed). Creator seeded as admin.
|
||||
try { db.exec('ALTER TABLE conversation_members ADD COLUMN admin INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||
try { db.exec('UPDATE conversation_members SET admin=1 WHERE user_id IN (SELECT created_by FROM conversations WHERE conversations.id=conversation_members.conversation_id) AND admin=0'); } catch (e) {}
|
||||
|
||||
// Avatars: a user's profile picture (BizGaze photo URL) and a group's uploaded image
|
||||
// (an attachment id, served via /files/<id> with group-membership auth).
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE conversations ADD COLUMN avatar_id TEXT'); } catch (e) { /* exists */ }
|
||||
|
||||
// @mentions on a (group) message: JSON array of mentioned user ids, and/or the literal
|
||||
// "everyone" for @everyone/@all. Used to highlight and notify mentioned members.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN mentions TEXT'); } catch (e) { /* exists */ }
|
||||
|
||||
// Delivered receipt for DMs (double tick): set when the recipient's client acknowledges.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN delivered_at INTEGER'); } catch (e) { /* exists */ }
|
||||
// Group setting: when 1, only the creator can add/remove members.
|
||||
try { db.exec('ALTER TABLE conversations ADD COLUMN admin_only INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||
|
||||
// Polls live within a group conversation, attached to a message (the poll's question is
|
||||
// the message body). options is a JSON array of option strings; votes are one row each.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN poll_id TEXT'); } catch (e) { /* exists */ }
|
||||
// Activity/event lines (e.g. 'call-start','call-end') render as centered system messages.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /* exists */ }
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS polls (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_id TEXT,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL,
|
||||
multi INTEGER NOT NULL DEFAULT 0,
|
||||
closed INTEGER NOT NULL DEFAULT 0,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS poll_votes (
|
||||
poll_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
option_idx INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (poll_id, user_id, option_idx)
|
||||
);
|
||||
`);
|
||||
|
||||
// Scheduled meetings/calls. Each carries a stable room_code so a scheduled call can be
|
||||
// joined later (the live mesh room is created on first join). group_id is optional — a
|
||||
// scheduled meeting may target a specific group conversation or be standalone.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS scheduled_meetings (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
group_id TEXT,
|
||||
room_code TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
scheduled_at INTEGER NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
ended_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sched_team ON scheduled_meetings(team_id, scheduled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sched_code ON scheduled_meetings(room_code);
|
||||
`);
|
||||
// Invited participants (JSON array of user ids) + a one-shot "10-min reminder sent" flag.
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN participants TEXT'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN reminded INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||
// Cancelled meetings are kept (shown as "Cancelled"), not deleted.
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN duration_mins INTEGER'); } catch (e) { /* exists */ }
|
||||
// Weekly recurrence: JSON array of weekdays (0=Sun..6=Sat), or null for a one-off.
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN recurrence TEXT'); } catch (e) { /* exists */ }
|
||||
|
||||
// Meeting recordings & transcripts. Video bytes live in recordings/m_<id>.webm, transcript text
|
||||
// in transcripts/m_<id>.txt. Tied to a room (and group/scheduled meeting when applicable) so they
|
||||
// surface under "Past meetings". kind = 'video' | 'transcript'.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
room TEXT,
|
||||
group_id TEXT,
|
||||
meeting_id TEXT,
|
||||
title TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
file TEXT,
|
||||
mime TEXT,
|
||||
size INTEGER,
|
||||
duration_ms INTEGER,
|
||||
created_by TEXT,
|
||||
created_by_name TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_team ON recordings(team_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_room ON recordings(room);
|
||||
`);
|
||||
|
||||
// Web Push subscriptions (one per browser/device per user) for background/closed-tab
|
||||
// notifications. endpoint is unique; p256dh+auth are the encryption keys from the browser.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
|
||||
`);
|
||||
|
||||
module.exports = db;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// BizGaze user-directory search (cross-tenant). The auth token is kept SERVER-SIDE only — the
|
||||
// browser calls /api/directory/search and never sees the token. Configure via env in production:
|
||||
// BIZGAZE_DIRECTORY_URL (base, the search term is appended url-encoded)
|
||||
// BIZGAZE_DIRECTORY_TOKEN (the "stat ..." Authorization header value)
|
||||
const DEFAULT_URL = 'https://app.bizgaze.com/apis/v4/bizgaze/integrations/users_chatsearch/get_usersforchatsearch/searchterm/';
|
||||
const DEFAULT_TOKEN = 'stat 3cd2e190b4db448496ae316b155d2441';
|
||||
|
||||
function baseUrl() { return process.env.BIZGAZE_DIRECTORY_URL || DEFAULT_URL; }
|
||||
function token() { return process.env.BIZGAZE_DIRECTORY_TOKEN || DEFAULT_TOKEN; }
|
||||
function enabled() { return !!(baseUrl() && token()); }
|
||||
|
||||
// Pull a field from an object by any of several case-insensitive key names.
|
||||
function field(o, names) {
|
||||
const keys = Object.keys(o || {});
|
||||
for (const want of names) { for (const k of keys) { if (k.toLowerCase() === want) { const v = o[k]; if (v != null && v !== '') return String(v); } } }
|
||||
return '';
|
||||
}
|
||||
|
||||
// BizGaze responses vary (raw array, or wrapped in Result/data, sometimes a JSON string). Normalize.
|
||||
function toArray(data) {
|
||||
let d = data;
|
||||
if (typeof d === 'string') { try { d = JSON.parse(d); } catch (_) { return []; } }
|
||||
if (Array.isArray(d)) return d;
|
||||
if (d && typeof d === 'object') {
|
||||
for (const key of ['Result', 'result', 'data', 'Data', 'records', 'Records', 'items', 'Items']) {
|
||||
if (d[key] != null) { let v = d[key]; if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_) {} } if (Array.isArray(v)) return v; }
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function pick(o) {
|
||||
return {
|
||||
id: field(o, ['userid', 'id', 'contactid', 'partyid', 'recordid']),
|
||||
name: field(o, ['fullname', 'name', 'displayname', 'username', 'contactname', 'firstname']),
|
||||
email: field(o, ['email', 'emailaddress', 'emailid', 'mail']),
|
||||
phone: field(o, ['mobile', 'mobilenumber', 'phone', 'phonenumber', 'contactno', 'contactnumber']),
|
||||
avatar: field(o, ['photourl', 'photo', 'avatar', 'imageurl', 'profilepic', 'profileimage']),
|
||||
org: field(o, ['organization', 'organisation', 'company', 'tenantname', 'orgname']),
|
||||
};
|
||||
}
|
||||
|
||||
async function search(term) {
|
||||
if (!enabled() || !term || term.trim().length < 2) return [];
|
||||
const url = baseUrl() + encodeURIComponent(term.trim());
|
||||
const ctrl = new AbortController();
|
||||
const to = setTimeout(() => ctrl.abort(), 8000);
|
||||
try {
|
||||
const r = await fetch(url, { headers: { Authorization: token(), Accept: 'application/json' }, signal: ctrl.signal });
|
||||
if (!r.ok) return [];
|
||||
const data = await r.json().catch(() => null);
|
||||
return toArray(data).map(pick).filter((x) => x.name || x.email || x.phone).slice(0, 25);
|
||||
} catch (_) { return []; }
|
||||
finally { clearTimeout(to); }
|
||||
}
|
||||
|
||||
module.exports = { search, enabled };
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
const now = () => Date.now();
|
||||
|
||||
const json = (res, code, body) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
// no-store: API/JSON responses (and 404s) must never be cached — a cached 404 for an asset
|
||||
// like /manifest.json would otherwise persist on a device even after the file is deployed.
|
||||
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
|
||||
Generated
+192
-4
@@ -1,17 +1,205 @@
|
||||
{
|
||||
"name": "remote-access-server",
|
||||
"version": "0.2.0",
|
||||
"name": "bizgaze-support-server",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remote-access-server",
|
||||
"version": "0.2.0",
|
||||
"name": "bizgaze-support-server",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"nodemailer": "^6.9.14"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"license": "MIT-0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
|
||||
+13
-4
@@ -3,8 +3,17 @@
|
||||
"version": "2.0.0",
|
||||
"description": "BizGaze Support — remote screen sharing: public landing, agent console, sessions, SSO + webhook for BizGaze integration",
|
||||
"main": "server.js",
|
||||
"scripts": { "start": "node server.js" },
|
||||
"engines": { "node": ">=22.5.0" },
|
||||
"dependencies": { "ws": "^8.18.0" },
|
||||
"optionalDependencies": { "nodemailer": "^6.9.14" }
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"nodemailer": "^6.9.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,13 @@ module.exports = {
|
||||
onlineAgents: new Map(), // machineId -> { ws, machine }
|
||||
liveSessions: new Map(), // sessionId -> { agentWs, viewerWs, machine, user }
|
||||
pendingShares: new Map(), // code -> { sharerWs, sessionId } (no-install ad-hoc shares)
|
||||
chatClients: new Map(), // userId -> Set<ws> (a user may have several tabs/devices open)
|
||||
meetingRooms: new Map(), // roomCode -> Map(peerId -> { ws, name }) (mesh meetings MVP)
|
||||
groupCalls: new Map(), // groupId -> { room, startedAt, startedBy, startedByName } (shared group calls)
|
||||
roomToGroupCall: new Map(),// roomCode -> groupId (end a group call when its room empties)
|
||||
dmCalls: new Map(), // pairKey "a|b" -> { room, startedAt, startedBy, startedByName, users:[a,b] }
|
||||
roomToDmCall: new Map(), // roomCode -> pairKey (end a 1:1 call when its room empties)
|
||||
roomHost: new Map(), // roomCode -> userId (the meeting creator = host; transferable in-call)
|
||||
transcriptBuffers: new Map(),// roomCode -> [{ t, speaker, text }] (shared full-conversation buffer)
|
||||
transcriptSubs: new Map(), // roomCode -> Set(userId) (who wants a private copy of the transcript)
|
||||
};
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -31,6 +31,10 @@
|
||||
.topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);}
|
||||
#endBtn{padding:.45rem 1rem;background:#fee2e2;color:#b91c1c;border:none;border-radius:8px;font-weight:600;cursor:pointer;}
|
||||
#video{width:100vw;height:calc(100vh - 46px);background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;}
|
||||
/* Control bar sits vertically on the RIGHT; reserve a thin right strip so the screen uses the full
|
||||
height/width otherwise (maximised view) and the icons never overlay the shared content. */
|
||||
body.has-bar #video{width:calc(100vw - 76px);}
|
||||
body.has-bar{background:#0b1220;}
|
||||
.profile{position:relative}
|
||||
.profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
||||
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
||||
@@ -53,10 +57,11 @@
|
||||
html.embed #homeLink{display:none!important;}
|
||||
html.embed #video{height:100vh!important;}
|
||||
</style>
|
||||
<script src="/icons.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
|
||||
<a href="/home" id="homeLink">← Home</a>
|
||||
<a href="/home" id="homeLink"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
|
||||
<div class="topbar" id="topbar">
|
||||
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span>Support</span></div></div>
|
||||
<div class="agentchip" id="agentChip"></div>
|
||||
@@ -163,7 +168,8 @@ function connectWS(){
|
||||
case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
|
||||
case 'session-ready': if(statusEl)statusEl.textContent='Allowed — connecting…'; break;
|
||||
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
|
||||
try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){}
|
||||
// Acquire the agent mic once; on renegotiation (e.g. customer unmutes) just answer.
|
||||
if(!window.__mic){ try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){} }
|
||||
const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
|
||||
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
|
||||
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
|
||||
@@ -191,6 +197,8 @@ function renderEnded(msg){
|
||||
bzcSession(false);
|
||||
try{ stopRecording(); }catch(_){}
|
||||
removeSessionUI();
|
||||
document.body.classList.remove('has-bar');
|
||||
if(window.__mic){ try{ window.__mic.getTracks().forEach(t=>t.stop()); }catch(_){} window.__mic=null; } // release mic so the tab's recording dot clears
|
||||
if(pc){ try{pc.close();}catch(e){} pc=null; }
|
||||
video.style.display='none'; bar.classList.remove('show');
|
||||
topbar.style.display='flex'; wrap.style.display='grid';
|
||||
@@ -207,7 +215,7 @@ let chatOpen=false;
|
||||
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||
const SVG_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||
const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const SVG_END='<svg viewBox="0 0 24 24" width="17" height="17" fill="#fff"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>';
|
||||
const SVG_END='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g></svg>';
|
||||
const SVG_REC='<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="7" fill="#ef4444"/></svg>';
|
||||
const SVG_RECSTOP='<svg viewBox="0 0 24 24" width="15" height="15" fill="#fff"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
|
||||
let mediaRecorder=null, recChunks=[], recCtx=null;
|
||||
@@ -296,20 +304,21 @@ function stopRecording(){
|
||||
showRecTimer(false);
|
||||
try{ws.send(JSON.stringify({type:'recording',sessionId,on:false}));}catch(_){}
|
||||
}
|
||||
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML='<span style="display:inline-flex">'+svg+'</span><span>'+label+'</span>';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;}
|
||||
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.title=label;b.setAttribute('aria-label',label);b.innerHTML='<span style="display:inline-flex">'+svg+'</span>';b.style.cssText='display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border:none;border-radius:50%;cursor:pointer;color:#fff;background:'+bg+';box-shadow:0 2px 6px rgba(0,0,0,.25);transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-2px)';b.onmouseleave=()=>b.style.transform='none';return b;}
|
||||
function buildBar(){
|
||||
if(document.getElementById('sessionBar'))return;
|
||||
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
|
||||
bzcSession(true);
|
||||
const bar=document.createElement('div'); bar.id='sessionBar';
|
||||
bar.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
|
||||
bar.style.cssText='position:fixed;right:14px;top:50%;transform:translateY(-50%);z-index:2147483000;display:flex;flex-direction:column;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:12px 8px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
|
||||
const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
|
||||
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
|
||||
const rec=_btn('recBtn',SVG_REC,'','#0ea5e9'); rec.title='Record'; rec.querySelectorAll('span').forEach((s,i)=>{ if(i>0) s.remove(); });
|
||||
const end=_btn('endBtn2',SVG_END,'End','#dc2626');
|
||||
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end);
|
||||
document.body.appendChild(bar);
|
||||
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span><span>'+(t.enabled?'Mic':'Muted')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
|
||||
document.body.classList.add('has-bar'); // reserve space so the bar never overlays the shared screen
|
||||
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.title=t.enabled?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
|
||||
chat.onclick=toggleChat;
|
||||
rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
|
||||
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
|
||||
@@ -321,7 +330,7 @@ function buildBar(){
|
||||
function buildChatPanel(){
|
||||
if(document.getElementById('chatPanel'))return;
|
||||
const p=document.createElement('div'); p.id='chatPanel';
|
||||
p.style.cssText='position:fixed;right:18px;bottom:84px;width:300px;max-width:92vw;height:360px;max-height:62vh;z-index:2147483001;background:#fff;border:1px solid #e6e9ef;border-radius:16px;box-shadow:0 14px 34px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden';
|
||||
p.style.cssText='position:fixed;right:88px;bottom:18px;width:300px;max-width:80vw;height:360px;max-height:62vh;z-index:2147483001;background:#fff;border:1px solid #e6e9ef;border-radius:16px;box-shadow:0 14px 34px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden';
|
||||
p.innerHTML='<div style="background:#1F3B73;color:#fff;padding:.6rem .85rem;font-weight:600;font-size:.92rem;display:flex;justify-content:space-between;align-items:center">Chat <span id="chatClose" style="cursor:pointer;opacity:.85">✕</span></div><div id="chatMsgs" style="flex:1;overflow-y:auto;padding:.6rem;display:flex;flex-direction:column;gap:.4rem;font-size:.85rem"></div><div style="display:flex;gap:.4rem;padding:.5rem;border-top:1px solid #e6e9ef"><input id="chatInput" placeholder="Type a message..." style="flex:1;padding:.5rem;border:1px solid #e6e9ef;border-radius:8px;font-size:.85rem;outline:none"><button id="chatSend" style="background:#FFC708;border:none;border-radius:8px;padding:.5rem .85rem;font-weight:700;cursor:pointer">Send</button></div>';
|
||||
document.body.appendChild(p);
|
||||
document.getElementById('chatSend').onclick=sendChat;
|
||||
@@ -368,5 +377,6 @@ video.addEventListener('keyup',e=>{e.preventDefault();send({kind:'keyup',key:e.k
|
||||
document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</script>
|
||||
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
|
||||
.pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
|
||||
.pill.on{background:#ecfdf3;color:#15803d;}
|
||||
.pill.off{background:#fee2e2;color:var(--red);}
|
||||
.reveal{margin-top:1rem;background:#f1f7ec;border:1px solid #cfe8bf;border-radius:10px;padding:.8rem 1rem;}
|
||||
.reveal code{flex:1;word-break:break-all;background:#fff;border:1px solid var(--line);border-radius:8px;padding:.5rem .6rem;font-size:.85rem;}
|
||||
.chk{display:flex;align-items:center;gap:.4rem;font-size:.85rem;}
|
||||
.chk input{width:16px;height:16px;margin:0;accent-color:var(--blue);}
|
||||
.hidden{display:none;}
|
||||
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
|
||||
.tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
|
||||
@@ -64,7 +69,9 @@
|
||||
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
|
||||
.profile .pmenu a:hover{background:#f1f5f9}
|
||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
.ic{display:inline-block;vertical-align:middle}
|
||||
</style>
|
||||
<script src="/icons.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -188,21 +195,109 @@ async function dashboard(me) {
|
||||
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
|
||||
<div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
|
||||
<button id="fApply">Apply</button>
|
||||
<button id="fExcel" class="mini" style="padding:.6rem .9rem">⬇ Excel</button>
|
||||
<button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
|
||||
<button id="fExcel" class="mini" style="padding:.6rem .9rem">${ic('download',15)} Excel</button>
|
||||
<button id="fPdf" class="mini" style="padding:.6rem .9rem">${ic('download',15)} PDF</button>
|
||||
</div>
|
||||
${IS_ADMIN ? '<input id="repSearch" class="srch" placeholder="Search by agent or ticket">' : ''}
|
||||
<table id="report"><thead><tr><th>Date</th><th>Start time</th>${IS_ADMIN ? '<th>Agent</th>' : ''}<th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table>
|
||||
<div id="repPager" class="pager"></div>
|
||||
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
|
||||
</div>`);
|
||||
</div>
|
||||
${IS_ADMIN ? `
|
||||
<div class="card" id="keysCard">
|
||||
<h2>API keys <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— let other systems read your data programmatically</span></h2>
|
||||
<table id="keys"><thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody></tbody></table>
|
||||
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
|
||||
<div><span class="lbl">Name</span><input id="kName" placeholder="e.g. Partner X" style="max-width:200px"></div>
|
||||
<label class="chk"><input type="checkbox" id="kReport" checked> report:read</label>
|
||||
<label class="chk"><input type="checkbox" id="kAudit"> audit:read</label>
|
||||
<button id="kAdd">Generate key</button>
|
||||
</div>
|
||||
<div id="kOut"></div>
|
||||
</div>
|
||||
<div class="card" id="hooksCard">
|
||||
<h2>Webhooks <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— signed event callbacks to your systems</span></h2>
|
||||
<table id="hooks"><thead><tr><th>Endpoint</th><th>Events</th><th>Status</th><th>Last delivery</th><th></th></tr></thead><tbody></tbody></table>
|
||||
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
|
||||
<div style="flex:1;min-width:240px"><span class="lbl">Endpoint URL</span><input id="hUrl" placeholder="https://your-system.example.com/webhook"></div>
|
||||
<label class="chk"><input type="checkbox" id="hStarted" checked> session.started</label>
|
||||
<label class="chk"><input type="checkbox" id="hEnded" checked> session.ended</label>
|
||||
<button id="hAdd">Add webhook</button>
|
||||
</div>
|
||||
<div id="hOut"></div>
|
||||
</div>` : ''}`);
|
||||
document.getElementById('fApply').onclick = loadReport;
|
||||
document.getElementById('fExcel').onclick = exportExcel;
|
||||
document.getElementById('fPdf').onclick = exportPdf;
|
||||
if (IS_ADMIN) await populateAgentFilter();
|
||||
await loadReport();
|
||||
if (IS_ADMIN) {
|
||||
document.getElementById('kAdd').onclick = createKey;
|
||||
document.getElementById('hAdd').onclick = createHook;
|
||||
await loadKeys();
|
||||
await loadHooks();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Integrations: API keys + webhooks (admin) ----------
|
||||
function fmtTs(ms){ return ms ? new Date(ms).toLocaleString() : '—'; }
|
||||
function revealBox(label, value, note){
|
||||
return '<div class="reveal"><div class="lbl" style="margin:0 0 .3rem">'+esc(label)+' — copy now</div>'
|
||||
+ '<div style="display:flex;gap:.5rem;align-items:center"><code id="revealVal">'+esc(value)+'</code>'
|
||||
+ '<button class="mini" id="copyReveal">Copy</button></div>'
|
||||
+ '<div class="muted" style="margin-top:.4rem;font-size:.78rem">'+esc(note)+'</div></div>';
|
||||
}
|
||||
function wireCopy(){ const b=document.getElementById('copyReveal'); if(!b)return; b.onclick=async()=>{ try{ await navigator.clipboard.writeText(document.getElementById('revealVal').textContent); }catch(_){} b.textContent='Copied'; setTimeout(()=>{b.textContent='Copy';},1500); }; }
|
||||
|
||||
async function loadKeys(){
|
||||
let rows=[]; try{ rows = await api('/api/keys', null, 'GET'); }catch(e){ return; }
|
||||
document.querySelector('#keys tbody').innerHTML = rows.length ? rows.map(k=>`
|
||||
<tr style="${k.revoked?'opacity:.5':''}">
|
||||
<td>${esc(k.name||'—')}</td>
|
||||
<td class="muted">${esc(k.scopes||'')}</td>
|
||||
<td>${fmtTs(k.created_at)}</td>
|
||||
<td>${fmtTs(k.last_used_at)}</td>
|
||||
<td>${k.revoked?'<span class="pill off">revoked</span>':'<span class="pill on">active</span>'}</td>
|
||||
<td>${k.revoked?'':`<button class="mini danger" onclick="revokeKey('${k.id}')">Revoke</button>`}</td>
|
||||
</tr>`).join('') : '<tr><td colspan=6 class="muted">No API keys yet.</td></tr>';
|
||||
}
|
||||
async function createKey(){
|
||||
const scopes=[]; if(document.getElementById('kReport').checked)scopes.push('report:read'); if(document.getElementById('kAudit').checked)scopes.push('audit:read');
|
||||
if(!scopes.length){ document.getElementById('kOut').innerHTML='<p class="muted">Select at least one scope.</p>'; return; }
|
||||
try{
|
||||
const r = await api('/api/keys', { name: document.getElementById('kName').value, scopes }, 'POST');
|
||||
document.getElementById('kName').value='';
|
||||
document.getElementById('kOut').innerHTML = revealBox('API key', r.key, "Send this to the integrator. It won't be shown again — revoke and re-issue if lost.");
|
||||
wireCopy(); loadKeys();
|
||||
}catch(e){ document.getElementById('kOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
|
||||
}
|
||||
window.revokeKey = async (id)=>{ if(!confirm('Revoke this API key? Integrations using it will stop working.'))return; try{ await api('/api/keys/revoke',{id},'POST'); loadKeys(); }catch(e){} };
|
||||
|
||||
async function loadHooks(){
|
||||
let rows=[]; try{ rows = await api('/api/webhooks', null, 'GET'); }catch(e){ return; }
|
||||
document.querySelector('#hooks tbody').innerHTML = rows.length ? rows.map(h=>`
|
||||
<tr style="${h.active?'':'opacity:.5'}">
|
||||
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis" class="muted">${esc(h.url)}</td>
|
||||
<td class="muted">${esc(h.events||'')}</td>
|
||||
<td>${h.last_status==null?'<span class="muted">—</span>':(h.last_status?'<span class="pill on">ok</span>':'<span class="pill off">failing</span>')}</td>
|
||||
<td>${fmtTs(h.last_at)}${h.last_error?' <span class="muted" title="'+esc(h.last_error)+'">'+ic('alertTriangle',13)+'</span>':''}</td>
|
||||
<td><button class="mini danger" onclick="deleteHook('${h.id}')">Delete</button></td>
|
||||
</tr>`).join('') : '<tr><td colspan=5 class="muted">No webhooks yet.</td></tr>';
|
||||
}
|
||||
async function createHook(){
|
||||
const events=[]; if(document.getElementById('hStarted').checked)events.push('session.started'); if(document.getElementById('hEnded').checked)events.push('session.ended');
|
||||
const url=document.getElementById('hUrl').value.trim();
|
||||
if(!/^https?:\/\//i.test(url)){ document.getElementById('hOut').innerHTML='<p class="muted">Enter a valid http(s) URL.</p>'; return; }
|
||||
if(!events.length){ document.getElementById('hOut').innerHTML='<p class="muted">Select at least one event.</p>'; return; }
|
||||
try{
|
||||
const r = await api('/api/webhooks', { url, events }, 'POST');
|
||||
document.getElementById('hUrl').value='';
|
||||
document.getElementById('hOut').innerHTML = revealBox('Signing secret', r.secret, 'Verify the X-BizGaze-Signature header (HMAC-SHA256 of the body) with this. Shown once.');
|
||||
wireCopy(); loadHooks();
|
||||
}catch(e){ document.getElementById('hOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
|
||||
}
|
||||
window.deleteHook = async (id)=>{ if(!confirm('Delete this webhook?'))return; try{ await api('/api/webhooks/delete',{id},'POST'); loadHooks(); }catch(e){} };
|
||||
|
||||
const PER_PAGE = 5;
|
||||
function pagerHTML(page, pages, total, fn){
|
||||
if (total <= PER_PAGE) return total ? `<span>${total} total</span>` : '';
|
||||
@@ -241,8 +336,8 @@ function reportRowHTML(r){
|
||||
<td>${esc(r.ticket || 'Direct session')}</td>
|
||||
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
|
||||
<td>${[
|
||||
r.recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/${esc(r.recording)}" download>⬇ Video</a>` : '',
|
||||
r.transcript ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/transcripts/${esc(r.transcript)}" download>⬇ Text</a>` : ''
|
||||
r.recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/${esc(r.recording)}" download>${ic('download',14)} Video</a>` : '',
|
||||
r.transcript ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/transcripts/${esc(r.transcript)}" download>${ic('download',14)} Text</a>` : ''
|
||||
].join('') || '<span class="muted">—</span>'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
+1961
-101
File diff suppressed because it is too large
Load Diff
@@ -19,10 +19,11 @@
|
||||
.indicator { position:fixed; bottom:0; left:0; right:0; background:#b91c1c; color:#fff; text-align:center; padding:.4rem; font-size:.85rem; display:none; }
|
||||
.indicator.show { display:block; }
|
||||
</style>
|
||||
<script src="/icons.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🖥️ Browser Host (no install)</h1>
|
||||
<h1><span data-ic="monitor" data-sz="22"></span> Browser Host (no install)</h1>
|
||||
<p class="muted">Shares this screen with a technician. Paste the enroll token from the console and click Go online.</p>
|
||||
<input id="token" placeholder="enroll token">
|
||||
<button id="goBtn">Go online</button>
|
||||
@@ -98,5 +99,6 @@ function teardown() {
|
||||
}
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</script>
|
||||
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,65 @@
|
||||
// Shared icon set (Lucide — modern line icons). Use ic('name', size) for any UI icon.
|
||||
// Add new icons here so the whole app stays visually consistent.
|
||||
(function () {
|
||||
const P = {
|
||||
chat: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
|
||||
screenShare: '<path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m17 8 5-5"/><path d="M17 3h5v5"/>',
|
||||
wifi: '<path d="M12 20h.01"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 14 0"/><path d="M2 8.8a15 15 0 0 1 20 0"/>',
|
||||
video: '<path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/>',
|
||||
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
smile: '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>',
|
||||
smilePlus: '<path d="M22 11v1a10 10 0 1 1-9-10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/><path d="M16 5h6"/><path d="M19 2v6"/>',
|
||||
reply: '<polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
arrowLeft: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
|
||||
send: '<path d="M14.54 21.69a.5.5 0 0 0 .94-.03l6.5-19a.5.5 0 0 0-.64-.63l-19 6.5a.5.5 0 0 0-.02.93l7.93 3.18a2 2 0 0 1 1.1 1.11z"/><path d="m21.85 2.15-10.94 10.94"/>',
|
||||
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
||||
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||||
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
|
||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
|
||||
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
|
||||
micOff: '<line x1="2" x2="22" y1="2" y2="22"/><path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2"/><path d="M5 10v2a7 7 0 0 0 12 5"/><path d="M15 9.34V5a3 3 0 0 0-5.68-1.33"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12"/><line x1="12" x2="12" y1="19" y2="22"/>',
|
||||
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
|
||||
cameraOff: '<line x1="2" x2="22" y1="2" y2="22"/><path d="M7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12"/><path d="M9.5 4h5L17 7h3a2 2 0 0 1 2 2v7.5"/>',
|
||||
phoneOff: '<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="2" x2="22" y1="2" y2="22"/>',
|
||||
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
|
||||
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
|
||||
pencil: '<path d="M21.17 6.83a2.83 2.83 0 0 0-4-4L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/><path d="m15 5 4 4"/>',
|
||||
chevronDown: '<path d="m6 9 6 6 6-6"/>',
|
||||
layoutDashboard:'<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
|
||||
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
alertTriangle:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
monitor: '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
|
||||
barChart: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><rect x="7" y="11" width="3" height="6" rx="1"/><rect x="12" y="7" width="3" height="10" rx="1"/><rect x="17" y="13" width="3" height="4" rx="1"/>',
|
||||
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
||||
bold: '<path d="M14 12a4 4 0 0 0 0-8H6v8"/><path d="M15 20a4 4 0 0 0 0-8H6v8Z"/>',
|
||||
italic: '<line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/>',
|
||||
strikethrough:'<path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/>',
|
||||
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
|
||||
list: '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>',
|
||||
listOrdered: '<line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/>',
|
||||
type: '<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/>',
|
||||
crown: '<path d="m2 4 3 12h14l3-12-6 7-4-7-4 7-6-7z"/><path d="M5 20h14"/>',
|
||||
checkCheck: '<path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/>',
|
||||
calendarX: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="m14 14-4 4"/><path d="m10 14 4 4"/>',
|
||||
calendarClock:'<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><circle cx="16" cy="16" r="6"/><path d="M16 14v2l1.5 1"/>',
|
||||
fileText: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
|
||||
record: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/>',
|
||||
callEnd: '<g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g>',
|
||||
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
};
|
||||
window.ICON = P;
|
||||
window.ic = function (name, size) {
|
||||
const s = size || 18;
|
||||
return '<svg class="ic" width="' + s + '" height="' + s + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (P[name] || '') + '</svg>';
|
||||
};
|
||||
})();
|
||||
@@ -17,9 +17,9 @@
|
||||
.inner{max-width:780px;width:100%;text-align:center;}
|
||||
h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;}
|
||||
.sub{color:var(--muted);margin-bottom:2.2rem;}
|
||||
.ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--blue);color:#fff;text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(31,59,115,.28);transition:transform .12s,box-shadow .12s,background .12s;}
|
||||
.ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(31,59,115,.34);background:var(--blue-d);}
|
||||
.ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.9rem;}
|
||||
.ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--brand);color:var(--blue);text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(224,172,0,.32);transition:transform .12s,box-shadow .12s,background .12s;}
|
||||
.ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(224,172,0,.4);background:var(--brand-d);}
|
||||
.ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--blue);color:var(--brand);display:grid;place-items:center;font-weight:800;font-size:.9rem;}
|
||||
.divider{display:flex;align-items:center;gap:1rem;color:var(--muted);font-size:.85rem;max-width:360px;margin:1.8rem auto;}
|
||||
.divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--line);}
|
||||
.choices{display:flex;gap:1.4rem;flex-wrap:wrap;justify-content:center;}
|
||||
@@ -41,6 +41,7 @@
|
||||
.profile .pmenu a:hover{background:#f1f5f9}
|
||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
</style>
|
||||
<script src="/icons.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -63,7 +64,7 @@
|
||||
<div><h3>Share my screen</h3><p>Get a one-time code and show your screen to a BizGaze support agent — no login, no download.</p></div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="foot">🔒 Screen sharing only starts after you approve it, and can be stopped anytime.</div>
|
||||
<div class="foot"><span data-ic="lock" data-sz="14"></span> Screen sharing only starts after you approve it, and can be stopped anytime.</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer>© BizGaze · Remote Support</footer>
|
||||
@@ -81,5 +82,6 @@ makeBrandClickable();
|
||||
const dv=document.querySelector('.divider'); if(dv) dv.textContent='need to help someone? share your screen';
|
||||
}}catch(_){}})();
|
||||
</script>
|
||||
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "BizGaze Connect",
|
||||
"short_name": "Connect",
|
||||
"description": "Chat, screen share, and video meetings for the BizGaze ecosystem.",
|
||||
"start_url": "/home",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#1F3B73",
|
||||
"theme_color": "#1F3B73",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png?v=2", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
+27
-14
@@ -20,7 +20,7 @@
|
||||
.sub{color:var(--muted);font-size:.97rem;line-height:1.5;margin-bottom:1.6rem;}
|
||||
.codewrap{background:#fffdf2;border:2px dashed var(--brand);border-radius:14px;padding:1.2rem;}
|
||||
.codelabel{font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:.3rem;}
|
||||
.code{font-size:clamp(1.9rem,12vw,3rem);letter-spacing:clamp(.18rem,3vw,.5rem);font-weight:800;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:clip;}
|
||||
.code{font-size:clamp(1.6rem,9vw,2.6rem);letter-spacing:clamp(.1rem,2vw,.4rem);padding-left:clamp(.1rem,2vw,.4rem);font-weight:800;color:var(--ink);white-space:nowrap;}
|
||||
.status{margin-top:1.3rem;padding:.7rem 1rem;border-radius:10px;background:#f1f5f9;color:#475569;font-size:.92rem;}
|
||||
.status.on{background:#ecfdf3;color:#15803d;}
|
||||
.consent{margin-top:1.3rem;border:1px solid #c7d6f0;background:var(--blue-soft);border-left:5px solid var(--blue);border-radius:12px;padding:1.3rem;text-align:left;color:var(--blue-d);}
|
||||
@@ -46,11 +46,12 @@
|
||||
.profile .pmenu a:hover{background:#f1f5f9}
|
||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
</style>
|
||||
<script src="/icons.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
|
||||
<div class="indicator" id="indicator">● Your screen is being shared — close this tab anytime to stop</div>
|
||||
<a href="/" id="homeLink" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)">← Home</a>
|
||||
<a href="/" id="homeLink" style="position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15)"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
|
||||
<div class="stage">
|
||||
<div class="brandpanel">
|
||||
<img src="/logo.png" alt="" style="width:88px;height:88px;border-radius:22px;object-fit:contain;margin-bottom:1.2rem;background:#fff;padding:8px;box-shadow:0 12px 30px rgba(0,0,0,.25)" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'mark',textContent:'B'}))">
|
||||
@@ -65,12 +66,12 @@
|
||||
<div class="codelabel">Your session code</div>
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:.7rem">
|
||||
<div class="code" id="code">······</div>
|
||||
<button id="copyBtn" title="Click to copy" aria-label="Click to copy" style="flex:0 0 auto;width:38px;height:38px;padding:0;background:#fff;border:1px solid var(--brand);color:var(--blue);border-radius:8px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center"><svg id="copyIcon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
<button id="copyBtn" title="Click to copy" aria-label="Click to copy" style="flex:0 0 auto;width:28px;height:28px;padding:0;background:#fff;border:1px solid var(--brand);color:var(--blue);border-radius:7px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center"><svg id="copyIcon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status" class="status">Preparing your code…</div>
|
||||
<div id="consentBox"></div>
|
||||
<div class="foot">🔒 You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.</div>
|
||||
<div class="foot"><span data-ic="lock" data-sz="14"></span> You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +99,7 @@ document.getElementById('copyBtn').onclick=async()=>{
|
||||
try{ await navigator.clipboard.writeText(code); }
|
||||
catch(e){ const ta=document.createElement('textarea');ta.value=code;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove(); }
|
||||
const b=document.getElementById('copyBtn'); const old=b.innerHTML;
|
||||
b.innerHTML='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||
b.innerHTML='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||
setTimeout(()=>{b.innerHTML=old;},1500);
|
||||
};
|
||||
let ws,pc,localStream,chatChannel,sessionId;
|
||||
@@ -144,7 +145,9 @@ function showConsent(m){
|
||||
async function beginCapture(){
|
||||
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
|
||||
catch(err){ return false; }
|
||||
try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>localStream.addTrack(t)); }catch(e){}
|
||||
// Mic is OFF by default — we do NOT prompt for it here. Asking for the screen and the
|
||||
// mic at once confused customers and silently cancelled the share. The mic permission
|
||||
// is requested only when the customer taps Unmute (see the bar's mic button).
|
||||
try{ ensureIce(); }catch(_){}
|
||||
return true;
|
||||
}
|
||||
@@ -152,11 +155,10 @@ async function startStreaming(){
|
||||
// If the Allow tap already captured the screen (mobile path), reuse it.
|
||||
if(!localStream){
|
||||
await ensureIce();
|
||||
let mic=null; try{ mic=await navigator.mediaDevices.getUserMedia({audio:true}); }catch(e){ mic=null; }
|
||||
setStatus('In the popup: choose your screen, then tap Share / Start.','on');
|
||||
// Screen only — mic stays off until the customer taps Unmute (avoids the dual prompt).
|
||||
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
|
||||
catch(err){ if(mic){try{mic.getTracks().forEach(t=>t.stop());}catch(_){}} try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'share-cancelled'}));}catch(e){} setStatus('Screen share was cancelled. Refresh the page to try again.'); return; }
|
||||
if(mic){ window.__mic=mic; try{mic.getAudioTracks().forEach(t=>localStream.addTrack(t));}catch(_){} }
|
||||
catch(err){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'share-cancelled'}));}catch(e){} setStatus('Screen share was cancelled. Refresh the page to try again.'); return; }
|
||||
}
|
||||
await ensureIce();
|
||||
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true);
|
||||
@@ -225,18 +227,28 @@ let chatOpen=false;
|
||||
const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||
const SVG_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
|
||||
const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const SVG_END='<svg viewBox="0 0 24 24" width="17" height="17" fill="#fff"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>';
|
||||
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML='<span style="display:inline-flex">'+svg+'</span><span>'+label+'</span>';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;}
|
||||
const SVG_END='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g></svg>';
|
||||
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.title=label;b.setAttribute('aria-label',label);b.innerHTML='<span style="display:inline-flex">'+svg+'</span>';b.style.cssText='display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border:none;border-radius:50%;cursor:pointer;color:#fff;background:'+bg+';box-shadow:0 2px 6px rgba(0,0,0,.25);transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-2px)';b.onmouseleave=()=>b.style.transform='none';return b;}
|
||||
function buildBar(){
|
||||
if(document.getElementById('sessionBar'))return;
|
||||
const bar=document.createElement('div'); bar.id='sessionBar';
|
||||
bar.style.cssText='position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
|
||||
const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
|
||||
const mic=_btn('micBtn',SVG_MICOFF,'Muted','#6b7280');
|
||||
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
|
||||
const end=_btn('endBtn2',SVG_END,'Stop','#dc2626');
|
||||
const end=_btn('endBtn2',SVG_END,'End','#dc2626');
|
||||
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end);
|
||||
document.body.appendChild(bar);
|
||||
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span><span>'+(t.enabled?'Mic':'Muted')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
|
||||
const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+(on?SVG_MIC:SVG_MICOFF)+'</span>';mic.style.background=on?'#2563eb':'#6b7280';};
|
||||
mic.onclick=async()=>{
|
||||
if(!window.__mic){
|
||||
// First unmute: NOW request mic permission, add the track, and renegotiate.
|
||||
let m; try{ m=await navigator.mediaDevices.getUserMedia({audio:true}); }catch(e){ setStatus('Microphone permission was blocked. Allow it in the browser to talk.'); return; }
|
||||
window.__mic=m; const t=m.getAudioTracks()[0];
|
||||
try{ localStream.addTrack(t); if(pc){ pc.addTrack(t,localStream); const offer=await pc.createOffer(); await pc.setLocalDescription(offer); ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription})); } }catch(_){}
|
||||
setMic(true); return;
|
||||
}
|
||||
const t=window.__mic.getAudioTracks()[0]; if(!t) return; t.enabled=!t.enabled; setMic(t.enabled);
|
||||
};
|
||||
chat.onclick=toggleChat;
|
||||
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} teardown(); };
|
||||
buildChatPanel();
|
||||
@@ -267,5 +279,6 @@ function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','ms
|
||||
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</script>
|
||||
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// BizGaze Connect service worker — NOTIFICATIONS ONLY.
|
||||
// Intentionally has NO 'fetch' handler and NO caching, so it can never serve a stale
|
||||
// version of the app. Its only job is to show push notifications when the page is in
|
||||
// the background / frozen / closed, and to open the right chat when one is clicked.
|
||||
self.addEventListener('install', (e) => { self.skipWaiting(); });
|
||||
self.addEventListener('activate', (e) => { e.waitUntil(self.clients.claim()); });
|
||||
// No-op fetch handler: present only so the app meets PWA installability criteria. It never
|
||||
// calls respondWith(), so the browser performs its normal network fetch — NO caching, so this
|
||||
// can never serve a stale app.
|
||||
self.addEventListener('fetch', () => {});
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let d = {};
|
||||
try { d = event.data ? event.data.json() : {}; } catch (_) {}
|
||||
const title = d.title || 'BizGaze Connect';
|
||||
const options = {
|
||||
body: d.body || '',
|
||||
icon: '/logo.png',
|
||||
badge: '/logo.png',
|
||||
tag: d.tag || undefined, // collapse repeats from the same chat
|
||||
renotify: !!d.tag,
|
||||
data: { kind: d.kind || '', id: d.id || '' },
|
||||
};
|
||||
event.waitUntil((async () => {
|
||||
// If a BizGaze tab is currently VISIBLE, the page itself alerts the user (ping / in-page
|
||||
// popup) — skip the OS popup to avoid a double. Only show when no tab is visible
|
||||
// (another tab/app, minimized, or closed) — exactly when the page can't alert.
|
||||
const clientsArr = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const visible = clientsArr.some((c) => c.visibilityState === 'visible');
|
||||
if (visible) return;
|
||||
await self.registration.showNotification(title, options);
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const { kind, id } = event.notification.data || {};
|
||||
const url = '/home' + (id ? ('?openKind=' + encodeURIComponent(kind || 'dm') + '&openId=' + encodeURIComponent(id)) : '');
|
||||
event.waitUntil((async () => {
|
||||
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
for (const c of all) {
|
||||
if (c.url.includes('/home')) { try { await c.focus(); c.postMessage({ type: 'open-chat', kind, id }); return; } catch (_) {} }
|
||||
}
|
||||
try { await self.clients.openWindow(url); } catch (_) {}
|
||||
})());
|
||||
});
|
||||
@@ -11,12 +11,13 @@
|
||||
button { padding: 0.5rem 1rem; background: #334155; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
|
||||
a { color: #3b82f6; }
|
||||
</style>
|
||||
<script src="/icons.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div id="status">Connecting…</div>
|
||||
<div>
|
||||
<a href="/">← Console</a>
|
||||
<a href="/"><span data-ic="arrowLeft" data-sz="16"></span> Console</a>
|
||||
<button id="endBtn">End session</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -103,5 +104,6 @@ document.getElementById('endBtn').onclick = () => {
|
||||
setTimeout(() => (location.href = '/'), 300);
|
||||
};
|
||||
</script>
|
||||
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Web Push (background / closed-tab / mobile notifications). Fully optional:
|
||||
// - if the `web-push` package isn't installed, or VAPID env keys aren't set,
|
||||
// isEnabled() is false and every call is a silent no-op (the app is unaffected).
|
||||
// Configure in production by setting:
|
||||
// VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT (e.g. mailto:admin@bizgaze.com)
|
||||
// Generate a key pair once with: npx web-push generate-vapid-keys
|
||||
const R = require('./repos');
|
||||
|
||||
let webpush = null;
|
||||
try { webpush = require('web-push'); } catch (_) { /* package not installed -> push disabled */ }
|
||||
|
||||
const PUBLIC = process.env.VAPID_PUBLIC_KEY || '';
|
||||
const PRIVATE = process.env.VAPID_PRIVATE_KEY || '';
|
||||
const SUBJECT = process.env.VAPID_SUBJECT || 'mailto:support@bizgaze.com';
|
||||
|
||||
let ready = false;
|
||||
if (webpush && PUBLIC && PRIVATE) {
|
||||
try { webpush.setVapidDetails(SUBJECT, PUBLIC, PRIVATE); ready = true; }
|
||||
catch (e) { console.warn('[push] invalid VAPID config:', e.message); }
|
||||
}
|
||||
if (!ready) console.log('[push] Web Push disabled (set web-push + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY to enable).');
|
||||
|
||||
function isEnabled() { return ready; }
|
||||
function publicKey() { return ready ? PUBLIC : ''; }
|
||||
|
||||
// Fire-and-forget push to every device the user has subscribed. Dead subscriptions
|
||||
// (410 Gone / 404) are pruned. Never throws.
|
||||
async function sendToUser(userId, payload) {
|
||||
if (!ready) return;
|
||||
let subs = [];
|
||||
try { subs = R.pushSubs.byUser(userId); } catch (_) { return; }
|
||||
const data = JSON.stringify(payload || {});
|
||||
for (const s of subs) {
|
||||
const sub = { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } };
|
||||
try {
|
||||
await webpush.sendNotification(sub, data, { TTL: 600 });
|
||||
} catch (err) {
|
||||
const code = err && err.statusCode;
|
||||
if (code === 404 || code === 410) { try { R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} }
|
||||
// other errors (network, 4xx) are ignored — push is best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isEnabled, publicKey, sendToUser };
|
||||
@@ -0,0 +1,25 @@
|
||||
// Fires a one-shot "starts in ~10 minutes" reminder to a scheduled meeting's host,
|
||||
// group members, and invited participants. Runs on a 60s tick; marks each meeting reminded.
|
||||
const R = require('./repos');
|
||||
const CHAT = require('./chat');
|
||||
|
||||
function tick() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const due = R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min
|
||||
for (const s of due) {
|
||||
const recipients = new Set([s.created_by]);
|
||||
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
|
||||
invited.forEach((id) => recipients.add(id));
|
||||
if (s.group_id) { try { R.conversations.members(s.group_id).forEach((m) => recipients.add(m)); } catch (_) {} }
|
||||
const evt = { type: 'meeting-reminder', meeting: { id: s.id, title: s.title, scheduledAt: s.scheduled_at, room: s.room_code } };
|
||||
recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} });
|
||||
R.scheduledMeetings.markReminded(s.id);
|
||||
}
|
||||
} catch (_) { /* never let the timer die */ }
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
function start() { if (!timer) timer = setInterval(tick, 60 * 1000); }
|
||||
start();
|
||||
module.exports = { start, tick };
|
||||
+176
-2
@@ -25,7 +25,7 @@ const users = {
|
||||
byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email),
|
||||
emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email),
|
||||
listByTenant: (tenantId) =>
|
||||
db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(tenantId),
|
||||
db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId),
|
||||
inTenant: (id, tenantId) =>
|
||||
db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId),
|
||||
create: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => {
|
||||
@@ -40,6 +40,7 @@ const users = {
|
||||
setRole: (id, role) => db.prepare('UPDATE users SET role=? WHERE id=?').run(role, id),
|
||||
setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id),
|
||||
setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id),
|
||||
setAvatar: (id, url) => db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(url || null, id),
|
||||
remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id),
|
||||
};
|
||||
|
||||
@@ -101,4 +102,177 @@ const sessionsLog = {
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { teams, users, authSessions, machines, audit, sessionsLog };
|
||||
const refreshTokens = {
|
||||
create: ({ userId, tokenHash, ttl }) =>
|
||||
db.prepare('INSERT INTO refresh_tokens (token_hash,user_id,created_at,expires_at,revoked) VALUES (?,?,?,?,0)')
|
||||
.run(tokenHash, userId, now(), now() + ttl),
|
||||
byHash: (h) => db.prepare('SELECT * FROM refresh_tokens WHERE token_hash=?').get(h),
|
||||
revoke: (h) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE token_hash=?').run(h),
|
||||
revokeByUser: (userId) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE user_id=?').run(userId),
|
||||
};
|
||||
|
||||
const apiKeys = {
|
||||
create: ({ id, tenantId, name, keyHash, scopes, createdBy }) =>
|
||||
db.prepare('INSERT INTO api_keys (id,team_id,name,key_hash,scopes,created_by,created_at,revoked) VALUES (?,?,?,?,?,?,?,0)')
|
||||
.run(id, tenantId, name || null, keyHash, scopes || '', createdBy || null, now()),
|
||||
byHash: (h) => db.prepare('SELECT * FROM api_keys WHERE key_hash=?').get(h),
|
||||
listByTenant: (tenantId) =>
|
||||
db.prepare('SELECT id,name,scopes,created_by,created_at,last_used_at,revoked FROM api_keys WHERE team_id=? ORDER BY created_at DESC').all(tenantId),
|
||||
revoke: (id, tenantId) => db.prepare('UPDATE api_keys SET revoked=1 WHERE id=? AND team_id=?').run(id, tenantId),
|
||||
touch: (id) => db.prepare('UPDATE api_keys SET last_used_at=? WHERE id=?').run(now(), id),
|
||||
};
|
||||
|
||||
const webhooks = {
|
||||
create: ({ id, tenantId, url, secret, events, createdBy }) =>
|
||||
db.prepare('INSERT INTO webhooks (id,team_id,url,secret,events,active,created_by,created_at) VALUES (?,?,?,?,?,1,?,?)')
|
||||
.run(id, tenantId, url, secret, events || '', createdBy || null, now()),
|
||||
activeForTenant: (tenantId) => db.prepare('SELECT * FROM webhooks WHERE team_id=? AND active=1').all(tenantId),
|
||||
listByTenant: (tenantId) =>
|
||||
db.prepare('SELECT id,url,events,active,created_by,created_at,last_status,last_error,last_at FROM webhooks WHERE team_id=? ORDER BY created_at DESC').all(tenantId),
|
||||
remove: (id, tenantId) => db.prepare('DELETE FROM webhooks WHERE id=? AND team_id=?').run(id, tenantId),
|
||||
setStatus: (id, status, err) => db.prepare('UPDATE webhooks SET last_status=?, last_error=?, last_at=? WHERE id=?').run(status, err || null, now(), id),
|
||||
};
|
||||
|
||||
const messages = {
|
||||
send: ({ id, teamId, senderId, recipientId, body, replyTo, attachmentId, conversationId, mentions, msgType }) =>
|
||||
db.prepare('INSERT INTO messages (id,team_id,sender_id,recipient_id,body,created_at,reply_to,attachment_id,conversation_id,mentions,msg_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
|
||||
.run(id, teamId, senderId, recipientId || '', body, now(), replyTo || null, attachmentId || null, conversationId || null, (mentions && mentions.length) ? JSON.stringify(mentions) : null, msgType || null),
|
||||
byId: (id) => db.prepare('SELECT * FROM messages WHERE id=?').get(id),
|
||||
byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId),
|
||||
setPoll: (messageId, pollId) => db.prepare('UPDATE messages SET poll_id=? WHERE id=?').run(pollId, messageId),
|
||||
markDelivered: (id) => db.prepare('UPDATE messages SET delivered_at=? WHERE id=? AND delivered_at IS NULL').run(now(), id),
|
||||
// Full 1:1 (DM) thread between two users (both directions), oldest first.
|
||||
thread: (teamId, a, b, limit = 300) =>
|
||||
db.prepare(`SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
|
||||
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
|
||||
ORDER BY created_at ASC LIMIT ?`).all(teamId, a, b, b, a, limit),
|
||||
markRead: (teamId, recipientId, senderId) =>
|
||||
db.prepare('UPDATE messages SET read_at=? WHERE team_id=? AND conversation_id IS NULL AND recipient_id=? AND sender_id=? AND read_at IS NULL')
|
||||
.run(now(), teamId, recipientId, senderId),
|
||||
// Recent DM messages involving a user (newest first) — reduced into per-contact conversations.
|
||||
recentFor: (teamId, userId, limit = 1000) =>
|
||||
db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND (sender_id=? OR recipient_id=?) ORDER BY created_at DESC LIMIT ?')
|
||||
.all(teamId, userId, userId, limit),
|
||||
// Group conversation helpers.
|
||||
threadByConversation: (conversationId, limit = 300) =>
|
||||
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at ASC LIMIT ?').all(conversationId, limit),
|
||||
lastInConversation: (conversationId) =>
|
||||
db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId),
|
||||
unreadInConversation: (conversationId, userId, since) =>
|
||||
db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since).c,
|
||||
};
|
||||
|
||||
const reactions = {
|
||||
// Toggle with ONE reaction per user per message: picking an emoji replaces any prior
|
||||
// reaction by that user; picking the same one again removes it. Returns true if added.
|
||||
toggle: (messageId, userId, emoji) => {
|
||||
const had = db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji);
|
||||
db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId);
|
||||
if (had) return false;
|
||||
db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now());
|
||||
return true;
|
||||
},
|
||||
forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId),
|
||||
// All reactions on the messages in a 1:1 thread.
|
||||
forPair: (teamId, a, b) =>
|
||||
db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r
|
||||
JOIN messages m ON r.message_id = m.id
|
||||
WHERE m.team_id=? AND m.conversation_id IS NULL AND ((m.sender_id=? AND m.recipient_id=?) OR (m.sender_id=? AND m.recipient_id=?))`).all(teamId, a, b, b, a),
|
||||
// All reactions on the messages in a group conversation.
|
||||
forConversation: (conversationId) =>
|
||||
db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r
|
||||
JOIN messages m ON r.message_id = m.id WHERE m.conversation_id=?`).all(conversationId),
|
||||
};
|
||||
|
||||
const conversations = {
|
||||
create: ({ id, teamId, name, createdBy }) =>
|
||||
db.prepare('INSERT INTO conversations (id,team_id,type,name,created_by,created_at) VALUES (?,?,?,?,?,?)').run(id, teamId, 'group', name || null, createdBy || null, now()),
|
||||
byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id),
|
||||
addMember: (conversationId, userId, admin) =>
|
||||
db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?)').run(conversationId, userId, 0, now(), admin ? 1 : 0),
|
||||
members: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId).map((r) => r.user_id),
|
||||
isMember: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId),
|
||||
// ---- Group admins (multiple allowed) ----
|
||||
isAdmin: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId),
|
||||
admins: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId).map((r) => r.user_id),
|
||||
setMemberAdmin: (conversationId, userId, v) => db.prepare('UPDATE conversation_members SET admin=? WHERE conversation_id=? AND user_id=?').run(v ? 1 : 0, conversationId, userId),
|
||||
oldestMember: (conversationId) => { const r = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; },
|
||||
listForUser: (teamId, userId) =>
|
||||
db.prepare('SELECT c.* FROM conversations c JOIN conversation_members m ON m.conversation_id=c.id WHERE c.team_id=? AND m.user_id=?').all(teamId, userId),
|
||||
lastReadAt: (conversationId, userId) => { const r = db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; },
|
||||
memberReads: (conversationId) => db.prepare('SELECT user_id, last_read_at FROM conversation_members WHERE conversation_id=?').all(conversationId),
|
||||
setAdminOnly: (id, v) => db.prepare('UPDATE conversations SET admin_only=? WHERE id=?').run(v ? 1 : 0, id),
|
||||
markRead: (conversationId, userId) => db.prepare('UPDATE conversation_members SET last_read_at=? WHERE conversation_id=? AND user_id=?').run(now(), conversationId, userId),
|
||||
rename: (id, name) => db.prepare('UPDATE conversations SET name=? WHERE id=?').run(name, id),
|
||||
setAvatar: (id, attachmentId) => db.prepare('UPDATE conversations SET avatar_id=? WHERE id=?').run(attachmentId || null, id),
|
||||
byAvatar: (attachmentId) => db.prepare('SELECT * FROM conversations WHERE avatar_id=? LIMIT 1').get(attachmentId),
|
||||
removeMember: (conversationId, userId) => db.prepare('DELETE FROM conversation_members WHERE conversation_id=? AND user_id=?').run(conversationId, userId),
|
||||
remove: (id) => { db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); db.prepare('DELETE FROM conversations WHERE id=?').run(id); },
|
||||
};
|
||||
|
||||
const attachments = {
|
||||
create: ({ id, teamId, uploaderId, name, mime, size }) =>
|
||||
db.prepare('INSERT INTO attachments (id,team_id,uploader_id,name,mime,size,created_at) VALUES (?,?,?,?,?,?,?)')
|
||||
.run(id, teamId, uploaderId, name, mime || null, size || 0, now()),
|
||||
byId: (id) => db.prepare('SELECT * FROM attachments WHERE id=?').get(id),
|
||||
};
|
||||
|
||||
const scheduledMeetings = {
|
||||
create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence }) =>
|
||||
db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
.run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null),
|
||||
byId: (id) => db.prepare('SELECT * FROM scheduled_meetings WHERE id=?').get(id),
|
||||
byCode: (code) => db.prepare('SELECT * FROM scheduled_meetings WHERE room_code=? ORDER BY created_at DESC LIMIT 1').get(code),
|
||||
// Meetings a user can see: created by them, a member of the group, or an invited participant.
|
||||
listForUser: (teamId, userId) =>
|
||||
db.prepare(`SELECT s.* FROM scheduled_meetings s
|
||||
WHERE s.team_id=? AND (
|
||||
s.created_by=? OR
|
||||
(s.group_id IS NOT NULL AND EXISTS (SELECT 1 FROM conversation_members cm WHERE cm.conversation_id=s.group_id AND cm.user_id=?)) OR
|
||||
(s.participants IS NOT NULL AND s.participants LIKE '%'||?||'%'))
|
||||
ORDER BY s.scheduled_at ASC`).all(teamId, userId, userId, '"' + userId + '"'),
|
||||
dueForReminder: (fromTs, toTs) => db.prepare('SELECT * FROM scheduled_meetings WHERE reminded=0 AND ended_at IS NULL AND scheduled_at>=? AND scheduled_at<=?').all(fromTs, toTs),
|
||||
markReminded: (id) => db.prepare('UPDATE scheduled_meetings SET reminded=1 WHERE id=?').run(id),
|
||||
end: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId),
|
||||
cancel: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET cancelled=1, ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId),
|
||||
reschedule: (id, teamId, ts) => db.prepare('UPDATE scheduled_meetings SET scheduled_at=?, reminded=0 WHERE id=? AND team_id=?').run(ts, id, teamId), // recurrence: roll to next occurrence
|
||||
update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence }) =>
|
||||
db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, reminded=0 WHERE id=? AND team_id=?')
|
||||
.run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, id, teamId),
|
||||
remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId),
|
||||
};
|
||||
|
||||
const recordings = {
|
||||
create: ({ id, teamId, room, groupId, meetingId, title, kind, file, mime, size, durationMs, createdBy, createdByName }) =>
|
||||
db.prepare('INSERT INTO recordings (id,team_id,room,group_id,meeting_id,title,kind,file,mime,size,duration_ms,created_by,created_by_name,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
.run(id, teamId, room || null, groupId || null, meetingId || null, title || null, kind, file || null, mime || null, size || null, durationMs || null, createdBy || null, createdByName || null, now()),
|
||||
byId: (id) => db.prepare('SELECT * FROM recordings WHERE id=?').get(id),
|
||||
forTeam: (teamId) => db.prepare('SELECT * FROM recordings WHERE team_id=? ORDER BY created_at DESC').all(teamId),
|
||||
};
|
||||
|
||||
const polls = {
|
||||
create: ({ id, teamId, conversationId, messageId, question, options, multi, createdBy }) =>
|
||||
db.prepare('INSERT INTO polls (id,team_id,conversation_id,message_id,question,options,multi,closed,created_by,created_at) VALUES (?,?,?,?,?,?,?,0,?,?)')
|
||||
.run(id, teamId, conversationId, messageId || null, question, JSON.stringify(options), multi ? 1 : 0, createdBy, now()),
|
||||
byId: (id) => db.prepare('SELECT * FROM polls WHERE id=?').get(id),
|
||||
close: (id) => db.prepare('UPDATE polls SET closed=1 WHERE id=?').run(id),
|
||||
};
|
||||
|
||||
const pollVotes = {
|
||||
forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId),
|
||||
hasVoted: (pollId, userId, idx) => !!db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx),
|
||||
add: (pollId, userId, idx) => db.prepare('INSERT OR IGNORE INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?)').run(pollId, userId, idx, now()),
|
||||
remove: (pollId, userId, idx) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').run(pollId, userId, idx),
|
||||
clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId),
|
||||
};
|
||||
|
||||
const pushSubs = {
|
||||
// Upsert by endpoint: re-subscribing the same browser updates its keys/owner.
|
||||
add: ({ id, userId, endpoint, p256dh, auth }) =>
|
||||
db.prepare('INSERT INTO push_subscriptions (id,user_id,endpoint,p256dh,auth,created_at) VALUES (?,?,?,?,?,?) ON CONFLICT(endpoint) DO UPDATE SET user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth')
|
||||
.run(id, userId, endpoint, p256dh, auth, now()),
|
||||
byUser: (userId) => db.prepare('SELECT * FROM push_subscriptions WHERE user_id=?').all(userId),
|
||||
removeByEndpoint: (endpoint) => db.prepare('DELETE FROM push_subscriptions WHERE endpoint=?').run(endpoint),
|
||||
};
|
||||
|
||||
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs };
|
||||
|
||||
+930
-28
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -1,5 +1,6 @@
|
||||
// Session/auth helpers: resolve the current user from the cookie, write audit rows.
|
||||
const R = require('./repos');
|
||||
const A = require('./auth');
|
||||
const { parseCookies, now } = require('./lib');
|
||||
|
||||
function audit(entry) {
|
||||
@@ -35,4 +36,19 @@ function currentUser(req, { requireMfa = true } = {}) {
|
||||
return { ...u, _session: s };
|
||||
}
|
||||
|
||||
module.exports = { audit, currentUser, tokenFromReq };
|
||||
// Resolve a third-party API key from `X-API-Key` or `Authorization: Bearer bzc_...`.
|
||||
// Returns { id, teamId, scopes:[], name } or null. Keys are prefixed `bzc_` and stored hashed.
|
||||
function apiKeyFromReq(req) {
|
||||
let raw = req.headers && req.headers['x-api-key'];
|
||||
if (!raw) {
|
||||
const h = req.headers && (req.headers.authorization || req.headers.Authorization);
|
||||
if (h && /^Bearer\s+bzc_/i.test(h)) raw = h.replace(/^Bearer\s+/i, '').trim();
|
||||
}
|
||||
if (!raw || !/^bzc_/.test(raw)) return null;
|
||||
const row = R.apiKeys.byHash(A.hashToken(raw));
|
||||
if (!row || row.revoked) return null;
|
||||
return { id: row.id, teamId: row.team_id, scopes: String(row.scopes || '').split(',').map((s) => s.trim()).filter(Boolean), name: row.name };
|
||||
}
|
||||
function keyHasScope(key, scope) { return !!key && (key.scopes.includes(scope) || key.scopes.includes('*')); }
|
||||
|
||||
module.exports = { audit, currentUser, tokenFromReq, apiKeyFromReq, keyHasScope };
|
||||
|
||||
+165
-17
@@ -5,7 +5,9 @@
|
||||
const R = require('./repos');
|
||||
const A = require('./auth');
|
||||
const { currentUser, audit } = require('./session');
|
||||
const { onlineAgents, liveSessions, pendingShares } = require('./presence');
|
||||
const { onlineAgents, liveSessions, pendingShares, meetingRooms, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
|
||||
const W = require('./webhooks');
|
||||
const CHAT = require('./chat');
|
||||
|
||||
function onConnection(ws, req) {
|
||||
const hb = setInterval(() => {
|
||||
@@ -20,6 +22,132 @@ function onConnection(ws, req) {
|
||||
|
||||
function handle(ws, m, req) {
|
||||
switch (m.type) {
|
||||
// --- Logged-in user registers this socket for live chat delivery ---
|
||||
case 'chat-hello': {
|
||||
const u = currentUser(req); // identity from the cookie/Bearer on the WS upgrade
|
||||
if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' }));
|
||||
ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
|
||||
CHAT.register(u.id, ws);
|
||||
ws.send(JSON.stringify({ type: 'chat-ready' }));
|
||||
break;
|
||||
}
|
||||
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.
|
||||
case 'chat-delivered': {
|
||||
if (!ws._chatUserId || !m.id) break;
|
||||
const msg = R.messages.byId(m.id);
|
||||
if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only
|
||||
if (msg.recipient_id !== ws._chatUserId) break; // only the recipient can ack
|
||||
if (!msg.delivered_at) { R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id }); } catch (_) {} }
|
||||
break;
|
||||
}
|
||||
// --- Meetings (mesh): create a room, join by code, relay SDP/ICE peer-to-peer ---
|
||||
case 'meeting-create': {
|
||||
let code; do { code = A.numericCode(6); } while (meetingRooms.has(code));
|
||||
meetingRooms.set(code, new Map());
|
||||
const cu = currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host
|
||||
ws.send(JSON.stringify({ type: 'meeting-created', room: code }));
|
||||
break;
|
||||
}
|
||||
case 'meeting-join': {
|
||||
const room = String(m.room || '').trim();
|
||||
let peers = meetingRooms.get(room);
|
||||
// A scheduled meeting's room is created lazily on first join (its code lives in the DB).
|
||||
if (!peers) {
|
||||
const sched = R.scheduledMeetings.byCode(room);
|
||||
if (sched && !sched.ended_at) { peers = new Map(); meetingRooms.set(room, peers); }
|
||||
}
|
||||
if (!peers) return ws.send(JSON.stringify({ type: 'error', message: 'Meeting not found' }));
|
||||
const peerId = A.token(6);
|
||||
const name = String(m.name || 'Guest').slice(0, 60);
|
||||
ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name;
|
||||
// Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by.
|
||||
let hostUserId = roomHost.get(room);
|
||||
if (hostUserId === undefined) { try { const s = R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }
|
||||
const ju = currentUser(req);
|
||||
ws._meetingUserId = ju ? ju.id : null; // for per-user transcript ownership
|
||||
const isHost = !!(ju && hostUserId && ju.id === hostUserId);
|
||||
// Tell the newcomer who's already here (they initiate offers to existing peers)…
|
||||
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name })) }));
|
||||
// …and tell existing peers a newcomer arrived.
|
||||
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name })); }
|
||||
peers.set(peerId, { ws, name });
|
||||
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true })); // catch up: already transcribing
|
||||
break;
|
||||
}
|
||||
case 'meeting-signal': {
|
||||
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
|
||||
if (!peers) return;
|
||||
const target = peers.get(m.to);
|
||||
if (target && target.ws.readyState === 1) target.ws.send(JSON.stringify({ type: 'meeting-signal', from: ws._peerId, data: m.data }));
|
||||
break;
|
||||
}
|
||||
// Relay a peer's mic/cam state to everyone else in the room (for the tile mute icon).
|
||||
case 'meeting-state': {
|
||||
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
|
||||
if (!peers) return;
|
||||
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-state', peerId: ws._peerId, muted: !!m.muted, camOff: !!m.camOff })); }
|
||||
break;
|
||||
}
|
||||
// Relay a peer's screen-share on/off to everyone else (for the tile badge + single-share rule).
|
||||
case 'meeting-screen': {
|
||||
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
|
||||
if (!peers) return;
|
||||
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-screen', from: ws._peerId, on: !!m.on })); }
|
||||
break;
|
||||
}
|
||||
// Host: set whether multiple people may share their screen at once.
|
||||
case 'meeting-sharemode': {
|
||||
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
|
||||
if (!peers) return;
|
||||
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-sharemode', multi: !!m.multi })); }
|
||||
break;
|
||||
}
|
||||
// Host starts/stops recording → tell everyone so they see (and hear) the "being recorded" notice.
|
||||
case 'meeting-recording': {
|
||||
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
|
||||
if (!peers) return;
|
||||
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-recording', on: !!m.on, by: ws._peerName || 'The host' })); }
|
||||
break;
|
||||
}
|
||||
// A participant subscribes/unsubscribes to a transcript copy. While ≥1 subscriber, EVERY client
|
||||
// transcribes its own mic (full conversation); each subscriber gets their own private copy.
|
||||
// Unsubscribing only drops YOUR copy — it never stops anyone else's.
|
||||
case 'meeting-transcribe': {
|
||||
const room = ws._meetingRoom; const peers = room && meetingRooms.get(room);
|
||||
if (!peers) return; const uid = ws._meetingUserId; if (!uid) return;
|
||||
let subs = transcriptSubs.get(room); if (!subs) { subs = new Set(); transcriptSubs.set(room, subs); }
|
||||
if (m.on) subs.add(uid); else { try { require('./calls').finalizeTranscript(room, uid); } catch (_) {} } // finalize writes + removes the sub
|
||||
const active = subs.size > 0;
|
||||
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active })); }
|
||||
break;
|
||||
}
|
||||
// A participant's recognized speech segment → appended to the room's shared transcript buffer.
|
||||
case 'meeting-transcript': {
|
||||
const room = ws._meetingRoom; if (!room || !meetingRooms.get(room)) return;
|
||||
const text = String(m.text || '').slice(0, 1000).trim(); if (!text) return;
|
||||
let buf = transcriptBuffers.get(room); if (!buf) { buf = []; transcriptBuffers.set(room, buf); }
|
||||
buf.push({ t: Date.now(), speaker: ws._peerName || 'Guest', text });
|
||||
if (buf.length > 8000) buf.shift();
|
||||
break;
|
||||
}
|
||||
// Host: mute everyone else in the room.
|
||||
case 'meeting-muteall': {
|
||||
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
|
||||
if (!peers) return;
|
||||
for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-muteall', by: ws._peerId })); }
|
||||
break;
|
||||
}
|
||||
// Host: transfer host to another peer (broadcast the new host to the room).
|
||||
case 'meeting-host': {
|
||||
const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom);
|
||||
if (!peers || !m.to) return;
|
||||
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-host', hostPeerId: m.to })); }
|
||||
break;
|
||||
}
|
||||
case 'meeting-leave': {
|
||||
leaveMeeting(ws);
|
||||
break;
|
||||
}
|
||||
// --- Agent comes online ---
|
||||
case 'agent-hello': {
|
||||
const machine = R.machines.byEnrollToken(m.enrollToken);
|
||||
@@ -61,6 +189,7 @@ function handle(ws, m, req) {
|
||||
try {
|
||||
R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null });
|
||||
} catch (e) { /* duplicate consent */ }
|
||||
try { W.emit('session.started', sess.machine.team_id, { sessionId: m.sessionId, agent_email: sess.user.email, agent_name: sess.agentName || sess.user.email, ticket: sess.ticket || null, started_at: Date.now() }); } catch (_) {}
|
||||
sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId }));
|
||||
sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId }));
|
||||
} else {
|
||||
@@ -133,26 +262,16 @@ function handle(ws, m, req) {
|
||||
}
|
||||
}
|
||||
|
||||
function notifyBizGaze(sessionId) {
|
||||
const url = process.env.BIZGAZE_WEBHOOK_URL;
|
||||
if (!url) return;
|
||||
try {
|
||||
const row = R.sessionsLog.byId(sessionId);
|
||||
if (!row) return;
|
||||
const body = JSON.stringify({ event: 'session.ended', sessionId: row.id, agent_email: row.agent_email,
|
||||
agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
|
||||
duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
|
||||
const crypto = require('crypto');
|
||||
const sig = process.env.SSO_SECRET ? crypto.createHmac('sha256', process.env.SSO_SECRET).update(body).digest('base64url') : '';
|
||||
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sig }, body }).catch(()=>{});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function endSession(sessionId, reason) {
|
||||
const sess = liveSessions.get(sessionId);
|
||||
if (!sess) return;
|
||||
try { R.sessionsLog.end(sessionId); } catch (e) {}
|
||||
notifyBizGaze(sessionId);
|
||||
try {
|
||||
const row = R.sessionsLog.byId(sessionId);
|
||||
if (row) W.emit('session.ended', sess.machine.team_id, { sessionId: row.id, agent_email: row.agent_email,
|
||||
agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at,
|
||||
duration_ms: row.ended_at ? row.ended_at - row.started_at : null });
|
||||
} catch (e) {}
|
||||
audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'session_ended', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') });
|
||||
[sess.agentWs, sess.viewerWs].forEach((p) => {
|
||||
if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null }));
|
||||
@@ -160,7 +279,36 @@ function endSession(sessionId, reason) {
|
||||
liveSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
function leaveMeeting(ws) {
|
||||
const room = ws._meetingRoom;
|
||||
if (!room) return;
|
||||
const peers = meetingRooms.get(room);
|
||||
ws._meetingRoom = null;
|
||||
const pid = ws._peerId;
|
||||
if (!peers) return;
|
||||
try { require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript
|
||||
peers.delete(pid);
|
||||
// 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call).
|
||||
if (roomToDmCall.has(room)) {
|
||||
for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } }
|
||||
meetingRooms.delete(room);
|
||||
try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
|
||||
roomHost.delete(room);
|
||||
try { require('./calls').endCallByRoom(room); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); }
|
||||
if (peers.size === 0) {
|
||||
meetingRooms.delete(room);
|
||||
try { require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps
|
||||
roomHost.delete(room);
|
||||
try { require('./calls').endCallByRoom(room); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup(ws) {
|
||||
CHAT.unregister(ws);
|
||||
leaveMeeting(ws);
|
||||
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
|
||||
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
|
||||
if (ws.sessionId) {
|
||||
|
||||
+81
-7
@@ -5,9 +5,9 @@ const path = require('path');
|
||||
const R = require('./repos');
|
||||
const { json } = require('./lib');
|
||||
const { currentUser } = require('./session');
|
||||
const { PUBLIC_DIR, REC_DIR, TRANS_DIR } = require('./config');
|
||||
const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR } = require('./config');
|
||||
|
||||
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
|
||||
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };
|
||||
|
||||
function serveStatic(req, res) {
|
||||
let p = req.url.split('?')[0];
|
||||
@@ -19,11 +19,28 @@ function serveStatic(req, res) {
|
||||
if (p === '/connect') p = '/connect.html';
|
||||
const fp = path.join(PUBLIC_DIR, path.normalize(p));
|
||||
if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||
fs.readFile(fp, (err, data) => {
|
||||
if (err) return json(res, 404, { error: 'not found' });
|
||||
const ct = MIME[path.extname(fp)] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' });
|
||||
res.end(data);
|
||||
// ETag + revalidation: the browser keeps the file cached and we answer repeat loads with a
|
||||
// tiny 304 (no re-download) when nothing changed — fast reloads, but always fresh on edits.
|
||||
fs.stat(fp, (serr, st) => {
|
||||
if (serr || !st.isFile()) return json(res, 404, { error: 'not found' });
|
||||
const ext = path.extname(fp);
|
||||
const ct = MIME[ext] || 'application/octet-stream';
|
||||
// HTML entry pages are NEVER cached (no-store) so a deploy reaches every browser on the
|
||||
// next load — no hard-refresh needed. Versioned assets (e.g. icons.js?v=) still revalidate
|
||||
// cheaply via ETag/304.
|
||||
const isHtml = ext === '.html';
|
||||
const etag = '"' + st.size.toString(16) + '-' + Math.round(st.mtimeMs).toString(16) + '"';
|
||||
if (!isHtml && req.headers['if-none-match'] === etag) {
|
||||
res.writeHead(304, { ETag: etag, 'Cache-Control': 'no-cache' });
|
||||
return res.end();
|
||||
}
|
||||
fs.readFile(fp, (err, data) => {
|
||||
if (err) return json(res, 404, { error: 'not found' });
|
||||
const headers = { 'Content-Type': ct, 'Content-Length': st.size, 'Cache-Control': isHtml ? 'no-store, must-revalidate' : 'no-cache' };
|
||||
if (!isHtml) headers.ETag = etag;
|
||||
res.writeHead(200, headers);
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +83,63 @@ function handleGet(req, res) {
|
||||
rs.pipe(res);
|
||||
});
|
||||
}
|
||||
// Meeting recordings & transcripts (/mrec/<id>). Visible to the creator, group members, or those
|
||||
// who can see the scheduled meeting it belongs to.
|
||||
if (pathOnly.startsWith('/mrec/')) {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const id = path.basename(decodeURIComponent(pathOnly));
|
||||
const r = R.recordings.byId(id);
|
||||
if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' });
|
||||
let allowed = r.created_by === u.id;
|
||||
if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner
|
||||
else {
|
||||
if (!allowed && r.group_id) allowed = R.conversations.isMember(r.group_id, u.id);
|
||||
if (!allowed && r.meeting_id) { const s = R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); }
|
||||
}
|
||||
if (!allowed) return json(res, 403, { error: 'forbidden' });
|
||||
const isVideo = r.kind === 'video';
|
||||
const dir = isVideo ? REC_DIR : TRANS_DIR;
|
||||
const fp = path.join(dir, r.file);
|
||||
if (!fp.startsWith(dir)) return json(res, 403, { error: 'forbidden' });
|
||||
const ext = isVideo ? 'webm' : 'txt';
|
||||
const fname = String(r.title || 'meeting').replace(/[^a-z0-9 _-]/gi, '').trim().slice(0, 40) || 'meeting';
|
||||
return fs.stat(fp, (err, st) => {
|
||||
if (err) return json(res, 404, { error: 'not found' });
|
||||
res.writeHead(200, { 'Content-Type': r.mime || (isVideo ? 'video/webm' : 'text/plain; charset=utf-8'), 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="' + fname + '-' + (isVideo ? 'recording' : 'transcript') + '.' + ext + '"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
|
||||
const rs = fs.createReadStream(fp);
|
||||
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
|
||||
rs.pipe(res);
|
||||
});
|
||||
}
|
||||
if (pathOnly.startsWith('/files/')) {
|
||||
const u = currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const id = path.basename(decodeURIComponent(pathOnly));
|
||||
const a = R.attachments.byId(id);
|
||||
if (!a || a.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
||||
// Authorize: the uploader, a participant of the message carrying this attachment,
|
||||
// or a member of the group that uses this attachment as its image.
|
||||
const msg = R.messages.byAttachment(id);
|
||||
const avatarGroup = R.conversations.byAvatar(id);
|
||||
const allowed = a.uploader_id === u.id
|
||||
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id))
|
||||
|| (msg && (
|
||||
msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id)
|
||||
: (msg.sender_id === u.id || msg.recipient_id === u.id)));
|
||||
if (!allowed) return json(res, 403, { error: 'forbidden' });
|
||||
const fp = path.join(UPLOADS_DIR, id);
|
||||
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
|
||||
const isImage = /^image\//.test(a.mime || '');
|
||||
const safeName = String(a.name || 'file').replace(/[\r\n"]/g, '');
|
||||
return fs.stat(fp, (err, st) => {
|
||||
if (err) return json(res, 404, { error: 'not found' });
|
||||
res.writeHead(200, { 'Content-Type': a.mime || 'application/octet-stream', 'Content-Length': st.size, 'Content-Disposition': (isImage ? 'inline' : 'attachment') + '; filename="' + safeName + '"', 'Cache-Control': 'private, max-age=86400' });
|
||||
const rs = fs.createReadStream(fp);
|
||||
rs.on('error', () => { try { res.destroy(); } catch (e) {} });
|
||||
rs.pipe(res);
|
||||
});
|
||||
}
|
||||
return serveStatic(req, res);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ process.env.HTTPS_PORT = 8444; // avoid clashing with a running dev server on 84
|
||||
const { server } = require('../server');
|
||||
const A = require('../auth');
|
||||
const WebSocket = require('ws');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const BASE = `http://localhost:${PORT}`;
|
||||
let passed = 0, failed = 0;
|
||||
@@ -64,6 +66,12 @@ function nextMsg(ws, type, timeout = 3000) {
|
||||
await wait(300); // let server bind
|
||||
console.log('E2E backend tests:');
|
||||
|
||||
// Local receiver to capture outbound webhook deliveries.
|
||||
const webhookHits = [];
|
||||
const hookSrv = http.createServer((rq, rs) => { let b = ''; rq.on('data', (c) => (b += c)); rq.on('end', () => { webhookHits.push({ sig: rq.headers['x-bizgaze-signature'], body: b }); rs.writeHead(200); rs.end('ok'); }); });
|
||||
await new Promise((r) => hookSrv.listen(8077, r));
|
||||
const HOOK_URL = 'http://localhost:8077/hook';
|
||||
|
||||
// 1. Register (first user becomes admin)
|
||||
const email = 'tech@example.com';
|
||||
const reg = await call('/api/register', { email, password: 'supersecret', teamName: 'Acme IT' });
|
||||
@@ -78,6 +86,258 @@ function nextMsg(ws, type, timeout = 3000) {
|
||||
const me = await get('/api/me', cookie);
|
||||
check('me works after login, role=admin', me.status === 200 && me.data.role === 'admin');
|
||||
|
||||
// 3b. Native client path: access+refresh tokens, refresh exchange (rotated), Bearer auth
|
||||
check('login returns access + refresh tokens', !!login.data.token && !!login.data.refreshToken);
|
||||
const refreshed = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken });
|
||||
check('refresh issues a new access token', refreshed.status === 200 && !!refreshed.data.token && !!refreshed.data.refreshToken);
|
||||
const meBearer = await fetch(BASE + '/api/v1/me', { headers: { Authorization: 'Bearer ' + refreshed.data.token } });
|
||||
check('new access token authorizes /api/v1/me (Bearer)', meBearer.status === 200);
|
||||
const reuse = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken });
|
||||
check('rotated (old) refresh token is rejected', reuse.status === 401);
|
||||
|
||||
// 3c. API keys (machine-to-machine integration), scoped + revocable
|
||||
const mkKey = await call('/api/v1/keys', { name: 'ci', scopes: ['report:read'] }, cookie);
|
||||
check('admin creates API key (bzc_ prefix)', mkKey.status === 200 && /^bzc_/.test(mkKey.data.key || ''));
|
||||
const apiKey = mkKey.data.key;
|
||||
const repKey = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } });
|
||||
check('API key with report:read reads /api/v1/report', repKey.status === 200);
|
||||
const repNone = await fetch(BASE + '/api/v1/report');
|
||||
check('no credential -> /api/v1/report 401', repNone.status === 401);
|
||||
const audKey = await fetch(BASE + '/api/v1/audit', { headers: { 'X-API-Key': apiKey } });
|
||||
check('report-only key cannot read audit (scope enforced)', audKey.status === 401);
|
||||
const revKey = await call('/api/v1/keys/revoke', { id: mkKey.data.id }, cookie);
|
||||
check('admin revokes API key', revKey.status === 200);
|
||||
const repRevoked = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } });
|
||||
check('revoked API key -> 401', repRevoked.status === 401);
|
||||
|
||||
// 3d. Register a webhook (delivery is asserted after the session flow below)
|
||||
const mkHook = await call('/api/v1/webhooks', { url: HOOK_URL, events: ['*'] }, cookie);
|
||||
check('admin registers a webhook', mkHook.status === 200 && !!mkHook.data.secret);
|
||||
const hookSecret = mkHook.data.secret;
|
||||
|
||||
// 3e. Chat (persistent 1:1 messaging) — two users in the same team
|
||||
const adminId = me.data.id;
|
||||
await call('/api/v1/users', { email: 'bob@example.com', password: 'supersecret', name: 'Bob' }, cookie);
|
||||
const bobLogin = await call('/api/v1/login', { email: 'bob@example.com', password: 'supersecret' });
|
||||
const bobCookie = bobLogin.cookie;
|
||||
const contacts = await get('/api/v1/messages/contacts', cookie);
|
||||
check('contacts list includes the other user', contacts.status === 200 && contacts.data.some((c) => c.email === 'bob@example.com'));
|
||||
const bobId = (contacts.data.find((c) => c.email === 'bob@example.com') || {}).id;
|
||||
const bobWs = new WebSocket(`ws://localhost:${PORT}/ws`, { headers: { Cookie: bobCookie } });
|
||||
bobWs.q = []; bobWs.on('message', (d) => bobWs.q.push(JSON.parse(d)));
|
||||
await new Promise((r) => bobWs.on('open', r));
|
||||
bobWs.send(JSON.stringify({ type: 'chat-hello' }));
|
||||
await nextMsg(bobWs, 'chat-ready');
|
||||
check('chat-hello -> chat-ready', true);
|
||||
const sent = await call('/api/v1/messages', { to: bobId, body: 'hello bob' }, cookie);
|
||||
check('message sent', sent.status === 200 && !!sent.data.id);
|
||||
const pushed = await nextMsg(bobWs, 'chat-message');
|
||||
check('recipient receives the message live over WS', pushed.message && pushed.message.body === 'hello bob');
|
||||
const bobConvos = await get('/api/v1/messages/conversations', bobCookie);
|
||||
check('recipient conversation shows unread', bobConvos.data.some((c) => c.contactId === adminId && c.unread >= 1));
|
||||
const bobThread = await get('/api/v1/messages/thread?with=' + adminId, bobCookie);
|
||||
check('thread returns the message', bobThread.status === 200 && bobThread.data.some((m) => m.body === 'hello bob'));
|
||||
const bobConvos2 = await get('/api/v1/messages/conversations', bobCookie);
|
||||
check('reading the thread clears unread', !bobConvos2.data.some((c) => c.contactId === adminId && c.unread >= 1));
|
||||
// read receipt: after bob read the thread, admin's sent message shows read_at
|
||||
const adminTh = await get('/api/v1/messages/thread?with=' + bobId, cookie);
|
||||
const seenMsg = adminTh.data.find((x) => x.body === 'hello bob');
|
||||
check('read receipt: sent message marked read after recipient reads', !!seenMsg && !!seenMsg.read_at);
|
||||
// reply / quote
|
||||
const reply = await call('/api/v1/messages', { to: bobId, body: 'replying to you', replyTo: sent.data.id }, cookie);
|
||||
check('reply accepts replyTo', reply.status === 200 && reply.data.reply_to === sent.data.id);
|
||||
const adminThread = await get('/api/v1/messages/thread?with=' + bobId, cookie);
|
||||
check('thread carries the quoted preview', adminThread.data.some((x) => x.reply && x.reply.body === 'hello bob'));
|
||||
// reactions
|
||||
const react1 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '👍' }, cookie);
|
||||
check('reaction toggles on', react1.status === 200 && react1.data.added === true);
|
||||
const rpush = await nextMsg(bobWs, 'chat-reaction');
|
||||
check('reaction pushed live (full set, with who)', rpush.messageId === sent.data.id && Array.isArray(rpush.reactions) && rpush.reactions.some((r) => r.emoji === '👍' && r.count === 1 && Array.isArray(r.who) && r.who.length === 1));
|
||||
const th2 = await get('/api/v1/messages/thread?with=' + bobId, cookie);
|
||||
const rmsg = th2.data.find((x) => x.id === sent.data.id);
|
||||
check('thread aggregates the reaction', !!rmsg && rmsg.reactions.some((r) => r.emoji === '👍' && r.count === 1 && r.mine === true && r.who.length === 1));
|
||||
// one reaction per user: a different emoji REPLACES the previous one (no stacking)
|
||||
const react1b = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie);
|
||||
check('switching emoji replaces (one reaction per user)', react1b.data.added === true && react1b.data.reactions.length === 1 && react1b.data.reactions[0].emoji === '❤️');
|
||||
const react2 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie);
|
||||
check('reaction toggles off', react2.data.added === false && react2.data.reactions.length === 0);
|
||||
// file sharing
|
||||
const up = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'text/plain', 'X-Filename': encodeURIComponent('note.txt') }, body: 'hello file' });
|
||||
const upd = await up.json();
|
||||
check('file upload returns an id', up.status === 200 && !!upd.id);
|
||||
const fmsg = await call('/api/v1/messages', { to: bobId, attachmentId: upd.id }, cookie);
|
||||
check('message with attachment (no text) accepted', fmsg.status === 200 && fmsg.data.attachment && fmsg.data.attachment.id === upd.id);
|
||||
const dl = await fetch(BASE + '/files/' + upd.id, { headers: { Cookie: cookie } });
|
||||
const dlBody = await dl.text();
|
||||
check('attachment downloads for a participant', dl.status === 200 && dlBody === 'hello file');
|
||||
const dlNo = await fetch(BASE + '/files/' + upd.id);
|
||||
check('attachment download denied without auth', dlNo.status === 401);
|
||||
|
||||
// 3g. Group chat
|
||||
const mkGroup = await call('/api/v1/groups', { name: 'Team Huddle', memberIds: [bobId] }, cookie);
|
||||
check('group created with members', mkGroup.status === 200 && !!mkGroup.data.id && mkGroup.data.members === 2);
|
||||
const gid = mkGroup.data.id;
|
||||
bobWs.q.length = 0; // drain prior pushes so we catch the group message
|
||||
const gsend = await call('/api/v1/messages', { group: gid, body: 'hi team' }, cookie);
|
||||
check('group message sent', gsend.status === 200 && gsend.data.conversation_id === gid);
|
||||
const gpush = await nextMsg(bobWs, 'chat-message');
|
||||
check('group message delivered to a member', gpush.message && gpush.message.conversation_id === gid && gpush.message.body === 'hi team');
|
||||
const gconv = await get('/api/v1/messages/conversations', bobCookie);
|
||||
const gItem = gconv.data.find((c) => c.kind === 'group' && c.id === gid);
|
||||
check('group appears in member conversations with unread', !!gItem && gItem.unread >= 1);
|
||||
const gthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
|
||||
check('group thread returns messages with sender name', gthread.status === 200 && gthread.data.some((m) => m.body === 'hi team' && m.fromName));
|
||||
const gconv2 = await get('/api/v1/messages/conversations', bobCookie);
|
||||
const gItem2 = gconv2.data.find((c) => c.kind === 'group' && c.id === gid);
|
||||
check('reading group thread clears unread', !!gItem2 && gItem2.unread === 0);
|
||||
// group "seen by": bob read the thread above, so admin's message lists him as a reader
|
||||
const gSeen = await get('/api/v1/messages/thread?group=' + gid, cookie);
|
||||
const hiTeam = gSeen.data.find((x) => x.body === 'hi team');
|
||||
check('group message shows seen-by readers', !!hiTeam && Array.isArray(hiTeam.seenBy) && hiTeam.seenBy.length >= 1);
|
||||
const gmembers = await get('/api/v1/groups/members?group=' + gid, cookie);
|
||||
check('group members listed', gmembers.status === 200 && gmembers.data.length === 2);
|
||||
// @mentions: members + @everyone are stored; non-members are filtered out
|
||||
const ment = await call('/api/v1/messages', { group: gid, body: 'ping @Bob and @everyone', mentions: [bobId, 'everyone', 'not-a-member'] }, cookie);
|
||||
check('group message accepts mentions', ment.status === 200);
|
||||
check('mentions filtered to members + everyone', Array.isArray(ment.data.mentions) && ment.data.mentions.includes(bobId) && ment.data.mentions.includes('everyone') && !ment.data.mentions.includes('not-a-member'));
|
||||
const mthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
|
||||
const mEntry = mthread.data.find((x) => x.id === ment.data.id);
|
||||
check('thread carries mentions', !!mEntry && mEntry.mentions.includes(bobId) && mEntry.mentions.includes('everyone'));
|
||||
|
||||
// 3j. Polls (single + multi, vote/switch, close, inline in thread)
|
||||
const mkPoll = await call('/api/v1/polls', { group: gid, question: 'Lunch?', options: ['Pizza', 'Sushi'], multi: false }, cookie);
|
||||
check('poll created', mkPoll.status === 200 && mkPoll.data.options.length === 2 && mkPoll.data.totalVotes === 0);
|
||||
const pid = mkPoll.data.id;
|
||||
const needTwo = await call('/api/v1/polls', { group: gid, question: 'X?', options: ['only one'] }, cookie);
|
||||
check('poll requires >=2 options', needTwo.status === 400);
|
||||
const v1 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie);
|
||||
check('vote recorded', v1.status === 200 && v1.data.options[0].votes === 1 && v1.data.options[0].mine === true);
|
||||
const v2 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 1 }, bobCookie); // single-choice -> switch
|
||||
check('single-choice switches the vote', v2.data.options[0].votes === 0 && v2.data.options[1].votes === 1);
|
||||
const av = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, cookie);
|
||||
check('second voter counted', av.data.totalVotes === 2 && av.data.voters === 2);
|
||||
const mkPoll2 = await call('/api/v1/polls', { group: gid, question: 'Toppings?', options: ['Cheese', 'Olives', 'Mushroom'], multi: true }, cookie);
|
||||
const pid2 = mkPoll2.data.id;
|
||||
await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 0 }, bobCookie);
|
||||
const mv = await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 1 }, bobCookie);
|
||||
check('multi-choice keeps multiple votes', mv.data.options[0].votes === 1 && mv.data.options[1].votes === 1 && mv.data.voters === 1);
|
||||
const badClose = await call('/api/v1/polls/close', { pollId: pid }, bobCookie);
|
||||
check('non-creator cannot close a poll', badClose.status === 403);
|
||||
const closed = await call('/api/v1/polls/close', { pollId: pid }, cookie);
|
||||
check('creator closes the poll', closed.status === 200 && closed.data.closed === true);
|
||||
const voteClosed = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie);
|
||||
check('cannot vote on a closed poll', voteClosed.status === 400);
|
||||
const pthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
|
||||
const pmsg = pthread.data.find((x) => x.poll && x.poll.id === pid);
|
||||
check('poll renders inline in the thread', !!pmsg && pmsg.poll.options.length === 2 && pmsg.poll.closed === true);
|
||||
|
||||
// group management: rename, info, remove(leave)
|
||||
const ren = await call('/api/v1/groups/rename', { group: gid, name: 'Renamed Huddle' }, cookie);
|
||||
check('group renamed', ren.status === 200 && ren.data.name === 'Renamed Huddle');
|
||||
const gthrRen = await get('/api/v1/messages/thread?group=' + gid, cookie);
|
||||
check('rename posts an activity message', gthrRen.data.some((x) => x.system && /renamed/.test(x.body)));
|
||||
// admin-only toggle: only the creator can change it / manage members when on
|
||||
const ao1 = await call('/api/v1/groups/admin-only', { group: gid, value: true }, cookie);
|
||||
check('admin enables admin-only', ao1.status === 200 && ao1.data.adminOnly === true);
|
||||
const aoBobToggle = await call('/api/v1/groups/admin-only', { group: gid, value: false }, bobCookie);
|
||||
check('non-admin cannot change admin-only', aoBobToggle.status === 403);
|
||||
const aoBobRemove = await call('/api/v1/groups/remove', { group: gid, userId: adminId }, bobCookie);
|
||||
check('admin-only blocks non-admin removing others', aoBobRemove.status === 403);
|
||||
const ao0 = await call('/api/v1/groups/admin-only', { group: gid, value: false }, cookie);
|
||||
check('admin disables admin-only', ao0.status === 200 && ao0.data.adminOnly === false);
|
||||
// shared group call: start returns a room; a second start joins the same live call
|
||||
const call1 = await call('/api/v1/groups/call/start', { group: gid }, cookie);
|
||||
check('group call starts with a room', call1.status === 200 && /^\d{6}$/.test(call1.data.room || '') && call1.data.active === true);
|
||||
const call2 = await call('/api/v1/groups/call/start', { group: gid }, bobCookie);
|
||||
check('second start joins the same call (no code)', call2.status === 200 && call2.data.room === call1.data.room && call2.data.already === true);
|
||||
const convCall = await get('/api/v1/messages/conversations', bobCookie);
|
||||
const gcRow = convCall.data.find((c) => c.kind === 'group' && c.id === gid);
|
||||
check('conversations expose the active call', !!gcRow && gcRow.callActive === true && gcRow.callRoom === call1.data.room);
|
||||
// 1:1 call + add-participant invite
|
||||
const dmCall1 = await call('/api/v1/calls/dm/start', { to: bobId }, cookie);
|
||||
check('DM call starts with a room', dmCall1.status === 200 && /^\d{6}$/.test(dmCall1.data.room || '') && dmCall1.data.active === true);
|
||||
const dmCall2 = await call('/api/v1/calls/dm/start', { to: adminId }, bobCookie);
|
||||
check('DM call join returns the same room', dmCall2.status === 200 && dmCall2.data.room === dmCall1.data.room && dmCall2.data.already === true);
|
||||
const inv = await call('/api/v1/calls/invite', { room: dmCall1.data.room, userIds: [bobId] }, cookie);
|
||||
check('invite to a live call accepted', inv.status === 200 && inv.data.invited === 1);
|
||||
const invBad = await call('/api/v1/calls/invite', { room: '000000', userIds: [bobId] }, cookie);
|
||||
check('invite to a non-existent call rejected', invBad.status === 404);
|
||||
const ginfo = await get('/api/v1/groups/info?group=' + gid, cookie);
|
||||
check('group info returns name + members + isCreator', ginfo.status === 200 && ginfo.data.name === 'Renamed Huddle' && ginfo.data.isCreator === true && ginfo.data.members.length === 2);
|
||||
|
||||
// 3h. Scheduled meetings (schedule -> announce -> list buckets -> lazy join -> cancel)
|
||||
const future = Date.now() + 24 * 60 * 60 * 1000;
|
||||
bobWs.q.length = 0;
|
||||
const sched = await call('/api/v1/meetings/schedule', { group: gid, title: 'Sprint Review', description: 'Demo + retro', scheduledAt: future, whenText: 'Tomorrow' }, cookie);
|
||||
check('meeting scheduled, returns 6-digit room code', sched.status === 200 && /^\d{6}$/.test(sched.data.roomCode || ''));
|
||||
const schP = await call('/api/v1/meetings/schedule', { title: 'Synced', scheduledAt: Date.now() + 3600000, participants: [bobId] }, cookie);
|
||||
check('meeting scheduled with participants', schP.status === 200 && Array.isArray(schP.data.participants) && schP.data.participants.includes(bobId));
|
||||
const bobMeetings = await get('/api/v1/meetings', bobCookie);
|
||||
const bm = bobMeetings.data.find((m) => m.id === schP.data.id);
|
||||
check('invited participant sees the meeting', !!bm && bm.isHost === false);
|
||||
const schPush = await nextMsg(bobWs, 'chat-message');
|
||||
check('schedule announced in the group chat', schPush.message && /Scheduled a call/.test(schPush.message.body));
|
||||
const pastM = await call('/api/v1/meetings/schedule', { group: gid, title: 'Old Standup', scheduledAt: Date.now() - 2 * 60 * 60 * 1000 }, cookie);
|
||||
check('past meeting scheduling is rejected', pastM.status === 400); // can't schedule in the past (#1)
|
||||
const mlist = await get('/api/v1/meetings', cookie);
|
||||
const schUp = mlist.data.find((m) => m.id === sched.data.id);
|
||||
check('meetings list buckets upcoming', mlist.status === 200 && schUp && schUp.status === 'upcoming');
|
||||
const sm = wsClient(); await new Promise((r) => sm.on('open', r));
|
||||
sm.send(JSON.stringify({ type: 'meeting-join', room: sched.data.roomCode, name: 'Admin' }));
|
||||
const smJoined = await nextMsg(sm, 'meeting-joined');
|
||||
check('scheduled meeting joinable by code (room created lazily)', !!smJoined.peerId && smJoined.room === sched.data.roomCode);
|
||||
const mlist2 = await get('/api/v1/meetings', cookie);
|
||||
const upRun = mlist2.data.find((m) => m.id === sched.data.id);
|
||||
check('scheduled meeting shows running while a peer is connected', !!upRun && upRun.status === 'running' && upRun.inCall >= 1);
|
||||
sm.close();
|
||||
const cancelBob = await call('/api/v1/meetings/cancel', { id: sched.data.id }, bobCookie);
|
||||
check('non-organizer cannot cancel a meeting', cancelBob.status === 403);
|
||||
const cancelOk = await call('/api/v1/meetings/cancel', { id: sched.data.id }, cookie);
|
||||
check('organizer cancels the meeting', cancelOk.status === 200);
|
||||
|
||||
// 3i. Group image (upload -> set as group avatar -> visible to members)
|
||||
const imgUp = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-Filename': encodeURIComponent('logo.png') }, body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) });
|
||||
const imgUpd = await imgUp.json();
|
||||
check('group image uploaded', imgUp.status === 200 && !!imgUpd.id);
|
||||
const badAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: upd.id }, cookie); // upd.id is text/plain
|
||||
check('non-image rejected as group photo', badAv.status === 400);
|
||||
const setAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: imgUpd.id }, cookie);
|
||||
check('group photo set', setAv.status === 200 && setAv.data.avatar === '/files/' + imgUpd.id);
|
||||
const ginfoAv = await get('/api/v1/groups/info?group=' + gid, cookie);
|
||||
check('group info exposes the photo', ginfoAv.status === 200 && ginfoAv.data.avatar === '/files/' + imgUpd.id);
|
||||
const memberFetch = await fetch(BASE + '/files/' + imgUpd.id, { headers: { Cookie: bobCookie } });
|
||||
check('group member can fetch the group photo', memberFetch.status === 200);
|
||||
|
||||
const bobLeave = await call('/api/v1/groups/remove', { group: gid }, bobCookie); // bob leaves
|
||||
check('member can leave the group', bobLeave.status === 200 && bobLeave.data.left === true);
|
||||
const gthrLeft = await get('/api/v1/messages/thread?group=' + gid, cookie);
|
||||
check('leaving posts an activity message', gthrLeft.data.some((x) => x.system && /left/.test(x.body)));
|
||||
const ginfo2 = await get('/api/v1/groups/info?group=' + gid, cookie);
|
||||
check('member count drops after leave', ginfo2.status === 200 && ginfo2.data.members.length === 1);
|
||||
bobWs.close();
|
||||
|
||||
// 3f. Meetings (mesh) signaling: create room, two peers join, relay signal, leave
|
||||
const alice = wsClient(); await new Promise((r) => alice.on('open', r));
|
||||
alice.send(JSON.stringify({ type: 'meeting-create' }));
|
||||
const created = await nextMsg(alice, 'meeting-created');
|
||||
check('meeting-create returns a 6-digit room code', /^\d{6}$/.test(created.room || ''));
|
||||
alice.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Alice' }));
|
||||
const aJoined = await nextMsg(alice, 'meeting-joined');
|
||||
check('first peer joins (room empty)', !!aJoined.peerId && aJoined.peers.length === 0);
|
||||
const carol = wsClient(); await new Promise((r) => carol.on('open', r));
|
||||
carol.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Carol' }));
|
||||
const cJoined = await nextMsg(carol, 'meeting-joined');
|
||||
check('second peer sees the first in the room', cJoined.peers.some((p) => p.peerId === aJoined.peerId));
|
||||
const aPeerJoined = await nextMsg(alice, 'meeting-peer-joined');
|
||||
check('existing peer notified of newcomer', aPeerJoined.peerId === cJoined.peerId);
|
||||
alice.send(JSON.stringify({ type: 'meeting-signal', to: cJoined.peerId, data: { fake: 'offer' } }));
|
||||
const relayed = await nextMsg(carol, 'meeting-signal');
|
||||
check('meeting signal relayed peer->peer', relayed.from === aJoined.peerId && relayed.data.fake === 'offer');
|
||||
carol.close();
|
||||
const aPeerLeft = await nextMsg(alice, 'meeting-peer-left');
|
||||
check('peer-left delivered on disconnect', aPeerLeft.peerId === cJoined.peerId);
|
||||
alice.close();
|
||||
|
||||
// 4. Wrong password rejected
|
||||
const badLogin = await call('/api/login', { email, password: 'wrong' });
|
||||
check('wrong password rejected', badLogin.status === 401);
|
||||
@@ -130,6 +390,15 @@ function nextMsg(ws, type, timeout = 3000) {
|
||||
await nextMsg(agent, 'session-ended');
|
||||
check('session-ended delivered to agent', true);
|
||||
|
||||
// 14b. Outbound webhook delivery (session.started + session.ended) with valid signatures
|
||||
await wait(900);
|
||||
const parse = (h) => { try { return JSON.parse(h.body); } catch { return {}; } };
|
||||
const hookStarted = webhookHits.find((h) => parse(h).event === 'session.started');
|
||||
const hookEnded = webhookHits.find((h) => parse(h).event === 'session.ended');
|
||||
check('webhook received session.started', !!hookStarted);
|
||||
check('webhook received session.ended', !!hookEnded);
|
||||
check('webhook signature is valid (HMAC-SHA256)', !!hookEnded && hookEnded.sig === crypto.createHmac('sha256', hookSecret).update(hookEnded.body).digest('base64url'));
|
||||
|
||||
// 15. Audit log captured the full flow
|
||||
const audit = await get('/api/audit', cookie);
|
||||
const actions = audit.data.map((a) => a.action);
|
||||
@@ -146,6 +415,7 @@ function nextMsg(ws, type, timeout = 3000) {
|
||||
check('consent denial -> viewer session-denied', !!denied);
|
||||
|
||||
agent.close(); viewer.close();
|
||||
hookSrv.close();
|
||||
console.log(`\n${passed} passed, ${failed} failed.`);
|
||||
server.close();
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Outbound webhook delivery. emit(event, tenantId, payload) fans the event out to every
|
||||
// active per-tenant subscription registered for that event, plus the legacy global
|
||||
// BIZGAZE_WEBHOOK_URL (back-compat). Each delivery is HMAC-signed and retried on failure.
|
||||
//
|
||||
// NOTE (roadmap): retries are in-memory/best-effort. For guaranteed delivery this should
|
||||
// move to a persistent queue when the app scales to multiple instances (see ARCHITECTURE.md).
|
||||
const R = require('./repos');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const EVENTS = ['session.started', 'session.ended'];
|
||||
|
||||
function sign(secret, body) {
|
||||
return crypto.createHmac('sha256', secret || '').update(body).digest('base64url');
|
||||
}
|
||||
|
||||
const RETRY_DELAYS = [2000, 10000, 30000]; // after the first attempt
|
||||
function deliver(url, secret, body, onDone) {
|
||||
let attempt = 0;
|
||||
const go = async () => {
|
||||
attempt++;
|
||||
let ok = false, status = 0, err = null;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sign(secret, body), 'X-BizGaze-Event': (() => { try { return JSON.parse(body).event; } catch { return ''; } })() },
|
||||
body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
status = res.status; ok = res.ok;
|
||||
} catch (e) { err = (e && e.message) || 'delivery failed'; }
|
||||
if (ok || attempt > RETRY_DELAYS.length) { if (onDone) onDone({ ok, status, err }); return; }
|
||||
setTimeout(go, RETRY_DELAYS[attempt - 1]);
|
||||
};
|
||||
go();
|
||||
}
|
||||
|
||||
function emit(event, tenantId, payload) {
|
||||
const body = JSON.stringify({ event, ...payload });
|
||||
// Per-tenant subscriptions
|
||||
try {
|
||||
for (const h of R.webhooks.activeForTenant(tenantId)) {
|
||||
const subs = String(h.events || '').split(',').map((s) => s.trim());
|
||||
if (subs.includes('*') || subs.includes(event)) {
|
||||
deliver(h.url, h.secret, body, (r) => { try { R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} });
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
// Legacy global webhook (back-compat): session.ended → BIZGAZE_WEBHOOK_URL, signed with SSO_SECRET.
|
||||
if (event === 'session.ended' && process.env.BIZGAZE_WEBHOOK_URL) {
|
||||
deliver(process.env.BIZGAZE_WEBHOOK_URL, process.env.SSO_SECRET || '', body);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { emit, sign, EVENTS };
|
||||
Reference in New Issue
Block a user