@internetderdinge/api 1.229.27 → 1.229.31

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.
@@ -0,0 +1,763 @@
1
+ import mongoose from "mongoose";
2
+ import Device from "../devices/devices.model.js";
3
+ import Organization from "../organizations/organizations.model.js";
4
+ import { User } from "../users/users.model.js";
5
+ import iotDevicesService from "../iotdevice/iotdevice.service.js";
6
+ import { auth0 } from "../accounts/auth0.service.js";
7
+ const IOT_DEVICES_CACHE_TTL_MS = 60000;
8
+ let iotDevicesCache = null;
9
+ const getCachedIotDevices = async () => {
10
+ const now = Date.now();
11
+ if (iotDevicesCache?.pending) {
12
+ return iotDevicesCache.pending;
13
+ }
14
+ if (iotDevicesCache &&
15
+ now - iotDevicesCache.loadedAt < IOT_DEVICES_CACHE_TTL_MS) {
16
+ return iotDevicesCache.devices;
17
+ }
18
+ const pending = Promise.resolve(iotDevicesService.getDeviceStatusList(undefined)).then((devices) => (Array.isArray(devices) ? devices : []));
19
+ iotDevicesCache = {
20
+ loadedAt: now,
21
+ devices: [],
22
+ pending,
23
+ };
24
+ try {
25
+ const devices = await pending;
26
+ iotDevicesCache = {
27
+ loadedAt: Date.now(),
28
+ devices,
29
+ };
30
+ return devices;
31
+ }
32
+ catch (error) {
33
+ iotDevicesCache = null;
34
+ throw error;
35
+ }
36
+ };
37
+ const toRegex = (search) => {
38
+ const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39
+ return new RegExp(escaped, "i");
40
+ };
41
+ const getNestedString = (source, path) => {
42
+ const value = path.split(".").reduce((current, part) => {
43
+ if (!current || typeof current !== "object") {
44
+ return undefined;
45
+ }
46
+ return current[part];
47
+ }, source);
48
+ return typeof value === "string" ? value : undefined;
49
+ };
50
+ const getFirstNestedString = (source, paths) => {
51
+ for (const path of paths) {
52
+ const value = getNestedString(source, path);
53
+ if (value) {
54
+ return value;
55
+ }
56
+ }
57
+ return undefined;
58
+ };
59
+ const userMetaEmailPaths = [
60
+ "email",
61
+ "data.email",
62
+ "user_metadata.email",
63
+ "app_metadata.email",
64
+ "auth0.email",
65
+ "auth0.data.email",
66
+ "auth0.user_metadata.email",
67
+ "auth0.app_metadata.email",
68
+ ];
69
+ const scoreMatch = (search, candidates) => {
70
+ const query = search.trim().toLowerCase();
71
+ if (!query) {
72
+ return 0;
73
+ }
74
+ let score = 0;
75
+ for (const rawCandidate of candidates) {
76
+ const candidate = rawCandidate?.toLowerCase().trim();
77
+ if (!candidate) {
78
+ continue;
79
+ }
80
+ if (candidate === query) {
81
+ score = Math.max(score, 100);
82
+ continue;
83
+ }
84
+ if (candidate.startsWith(query)) {
85
+ score = Math.max(score, 70);
86
+ continue;
87
+ }
88
+ if (candidate.includes(query)) {
89
+ score = Math.max(score, 40);
90
+ }
91
+ }
92
+ return score;
93
+ };
94
+ const sortByScore = (items, limit) => {
95
+ return items.sort((a, b) => b.score - a.score).slice(0, limit);
96
+ };
97
+ const AUTH0_COUNT_PAGE_SIZE = 100;
98
+ const MAX_AUTH0_COUNT_PAGES = 10;
99
+ let cachedAuth0ManagementToken = null;
100
+ let cachedAuth0ManagementTokenExpiresAt = null;
101
+ const getAuth0ManagementDomainUrl = () => {
102
+ const raw = process.env.AUTH0_MANAGEMENT_DOMAIN;
103
+ if (!raw) {
104
+ return null;
105
+ }
106
+ const normalized = raw.startsWith("http://") || raw.startsWith("https://")
107
+ ? raw
108
+ : `https://${raw}`;
109
+ try {
110
+ const parsed = new URL(normalized);
111
+ return parsed.origin;
112
+ }
113
+ catch {
114
+ return normalized;
115
+ }
116
+ };
117
+ const getAuth0ManagementTokenDirect = async () => {
118
+ const now = Math.floor(Date.now() / 1000);
119
+ if (cachedAuth0ManagementToken &&
120
+ cachedAuth0ManagementTokenExpiresAt &&
121
+ now < cachedAuth0ManagementTokenExpiresAt - 60) {
122
+ return cachedAuth0ManagementToken;
123
+ }
124
+ const domainUrl = getAuth0ManagementDomainUrl();
125
+ const clientId = process.env.AUTH0_MANAGEMENT_CLIENT_ID;
126
+ const clientSecret = process.env.AUTH0_MANAGEMENT_CLIENT_SECRET;
127
+ const audience = process.env.AUTH0_MANAGEMENT_AUDIENCE;
128
+ if (!domainUrl || !clientId || !clientSecret || !audience) {
129
+ throw new Error("Missing Auth0 Management API credentials in environment.");
130
+ }
131
+ const response = await fetch(`${domainUrl}/oauth/token`, {
132
+ method: "POST",
133
+ headers: {
134
+ "content-type": "application/json",
135
+ },
136
+ body: JSON.stringify({
137
+ grant_type: "client_credentials",
138
+ client_id: clientId,
139
+ client_secret: clientSecret,
140
+ audience,
141
+ }),
142
+ });
143
+ if (!response.ok) {
144
+ throw new Error(`Auth0 token request failed (${response.status}).`);
145
+ }
146
+ const payload = (await response.json());
147
+ if (!payload.access_token) {
148
+ throw new Error("Auth0 token response did not include access_token.");
149
+ }
150
+ const expiresIn = payload.expires_in ?? 3600;
151
+ cachedAuth0ManagementToken = payload.access_token;
152
+ cachedAuth0ManagementTokenExpiresAt = now + expiresIn;
153
+ return cachedAuth0ManagementToken;
154
+ };
155
+ const getAuth0UsersTotalViaManagementApi = async () => {
156
+ const domainUrl = getAuth0ManagementDomainUrl();
157
+ if (!domainUrl) {
158
+ throw new Error("Missing AUTH0_MANAGEMENT_DOMAIN in environment.");
159
+ }
160
+ const token = await getAuth0ManagementTokenDirect();
161
+ const response = await fetch(`${domainUrl}/api/v2/users?page=0&per_page=1&include_totals=true`, {
162
+ method: "GET",
163
+ headers: {
164
+ authorization: `Bearer ${token}`,
165
+ "content-type": "application/json",
166
+ },
167
+ });
168
+ if (!response.ok) {
169
+ throw new Error(`Auth0 users total request failed (${response.status}).`);
170
+ }
171
+ const payload = (await response.json());
172
+ if (typeof payload.total !== "number" || !Number.isFinite(payload.total)) {
173
+ throw new Error("Auth0 users response did not include a valid total.");
174
+ }
175
+ return payload.total;
176
+ };
177
+ const getUsersFromAuth0Response = (auth0Response) => {
178
+ if (Array.isArray(auth0Response)) {
179
+ return auth0Response;
180
+ }
181
+ if (auth0Response &&
182
+ typeof auth0Response === "object" &&
183
+ Symbol.iterator in auth0Response) {
184
+ return Array.from(auth0Response);
185
+ }
186
+ if (Array.isArray(auth0Response?.data)) {
187
+ return auth0Response.data;
188
+ }
189
+ if (Array.isArray(auth0Response?.users)) {
190
+ return auth0Response.users;
191
+ }
192
+ if (Array.isArray(auth0Response?.items)) {
193
+ return auth0Response.items;
194
+ }
195
+ return [];
196
+ };
197
+ const getTotalFromAuth0Response = (auth0Response) => {
198
+ if (!auth0Response || typeof auth0Response !== "object") {
199
+ return undefined;
200
+ }
201
+ const maybeNumber = auth0Response.total;
202
+ if (typeof maybeNumber === "number" && Number.isFinite(maybeNumber)) {
203
+ return maybeNumber;
204
+ }
205
+ const data = auth0Response.data;
206
+ if (data && typeof data === "object") {
207
+ const nested = data.total;
208
+ if (typeof nested === "number" && Number.isFinite(nested)) {
209
+ return nested;
210
+ }
211
+ }
212
+ return undefined;
213
+ };
214
+ const getAuth0UsersTotal = async () => {
215
+ try {
216
+ return await getAuth0UsersTotalViaManagementApi();
217
+ }
218
+ catch (error) {
219
+ console.warn("Admin stats Auth0 total-users API failed, fallback to list", error);
220
+ }
221
+ try {
222
+ const seenUserIds = new Set();
223
+ let countByLength = 0;
224
+ for (let page = 0; page < MAX_AUTH0_COUNT_PAGES; page += 1) {
225
+ let response;
226
+ try {
227
+ response = await auth0.users.list({
228
+ page,
229
+ per_page: AUTH0_COUNT_PAGE_SIZE,
230
+ include_totals: false,
231
+ });
232
+ }
233
+ catch (error) {
234
+ const message = error instanceof Error
235
+ ? error.message
236
+ : typeof error === "string"
237
+ ? error
238
+ : "";
239
+ if (message.includes("invalid_paging")) {
240
+ break;
241
+ }
242
+ throw error;
243
+ }
244
+ const users = getUsersFromAuth0Response(response);
245
+ countByLength += users.length;
246
+ for (const entry of users) {
247
+ const userId = typeof entry.user_id === "string"
248
+ ? entry.user_id
249
+ : typeof entry.id === "string"
250
+ ? entry.id
251
+ : undefined;
252
+ if (userId) {
253
+ seenUserIds.add(userId);
254
+ }
255
+ }
256
+ if (users.length < AUTH0_COUNT_PAGE_SIZE) {
257
+ break;
258
+ }
259
+ }
260
+ return seenUserIds.size > 0 ? seenUserIds.size : countByLength;
261
+ }
262
+ catch (error) {
263
+ console.warn("Admin stats Auth0 total query failed", error);
264
+ return 0;
265
+ }
266
+ };
267
+ const normalizeEmail = (value) => {
268
+ return typeof value === "string" && value.trim()
269
+ ? value.trim().toLowerCase()
270
+ : undefined;
271
+ };
272
+ const normalizeNumber = (value) => {
273
+ if (typeof value === "number" && Number.isFinite(value)) {
274
+ return value;
275
+ }
276
+ if (typeof value === "string" && value.trim()) {
277
+ const parsed = Number(value);
278
+ return Number.isFinite(parsed) ? parsed : undefined;
279
+ }
280
+ return undefined;
281
+ };
282
+ const normalizeDateString = (value) => {
283
+ if (value instanceof Date) {
284
+ return value.toISOString();
285
+ }
286
+ if (typeof value === "string" && value.trim()) {
287
+ return value;
288
+ }
289
+ return undefined;
290
+ };
291
+ const toRecord = (value) => {
292
+ return value && typeof value === "object"
293
+ ? value
294
+ : undefined;
295
+ };
296
+ const buildUserResult = (search, user) => {
297
+ const organizationData = user.organizationData;
298
+ const userMeta = user.meta;
299
+ const firstName = user.meta
300
+ ? user.meta.firstName
301
+ : undefined;
302
+ const lastName = user.meta
303
+ ? user.meta.lastName
304
+ : undefined;
305
+ const metadataEmail = getFirstNestedString(userMeta, userMetaEmailPaths);
306
+ return {
307
+ score: scoreMatch(search, [
308
+ user.name,
309
+ user.email,
310
+ metadataEmail,
311
+ firstName,
312
+ lastName,
313
+ organizationData?.name,
314
+ ]),
315
+ id: String(user._id),
316
+ name: user.name,
317
+ email: user.email || metadataEmail,
318
+ role: user.role,
319
+ organization: organizationData
320
+ ? {
321
+ id: String(organizationData._id),
322
+ name: organizationData.name,
323
+ kind: organizationData.kind,
324
+ }
325
+ : undefined,
326
+ };
327
+ };
328
+ const searchUsersInAuth0 = async (search, limit) => {
329
+ const trimmedSearch = search.trim();
330
+ const escaped = search.replace(/"/g, '\\"');
331
+ const auth0Query = [
332
+ `email:"${escaped}"`,
333
+ `user_metadata.email:"${escaped}"`,
334
+ `app_metadata.email:"${escaped}"`,
335
+ `email:*${escaped}*`,
336
+ ].join(" OR ");
337
+ try {
338
+ let auth0ListResponse = [];
339
+ try {
340
+ auth0ListResponse = await auth0.users.list({
341
+ search_engine: "v3",
342
+ q: auth0Query,
343
+ per_page: Math.min(limit * 2, 50),
344
+ page: 0,
345
+ });
346
+ }
347
+ catch (error) {
348
+ console.warn("Admin search Auth0 list query failed", error);
349
+ }
350
+ let auth0ExactEmailResponse = [];
351
+ if (trimmedSearch.includes("@")) {
352
+ try {
353
+ auth0ExactEmailResponse = await auth0.users.listUsersByEmail({
354
+ email: trimmedSearch,
355
+ });
356
+ }
357
+ catch (error) {
358
+ console.warn("Admin search Auth0 exact-email query failed", error);
359
+ }
360
+ }
361
+ const auth0UsersById = new Map();
362
+ for (const entry of [
363
+ ...getUsersFromAuth0Response(auth0ListResponse),
364
+ ...getUsersFromAuth0Response(auth0ExactEmailResponse),
365
+ ]) {
366
+ const userId = typeof entry.user_id === "string"
367
+ ? entry.user_id
368
+ : typeof entry.id === "string"
369
+ ? entry.id
370
+ : undefined;
371
+ if (!userId) {
372
+ continue;
373
+ }
374
+ auth0UsersById.set(userId, entry);
375
+ }
376
+ const auth0Users = Array.from(auth0UsersById.values());
377
+ const auth0Ids = auth0Users
378
+ .map((entry) => typeof entry.user_id === "string" ? entry.user_id : undefined)
379
+ .filter((entry) => Boolean(entry));
380
+ const auth0Emails = auth0Users
381
+ .map((entry) => normalizeEmail(entry.email))
382
+ .filter((entry) => Boolean(entry));
383
+ if (!auth0Ids.length && !auth0Emails.length) {
384
+ return [];
385
+ }
386
+ const filterOr = [];
387
+ if (auth0Ids.length) {
388
+ filterOr.push({ owner: { $in: auth0Ids } });
389
+ }
390
+ if (auth0Emails.length) {
391
+ filterOr.push({ email: { $in: auth0Emails } }, { "meta.email": { $in: auth0Emails } }, { "meta.data.email": { $in: auth0Emails } });
392
+ }
393
+ const mongoUsersRaw = await User.find({ $or: filterOr })
394
+ .select("_id name email role organization owner meta")
395
+ .populate("organizationData", "_id name kind")
396
+ .limit(limit * 2)
397
+ .lean();
398
+ const mongoUsers = mongoUsersRaw.map((user) => buildUserResult(search, user));
399
+ const mappedOwners = new Set(mongoUsersRaw
400
+ .map((user) => typeof user.owner === "string" ? user.owner : undefined)
401
+ .filter((entry) => Boolean(entry)));
402
+ const mappedEmails = new Set();
403
+ for (const user of mongoUsersRaw) {
404
+ const topLevelEmail = normalizeEmail(user.email);
405
+ if (topLevelEmail) {
406
+ mappedEmails.add(topLevelEmail);
407
+ }
408
+ const meta = user.meta;
409
+ const metaEmail = normalizeEmail(getFirstNestedString(meta, userMetaEmailPaths));
410
+ if (metaEmail) {
411
+ mappedEmails.add(metaEmail);
412
+ }
413
+ }
414
+ const auth0OnlyUsers = auth0Users
415
+ .map((entry) => {
416
+ const userId = typeof entry.user_id === "string"
417
+ ? entry.user_id
418
+ : typeof entry.id === "string"
419
+ ? entry.id
420
+ : undefined;
421
+ if (!userId) {
422
+ return undefined;
423
+ }
424
+ const email = normalizeEmail(entry.email);
425
+ const userMetadata = entry.user_metadata;
426
+ const appMetadata = entry.app_metadata;
427
+ const metadataEmail = normalizeEmail(userMetadata?.email) ||
428
+ normalizeEmail(appMetadata?.email);
429
+ const finalEmail = email || metadataEmail;
430
+ if (mappedOwners.has(userId)) {
431
+ return undefined;
432
+ }
433
+ if (finalEmail && mappedEmails.has(finalEmail)) {
434
+ return undefined;
435
+ }
436
+ const name = (typeof entry.name === "string" && entry.name) ||
437
+ (typeof entry.nickname === "string" && entry.nickname) ||
438
+ finalEmail ||
439
+ userId;
440
+ return {
441
+ id: `auth0:${userId}`,
442
+ name,
443
+ email: finalEmail,
444
+ role: "auth0",
445
+ score: scoreMatch(search, [
446
+ name,
447
+ finalEmail,
448
+ userId,
449
+ normalizeEmail(userMetadata?.email),
450
+ normalizeEmail(appMetadata?.email),
451
+ ]),
452
+ };
453
+ })
454
+ .filter((entry) => Boolean(entry));
455
+ return [...mongoUsers, ...auth0OnlyUsers];
456
+ }
457
+ catch (error) {
458
+ console.warn("Admin search Auth0 fallback failed", error);
459
+ return [];
460
+ }
461
+ };
462
+ export const searchAdminCollections = async ({ search, limit, }) => {
463
+ const startedAt = Date.now();
464
+ const hasSearch = search.trim().length > 0;
465
+ const regex = toRegex(search);
466
+ const objectId = mongoose.Types.ObjectId.isValid(search)
467
+ ? new mongoose.Types.ObjectId(search)
468
+ : undefined;
469
+ const organizationFilter = hasSearch
470
+ ? {
471
+ $or: [{ name: regex }, { kind: regex }, { "meta.name": regex }],
472
+ }
473
+ : {};
474
+ const userFilter = hasSearch
475
+ ? {
476
+ $or: [
477
+ { name: regex },
478
+ { email: regex },
479
+ { owner: regex },
480
+ { "meta.email": regex },
481
+ { "meta.data.email": regex },
482
+ { "meta.user_metadata.email": regex },
483
+ { "meta.app_metadata.email": regex },
484
+ { "meta.auth0.email": regex },
485
+ { "meta.auth0.data.email": regex },
486
+ { "meta.auth0.user_metadata.email": regex },
487
+ { "meta.auth0.app_metadata.email": regex },
488
+ { "meta.firstName": regex },
489
+ { "meta.lastName": regex },
490
+ ],
491
+ }
492
+ : {};
493
+ const deviceFilter = hasSearch
494
+ ? {
495
+ $or: [
496
+ { name: regex },
497
+ { deviceId: regex },
498
+ { kind: regex },
499
+ { "meta.name": regex },
500
+ { "meta.serialNumber": regex },
501
+ { "payment.id": regex },
502
+ ],
503
+ }
504
+ : {};
505
+ if (objectId) {
506
+ organizationFilter.$or.push({
507
+ _id: objectId,
508
+ });
509
+ userFilter.$or.push({ _id: objectId }, { organization: objectId });
510
+ deviceFilter.$or.push({ _id: objectId }, { organization: objectId }, { patient: objectId });
511
+ }
512
+ const [organizationsRaw, usersRaw, devicesRaw] = await Promise.all([
513
+ Organization.find(organizationFilter)
514
+ .select("_id name kind")
515
+ .limit(limit * 2)
516
+ .lean(),
517
+ User.find(userFilter)
518
+ .select("_id name email role organization meta")
519
+ .populate("organizationData", "_id name kind")
520
+ .limit(limit * 2)
521
+ .lean(),
522
+ Device.find(deviceFilter)
523
+ .select("_id name deviceId kind timezone eventDate payment organization patient meta createdAt updatedAt")
524
+ .populate("organization", "_id name kind")
525
+ .populate("patient", "_id name email meta")
526
+ .limit(limit * 2)
527
+ .lean(),
528
+ ]);
529
+ const iotStatusByDeviceId = new Map();
530
+ await Promise.all(devicesRaw.map(async (device) => {
531
+ const deviceId = typeof device.deviceId === "string" ? String(device.deviceId) : "";
532
+ if (!deviceId) {
533
+ return;
534
+ }
535
+ try {
536
+ const iotStatus = await iotDevicesService.getDeviceStatus(deviceId, device.kind);
537
+ const normalized = toRecord(iotStatus);
538
+ if (normalized) {
539
+ iotStatusByDeviceId.set(deviceId, normalized);
540
+ }
541
+ }
542
+ catch (error) {
543
+ console.warn(`Admin search iot status failed for ${deviceId}`, error);
544
+ }
545
+ }));
546
+ const organizations = sortByScore(organizationsRaw.map((organization) => ({
547
+ score: scoreMatch(search, [
548
+ organization.name,
549
+ organization.kind,
550
+ ]),
551
+ id: String(organization._id),
552
+ name: organization.name,
553
+ kind: organization.kind,
554
+ })), limit).map(({ score, ...entry }) => entry);
555
+ const mongoUsers = usersRaw.map((user) => buildUserResult(search, user));
556
+ const shouldSearchAuth0 = search.includes("@") || search.includes(".");
557
+ const auth0Users = shouldSearchAuth0
558
+ ? await searchUsersInAuth0(search, limit)
559
+ : [];
560
+ const mergedUsers = new Map();
561
+ for (const user of [...mongoUsers, ...auth0Users]) {
562
+ const existing = mergedUsers.get(user.id);
563
+ if (!existing || user.score > existing.score) {
564
+ mergedUsers.set(user.id, user);
565
+ }
566
+ }
567
+ const users = sortByScore(Array.from(mergedUsers.values()), limit).map(({ score, ...entry }) => entry);
568
+ const devices = sortByScore(devicesRaw.map((device) => {
569
+ const organizationData = device.organization;
570
+ const patientData = device.patient;
571
+ const deviceMeta = device.meta;
572
+ const iotStatus = toRecord(typeof device.deviceId === "string"
573
+ ? iotStatusByDeviceId.get(String(device.deviceId))
574
+ : undefined);
575
+ const patientMeta = patientData?.meta;
576
+ const patientName = patientData?.name
577
+ ? String(patientData.name)
578
+ : [patientMeta?.firstName, patientMeta?.lastName]
579
+ .filter(Boolean)
580
+ .join(" ");
581
+ const batteryLevel = normalizeNumber(iotStatus?.batLevel) ??
582
+ normalizeNumber(iotStatus?.batteryLevel) ??
583
+ normalizeNumber(iotStatus?.battery) ??
584
+ normalizeNumber(device.batLevel) ??
585
+ normalizeNumber(deviceMeta?.batLevel) ??
586
+ normalizeNumber(deviceMeta?.batteryLevel) ??
587
+ normalizeNumber(deviceMeta?.battery);
588
+ const batteryStatus = getFirstNestedString(iotStatus, [
589
+ "batteryStatus",
590
+ "battery.status",
591
+ "status.battery",
592
+ ]) ||
593
+ getFirstNestedString(deviceMeta, [
594
+ "batteryStatus",
595
+ "battery.status",
596
+ "status.battery",
597
+ ]) ||
598
+ (typeof batteryLevel === "number"
599
+ ? batteryLevel < 3450
600
+ ? "empty"
601
+ : batteryLevel < 3500
602
+ ? "low"
603
+ : "ok"
604
+ : undefined);
605
+ const lastReachableAgo = normalizeDateString(iotStatus?.lastReachableAgo) ||
606
+ normalizeDateString(iotStatus?.lastReachable) ||
607
+ normalizeDateString(device.lastReachableAgo) ||
608
+ getFirstNestedString(deviceMeta, [
609
+ "lastReachableAgo",
610
+ "reachability.last",
611
+ ]);
612
+ const serialNumber = getFirstNestedString(iotStatus, ["serialNumber", "serial", "sn"]) ||
613
+ getFirstNestedString(deviceMeta, ["serialNumber"]);
614
+ const signalStrength = normalizeNumber(iotStatus?.rssi) ??
615
+ normalizeNumber(iotStatus?.signalStrength) ??
616
+ normalizeNumber(iotStatus?.signal);
617
+ return {
618
+ score: scoreMatch(search, [
619
+ device.deviceId,
620
+ device.kind,
621
+ device.name,
622
+ serialNumber,
623
+ batteryStatus,
624
+ signalStrength !== undefined ? String(signalStrength) : undefined,
625
+ organizationData?.name,
626
+ patientName,
627
+ ]),
628
+ id: String(device._id),
629
+ name: device.name,
630
+ deviceId: device.deviceId,
631
+ kind: device.kind,
632
+ timezone: device.timezone,
633
+ eventDate: normalizeDateString(device.eventDate),
634
+ createdAt: normalizeDateString(device.createdAt),
635
+ updatedAt: normalizeDateString(device.updatedAt),
636
+ serialNumber,
637
+ paymentId: device.payment
638
+ ?.id,
639
+ batteryStatus,
640
+ batteryLevel,
641
+ signalStrength,
642
+ lastReachableAgo,
643
+ organization: organizationData
644
+ ? {
645
+ id: String(organizationData._id),
646
+ name: organizationData.name,
647
+ kind: organizationData.kind,
648
+ }
649
+ : undefined,
650
+ patient: patientData
651
+ ? {
652
+ id: String(patientData._id),
653
+ name: patientName,
654
+ }
655
+ : undefined,
656
+ };
657
+ }), limit).map(({ score, ...entry }) => entry);
658
+ return {
659
+ query: search,
660
+ organizations,
661
+ users,
662
+ devices,
663
+ total: organizations.length + users.length + devices.length,
664
+ tookMs: Date.now() - startedAt,
665
+ };
666
+ };
667
+ export const getAdminStats = async () => {
668
+ const startedAt = Date.now();
669
+ const [users, auth0Users, devices, organizations] = await Promise.all([
670
+ User.countDocuments({}),
671
+ getAuth0UsersTotal(),
672
+ Device.countDocuments({}),
673
+ Organization.countDocuments({}),
674
+ ]);
675
+ return {
676
+ users,
677
+ auth0Users,
678
+ devices,
679
+ organizations,
680
+ total: users + auth0Users + devices + organizations,
681
+ tookMs: Date.now() - startedAt,
682
+ };
683
+ };
684
+ export const getAdminIotDevices = async (query) => {
685
+ const startedAt = Date.now();
686
+ const iotDevices = await getCachedIotDevices();
687
+ const updatedSinceMs = query.updatedSince
688
+ ? new Date(query.updatedSince).getTime()
689
+ : null;
690
+ const normalized = Array.isArray(iotDevices)
691
+ ? iotDevices
692
+ .map((entry) => toRecord(entry))
693
+ .filter((entry) => typeof entry === "object" && entry !== null)
694
+ : [];
695
+ const filtered = updatedSinceMs && Number.isFinite(updatedSinceMs)
696
+ ? normalized.filter((entry) => {
697
+ const updatedAtValue = entry.updatedAt ??
698
+ entry.lastSeenAt ??
699
+ entry.lastSeen ??
700
+ entry.createdAt;
701
+ if (typeof updatedAtValue !== "string" &&
702
+ typeof updatedAtValue !== "number") {
703
+ return true;
704
+ }
705
+ const timestamp = new Date(String(updatedAtValue)).getTime();
706
+ if (!Number.isFinite(timestamp)) {
707
+ return true;
708
+ }
709
+ return timestamp >= updatedSinceMs;
710
+ })
711
+ : normalized;
712
+ const total = filtered.length;
713
+ const page = Math.max(1, query.page);
714
+ const perPage = Math.max(1, Math.min(500, query.perPage));
715
+ const totalPages = Math.max(1, Math.ceil(total / perPage));
716
+ const pageIndex = Math.min(page, totalPages) - 1;
717
+ const start = pageIndex * perPage;
718
+ const end = start + perPage;
719
+ const results = filtered.slice(start, end);
720
+ return {
721
+ results,
722
+ page,
723
+ perPage,
724
+ totalPages,
725
+ total,
726
+ tookMs: Date.now() - startedAt,
727
+ };
728
+ };
729
+ export const getAdminDevices = async (query) => {
730
+ const startedAt = Date.now();
731
+ const updatedSinceMs = query.updatedSince
732
+ ? new Date(query.updatedSince).getTime()
733
+ : null;
734
+ const filter = {};
735
+ if (updatedSinceMs && Number.isFinite(updatedSinceMs)) {
736
+ filter.updatedAt = { $gte: new Date(updatedSinceMs) };
737
+ }
738
+ const page = Math.max(1, query.page);
739
+ const perPage = Math.max(1, Math.min(500, query.perPage));
740
+ const skip = (page - 1) * perPage;
741
+ const [devices, total] = await Promise.all([
742
+ Device.find(filter)
743
+ .sort({ updatedAt: -1 })
744
+ .skip(skip)
745
+ .limit(perPage)
746
+ .lean(),
747
+ Device.countDocuments(filter),
748
+ ]);
749
+ const results = Array.isArray(devices)
750
+ ? devices
751
+ .map((entry) => toRecord(entry))
752
+ .filter((entry) => typeof entry === "object" && entry !== null)
753
+ : [];
754
+ const totalPages = Math.max(1, Math.ceil(total / perPage));
755
+ return {
756
+ results,
757
+ page,
758
+ perPage,
759
+ totalPages,
760
+ total,
761
+ tookMs: Date.now() - startedAt,
762
+ };
763
+ };