@internetderdinge/api 1.229.28 → 1.229.32

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,1096 @@
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
+
8
+ type SearchParams = {
9
+ search: string;
10
+ limit: number;
11
+ };
12
+
13
+ type OrganizationResult = {
14
+ id: string;
15
+ name?: string;
16
+ kind?: string;
17
+ };
18
+
19
+ type UserResult = {
20
+ id: string;
21
+ name?: string;
22
+ email?: string;
23
+ role?: string;
24
+ organization?: OrganizationResult;
25
+ };
26
+
27
+ type DeviceResult = {
28
+ id: string;
29
+ name?: string;
30
+ deviceId?: string;
31
+ kind?: string;
32
+ timezone?: string;
33
+ eventDate?: string;
34
+ createdAt?: string;
35
+ updatedAt?: string;
36
+ serialNumber?: string;
37
+ paymentId?: string;
38
+ batteryStatus?: string;
39
+ batteryLevel?: number;
40
+ lastReachableAgo?: string;
41
+ organization?: OrganizationResult;
42
+ patient?: {
43
+ id: string;
44
+ name?: string;
45
+ };
46
+ };
47
+
48
+ export type AdminSearchResponse = {
49
+ query: string;
50
+ organizations: OrganizationResult[];
51
+ users: UserResult[];
52
+ devices: DeviceResult[];
53
+ total: number;
54
+ tookMs: number;
55
+ };
56
+
57
+ export type AdminStatsResponse = {
58
+ users: number;
59
+ auth0Users: number;
60
+ devices: number;
61
+ organizations: number;
62
+ total: number;
63
+ tookMs: number;
64
+ };
65
+
66
+ export type AdminIotDevicesResponse = {
67
+ results: Array<Record<string, unknown>>;
68
+ page: number;
69
+ perPage: number;
70
+ totalPages: number;
71
+ total: number;
72
+ tookMs: number;
73
+ };
74
+
75
+ export type AdminDevicesResponse = {
76
+ results: Array<Record<string, unknown>>;
77
+ page: number;
78
+ perPage: number;
79
+ totalPages: number;
80
+ total: number;
81
+ tookMs: number;
82
+ };
83
+
84
+ type AdminListQuery = {
85
+ page: number;
86
+ perPage: number;
87
+ updatedSince: string | null;
88
+ };
89
+
90
+ const IOT_DEVICES_CACHE_TTL_MS = 60_000;
91
+
92
+ let iotDevicesCache:
93
+ | {
94
+ loadedAt: number;
95
+ devices: unknown[];
96
+ pending?: Promise<unknown[]>;
97
+ }
98
+ | null = null;
99
+
100
+ const getCachedIotDevices = async (): Promise<unknown[]> => {
101
+ const now = Date.now();
102
+ if (iotDevicesCache?.pending) {
103
+ return iotDevicesCache.pending;
104
+ }
105
+
106
+ if (
107
+ iotDevicesCache &&
108
+ now - iotDevicesCache.loadedAt < IOT_DEVICES_CACHE_TTL_MS
109
+ ) {
110
+ return iotDevicesCache.devices;
111
+ }
112
+
113
+ const pending = Promise.resolve(
114
+ iotDevicesService.getDeviceStatusList(undefined),
115
+ ).then(
116
+ (devices) => (Array.isArray(devices) ? devices : []),
117
+ );
118
+
119
+ iotDevicesCache = {
120
+ loadedAt: now,
121
+ devices: [],
122
+ pending,
123
+ };
124
+
125
+ try {
126
+ const devices = await pending;
127
+ iotDevicesCache = {
128
+ loadedAt: Date.now(),
129
+ devices,
130
+ };
131
+ return devices;
132
+ } catch (error) {
133
+ iotDevicesCache = null;
134
+ throw error;
135
+ }
136
+ };
137
+
138
+ const toRegex = (search: string): RegExp => {
139
+ const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
140
+ return new RegExp(escaped, "i");
141
+ };
142
+
143
+ const getNestedString = (
144
+ source: Record<string, unknown> | undefined,
145
+ path: string,
146
+ ): string | undefined => {
147
+ const value = path.split(".").reduce<unknown>((current, part) => {
148
+ if (!current || typeof current !== "object") {
149
+ return undefined;
150
+ }
151
+ return (current as Record<string, unknown>)[part];
152
+ }, source);
153
+
154
+ return typeof value === "string" ? value : undefined;
155
+ };
156
+
157
+ const getFirstNestedString = (
158
+ source: Record<string, unknown> | undefined,
159
+ paths: string[],
160
+ ): string | undefined => {
161
+ for (const path of paths) {
162
+ const value = getNestedString(source, path);
163
+ if (value) {
164
+ return value;
165
+ }
166
+ }
167
+ return undefined;
168
+ };
169
+
170
+ const userMetaEmailPaths = [
171
+ "email",
172
+ "data.email",
173
+ "user_metadata.email",
174
+ "app_metadata.email",
175
+ "auth0.email",
176
+ "auth0.data.email",
177
+ "auth0.user_metadata.email",
178
+ "auth0.app_metadata.email",
179
+ ];
180
+
181
+ const scoreMatch = (
182
+ search: string,
183
+ candidates: Array<string | undefined>,
184
+ ): number => {
185
+ const query = search.trim().toLowerCase();
186
+ if (!query) {
187
+ return 0;
188
+ }
189
+
190
+ let score = 0;
191
+
192
+ for (const rawCandidate of candidates) {
193
+ const candidate = rawCandidate?.toLowerCase().trim();
194
+ if (!candidate) {
195
+ continue;
196
+ }
197
+ if (candidate === query) {
198
+ score = Math.max(score, 100);
199
+ continue;
200
+ }
201
+ if (candidate.startsWith(query)) {
202
+ score = Math.max(score, 70);
203
+ continue;
204
+ }
205
+ if (candidate.includes(query)) {
206
+ score = Math.max(score, 40);
207
+ }
208
+ }
209
+
210
+ return score;
211
+ };
212
+
213
+ const sortByScore = <T extends { score: number }>(
214
+ items: T[],
215
+ limit: number,
216
+ ): T[] => {
217
+ return items.sort((a, b) => b.score - a.score).slice(0, limit);
218
+ };
219
+
220
+ type ScoredUserResult = UserResult & { score: number };
221
+
222
+ const AUTH0_COUNT_PAGE_SIZE = 100;
223
+ const MAX_AUTH0_COUNT_PAGES = 10;
224
+
225
+ type Auth0TokenResponse = {
226
+ access_token?: string;
227
+ expires_in?: number;
228
+ };
229
+
230
+ type Auth0TotalUsersResponse = {
231
+ total?: number;
232
+ };
233
+
234
+ let cachedAuth0ManagementToken: string | null = null;
235
+ let cachedAuth0ManagementTokenExpiresAt: number | null = null;
236
+
237
+ const getAuth0ManagementDomainUrl = (): string | null => {
238
+ const raw = process.env.AUTH0_MANAGEMENT_DOMAIN;
239
+ if (!raw) {
240
+ return null;
241
+ }
242
+
243
+ const normalized =
244
+ raw.startsWith("http://") || raw.startsWith("https://")
245
+ ? raw
246
+ : `https://${raw}`;
247
+
248
+ try {
249
+ const parsed = new URL(normalized);
250
+ return parsed.origin;
251
+ } catch {
252
+ return normalized;
253
+ }
254
+ };
255
+
256
+ const getAuth0ManagementTokenDirect = async (): Promise<string> => {
257
+ const now = Math.floor(Date.now() / 1000);
258
+ if (
259
+ cachedAuth0ManagementToken &&
260
+ cachedAuth0ManagementTokenExpiresAt &&
261
+ now < cachedAuth0ManagementTokenExpiresAt - 60
262
+ ) {
263
+ return cachedAuth0ManagementToken;
264
+ }
265
+
266
+ const domainUrl = getAuth0ManagementDomainUrl();
267
+ const clientId = process.env.AUTH0_MANAGEMENT_CLIENT_ID;
268
+ const clientSecret = process.env.AUTH0_MANAGEMENT_CLIENT_SECRET;
269
+ const audience = process.env.AUTH0_MANAGEMENT_AUDIENCE;
270
+
271
+ if (!domainUrl || !clientId || !clientSecret || !audience) {
272
+ throw new Error("Missing Auth0 Management API credentials in environment.");
273
+ }
274
+
275
+ const response = await fetch(`${domainUrl}/oauth/token`, {
276
+ method: "POST",
277
+ headers: {
278
+ "content-type": "application/json",
279
+ },
280
+ body: JSON.stringify({
281
+ grant_type: "client_credentials",
282
+ client_id: clientId,
283
+ client_secret: clientSecret,
284
+ audience,
285
+ }),
286
+ });
287
+
288
+ if (!response.ok) {
289
+ throw new Error(`Auth0 token request failed (${response.status}).`);
290
+ }
291
+
292
+ const payload = (await response.json()) as Auth0TokenResponse;
293
+ if (!payload.access_token) {
294
+ throw new Error("Auth0 token response did not include access_token.");
295
+ }
296
+
297
+ const expiresIn = payload.expires_in ?? 3600;
298
+ cachedAuth0ManagementToken = payload.access_token;
299
+ cachedAuth0ManagementTokenExpiresAt = now + expiresIn;
300
+
301
+ return cachedAuth0ManagementToken;
302
+ };
303
+
304
+ const getAuth0UsersTotalViaManagementApi = async (): Promise<number> => {
305
+ const domainUrl = getAuth0ManagementDomainUrl();
306
+ if (!domainUrl) {
307
+ throw new Error("Missing AUTH0_MANAGEMENT_DOMAIN in environment.");
308
+ }
309
+
310
+ const token = await getAuth0ManagementTokenDirect();
311
+ const response = await fetch(
312
+ `${domainUrl}/api/v2/users?page=0&per_page=1&include_totals=true`,
313
+ {
314
+ method: "GET",
315
+ headers: {
316
+ authorization: `Bearer ${token}`,
317
+ "content-type": "application/json",
318
+ },
319
+ },
320
+ );
321
+
322
+ if (!response.ok) {
323
+ throw new Error(`Auth0 users total request failed (${response.status}).`);
324
+ }
325
+
326
+ const payload = (await response.json()) as Auth0TotalUsersResponse;
327
+ if (typeof payload.total !== "number" || !Number.isFinite(payload.total)) {
328
+ throw new Error("Auth0 users response did not include a valid total.");
329
+ }
330
+
331
+ return payload.total;
332
+ };
333
+
334
+ const getUsersFromAuth0Response = (
335
+ auth0Response: unknown,
336
+ ): Array<Record<string, unknown>> => {
337
+ if (Array.isArray(auth0Response)) {
338
+ return auth0Response as Array<Record<string, unknown>>;
339
+ }
340
+
341
+ if (
342
+ auth0Response &&
343
+ typeof auth0Response === "object" &&
344
+ Symbol.iterator in (auth0Response as Record<string, unknown>)
345
+ ) {
346
+ return Array.from(auth0Response as Iterable<Record<string, unknown>>);
347
+ }
348
+
349
+ if (Array.isArray((auth0Response as { data?: unknown[] })?.data)) {
350
+ return (auth0Response as { data: unknown[] }).data as Array<
351
+ Record<string, unknown>
352
+ >;
353
+ }
354
+
355
+ if (Array.isArray((auth0Response as { users?: unknown[] })?.users)) {
356
+ return (auth0Response as { users: unknown[] }).users as Array<
357
+ Record<string, unknown>
358
+ >;
359
+ }
360
+
361
+ if (Array.isArray((auth0Response as { items?: unknown[] })?.items)) {
362
+ return (auth0Response as { items: unknown[] }).items as Array<
363
+ Record<string, unknown>
364
+ >;
365
+ }
366
+
367
+ return [];
368
+ };
369
+
370
+ const getTotalFromAuth0Response = (
371
+ auth0Response: unknown,
372
+ ): number | undefined => {
373
+ if (!auth0Response || typeof auth0Response !== "object") {
374
+ return undefined;
375
+ }
376
+
377
+ const maybeNumber = (auth0Response as { total?: unknown }).total;
378
+ if (typeof maybeNumber === "number" && Number.isFinite(maybeNumber)) {
379
+ return maybeNumber;
380
+ }
381
+
382
+ const data = (auth0Response as { data?: unknown }).data;
383
+ if (data && typeof data === "object") {
384
+ const nested = (data as { total?: unknown }).total;
385
+ if (typeof nested === "number" && Number.isFinite(nested)) {
386
+ return nested;
387
+ }
388
+ }
389
+
390
+ return undefined;
391
+ };
392
+
393
+ const getAuth0UsersTotal = async (): Promise<number> => {
394
+ try {
395
+ return await getAuth0UsersTotalViaManagementApi();
396
+ } catch (error) {
397
+ console.warn(
398
+ "Admin stats Auth0 total-users API failed, fallback to list",
399
+ error,
400
+ );
401
+ }
402
+
403
+ try {
404
+ const seenUserIds = new Set<string>();
405
+ let countByLength = 0;
406
+
407
+ for (let page = 0; page < MAX_AUTH0_COUNT_PAGES; page += 1) {
408
+ let response: unknown;
409
+ try {
410
+ response = await auth0.users.list({
411
+ page,
412
+ per_page: AUTH0_COUNT_PAGE_SIZE,
413
+ include_totals: false,
414
+ });
415
+ } catch (error) {
416
+ const message =
417
+ error instanceof Error
418
+ ? error.message
419
+ : typeof error === "string"
420
+ ? error
421
+ : "";
422
+
423
+ if (message.includes("invalid_paging")) {
424
+ break;
425
+ }
426
+
427
+ throw error;
428
+ }
429
+
430
+ const users = getUsersFromAuth0Response(response);
431
+ countByLength += users.length;
432
+
433
+ for (const entry of users) {
434
+ const userId =
435
+ typeof entry.user_id === "string"
436
+ ? entry.user_id
437
+ : typeof entry.id === "string"
438
+ ? entry.id
439
+ : undefined;
440
+ if (userId) {
441
+ seenUserIds.add(userId);
442
+ }
443
+ }
444
+
445
+ if (users.length < AUTH0_COUNT_PAGE_SIZE) {
446
+ break;
447
+ }
448
+ }
449
+
450
+ return seenUserIds.size > 0 ? seenUserIds.size : countByLength;
451
+ } catch (error) {
452
+ console.warn("Admin stats Auth0 total query failed", error);
453
+ return 0;
454
+ }
455
+ };
456
+
457
+ const normalizeEmail = (value: unknown): string | undefined => {
458
+ return typeof value === "string" && value.trim()
459
+ ? value.trim().toLowerCase()
460
+ : undefined;
461
+ };
462
+
463
+ const normalizeNumber = (value: unknown): number | undefined => {
464
+ if (typeof value === "number" && Number.isFinite(value)) {
465
+ return value;
466
+ }
467
+ if (typeof value === "string" && value.trim()) {
468
+ const parsed = Number(value);
469
+ return Number.isFinite(parsed) ? parsed : undefined;
470
+ }
471
+ return undefined;
472
+ };
473
+
474
+ const normalizeDateString = (value: unknown): string | undefined => {
475
+ if (value instanceof Date) {
476
+ return value.toISOString();
477
+ }
478
+ if (typeof value === "string" && value.trim()) {
479
+ return value;
480
+ }
481
+ return undefined;
482
+ };
483
+
484
+ const toRecord = (value: unknown): Record<string, unknown> | undefined => {
485
+ return value && typeof value === "object"
486
+ ? (value as Record<string, unknown>)
487
+ : undefined;
488
+ };
489
+
490
+ const buildUserResult = (
491
+ search: string,
492
+ user: Record<string, unknown>,
493
+ ): ScoredUserResult => {
494
+ const organizationData = user.organizationData as
495
+ | Record<string, unknown>
496
+ | undefined;
497
+ const userMeta = user.meta as Record<string, unknown> | undefined;
498
+ const firstName = user.meta
499
+ ? (user.meta as Record<string, unknown>).firstName
500
+ : undefined;
501
+ const lastName = user.meta
502
+ ? (user.meta as Record<string, unknown>).lastName
503
+ : undefined;
504
+ const metadataEmail = getFirstNestedString(userMeta, userMetaEmailPaths);
505
+
506
+ return {
507
+ score: scoreMatch(search, [
508
+ user.name as string,
509
+ user.email as string,
510
+ metadataEmail,
511
+ firstName as string,
512
+ lastName as string,
513
+ organizationData?.name as string,
514
+ ]),
515
+ id: String(user._id),
516
+ name: user.name as string | undefined,
517
+ email: (user.email as string | undefined) || metadataEmail,
518
+ role: user.role as string | undefined,
519
+ organization: organizationData
520
+ ? {
521
+ id: String(organizationData._id),
522
+ name: organizationData.name as string | undefined,
523
+ kind: organizationData.kind as string | undefined,
524
+ }
525
+ : undefined,
526
+ };
527
+ };
528
+
529
+ const searchUsersInAuth0 = async (
530
+ search: string,
531
+ limit: number,
532
+ ): Promise<ScoredUserResult[]> => {
533
+ const trimmedSearch = search.trim();
534
+ const escaped = search.replace(/"/g, '\\"');
535
+
536
+ const auth0Query = [
537
+ `email:"${escaped}"`,
538
+ `user_metadata.email:"${escaped}"`,
539
+ `app_metadata.email:"${escaped}"`,
540
+ `email:*${escaped}*`,
541
+ ].join(" OR ");
542
+
543
+ try {
544
+ let auth0ListResponse: unknown = [];
545
+ try {
546
+ auth0ListResponse = await auth0.users.list({
547
+ search_engine: "v3",
548
+ q: auth0Query,
549
+ per_page: Math.min(limit * 2, 50),
550
+ page: 0,
551
+ });
552
+ } catch (error) {
553
+ console.warn("Admin search Auth0 list query failed", error);
554
+ }
555
+
556
+ let auth0ExactEmailResponse: unknown = [];
557
+ if (trimmedSearch.includes("@")) {
558
+ try {
559
+ auth0ExactEmailResponse = await auth0.users.listUsersByEmail({
560
+ email: trimmedSearch,
561
+ });
562
+ } catch (error) {
563
+ console.warn("Admin search Auth0 exact-email query failed", error);
564
+ }
565
+ }
566
+
567
+ const auth0UsersById = new Map<string, Record<string, unknown>>();
568
+ for (const entry of [
569
+ ...getUsersFromAuth0Response(auth0ListResponse),
570
+ ...getUsersFromAuth0Response(auth0ExactEmailResponse),
571
+ ]) {
572
+ const userId =
573
+ typeof entry.user_id === "string"
574
+ ? entry.user_id
575
+ : typeof entry.id === "string"
576
+ ? entry.id
577
+ : undefined;
578
+ if (!userId) {
579
+ continue;
580
+ }
581
+ auth0UsersById.set(userId, entry);
582
+ }
583
+
584
+ const auth0Users = Array.from(auth0UsersById.values());
585
+
586
+ const auth0Ids = auth0Users
587
+ .map((entry) =>
588
+ typeof entry.user_id === "string" ? entry.user_id : undefined,
589
+ )
590
+ .filter((entry): entry is string => Boolean(entry));
591
+
592
+ const auth0Emails = auth0Users
593
+ .map((entry) => normalizeEmail(entry.email))
594
+ .filter((entry): entry is string => Boolean(entry));
595
+
596
+ if (!auth0Ids.length && !auth0Emails.length) {
597
+ return [];
598
+ }
599
+
600
+ const filterOr: Array<Record<string, unknown>> = [];
601
+ if (auth0Ids.length) {
602
+ filterOr.push({ owner: { $in: auth0Ids } });
603
+ }
604
+ if (auth0Emails.length) {
605
+ filterOr.push(
606
+ { email: { $in: auth0Emails } },
607
+ { "meta.email": { $in: auth0Emails } },
608
+ { "meta.data.email": { $in: auth0Emails } },
609
+ );
610
+ }
611
+
612
+ const mongoUsersRaw = await User.find({ $or: filterOr })
613
+ .select("_id name email role organization owner meta")
614
+ .populate("organizationData", "_id name kind")
615
+ .limit(limit * 2)
616
+ .lean<Array<Record<string, unknown>>>();
617
+
618
+ const mongoUsers = mongoUsersRaw.map((user) =>
619
+ buildUserResult(search, user),
620
+ );
621
+
622
+ const mappedOwners = new Set<string>(
623
+ mongoUsersRaw
624
+ .map((user) =>
625
+ typeof user.owner === "string" ? (user.owner as string) : undefined,
626
+ )
627
+ .filter((entry): entry is string => Boolean(entry)),
628
+ );
629
+
630
+ const mappedEmails = new Set<string>();
631
+ for (const user of mongoUsersRaw) {
632
+ const topLevelEmail = normalizeEmail(user.email);
633
+ if (topLevelEmail) {
634
+ mappedEmails.add(topLevelEmail);
635
+ }
636
+
637
+ const meta = user.meta as Record<string, unknown> | undefined;
638
+ const metaEmail = normalizeEmail(
639
+ getFirstNestedString(meta, userMetaEmailPaths),
640
+ );
641
+ if (metaEmail) {
642
+ mappedEmails.add(metaEmail);
643
+ }
644
+ }
645
+
646
+ const auth0OnlyUsers: ScoredUserResult[] = auth0Users
647
+ .map((entry): ScoredUserResult | undefined => {
648
+ const userId =
649
+ typeof entry.user_id === "string"
650
+ ? entry.user_id
651
+ : typeof entry.id === "string"
652
+ ? entry.id
653
+ : undefined;
654
+ if (!userId) {
655
+ return undefined;
656
+ }
657
+
658
+ const email = normalizeEmail(entry.email);
659
+ const userMetadata = entry.user_metadata as
660
+ | Record<string, unknown>
661
+ | undefined;
662
+ const appMetadata = entry.app_metadata as
663
+ | Record<string, unknown>
664
+ | undefined;
665
+ const metadataEmail =
666
+ normalizeEmail(userMetadata?.email) ||
667
+ normalizeEmail(appMetadata?.email);
668
+ const finalEmail = email || metadataEmail;
669
+
670
+ if (mappedOwners.has(userId)) {
671
+ return undefined;
672
+ }
673
+ if (finalEmail && mappedEmails.has(finalEmail)) {
674
+ return undefined;
675
+ }
676
+
677
+ const name =
678
+ (typeof entry.name === "string" && entry.name) ||
679
+ (typeof entry.nickname === "string" && entry.nickname) ||
680
+ finalEmail ||
681
+ userId;
682
+
683
+ return {
684
+ id: `auth0:${userId}`,
685
+ name,
686
+ email: finalEmail,
687
+ role: "auth0",
688
+ score: scoreMatch(search, [
689
+ name,
690
+ finalEmail,
691
+ userId,
692
+ normalizeEmail(userMetadata?.email),
693
+ normalizeEmail(appMetadata?.email),
694
+ ]),
695
+ };
696
+ })
697
+ .filter((entry): entry is ScoredUserResult => Boolean(entry));
698
+
699
+ return [...mongoUsers, ...auth0OnlyUsers];
700
+ } catch (error) {
701
+ console.warn("Admin search Auth0 fallback failed", error);
702
+ return [];
703
+ }
704
+ };
705
+
706
+ export const searchAdminCollections = async ({
707
+ search,
708
+ limit,
709
+ }: SearchParams): Promise<AdminSearchResponse> => {
710
+ const startedAt = Date.now();
711
+ const hasSearch = search.trim().length > 0;
712
+ const regex = toRegex(search);
713
+ const objectId = mongoose.Types.ObjectId.isValid(search)
714
+ ? new mongoose.Types.ObjectId(search)
715
+ : undefined;
716
+
717
+ const organizationFilter: Record<string, unknown> = hasSearch
718
+ ? {
719
+ $or: [{ name: regex }, { kind: regex }, { "meta.name": regex }],
720
+ }
721
+ : {};
722
+
723
+ const userFilter: Record<string, unknown> = hasSearch
724
+ ? {
725
+ $or: [
726
+ { name: regex },
727
+ { email: regex },
728
+ { owner: regex },
729
+ { "meta.email": regex },
730
+ { "meta.data.email": regex },
731
+ { "meta.user_metadata.email": regex },
732
+ { "meta.app_metadata.email": regex },
733
+ { "meta.auth0.email": regex },
734
+ { "meta.auth0.data.email": regex },
735
+ { "meta.auth0.user_metadata.email": regex },
736
+ { "meta.auth0.app_metadata.email": regex },
737
+ { "meta.firstName": regex },
738
+ { "meta.lastName": regex },
739
+ ],
740
+ }
741
+ : {};
742
+
743
+ const deviceFilter: Record<string, unknown> = hasSearch
744
+ ? {
745
+ $or: [
746
+ { name: regex },
747
+ { deviceId: regex },
748
+ { kind: regex },
749
+ { "meta.name": regex },
750
+ { "meta.serialNumber": regex },
751
+ { "payment.id": regex },
752
+ ],
753
+ }
754
+ : {};
755
+
756
+ if (objectId) {
757
+ (organizationFilter.$or as Array<Record<string, unknown>>).push({
758
+ _id: objectId,
759
+ });
760
+ (userFilter.$or as Array<Record<string, unknown>>).push(
761
+ { _id: objectId },
762
+ { organization: objectId },
763
+ );
764
+ (deviceFilter.$or as Array<Record<string, unknown>>).push(
765
+ { _id: objectId },
766
+ { organization: objectId },
767
+ { patient: objectId },
768
+ );
769
+ }
770
+
771
+ const [organizationsRaw, usersRaw, devicesRaw] = await Promise.all([
772
+ Organization.find(organizationFilter)
773
+ .select("_id name kind")
774
+ .limit(limit * 2)
775
+ .lean<Array<Record<string, unknown>>>(),
776
+ User.find(userFilter)
777
+ .select("_id name email role organization meta")
778
+ .populate("organizationData", "_id name kind")
779
+ .limit(limit * 2)
780
+ .lean<Array<Record<string, unknown>>>(),
781
+ Device.find(deviceFilter)
782
+ .select(
783
+ "_id name deviceId kind timezone eventDate payment organization patient meta createdAt updatedAt",
784
+ )
785
+ .populate("organization", "_id name kind")
786
+ .populate("patient", "_id name email meta")
787
+ .limit(limit * 2)
788
+ .lean<Array<Record<string, unknown>>>(),
789
+ ]);
790
+
791
+ const iotStatusByDeviceId = new Map<string, Record<string, unknown>>();
792
+ await Promise.all(
793
+ devicesRaw.map(async (device) => {
794
+ const deviceId =
795
+ typeof device.deviceId === "string" ? String(device.deviceId) : "";
796
+ if (!deviceId) {
797
+ return;
798
+ }
799
+
800
+ try {
801
+ const iotStatus = await iotDevicesService.getDeviceStatus(
802
+ deviceId,
803
+ device.kind as string | undefined,
804
+ );
805
+ const normalized = toRecord(iotStatus);
806
+ if (normalized) {
807
+ iotStatusByDeviceId.set(deviceId, normalized);
808
+ }
809
+ } catch (error) {
810
+ console.warn(`Admin search iot status failed for ${deviceId}`, error);
811
+ }
812
+ }),
813
+ );
814
+
815
+ const organizations = sortByScore(
816
+ organizationsRaw.map((organization) => ({
817
+ score: scoreMatch(search, [
818
+ organization.name as string,
819
+ organization.kind as string,
820
+ ]),
821
+ id: String(organization._id),
822
+ name: organization.name as string | undefined,
823
+ kind: organization.kind as string | undefined,
824
+ })),
825
+ limit,
826
+ ).map(({ score, ...entry }) => entry);
827
+
828
+ const mongoUsers = usersRaw.map((user) => buildUserResult(search, user));
829
+ const shouldSearchAuth0 = search.includes("@") || search.includes(".");
830
+ const auth0Users = shouldSearchAuth0
831
+ ? await searchUsersInAuth0(search, limit)
832
+ : [];
833
+
834
+ const mergedUsers = new Map<string, ScoredUserResult>();
835
+ for (const user of [...mongoUsers, ...auth0Users]) {
836
+ const existing = mergedUsers.get(user.id);
837
+ if (!existing || user.score > existing.score) {
838
+ mergedUsers.set(user.id, user);
839
+ }
840
+ }
841
+
842
+ const users = sortByScore(Array.from(mergedUsers.values()), limit).map(
843
+ ({ score, ...entry }) => entry,
844
+ );
845
+
846
+ const devices = sortByScore(
847
+ devicesRaw.map((device) => {
848
+ const organizationData = device.organization as
849
+ | Record<string, unknown>
850
+ | undefined;
851
+ const patientData = device.patient as Record<string, unknown> | undefined;
852
+ const deviceMeta = device.meta as Record<string, unknown> | undefined;
853
+ const iotStatus = toRecord(
854
+ typeof device.deviceId === "string"
855
+ ? iotStatusByDeviceId.get(String(device.deviceId))
856
+ : undefined,
857
+ );
858
+ const patientMeta = patientData?.meta as
859
+ | Record<string, unknown>
860
+ | undefined;
861
+ const patientName = patientData?.name
862
+ ? String(patientData.name)
863
+ : [patientMeta?.firstName, patientMeta?.lastName]
864
+ .filter(Boolean)
865
+ .join(" ");
866
+
867
+ const batteryLevel =
868
+ normalizeNumber(iotStatus?.batLevel) ??
869
+ normalizeNumber(iotStatus?.batteryLevel) ??
870
+ normalizeNumber(iotStatus?.battery) ??
871
+ normalizeNumber(device.batLevel) ??
872
+ normalizeNumber(deviceMeta?.batLevel) ??
873
+ normalizeNumber(deviceMeta?.batteryLevel) ??
874
+ normalizeNumber(deviceMeta?.battery);
875
+
876
+ const batteryStatus =
877
+ getFirstNestedString(iotStatus, [
878
+ "batteryStatus",
879
+ "battery.status",
880
+ "status.battery",
881
+ ]) ||
882
+ getFirstNestedString(deviceMeta, [
883
+ "batteryStatus",
884
+ "battery.status",
885
+ "status.battery",
886
+ ]) ||
887
+ (typeof batteryLevel === "number"
888
+ ? batteryLevel < 3450
889
+ ? "empty"
890
+ : batteryLevel < 3500
891
+ ? "low"
892
+ : "ok"
893
+ : undefined);
894
+
895
+ const lastReachableAgo =
896
+ normalizeDateString(iotStatus?.lastReachableAgo) ||
897
+ normalizeDateString(iotStatus?.lastReachable) ||
898
+ normalizeDateString(device.lastReachableAgo) ||
899
+ getFirstNestedString(deviceMeta, [
900
+ "lastReachableAgo",
901
+ "reachability.last",
902
+ ]);
903
+
904
+ const serialNumber =
905
+ getFirstNestedString(iotStatus, ["serialNumber", "serial", "sn"]) ||
906
+ getFirstNestedString(deviceMeta, ["serialNumber"]);
907
+
908
+ const signalStrength =
909
+ normalizeNumber(iotStatus?.rssi) ??
910
+ normalizeNumber(iotStatus?.signalStrength) ??
911
+ normalizeNumber(iotStatus?.signal);
912
+
913
+ return {
914
+ score: scoreMatch(search, [
915
+ device.deviceId as string,
916
+ device.kind as string,
917
+ device.name as string,
918
+ serialNumber,
919
+ batteryStatus,
920
+ signalStrength !== undefined ? String(signalStrength) : undefined,
921
+ organizationData?.name as string,
922
+ patientName,
923
+ ]),
924
+ id: String(device._id),
925
+ name: device.name as string | undefined,
926
+ deviceId: device.deviceId as string | undefined,
927
+ kind: device.kind as string | undefined,
928
+ timezone: device.timezone as string | undefined,
929
+ eventDate: normalizeDateString(device.eventDate),
930
+ createdAt: normalizeDateString(device.createdAt),
931
+ updatedAt: normalizeDateString(device.updatedAt),
932
+ serialNumber,
933
+ paymentId: (device.payment as Record<string, unknown> | undefined)
934
+ ?.id as string | undefined,
935
+ batteryStatus,
936
+ batteryLevel,
937
+ signalStrength,
938
+ lastReachableAgo,
939
+ organization: organizationData
940
+ ? {
941
+ id: String(organizationData._id),
942
+ name: organizationData.name as string | undefined,
943
+ kind: organizationData.kind as string | undefined,
944
+ }
945
+ : undefined,
946
+ patient: patientData
947
+ ? {
948
+ id: String(patientData._id),
949
+ name: patientName,
950
+ }
951
+ : undefined,
952
+ };
953
+ }),
954
+ limit,
955
+ ).map(({ score, ...entry }) => entry);
956
+
957
+ return {
958
+ query: search,
959
+ organizations,
960
+ users,
961
+ devices,
962
+ total: organizations.length + users.length + devices.length,
963
+ tookMs: Date.now() - startedAt,
964
+ };
965
+ };
966
+
967
+ export const getAdminStats = async (): Promise<AdminStatsResponse> => {
968
+ const startedAt = Date.now();
969
+
970
+ const [users, auth0Users, devices, organizations] = await Promise.all([
971
+ User.countDocuments({}),
972
+ getAuth0UsersTotal(),
973
+ Device.countDocuments({}),
974
+ Organization.countDocuments({}),
975
+ ]);
976
+
977
+ return {
978
+ users,
979
+ auth0Users,
980
+ devices,
981
+ organizations,
982
+ total: users + auth0Users + devices + organizations,
983
+ tookMs: Date.now() - startedAt,
984
+ };
985
+ };
986
+
987
+ export const getAdminIotDevices = async (
988
+ query: AdminListQuery,
989
+ ): Promise<AdminIotDevicesResponse> => {
990
+ const startedAt = Date.now();
991
+
992
+ const iotDevices = await getCachedIotDevices();
993
+ const updatedSinceMs = query.updatedSince
994
+ ? new Date(query.updatedSince).getTime()
995
+ : null;
996
+
997
+ const normalized = Array.isArray(iotDevices)
998
+ ? iotDevices
999
+ .map((entry) => toRecord(entry))
1000
+ .filter(
1001
+ (entry): entry is Record<string, unknown> =>
1002
+ typeof entry === "object" && entry !== null,
1003
+ )
1004
+ : [];
1005
+
1006
+ const filtered =
1007
+ updatedSinceMs && Number.isFinite(updatedSinceMs)
1008
+ ? normalized.filter((entry) => {
1009
+ const updatedAtValue =
1010
+ entry.updatedAt ??
1011
+ entry.lastSeenAt ??
1012
+ entry.lastSeen ??
1013
+ entry.createdAt;
1014
+
1015
+ if (
1016
+ typeof updatedAtValue !== "string" &&
1017
+ typeof updatedAtValue !== "number"
1018
+ ) {
1019
+ return true;
1020
+ }
1021
+
1022
+ const timestamp = new Date(String(updatedAtValue)).getTime();
1023
+ if (!Number.isFinite(timestamp)) {
1024
+ return true;
1025
+ }
1026
+
1027
+ return timestamp >= updatedSinceMs;
1028
+ })
1029
+ : normalized;
1030
+
1031
+ const total = filtered.length;
1032
+ const page = Math.max(1, query.page);
1033
+ const perPage = Math.max(1, Math.min(500, query.perPage));
1034
+ const totalPages = Math.max(1, Math.ceil(total / perPage));
1035
+ const pageIndex = Math.min(page, totalPages) - 1;
1036
+ const start = pageIndex * perPage;
1037
+ const end = start + perPage;
1038
+ const results = filtered.slice(start, end);
1039
+
1040
+ return {
1041
+ results,
1042
+ page,
1043
+ perPage,
1044
+ totalPages,
1045
+ total,
1046
+ tookMs: Date.now() - startedAt,
1047
+ };
1048
+ };
1049
+
1050
+ export const getAdminDevices = async (
1051
+ query: AdminListQuery,
1052
+ ): Promise<AdminDevicesResponse> => {
1053
+ const startedAt = Date.now();
1054
+
1055
+ const updatedSinceMs = query.updatedSince
1056
+ ? new Date(query.updatedSince).getTime()
1057
+ : null;
1058
+
1059
+ const filter: Record<string, unknown> = {};
1060
+ if (updatedSinceMs && Number.isFinite(updatedSinceMs)) {
1061
+ filter.updatedAt = { $gte: new Date(updatedSinceMs) };
1062
+ }
1063
+
1064
+ const page = Math.max(1, query.page);
1065
+ const perPage = Math.max(1, Math.min(500, query.perPage));
1066
+ const skip = (page - 1) * perPage;
1067
+
1068
+ const [devices, total] = await Promise.all([
1069
+ Device.find(filter)
1070
+ .sort({ updatedAt: -1 })
1071
+ .skip(skip)
1072
+ .limit(perPage)
1073
+ .lean(),
1074
+ Device.countDocuments(filter),
1075
+ ]);
1076
+
1077
+ const results = Array.isArray(devices)
1078
+ ? devices
1079
+ .map((entry) => toRecord(entry))
1080
+ .filter(
1081
+ (entry): entry is Record<string, unknown> =>
1082
+ typeof entry === "object" && entry !== null,
1083
+ )
1084
+ : [];
1085
+
1086
+ const totalPages = Math.max(1, Math.ceil(total / perPage));
1087
+
1088
+ return {
1089
+ results,
1090
+ page,
1091
+ perPage,
1092
+ totalPages,
1093
+ total,
1094
+ tookMs: Date.now() - startedAt,
1095
+ };
1096
+ };