@justair/justair-library 7.5.0 → 7.6.0-alpha.2ec581a
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 +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/models/users.d.ts.map +1 -1
- package/dist/utils/subscribedDevices.d.ts +18 -0
- 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 +42 -9
- package/src/models/users.js +29 -15
- package/src/utils/subscribedDevices.js +327 -80
- package/src/utils/tests/subscribedDevices.test.js +284 -0
- package/src/utils/tests/subscribedDevicesBarrel.test.js +6 -0
|
@@ -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,7 +106,10 @@ 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";
|
|
111
|
+
import { resolveSubscribedMonitorIdsByUser } from "./utils/subscribedDevices.js";
|
|
112
|
+
import { VISIBLE_SITE_MONITOR_FILTER } from "./utils/subscribedDevices.js";
|
|
110
113
|
import { SUBSCRIBED_DEVICES_ERRORS } from "./utils/subscribedDevices.js";
|
|
111
114
|
import { splitName } from "./utils/splitName.js";
|
|
112
115
|
import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
|
|
@@ -118,5 +121,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
|
|
|
118
121
|
import { getZoneMinutes } from "./utils/quietHours.js";
|
|
119
122
|
import { isInQuietWindow } from "./utils/quietHours.js";
|
|
120
123
|
import { resolveQuietHours } from "./utils/quietHours.js";
|
|
121
|
-
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, SUBSCRIBED_DEVICES_ERRORS, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
|
|
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 };
|
|
122
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"}
|
|
@@ -3,6 +3,15 @@ export const SUBSCRIBED_DEVICES_ERRORS: Readonly<{
|
|
|
3
3
|
RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array";
|
|
4
4
|
RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays";
|
|
5
5
|
RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents";
|
|
6
|
+
MISSING_COHORT_SITE_RESOLVER: "missing_cohort_site_resolver";
|
|
7
|
+
COHORT_RESOLVER_RETURNED_NON_MAP: "cohort_resolver_returned_non_map";
|
|
8
|
+
COHORT_USER_MISSING_ID: "cohort_user_missing_id";
|
|
9
|
+
}>;
|
|
10
|
+
export const VISIBLE_SITE_MONITOR_FILTER: Readonly<{
|
|
11
|
+
isPrivate: Readonly<{
|
|
12
|
+
$ne: true;
|
|
13
|
+
}>;
|
|
14
|
+
isActive: true;
|
|
6
15
|
}>;
|
|
7
16
|
export function projectSubscribedMonitors(subscribedDevices?: Array<{
|
|
8
17
|
deviceId: import("mongoose").Types.ObjectId;
|
|
@@ -10,4 +19,13 @@ export function projectSubscribedMonitors(subscribedDevices?: Array<{
|
|
|
10
19
|
}>, { resolveSiteMonitorIds }?: {
|
|
11
20
|
resolveSiteMonitorIds?: (siteIds: import("mongoose").Types.ObjectId[]) => Promise<Array<import("mongoose").Types.ObjectId | null | undefined>>;
|
|
12
21
|
}): Promise<import("mongoose").Types.ObjectId[]>;
|
|
22
|
+
export function resolveSubscribedMonitorIdsByUser(users?: Array<{
|
|
23
|
+
_id: any;
|
|
24
|
+
subscribedDevices?: Array<{
|
|
25
|
+
deviceId: import("mongoose").Types.ObjectId;
|
|
26
|
+
deviceType: import("../constants/deviceTypes.js").DeviceType;
|
|
27
|
+
}>;
|
|
28
|
+
}>, { resolveVisibleSiteMonitors }?: {
|
|
29
|
+
resolveVisibleSiteMonitors?: (siteIds: import("mongoose").Types.ObjectId[]) => Promise<Map<string, import("mongoose").Types.ObjectId[]>>;
|
|
30
|
+
}): Promise<Map<string, import("mongoose").Types.ObjectId[]>>;
|
|
13
31
|
//# sourceMappingURL=subscribedDevices.d.ts.map
|
|
@@ -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;AA+CH;;;;;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;AA0EM,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,6 +109,8 @@ import {
|
|
|
102
109
|
import { isValidIANATimezone } from "./utils/timezone.js";
|
|
103
110
|
import {
|
|
104
111
|
projectSubscribedMonitors,
|
|
112
|
+
resolveSubscribedMonitorIdsByUser,
|
|
113
|
+
VISIBLE_SITE_MONITOR_FILTER,
|
|
105
114
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
106
115
|
} from "./utils/subscribedDevices.js";
|
|
107
116
|
|
|
@@ -125,7 +134,10 @@ import {
|
|
|
125
134
|
SamplesAudit,
|
|
126
135
|
sampleParameterReferenceConcentrations,
|
|
127
136
|
} from "./models/samples.js";
|
|
128
|
-
import {
|
|
137
|
+
import {
|
|
138
|
+
passwordResetTokenSchema,
|
|
139
|
+
PasswordResetToken,
|
|
140
|
+
} from "./models/passwordResetToken.js";
|
|
129
141
|
import {
|
|
130
142
|
dataDownloadSchema,
|
|
131
143
|
DataDownloads,
|
|
@@ -272,13 +284,34 @@ export {
|
|
|
272
284
|
DEVICE_TYPES,
|
|
273
285
|
DEVICE_TYPE_VALUES,
|
|
274
286
|
isAqiCapableDeviceType,
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
//
|
|
280
|
-
//
|
|
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
|
+
// subscribedDevices → monitor ids, resolved at READ time (JA-3110 / JA-3335).
|
|
292
|
+
// Users.subscribedDevices is the source of truth; Users.subscribedMonitors is
|
|
293
|
+
// being retired rather than kept in step with it. Nothing should store this
|
|
294
|
+
// result — see the module header for the five bugs a stored copy caused.
|
|
295
|
+
//
|
|
296
|
+
// Both resolvers are only correct for users whose subscribedDevices has been
|
|
297
|
+
// backfilled from subscribedMonitors — read the PRECONDITION block in the
|
|
298
|
+
// module header before pointing a service at either of them.
|
|
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.
|
|
303
|
+
// projectSubscribedMonitors — the single-user rule the batch form is built
|
|
304
|
+
// on. Named after the field being retired; it
|
|
305
|
+
// gets renamed when that field is dropped.
|
|
306
|
+
// VISIBLE_SITE_MONITOR_FILTER — the privacy predicate every consumer must
|
|
307
|
+
// apply when turning a Site into monitor ids.
|
|
308
|
+
// Sites.monitorIds is unfiltered; reading it
|
|
309
|
+
// straight through leaks private monitors.
|
|
310
|
+
// SUBSCRIBED_DEVICES_ERRORS — stable `err.code` vocabulary; consumers
|
|
311
|
+
// branch on it, never on message wording.
|
|
281
312
|
projectSubscribedMonitors,
|
|
313
|
+
resolveSubscribedMonitorIdsByUser,
|
|
314
|
+
VISIBLE_SITE_MONITOR_FILTER,
|
|
282
315
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
283
316
|
// Utilities
|
|
284
317
|
splitName,
|
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
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
// Subscribed devices (JA-3110 / JA-3335):
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// Single source of truth for every consumer that writes a subscription —
|
|
6
|
-
// JustAirUserService recomputes on subscribe/unsubscribe, and any backfill or
|
|
7
|
-
// repair script uses the same function. Pure: no DB, no logger, no clock. The
|
|
8
|
-
// one thing it cannot know on its own is which monitors a Site links, so that
|
|
9
|
-
// lookup is injected as `resolveSiteMonitorIds`.
|
|
10
|
-
//
|
|
11
|
-
// The rule, per JA-3110 AC "user will receive AQI notifications if the site
|
|
12
|
-
// has an AQI":
|
|
1
|
+
// Subscribed devices (JA-3110 / JA-3335): resolving Users.subscribedDevices —
|
|
2
|
+
// the source of truth for what a user follows, across all three device types —
|
|
3
|
+
// down to the flat monitor ids that make up their AQI-alert audience.
|
|
13
4
|
//
|
|
14
5
|
// monitor → contributes its own deviceId
|
|
15
6
|
// linked-device → contributes the monitorIds of its Site (none if the Site
|
|
@@ -18,65 +9,79 @@
|
|
|
18
9
|
// sample-site → contributes nothing; no live AQI, so it can never trigger
|
|
19
10
|
// an AQI alert
|
|
20
11
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
12
|
+
// Pure: no DB, no logger, no clock. The one thing this module cannot know on
|
|
13
|
+
// its own is which monitors a Site links, so that lookup is injected.
|
|
14
|
+
//
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// THIS IS A READ-TIME RESOLUTION, NOT A STORED PROJECTION
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// `Users.subscribedMonitors` used to be a denormalized copy of this result,
|
|
19
|
+
// written on every subscribe and unsubscribe. It is being removed
|
|
20
|
+
// (JA-3110 "Retire subscribedMonitors"), and this module is what replaces it:
|
|
21
|
+
// consumers resolve the audience when they need it and store nothing.
|
|
22
|
+
//
|
|
23
|
+
// That decision came out of the JA-3110 review, where five separate
|
|
24
|
+
// silent-failure bugs all traced back to keeping a stored copy in step with
|
|
25
|
+
// the source of truth:
|
|
26
|
+
//
|
|
27
|
+
// * a legacy id with no subscribedDevices row being dropped when a Site that
|
|
28
|
+
// linked the same monitor was unfollowed
|
|
29
|
+
// * "half-migrated" users — a stored copy holding ids the source of truth
|
|
30
|
+
// did not — passing a migration gate that checked presence, not content
|
|
31
|
+
// * unsubscribe computing a removal set from a snapshot, so a concurrent
|
|
32
|
+
// subscribe lost its contribution
|
|
33
|
+
// * a hard-deleted Site leaving its monitors in the stored copy forever
|
|
34
|
+
// * a Site gaining or losing a monitor never reaching its existing followers
|
|
35
|
+
//
|
|
36
|
+
// None of those exist here. There is no snapshot to go stale, nothing to
|
|
37
|
+
// reproject when a Site changes, and unsubscribe is a plain `$pull` of one
|
|
38
|
+
// subscribedDevices entry. If you find yourself reintroducing a stored copy of
|
|
39
|
+
// this result, you are reintroducing all five.
|
|
40
|
+
//
|
|
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.
|
|
27
63
|
//
|
|
28
64
|
// ---------------------------------------------------------------------------
|
|
29
|
-
//
|
|
65
|
+
// PRIVACY IS THE CALLER'S JOB, AND IT IS NOT OPTIONAL
|
|
30
66
|
// ---------------------------------------------------------------------------
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// { deviceId, deviceType: "monitor" } entry per existing id), then
|
|
43
|
-
// overwrite freely. This is the target state.
|
|
44
|
-
// 2. Until that backfill has run for the user, union the projection with the
|
|
45
|
-
// existing subscribedMonitors instead of overwriting — AND write the
|
|
46
|
-
// per-user backfill in the same update, one
|
|
47
|
-
// { deviceId, deviceType: "monitor" } entry per id already in
|
|
48
|
-
// subscribedMonitors, alongside the device being subscribed. Both halves
|
|
49
|
-
// are required. Unioning without backfilling leaves a user who looks
|
|
50
|
-
// migrated (subscribedDevices is non-empty) but is not, so the obvious
|
|
51
|
-
// "has this user been backfilled?" check answers yes and the next
|
|
52
|
-
// unsubscribe overwrites subscribedMonitors with a projection missing
|
|
53
|
-
// every monitor they had before. A half-migrated user must never exist.
|
|
54
|
-
//
|
|
55
|
-
// This path is also SUBSCRIBE-ONLY. A union can only ever add ids, so
|
|
56
|
-
// using it for an unsubscribe makes the removal a silent no-op. There is
|
|
57
|
-
// deliberately no third option: an unsubscribe on a user who predates
|
|
58
|
-
// subscribedDevices MUST be preceded by the backfill, and only then
|
|
59
|
-
// overwrite. Removing a subscription is not correct any other way.
|
|
60
|
-
//
|
|
61
|
-
// This module deliberately does NOT offer a `union: true` flag. A blanket
|
|
62
|
-
// union is correct for subscribe and silently wrong for unsubscribe — it would
|
|
63
|
-
// make removal a no-op — so the choice belongs at the call site, which knows
|
|
64
|
-
// which operation it is performing. The backfill itself is consumer-side
|
|
65
|
-
// (JustAirUserService owns the write path) and is not shipped in this library;
|
|
66
|
-
// it needs its own ticket under JA-3110 before any consumer moves to
|
|
67
|
-
// overwrite-always.
|
|
67
|
+
// A Site's `monitorIds` is a raw membership list with no visibility filtering.
|
|
68
|
+
// Reading it straight through enrols a Site's followers in alerts for any
|
|
69
|
+
// private monitor it links — a real leak, not a theoretical one, because the
|
|
70
|
+
// notification service filters monitors on `monitorState`/`isActive` and not on
|
|
71
|
+
// `isPrivate`.
|
|
72
|
+
//
|
|
73
|
+
// So the injected resolver must return only VISIBLE monitors, which is why it
|
|
74
|
+
// is named `resolveVisibleSiteMonitors` and why VISIBLE_SITE_MONITOR_FILTER
|
|
75
|
+
// exists below: three repos need the same predicate, and `$ne: true` (rather
|
|
76
|
+
// than `false`) is the half that is easy to get wrong on documents written
|
|
77
|
+
// before `isPrivate` existed.
|
|
68
78
|
//
|
|
69
79
|
// ---------------------------------------------------------------------------
|
|
70
|
-
//
|
|
80
|
+
// NAMING
|
|
71
81
|
// ---------------------------------------------------------------------------
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// wrong — a monitor added to a Site never alerts its existing subscribers, and
|
|
76
|
-
// a removed one keeps alerting them, in both cases silently. Any code that
|
|
77
|
-
// mutates Sites.monitorIds is therefore responsible for reprojecting the
|
|
78
|
-
// affected subscribers. That reprojection is not implemented anywhere yet and
|
|
79
|
-
// needs a follow-up ticket under JA-3110.
|
|
82
|
+
// `projectSubscribedMonitors` is named after the field being retired. It is
|
|
83
|
+
// kept under that name while consumers migrate; renaming it to something that
|
|
84
|
+
// does not reference a dead field belongs in the step that drops the field.
|
|
80
85
|
|
|
81
86
|
import { DEVICE_TYPES } from "../constants/deviceTypes.js";
|
|
82
87
|
|
|
@@ -88,6 +93,60 @@ export const SUBSCRIBED_DEVICES_ERRORS = Object.freeze({
|
|
|
88
93
|
RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array",
|
|
89
94
|
RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays",
|
|
90
95
|
RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents",
|
|
96
|
+
// resolveSubscribedMonitorIdsByUser only
|
|
97
|
+
MISSING_COHORT_SITE_RESOLVER: "missing_cohort_site_resolver",
|
|
98
|
+
COHORT_RESOLVER_RETURNED_NON_MAP: "cohort_resolver_returned_non_map",
|
|
99
|
+
COHORT_USER_MISSING_ID: "cohort_user_missing_id",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// The predicate that makes a Site's monitor visible to its followers. Frozen
|
|
103
|
+
// and exported because JustAirService, JustAirUserService and
|
|
104
|
+
// JustAirNotificationService all need the same one, and a fourth hand-written
|
|
105
|
+
// copy is how one of them quietly starts leaking private monitors.
|
|
106
|
+
//
|
|
107
|
+
// `isPrivate: { $ne: true }` rather than `{ $eq: false }`: the field has a
|
|
108
|
+
// schema default but that only governs writes made through mongoose, so
|
|
109
|
+
// documents written before it existed have no key at all and would vanish
|
|
110
|
+
// under an exact match. Same rule the public-map path uses.
|
|
111
|
+
//
|
|
112
|
+
// Spread it into the Monitors query the resolver is built from:
|
|
113
|
+
//
|
|
114
|
+
// const monitors = await Monitors.find(
|
|
115
|
+
// { _id: { $in: siteMonitorIds }, ...VISIBLE_SITE_MONITOR_FILTER },
|
|
116
|
+
// "_id",
|
|
117
|
+
// ).lean();
|
|
118
|
+
//
|
|
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
|
+
// `monitorState: "Deployed"` is deliberately NOT here. That is an
|
|
135
|
+
// alerting-eligibility rule, not a visibility rule, and it belongs to the
|
|
136
|
+
// notification service alone — the org admin page and the session response
|
|
137
|
+
// must still show a follower the monitors they follow while those monitors sit
|
|
138
|
+
// in a non-Deployed state. Do not consolidate the two filters; keep
|
|
139
|
+
// 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
|
+
export const VISIBLE_SITE_MONITOR_FILTER = Object.freeze({
|
|
148
|
+
isPrivate: Object.freeze({ $ne: true }),
|
|
149
|
+
isActive: true,
|
|
91
150
|
});
|
|
92
151
|
|
|
93
152
|
// Dedupe by string form, keep the caller's ObjectId instances. Two equal
|
|
@@ -148,7 +207,7 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
148
207
|
|
|
149
208
|
if (!Array.isArray(resolved)) {
|
|
150
209
|
const error = new TypeError(
|
|
151
|
-
"resolveSiteMonitorIds must resolve to an array of monitor ids"
|
|
210
|
+
"resolveSiteMonitorIds must resolve to an array of monitor ids",
|
|
152
211
|
);
|
|
153
212
|
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NON_ARRAY;
|
|
154
213
|
throw error;
|
|
@@ -160,7 +219,7 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
160
219
|
// silently works, two throws a CastError at write time, far from the cause.
|
|
161
220
|
if (resolved.some(Array.isArray)) {
|
|
162
221
|
const error = new TypeError(
|
|
163
|
-
"resolveSiteMonitorIds must resolve to a flat array of monitor ids"
|
|
222
|
+
"resolveSiteMonitorIds must resolve to a flat array of monitor ids",
|
|
164
223
|
);
|
|
165
224
|
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NESTED_ARRAYS;
|
|
166
225
|
throw error;
|
|
@@ -176,11 +235,11 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
176
235
|
// (`Sites.find(..., "monitorIds -_id").lean()`) carries none.
|
|
177
236
|
if (
|
|
178
237
|
resolved.some(
|
|
179
|
-
(id) => isPlainObject(id) && typeof id.toHexString !== "function"
|
|
238
|
+
(id) => isPlainObject(id) && typeof id.toHexString !== "function",
|
|
180
239
|
)
|
|
181
240
|
) {
|
|
182
241
|
const error = new TypeError(
|
|
183
|
-
"resolveSiteMonitorIds must resolve to monitor ids, not Site documents"
|
|
242
|
+
"resolveSiteMonitorIds must resolve to monitor ids, not Site documents",
|
|
184
243
|
);
|
|
185
244
|
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS;
|
|
186
245
|
throw error;
|
|
@@ -189,10 +248,42 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
189
248
|
return resolved;
|
|
190
249
|
};
|
|
191
250
|
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
251
|
+
// The cohort resolver's return, checked the same way and for the same reason:
|
|
252
|
+
// it is consumer-supplied, and every bad shape below would otherwise resolve to
|
|
253
|
+
// an empty audience rather than an error — a user silently stops getting
|
|
254
|
+
// alerts, which is the one outcome this module exists to prevent.
|
|
255
|
+
//
|
|
256
|
+
// Keys are re-derived with String() rather than trusted. A consumer who builds
|
|
257
|
+
// `new Map([[site._id, ids]])` with raw ObjectId keys produces a Map that looks
|
|
258
|
+
// correct, passes every type check, and misses on every `.get(String(id))`
|
|
259
|
+
// because two equal ObjectIds are different object identities. Normalising is
|
|
260
|
+
// forgiving in precisely the place where strictness would fail silently.
|
|
261
|
+
const validateResolvedSiteMonitorMap = (resolved) => {
|
|
262
|
+
if (resolved === null || resolved === undefined) {
|
|
263
|
+
return new Map();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!(resolved instanceof Map)) {
|
|
267
|
+
const error = new TypeError(
|
|
268
|
+
"resolveVisibleSiteMonitors must resolve to a Map of site id to monitor ids",
|
|
269
|
+
);
|
|
270
|
+
error.code = SUBSCRIBED_DEVICES_ERRORS.COHORT_RESOLVER_RETURNED_NON_MAP;
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return new Map(
|
|
275
|
+
[...resolved.entries()].map(([siteId, monitorIds]) => [
|
|
276
|
+
String(siteId),
|
|
277
|
+
validateResolvedSiteMonitorIds(monitorIds),
|
|
278
|
+
]),
|
|
279
|
+
);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Resolves one user's subscribedDevices array down to the flat monitor ids
|
|
283
|
+
// that make up their AQI-alert audience. Order is deterministic — direct
|
|
284
|
+
// `monitor` subscriptions in array order, then Site-derived ids in resolver
|
|
285
|
+
// order, first occurrence wins — but carries no meaning; treat the result as a
|
|
286
|
+
// set. This is a value to use, not a value to persist (see below).
|
|
196
287
|
//
|
|
197
288
|
// `subscribedDevices` tolerates undefined/null: a user row written before this
|
|
198
289
|
// field existed has no key at all on a `.lean()` read (pinned in
|
|
@@ -201,10 +292,21 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
201
292
|
//
|
|
202
293
|
// `resolveSiteMonitorIds(siteIds)` is called at most once, with every
|
|
203
294
|
// linked-device id at a time, and must resolve to the flat union of those
|
|
204
|
-
// Sites' monitorIds — the ids themselves, not the Site documents
|
|
295
|
+
// Sites' VISIBLE monitorIds — the ids themselves, not the Site documents.
|
|
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:
|
|
205
300
|
//
|
|
206
301
|
// const sites = await Sites.find({ _id: { $in: siteIds } }, "monitorIds").lean();
|
|
207
|
-
//
|
|
302
|
+
// const monitors = await Monitors.find(
|
|
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);
|
|
208
310
|
//
|
|
209
311
|
// Returning the documents is the easy mistake, so it is rejected rather than
|
|
210
312
|
// merely documented: the return is validated and a document-shaped entry
|
|
@@ -219,8 +321,14 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
219
321
|
// Site has no array — and they are filtered out here rather than trusted to
|
|
220
322
|
// the caller.
|
|
221
323
|
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
324
|
+
// Do NOT store this result. Read the "THIS IS A READ-TIME RESOLUTION" note at
|
|
325
|
+
// the top of this file — a stored copy is where all five JA-3110 bugs came
|
|
326
|
+
// from. Its privacy obligation applies here too: the ids this returns are only
|
|
327
|
+
// as safe to alert on as the resolver that produced them.
|
|
328
|
+
//
|
|
329
|
+
// For a whole cohort of users, prefer resolveSubscribedMonitorIdsByUser
|
|
330
|
+
// below — it does the Sites lookup once for the batch instead of once per
|
|
331
|
+
// user.
|
|
224
332
|
/**
|
|
225
333
|
* @param {Array<{ deviceId: import("mongoose").Types.ObjectId,
|
|
226
334
|
* deviceType: import("../constants/deviceTypes.js").DeviceType }>} [subscribedDevices]
|
|
@@ -230,7 +338,7 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
230
338
|
*/
|
|
231
339
|
export const projectSubscribedMonitors = async (
|
|
232
340
|
subscribedDevices,
|
|
233
|
-
{ resolveSiteMonitorIds } = {}
|
|
341
|
+
{ resolveSiteMonitorIds } = {},
|
|
234
342
|
) => {
|
|
235
343
|
const entries = (subscribedDevices ?? []).filter(isPlainObject);
|
|
236
344
|
const direct = directMonitorIds(entries);
|
|
@@ -242,14 +350,153 @@ export const projectSubscribedMonitors = async (
|
|
|
242
350
|
|
|
243
351
|
if (typeof resolveSiteMonitorIds !== "function") {
|
|
244
352
|
const error = new TypeError(
|
|
245
|
-
"projectSubscribedMonitors requires resolveSiteMonitorIds when a linked-device is subscribed"
|
|
353
|
+
"projectSubscribedMonitors requires resolveSiteMonitorIds when a linked-device is subscribed",
|
|
246
354
|
);
|
|
247
355
|
error.code = SUBSCRIBED_DEVICES_ERRORS.MISSING_SITE_RESOLVER;
|
|
248
356
|
throw error;
|
|
249
357
|
}
|
|
250
358
|
|
|
251
359
|
const siteMonitorIds = validateResolvedSiteMonitorIds(
|
|
252
|
-
await resolveSiteMonitorIds(siteIds)
|
|
360
|
+
await resolveSiteMonitorIds(siteIds),
|
|
253
361
|
);
|
|
254
362
|
return dedupeById([...direct, ...siteMonitorIds]);
|
|
255
363
|
};
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Cohort-scoped resolution
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// The batch form of the above, and the one every read-time consumer should
|
|
369
|
+
// reach for: an alert run, an org admin page, a session response. Resolves a
|
|
370
|
+
// whole list of users with ONE Sites lookup covering every Site anyone in the
|
|
371
|
+
// cohort follows, rather than one lookup per user.
|
|
372
|
+
//
|
|
373
|
+
// That distinction is the difference between a constant-cost alert run and an
|
|
374
|
+
// N+1 across the entire user base, which is the shape this codebase keeps
|
|
375
|
+
// re-learning. The lookup is not performed at all when nobody in the cohort
|
|
376
|
+
// follows a linked-device.
|
|
377
|
+
//
|
|
378
|
+
// resolveVisibleSiteMonitors(siteIds) -> Map<string siteId, ObjectId[]>
|
|
379
|
+
//
|
|
380
|
+
// The resolver is keyed per Site rather than returning a flat union, because
|
|
381
|
+
// each user needs their own Sites' monitors, not the cohort's. It must return
|
|
382
|
+
// only VISIBLE monitors — see the privacy note at the top of this file, and
|
|
383
|
+
// build it from VISIBLE_SITE_MONITOR_FILTER:
|
|
384
|
+
//
|
|
385
|
+
// const sites = await Sites.find(
|
|
386
|
+
// { _id: { $in: siteIds } }, "monitorIds",
|
|
387
|
+
// ).lean();
|
|
388
|
+
// const monitors = await Monitors.find(
|
|
389
|
+
// {
|
|
390
|
+
// _id: { $in: sites.flatMap((s) => s.monitorIds ?? []) },
|
|
391
|
+
// ...VISIBLE_SITE_MONITOR_FILTER,
|
|
392
|
+
// },
|
|
393
|
+
// "_id",
|
|
394
|
+
// ).lean();
|
|
395
|
+
// const visible = new Set(monitors.map((m) => String(m._id)));
|
|
396
|
+
// return new Map(sites.map((s) => [
|
|
397
|
+
// String(s._id),
|
|
398
|
+
// (s.monitorIds ?? []).filter((id) => visible.has(String(id))),
|
|
399
|
+
// ]));
|
|
400
|
+
//
|
|
401
|
+
// A Site id the resolver omits resolves to no monitors for that user rather
|
|
402
|
+
// than throwing — a followed Site that has since been deleted is an ordinary
|
|
403
|
+
// state, not an error. What that user is left holding (an entry that resolves
|
|
404
|
+
// to nothing, so their alerts stop) is the subscription-identity question
|
|
405
|
+
// tracked in TODOS, and is deliberately not papered over here.
|
|
406
|
+
//
|
|
407
|
+
// Users are keyed by `String(user._id)`, so callers look up with the same.
|
|
408
|
+
// Anything in the cohort that is not a user object carrying an `_id` — a
|
|
409
|
+
// missing `_id`, a `null` slot, a bare id string — throws COHORT_USER_MISSING_ID
|
|
410
|
+
// rather than being skipped. Skipping would silently remove a real person from
|
|
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.
|
|
414
|
+
//
|
|
415
|
+
// 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";
|
|
422
|
+
//
|
|
423
|
+
// { "subscribedDevices.deviceType": { $in: AQI_CAPABLE_DEVICE_TYPES } }
|
|
424
|
+
//
|
|
425
|
+
// There is no index on `subscribedDevices.deviceType` (only on
|
|
426
|
+
// `subscribedDevices.deviceId`), so this narrows the documents returned rather
|
|
427
|
+
// than the documents scanned — still worth it on a cohort query that is already
|
|
428
|
+
// scanning, not worth building a JS-side filter for.
|
|
429
|
+
/**
|
|
430
|
+
* @param {Array<{ _id: any, subscribedDevices?: Array<{
|
|
431
|
+
* deviceId: import("mongoose").Types.ObjectId,
|
|
432
|
+
* deviceType: import("../constants/deviceTypes.js").DeviceType }> }>} [users]
|
|
433
|
+
* @param {{ resolveVisibleSiteMonitors?: (siteIds: import("mongoose").Types.ObjectId[]) =>
|
|
434
|
+
* Promise<Map<string, import("mongoose").Types.ObjectId[]>> }} [options]
|
|
435
|
+
* @returns {Promise<Map<string, import("mongoose").Types.ObjectId[]>>}
|
|
436
|
+
*/
|
|
437
|
+
export const resolveSubscribedMonitorIdsByUser = async (
|
|
438
|
+
users,
|
|
439
|
+
{ resolveVisibleSiteMonitors } = {},
|
|
440
|
+
) => {
|
|
441
|
+
const cohort = users ?? [];
|
|
442
|
+
|
|
443
|
+
// Every entry must be a user object carrying an _id, and anything else
|
|
444
|
+
// throws rather than being filtered out. A `null` slot used to be dropped by
|
|
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,
|
|
453
|
+
);
|
|
454
|
+
if (invalid) {
|
|
455
|
+
const error = new TypeError(
|
|
456
|
+
"resolveSubscribedMonitorIdsByUser requires every cohort entry to be a user object carrying an _id",
|
|
457
|
+
);
|
|
458
|
+
error.code = SUBSCRIBED_DEVICES_ERRORS.COHORT_USER_MISSING_ID;
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Every Site anyone in the cohort follows, deduped across users — the point
|
|
463
|
+
// of the batch form. Two users following the same Site cost one entry.
|
|
464
|
+
const siteIds = dedupeById(
|
|
465
|
+
cohort.flatMap((user) =>
|
|
466
|
+
linkedSiteIds((user.subscribedDevices ?? []).filter(isPlainObject)),
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
let monitorIdsBySite = new Map();
|
|
471
|
+
if (siteIds.length > 0) {
|
|
472
|
+
if (typeof resolveVisibleSiteMonitors !== "function") {
|
|
473
|
+
const error = new TypeError(
|
|
474
|
+
"resolveSubscribedMonitorIdsByUser requires resolveVisibleSiteMonitors when anyone in the cohort follows a linked-device",
|
|
475
|
+
);
|
|
476
|
+
error.code = SUBSCRIBED_DEVICES_ERRORS.MISSING_COHORT_SITE_RESOLVER;
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
monitorIdsBySite = validateResolvedSiteMonitorMap(
|
|
480
|
+
await resolveVisibleSiteMonitors(siteIds),
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Synchronous and Map-backed, so the per-user projections below issue no
|
|
485
|
+
// further lookups no matter how large the cohort is. Per-entry validation
|
|
486
|
+
// (arrays, not nested arrays, ids rather than documents) is inherited from
|
|
487
|
+
// projectSubscribedMonitors rather than duplicated.
|
|
488
|
+
const resolveSiteMonitorIds = (ids) =>
|
|
489
|
+
ids.flatMap((id) => monitorIdsBySite.get(String(id)) ?? []);
|
|
490
|
+
|
|
491
|
+
const resolved = await Promise.all(
|
|
492
|
+
cohort.map((user) =>
|
|
493
|
+
projectSubscribedMonitors(user.subscribedDevices, {
|
|
494
|
+
resolveSiteMonitorIds,
|
|
495
|
+
}),
|
|
496
|
+
),
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
return new Map(
|
|
500
|
+
cohort.map((user, index) => [String(user._id), resolved[index]]),
|
|
501
|
+
);
|
|
502
|
+
};
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
} from '../../constants/deviceTypes.js';
|
|
7
7
|
import {
|
|
8
8
|
projectSubscribedMonitors,
|
|
9
|
+
resolveSubscribedMonitorIdsByUser,
|
|
10
|
+
VISIBLE_SITE_MONITOR_FILTER,
|
|
9
11
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
10
12
|
} from '../subscribedDevices.js';
|
|
11
13
|
|
|
@@ -413,3 +415,285 @@ describe('isAqiCapableDeviceType agrees with the projection', () => {
|
|
|
413
415
|
expect(projected.length > 0).toBe(isAqiCapableDeviceType(deviceType));
|
|
414
416
|
});
|
|
415
417
|
});
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// resolveSubscribedMonitorIdsByUser — the cohort form
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// The batch form exists for exactly two reasons, and both are asserted here
|
|
423
|
+
// rather than just described: it must issue ONE Sites lookup for a whole
|
|
424
|
+
// cohort (never one per user), and each user must get THEIR OWN Sites'
|
|
425
|
+
// monitors, not the cohort's union. Get the second wrong and every follower of
|
|
426
|
+
// any Site is enrolled in every other Site's alerts — a bug that looks like
|
|
427
|
+
// working code on a one-user fixture.
|
|
428
|
+
|
|
429
|
+
// Records what the cohort resolver was asked for, so the tests can assert the
|
|
430
|
+
// call shape and not just the result.
|
|
431
|
+
const recordingSiteMap = (byId) => {
|
|
432
|
+
const calls = [];
|
|
433
|
+
const resolve = async (siteIds) => {
|
|
434
|
+
calls.push(siteIds);
|
|
435
|
+
return new Map(siteIds.map((id) => [String(id), byId[String(id)] ?? []]));
|
|
436
|
+
};
|
|
437
|
+
return { resolve, calls };
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const user = (subscribedDevices) => ({ _id: oid(), subscribedDevices });
|
|
441
|
+
|
|
442
|
+
describe('resolveSubscribedMonitorIdsByUser', () => {
|
|
443
|
+
test('keys the result by String(user._id)', async () => {
|
|
444
|
+
const u = user([]);
|
|
445
|
+
|
|
446
|
+
const result = await resolveSubscribedMonitorIdsByUser([u]);
|
|
447
|
+
|
|
448
|
+
expect([...result.keys()]).toEqual([String(u._id)]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('a monitor-only cohort never touches the Sites resolver', async () => {
|
|
452
|
+
// The cheap path: a consumer whose users follow no Sites pays no lookup.
|
|
453
|
+
const { resolve, calls } = recordingSiteMap({});
|
|
454
|
+
const [m1, m2] = [oid(), oid()];
|
|
455
|
+
const [u1, u2] = [
|
|
456
|
+
user([{ deviceId: m1, deviceType: 'monitor' }]),
|
|
457
|
+
user([{ deviceId: m2, deviceType: 'monitor' }]),
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
const result = await resolveSubscribedMonitorIdsByUser([u1, u2], {
|
|
461
|
+
resolveVisibleSiteMonitors: resolve,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
expect(calls).toEqual([]);
|
|
465
|
+
expect(result.get(String(u1._id))).toEqual([m1]);
|
|
466
|
+
expect(result.get(String(u2._id))).toEqual([m2]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('issues exactly ONE resolver call for the whole cohort, deduped', async () => {
|
|
470
|
+
// Two users following the same Site must cost one entry, not two — this is
|
|
471
|
+
// the entire point of the batch form.
|
|
472
|
+
const [siteA, siteB] = [oid(), oid()];
|
|
473
|
+
const { resolve, calls } = recordingSiteMap({
|
|
474
|
+
[siteA]: [oid()],
|
|
475
|
+
[siteB]: [oid()],
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await resolveSubscribedMonitorIdsByUser(
|
|
479
|
+
[
|
|
480
|
+
user([{ deviceId: siteA, deviceType: 'linked-device' }]),
|
|
481
|
+
user([{ deviceId: siteA, deviceType: 'linked-device' }]),
|
|
482
|
+
user([{ deviceId: siteB, deviceType: 'linked-device' }]),
|
|
483
|
+
],
|
|
484
|
+
{ resolveVisibleSiteMonitors: resolve },
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
expect(calls).toHaveLength(1);
|
|
488
|
+
expect(calls[0].map(String).sort()).toEqual(
|
|
489
|
+
[String(siteA), String(siteB)].sort(),
|
|
490
|
+
);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test('each user gets only THEIR sites monitors, never the cohort union', async () => {
|
|
494
|
+
const [siteA, siteB] = [oid(), oid()];
|
|
495
|
+
const [a1, b1] = [oid(), oid()];
|
|
496
|
+
const { resolve } = recordingSiteMap({ [siteA]: [a1], [siteB]: [b1] });
|
|
497
|
+
const [ua, ub] = [
|
|
498
|
+
user([{ deviceId: siteA, deviceType: 'linked-device' }]),
|
|
499
|
+
user([{ deviceId: siteB, deviceType: 'linked-device' }]),
|
|
500
|
+
];
|
|
501
|
+
|
|
502
|
+
const result = await resolveSubscribedMonitorIdsByUser([ua, ub], {
|
|
503
|
+
resolveVisibleSiteMonitors: resolve,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
expect(result.get(String(ua._id))).toEqual([a1]);
|
|
507
|
+
expect(result.get(String(ub._id))).toEqual([b1]);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('dedupes a monitor followed both directly and through a Site', async () => {
|
|
511
|
+
const siteId = oid();
|
|
512
|
+
const shared = oid();
|
|
513
|
+
const { resolve } = recordingSiteMap({ [siteId]: [shared] });
|
|
514
|
+
const u = user([
|
|
515
|
+
{ deviceId: shared, deviceType: 'monitor' },
|
|
516
|
+
{ deviceId: siteId, deviceType: 'linked-device' },
|
|
517
|
+
]);
|
|
518
|
+
|
|
519
|
+
const result = await resolveSubscribedMonitorIdsByUser([u], {
|
|
520
|
+
resolveVisibleSiteMonitors: resolve,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
expect(result.get(String(u._id))).toEqual([shared]);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('a sample-site follower resolves to no monitors', async () => {
|
|
527
|
+
const u = user([{ deviceId: oid(), deviceType: 'sample-site' }]);
|
|
528
|
+
|
|
529
|
+
const result = await resolveSubscribedMonitorIdsByUser([u]);
|
|
530
|
+
|
|
531
|
+
expect(result.get(String(u._id))).toEqual([]);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test('a Site the resolver omits resolves to nothing, not an error', async () => {
|
|
535
|
+
// A followed Site that has since been hard-deleted. Their alerts stopping
|
|
536
|
+
// is the subscription-identity question, not a crash in the resolver.
|
|
537
|
+
const deletedSite = oid();
|
|
538
|
+
const { resolve } = recordingSiteMap({});
|
|
539
|
+
const u = user([{ deviceId: deletedSite, deviceType: 'linked-device' }]);
|
|
540
|
+
|
|
541
|
+
const result = await resolveSubscribedMonitorIdsByUser([u], {
|
|
542
|
+
resolveVisibleSiteMonitors: resolve,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
expect(result.get(String(u._id))).toEqual([]);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('normalises ObjectId keys in the returned Map', async () => {
|
|
549
|
+
// The silent-miss trap: `new Map([[site._id, ids]])` type-checks, looks
|
|
550
|
+
// correct, and misses every lookup because two equal ObjectIds are
|
|
551
|
+
// different object identities. Forgiven here rather than failing quietly.
|
|
552
|
+
const siteId = oid();
|
|
553
|
+
const monitorId = oid();
|
|
554
|
+
const u = user([{ deviceId: siteId, deviceType: 'linked-device' }]);
|
|
555
|
+
|
|
556
|
+
const result = await resolveSubscribedMonitorIdsByUser([u], {
|
|
557
|
+
resolveVisibleSiteMonitors: async () => new Map([[siteId, [monitorId]]]),
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
expect(result.get(String(u._id))).toEqual([monitorId]);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('tolerates a user with no subscribedDevices key at all', async () => {
|
|
564
|
+
const u = { _id: oid() };
|
|
565
|
+
|
|
566
|
+
const result = await resolveSubscribedMonitorIdsByUser([u]);
|
|
567
|
+
|
|
568
|
+
expect(result.get(String(u._id))).toEqual([]);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('an empty or nullish cohort resolves to an empty Map', async () => {
|
|
572
|
+
expect(await resolveSubscribedMonitorIdsByUser([])).toEqual(new Map());
|
|
573
|
+
expect(await resolveSubscribedMonitorIdsByUser(undefined)).toEqual(new Map());
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
describe('resolveSubscribedMonitorIdsByUser — rejects what would otherwise fail silently', () => {
|
|
578
|
+
test('throws MISSING_COHORT_SITE_RESOLVER when anyone follows a linked-device', async () => {
|
|
579
|
+
await expect(
|
|
580
|
+
resolveSubscribedMonitorIdsByUser([
|
|
581
|
+
user([{ deviceId: oid(), deviceType: 'linked-device' }]),
|
|
582
|
+
]),
|
|
583
|
+
).rejects.toMatchObject({
|
|
584
|
+
code: SUBSCRIBED_DEVICES_ERRORS.MISSING_COHORT_SITE_RESOLVER,
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('does NOT require a resolver when nobody follows a linked-device', async () => {
|
|
589
|
+
const monitorId = oid();
|
|
590
|
+
const u = user([{ deviceId: monitorId, deviceType: 'monitor' }]);
|
|
591
|
+
|
|
592
|
+
const result = await resolveSubscribedMonitorIdsByUser([u]);
|
|
593
|
+
|
|
594
|
+
expect(result.get(String(u._id))).toEqual([monitorId]);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test('throws COHORT_RESOLVER_RETURNED_NON_MAP for an array of entries', async () => {
|
|
598
|
+
// The natural mistake: returning `[[siteId, ids]]` instead of a Map.
|
|
599
|
+
const siteId = oid();
|
|
600
|
+
|
|
601
|
+
await expect(
|
|
602
|
+
resolveSubscribedMonitorIdsByUser(
|
|
603
|
+
[user([{ deviceId: siteId, deviceType: 'linked-device' }])],
|
|
604
|
+
{ resolveVisibleSiteMonitors: async () => [[String(siteId), [oid()]]] },
|
|
605
|
+
),
|
|
606
|
+
).rejects.toMatchObject({
|
|
607
|
+
code: SUBSCRIBED_DEVICES_ERRORS.COHORT_RESOLVER_RETURNED_NON_MAP,
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('a nullish resolver return is an empty audience, not a throw', async () => {
|
|
612
|
+
const u = user([{ deviceId: oid(), deviceType: 'linked-device' }]);
|
|
613
|
+
|
|
614
|
+
const result = await resolveSubscribedMonitorIdsByUser([u], {
|
|
615
|
+
resolveVisibleSiteMonitors: async () => null,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
expect(result.get(String(u._id))).toEqual([]);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test('inherits the per-value guard: Site documents in a value are rejected', async () => {
|
|
622
|
+
// Same failure projectSubscribedMonitors guards against, reached through
|
|
623
|
+
// the Map. Left unchecked each doc casts to its own _id and Site ids land
|
|
624
|
+
// in the alert audience.
|
|
625
|
+
const siteId = oid();
|
|
626
|
+
|
|
627
|
+
await expect(
|
|
628
|
+
resolveSubscribedMonitorIdsByUser(
|
|
629
|
+
[user([{ deviceId: siteId, deviceType: 'linked-device' }])],
|
|
630
|
+
{
|
|
631
|
+
resolveVisibleSiteMonitors: async () =>
|
|
632
|
+
new Map([[String(siteId), [{ _id: oid(), monitorIds: [] }]]]),
|
|
633
|
+
},
|
|
634
|
+
),
|
|
635
|
+
).rejects.toMatchObject({
|
|
636
|
+
code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS,
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('throws COHORT_USER_MISSING_ID rather than silently dropping the user', async () => {
|
|
641
|
+
// Skipping would remove a real person from an alert audience with no error
|
|
642
|
+
// anywhere — the exact failure mode this module exists to prevent.
|
|
643
|
+
await expect(
|
|
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]),
|
|
661
|
+
).rejects.toMatchObject({
|
|
662
|
+
code: SUBSCRIBED_DEVICES_ERRORS.COHORT_USER_MISSING_ID,
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
describe('VISIBLE_SITE_MONITOR_FILTER', () => {
|
|
668
|
+
test('privacy uses $ne, eligibility uses an exact match — deliberately', () => {
|
|
669
|
+
// The asymmetry is the point, and this test exists to stop it being
|
|
670
|
+
// "fixed". isPrivate is a privacy gate: $ne keeps a monitor that was never
|
|
671
|
+
// marked private, which its followers are entitled to see. isActive is an
|
|
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.
|
|
676
|
+
expect(VISIBLE_SITE_MONITOR_FILTER).toEqual({
|
|
677
|
+
isPrivate: { $ne: true },
|
|
678
|
+
isActive: true,
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test('is frozen all the way down — one consumer must not change it for the rest', () => {
|
|
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.
|
|
686
|
+
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
|
+
});
|
|
699
|
+
});
|
|
@@ -9,18 +9,23 @@
|
|
|
9
9
|
// deliberate contract change, not a test fix.
|
|
10
10
|
import {
|
|
11
11
|
projectSubscribedMonitors,
|
|
12
|
+
resolveSubscribedMonitorIdsByUser,
|
|
13
|
+
VISIBLE_SITE_MONITOR_FILTER,
|
|
12
14
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
13
15
|
} from '../subscribedDevices.js';
|
|
14
16
|
import {
|
|
15
17
|
DEVICE_TYPES,
|
|
16
18
|
DEVICE_TYPE_VALUES,
|
|
17
19
|
isAqiCapableDeviceType,
|
|
20
|
+
AQI_CAPABLE_DEVICE_TYPES,
|
|
18
21
|
} from '../../constants/deviceTypes.js';
|
|
19
22
|
|
|
20
23
|
test('barrel re-exports the subscribedDevices surface unchanged (identity, not copies)', async () => {
|
|
21
24
|
const barrel = await import('../../index.js');
|
|
22
25
|
|
|
23
26
|
expect(barrel.projectSubscribedMonitors).toBe(projectSubscribedMonitors);
|
|
27
|
+
expect(barrel.resolveSubscribedMonitorIdsByUser).toBe(resolveSubscribedMonitorIdsByUser);
|
|
28
|
+
expect(barrel.VISIBLE_SITE_MONITOR_FILTER).toBe(VISIBLE_SITE_MONITOR_FILTER);
|
|
24
29
|
expect(barrel.SUBSCRIBED_DEVICES_ERRORS).toBe(SUBSCRIBED_DEVICES_ERRORS);
|
|
25
30
|
});
|
|
26
31
|
|
|
@@ -35,4 +40,5 @@ test('barrel re-exports the device-type vocabulary unchanged (identity, not copi
|
|
|
35
40
|
expect(barrel.DEVICE_TYPES).toBe(DEVICE_TYPES);
|
|
36
41
|
expect(barrel.DEVICE_TYPE_VALUES).toBe(DEVICE_TYPE_VALUES);
|
|
37
42
|
expect(barrel.isAqiCapableDeviceType).toBe(isAqiCapableDeviceType);
|
|
43
|
+
expect(barrel.AQI_CAPABLE_DEVICE_TYPES).toBe(AQI_CAPABLE_DEVICE_TYPES);
|
|
38
44
|
});
|