@internetderdinge/api 1.229.28 → 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.
- package/dist/src/admin/adminSearch.controller.js +54 -0
- package/dist/src/admin/adminSearch.route.js +52 -0
- package/dist/src/admin/adminSearch.schemas.js +68 -0
- package/dist/src/admin/adminSearch.service.js +763 -0
- package/dist/src/admin/adminSearch.validation.js +24 -0
- package/dist/src/index.js +1 -0
- package/dist/src/utils/registerOpenApi.js +0 -21
- package/package.json +1 -1
- package/src/admin/adminSearch.controller.ts +101 -0
- package/src/admin/adminSearch.route.ts +73 -0
- package/src/admin/adminSearch.schemas.ts +75 -0
- package/src/admin/adminSearch.service.ts +1096 -0
- package/src/admin/adminSearch.validation.ts +28 -0
- package/src/index.ts +1 -0
- package/src/utils/registerOpenApi.ts +0 -21
|
@@ -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
|
+
};
|