@justair/justair-library 7.6.0-alpha.2ec581a → 7.6.0-alpha.4ff5aae
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 +0 -1
- package/dist/constants/deviceTypes.d.ts.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/models/users.d.ts.map +1 -1
- package/dist/utils/subscribedDevices.d.ts +4 -4
- package/dist/utils/subscribedDevices.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants/deviceTypes.js +0 -19
- package/src/constants/tests/deviceTypes.test.js +0 -22
- package/src/index.js +8 -26
- package/src/models/users.js +15 -29
- package/src/utils/subscribedDevices.js +39 -112
- package/src/utils/tests/subscribedDevices.test.js +26 -60
- package/src/utils/tests/subscribedDevicesBarrel.test.js +2 -4
|
@@ -5,7 +5,6 @@ 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")[];
|
|
9
8
|
/**
|
|
10
9
|
* The device-type vocabulary as a type, so consumers get autocomplete and a
|
|
11
10
|
* 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;;;;;;yBAZ9B,CAAA,OAAO,YAAY,EAAC,MAAM,OAAO,YAAY,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -106,9 +106,8 @@ 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";
|
|
110
109
|
import { projectSubscribedMonitors } from "./utils/subscribedDevices.js";
|
|
111
|
-
import {
|
|
110
|
+
import { resolveMonitorIdsByUser } from "./utils/subscribedDevices.js";
|
|
112
111
|
import { VISIBLE_SITE_MONITOR_FILTER } from "./utils/subscribedDevices.js";
|
|
113
112
|
import { SUBSCRIBED_DEVICES_ERRORS } from "./utils/subscribedDevices.js";
|
|
114
113
|
import { splitName } from "./utils/splitName.js";
|
|
@@ -121,5 +120,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
|
|
|
121
120
|
import { getZoneMinutes } from "./utils/quietHours.js";
|
|
122
121
|
import { isInQuietWindow } from "./utils/quietHours.js";
|
|
123
122
|
import { resolveQuietHours } from "./utils/quietHours.js";
|
|
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,
|
|
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, resolveMonitorIdsByUser, VISIBLE_SITE_MONITOR_FILTER, SUBSCRIBED_DEVICES_ERRORS, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
|
|
125
124
|
//# 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":"AA+JA;;;iBAEC;yBA/FwB,oBAAoB;qBADxB,gBAAgB;4BAjEF,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;qCACgB,4BAA4B;+BAA5B,4BAA4B;mCAChC,0BAA0B;6BAA1B,0BAA0B;8CAIpE,qCAAqC;wCAArC,qCAAqC;4BA8CT,mBAAmB;sBAAnB,mBAAmB;kCAS/C,yBAAyB;4BAAzB,yBAAyB;sCAAzB,yBAAyB;gCAAzB,yBAAyB;qCAAzB,yBAAyB;8BASzB,qBAAqB;wBAArB,qBAAqB;mCAArB,qBAAqB;6BAArB,qBAAqB;uDAArB,qBAAqB;yCACiC,gCAAgC;mCAAhC,gCAAgC;sCAlDtF,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;oCApBF,8BAA8B;mCAiE3D,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAsB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;yEAAnC,mCAAmC;2EAAnC,mCAAmC;2BA1FD,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;6BAOzE,4BAA4B;mCAA5B,4BAA4B;uCAA5B,4BAA4B;0CAgC5B,8BAA8B;wCAA9B,8BAA8B;4CAA9B,8BAA8B;0CAA9B,8BAA8B;0BAhBX,sBAAsB;oCASzC,uBAAuB;iCA/BG,0BAA0B;mCA+BpD,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":"AAyFA,8BAgGE;AAiCF,wBAAmD;AAjNnD,iDAA4D;AAC5D,6CAAmD;AAUnD,4DAsBC"}
|
|
@@ -8,9 +8,9 @@ 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
|
-
}
|
|
11
|
+
isPrivate: {
|
|
12
|
+
$ne: boolean;
|
|
13
|
+
};
|
|
14
14
|
isActive: true;
|
|
15
15
|
}>;
|
|
16
16
|
export function projectSubscribedMonitors(subscribedDevices?: Array<{
|
|
@@ -19,7 +19,7 @@ export function projectSubscribedMonitors(subscribedDevices?: Array<{
|
|
|
19
19
|
}>, { resolveSiteMonitorIds }?: {
|
|
20
20
|
resolveSiteMonitorIds?: (siteIds: import("mongoose").Types.ObjectId[]) => Promise<Array<import("mongoose").Types.ObjectId | null | undefined>>;
|
|
21
21
|
}): Promise<import("mongoose").Types.ObjectId[]>;
|
|
22
|
-
export function
|
|
22
|
+
export function resolveMonitorIdsByUser(users?: Array<{
|
|
23
23
|
_id: any;
|
|
24
24
|
subscribedDevices?: Array<{
|
|
25
25
|
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":"AAmEA;;;;;;;;GASG;AAyBH;;;;;GAGG;AAiLI,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;AAkEM,gDAPI,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,CA2DrE"}
|
package/package.json
CHANGED
|
@@ -50,22 +50,3 @@ 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,14 +8,12 @@ import {
|
|
|
8
8
|
DEVICE_TYPES,
|
|
9
9
|
DEVICE_TYPE_VALUES,
|
|
10
10
|
isAqiCapableDeviceType,
|
|
11
|
-
AQI_CAPABLE_DEVICE_TYPES,
|
|
12
11
|
} from '../deviceTypes.js';
|
|
13
12
|
|
|
14
13
|
test('barrel exports the device-type constants', () => {
|
|
15
14
|
expect(barrel.DEVICE_TYPES).toBe(DEVICE_TYPES);
|
|
16
15
|
expect(barrel.DEVICE_TYPE_VALUES).toBe(DEVICE_TYPE_VALUES);
|
|
17
16
|
expect(barrel.isAqiCapableDeviceType).toBe(isAqiCapableDeviceType);
|
|
18
|
-
expect(barrel.AQI_CAPABLE_DEVICE_TYPES).toBe(AQI_CAPABLE_DEVICE_TYPES);
|
|
19
17
|
});
|
|
20
18
|
|
|
21
19
|
test('DEVICE_TYPES values match the strings the devices API emits', () => {
|
|
@@ -53,23 +51,3 @@ describe('isAqiCapableDeviceType', () => {
|
|
|
53
51
|
expect(isAqiCapableDeviceType('')).toBe(false);
|
|
54
52
|
});
|
|
55
53
|
});
|
|
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,10 +57,7 @@ import {
|
|
|
57
57
|
DataCompleteness,
|
|
58
58
|
dataCompletenessSchema,
|
|
59
59
|
} from "./models/dataCompleteness.js";
|
|
60
|
-
import {
|
|
61
|
-
NetworkMetrics,
|
|
62
|
-
networkMetricsSchema,
|
|
63
|
-
} from "./models/networkMetrics.js";
|
|
60
|
+
import { NetworkMetrics, networkMetricsSchema } from "./models/networkMetrics.js";
|
|
64
61
|
import { RateOfChange, rateOfChangeSchema } from "./models/rateOfChange.js";
|
|
65
62
|
import {
|
|
66
63
|
ChangeStreamCheckpoints,
|
|
@@ -69,17 +66,13 @@ import {
|
|
|
69
66
|
import Database from "./config/db.js"; // Import the new Database class
|
|
70
67
|
import CustomLogger from "./config/logger.js";
|
|
71
68
|
import { PARAMETERS, HEAVY_METALS } from "./constants/pollutants.js";
|
|
72
|
-
import {
|
|
73
|
-
ALERT_LEVEL_WEIGHTS,
|
|
74
|
-
getAlertWeight,
|
|
75
|
-
} from "./constants/alertLevels.js";
|
|
69
|
+
import { ALERT_LEVEL_WEIGHTS, getAlertWeight } from "./constants/alertLevels.js";
|
|
76
70
|
import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
|
|
77
71
|
import { DATA_DOWNLOAD_TYPES } from "./constants/dataDownloads.js";
|
|
78
72
|
import {
|
|
79
73
|
DEVICE_TYPES,
|
|
80
74
|
DEVICE_TYPE_VALUES,
|
|
81
75
|
isAqiCapableDeviceType,
|
|
82
|
-
AQI_CAPABLE_DEVICE_TYPES,
|
|
83
76
|
} from "./constants/deviceTypes.js";
|
|
84
77
|
import {
|
|
85
78
|
monitorEpisodesSchema,
|
|
@@ -109,7 +102,7 @@ import {
|
|
|
109
102
|
import { isValidIANATimezone } from "./utils/timezone.js";
|
|
110
103
|
import {
|
|
111
104
|
projectSubscribedMonitors,
|
|
112
|
-
|
|
105
|
+
resolveMonitorIdsByUser,
|
|
113
106
|
VISIBLE_SITE_MONITOR_FILTER,
|
|
114
107
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
115
108
|
} from "./utils/subscribedDevices.js";
|
|
@@ -134,10 +127,7 @@ import {
|
|
|
134
127
|
SamplesAudit,
|
|
135
128
|
sampleParameterReferenceConcentrations,
|
|
136
129
|
} from "./models/samples.js";
|
|
137
|
-
import {
|
|
138
|
-
passwordResetTokenSchema,
|
|
139
|
-
PasswordResetToken,
|
|
140
|
-
} from "./models/passwordResetToken.js";
|
|
130
|
+
import { passwordResetTokenSchema, PasswordResetToken } from "./models/passwordResetToken.js";
|
|
141
131
|
import {
|
|
142
132
|
dataDownloadSchema,
|
|
143
133
|
DataDownloads,
|
|
@@ -284,22 +274,14 @@ export {
|
|
|
284
274
|
DEVICE_TYPES,
|
|
285
275
|
DEVICE_TYPE_VALUES,
|
|
286
276
|
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,
|
|
291
277
|
// subscribedDevices → monitor ids, resolved at READ time (JA-3110 / JA-3335).
|
|
292
278
|
// Users.subscribedDevices is the source of truth; Users.subscribedMonitors is
|
|
293
279
|
// being retired rather than kept in step with it. Nothing should store this
|
|
294
280
|
// result — see the module header for the five bugs a stored copy caused.
|
|
295
281
|
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
// resolveSubscribedMonitorIdsByUser — the batch form. One Sites lookup for
|
|
301
|
-
// a whole cohort. What alert runs, org pages and
|
|
302
|
-
// session responses should call.
|
|
282
|
+
// resolveMonitorIdsByUser — the batch form. One Sites lookup for a whole
|
|
283
|
+
// cohort. What alert runs, org pages and session
|
|
284
|
+
// responses should call.
|
|
303
285
|
// projectSubscribedMonitors — the single-user rule the batch form is built
|
|
304
286
|
// on. Named after the field being retired; it
|
|
305
287
|
// gets renamed when that field is dropped.
|
|
@@ -310,7 +292,7 @@ export {
|
|
|
310
292
|
// SUBSCRIBED_DEVICES_ERRORS — stable `err.code` vocabulary; consumers
|
|
311
293
|
// branch on it, never on message wording.
|
|
312
294
|
projectSubscribedMonitors,
|
|
313
|
-
|
|
295
|
+
resolveMonitorIdsByUser,
|
|
314
296
|
VISIBLE_SITE_MONITOR_FILTER,
|
|
315
297
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
316
298
|
// Utilities
|
package/src/models/users.js
CHANGED
|
@@ -15,15 +15,11 @@ 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 (
|
|
23
|
-
typeof consent !== "object" ||
|
|
24
|
-
consent === null ||
|
|
25
|
-
Array.isArray(consent)
|
|
26
|
-
) {
|
|
22
|
+
if (typeof consent !== "object" || consent === null || Array.isArray(consent)) {
|
|
27
23
|
return "cookie_consent must be an object";
|
|
28
24
|
}
|
|
29
25
|
|
|
@@ -38,7 +34,7 @@ const validateCookieConsent = (consent) => {
|
|
|
38
34
|
}
|
|
39
35
|
if (!COOKIE_CONSENT_VALUES.includes(value)) {
|
|
40
36
|
return `Invalid value for ${category}: must be one of ${COOKIE_CONSENT_VALUES.join(
|
|
41
|
-
", "
|
|
37
|
+
", "
|
|
42
38
|
)}`;
|
|
43
39
|
}
|
|
44
40
|
}
|
|
@@ -88,7 +84,7 @@ const subscribedDeviceSchema = mongoose.Schema(
|
|
|
88
84
|
// writing (and/or pass `{ runValidators: true }` to the update).
|
|
89
85
|
deviceType: { type: String, enum: DEVICE_TYPE_VALUES, required: true },
|
|
90
86
|
},
|
|
91
|
-
{ _id: false }
|
|
87
|
+
{ _id: false }
|
|
92
88
|
);
|
|
93
89
|
|
|
94
90
|
const usersSchema = mongoose.Schema(
|
|
@@ -103,31 +99,21 @@ const usersSchema = mongoose.Schema(
|
|
|
103
99
|
organizations: [{ type: mongoose.Types.ObjectId, ref: "Organizations" }],
|
|
104
100
|
alertPhone: String,
|
|
105
101
|
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.
|
|
117
102
|
subscribedMonitors: [{ type: mongoose.Types.ObjectId, ref: "Monitors" }],
|
|
118
|
-
// Subscribed devices (JA-3110 / JA-3335) — the
|
|
103
|
+
// Subscribed devices (JA-3110 / JA-3335) — the source of truth for what the
|
|
119
104
|
// user has added to "My Monitors", across all three device types.
|
|
120
105
|
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// `projectSubscribedMonitors`
|
|
124
|
-
//
|
|
125
|
-
// a Site lookup, which the schema has no business
|
|
106
|
+
// subscribedMonitors above is its derived AQI-alert projection (monitor ids
|
|
107
|
+
// only). The two must be written together on every subscribe and
|
|
108
|
+
// unsubscribe path, using `projectSubscribedMonitors` rather than a
|
|
109
|
+
// per-consumer reimplementation. It is deliberately not a schema validator:
|
|
110
|
+
// the projection needs a Site lookup, which the schema has no business
|
|
111
|
+
// doing.
|
|
126
112
|
//
|
|
127
113
|
// Everything else — the per-device-type rule, the backfill PRECONDITION,
|
|
128
|
-
// the
|
|
129
|
-
//
|
|
130
|
-
//
|
|
114
|
+
// the STALENESS gap, and why the field is split at all — is specified once,
|
|
115
|
+
// in the header of src/utils/subscribedDevices.js. Read it there; it is
|
|
116
|
+
// deliberately not summarised here.
|
|
131
117
|
subscribedDevices: [subscribedDeviceSchema],
|
|
132
118
|
currentAlertMode: {
|
|
133
119
|
type: String,
|
|
@@ -196,7 +182,7 @@ const usersSchema = mongoose.Schema(
|
|
|
196
182
|
},
|
|
197
183
|
{
|
|
198
184
|
timestamps: true,
|
|
199
|
-
}
|
|
185
|
+
}
|
|
200
186
|
);
|
|
201
187
|
|
|
202
188
|
// Cross-field quiet-hours rules (both-or-neither, start ≠ end) delegated to
|
|
@@ -39,29 +39,6 @@
|
|
|
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
|
-
// ---------------------------------------------------------------------------
|
|
65
42
|
// PRIVACY IS THE CALLER'S JOB, AND IT IS NOT OPTIONAL
|
|
66
43
|
// ---------------------------------------------------------------------------
|
|
67
44
|
// A Site's `monitorIds` is a raw membership list with no visibility filtering.
|
|
@@ -93,7 +70,7 @@ export const SUBSCRIBED_DEVICES_ERRORS = Object.freeze({
|
|
|
93
70
|
RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array",
|
|
94
71
|
RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays",
|
|
95
72
|
RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents",
|
|
96
|
-
//
|
|
73
|
+
// resolveMonitorIdsByUser only
|
|
97
74
|
MISSING_COHORT_SITE_RESOLVER: "missing_cohort_site_resolver",
|
|
98
75
|
COHORT_RESOLVER_RETURNED_NON_MAP: "cohort_resolver_returned_non_map",
|
|
99
76
|
COHORT_USER_MISSING_ID: "cohort_user_missing_id",
|
|
@@ -116,36 +93,14 @@ export const SUBSCRIBED_DEVICES_ERRORS = Object.freeze({
|
|
|
116
93
|
// "_id",
|
|
117
94
|
// ).lean();
|
|
118
95
|
//
|
|
119
|
-
// `isActive: true` is an EXACT match, and that asymmetry with the `$ne` above
|
|
120
|
-
// is DELIBERATE — do not "fix" it to `{ $ne: false }`. The two clauses answer
|
|
121
|
-
// different questions. `isPrivate` is a privacy gate: a monitor with no
|
|
122
|
-
// `isPrivate` key was never marked private, so excluding it would hide a
|
|
123
|
-
// monitor its followers are entitled to see. `isActive` is an eligibility
|
|
124
|
-
// gate: a monitor only earns a place in an alert audience by positively
|
|
125
|
-
// asserting it is active. A document with no `isActive` key has made no such
|
|
126
|
-
// assertion, and the safe reading of "we don't know" is to leave it out rather
|
|
127
|
-
// than alert on it.
|
|
128
|
-
//
|
|
129
|
-
// So the failure modes are not symmetric and should not be made symmetric.
|
|
130
|
-
// Over-including here means alerting people about a monitor nobody confirmed
|
|
131
|
-
// is live; under-including means one unconfirmed monitor is missing from an
|
|
132
|
-
// audience until someone sets the flag. This predicate takes the second.
|
|
133
|
-
//
|
|
134
96
|
// `monitorState: "Deployed"` is deliberately NOT here. That is an
|
|
135
97
|
// alerting-eligibility rule, not a visibility rule, and it belongs to the
|
|
136
98
|
// notification service alone — the org admin page and the session response
|
|
137
99
|
// must still show a follower the monitors they follow while those monitors sit
|
|
138
100
|
// in a non-Deployed state. Do not consolidate the two filters; keep
|
|
139
101
|
// JustAirNotificationService's own `monitorState` predicate where it is.
|
|
140
|
-
//
|
|
141
|
-
// Frozen at every level, not just the top. `Object.freeze` is shallow, so a
|
|
142
|
-
// top-level-only freeze still lets a consumer write
|
|
143
|
-
// `VISIBLE_SITE_MONITOR_FILTER.isPrivate.$ne = false` — and because every
|
|
144
|
-
// consumer spreads THIS instance, that one write flips the privacy predicate
|
|
145
|
-
// for the whole process. `isPrivate` is the only nested object here; freeze it
|
|
146
|
-
// too, and freeze any operator object a future clause introduces.
|
|
147
102
|
export const VISIBLE_SITE_MONITOR_FILTER = Object.freeze({
|
|
148
|
-
isPrivate:
|
|
103
|
+
isPrivate: { $ne: true },
|
|
149
104
|
isActive: true,
|
|
150
105
|
});
|
|
151
106
|
|
|
@@ -207,7 +162,7 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
207
162
|
|
|
208
163
|
if (!Array.isArray(resolved)) {
|
|
209
164
|
const error = new TypeError(
|
|
210
|
-
"resolveSiteMonitorIds must resolve to an array of monitor ids"
|
|
165
|
+
"resolveSiteMonitorIds must resolve to an array of monitor ids"
|
|
211
166
|
);
|
|
212
167
|
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NON_ARRAY;
|
|
213
168
|
throw error;
|
|
@@ -219,7 +174,7 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
219
174
|
// silently works, two throws a CastError at write time, far from the cause.
|
|
220
175
|
if (resolved.some(Array.isArray)) {
|
|
221
176
|
const error = new TypeError(
|
|
222
|
-
"resolveSiteMonitorIds must resolve to a flat array of monitor ids"
|
|
177
|
+
"resolveSiteMonitorIds must resolve to a flat array of monitor ids"
|
|
223
178
|
);
|
|
224
179
|
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NESTED_ARRAYS;
|
|
225
180
|
throw error;
|
|
@@ -235,11 +190,11 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
235
190
|
// (`Sites.find(..., "monitorIds -_id").lean()`) carries none.
|
|
236
191
|
if (
|
|
237
192
|
resolved.some(
|
|
238
|
-
(id) => isPlainObject(id) && typeof id.toHexString !== "function"
|
|
193
|
+
(id) => isPlainObject(id) && typeof id.toHexString !== "function"
|
|
239
194
|
)
|
|
240
195
|
) {
|
|
241
196
|
const error = new TypeError(
|
|
242
|
-
"resolveSiteMonitorIds must resolve to monitor ids, not Site documents"
|
|
197
|
+
"resolveSiteMonitorIds must resolve to monitor ids, not Site documents"
|
|
243
198
|
);
|
|
244
199
|
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS;
|
|
245
200
|
throw error;
|
|
@@ -265,7 +220,7 @@ const validateResolvedSiteMonitorMap = (resolved) => {
|
|
|
265
220
|
|
|
266
221
|
if (!(resolved instanceof Map)) {
|
|
267
222
|
const error = new TypeError(
|
|
268
|
-
"resolveVisibleSiteMonitors must resolve to a Map of site id to monitor ids"
|
|
223
|
+
"resolveVisibleSiteMonitors must resolve to a Map of site id to monitor ids"
|
|
269
224
|
);
|
|
270
225
|
error.code = SUBSCRIBED_DEVICES_ERRORS.COHORT_RESOLVER_RETURNED_NON_MAP;
|
|
271
226
|
throw error;
|
|
@@ -275,15 +230,15 @@ const validateResolvedSiteMonitorMap = (resolved) => {
|
|
|
275
230
|
[...resolved.entries()].map(([siteId, monitorIds]) => [
|
|
276
231
|
String(siteId),
|
|
277
232
|
validateResolvedSiteMonitorIds(monitorIds),
|
|
278
|
-
])
|
|
233
|
+
])
|
|
279
234
|
);
|
|
280
235
|
};
|
|
281
236
|
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
237
|
+
|
|
238
|
+
// Returns the monitor ids that should be stored in Users.subscribedMonitors for
|
|
239
|
+
// the given subscribedDevices array. Order is deterministic — direct `monitor`
|
|
240
|
+
// subscriptions in array order, then Site-derived ids in resolver order, first
|
|
241
|
+
// occurrence wins — but carries no meaning; treat the result as a set.
|
|
287
242
|
//
|
|
288
243
|
// `subscribedDevices` tolerates undefined/null: a user row written before this
|
|
289
244
|
// field existed has no key at all on a `.lean()` read (pinned in
|
|
@@ -292,21 +247,10 @@ const validateResolvedSiteMonitorMap = (resolved) => {
|
|
|
292
247
|
//
|
|
293
248
|
// `resolveSiteMonitorIds(siteIds)` is called at most once, with every
|
|
294
249
|
// linked-device id at a time, and must resolve to the flat union of those
|
|
295
|
-
// Sites'
|
|
296
|
-
// `Sites.monitorIds` is a raw membership list, so it goes through
|
|
297
|
-
// VISIBLE_SITE_MONITOR_FILTER before it becomes an alert audience; handing
|
|
298
|
-
// back `site.monitorIds` untouched is the leak the privacy note above
|
|
299
|
-
// describes:
|
|
250
|
+
// Sites' monitorIds — the ids themselves, not the Site documents:
|
|
300
251
|
//
|
|
301
252
|
// const sites = await Sites.find({ _id: { $in: siteIds } }, "monitorIds").lean();
|
|
302
|
-
//
|
|
303
|
-
// {
|
|
304
|
-
// _id: { $in: sites.flatMap((site) => site.monitorIds ?? []) },
|
|
305
|
-
// ...VISIBLE_SITE_MONITOR_FILTER,
|
|
306
|
-
// },
|
|
307
|
-
// "_id",
|
|
308
|
-
// ).lean();
|
|
309
|
-
// return monitors.map((monitor) => monitor._id);
|
|
253
|
+
// return sites.flatMap((site) => site.monitorIds ?? []);
|
|
310
254
|
//
|
|
311
255
|
// Returning the documents is the easy mistake, so it is rejected rather than
|
|
312
256
|
// merely documented: the return is validated and a document-shaped entry
|
|
@@ -326,9 +270,8 @@ const validateResolvedSiteMonitorMap = (resolved) => {
|
|
|
326
270
|
// from. Its privacy obligation applies here too: the ids this returns are only
|
|
327
271
|
// as safe to alert on as the resolver that produced them.
|
|
328
272
|
//
|
|
329
|
-
// For a whole cohort of users, prefer
|
|
330
|
-
//
|
|
331
|
-
// user.
|
|
273
|
+
// For a whole cohort of users, prefer resolveMonitorIdsByUser below — it does
|
|
274
|
+
// the Sites lookup once for the batch instead of once per user.
|
|
332
275
|
/**
|
|
333
276
|
* @param {Array<{ deviceId: import("mongoose").Types.ObjectId,
|
|
334
277
|
* deviceType: import("../constants/deviceTypes.js").DeviceType }>} [subscribedDevices]
|
|
@@ -338,7 +281,7 @@ const validateResolvedSiteMonitorMap = (resolved) => {
|
|
|
338
281
|
*/
|
|
339
282
|
export const projectSubscribedMonitors = async (
|
|
340
283
|
subscribedDevices,
|
|
341
|
-
{ resolveSiteMonitorIds } = {}
|
|
284
|
+
{ resolveSiteMonitorIds } = {}
|
|
342
285
|
) => {
|
|
343
286
|
const entries = (subscribedDevices ?? []).filter(isPlainObject);
|
|
344
287
|
const direct = directMonitorIds(entries);
|
|
@@ -350,14 +293,14 @@ export const projectSubscribedMonitors = async (
|
|
|
350
293
|
|
|
351
294
|
if (typeof resolveSiteMonitorIds !== "function") {
|
|
352
295
|
const error = new TypeError(
|
|
353
|
-
"projectSubscribedMonitors requires resolveSiteMonitorIds when a linked-device is subscribed"
|
|
296
|
+
"projectSubscribedMonitors requires resolveSiteMonitorIds when a linked-device is subscribed"
|
|
354
297
|
);
|
|
355
298
|
error.code = SUBSCRIBED_DEVICES_ERRORS.MISSING_SITE_RESOLVER;
|
|
356
299
|
throw error;
|
|
357
300
|
}
|
|
358
301
|
|
|
359
302
|
const siteMonitorIds = validateResolvedSiteMonitorIds(
|
|
360
|
-
await resolveSiteMonitorIds(siteIds)
|
|
303
|
+
await resolveSiteMonitorIds(siteIds)
|
|
361
304
|
);
|
|
362
305
|
return dedupeById([...direct, ...siteMonitorIds]);
|
|
363
306
|
};
|
|
@@ -404,21 +347,13 @@ export const projectSubscribedMonitors = async (
|
|
|
404
347
|
// to nothing, so their alerts stop) is the subscription-identity question
|
|
405
348
|
// tracked in TODOS, and is deliberately not papered over here.
|
|
406
349
|
//
|
|
407
|
-
// Users are keyed by `String(user._id)`, so callers look up with the same.
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
// an alert audience, which is the failure mode this whole module exists to
|
|
412
|
-
// avoid, and a caller cannot tell a dropped entry apart from one that resolved
|
|
413
|
-
// to nothing.
|
|
350
|
+
// Users are keyed by `String(user._id)`, so callers look up with the same. A
|
|
351
|
+
// user with no `_id` throws rather than being skipped: skipping would silently
|
|
352
|
+
// remove a real person from an alert audience, which is the failure mode this
|
|
353
|
+
// whole module exists to avoid.
|
|
414
354
|
//
|
|
415
355
|
// Consumers can shrink the cohort by pushing the AQI-capability predicate into
|
|
416
|
-
// the Users query, so rows that can never alert are not loaded at all
|
|
417
|
-
// AQI_CAPABLE_DEVICE_TYPES is the query form of isAqiCapableDeviceType and is
|
|
418
|
-
// exported from constants/deviceTypes.js (and the root barrel) for exactly
|
|
419
|
-
// this:
|
|
420
|
-
//
|
|
421
|
-
// import { AQI_CAPABLE_DEVICE_TYPES } from "@justair/justair-library";
|
|
356
|
+
// the Users query, so rows that can never alert are not loaded at all:
|
|
422
357
|
//
|
|
423
358
|
// { "subscribedDevices.deviceType": { $in: AQI_CAPABLE_DEVICE_TYPES } }
|
|
424
359
|
//
|
|
@@ -434,26 +369,18 @@ export const projectSubscribedMonitors = async (
|
|
|
434
369
|
* Promise<Map<string, import("mongoose").Types.ObjectId[]>> }} [options]
|
|
435
370
|
* @returns {Promise<Map<string, import("mongoose").Types.ObjectId[]>>}
|
|
436
371
|
*/
|
|
437
|
-
export const
|
|
372
|
+
export const resolveMonitorIdsByUser = async (
|
|
438
373
|
users,
|
|
439
|
-
{ resolveVisibleSiteMonitors } = {}
|
|
374
|
+
{ resolveVisibleSiteMonitors } = {}
|
|
440
375
|
) => {
|
|
441
|
-
const cohort = users ?? [];
|
|
376
|
+
const cohort = (users ?? []).filter(isPlainObject);
|
|
442
377
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
// an isPlainObject filter and then never appear in the returned Map, which
|
|
446
|
-
// is the same silent removal from an alert audience that the missing-_id
|
|
447
|
-
// check below already rejects — a caller cannot tell "that user follows
|
|
448
|
-
// nothing" apart from "that user was quietly discarded". One rule for both:
|
|
449
|
-
// if the cohort contains something that is not a resolvable user, say so.
|
|
450
|
-
const invalid = cohort.some(
|
|
451
|
-
(user) =>
|
|
452
|
-
!isPlainObject(user) || user._id === null || user._id === undefined,
|
|
378
|
+
const missing = cohort.find(
|
|
379
|
+
(user) => user._id === null || user._id === undefined
|
|
453
380
|
);
|
|
454
|
-
if (
|
|
381
|
+
if (missing) {
|
|
455
382
|
const error = new TypeError(
|
|
456
|
-
"
|
|
383
|
+
"resolveMonitorIdsByUser requires every user to carry an _id"
|
|
457
384
|
);
|
|
458
385
|
error.code = SUBSCRIBED_DEVICES_ERRORS.COHORT_USER_MISSING_ID;
|
|
459
386
|
throw error;
|
|
@@ -463,21 +390,21 @@ export const resolveSubscribedMonitorIdsByUser = async (
|
|
|
463
390
|
// of the batch form. Two users following the same Site cost one entry.
|
|
464
391
|
const siteIds = dedupeById(
|
|
465
392
|
cohort.flatMap((user) =>
|
|
466
|
-
linkedSiteIds((user.subscribedDevices ?? []).filter(isPlainObject))
|
|
467
|
-
)
|
|
393
|
+
linkedSiteIds((user.subscribedDevices ?? []).filter(isPlainObject))
|
|
394
|
+
)
|
|
468
395
|
);
|
|
469
396
|
|
|
470
397
|
let monitorIdsBySite = new Map();
|
|
471
398
|
if (siteIds.length > 0) {
|
|
472
399
|
if (typeof resolveVisibleSiteMonitors !== "function") {
|
|
473
400
|
const error = new TypeError(
|
|
474
|
-
"
|
|
401
|
+
"resolveMonitorIdsByUser requires resolveVisibleSiteMonitors when anyone in the cohort follows a linked-device"
|
|
475
402
|
);
|
|
476
403
|
error.code = SUBSCRIBED_DEVICES_ERRORS.MISSING_COHORT_SITE_RESOLVER;
|
|
477
404
|
throw error;
|
|
478
405
|
}
|
|
479
406
|
monitorIdsBySite = validateResolvedSiteMonitorMap(
|
|
480
|
-
await resolveVisibleSiteMonitors(siteIds)
|
|
407
|
+
await resolveVisibleSiteMonitors(siteIds)
|
|
481
408
|
);
|
|
482
409
|
}
|
|
483
410
|
|
|
@@ -492,11 +419,11 @@ export const resolveSubscribedMonitorIdsByUser = async (
|
|
|
492
419
|
cohort.map((user) =>
|
|
493
420
|
projectSubscribedMonitors(user.subscribedDevices, {
|
|
494
421
|
resolveSiteMonitorIds,
|
|
495
|
-
})
|
|
496
|
-
)
|
|
422
|
+
})
|
|
423
|
+
)
|
|
497
424
|
);
|
|
498
425
|
|
|
499
426
|
return new Map(
|
|
500
|
-
cohort.map((user, index) => [String(user._id), resolved[index]])
|
|
427
|
+
cohort.map((user, index) => [String(user._id), resolved[index]])
|
|
501
428
|
);
|
|
502
429
|
};
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from '../../constants/deviceTypes.js';
|
|
7
7
|
import {
|
|
8
8
|
projectSubscribedMonitors,
|
|
9
|
-
|
|
9
|
+
resolveMonitorIdsByUser,
|
|
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
|
+
// resolveMonitorIdsByUser — 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('resolveMonitorIdsByUser', () => {
|
|
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 resolveMonitorIdsByUser([u]);
|
|
447
447
|
|
|
448
448
|
expect([...result.keys()]).toEqual([String(u._id)]);
|
|
449
449
|
});
|
|
@@ -457,7 +457,7 @@ describe('resolveSubscribedMonitorIdsByUser', () => {
|
|
|
457
457
|
user([{ deviceId: m2, deviceType: 'monitor' }]),
|
|
458
458
|
];
|
|
459
459
|
|
|
460
|
-
const result = await
|
|
460
|
+
const result = await resolveMonitorIdsByUser([u1, u2], {
|
|
461
461
|
resolveVisibleSiteMonitors: resolve,
|
|
462
462
|
});
|
|
463
463
|
|
|
@@ -475,7 +475,7 @@ describe('resolveSubscribedMonitorIdsByUser', () => {
|
|
|
475
475
|
[siteB]: [oid()],
|
|
476
476
|
});
|
|
477
477
|
|
|
478
|
-
await
|
|
478
|
+
await resolveMonitorIdsByUser(
|
|
479
479
|
[
|
|
480
480
|
user([{ deviceId: siteA, deviceType: 'linked-device' }]),
|
|
481
481
|
user([{ deviceId: siteA, deviceType: 'linked-device' }]),
|
|
@@ -499,7 +499,7 @@ describe('resolveSubscribedMonitorIdsByUser', () => {
|
|
|
499
499
|
user([{ deviceId: siteB, deviceType: 'linked-device' }]),
|
|
500
500
|
];
|
|
501
501
|
|
|
502
|
-
const result = await
|
|
502
|
+
const result = await resolveMonitorIdsByUser([ua, ub], {
|
|
503
503
|
resolveVisibleSiteMonitors: resolve,
|
|
504
504
|
});
|
|
505
505
|
|
|
@@ -516,7 +516,7 @@ describe('resolveSubscribedMonitorIdsByUser', () => {
|
|
|
516
516
|
{ deviceId: siteId, deviceType: 'linked-device' },
|
|
517
517
|
]);
|
|
518
518
|
|
|
519
|
-
const result = await
|
|
519
|
+
const result = await resolveMonitorIdsByUser([u], {
|
|
520
520
|
resolveVisibleSiteMonitors: resolve,
|
|
521
521
|
});
|
|
522
522
|
|
|
@@ -526,7 +526,7 @@ describe('resolveSubscribedMonitorIdsByUser', () => {
|
|
|
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 resolveMonitorIdsByUser([u]);
|
|
530
530
|
|
|
531
531
|
expect(result.get(String(u._id))).toEqual([]);
|
|
532
532
|
});
|
|
@@ -538,7 +538,7 @@ describe('resolveSubscribedMonitorIdsByUser', () => {
|
|
|
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 resolveMonitorIdsByUser([u], {
|
|
542
542
|
resolveVisibleSiteMonitors: resolve,
|
|
543
543
|
});
|
|
544
544
|
|
|
@@ -553,7 +553,7 @@ describe('resolveSubscribedMonitorIdsByUser', () => {
|
|
|
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 resolveMonitorIdsByUser([u], {
|
|
557
557
|
resolveVisibleSiteMonitors: async () => new Map([[siteId, [monitorId]]]),
|
|
558
558
|
});
|
|
559
559
|
|
|
@@ -563,21 +563,21 @@ describe('resolveSubscribedMonitorIdsByUser', () => {
|
|
|
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 resolveMonitorIdsByUser([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 resolveMonitorIdsByUser([])).toEqual(new Map());
|
|
573
|
+
expect(await resolveMonitorIdsByUser(undefined)).toEqual(new Map());
|
|
574
574
|
});
|
|
575
575
|
});
|
|
576
576
|
|
|
577
|
-
describe('
|
|
577
|
+
describe('resolveMonitorIdsByUser — 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
|
+
resolveMonitorIdsByUser([
|
|
581
581
|
user([{ deviceId: oid(), deviceType: 'linked-device' }]),
|
|
582
582
|
]),
|
|
583
583
|
).rejects.toMatchObject({
|
|
@@ -589,7 +589,7 @@ describe('resolveSubscribedMonitorIdsByUser — rejects what would otherwise fai
|
|
|
589
589
|
const monitorId = oid();
|
|
590
590
|
const u = user([{ deviceId: monitorId, deviceType: 'monitor' }]);
|
|
591
591
|
|
|
592
|
-
const result = await
|
|
592
|
+
const result = await resolveMonitorIdsByUser([u]);
|
|
593
593
|
|
|
594
594
|
expect(result.get(String(u._id))).toEqual([monitorId]);
|
|
595
595
|
});
|
|
@@ -599,7 +599,7 @@ describe('resolveSubscribedMonitorIdsByUser — rejects what would otherwise fai
|
|
|
599
599
|
const siteId = oid();
|
|
600
600
|
|
|
601
601
|
await expect(
|
|
602
|
-
|
|
602
|
+
resolveMonitorIdsByUser(
|
|
603
603
|
[user([{ deviceId: siteId, deviceType: 'linked-device' }])],
|
|
604
604
|
{ resolveVisibleSiteMonitors: async () => [[String(siteId), [oid()]]] },
|
|
605
605
|
),
|
|
@@ -611,7 +611,7 @@ describe('resolveSubscribedMonitorIdsByUser — rejects what would otherwise fai
|
|
|
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 resolveMonitorIdsByUser([u], {
|
|
615
615
|
resolveVisibleSiteMonitors: async () => null,
|
|
616
616
|
});
|
|
617
617
|
|
|
@@ -625,7 +625,7 @@ describe('resolveSubscribedMonitorIdsByUser — rejects what would otherwise fai
|
|
|
625
625
|
const siteId = oid();
|
|
626
626
|
|
|
627
627
|
await expect(
|
|
628
|
-
|
|
628
|
+
resolveMonitorIdsByUser(
|
|
629
629
|
[user([{ deviceId: siteId, deviceType: 'linked-device' }])],
|
|
630
630
|
{
|
|
631
631
|
resolveVisibleSiteMonitors: async () =>
|
|
@@ -641,23 +641,7 @@ describe('resolveSubscribedMonitorIdsByUser — rejects what would otherwise fai
|
|
|
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
|
-
|
|
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]),
|
|
644
|
+
resolveMonitorIdsByUser([{ subscribedDevices: [] }]),
|
|
661
645
|
).rejects.toMatchObject({
|
|
662
646
|
code: SUBSCRIBED_DEVICES_ERRORS.COHORT_USER_MISSING_ID,
|
|
663
647
|
});
|
|
@@ -665,35 +649,17 @@ describe('resolveSubscribedMonitorIdsByUser — rejects what would otherwise fai
|
|
|
665
649
|
});
|
|
666
650
|
|
|
667
651
|
describe('VISIBLE_SITE_MONITOR_FILTER', () => {
|
|
668
|
-
test('
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
// eligibility gate: a monitor earns a place in an alert audience only by
|
|
673
|
-
// positively asserting it is active, so a document that has made no such
|
|
674
|
-
// assertion stays out. Confirmed product decision — flipping either half
|
|
675
|
-
// is a behaviour change, not a cleanup.
|
|
652
|
+
test('excludes private monitors with $ne: true, not an exact false match', () => {
|
|
653
|
+
// $ne matches both `false` and a MISSING key. Documents written before
|
|
654
|
+
// isPrivate existed carry no key, and an exact { isPrivate: false } match
|
|
655
|
+
// would drop them from every Site's audience.
|
|
676
656
|
expect(VISIBLE_SITE_MONITOR_FILTER).toEqual({
|
|
677
657
|
isPrivate: { $ne: true },
|
|
678
658
|
isActive: true,
|
|
679
659
|
});
|
|
680
660
|
});
|
|
681
661
|
|
|
682
|
-
test('is frozen
|
|
683
|
-
// Object.freeze is shallow, so the top-level assertion alone would pass
|
|
684
|
-
// while `FILTER.isPrivate.$ne = false` still silently flipped the privacy
|
|
685
|
-
// predicate for every consumer that spreads this instance.
|
|
662
|
+
test('is frozen — one consumer mutating it must not change it for the rest', () => {
|
|
686
663
|
expect(Object.isFrozen(VISIBLE_SITE_MONITOR_FILTER)).toBe(true);
|
|
687
|
-
expect(Object.isFrozen(VISIBLE_SITE_MONITOR_FILTER.isPrivate)).toBe(true);
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
test('a nested write is rejected rather than shared with every consumer', () => {
|
|
691
|
-
// ES modules are strict, so a write to a frozen property throws rather
|
|
692
|
-
// than failing silently.
|
|
693
|
-
expect(() => {
|
|
694
|
-
VISIBLE_SITE_MONITOR_FILTER.isPrivate.$ne = false;
|
|
695
|
-
}).toThrow(TypeError);
|
|
696
|
-
|
|
697
|
-
expect(VISIBLE_SITE_MONITOR_FILTER.isPrivate.$ne).toBe(true);
|
|
698
664
|
});
|
|
699
665
|
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// deliberate contract change, not a test fix.
|
|
10
10
|
import {
|
|
11
11
|
projectSubscribedMonitors,
|
|
12
|
-
|
|
12
|
+
resolveMonitorIdsByUser,
|
|
13
13
|
VISIBLE_SITE_MONITOR_FILTER,
|
|
14
14
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
15
15
|
} from '../subscribedDevices.js';
|
|
@@ -17,14 +17,13 @@ import {
|
|
|
17
17
|
DEVICE_TYPES,
|
|
18
18
|
DEVICE_TYPE_VALUES,
|
|
19
19
|
isAqiCapableDeviceType,
|
|
20
|
-
AQI_CAPABLE_DEVICE_TYPES,
|
|
21
20
|
} from '../../constants/deviceTypes.js';
|
|
22
21
|
|
|
23
22
|
test('barrel re-exports the subscribedDevices surface unchanged (identity, not copies)', async () => {
|
|
24
23
|
const barrel = await import('../../index.js');
|
|
25
24
|
|
|
26
25
|
expect(barrel.projectSubscribedMonitors).toBe(projectSubscribedMonitors);
|
|
27
|
-
expect(barrel.
|
|
26
|
+
expect(barrel.resolveMonitorIdsByUser).toBe(resolveMonitorIdsByUser);
|
|
28
27
|
expect(barrel.VISIBLE_SITE_MONITOR_FILTER).toBe(VISIBLE_SITE_MONITOR_FILTER);
|
|
29
28
|
expect(barrel.SUBSCRIBED_DEVICES_ERRORS).toBe(SUBSCRIBED_DEVICES_ERRORS);
|
|
30
29
|
});
|
|
@@ -40,5 +39,4 @@ test('barrel re-exports the device-type vocabulary unchanged (identity, not copi
|
|
|
40
39
|
expect(barrel.DEVICE_TYPES).toBe(DEVICE_TYPES);
|
|
41
40
|
expect(barrel.DEVICE_TYPE_VALUES).toBe(DEVICE_TYPE_VALUES);
|
|
42
41
|
expect(barrel.isAqiCapableDeviceType).toBe(isAqiCapableDeviceType);
|
|
43
|
-
expect(barrel.AQI_CAPABLE_DEVICE_TYPES).toBe(AQI_CAPABLE_DEVICE_TYPES);
|
|
44
42
|
});
|