@alizadefaig/pos-server 1.2.25 → 1.2.27

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 (116) 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 +85 -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 +277 -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 +903 -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 +20 -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 +35 -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 +111 -4
  115. package/utils.js +6 -0
  116. package/tests/usbPrinterTest.js +0 -20
@@ -0,0 +1,468 @@
1
+ const crypto = require('crypto');
2
+ const sanctum = require('../auth/sanctum');
3
+ const scope = require('../auth/scope');
4
+ const {platformHeaders} = require('../auth');
5
+ const {teamUserResource} = require('../resources/TeamResource');
6
+ const {shiftResource} = require('../resources/ShiftResource');
7
+ const {nullableBool, modelArray, takeText, jsonText} = require('../resources/casts');
8
+ const {isoDate, phpArray} = require('../support/php');
9
+ const {transArray, FALLBACK_LOCALE} = require('../support/lang');
10
+ const {teamSettings, hasTaxIntegration} = require('../support/teamSettings');
11
+ const {imageUrls, castImage} = require('../support/images');
12
+ const {unique, groupBy, indexBy, alive, whereIn} = require('../support/rows');
13
+ const {nowSql} = require('../support/time');
14
+ const ShiftService = require('./ShiftService');
15
+ const {HttpError, notFound} = require('../errors');
16
+
17
+ /**
18
+ * Вход терминала — порт AuthController (auth, loginTerminal, logoutApp, unlock) и
19
+ * TeamUserService::unlock. Только облако: на кассе вход по логину невозможен без облака, а
20
+ * `auth.unlock` там обслуживает офлайн-обработчик из снапшота.
21
+ *
22
+ * Ответ `app.auth` — это `toArray()` моделей User и Team со связями, поэтому ключи идут в порядке
23
+ * колонок таблицы (SELECT *), касты — как в моделях. Отступления от Laravel:
24
+ * - секреты не отдаются: `teams.admin_passwords` и `terminals.password` (в PHP уходили на кассу);
25
+ * - удалённая строка team_users вход в команду не даёт;
26
+ * - то, на чём PHP падал 500 (не-bcrypt хеш, нет валюты у команды, нет терминала при
27
+ * loginTerminal), здесь даёт понятный ответ.
28
+ */
29
+
30
+ const NOT_FOUND = {message: 'user_not_found', success: false};
31
+ const TOO_MANY = 'Çox cəhd edildi, bir az gözləyin';
32
+
33
+ let bcrypt = null;
34
+ const hashCheck = async (value, hashed) => {
35
+ if (typeof hashed !== 'string' || hashed === '') {
36
+ return false;
37
+ }
38
+ if (typeof value === 'number') {
39
+ value = String(value);
40
+ }
41
+ if (typeof value !== 'string') {
42
+ return false;
43
+ }
44
+ bcrypt = bcrypt || require('bcryptjs');
45
+ try {
46
+ return await bcrypt.compare(value, hashed);
47
+ } catch (e) {
48
+ return false;
49
+ }
50
+ };
51
+
52
+ /** `$request->boolean()` — FILTER_VALIDATE_BOOLEAN. */
53
+ const phpBoolean = (value) => {
54
+ if (typeof value === 'boolean') {
55
+ return value;
56
+ }
57
+ if (typeof value === 'number') {
58
+ return value === 1;
59
+ }
60
+ return typeof value === 'string' && ['1', 'true', 'on', 'yes'].includes(value.trim().toLowerCase());
61
+ };
62
+
63
+ /** `!$code` PHP. */
64
+ const phpEmpty = (value) => value === null || value === undefined || value === false || value === 0
65
+ || value === '' || value === '0' || (Array.isArray(value) && value.length === 0);
66
+
67
+ const isScalar = (value) => ['string', 'number', 'boolean'].includes(typeof value);
68
+
69
+ const phpString = (value) => (typeof value === 'boolean' ? (value ? '1' : '') : String(value));
70
+
71
+ /**
72
+ * RateLimiter Laravel для `auth.unlock`: окно ставит первая неудача и дальше не сдвигается,
73
+ * успех счётчик сбрасывает. В Laravel он в общем кэше, здесь — в памяти процесса (у каждой
74
+ * реплики облачного Node свой).
75
+ */
76
+ const unlockLimiter = (() => {
77
+ const hits = new Map();
78
+ const LIMIT = 20000;
79
+ const live = (key) => {
80
+ const entry = hits.get(key);
81
+ if (entry && entry.resetAt > Date.now()) {
82
+ return entry;
83
+ }
84
+ hits.delete(key);
85
+ return null;
86
+ };
87
+ return {
88
+ /** null — можно, иначе секунды до снятия. */
89
+ retryAfter(key, maxAttempts) {
90
+ const entry = live(key);
91
+ if (!entry || entry.attempts < maxAttempts) {
92
+ return null;
93
+ }
94
+ return Math.max(1, Math.ceil((entry.resetAt - Date.now()) / 1000));
95
+ },
96
+ hit(key, decaySeconds) {
97
+ const entry = live(key);
98
+ if (entry) {
99
+ entry.attempts += 1;
100
+ return;
101
+ }
102
+ if (hits.size >= LIMIT) {
103
+ hits.clear();
104
+ }
105
+ hits.set(key, {attempts: 1, resetAt: Date.now() + decaySeconds * 1000});
106
+ },
107
+ clear(key) {
108
+ hits.delete(key);
109
+ },
110
+ };
111
+ })();
112
+
113
+ const unlockConfig = () => {
114
+ const max = parseInt(process.env.UNLOCK_MAX_ATTEMPTS ?? '0', 10);
115
+ const decay = parseInt(process.env.UNLOCK_DECAY_SECONDS ?? '60', 10);
116
+ return {
117
+ maxAttempts: Number.isFinite(max) ? max : 0,
118
+ decaySeconds: Math.max(1, Number.isFinite(decay) ? decay : 60),
119
+ };
120
+ };
121
+
122
+ // ------------------------------------------------------------ toArray моделей
123
+
124
+ /** User: скрыты password и remember_token, email_verified_at — datetime. */
125
+ const userArray = (row) => {
126
+ const {password, remember_token: rememberToken, ...rest} = row;
127
+ return modelArray(rest, {email_verified_at: isoDate});
128
+ };
129
+
130
+ /** Currency: HasTranslations на name и coins_name. */
131
+ const currencyArray = (row, locale) => modelArray(row, {
132
+ name: (value) => transArray(value, locale),
133
+ coins_name: (value) => transArray(value, locale),
134
+ // double без каста: PDO отдаёт его строкой ("1"), драйвер Node — числом.
135
+ exchange_rate: (value) => (value === null || value === undefined ? value : String(value)),
136
+ });
137
+
138
+ /** Company: working_times_by_week_day без каста — текстом, как отдаёт PDO. */
139
+ const companyArray = (row) => modelArray(takeText(row, ['working_times_by_week_day']), {
140
+ settings: phpArray,
141
+ is_all_day: nullableBool,
142
+ service_charge: (value) => (value === null || value === undefined ? null : parseInt(value, 10)),
143
+ pos_master_epoch: (value) => (value === null || value === undefined ? null : parseInt(value, 10)),
144
+ pos_mode_changed_at: isoDate,
145
+ });
146
+
147
+ /** Terminal (+ company). password не отдаётся. */
148
+ const terminalArray = (row, company) => {
149
+ const {password, ...rest} = row;
150
+ const result = modelArray(rest, {
151
+ settings: phpArray,
152
+ sync_status: phpArray,
153
+ sync_seen_at: isoDate,
154
+ });
155
+ result.company = company;
156
+ return result;
157
+ };
158
+
159
+ /**
160
+ * Team: связи в порядке загрузки — owner и terminals (with), затем currency: её лениво
161
+ * подтягивает TeamSettingsCast ради символа валюты.
162
+ */
163
+ const teamArray = (row, {owner, terminals, currency, urls, locale}) => {
164
+ const {admin_passwords: adminPasswords, ...rest} = row;
165
+ const result = modelArray(rest, {
166
+ name: (value) => transArray(value, locale),
167
+ logo: (value) => castImage(urls, value),
168
+ settings: (value) => teamSettings(value, currency ? currency.symbol : null),
169
+ is_trial: nullableBool,
170
+ });
171
+ result.has_tax_integration = hasTaxIntegration(row.settings);
172
+ result.owner = owner;
173
+ result.terminals = terminals;
174
+ result.currency = currency;
175
+ return result;
176
+ };
177
+
178
+ class AuthService {
179
+ /**
180
+ * @param {{db: object, context: import('../Context')}} services
181
+ */
182
+ constructor({db, context}) {
183
+ this.db = db;
184
+ this.context = context;
185
+ }
186
+
187
+ alive(alias = null) {
188
+ return alive(this.db, alias);
189
+ }
190
+
191
+ whereIn(sql, ids, params = []) {
192
+ return whereIn(this.db, sql, ids, params);
193
+ }
194
+
195
+ /** Токен запроса: из цепочки проверок, а при CORE_AUTH=false — сами. */
196
+ async who(extra) {
197
+ if (extra.auth && extra.auth.auth) {
198
+ return extra.auth.auth;
199
+ }
200
+ const found = await sanctum.authenticate(this.db, extra.headers || {});
201
+ if (!found) {
202
+ throw new HttpError(401, {message: 'Unauthenticated.'});
203
+ }
204
+ return found;
205
+ }
206
+
207
+ // ------------------------------------------------------------ app.auth
208
+
209
+ /** AuthController::auth — вход по логину и паролю, выпуск токена `terminal`. */
210
+ async auth(params) {
211
+ const username = params.username;
212
+ if (!isScalar(username) || phpString(username) === '') {
213
+ throw new HttpError(404, NOT_FOUND);
214
+ }
215
+ const user = await this.db.get(
216
+ `SELECT * FROM users WHERE email = ? AND is_super_admin = ?${this.alive()} LIMIT 1`,
217
+ [phpString(username), false]
218
+ );
219
+ if (!user || !await hashCheck(params.password, user.password)) {
220
+ throw new HttpError(404, NOT_FOUND);
221
+ }
222
+
223
+ const teams = await this.teams(user.id);
224
+ const token = await sanctum.issue(this.db, user.id, 'terminal');
225
+ const result = userArray(user);
226
+ result.token = token.plainTextToken;
227
+ return {success: true, type: 'user', user: result, teams};
228
+ }
229
+
230
+ /** Команды, где у пользователя роль с доступом к терминалу или роль владельца. */
231
+ async teams(userId) {
232
+ const rows = await this.db.all(
233
+ `SELECT teams.* FROM teams
234
+ LEFT JOIN team_users ON team_users.team_id = teams.id
235
+ LEFT JOIN roles ON team_users.role_id = roles.id
236
+ WHERE team_users.user_id = ?
237
+ AND (roles.can_work_with_terminal = ? OR roles.is_owner = ?)${this.alive('teams')}${this.alive('team_users')}`,
238
+ [userId, true, true]
239
+ );
240
+ if (!rows.length) {
241
+ return [];
242
+ }
243
+ const locale = process.env.APP_LOCALE || FALLBACK_LOCALE;
244
+ const teamIds = unique(rows.map((row) => row.id));
245
+ const ownerIds = unique(rows.map((row) => row.user_id));
246
+ const currencyIds = unique(rows.map((row) => row.currency_id));
247
+
248
+ const [owners, terminals, currencies, urls] = await Promise.all([
249
+ this.whereIn(`SELECT * FROM users WHERE id IN (:ids)${this.alive()}`, ownerIds),
250
+ this.whereIn(
251
+ `SELECT * FROM terminals WHERE team_id IN (:ids) AND is_logged_in = ?${this.alive()}`,
252
+ teamIds,
253
+ [false]
254
+ ),
255
+ currencyIds.length
256
+ ? this.whereIn(`SELECT * FROM currencies WHERE id IN (:ids)${this.alive()}`, currencyIds)
257
+ : [],
258
+ imageUrls(this.db, rows.map((row) => row.logo)),
259
+ ]);
260
+
261
+ const companyIds = unique(terminals.map((row) => row.company_id));
262
+ const companies = companyIds.length
263
+ ? await this.whereIn(
264
+ `SELECT *${jsonText(this.db, ['working_times_by_week_day'])} FROM companies WHERE id IN (:ids)${this.alive()}`,
265
+ companyIds
266
+ )
267
+ : [];
268
+
269
+ const companiesById = new Map(companies.map((row) => [String(row.id), companyArray(row)]));
270
+ const ownersById = indexBy(owners);
271
+ const currenciesById = indexBy(currencies);
272
+ const terminalsByTeam = groupBy(terminals, 'team_id');
273
+
274
+ return rows.map((row) => {
275
+ const owner = ownersById.get(String(row.user_id));
276
+ const currency = row.currency_id ? currenciesById.get(String(row.currency_id)) : null;
277
+ return teamArray(row, {
278
+ owner: owner ? userArray(owner) : null,
279
+ terminals: (terminalsByTeam.get(String(row.id)) || []).map((terminal) =>
280
+ terminalArray(terminal, companiesById.get(String(terminal.company_id)) || null)),
281
+ currency: currency ? currencyArray(currency, locale) : null,
282
+ urls,
283
+ locale,
284
+ });
285
+ });
286
+ }
287
+
288
+ // ------------------------------------------------------------ auth.loginTerminal
289
+
290
+ /** AuthController::loginTerminal — привязать токен к терминалу, терминал «вошёл». */
291
+ async loginTerminal(params, extra) {
292
+ const who = await this.who(extra);
293
+ const platform = extra.auth ? extra.auth.platform : platformHeaders(extra.headers || {});
294
+ const id = params.id;
295
+
296
+ const scopeMode = scope.mode();
297
+ if (scopeMode !== scope.MODE_OFF) {
298
+ const belongs = await scope.terminalBelongs(
299
+ this.db, id, platform.teamId, scope.value(platform.companyId), true
300
+ );
301
+ if (!belongs) {
302
+ scope.report(scope.REASON_TERMINAL, {event: extra.event, auth: who, platform, ip: extra.ip}, scopeMode, {
303
+ target_terminal_id: isScalar(id) ? phpString(id) : null,
304
+ });
305
+ if (scopeMode === scope.MODE_ENFORCE) {
306
+ // Токен не трогаем: привязка к чужому терминалу — ровно то, что запрещаем.
307
+ const {status, body} = scope.deny(scope.REASON_TERMINAL);
308
+ throw new HttpError(status, body);
309
+ }
310
+ }
311
+ }
312
+
313
+ // В PHP токен переписывался раньше поиска терминала, и на отсутствующем терминале
314
+ // запрос падал 500 с уже изменённым токеном. Здесь сначала ищем.
315
+ const terminal = scope.isUuid(id)
316
+ ? await this.db.get(`SELECT id FROM terminals WHERE id = ?${this.alive()}`, [id])
317
+ : null;
318
+ if (!terminal) {
319
+ throw notFound('Terminal not found');
320
+ }
321
+
322
+ // Eloquent пишет только изменённое: та же привязка не трогает updated_at.
323
+ const stamp = nowSql();
324
+ await this.db.run(
325
+ `UPDATE personal_access_tokens SET terminal_id = ?, updated_at = ?
326
+ WHERE id = ? AND (terminal_id IS NULL OR terminal_id <> ?)`,
327
+ [terminal.id, stamp, who.tokenId, terminal.id]
328
+ );
329
+ await this.db.run(
330
+ 'UPDATE terminals SET is_logged_in = ?, updated_at = ? WHERE id = ? AND is_logged_in <> ?',
331
+ [true, stamp, terminal.id, true]
332
+ );
333
+ return true;
334
+ }
335
+
336
+ // ------------------------------------------------------------ auth.logoutApp
337
+
338
+ /**
339
+ * AuthController::logoutApp — отозвать токен. `keep_terminal`: касса отзывает токен прошлой
340
+ * сессии, а терминал уже вошёл с новым — отметку «вошёл» не сбрасываем.
341
+ */
342
+ async logoutApp(params, extra) {
343
+ const who = await this.who(extra);
344
+ await sanctum.revoke(this.db, who.tokenId);
345
+ const terminalId = this.context.terminalId;
346
+ if (!phpBoolean(params.keep_terminal) && scope.isUuid(terminalId)) {
347
+ await this.db.run(
348
+ `UPDATE terminals SET is_logged_in = ?, updated_at = ?
349
+ WHERE id = ? AND is_logged_in <> ?${this.alive()}`,
350
+ [false, nowSql(), terminalId, false]
351
+ );
352
+ }
353
+ return true;
354
+ }
355
+
356
+ // ------------------------------------------------------------ auth.unlock
357
+
358
+ /** Ключ лимитера — как unlockThrottleKey в PHP: терминал команды, иначе IP. */
359
+ async unlockKey(ip) {
360
+ const teamId = scope.value(this.context.teamId) ?? '';
361
+ const terminalId = scope.value(this.context.terminalId);
362
+ const target = terminalId !== null && await scope.terminalBelongs(this.db, terminalId, teamId)
363
+ ? `terminal:${terminalId.toLowerCase()}`
364
+ : `ip:${ip ?? ''}`;
365
+ return 'auth-unlock:' + crypto.createHash('sha1').update(`${teamId}|${target}`).digest('hex');
366
+ }
367
+
368
+ /** AuthController::unlock — сотрудник по PIN или карте, со сменами. */
369
+ async unlock(params, extra) {
370
+ const {maxAttempts, decaySeconds} = unlockConfig();
371
+ const key = maxAttempts > 0 ? await this.unlockKey(extra.ip) : null;
372
+ if (key) {
373
+ const retryAfter = unlockLimiter.retryAfter(key, maxAttempts);
374
+ if (retryAfter !== null) {
375
+ throw new HttpError(429, {
376
+ message: TOO_MANY,
377
+ error_code: 'too_many_attempts',
378
+ retry_after: retryAfter,
379
+ }, {'Retry-After': String(retryAfter)});
380
+ }
381
+ }
382
+
383
+ const member = await this.findUnlockMember(params.code);
384
+ if (!member) {
385
+ if (key) {
386
+ unlockLimiter.hit(key, decaySeconds);
387
+ }
388
+ // response()->json(null): Symfony подменяет null на пустой ArrayObject — уходит {}.
389
+ return {};
390
+ }
391
+ if (key) {
392
+ unlockLimiter.clear(key);
393
+ }
394
+
395
+ await this.context.load();
396
+ const team = this.context.getTeam();
397
+ const [user, role, scopes, cashShift, workShift] = await Promise.all([
398
+ this.db.get(`SELECT id, name FROM users WHERE id = ?${this.alive()}`, [member.user_id]),
399
+ this.db.get(`SELECT * FROM roles WHERE id = ?${this.alive()}`, [member.role_id]),
400
+ this.roleScopes(member.role_id),
401
+ this.cashShift(),
402
+ this.workShift(member.id),
403
+ ]);
404
+ return {
405
+ user: teamUserResource(member, {
406
+ user,
407
+ role,
408
+ scopes,
409
+ cashShift,
410
+ workShift,
411
+ teamOwnerId: team ? team.user_id : null,
412
+ teamId: this.context.teamId,
413
+ }),
414
+ // JsonResource от null сериализуется в [].
415
+ shift: workShift ? shiftResource(workShift) : [],
416
+ cash_shift: cashShift ? shiftResource(cashShift) : [],
417
+ };
418
+ }
419
+
420
+ /** TeamUserService::unlock. Код биндится строкой: число против varchar в PHP давало 500. */
421
+ async findUnlockMember(code) {
422
+ if (phpEmpty(code) || !isScalar(code) || !scope.isUuid(this.context.teamId)) {
423
+ return null;
424
+ }
425
+ const value = phpString(code);
426
+ return this.db.get(
427
+ `SELECT tu.* FROM team_users tu
428
+ WHERE tu.team_id = ? AND (tu.pin = ? OR tu.ic_card = ?)${this.alive('tu')}
429
+ AND EXISTS (
430
+ SELECT 1 FROM roles r
431
+ WHERE r.id = tu.role_id AND (r.can_work_with_terminal = ? OR r.is_owner = ?)${this.alive('r')}
432
+ )
433
+ LIMIT 1`,
434
+ [this.context.teamId, value, value, true, true]
435
+ );
436
+ }
437
+
438
+ async roleScopes(roleId) {
439
+ if (!roleId) {
440
+ return {};
441
+ }
442
+ const rows = await this.db.all(
443
+ `SELECT pe."group", pe.slug, per.can
444
+ FROM platform_events pe
445
+ INNER JOIN platform_event_roles per ON pe.id = per.platform_event_id
446
+ WHERE per.role_id = ?${this.alive('pe')}`,
447
+ [roleId]
448
+ );
449
+ const scopes = {};
450
+ for (const row of rows) {
451
+ scopes[`${row.group}:${row.slug}`] = row.can;
452
+ }
453
+ return scopes;
454
+ }
455
+
456
+ /** ShiftService::getCashShift. */
457
+ async cashShift() {
458
+ return new ShiftService({db: this.db, context: this.context}).cashShift();
459
+ }
460
+
461
+ /** ShiftService::getWorkShift — по сотруднику, см. ShiftService.workShift. */
462
+ async workShift(teamUserId) {
463
+ return new ShiftService({db: this.db, context: this.context}).workShift(teamUserId);
464
+ }
465
+ }
466
+
467
+ module.exports = AuthService;
468
+ module.exports._unlockLimiter = unlockLimiter;