@alizadefaig/pos-server 1.2.26 → 1.2.28

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.
Files changed (117) hide show
  1. package/app/core/Context.js +170 -0
  2. package/app/core/auth/index.js +133 -0
  3. package/app/core/auth/license.js +45 -0
  4. package/app/core/auth/posMode.js +260 -0
  5. package/app/core/auth/sanctum.js +209 -0
  6. package/app/core/auth/scope.js +231 -0
  7. package/app/core/emitter.js +67 -0
  8. package/app/core/errors.js +18 -0
  9. package/app/core/guard.js +54 -0
  10. package/app/core/idempotency.js +196 -0
  11. package/app/core/index.js +374 -0
  12. package/app/core/models/Order.js +176 -0
  13. package/app/core/models/OrderProduct.js +249 -0
  14. package/app/core/repositories/EventRepository.js +51 -0
  15. package/app/core/repositories/OrderProductRepository.js +225 -0
  16. package/app/core/repositories/OrderRepository.js +274 -0
  17. package/app/core/repositories/OutboxRepository.js +52 -0
  18. package/app/core/repositories/PaymentRepository.js +43 -0
  19. package/app/core/repositories/PrinterRepository.js +179 -0
  20. package/app/core/repositories/ProductRelations.js +148 -0
  21. package/app/core/repositories/SnapshotRepository.js +197 -0
  22. package/app/core/resources/HallResource.js +54 -0
  23. package/app/core/resources/OrderResource.js +177 -0
  24. package/app/core/resources/ProductResource.js +186 -0
  25. package/app/core/resources/ShiftResource.js +42 -0
  26. package/app/core/resources/TeamResource.js +117 -0
  27. package/app/core/resources/casts.js +55 -0
  28. package/app/core/services/AuthService.js +468 -0
  29. package/app/core/services/InitService.js +279 -0
  30. package/app/core/services/KdsService.js +639 -0
  31. package/app/core/services/OrderProductService.js +309 -0
  32. package/app/core/services/OrderService.js +911 -0
  33. package/app/core/services/PrintService.js +715 -0
  34. package/app/core/services/PrinterAdminService.js +324 -0
  35. package/app/core/services/ReportService.js +581 -0
  36. package/app/core/services/ShiftService.js +199 -0
  37. package/app/core/services/StorageService.js +425 -0
  38. package/app/core/services/TelegramService.js +162 -0
  39. package/app/core/support/db.js +48 -0
  40. package/app/core/support/ids.js +9 -0
  41. package/app/core/support/imageData.js +135 -0
  42. package/app/core/support/images.js +92 -0
  43. package/app/core/support/lanHandoff.js +105 -0
  44. package/app/core/support/lang.js +98 -0
  45. package/app/core/support/lock.js +63 -0
  46. package/app/core/support/num.js +51 -0
  47. package/app/core/support/php.js +125 -0
  48. package/app/core/support/pin.js +34 -0
  49. package/app/core/support/receipts.js +278 -0
  50. package/app/core/support/rows.js +38 -0
  51. package/app/core/support/str.js +31 -0
  52. package/app/core/support/teamSettings.js +77 -0
  53. package/app/core/support/telegram.js +253 -0
  54. package/app/core/support/telegramReport.js +143 -0
  55. package/app/core/support/time.js +58 -0
  56. package/app/core/support/tz.js +80 -0
  57. package/app/core/support/unlock.js +113 -0
  58. package/app/core/timers.js +151 -0
  59. package/app/db/Database.js +197 -1
  60. package/app/db/Migrator.js +45 -0
  61. package/app/db/driver.js +248 -0
  62. package/app/db/migrations/001_snapshot.sql +214 -0
  63. package/app/db/migrations/002_orders.sql +190 -0
  64. package/app/db/migrations/003_idempotency.sql +18 -0
  65. package/app/db/migrations/004_shifts.sql +21 -0
  66. package/app/db/migrations/005_printers.sql +43 -0
  67. package/app/db/migrations/006_sync.sql +136 -0
  68. package/app/db/migrations/007_printer_mac.sql +4 -0
  69. package/app/db/migrations/008_kds.sql +94 -0
  70. package/app/db/migrations/009_product_categories.sql +20 -0
  71. package/app/db/migrations/010_printers_sync.sql +43 -0
  72. package/app/db/migrations/011_team_logos.sql +13 -0
  73. package/app/db/migrations/012_category_workshop.sql +4 -0
  74. package/app/monitor.js +185 -0
  75. package/app/printers/address.js +348 -0
  76. package/app/proxy.js +11 -3
  77. package/app/relay/masters.js +173 -0
  78. package/app/security.js +218 -0
  79. package/app/socket/auth.js +160 -0
  80. package/app/socket/request.js +87 -0
  81. package/app/socket/server.js +121 -19
  82. package/app/sync/bridge.js +162 -0
  83. package/app/sync/cloud.js +120 -0
  84. package/app/sync/config.js +185 -0
  85. package/app/sync/discovery.js +546 -0
  86. package/app/sync/index.js +1644 -0
  87. package/app/sync/kv.js +26 -0
  88. package/app/sync/lan.js +139 -0
  89. package/app/sync/lanDevices.js +177 -0
  90. package/app/sync/lanFront.js +431 -0
  91. package/app/sync/mirror.js +236 -0
  92. package/app/sync/push.js +324 -0
  93. package/app/sync/reconcile.js +161 -0
  94. package/app/sync/signal.js +202 -0
  95. package/app/sync/snapshot.js +189 -0
  96. package/app/sync/store.js +234 -0
  97. package/app/tcp.js +11 -2
  98. package/package.json +6 -3
  99. package/routes/common/api/auth.js +38 -0
  100. package/routes/common/api/kds.js +37 -0
  101. package/routes/common/api/orderProducts.js +81 -0
  102. package/routes/common/api/orders.js +176 -0
  103. package/routes/common/api/printersCore.js +35 -0
  104. package/routes/common/api/reports.js +29 -0
  105. package/routes/common/socket.js +15 -35
  106. package/routes/common/tax.js +29 -7
  107. package/routes/desktop/api/offline.js +146 -0
  108. package/routes/desktop/api/printers.js +288 -5
  109. package/routes/desktop/local.js +130 -0
  110. package/routes/server/api/login.js +37 -0
  111. package/routes/server/api/platform.js +31 -0
  112. package/routes/server/api/shifts.js +25 -0
  113. package/routes/server/master.js +24 -0
  114. package/server.js +72 -3
  115. package/utils.js +6 -0
  116. package/tests/usbDiagnose.js +0 -59
  117. package/tests/usbPrinterTest.js +0 -20
@@ -5,6 +5,40 @@ const https = require("https");
5
5
  const {getConfig} = require("../config");
6
6
  const {getLogger} = require("../logger/Log");
7
7
  const {socketSendByFilter} = require("../../utils");
8
+ const {canHandle: coreCanHandle, dispatch: coreDispatch} = require("../core");
9
+ const {socketGuard} = require("../sync/lan");
10
+ const {socketAllowRequest} = require("../security");
11
+ const {socketAuth} = require("./auth");
12
+ const syncConfig = require("../sync/config");
13
+
14
+ // Офлайн-контур (модель F): клиентские бродкасты фронта, которые надо разнести по залу.
15
+ // Служебные каналы (api:*, ответы на request:*) адресные — их не ретранслируем.
16
+ const isRelayable = (eventName) => !eventName.startsWith('api:') && !eventName.startsWith('response:');
17
+
18
+ // Контекст запроса телефона по LAN совпадает с тем, по которому его пустили. Пустой заголовок
19
+ // подменой не считается: lanDeviceHeaders заполнит его значением устройства. Сверяются все
20
+ // варианты имени (`company-id` и `Company-Id` в одном запросе), а не только первый найденный.
21
+ const LAN_HEADERS = [['terminal-id', 'terminal_id'], ['company-id', 'company_id'], ['team-id', 'team_id']];
22
+ const lanHeadersMatch = (device, headers) => {
23
+ const fields = new Map(LAN_HEADERS);
24
+ return Object.entries(headers && typeof headers === 'object' ? headers : {}).every(([key, raw]) => {
25
+ const field = fields.get(key.toLowerCase());
26
+ const value = Array.isArray(raw) ? raw[0] : raw;
27
+ return !field || value === undefined || value === null || value === ''
28
+ || String(value) === String(device[field]);
29
+ });
30
+ };
31
+ const lanDeviceHeaders = (device, headers) => {
32
+ const names = new Set(LAN_HEADERS.map(([header]) => header));
33
+ const result = {};
34
+ for (const [key, value] of Object.entries(headers && typeof headers === 'object' ? headers : {})) {
35
+ if (!names.has(key.toLowerCase())) result[key] = value;
36
+ }
37
+ for (const [header, field] of LAN_HEADERS) {
38
+ if (device[field]) result[header] = device[field];
39
+ }
40
+ return result;
41
+ };
8
42
 
9
43
  // Keep-alive agent — переиспользуем TCP-соединения к Laravel вместо новых на каждый запрос.
10
44
  // БАГ C: maxSockets 64→128 (запас; после async-печати длинных холдов почти нет). env-настраиваемо.
@@ -48,7 +82,8 @@ module.exports = (server = null) => {
48
82
  if (server && !serverSocket) {
49
83
  // Дефолт = поведение как сейчас (нативные heartbeat/адаптер). Опции добавляются
50
84
  // только при явном включении флагов — это и есть «правка под флагом с откатом».
51
- const serverOptions = { cors: { origin: '*' } };
85
+ // allowRequest: WebSocket не подчиняется CORS — Origin проверяем сами (no-op без офлайна).
86
+ const serverOptions = { cors: { origin: '*' }, allowRequest: socketAllowRequest };
52
87
  if (WS_HEARTBEAT_ENABLED) {
53
88
  serverOptions.pingInterval = WS_PING_INTERVAL; // быстрый детект мёртвого сокета (~15с)
54
89
  serverOptions.pingTimeout = WS_PING_TIMEOUT;
@@ -61,6 +96,10 @@ module.exports = (server = null) => {
61
96
  };
62
97
  }
63
98
  serverSocket = new Server(server, serverOptions);
99
+ // Мастер зала слушает 0.0.0.0 — сокет без ключа зала не пускаем (no-op без синка/ключа).
100
+ serverSocket.use(socketGuard);
101
+ // Облачный Node: токен сокета проверяет Laravel (SOCKET_AUTH, по умолчанию off).
102
+ serverSocket.use(socketAuth);
64
103
 
65
104
  const logger = getLogger();
66
105
 
@@ -90,7 +129,10 @@ module.exports = (server = null) => {
90
129
  const companyId = socket.handshake.query.company_id;
91
130
  const terminalId = socket.handshake.query.terminal_id;
92
131
  let userId = socket.handshake.query.user_id;
93
- const isProxy = socket.handshake.query.is_proxy_server ?? false;
132
+ // Телефон, пришедший по LAN со своим токеном (socketGuard), — обычный клиент,
133
+ // прокси-сокетом он себя не объявит.
134
+ const lanDevice = socket.data.lanDevice || null;
135
+ const isProxy = lanDevice ? false : (socket.handshake.query.is_proxy_server ?? false);
94
136
  const PROXY_TARGET = getConfig().apiUrl;
95
137
 
96
138
  socket.join(companyId);
@@ -100,12 +142,16 @@ module.exports = (server = null) => {
100
142
  socket.userId = userId;
101
143
  socket.isProxy = isProxy;
102
144
 
145
+ // С синком фронт подключается к локальному Node только офлайн: облаку о его
146
+ // присутствии и событиях сообщить нечем (запросы висели бы до таймаута TCP).
147
+ const reportToCloud = !syncConfig.isSyncEnabled();
148
+
103
149
  if (!isProxy) {
104
150
  // При connectionStateRecovery 'connection' ре-фаерится на восстановленном
105
151
  // сокете (socket.recovered=true) — повторный presence-POST не нужен (терминал
106
152
  // уже зарегистрирован, комнаты восстановлены). Обработчики ниже регистрируем
107
153
  // всегда (объект сокета новый).
108
- if (!socket.recovered) {
154
+ if (!socket.recovered && reportToCloud) {
109
155
  axios.post(`${PROXY_TARGET}/api/socket/connection`, {
110
156
  companyId,
111
157
  terminalId,
@@ -126,6 +172,35 @@ module.exports = (server = null) => {
126
172
  // Немедленно подтверждаем получение — фронт знает что запрос дошёл
127
173
  socket.emit(ackEvent);
128
174
 
175
+ // Телефон по LAN проверен при подключении по своему терминалу и заведению.
176
+ // Заголовки запроса обязаны говорить о том же: иначе по токену одного
177
+ // терминала можно было бы действовать от имени другого.
178
+ if (lanDevice) {
179
+ if (!lanHeadersMatch(lanDevice, headers)) {
180
+ socket.emit(responseEvent, {
181
+ success: false,
182
+ error: { success: false, message: 'Forbidden', error_code: 'lan_device_mismatch' },
183
+ });
184
+ return;
185
+ }
186
+ headers = lanDeviceHeaders(lanDevice, headers);
187
+ }
188
+
189
+ // Офлайн-ядро: событие обрабатывается локальной БД, в Laravel не идёт.
190
+ // canHandle учитывает флаг OFFLINE_CORE — при выключенном флаге (по умолчанию)
191
+ // всегда false, и запрос уходит наверх ровно как раньше.
192
+ if (coreCanHandle(event)) {
193
+ try {
194
+ const result = await coreDispatch(event, { headers: headers ?? {}, params: data ?? {} });
195
+ if (HOT_LOGS) console.log(`[api:res] ${event} ✓ local ${Date.now() - t0}ms id=${request_id.slice(0, 8)}`);
196
+ socket.emit(responseEvent, { success: true, data: result });
197
+ } catch (err) {
198
+ if (HOT_LOGS) console.log(`[api:res] ${event} ✗ local status=${err?.status} id=${request_id.slice(0, 8)}`);
199
+ socket.emit(responseEvent, { success: false, error: err?.body ?? { message: err.message } });
200
+ }
201
+ return;
202
+ }
203
+
129
204
  // Прокидываем Idempotency-Key если прислан
130
205
  const idempotencyKey = headers?.['Idempotency-Key'] ?? headers?.['idempotency-key'];
131
206
  const upstreamHeaders = {
@@ -158,13 +233,15 @@ module.exports = (server = null) => {
158
233
  socket.on('setUserId', (id) => {
159
234
  userId = id;
160
235
 
161
- axios.post(`${PROXY_TARGET}/api/socket/setUserId`, {
162
- companyId,
163
- terminalId,
164
- userId,
165
- }).catch((err) => {
166
- logger.error(err);
167
- });
236
+ if (reportToCloud) {
237
+ axios.post(`${PROXY_TARGET}/api/socket/setUserId`, {
238
+ companyId,
239
+ terminalId,
240
+ userId,
241
+ }).catch((err) => {
242
+ logger.error(err);
243
+ });
244
+ }
168
245
 
169
246
 
170
247
  if (id) {
@@ -174,6 +251,7 @@ module.exports = (server = null) => {
174
251
 
175
252
 
176
253
  socket.on('disconnect', () => {
254
+ if (!reportToCloud) return;
177
255
  axios.post(`${PROXY_TARGET}/api/socket/disconnect`, {
178
256
  companyId,
179
257
  terminalId,
@@ -185,17 +263,41 @@ module.exports = (server = null) => {
185
263
 
186
264
  socket.onAny((eventName, ...data) => {
187
265
  if (eventName.startsWith('api:')) return; // handled by api:request handler
188
- axios.post(`${PROXY_TARGET}/api/socket/${eventName}`, {
189
- companyId,
190
- terminalId,
191
- userId,
192
- data
193
- }).catch((err) => {
194
- logger.error(err);
195
- });
266
+ if (reportToCloud) {
267
+ axios.post(`${PROXY_TARGET}/api/socket/${eventName}`, {
268
+ companyId,
269
+ terminalId,
270
+ userId,
271
+ data
272
+ }).catch((err) => {
273
+ logger.error(err);
274
+ });
275
+ }
276
+ socket.to(companyId).emit(eventName, ...data);
277
+
278
+ // Ведомый терминал: остальной зал сидит на мастере — отдаём бродкаст туда.
279
+ if (!reportToCloud && syncConfig.role() === 'follower' && isRelayable(eventName)) {
280
+ const bridge = require('../sync').bridge();
281
+ if (bridge) bridge.forward(eventName, ...data);
282
+ }
283
+ // Мастер заведения (модель M): терминалы, работающие через облако, тоже
284
+ // должны увидеть бродкаст. Вне режима local — no-op.
285
+ if (!reportToCloud && syncConfig.role() !== 'follower' && isRelayable(eventName)) {
286
+ require('../sync').relayBroadcast(eventName, data);
287
+ }
288
+ });
289
+ } else if (socket.handshake.auth?.master && syncConfig.isMasterRelayEnabled()) {
290
+ // Облачный Node: сигнальный сокет мастер-кассы. Личность проверяется через
291
+ // Laravel, до ответа master:ready канал ретрансляции закрыт.
292
+ require('../relay/masters').register(socket);
293
+ } else if (syncConfig.isSyncEnabled() && syncConfig.role() !== 'follower') {
294
+ // Мастер зала: прокси-сокет — это мост ведомого терминала. Его клиентские
295
+ // бродкасты (orders:closing…) раздаём остальным терминалам компании.
296
+ socket.onAny((eventName, ...data) => {
297
+ if (!isRelayable(eventName)) return;
196
298
  socket.to(companyId).emit(eventName, ...data);
299
+ require('../sync').relayBroadcast(eventName, data);
197
300
  });
198
- } else {
199
301
  }
200
302
  });
201
303
  }
@@ -0,0 +1,162 @@
1
+ const {io} = require('socket.io-client');
2
+ const {lanKey} = require('./config');
3
+
4
+ /**
5
+ * Мост ведомого терминала к мастеру зала (socket.io).
6
+ *
7
+ * Офлайн фронт ведомого подключён к СВОЕМУ Node, а чеки живут у мастера. Мост:
8
+ * мастер → фронт: бродкасты мастера (orders:patch и т.п.) пересылаются в комнату компании
9
+ * своего сокет-сервера — фронт видит их так же, как видел бы из облака;
10
+ * мастер → фронт: request:<event>:<id> (печать через терминал) — адресный запрос к своему
11
+ * фронту, ответ уходит мастеру как response:<event>:<id>;
12
+ * фронт → мастер: клиентские бродкасты фронта (orders:closing…) уходят мастеру, а он
13
+ * раздаёт их остальным терминалам зала.
14
+ *
15
+ * Мост подключается с terminal_id этого терминала: так мастер находит его по terminalId,
16
+ * когда нужно напечатать на принтере, подключённом к этой кассе.
17
+ */
18
+ const REQUEST_TIMEOUT_MS = Number(process.env.OFFLINE_BRIDGE_REQUEST_TIMEOUT_MS || 120000);
19
+
20
+ const findSocket = (server, terminalId) => {
21
+ if (!server) {
22
+ return null;
23
+ }
24
+ for (const socket of server.sockets.sockets.values()) {
25
+ if (socket.terminalId === terminalId && !socket.isProxy) {
26
+ return socket;
27
+ }
28
+ }
29
+ return null;
30
+ };
31
+
32
+ /** Запасной вариант адресного запроса, если app/socket/request.js ещё нет. */
33
+ const fallbackRequest = ({terminalId, eventName, data, requestId, timeout}) => new Promise((resolve) => {
34
+ const server = require('../socket/server')();
35
+ const socket = findSocket(server, terminalId);
36
+ if (!socket) {
37
+ resolve({success: false, message: 'No socket'});
38
+ return;
39
+ }
40
+ const responseEvent = `response:${eventName}:${requestId}`;
41
+ let done = false;
42
+ const timer = setTimeout(() => {
43
+ if (!done) {
44
+ done = true;
45
+ socket.off(responseEvent, onResponse);
46
+ resolve({success: false, message: 'Timeout error'});
47
+ }
48
+ }, timeout);
49
+ const onResponse = (payload) => {
50
+ if (!done) {
51
+ done = true;
52
+ clearTimeout(timer);
53
+ resolve(payload);
54
+ }
55
+ };
56
+ socket.once(responseEvent, onResponse);
57
+ socket.emit(`request:${eventName}:${requestId}`, data);
58
+ });
59
+
60
+ const localRequest = (options) => {
61
+ try {
62
+ const module_ = require('../socket/request');
63
+ if (module_ && typeof module_.socketRequest === 'function') {
64
+ return module_.socketRequest(options);
65
+ }
66
+ } catch (e) {
67
+ if (e.code !== 'MODULE_NOT_FOUND') {
68
+ throw e;
69
+ }
70
+ }
71
+ return fallbackRequest(options);
72
+ };
73
+
74
+ class MasterBridge {
75
+ /**
76
+ * @param {{masterUrl: string, session: object, isRelaying: () => boolean}} options
77
+ * isRelaying — пересылать ли бродкасты: только пока фронт работает через локальный Node,
78
+ * в облачном режиме он получает их из облака, и копия от мастера была бы дублем.
79
+ */
80
+ constructor({masterUrl, session, isRelaying}) {
81
+ this.masterUrl = masterUrl;
82
+ this.session = session;
83
+ this.isRelaying = isRelaying;
84
+ this.socket = null;
85
+ }
86
+
87
+ get connected() {
88
+ return Boolean(this.socket && this.socket.connected);
89
+ }
90
+
91
+ start() {
92
+ if (this.socket || !this.session.company_id) {
93
+ return this;
94
+ }
95
+ this.socket = io(this.masterUrl, {
96
+ query: {
97
+ company_id: this.session.company_id,
98
+ terminal_id: this.session.terminal_id || '',
99
+ is_proxy_server: 'true',
100
+ },
101
+ auth: lanKey() ? {lan_key: lanKey()} : {},
102
+ reconnection: true,
103
+ reconnectionDelay: 1000,
104
+ reconnectionDelayMax: 5000,
105
+ });
106
+
107
+ this.socket.onAny((eventName, ...args) => {
108
+ if (eventName.startsWith('request:')) {
109
+ this.handleRequest(eventName, args[0]).catch((e) => {
110
+ console.error('[sync] bridge request failed:', e.message);
111
+ });
112
+ return;
113
+ }
114
+ if (eventName.startsWith('response:') || !this.isRelaying()) {
115
+ return;
116
+ }
117
+ const server = require('../socket/server')();
118
+ if (server) {
119
+ server.to(this.session.company_id).emit(eventName, ...args);
120
+ }
121
+ });
122
+ return this;
123
+ }
124
+
125
+ async handleRequest(eventName, data) {
126
+ const rest = eventName.slice('request:'.length);
127
+ const separator = rest.lastIndexOf(':');
128
+ if (separator <= 0) {
129
+ return;
130
+ }
131
+ const name = rest.slice(0, separator);
132
+ const requestId = rest.slice(separator + 1);
133
+ const result = await localRequest({
134
+ terminalId: this.session.terminal_id,
135
+ eventName: name,
136
+ data,
137
+ requestId,
138
+ timeout: REQUEST_TIMEOUT_MS,
139
+ });
140
+ if (this.socket) {
141
+ this.socket.emit(`response:${name}:${requestId}`, result);
142
+ }
143
+ }
144
+
145
+ /** Клиентский бродкаст фронта → мастеру (он раздаст его остальным терминалам). */
146
+ forward(eventName, ...args) {
147
+ if (this.connected && this.isRelaying()) {
148
+ this.socket.emit(eventName, ...args);
149
+ }
150
+ }
151
+
152
+ stop() {
153
+ if (this.socket) {
154
+ this.socket.offAny();
155
+ this.socket.removeAllListeners();
156
+ this.socket.disconnect();
157
+ this.socket = null;
158
+ }
159
+ }
160
+ }
161
+
162
+ module.exports = {MasterBridge, localRequest};
@@ -0,0 +1,120 @@
1
+ const axios = require('axios');
2
+ const http = require('node:http');
3
+ const https = require('node:https');
4
+ const {healthPath, timeouts, stripSlash} = require('./config');
5
+
6
+ /**
7
+ * HTTP-клиент синка к облаку. Ходит от имени терминала (сессия из POST /local/session) —
8
+ * те же заголовки, что шлёт фронт, поэтому Laravel видит обычный запрос кассы.
9
+ *
10
+ * Каждый ответ — сигнал о связи для монитора: нет ответа или 5xx — провал, любой другой
11
+ * ответ — связь есть (даже 4xx: облако живо, просто запрос ему не понравился).
12
+ */
13
+ const httpAgent = new http.Agent({keepAlive: true, maxSockets: 8});
14
+ const httpsAgent = new https.Agent({keepAlive: true, maxSockets: 8});
15
+
16
+ /** Сеть/таймаут/5xx: облако недоступно, операцию надо повторить позже. */
17
+ class CloudUnavailable extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'CloudUnavailable';
21
+ }
22
+ }
23
+
24
+ /** Облако ответило 4xx: связь есть, но запрос отвергнут. */
25
+ class CloudRequestError extends Error {
26
+ constructor(event, status, body) {
27
+ super(`${event}: HTTP ${status}${body && body.message ? ` ${body.message}` : ''}`);
28
+ this.name = 'CloudRequestError';
29
+ this.status = status;
30
+ this.body = body;
31
+ }
32
+ }
33
+
34
+ // Старый облачный Node при упавшем Laravel отвечает 200 с таким телом (см. app/proxy.js).
35
+ const isBadGateway = (data) => Boolean(data && typeof data === 'object' && data.error === 'Bad Gateway');
36
+
37
+ /** api_url фронта бывает и с хвостом /api — приводим к адресу хоста. */
38
+ const normalizeApiUrl = (url) => stripSlash(url).replace(/\/api$/, '');
39
+
40
+ class CloudClient {
41
+ constructor(session, {onLink = null} = {}) {
42
+ this.session = session;
43
+ this.onLink = onLink || (() => {});
44
+ }
45
+
46
+ get baseUrl() {
47
+ return normalizeApiUrl(this.session.api_url);
48
+ }
49
+
50
+ headers(extra = {}) {
51
+ const session = this.session;
52
+ const headers = {
53
+ Accept: 'application/json',
54
+ 'Content-Type': 'application/json',
55
+ timestamp: String(Math.floor(Date.now() / 1000)),
56
+ };
57
+ if (session.token) {
58
+ headers.Authorization = `Bearer ${session.token}`;
59
+ }
60
+ if (session.team_id) {
61
+ headers['team-id'] = session.team_id;
62
+ }
63
+ if (session.company_id) {
64
+ headers['company-id'] = session.company_id;
65
+ }
66
+ if (session.terminal_id) {
67
+ headers['terminal-id'] = session.terminal_id;
68
+ }
69
+ if (session.employee_id) {
70
+ headers['employee-id'] = session.employee_id;
71
+ }
72
+ return {...headers, ...extra};
73
+ }
74
+
75
+ async call(event, data = {}, {timeout = timeouts().api, idempotencyKey = null} = {}) {
76
+ let response;
77
+ try {
78
+ response = await axios.post(`${this.baseUrl}/api/${event}`, data, {
79
+ headers: this.headers(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {}),
80
+ timeout,
81
+ validateStatus: () => true,
82
+ httpAgent,
83
+ httpsAgent,
84
+ });
85
+ } catch (e) {
86
+ this.onLink(false, `${event}: ${e.code || e.message}`);
87
+ throw new CloudUnavailable(`${event}: ${e.code || e.message}`);
88
+ }
89
+
90
+ if (response.status >= 500 || isBadGateway(response.data)) {
91
+ this.onLink(false, `${event}: HTTP ${response.status}`);
92
+ throw new CloudUnavailable(`${event}: HTTP ${response.status}`);
93
+ }
94
+ this.onLink(true);
95
+ if (response.status >= 400) {
96
+ throw new CloudRequestError(event, response.status, response.data);
97
+ }
98
+ return response.data;
99
+ }
100
+
101
+ /** Проверка связи. Через облачный Node /up доходит до Laravel — проверяется весь путь. */
102
+ async health() {
103
+ try {
104
+ const response = await axios.get(`${this.baseUrl}${healthPath()}`, {
105
+ timeout: timeouts().health,
106
+ validateStatus: () => true,
107
+ httpAgent,
108
+ httpsAgent,
109
+ });
110
+ const ok = response.status < 500 && !isBadGateway(response.data);
111
+ this.onLink(ok, ok ? null : `health: HTTP ${response.status}`);
112
+ return ok;
113
+ } catch (e) {
114
+ this.onLink(false, `health: ${e.code || e.message}`);
115
+ return false;
116
+ }
117
+ }
118
+ }
119
+
120
+ module.exports = {CloudClient, CloudUnavailable, CloudRequestError, normalizeApiUrl, isBadGateway};