@justair/justair-library 7.6.0-alpha.4ff5aae → 7.6.0-alpha.f80fd11
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/constants/deviceTypes.d.ts +1 -0
- package/dist/constants/deviceTypes.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/models/users.d.ts.map +1 -1
- package/dist/utils/subscribedDevices.d.ts +7 -5
- package/dist/utils/subscribedDevices.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants/deviceTypes.js +19 -0
- package/src/constants/tests/deviceTypes.test.js +22 -0
- package/src/index.js +26 -8
- package/src/models/users.js +29 -15
- package/src/utils/subscribedDevices.js +100 -36
- package/src/utils/tests/subscribedDevices.test.js +59 -27
- package/src/utils/tests/subscribedDevicesBarrel.test.js +4 -2
|
@@ -5,6 +5,7 @@ export const DEVICE_TYPES: Readonly<{
|
|
|
5
5
|
}>;
|
|
6
6
|
export const DEVICE_TYPE_VALUES: readonly ("monitor" | "sample-site" | "linked-device")[];
|
|
7
7
|
export function isAqiCapableDeviceType(deviceType: any): boolean;
|
|
8
|
+
export const AQI_CAPABLE_DEVICE_TYPES: readonly ("monitor" | "sample-site" | "linked-device")[];
|
|
8
9
|
/**
|
|
9
10
|
* The device-type vocabulary as a type, so consumers get autocomplete and a
|
|
10
11
|
* compile error on a mis-cased value like "sampleSite" — the exact bug the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deviceTypes.d.ts","sourceRoot":"","sources":["../../src/constants/deviceTypes.js"],"names":[],"mappings":"AA0BA;;;;GAIG;AAEH,0FAA6E;AAiBtE,iEAEoC;;;;;;
|
|
1
|
+
{"version":3,"file":"deviceTypes.d.ts","sourceRoot":"","sources":["../../src/constants/deviceTypes.js"],"names":[],"mappings":"AA0BA;;;;GAIG;AAEH,0FAA6E;AAiBtE,iEAEoC;AAiB3C,gGAEE;;;;;;yBA/BW,CAAA,OAAO,YAAY,EAAC,MAAM,OAAO,YAAY,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -106,8 +106,9 @@ import { getAlertWeight } from "./constants/alertLevels.js";
|
|
|
106
106
|
import { DEVICE_TYPES } from "./constants/deviceTypes.js";
|
|
107
107
|
import { DEVICE_TYPE_VALUES } from "./constants/deviceTypes.js";
|
|
108
108
|
import { isAqiCapableDeviceType } from "./constants/deviceTypes.js";
|
|
109
|
+
import { AQI_CAPABLE_DEVICE_TYPES } from "./constants/deviceTypes.js";
|
|
109
110
|
import { projectSubscribedMonitors } from "./utils/subscribedDevices.js";
|
|
110
|
-
import {
|
|
111
|
+
import { resolveSubscribedMonitorIdsByUser } from "./utils/subscribedDevices.js";
|
|
111
112
|
import { VISIBLE_SITE_MONITOR_FILTER } from "./utils/subscribedDevices.js";
|
|
112
113
|
import { SUBSCRIBED_DEVICES_ERRORS } from "./utils/subscribedDevices.js";
|
|
113
114
|
import { splitName } from "./utils/splitName.js";
|
|
@@ -120,5 +121,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
|
|
|
120
121
|
import { getZoneMinutes } from "./utils/quietHours.js";
|
|
121
122
|
import { isInQuietWindow } from "./utils/quietHours.js";
|
|
122
123
|
import { resolveQuietHours } from "./utils/quietHours.js";
|
|
123
|
-
export { Database, adminSchema, Admin, configurationsSchema, Configurations, measurementsSchema, Measurements, monitorRequestsSchema, MonitorRequests, monitorsSchema, Monitors, organizationsSchema, Organizations, usersSchema, Users, COOKIE_CONSENT_CATEGORIES, COOKIE_CONSENT_VALUES, validateCookieConsent, eventsSchema, Events, lightMonitorSchema, LightMonitors, monitorSuppliersSchema, MonitorSuppliers, contextsSchema, Contexts, parametersSchema, Parameters, announcementSchema, Announcements, jobsSchema, Jobs, apiKeySchema, ApiKey, UsageMetrics, usageMetricsSchema, Audit, auditSchema, EventsAudit, eventsAuditSchema, MonitorAudit, monitorAuditSchema, parametersEnum, deploymentTypesEnum, AlertsAudit, Alerts, alertsSchema, alertsAuditSchema, Features, featuresSchema, dataCompletenessSchema, DataCompleteness, networkMetricsSchema, NetworkMetrics, rateOfChangeSchema, RateOfChange, changeStreamCheckpointsSchema, ChangeStreamCheckpoints, sitesSchema, Sites, sampleSitesSchema, SampleSites, sampleSiteAuditSchema, SampleSiteAudit, sampleParametersEnum, samplesSchema, Samples, samplesAuditSchema, SamplesAudit, sampleParameterReferenceConcentrations, passwordResetTokenSchema, PasswordResetToken, monitorEpisodesSchema, MonitorEpisode, userNotificationsSchema, UserNotification, AqiAlertNotification, DeviceOfflineNotification, WeeklyDigestNotification, AccountNotification, NOTIFICATION_KINDS, NOTIFICATION_CHANNELS, DELIVERY_STATUSES, DATA_DOWNLOAD_TYPES, dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload, DATA_DOWNLOAD_MAX_RANGE_DAYS, DATA_DOWNLOAD_ERRORS, validateDataDownloadDateOrder, validateDataDownloadMaxDateRange, validateDataDownloadEmail, normalizeDataDownloadEmail, validateDataDownloadTimezone, normalizeDataDownloadTimezone, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, DEVICE_TYPES, DEVICE_TYPE_VALUES, isAqiCapableDeviceType, projectSubscribedMonitors,
|
|
124
|
+
export { Database, adminSchema, Admin, configurationsSchema, Configurations, measurementsSchema, Measurements, monitorRequestsSchema, MonitorRequests, monitorsSchema, Monitors, organizationsSchema, Organizations, usersSchema, Users, COOKIE_CONSENT_CATEGORIES, COOKIE_CONSENT_VALUES, validateCookieConsent, eventsSchema, Events, lightMonitorSchema, LightMonitors, monitorSuppliersSchema, MonitorSuppliers, contextsSchema, Contexts, parametersSchema, Parameters, announcementSchema, Announcements, jobsSchema, Jobs, apiKeySchema, ApiKey, UsageMetrics, usageMetricsSchema, Audit, auditSchema, EventsAudit, eventsAuditSchema, MonitorAudit, monitorAuditSchema, parametersEnum, deploymentTypesEnum, AlertsAudit, Alerts, alertsSchema, alertsAuditSchema, Features, featuresSchema, dataCompletenessSchema, DataCompleteness, networkMetricsSchema, NetworkMetrics, rateOfChangeSchema, RateOfChange, changeStreamCheckpointsSchema, ChangeStreamCheckpoints, sitesSchema, Sites, sampleSitesSchema, SampleSites, sampleSiteAuditSchema, SampleSiteAudit, sampleParametersEnum, samplesSchema, Samples, samplesAuditSchema, SamplesAudit, sampleParameterReferenceConcentrations, passwordResetTokenSchema, PasswordResetToken, monitorEpisodesSchema, MonitorEpisode, userNotificationsSchema, UserNotification, AqiAlertNotification, DeviceOfflineNotification, WeeklyDigestNotification, AccountNotification, NOTIFICATION_KINDS, NOTIFICATION_CHANNELS, DELIVERY_STATUSES, DATA_DOWNLOAD_TYPES, dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload, DATA_DOWNLOAD_MAX_RANGE_DAYS, DATA_DOWNLOAD_ERRORS, validateDataDownloadDateOrder, validateDataDownloadMaxDateRange, validateDataDownloadEmail, normalizeDataDownloadEmail, validateDataDownloadTimezone, normalizeDataDownloadTimezone, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, DEVICE_TYPES, DEVICE_TYPE_VALUES, isAqiCapableDeviceType, AQI_CAPABLE_DEVICE_TYPES, projectSubscribedMonitors, resolveSubscribedMonitorIdsByUser, VISIBLE_SITE_MONITOR_FILTER, SUBSCRIBED_DEVICES_ERRORS, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
|
|
124
125
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAyKA;;;iBAEC;yBAtGwB,oBAAoB;qBADxB,gBAAgB;4BApEF,mBAAmB;sBAAnB,mBAAmB;qCAI/C,4BAA4B;+BAA5B,4BAA4B;mCAM5B,0BAA0B;6BAA1B,0BAA0B;sCAI1B,6BAA6B;gCAA7B,6BAA6B;+BAQ7B,sBAAsB;yBAAtB,sBAAsB;oCACsB,2BAA2B;8BAA3B,2BAA2B;4BAcvE,mBAAmB;sBAAnB,mBAAmB;0CAAnB,mBAAmB;sCAAnB,mBAAmB;sCAAnB,mBAAmB;6BAMnB,oBAAoB;uBAApB,oBAAoB;mCAnBuB,2BAA2B;8BAA3B,2BAA2B;uCAItE,8BAA8B;iCAA9B,8BAA8B;+BACI,sBAAsB;yBAAtB,sBAAsB;iCAClB,wBAAwB;2BAAxB,wBAAwB;mCAcnB,2BAA2B;8BAA3B,2BAA2B;2BAC5C,kBAAkB;qBAAlB,kBAAkB;6BACd,oBAAoB;uBAApB,oBAAoB;6BACR,0BAA0B;mCAA1B,0BAA0B;sBArCpE,0BAA0B;4BAA1B,0BAA0B;4BAiC1B,oBAAoB;kCAApB,oBAAoB;6BArBpB,sBAAsB;mCAAtB,sBAAsB;+BAAtB,sBAAsB;oCAAtB,sBAAsB;4BA+BtB,oBAAoB;uBAApB,oBAAoB;6BAApB,oBAAoB;kCAApB,oBAAoB;yBACc,sBAAsB;+BAAtB,sBAAsB;uCAIxD,8BAA8B;iCAA9B,8BAA8B;qCAI9B,4BAA4B;+BAA5B,4BAA4B;mCACc,0BAA0B;6BAA1B,0BAA0B;8CAIpE,qCAAqC;wCAArC,qCAAqC;4BAkDT,mBAAmB;sBAAnB,mBAAmB;kCAS/C,yBAAyB;4BAAzB,yBAAyB;sCAAzB,yBAAyB;gCAAzB,yBAAyB;qCAAzB,yBAAyB;8BASzB,qBAAqB;wBAArB,qBAAqB;mCAArB,qBAAqB;6BAArB,qBAAqB;uDAArB,qBAAqB;yCAIrB,gCAAgC;mCAAhC,gCAAgC;sCArDhC,6BAA6B;+BAA7B,6BAA6B;wCAW7B,+BAA+B;iCAA/B,+BAA+B;qCAA/B,+BAA+B;0CAA/B,+BAA+B;yCAA/B,+BAA+B;oCAA/B,+BAA+B;mCAA/B,+BAA+B;sCAA/B,+BAA+B;kCAA/B,+BAA+B;oCArBF,8BAA8B;mCAqE3D,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAsB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;yEAAnC,mCAAmC;2EAAnC,mCAAmC;2BAjGD,2BAA2B;6BAA3B,2BAA2B;oCAI7D,4BAA4B;+BAA5B,4BAA4B;6BAQ5B,4BAA4B;mCAA5B,4BAA4B;uCAA5B,4BAA4B;yCAA5B,4BAA4B;0CAgC5B,8BAA8B;kDAA9B,8BAA8B;4CAA9B,8BAA8B;0CAA9B,8BAA8B;0BAhBX,sBAAsB;oCASzC,uBAAuB;iCAhCG,0BAA0B;mCAgCpD,uBAAuB;kCAAvB,uBAAuB;oCACM,qBAAqB;mCADlD,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/models/users.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/models/users.js"],"names":[],"mappings":"AA6FA,8BA0GE;AAiCF,wBAAmD;AA/NnD,iDAA4D;AAC5D,6CAAmD;AAUnD,4DA0BC"}
|
|
@@ -8,10 +8,12 @@ export const SUBSCRIBED_DEVICES_ERRORS: Readonly<{
|
|
|
8
8
|
COHORT_USER_MISSING_ID: "cohort_user_missing_id";
|
|
9
9
|
}>;
|
|
10
10
|
export const VISIBLE_SITE_MONITOR_FILTER: Readonly<{
|
|
11
|
-
isPrivate: {
|
|
12
|
-
$ne:
|
|
13
|
-
}
|
|
14
|
-
isActive:
|
|
11
|
+
isPrivate: Readonly<{
|
|
12
|
+
$ne: true;
|
|
13
|
+
}>;
|
|
14
|
+
isActive: Readonly<{
|
|
15
|
+
$ne: false;
|
|
16
|
+
}>;
|
|
15
17
|
}>;
|
|
16
18
|
export function projectSubscribedMonitors(subscribedDevices?: Array<{
|
|
17
19
|
deviceId: import("mongoose").Types.ObjectId;
|
|
@@ -19,7 +21,7 @@ export function projectSubscribedMonitors(subscribedDevices?: Array<{
|
|
|
19
21
|
}>, { resolveSiteMonitorIds }?: {
|
|
20
22
|
resolveSiteMonitorIds?: (siteIds: import("mongoose").Types.ObjectId[]) => Promise<Array<import("mongoose").Types.ObjectId | null | undefined>>;
|
|
21
23
|
}): Promise<import("mongoose").Types.ObjectId[]>;
|
|
22
|
-
export function
|
|
24
|
+
export function resolveSubscribedMonitorIdsByUser(users?: Array<{
|
|
23
25
|
_id: any;
|
|
24
26
|
subscribedDevices?: Array<{
|
|
25
27
|
deviceId: import("mongoose").Types.ObjectId;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subscribedDevices.d.ts","sourceRoot":"","sources":["../../src/utils/subscribedDevices.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"subscribedDevices.d.ts","sourceRoot":"","sources":["../../src/utils/subscribedDevices.js"],"names":[],"mappings":"AA0FA;;;;;;;;GASG;AAyCH;;;;;;;GAGG;AA6LI,8DANI,KAAK,CAAC;IAAE,QAAQ,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,CAAC;IAC1D,UAAU,EAAE,OAAO,6BAA6B,EAAE,UAAU,CAAA;CAAE,CAAC,8BACzD;IAAE,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,KAC7E,OAAO,CAAC,KAAK,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC,CAAA;CAAE,GAC9D,OAAO,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CA0BxD;AAuEM,0DAPI,KAAK,CAAC;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,iBAAiB,CAAC,EAAE,KAAK,CAAC;QAClD,QAAQ,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,CAAC;QAC5C,UAAU,EAAE,OAAO,6BAA6B,EAAE,UAAU,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,mCAC5D;IAAE,0BAA0B,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,KAClF,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAA;CAAE,GACnD,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CAmErE"}
|
package/package.json
CHANGED
|
@@ -50,3 +50,22 @@ export const DEVICE_TYPE_VALUES = Object.freeze(Object.values(DEVICE_TYPES));
|
|
|
50
50
|
export const isAqiCapableDeviceType = (deviceType) =>
|
|
51
51
|
deviceType === DEVICE_TYPES.MONITOR ||
|
|
52
52
|
deviceType === DEVICE_TYPES.LINKED_DEVICE;
|
|
53
|
+
|
|
54
|
+
// The same predicate in the shape a Mongo query wants, so a cohort read can
|
|
55
|
+
// leave out rows that could never alert instead of loading and discarding
|
|
56
|
+
// them:
|
|
57
|
+
//
|
|
58
|
+
// { "subscribedDevices.deviceType": { $in: AQI_CAPABLE_DEVICE_TYPES } }
|
|
59
|
+
//
|
|
60
|
+
// Derived from isAqiCapableDeviceType's own answer rather than hand-listed, so
|
|
61
|
+
// the two cannot disagree if a fourth device type ever arrives. Frozen for the
|
|
62
|
+
// same reason DEVICE_TYPE_VALUES is: every importer in a process shares this
|
|
63
|
+
// instance, and one `.push()` would silently widen the audience for all of
|
|
64
|
+
// them.
|
|
65
|
+
//
|
|
66
|
+
// Carries the same asymmetry the predicate does: `linked-device` being in this
|
|
67
|
+
// list is necessary and NOT sufficient — a Site only actually alerts when it
|
|
68
|
+
// links at least one monitor, which no query on deviceType can tell you.
|
|
69
|
+
export const AQI_CAPABLE_DEVICE_TYPES = Object.freeze(
|
|
70
|
+
DEVICE_TYPE_VALUES.filter(isAqiCapableDeviceType),
|
|
71
|
+
);
|
|
@@ -8,12 +8,14 @@ import {
|
|
|
8
8
|
DEVICE_TYPES,
|
|
9
9
|
DEVICE_TYPE_VALUES,
|
|
10
10
|
isAqiCapableDeviceType,
|
|
11
|
+
AQI_CAPABLE_DEVICE_TYPES,
|
|
11
12
|
} from '../deviceTypes.js';
|
|
12
13
|
|
|
13
14
|
test('barrel exports the device-type constants', () => {
|
|
14
15
|
expect(barrel.DEVICE_TYPES).toBe(DEVICE_TYPES);
|
|
15
16
|
expect(barrel.DEVICE_TYPE_VALUES).toBe(DEVICE_TYPE_VALUES);
|
|
16
17
|
expect(barrel.isAqiCapableDeviceType).toBe(isAqiCapableDeviceType);
|
|
18
|
+
expect(barrel.AQI_CAPABLE_DEVICE_TYPES).toBe(AQI_CAPABLE_DEVICE_TYPES);
|
|
17
19
|
});
|
|
18
20
|
|
|
19
21
|
test('DEVICE_TYPES values match the strings the devices API emits', () => {
|
|
@@ -51,3 +53,23 @@ describe('isAqiCapableDeviceType', () => {
|
|
|
51
53
|
expect(isAqiCapableDeviceType('')).toBe(false);
|
|
52
54
|
});
|
|
53
55
|
});
|
|
56
|
+
|
|
57
|
+
describe('AQI_CAPABLE_DEVICE_TYPES', () => {
|
|
58
|
+
test('is the $in form of isAqiCapableDeviceType, in vocabulary order', () => {
|
|
59
|
+
expect(AQI_CAPABLE_DEVICE_TYPES).toEqual(['monitor', 'linked-device']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('agrees with the predicate for every device type', () => {
|
|
63
|
+
// Derived from the predicate rather than hand-listed, so this pins that
|
|
64
|
+
// the derivation stays wired up if a fourth device type is added.
|
|
65
|
+
DEVICE_TYPE_VALUES.forEach((deviceType) => {
|
|
66
|
+
expect(AQI_CAPABLE_DEVICE_TYPES.includes(deviceType)).toBe(
|
|
67
|
+
isAqiCapableDeviceType(deviceType),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('is frozen — every importer shares this instance', () => {
|
|
73
|
+
expect(Object.isFrozen(AQI_CAPABLE_DEVICE_TYPES)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/index.js
CHANGED
|
@@ -57,7 +57,10 @@ import {
|
|
|
57
57
|
DataCompleteness,
|
|
58
58
|
dataCompletenessSchema,
|
|
59
59
|
} from "./models/dataCompleteness.js";
|
|
60
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
NetworkMetrics,
|
|
62
|
+
networkMetricsSchema,
|
|
63
|
+
} from "./models/networkMetrics.js";
|
|
61
64
|
import { RateOfChange, rateOfChangeSchema } from "./models/rateOfChange.js";
|
|
62
65
|
import {
|
|
63
66
|
ChangeStreamCheckpoints,
|
|
@@ -66,13 +69,17 @@ import {
|
|
|
66
69
|
import Database from "./config/db.js"; // Import the new Database class
|
|
67
70
|
import CustomLogger from "./config/logger.js";
|
|
68
71
|
import { PARAMETERS, HEAVY_METALS } from "./constants/pollutants.js";
|
|
69
|
-
import {
|
|
72
|
+
import {
|
|
73
|
+
ALERT_LEVEL_WEIGHTS,
|
|
74
|
+
getAlertWeight,
|
|
75
|
+
} from "./constants/alertLevels.js";
|
|
70
76
|
import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
|
|
71
77
|
import { DATA_DOWNLOAD_TYPES } from "./constants/dataDownloads.js";
|
|
72
78
|
import {
|
|
73
79
|
DEVICE_TYPES,
|
|
74
80
|
DEVICE_TYPE_VALUES,
|
|
75
81
|
isAqiCapableDeviceType,
|
|
82
|
+
AQI_CAPABLE_DEVICE_TYPES,
|
|
76
83
|
} from "./constants/deviceTypes.js";
|
|
77
84
|
import {
|
|
78
85
|
monitorEpisodesSchema,
|
|
@@ -102,7 +109,7 @@ import {
|
|
|
102
109
|
import { isValidIANATimezone } from "./utils/timezone.js";
|
|
103
110
|
import {
|
|
104
111
|
projectSubscribedMonitors,
|
|
105
|
-
|
|
112
|
+
resolveSubscribedMonitorIdsByUser,
|
|
106
113
|
VISIBLE_SITE_MONITOR_FILTER,
|
|
107
114
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
108
115
|
} from "./utils/subscribedDevices.js";
|
|
@@ -127,7 +134,10 @@ import {
|
|
|
127
134
|
SamplesAudit,
|
|
128
135
|
sampleParameterReferenceConcentrations,
|
|
129
136
|
} from "./models/samples.js";
|
|
130
|
-
import {
|
|
137
|
+
import {
|
|
138
|
+
passwordResetTokenSchema,
|
|
139
|
+
PasswordResetToken,
|
|
140
|
+
} from "./models/passwordResetToken.js";
|
|
131
141
|
import {
|
|
132
142
|
dataDownloadSchema,
|
|
133
143
|
DataDownloads,
|
|
@@ -274,17 +284,25 @@ export {
|
|
|
274
284
|
DEVICE_TYPES,
|
|
275
285
|
DEVICE_TYPE_VALUES,
|
|
276
286
|
isAqiCapableDeviceType,
|
|
287
|
+
// The query form of isAqiCapableDeviceType, for narrowing a Users read to
|
|
288
|
+
// rows that could actually alert:
|
|
289
|
+
// { "subscribedDevices.deviceType": { $in: AQI_CAPABLE_DEVICE_TYPES } }
|
|
290
|
+
AQI_CAPABLE_DEVICE_TYPES,
|
|
277
291
|
// subscribedDevices → monitor ids, resolved at READ time (JA-3110 / JA-3335).
|
|
278
292
|
// Users.subscribedDevices is the source of truth; Users.subscribedMonitors is
|
|
279
293
|
// being retired rather than kept in step with it. Nothing should store this
|
|
280
294
|
// result — see the module header for the five bugs a stored copy caused.
|
|
281
295
|
//
|
|
282
|
-
//
|
|
283
|
-
// cohort. What alert runs, org pages and
|
|
284
|
-
// responses should call.
|
|
296
|
+
// resolveSubscribedMonitorIdsByUser — the batch form. One Sites lookup for
|
|
297
|
+
// a whole cohort. What alert runs, org pages and
|
|
298
|
+
// session responses should call.
|
|
285
299
|
// projectSubscribedMonitors — the single-user rule the batch form is built
|
|
286
300
|
// on. Named after the field being retired; it
|
|
287
301
|
// gets renamed when that field is dropped.
|
|
302
|
+
//
|
|
303
|
+
// Only correct for users whose subscribedDevices has been backfilled from
|
|
304
|
+
// subscribedMonitors — see the PRECONDITION block in the module header
|
|
305
|
+
// before pointing a service at these.
|
|
288
306
|
// VISIBLE_SITE_MONITOR_FILTER — the privacy predicate every consumer must
|
|
289
307
|
// apply when turning a Site into monitor ids.
|
|
290
308
|
// Sites.monitorIds is unfiltered; reading it
|
|
@@ -292,7 +310,7 @@ export {
|
|
|
292
310
|
// SUBSCRIBED_DEVICES_ERRORS — stable `err.code` vocabulary; consumers
|
|
293
311
|
// branch on it, never on message wording.
|
|
294
312
|
projectSubscribedMonitors,
|
|
295
|
-
|
|
313
|
+
resolveSubscribedMonitorIdsByUser,
|
|
296
314
|
VISIBLE_SITE_MONITOR_FILTER,
|
|
297
315
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
298
316
|
// Utilities
|
package/src/models/users.js
CHANGED
|
@@ -15,11 +15,15 @@ const cookieConsentSchemaDefinition = COOKIE_CONSENT_CATEGORIES.reduce(
|
|
|
15
15
|
definition[category] = { type: String, enum: COOKIE_CONSENT_VALUES };
|
|
16
16
|
return definition;
|
|
17
17
|
},
|
|
18
|
-
{}
|
|
18
|
+
{},
|
|
19
19
|
);
|
|
20
20
|
|
|
21
21
|
const validateCookieConsent = (consent) => {
|
|
22
|
-
if (
|
|
22
|
+
if (
|
|
23
|
+
typeof consent !== "object" ||
|
|
24
|
+
consent === null ||
|
|
25
|
+
Array.isArray(consent)
|
|
26
|
+
) {
|
|
23
27
|
return "cookie_consent must be an object";
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -34,7 +38,7 @@ const validateCookieConsent = (consent) => {
|
|
|
34
38
|
}
|
|
35
39
|
if (!COOKIE_CONSENT_VALUES.includes(value)) {
|
|
36
40
|
return `Invalid value for ${category}: must be one of ${COOKIE_CONSENT_VALUES.join(
|
|
37
|
-
", "
|
|
41
|
+
", ",
|
|
38
42
|
)}`;
|
|
39
43
|
}
|
|
40
44
|
}
|
|
@@ -84,7 +88,7 @@ const subscribedDeviceSchema = mongoose.Schema(
|
|
|
84
88
|
// writing (and/or pass `{ runValidators: true }` to the update).
|
|
85
89
|
deviceType: { type: String, enum: DEVICE_TYPE_VALUES, required: true },
|
|
86
90
|
},
|
|
87
|
-
{ _id: false }
|
|
91
|
+
{ _id: false },
|
|
88
92
|
);
|
|
89
93
|
|
|
90
94
|
const usersSchema = mongoose.Schema(
|
|
@@ -99,21 +103,31 @@ const usersSchema = mongoose.Schema(
|
|
|
99
103
|
organizations: [{ type: mongoose.Types.ObjectId, ref: "Organizations" }],
|
|
100
104
|
alertPhone: String,
|
|
101
105
|
alertCount: { type: Number, default: 0 },
|
|
106
|
+
// BEING RETIRED (JA-3110 / JA-3335). This was a denormalized copy of the
|
|
107
|
+
// AQI-alert audience, rewritten on every subscribe and unsubscribe. Do not
|
|
108
|
+
// write it from new code and do not keep it in step with anything: the
|
|
109
|
+
// audience is now RESOLVED AT READ TIME from subscribedDevices below, and
|
|
110
|
+
// the field is dropped once every consumer has moved off it. Five distinct
|
|
111
|
+
// silent-failure bugs traced back to maintaining this copy; they are
|
|
112
|
+
// enumerated in the header of src/utils/subscribedDevices.js.
|
|
113
|
+
//
|
|
114
|
+
// It is still read (and still the correct thing to read) for any user
|
|
115
|
+
// whose subscribedDevices has not been backfilled yet — the same header's
|
|
116
|
+
// PRECONDITION block has the ordering.
|
|
102
117
|
subscribedMonitors: [{ type: mongoose.Types.ObjectId, ref: "Monitors" }],
|
|
103
|
-
// Subscribed devices (JA-3110 / JA-3335) — the
|
|
118
|
+
// Subscribed devices (JA-3110 / JA-3335) — the SOURCE OF TRUTH for what the
|
|
104
119
|
// user has added to "My Monitors", across all three device types.
|
|
105
120
|
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
// doing.
|
|
121
|
+
// The monitor ids this implies are derived on read, never stored:
|
|
122
|
+
// `resolveSubscribedMonitorIdsByUser` for a cohort,
|
|
123
|
+
// `projectSubscribedMonitors` for one user, both exported from
|
|
124
|
+
// src/index.js. Deliberately not a schema validator — the derivation needs
|
|
125
|
+
// a Site lookup, which the schema has no business doing.
|
|
112
126
|
//
|
|
113
127
|
// Everything else — the per-device-type rule, the backfill PRECONDITION,
|
|
114
|
-
// the
|
|
115
|
-
// in the header of src/utils/subscribedDevices.js. Read it there; it
|
|
116
|
-
// deliberately not summarised here.
|
|
128
|
+
// the privacy obligation, and why the field is split at all — is specified
|
|
129
|
+
// once, in the header of src/utils/subscribedDevices.js. Read it there; it
|
|
130
|
+
// is deliberately not summarised here.
|
|
117
131
|
subscribedDevices: [subscribedDeviceSchema],
|
|
118
132
|
currentAlertMode: {
|
|
119
133
|
type: String,
|
|
@@ -182,7 +196,7 @@ const usersSchema = mongoose.Schema(
|
|
|
182
196
|
},
|
|
183
197
|
{
|
|
184
198
|
timestamps: true,
|
|
185
|
-
}
|
|
199
|
+
},
|
|
186
200
|
);
|
|
187
201
|
|
|
188
202
|
// Cross-field quiet-hours rules (both-or-neither, start ≠ end) delegated to
|
|
@@ -39,6 +39,29 @@
|
|
|
39
39
|
// this result, you are reintroducing all five.
|
|
40
40
|
//
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
|
+
// PRECONDITION: THE BACKFILL LANDS BEFORE ANY CONSUMER SWITCHES OVER
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Resolving at read time is only CORRECT for a user whose `subscribedDevices`
|
|
45
|
+
// has already been backfilled from `subscribedMonitors`. A user who predates
|
|
46
|
+
// the field carries a full `subscribedMonitors` and no `subscribedDevices` key
|
|
47
|
+
// at all on a `.lean()` read — real state, pinned in
|
|
48
|
+
// `src/models/tests/users.test.js`. Call `resolveSubscribedMonitorIdsByUser`
|
|
49
|
+
// on that user and you get `[]` back: their AQI alerts stop, and nothing
|
|
50
|
+
// anywhere errors.
|
|
51
|
+
//
|
|
52
|
+
// So the ordering is not negotiable. Per environment: backfill
|
|
53
|
+
// `subscribedDevices` from `subscribedMonitors` FIRST, verify it, and only
|
|
54
|
+
// then point that environment's consumers at this module. Until the backfill
|
|
55
|
+
// has run there, consumers keep reading `subscribedMonitors`. Dropping the
|
|
56
|
+
// field is the last step of the JA-3110 plan (see "JA-3110 — Retire
|
|
57
|
+
// subscribedMonitors" in JustAirService `TODOS.md`), never the first.
|
|
58
|
+
//
|
|
59
|
+
// This module cannot detect the un-backfilled case for you — an empty
|
|
60
|
+
// `subscribedDevices` and "follows nothing" are the same value — which is
|
|
61
|
+
// exactly why the ordering has to be an operational guarantee rather than a
|
|
62
|
+
// runtime check.
|
|
63
|
+
//
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
42
65
|
// PRIVACY IS THE CALLER'S JOB, AND IT IS NOT OPTIONAL
|
|
43
66
|
// ---------------------------------------------------------------------------
|
|
44
67
|
// A Site's `monitorIds` is a raw membership list with no visibility filtering.
|
|
@@ -70,7 +93,7 @@ export const SUBSCRIBED_DEVICES_ERRORS = Object.freeze({
|
|
|
70
93
|
RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array",
|
|
71
94
|
RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays",
|
|
72
95
|
RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents",
|
|
73
|
-
//
|
|
96
|
+
// resolveSubscribedMonitorIdsByUser only
|
|
74
97
|
MISSING_COHORT_SITE_RESOLVER: "missing_cohort_site_resolver",
|
|
75
98
|
COHORT_RESOLVER_RETURNED_NON_MAP: "cohort_resolver_returned_non_map",
|
|
76
99
|
COHORT_USER_MISSING_ID: "cohort_user_missing_id",
|
|
@@ -93,15 +116,31 @@ export const SUBSCRIBED_DEVICES_ERRORS = Object.freeze({
|
|
|
93
116
|
// "_id",
|
|
94
117
|
// ).lean();
|
|
95
118
|
//
|
|
119
|
+
// `isActive: { $ne: false }` for the same reason, one step weaker than it
|
|
120
|
+
// looks: `isActive` is `default: true` and NOT `required` (models/monitors.js),
|
|
121
|
+
// so a monitor written before the field existed carries no key. An exact
|
|
122
|
+
// `isActive: true` match would drop it out of every Site's audience silently —
|
|
123
|
+
// the same class of bug as an exact `isPrivate: false` match, and the two
|
|
124
|
+
// halves of this predicate should fail the same way. Both clauses are
|
|
125
|
+
// deliberately permissive on a missing key: the cost of over-including a stale
|
|
126
|
+
// monitor is a redundant alert, the cost of under-including is a person who
|
|
127
|
+
// stops being alerted at all.
|
|
128
|
+
//
|
|
96
129
|
// `monitorState: "Deployed"` is deliberately NOT here. That is an
|
|
97
130
|
// alerting-eligibility rule, not a visibility rule, and it belongs to the
|
|
98
131
|
// notification service alone — the org admin page and the session response
|
|
99
132
|
// must still show a follower the monitors they follow while those monitors sit
|
|
100
133
|
// in a non-Deployed state. Do not consolidate the two filters; keep
|
|
101
134
|
// JustAirNotificationService's own `monitorState` predicate where it is.
|
|
135
|
+
//
|
|
136
|
+
// Frozen at every level, not just the top. `Object.freeze` is shallow, so a
|
|
137
|
+
// top-level-only freeze still lets a consumer write
|
|
138
|
+
// `VISIBLE_SITE_MONITOR_FILTER.isPrivate.$ne = false` — and because every
|
|
139
|
+
// consumer spreads THIS instance, that one write flips the privacy predicate
|
|
140
|
+
// for the whole process. Freeze each nested operator object too.
|
|
102
141
|
export const VISIBLE_SITE_MONITOR_FILTER = Object.freeze({
|
|
103
|
-
isPrivate: { $ne: true },
|
|
104
|
-
isActive:
|
|
142
|
+
isPrivate: Object.freeze({ $ne: true }),
|
|
143
|
+
isActive: Object.freeze({ $ne: false }),
|
|
105
144
|
});
|
|
106
145
|
|
|
107
146
|
// Dedupe by string form, keep the caller's ObjectId instances. Two equal
|
|
@@ -162,7 +201,7 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
162
201
|
|
|
163
202
|
if (!Array.isArray(resolved)) {
|
|
164
203
|
const error = new TypeError(
|
|
165
|
-
"resolveSiteMonitorIds must resolve to an array of monitor ids"
|
|
204
|
+
"resolveSiteMonitorIds must resolve to an array of monitor ids",
|
|
166
205
|
);
|
|
167
206
|
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NON_ARRAY;
|
|
168
207
|
throw error;
|
|
@@ -174,7 +213,7 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
174
213
|
// silently works, two throws a CastError at write time, far from the cause.
|
|
175
214
|
if (resolved.some(Array.isArray)) {
|
|
176
215
|
const error = new TypeError(
|
|
177
|
-
"resolveSiteMonitorIds must resolve to a flat array of monitor ids"
|
|
216
|
+
"resolveSiteMonitorIds must resolve to a flat array of monitor ids",
|
|
178
217
|
);
|
|
179
218
|
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NESTED_ARRAYS;
|
|
180
219
|
throw error;
|
|
@@ -190,11 +229,11 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
190
229
|
// (`Sites.find(..., "monitorIds -_id").lean()`) carries none.
|
|
191
230
|
if (
|
|
192
231
|
resolved.some(
|
|
193
|
-
(id) => isPlainObject(id) && typeof id.toHexString !== "function"
|
|
232
|
+
(id) => isPlainObject(id) && typeof id.toHexString !== "function",
|
|
194
233
|
)
|
|
195
234
|
) {
|
|
196
235
|
const error = new TypeError(
|
|
197
|
-
"resolveSiteMonitorIds must resolve to monitor ids, not Site documents"
|
|
236
|
+
"resolveSiteMonitorIds must resolve to monitor ids, not Site documents",
|
|
198
237
|
);
|
|
199
238
|
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS;
|
|
200
239
|
throw error;
|
|
@@ -220,7 +259,7 @@ const validateResolvedSiteMonitorMap = (resolved) => {
|
|
|
220
259
|
|
|
221
260
|
if (!(resolved instanceof Map)) {
|
|
222
261
|
const error = new TypeError(
|
|
223
|
-
"resolveVisibleSiteMonitors must resolve to a Map of site id to monitor ids"
|
|
262
|
+
"resolveVisibleSiteMonitors must resolve to a Map of site id to monitor ids",
|
|
224
263
|
);
|
|
225
264
|
error.code = SUBSCRIBED_DEVICES_ERRORS.COHORT_RESOLVER_RETURNED_NON_MAP;
|
|
226
265
|
throw error;
|
|
@@ -230,15 +269,15 @@ const validateResolvedSiteMonitorMap = (resolved) => {
|
|
|
230
269
|
[...resolved.entries()].map(([siteId, monitorIds]) => [
|
|
231
270
|
String(siteId),
|
|
232
271
|
validateResolvedSiteMonitorIds(monitorIds),
|
|
233
|
-
])
|
|
272
|
+
]),
|
|
234
273
|
);
|
|
235
274
|
};
|
|
236
275
|
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
276
|
+
// Resolves one user's subscribedDevices array down to the flat monitor ids
|
|
277
|
+
// that make up their AQI-alert audience. Order is deterministic — direct
|
|
278
|
+
// `monitor` subscriptions in array order, then Site-derived ids in resolver
|
|
279
|
+
// order, first occurrence wins — but carries no meaning; treat the result as a
|
|
280
|
+
// set. This is a value to use, not a value to persist (see below).
|
|
242
281
|
//
|
|
243
282
|
// `subscribedDevices` tolerates undefined/null: a user row written before this
|
|
244
283
|
// field existed has no key at all on a `.lean()` read (pinned in
|
|
@@ -247,10 +286,21 @@ const validateResolvedSiteMonitorMap = (resolved) => {
|
|
|
247
286
|
//
|
|
248
287
|
// `resolveSiteMonitorIds(siteIds)` is called at most once, with every
|
|
249
288
|
// linked-device id at a time, and must resolve to the flat union of those
|
|
250
|
-
// Sites' monitorIds — the ids themselves, not the Site documents
|
|
289
|
+
// Sites' VISIBLE monitorIds — the ids themselves, not the Site documents.
|
|
290
|
+
// `Sites.monitorIds` is a raw membership list, so it goes through
|
|
291
|
+
// VISIBLE_SITE_MONITOR_FILTER before it becomes an alert audience; handing
|
|
292
|
+
// back `site.monitorIds` untouched is the leak the privacy note above
|
|
293
|
+
// describes:
|
|
251
294
|
//
|
|
252
295
|
// const sites = await Sites.find({ _id: { $in: siteIds } }, "monitorIds").lean();
|
|
253
|
-
//
|
|
296
|
+
// const monitors = await Monitors.find(
|
|
297
|
+
// {
|
|
298
|
+
// _id: { $in: sites.flatMap((site) => site.monitorIds ?? []) },
|
|
299
|
+
// ...VISIBLE_SITE_MONITOR_FILTER,
|
|
300
|
+
// },
|
|
301
|
+
// "_id",
|
|
302
|
+
// ).lean();
|
|
303
|
+
// return monitors.map((monitor) => monitor._id);
|
|
254
304
|
//
|
|
255
305
|
// Returning the documents is the easy mistake, so it is rejected rather than
|
|
256
306
|
// merely documented: the return is validated and a document-shaped entry
|
|
@@ -270,8 +320,9 @@ const validateResolvedSiteMonitorMap = (resolved) => {
|
|
|
270
320
|
// from. Its privacy obligation applies here too: the ids this returns are only
|
|
271
321
|
// as safe to alert on as the resolver that produced them.
|
|
272
322
|
//
|
|
273
|
-
// For a whole cohort of users, prefer
|
|
274
|
-
// the Sites lookup once for the batch instead of once per
|
|
323
|
+
// For a whole cohort of users, prefer resolveSubscribedMonitorIdsByUser
|
|
324
|
+
// below — it does the Sites lookup once for the batch instead of once per
|
|
325
|
+
// user.
|
|
275
326
|
/**
|
|
276
327
|
* @param {Array<{ deviceId: import("mongoose").Types.ObjectId,
|
|
277
328
|
* deviceType: import("../constants/deviceTypes.js").DeviceType }>} [subscribedDevices]
|
|
@@ -281,7 +332,7 @@ const validateResolvedSiteMonitorMap = (resolved) => {
|
|
|
281
332
|
*/
|
|
282
333
|
export const projectSubscribedMonitors = async (
|
|
283
334
|
subscribedDevices,
|
|
284
|
-
{ resolveSiteMonitorIds } = {}
|
|
335
|
+
{ resolveSiteMonitorIds } = {},
|
|
285
336
|
) => {
|
|
286
337
|
const entries = (subscribedDevices ?? []).filter(isPlainObject);
|
|
287
338
|
const direct = directMonitorIds(entries);
|
|
@@ -293,14 +344,14 @@ export const projectSubscribedMonitors = async (
|
|
|
293
344
|
|
|
294
345
|
if (typeof resolveSiteMonitorIds !== "function") {
|
|
295
346
|
const error = new TypeError(
|
|
296
|
-
"projectSubscribedMonitors requires resolveSiteMonitorIds when a linked-device is subscribed"
|
|
347
|
+
"projectSubscribedMonitors requires resolveSiteMonitorIds when a linked-device is subscribed",
|
|
297
348
|
);
|
|
298
349
|
error.code = SUBSCRIBED_DEVICES_ERRORS.MISSING_SITE_RESOLVER;
|
|
299
350
|
throw error;
|
|
300
351
|
}
|
|
301
352
|
|
|
302
353
|
const siteMonitorIds = validateResolvedSiteMonitorIds(
|
|
303
|
-
await resolveSiteMonitorIds(siteIds)
|
|
354
|
+
await resolveSiteMonitorIds(siteIds),
|
|
304
355
|
);
|
|
305
356
|
return dedupeById([...direct, ...siteMonitorIds]);
|
|
306
357
|
};
|
|
@@ -353,7 +404,12 @@ export const projectSubscribedMonitors = async (
|
|
|
353
404
|
// whole module exists to avoid.
|
|
354
405
|
//
|
|
355
406
|
// Consumers can shrink the cohort by pushing the AQI-capability predicate into
|
|
356
|
-
// the Users query, so rows that can never alert are not loaded at all
|
|
407
|
+
// the Users query, so rows that can never alert are not loaded at all.
|
|
408
|
+
// AQI_CAPABLE_DEVICE_TYPES is the query form of isAqiCapableDeviceType and is
|
|
409
|
+
// exported from constants/deviceTypes.js (and the root barrel) for exactly
|
|
410
|
+
// this:
|
|
411
|
+
//
|
|
412
|
+
// import { AQI_CAPABLE_DEVICE_TYPES } from "@justair/justair-library";
|
|
357
413
|
//
|
|
358
414
|
// { "subscribedDevices.deviceType": { $in: AQI_CAPABLE_DEVICE_TYPES } }
|
|
359
415
|
//
|
|
@@ -369,18 +425,26 @@ export const projectSubscribedMonitors = async (
|
|
|
369
425
|
* Promise<Map<string, import("mongoose").Types.ObjectId[]>> }} [options]
|
|
370
426
|
* @returns {Promise<Map<string, import("mongoose").Types.ObjectId[]>>}
|
|
371
427
|
*/
|
|
372
|
-
export const
|
|
428
|
+
export const resolveSubscribedMonitorIdsByUser = async (
|
|
373
429
|
users,
|
|
374
|
-
{ resolveVisibleSiteMonitors } = {}
|
|
430
|
+
{ resolveVisibleSiteMonitors } = {},
|
|
375
431
|
) => {
|
|
376
|
-
const cohort =
|
|
432
|
+
const cohort = users ?? [];
|
|
377
433
|
|
|
378
|
-
|
|
379
|
-
|
|
434
|
+
// Every entry must be a user object carrying an _id, and anything else
|
|
435
|
+
// throws rather than being filtered out. A `null` slot used to be dropped by
|
|
436
|
+
// an isPlainObject filter and then never appear in the returned Map, which
|
|
437
|
+
// is the same silent removal from an alert audience that the missing-_id
|
|
438
|
+
// check below already rejects — a caller cannot tell "that user follows
|
|
439
|
+
// nothing" apart from "that user was quietly discarded". One rule for both:
|
|
440
|
+
// if the cohort contains something that is not a resolvable user, say so.
|
|
441
|
+
const invalid = cohort.some(
|
|
442
|
+
(user) =>
|
|
443
|
+
!isPlainObject(user) || user._id === null || user._id === undefined,
|
|
380
444
|
);
|
|
381
|
-
if (
|
|
445
|
+
if (invalid) {
|
|
382
446
|
const error = new TypeError(
|
|
383
|
-
"
|
|
447
|
+
"resolveSubscribedMonitorIdsByUser requires every cohort entry to be a user object carrying an _id",
|
|
384
448
|
);
|
|
385
449
|
error.code = SUBSCRIBED_DEVICES_ERRORS.COHORT_USER_MISSING_ID;
|
|
386
450
|
throw error;
|
|
@@ -390,21 +454,21 @@ export const resolveMonitorIdsByUser = async (
|
|
|
390
454
|
// of the batch form. Two users following the same Site cost one entry.
|
|
391
455
|
const siteIds = dedupeById(
|
|
392
456
|
cohort.flatMap((user) =>
|
|
393
|
-
linkedSiteIds((user.subscribedDevices ?? []).filter(isPlainObject))
|
|
394
|
-
)
|
|
457
|
+
linkedSiteIds((user.subscribedDevices ?? []).filter(isPlainObject)),
|
|
458
|
+
),
|
|
395
459
|
);
|
|
396
460
|
|
|
397
461
|
let monitorIdsBySite = new Map();
|
|
398
462
|
if (siteIds.length > 0) {
|
|
399
463
|
if (typeof resolveVisibleSiteMonitors !== "function") {
|
|
400
464
|
const error = new TypeError(
|
|
401
|
-
"
|
|
465
|
+
"resolveSubscribedMonitorIdsByUser requires resolveVisibleSiteMonitors when anyone in the cohort follows a linked-device",
|
|
402
466
|
);
|
|
403
467
|
error.code = SUBSCRIBED_DEVICES_ERRORS.MISSING_COHORT_SITE_RESOLVER;
|
|
404
468
|
throw error;
|
|
405
469
|
}
|
|
406
470
|
monitorIdsBySite = validateResolvedSiteMonitorMap(
|
|
407
|
-
await resolveVisibleSiteMonitors(siteIds)
|
|
471
|
+
await resolveVisibleSiteMonitors(siteIds),
|
|
408
472
|
);
|
|
409
473
|
}
|
|
410
474
|
|
|
@@ -419,11 +483,11 @@ export const resolveMonitorIdsByUser = async (
|
|
|
419
483
|
cohort.map((user) =>
|
|
420
484
|
projectSubscribedMonitors(user.subscribedDevices, {
|
|
421
485
|
resolveSiteMonitorIds,
|
|
422
|
-
})
|
|
423
|
-
)
|
|
486
|
+
}),
|
|
487
|
+
),
|
|
424
488
|
);
|
|
425
489
|
|
|
426
490
|
return new Map(
|
|
427
|
-
cohort.map((user, index) => [String(user._id), resolved[index]])
|
|
491
|
+
cohort.map((user, index) => [String(user._id), resolved[index]]),
|
|
428
492
|
);
|
|
429
493
|
};
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from '../../constants/deviceTypes.js';
|
|
7
7
|
import {
|
|
8
8
|
projectSubscribedMonitors,
|
|
9
|
-
|
|
9
|
+
resolveSubscribedMonitorIdsByUser,
|
|
10
10
|
VISIBLE_SITE_MONITOR_FILTER,
|
|
11
11
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
12
12
|
} from '../subscribedDevices.js';
|
|
@@ -417,7 +417,7 @@ describe('isAqiCapableDeviceType agrees with the projection', () => {
|
|
|
417
417
|
});
|
|
418
418
|
|
|
419
419
|
// ---------------------------------------------------------------------------
|
|
420
|
-
//
|
|
420
|
+
// resolveSubscribedMonitorIdsByUser — the cohort form
|
|
421
421
|
// ---------------------------------------------------------------------------
|
|
422
422
|
// The batch form exists for exactly two reasons, and both are asserted here
|
|
423
423
|
// rather than just described: it must issue ONE Sites lookup for a whole
|
|
@@ -439,11 +439,11 @@ const recordingSiteMap = (byId) => {
|
|
|
439
439
|
|
|
440
440
|
const user = (subscribedDevices) => ({ _id: oid(), subscribedDevices });
|
|
441
441
|
|
|
442
|
-
describe('
|
|
442
|
+
describe('resolveSubscribedMonitorIdsByUser', () => {
|
|
443
443
|
test('keys the result by String(user._id)', async () => {
|
|
444
444
|
const u = user([]);
|
|
445
445
|
|
|
446
|
-
const result = await
|
|
446
|
+
const result = await resolveSubscribedMonitorIdsByUser([u]);
|
|
447
447
|
|
|
448
448
|
expect([...result.keys()]).toEqual([String(u._id)]);
|
|
449
449
|
});
|
|
@@ -457,7 +457,7 @@ describe('resolveMonitorIdsByUser', () => {
|
|
|
457
457
|
user([{ deviceId: m2, deviceType: 'monitor' }]),
|
|
458
458
|
];
|
|
459
459
|
|
|
460
|
-
const result = await
|
|
460
|
+
const result = await resolveSubscribedMonitorIdsByUser([u1, u2], {
|
|
461
461
|
resolveVisibleSiteMonitors: resolve,
|
|
462
462
|
});
|
|
463
463
|
|
|
@@ -475,7 +475,7 @@ describe('resolveMonitorIdsByUser', () => {
|
|
|
475
475
|
[siteB]: [oid()],
|
|
476
476
|
});
|
|
477
477
|
|
|
478
|
-
await
|
|
478
|
+
await resolveSubscribedMonitorIdsByUser(
|
|
479
479
|
[
|
|
480
480
|
user([{ deviceId: siteA, deviceType: 'linked-device' }]),
|
|
481
481
|
user([{ deviceId: siteA, deviceType: 'linked-device' }]),
|
|
@@ -499,7 +499,7 @@ describe('resolveMonitorIdsByUser', () => {
|
|
|
499
499
|
user([{ deviceId: siteB, deviceType: 'linked-device' }]),
|
|
500
500
|
];
|
|
501
501
|
|
|
502
|
-
const result = await
|
|
502
|
+
const result = await resolveSubscribedMonitorIdsByUser([ua, ub], {
|
|
503
503
|
resolveVisibleSiteMonitors: resolve,
|
|
504
504
|
});
|
|
505
505
|
|
|
@@ -516,7 +516,7 @@ describe('resolveMonitorIdsByUser', () => {
|
|
|
516
516
|
{ deviceId: siteId, deviceType: 'linked-device' },
|
|
517
517
|
]);
|
|
518
518
|
|
|
519
|
-
const result = await
|
|
519
|
+
const result = await resolveSubscribedMonitorIdsByUser([u], {
|
|
520
520
|
resolveVisibleSiteMonitors: resolve,
|
|
521
521
|
});
|
|
522
522
|
|
|
@@ -526,7 +526,7 @@ describe('resolveMonitorIdsByUser', () => {
|
|
|
526
526
|
test('a sample-site follower resolves to no monitors', async () => {
|
|
527
527
|
const u = user([{ deviceId: oid(), deviceType: 'sample-site' }]);
|
|
528
528
|
|
|
529
|
-
const result = await
|
|
529
|
+
const result = await resolveSubscribedMonitorIdsByUser([u]);
|
|
530
530
|
|
|
531
531
|
expect(result.get(String(u._id))).toEqual([]);
|
|
532
532
|
});
|
|
@@ -538,7 +538,7 @@ describe('resolveMonitorIdsByUser', () => {
|
|
|
538
538
|
const { resolve } = recordingSiteMap({});
|
|
539
539
|
const u = user([{ deviceId: deletedSite, deviceType: 'linked-device' }]);
|
|
540
540
|
|
|
541
|
-
const result = await
|
|
541
|
+
const result = await resolveSubscribedMonitorIdsByUser([u], {
|
|
542
542
|
resolveVisibleSiteMonitors: resolve,
|
|
543
543
|
});
|
|
544
544
|
|
|
@@ -553,7 +553,7 @@ describe('resolveMonitorIdsByUser', () => {
|
|
|
553
553
|
const monitorId = oid();
|
|
554
554
|
const u = user([{ deviceId: siteId, deviceType: 'linked-device' }]);
|
|
555
555
|
|
|
556
|
-
const result = await
|
|
556
|
+
const result = await resolveSubscribedMonitorIdsByUser([u], {
|
|
557
557
|
resolveVisibleSiteMonitors: async () => new Map([[siteId, [monitorId]]]),
|
|
558
558
|
});
|
|
559
559
|
|
|
@@ -563,21 +563,21 @@ describe('resolveMonitorIdsByUser', () => {
|
|
|
563
563
|
test('tolerates a user with no subscribedDevices key at all', async () => {
|
|
564
564
|
const u = { _id: oid() };
|
|
565
565
|
|
|
566
|
-
const result = await
|
|
566
|
+
const result = await resolveSubscribedMonitorIdsByUser([u]);
|
|
567
567
|
|
|
568
568
|
expect(result.get(String(u._id))).toEqual([]);
|
|
569
569
|
});
|
|
570
570
|
|
|
571
571
|
test('an empty or nullish cohort resolves to an empty Map', async () => {
|
|
572
|
-
expect(await
|
|
573
|
-
expect(await
|
|
572
|
+
expect(await resolveSubscribedMonitorIdsByUser([])).toEqual(new Map());
|
|
573
|
+
expect(await resolveSubscribedMonitorIdsByUser(undefined)).toEqual(new Map());
|
|
574
574
|
});
|
|
575
575
|
});
|
|
576
576
|
|
|
577
|
-
describe('
|
|
577
|
+
describe('resolveSubscribedMonitorIdsByUser — rejects what would otherwise fail silently', () => {
|
|
578
578
|
test('throws MISSING_COHORT_SITE_RESOLVER when anyone follows a linked-device', async () => {
|
|
579
579
|
await expect(
|
|
580
|
-
|
|
580
|
+
resolveSubscribedMonitorIdsByUser([
|
|
581
581
|
user([{ deviceId: oid(), deviceType: 'linked-device' }]),
|
|
582
582
|
]),
|
|
583
583
|
).rejects.toMatchObject({
|
|
@@ -589,7 +589,7 @@ describe('resolveMonitorIdsByUser — rejects what would otherwise fail silently
|
|
|
589
589
|
const monitorId = oid();
|
|
590
590
|
const u = user([{ deviceId: monitorId, deviceType: 'monitor' }]);
|
|
591
591
|
|
|
592
|
-
const result = await
|
|
592
|
+
const result = await resolveSubscribedMonitorIdsByUser([u]);
|
|
593
593
|
|
|
594
594
|
expect(result.get(String(u._id))).toEqual([monitorId]);
|
|
595
595
|
});
|
|
@@ -599,7 +599,7 @@ describe('resolveMonitorIdsByUser — rejects what would otherwise fail silently
|
|
|
599
599
|
const siteId = oid();
|
|
600
600
|
|
|
601
601
|
await expect(
|
|
602
|
-
|
|
602
|
+
resolveSubscribedMonitorIdsByUser(
|
|
603
603
|
[user([{ deviceId: siteId, deviceType: 'linked-device' }])],
|
|
604
604
|
{ resolveVisibleSiteMonitors: async () => [[String(siteId), [oid()]]] },
|
|
605
605
|
),
|
|
@@ -611,7 +611,7 @@ describe('resolveMonitorIdsByUser — rejects what would otherwise fail silently
|
|
|
611
611
|
test('a nullish resolver return is an empty audience, not a throw', async () => {
|
|
612
612
|
const u = user([{ deviceId: oid(), deviceType: 'linked-device' }]);
|
|
613
613
|
|
|
614
|
-
const result = await
|
|
614
|
+
const result = await resolveSubscribedMonitorIdsByUser([u], {
|
|
615
615
|
resolveVisibleSiteMonitors: async () => null,
|
|
616
616
|
});
|
|
617
617
|
|
|
@@ -625,7 +625,7 @@ describe('resolveMonitorIdsByUser — rejects what would otherwise fail silently
|
|
|
625
625
|
const siteId = oid();
|
|
626
626
|
|
|
627
627
|
await expect(
|
|
628
|
-
|
|
628
|
+
resolveSubscribedMonitorIdsByUser(
|
|
629
629
|
[user([{ deviceId: siteId, deviceType: 'linked-device' }])],
|
|
630
630
|
{
|
|
631
631
|
resolveVisibleSiteMonitors: async () =>
|
|
@@ -641,7 +641,23 @@ describe('resolveMonitorIdsByUser — rejects what would otherwise fail silently
|
|
|
641
641
|
// Skipping would remove a real person from an alert audience with no error
|
|
642
642
|
// anywhere — the exact failure mode this module exists to prevent.
|
|
643
643
|
await expect(
|
|
644
|
-
|
|
644
|
+
resolveSubscribedMonitorIdsByUser([{ subscribedDevices: [] }]),
|
|
645
|
+
).rejects.toMatchObject({
|
|
646
|
+
code: SUBSCRIBED_DEVICES_ERRORS.COHORT_USER_MISSING_ID,
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test.each([
|
|
651
|
+
['a null slot', null],
|
|
652
|
+
['an undefined slot', undefined],
|
|
653
|
+
['a bare id string', 'deadbeefdeadbeefdeadbeef'],
|
|
654
|
+
['an array', []],
|
|
655
|
+
])('throws COHORT_USER_MISSING_ID for %s in the cohort', async (_label, entry) => {
|
|
656
|
+
// A non-object entry used to be filtered out and then simply never appear
|
|
657
|
+
// in the returned Map — the same silent removal the missing-_id case above
|
|
658
|
+
// rejects, and indistinguishable to the caller from "follows nothing".
|
|
659
|
+
await expect(
|
|
660
|
+
resolveSubscribedMonitorIdsByUser([user([]), entry]),
|
|
645
661
|
).rejects.toMatchObject({
|
|
646
662
|
code: SUBSCRIBED_DEVICES_ERRORS.COHORT_USER_MISSING_ID,
|
|
647
663
|
});
|
|
@@ -649,17 +665,33 @@ describe('resolveMonitorIdsByUser — rejects what would otherwise fail silently
|
|
|
649
665
|
});
|
|
650
666
|
|
|
651
667
|
describe('VISIBLE_SITE_MONITOR_FILTER', () => {
|
|
652
|
-
test('
|
|
653
|
-
// $ne matches both
|
|
654
|
-
//
|
|
655
|
-
//
|
|
668
|
+
test('both clauses use $ne, so a document missing the key survives', () => {
|
|
669
|
+
// $ne matches both the negated value and a MISSING key. isPrivate has no
|
|
670
|
+
// key on documents written before it existed; isActive is `default: true`
|
|
671
|
+
// and NOT required (models/monitors.js), so it has the same gap. An exact
|
|
672
|
+
// match on either would drop those monitors out of every Site's audience
|
|
673
|
+
// with no error anywhere.
|
|
656
674
|
expect(VISIBLE_SITE_MONITOR_FILTER).toEqual({
|
|
657
675
|
isPrivate: { $ne: true },
|
|
658
|
-
isActive:
|
|
676
|
+
isActive: { $ne: false },
|
|
659
677
|
});
|
|
660
678
|
});
|
|
661
679
|
|
|
662
|
-
test('is frozen — one consumer
|
|
680
|
+
test('is frozen all the way down — one consumer must not change it for the rest', () => {
|
|
681
|
+
// Object.freeze is shallow, so the top-level assertion alone would pass
|
|
682
|
+
// while `FILTER.isPrivate.$ne = false` still silently flipped the privacy
|
|
683
|
+
// predicate for every consumer that spreads this instance.
|
|
663
684
|
expect(Object.isFrozen(VISIBLE_SITE_MONITOR_FILTER)).toBe(true);
|
|
685
|
+
expect(Object.isFrozen(VISIBLE_SITE_MONITOR_FILTER.isPrivate)).toBe(true);
|
|
686
|
+
expect(Object.isFrozen(VISIBLE_SITE_MONITOR_FILTER.isActive)).toBe(true);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test('a nested write is rejected rather than shared with every consumer', () => {
|
|
690
|
+
expect(() => {
|
|
691
|
+
'use strict';
|
|
692
|
+
VISIBLE_SITE_MONITOR_FILTER.isPrivate.$ne = false;
|
|
693
|
+
}).toThrow(TypeError);
|
|
694
|
+
|
|
695
|
+
expect(VISIBLE_SITE_MONITOR_FILTER.isPrivate.$ne).toBe(true);
|
|
664
696
|
});
|
|
665
697
|
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// deliberate contract change, not a test fix.
|
|
10
10
|
import {
|
|
11
11
|
projectSubscribedMonitors,
|
|
12
|
-
|
|
12
|
+
resolveSubscribedMonitorIdsByUser,
|
|
13
13
|
VISIBLE_SITE_MONITOR_FILTER,
|
|
14
14
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
15
15
|
} from '../subscribedDevices.js';
|
|
@@ -17,13 +17,14 @@ import {
|
|
|
17
17
|
DEVICE_TYPES,
|
|
18
18
|
DEVICE_TYPE_VALUES,
|
|
19
19
|
isAqiCapableDeviceType,
|
|
20
|
+
AQI_CAPABLE_DEVICE_TYPES,
|
|
20
21
|
} from '../../constants/deviceTypes.js';
|
|
21
22
|
|
|
22
23
|
test('barrel re-exports the subscribedDevices surface unchanged (identity, not copies)', async () => {
|
|
23
24
|
const barrel = await import('../../index.js');
|
|
24
25
|
|
|
25
26
|
expect(barrel.projectSubscribedMonitors).toBe(projectSubscribedMonitors);
|
|
26
|
-
expect(barrel.
|
|
27
|
+
expect(barrel.resolveSubscribedMonitorIdsByUser).toBe(resolveSubscribedMonitorIdsByUser);
|
|
27
28
|
expect(barrel.VISIBLE_SITE_MONITOR_FILTER).toBe(VISIBLE_SITE_MONITOR_FILTER);
|
|
28
29
|
expect(barrel.SUBSCRIBED_DEVICES_ERRORS).toBe(SUBSCRIBED_DEVICES_ERRORS);
|
|
29
30
|
});
|
|
@@ -39,4 +40,5 @@ test('barrel re-exports the device-type vocabulary unchanged (identity, not copi
|
|
|
39
40
|
expect(barrel.DEVICE_TYPES).toBe(DEVICE_TYPES);
|
|
40
41
|
expect(barrel.DEVICE_TYPE_VALUES).toBe(DEVICE_TYPE_VALUES);
|
|
41
42
|
expect(barrel.isAqiCapableDeviceType).toBe(isAqiCapableDeviceType);
|
|
43
|
+
expect(barrel.AQI_CAPABLE_DEVICE_TYPES).toBe(AQI_CAPABLE_DEVICE_TYPES);
|
|
42
44
|
});
|