zyro-gateway 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +226 -0
- package/bin/zyro-gateway.js +25 -0
- package/dist/zyro.js +395 -0
- package/package.json +76 -0
- package/scripts/build-zyro.js +22 -0
- package/scripts/init-config.js +21 -0
- package/scripts/load-zyro-config.js +4 -0
- package/server.js +4 -0
- package/src/config/load-config.js +68 -0
- package/src/config/pairing.js +21 -0
- package/src/index.js +19 -0
- package/src/server/broadcast.js +42 -0
- package/src/server/create-gateway.js +123 -0
- package/src/server/devices.js +49 -0
- package/src/server/rooms.js +71 -0
- package/src/server/routes.js +191 -0
- package/src/server/socket.js +115 -0
- package/src/server/terminal.js +49 -0
- package/src/utils/format.js +26 -0
- package/src/utils/network.js +17 -0
- package/zyro/browser-entry.js +1 -0
- package/zyro/zyro.js +415 -0
- package/zyro.config.example.js +22 -0
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zyro-gateway",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zyro Gateway — real-time phone ↔ desktop sync (Node server + browser client)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "orod-codes",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/orod-codes/zyro-getway.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/orod-codes/zyro-getway#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/orod-codes/zyro-getway/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/zyro.js",
|
|
16
|
+
"module": "zyro/zyro.js",
|
|
17
|
+
"browser": "dist/zyro.js",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": "./zyro/zyro.js",
|
|
21
|
+
"require": "./dist/zyro.js",
|
|
22
|
+
"default": "./dist/zyro.js"
|
|
23
|
+
},
|
|
24
|
+
"./server": {
|
|
25
|
+
"require": "./src/index.js",
|
|
26
|
+
"default": "./src/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./config.example": "./zyro.config.example.js",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin",
|
|
33
|
+
"dist",
|
|
34
|
+
"src",
|
|
35
|
+
"zyro",
|
|
36
|
+
"scripts",
|
|
37
|
+
"zyro.config.example.js",
|
|
38
|
+
"server.js",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"start": "node server.js",
|
|
45
|
+
"dev": "node --watch server.js",
|
|
46
|
+
"build": "node scripts/build-zyro.js",
|
|
47
|
+
"config": "node scripts/init-config.js",
|
|
48
|
+
"prestart": "npm run build",
|
|
49
|
+
"prepare": "npm run build",
|
|
50
|
+
"prepublishOnly": "npm run build"
|
|
51
|
+
},
|
|
52
|
+
"bin": {
|
|
53
|
+
"zyro-gateway": "bin/zyro-gateway.js"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=18"
|
|
57
|
+
},
|
|
58
|
+
"keywords": [
|
|
59
|
+
"zyro",
|
|
60
|
+
"zyro-gateway",
|
|
61
|
+
"socket.io",
|
|
62
|
+
"real-time",
|
|
63
|
+
"payment-sync",
|
|
64
|
+
"telebirr",
|
|
65
|
+
"ethiopia",
|
|
66
|
+
"flutter",
|
|
67
|
+
"sms-monitor"
|
|
68
|
+
],
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"express": "^4.21.2",
|
|
71
|
+
"socket.io": "^4.8.1"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"esbuild": "^0.25.0"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const root = path.join(__dirname, '..');
|
|
5
|
+
const entry = path.join(root, 'zyro', 'browser-entry.js');
|
|
6
|
+
const outFile = path.join(root, 'dist', 'zyro.js');
|
|
7
|
+
|
|
8
|
+
const esbuild = require('esbuild');
|
|
9
|
+
|
|
10
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
11
|
+
|
|
12
|
+
esbuild.buildSync({
|
|
13
|
+
entryPoints: [entry],
|
|
14
|
+
bundle: true,
|
|
15
|
+
format: 'iife',
|
|
16
|
+
globalName: 'Zyro',
|
|
17
|
+
outfile: outFile,
|
|
18
|
+
platform: 'browser',
|
|
19
|
+
target: ['es2020'],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
console.log('Built', outFile);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const root = path.join(__dirname, '..');
|
|
6
|
+
const example = path.join(root, 'zyro.config.example.js');
|
|
7
|
+
const target = path.join(process.cwd(), 'zyro.config.js');
|
|
8
|
+
|
|
9
|
+
if (fs.existsSync(target)) {
|
|
10
|
+
console.log('Already exists:', target);
|
|
11
|
+
process.exit(0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!fs.existsSync(example)) {
|
|
15
|
+
console.error('Missing', example);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fs.copyFileSync(example, target);
|
|
20
|
+
console.log('Created', target);
|
|
21
|
+
console.log('Edit ip, port, pairingCode then run: npx zyro-gateway');
|
package/server.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { createRequire } = require('module');
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
ip: '',
|
|
9
|
+
port: 3000,
|
|
10
|
+
pairingCode: '',
|
|
11
|
+
deviceName: '',
|
|
12
|
+
autoConnect: true,
|
|
13
|
+
pollIntervalMs: 1500,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function resolveConfigPath(packageDir) {
|
|
17
|
+
if (process.env.ZYRO_CONFIG) {
|
|
18
|
+
return path.resolve(process.env.ZYRO_CONFIG);
|
|
19
|
+
}
|
|
20
|
+
const cwdFile = path.join(process.cwd(), 'zyro.config.js');
|
|
21
|
+
if (fs.existsSync(cwdFile)) return cwdFile;
|
|
22
|
+
const pkgFile = path.join(packageDir, 'zyro.config.js');
|
|
23
|
+
if (fs.existsSync(pkgFile)) return pkgFile;
|
|
24
|
+
return cwdFile;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseExportDefault(text) {
|
|
28
|
+
const out = { ...DEFAULTS };
|
|
29
|
+
const ipMatch = text.match(/ip:\s*['"]([^'"]*)['"]/);
|
|
30
|
+
const portMatch = text.match(/port:\s*(\d+)/);
|
|
31
|
+
const pairingMatch = text.match(/pairingCode:\s*['"]([^'"]+)['"]/);
|
|
32
|
+
const deviceMatch = text.match(/deviceName:\s*['"]([^'"]+)['"]/);
|
|
33
|
+
const autoMatch = text.match(/autoConnect:\s*(true|false)/);
|
|
34
|
+
const pollMatch = text.match(/pollIntervalMs:\s*(\d+)/);
|
|
35
|
+
if (ipMatch) out.ip = ipMatch[1].trim();
|
|
36
|
+
if (portMatch) out.port = Number(portMatch[1]);
|
|
37
|
+
if (pairingMatch) out.pairingCode = pairingMatch[1].trim();
|
|
38
|
+
if (deviceMatch) out.deviceName = deviceMatch[1].trim();
|
|
39
|
+
if (autoMatch) out.autoConnect = autoMatch[1] === 'true';
|
|
40
|
+
if (pollMatch) out.pollIntervalMs = Number(pollMatch[1]);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadZyroConfig(packageDir) {
|
|
45
|
+
const configPath = resolveConfigPath(packageDir);
|
|
46
|
+
if (!fs.existsSync(configPath)) {
|
|
47
|
+
return { ...DEFAULTS, configPath, loaded: false };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const text = fs.readFileSync(configPath, 'utf8');
|
|
51
|
+
let parsed = { ...DEFAULTS };
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
if (/module\.exports\s*=/.test(text)) {
|
|
55
|
+
const req = createRequire(configPath);
|
|
56
|
+
const mod = req(configPath);
|
|
57
|
+
parsed = { ...DEFAULTS, ...(mod?.default || mod) };
|
|
58
|
+
} else {
|
|
59
|
+
parsed = parseExportDefault(text);
|
|
60
|
+
}
|
|
61
|
+
} catch (_) {
|
|
62
|
+
parsed = parseExportDefault(text);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { ...parsed, configPath, loaded: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { loadZyroConfig, resolveConfigPath, DEFAULTS };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function normalizePairing(raw) {
|
|
4
|
+
const p = String(raw || '')
|
|
5
|
+
.trim()
|
|
6
|
+
.toUpperCase()
|
|
7
|
+
.replace(/[^A-Z0-9]/g, '');
|
|
8
|
+
if (p.length < 4 || p.length > 12) return null;
|
|
9
|
+
return p;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resolvePairing(req, fallback) {
|
|
13
|
+
return (
|
|
14
|
+
normalizePairing(req?.query?.pairing) ||
|
|
15
|
+
normalizePairing(req?.headers?.['x-pairing']) ||
|
|
16
|
+
normalizePairing(fallback) ||
|
|
17
|
+
null
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { normalizePairing, resolvePairing };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { createGateway } = require('./server/create-gateway');
|
|
5
|
+
|
|
6
|
+
function start(options = {}) {
|
|
7
|
+
const packageRoot = options.packageRoot || path.join(__dirname, '..');
|
|
8
|
+
const gateway = createGateway({ packageRoot });
|
|
9
|
+
return gateway.start(options.listen);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (require.main === module) {
|
|
13
|
+
start().catch((err) => {
|
|
14
|
+
console.error(err);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { createGateway, start };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getRoom, buildDashboardPayload } = require('./rooms');
|
|
4
|
+
const { printIncome } = require('./terminal');
|
|
5
|
+
const { touchHttpPhone } = require('./devices');
|
|
6
|
+
|
|
7
|
+
function createBroadcaster(io) {
|
|
8
|
+
function broadcastIncome(roomKey, payload) {
|
|
9
|
+
const room = getRoom(roomKey);
|
|
10
|
+
const roomName = `pairing:${roomKey}`;
|
|
11
|
+
const tx = { ...payload, receivedAt: new Date().toISOString() };
|
|
12
|
+
room.transactions.unshift(tx);
|
|
13
|
+
if (room.transactions.length > 500) room.transactions.length = 500;
|
|
14
|
+
printIncome(roomKey, tx);
|
|
15
|
+
io.to(roomName).emit('income_transaction', tx);
|
|
16
|
+
io.to(roomName).emit('dashboard_update', buildDashboardPayload(room, roomKey));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function broadcastNotification(roomKey, payload) {
|
|
20
|
+
const room = getRoom(roomKey);
|
|
21
|
+
const roomName = `pairing:${roomKey}`;
|
|
22
|
+
const note = {
|
|
23
|
+
...payload,
|
|
24
|
+
receivedAt: new Date().toISOString(),
|
|
25
|
+
};
|
|
26
|
+
if (!note.id) {
|
|
27
|
+
note.id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
28
|
+
}
|
|
29
|
+
room.notifications.unshift(note);
|
|
30
|
+
if (room.notifications.length > 200) room.notifications.length = 200;
|
|
31
|
+
io.to(roomName).emit('notification_event', note);
|
|
32
|
+
io.to(roomName).emit('dashboard_update', buildDashboardPayload(room, roomKey));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bumpPhonePresence(roomKey, meta = {}) {
|
|
36
|
+
touchHttpPhone(io, roomKey, meta);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { broadcastIncome, broadcastNotification, bumpPhonePresence };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { createBroadcaster };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const express = require('express');
|
|
6
|
+
const { Server } = require('socket.io');
|
|
7
|
+
const { loadZyroConfig } = require('../config/load-config');
|
|
8
|
+
const { normalizePairing } = require('../config/pairing');
|
|
9
|
+
const { getLocalIp } = require('../utils/network');
|
|
10
|
+
const { createBroadcaster } = require('./broadcast');
|
|
11
|
+
const { registerRoutes } = require('./routes');
|
|
12
|
+
const { attachSocketHandlers } = require('./socket');
|
|
13
|
+
const { printStartup } = require('./terminal');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create an Express + Socket.IO gateway instance (does not listen until `.start()`).
|
|
17
|
+
* @param {{ packageRoot?: string }} [options]
|
|
18
|
+
*/
|
|
19
|
+
function createGateway(options = {}) {
|
|
20
|
+
const packageRoot =
|
|
21
|
+
options.packageRoot || path.join(__dirname, '../..');
|
|
22
|
+
const zyroConfig = loadZyroConfig(packageRoot);
|
|
23
|
+
const configPath = zyroConfig.configPath;
|
|
24
|
+
const envPort = process.env.PORT ? Number(process.env.PORT) : null;
|
|
25
|
+
const port =
|
|
26
|
+
(Number.isFinite(envPort) && envPort > 0 ? envPort : null) ??
|
|
27
|
+
zyroConfig.port ??
|
|
28
|
+
3000;
|
|
29
|
+
const configPairing = normalizePairing(zyroConfig.pairingCode) || '';
|
|
30
|
+
|
|
31
|
+
function publicIp() {
|
|
32
|
+
return zyroConfig.ip || getLocalIp();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const app = express();
|
|
36
|
+
app.use(express.json({ limit: '256kb' }));
|
|
37
|
+
app.use((req, res, next) => {
|
|
38
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
39
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Pairing');
|
|
40
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
41
|
+
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
42
|
+
next();
|
|
43
|
+
});
|
|
44
|
+
app.use(
|
|
45
|
+
'/zyro',
|
|
46
|
+
express.static(path.join(packageRoot, 'dist'), {
|
|
47
|
+
setHeaders(res, filePath) {
|
|
48
|
+
if (filePath.endsWith('.js')) {
|
|
49
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const server = http.createServer(app);
|
|
56
|
+
const io = new Server(server, {
|
|
57
|
+
cors: { origin: '*' },
|
|
58
|
+
transports: ['polling', 'websocket'],
|
|
59
|
+
pingTimeout: 120000,
|
|
60
|
+
pingInterval: 25000,
|
|
61
|
+
connectTimeout: 60000,
|
|
62
|
+
allowEIO3: true,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const broadcaster = createBroadcaster(io);
|
|
66
|
+
const ctx = {
|
|
67
|
+
packageRoot,
|
|
68
|
+
zyroConfig,
|
|
69
|
+
configPath,
|
|
70
|
+
port,
|
|
71
|
+
configPairing,
|
|
72
|
+
publicIp,
|
|
73
|
+
io,
|
|
74
|
+
broadcaster,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
registerRoutes(app, ctx);
|
|
78
|
+
attachSocketHandlers(io, ctx);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
app,
|
|
82
|
+
server,
|
|
83
|
+
io,
|
|
84
|
+
config: zyroConfig,
|
|
85
|
+
port,
|
|
86
|
+
pairingCode: configPairing,
|
|
87
|
+
publicIp,
|
|
88
|
+
/**
|
|
89
|
+
* @param {{ host?: string }} [listenOptions]
|
|
90
|
+
* @returns {Promise<{ url: string, port: number, pairingCode: string }>}
|
|
91
|
+
*/
|
|
92
|
+
start(listenOptions = {}) {
|
|
93
|
+
const host = listenOptions.host ?? '0.0.0.0';
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
server.once('error', reject);
|
|
96
|
+
server.listen(port, host, () => {
|
|
97
|
+
server.removeListener('error', reject);
|
|
98
|
+
const ip = publicIp();
|
|
99
|
+
printStartup({
|
|
100
|
+
configPath,
|
|
101
|
+
configLoaded: zyroConfig.loaded,
|
|
102
|
+
pairing: configPairing,
|
|
103
|
+
ip,
|
|
104
|
+
port,
|
|
105
|
+
});
|
|
106
|
+
resolve({
|
|
107
|
+
url: `http://${ip}:${port}`,
|
|
108
|
+
port,
|
|
109
|
+
pairingCode: configPairing,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
stop() {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
io.close();
|
|
117
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { createGateway };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getRoom, listDevices, presencePayload } = require('./rooms');
|
|
4
|
+
const { printConnectedDevices } = require('./terminal');
|
|
5
|
+
|
|
6
|
+
function defaultDeviceName(role) {
|
|
7
|
+
if (role === 'desktop') return 'Web Dashboard';
|
|
8
|
+
return 'Zyro Phone';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function upsertDevice(room, socket, meta = {}) {
|
|
12
|
+
const device = {
|
|
13
|
+
id: socket.id,
|
|
14
|
+
role: socket.data.role || 'phone',
|
|
15
|
+
deviceName:
|
|
16
|
+
meta.deviceName || meta.name || defaultDeviceName(socket.data.role),
|
|
17
|
+
platform: meta.platform || 'unknown',
|
|
18
|
+
connectedAt: meta.connectedAt || new Date().toISOString(),
|
|
19
|
+
lastSeen: new Date().toISOString(),
|
|
20
|
+
online: true,
|
|
21
|
+
};
|
|
22
|
+
room.deviceMap.set(socket.id, device);
|
|
23
|
+
return device;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function touchHttpPhone(io, roomKey, meta = {}) {
|
|
27
|
+
const room = getRoom(roomKey);
|
|
28
|
+
const stableId = String(meta.deviceId || 'phone').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
29
|
+
const id = `http:${roomKey}:${stableId}`;
|
|
30
|
+
const prev = room.deviceMap.get(id);
|
|
31
|
+
const device = {
|
|
32
|
+
id,
|
|
33
|
+
role: 'phone',
|
|
34
|
+
deviceName: meta.deviceName || prev?.deviceName || 'Zyro Phone',
|
|
35
|
+
platform: meta.platform || 'android',
|
|
36
|
+
connectedAt: prev?.connectedAt || new Date().toISOString(),
|
|
37
|
+
lastSeen: new Date().toISOString(),
|
|
38
|
+
online: true,
|
|
39
|
+
via: 'http',
|
|
40
|
+
};
|
|
41
|
+
room.deviceMap.set(id, device);
|
|
42
|
+
const roomName = `pairing:${roomKey}`;
|
|
43
|
+
io.to(roomName).emit('presence', presencePayload(room));
|
|
44
|
+
io.to(roomName).emit('device_joined', device);
|
|
45
|
+
printConnectedDevices(roomKey, listDevices(room));
|
|
46
|
+
return device;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { defaultDeviceName, upsertDevice, touchHttpPhone };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** @type {Map<string, object>} */
|
|
4
|
+
const rooms = new Map();
|
|
5
|
+
|
|
6
|
+
function getRoom(pairing) {
|
|
7
|
+
const key = pairing.toUpperCase();
|
|
8
|
+
if (!rooms.has(key)) {
|
|
9
|
+
rooms.set(key, {
|
|
10
|
+
phones: 0,
|
|
11
|
+
desktops: 0,
|
|
12
|
+
transactions: [],
|
|
13
|
+
notifications: [],
|
|
14
|
+
deviceMap: new Map(),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return rooms.get(key);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function listDevices(room) {
|
|
21
|
+
return Array.from(room.deviceMap.values()).sort(
|
|
22
|
+
(a, b) => new Date(b.connectedAt) - new Date(a.connectedAt),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function presencePayload(room) {
|
|
27
|
+
const devices = listDevices(room);
|
|
28
|
+
return {
|
|
29
|
+
phones: devices.filter((d) => d.role === 'phone').length,
|
|
30
|
+
desktops: devices.filter((d) => d.role === 'desktop').length,
|
|
31
|
+
devices,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildDashboardPayload(room, roomKey) {
|
|
36
|
+
const start = new Date();
|
|
37
|
+
start.setHours(0, 0, 0, 0);
|
|
38
|
+
const todayTx = room.transactions.filter((tx) => {
|
|
39
|
+
const t = new Date(tx.timestamp || tx.receivedAt);
|
|
40
|
+
return t >= start;
|
|
41
|
+
});
|
|
42
|
+
const todayTotal = todayTx.reduce(
|
|
43
|
+
(sum, tx) => sum + (Number(tx.amount) || 0),
|
|
44
|
+
0,
|
|
45
|
+
);
|
|
46
|
+
const matchedNotes = room.notifications.filter((n) => n.matched).length;
|
|
47
|
+
const devices = listDevices(room);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
serverTime: new Date().toISOString(),
|
|
51
|
+
pairingCode: roomKey,
|
|
52
|
+
stats: {
|
|
53
|
+
todayTotal,
|
|
54
|
+
transactionCount: room.transactions.length,
|
|
55
|
+
todayTransactionCount: todayTx.length,
|
|
56
|
+
notificationCount: room.notifications.length,
|
|
57
|
+
matchedNotificationCount: matchedNotes,
|
|
58
|
+
phones: devices.filter((d) => d.role === 'phone').length,
|
|
59
|
+
desktops: devices.filter((d) => d.role === 'desktop').length,
|
|
60
|
+
},
|
|
61
|
+
recentTransactions: room.transactions.slice(0, 8),
|
|
62
|
+
recentNotifications: room.notifications.slice(0, 8),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
getRoom,
|
|
68
|
+
listDevices,
|
|
69
|
+
presencePayload,
|
|
70
|
+
buildDashboardPayload,
|
|
71
|
+
};
|