@justair/justair-library 7.6.0-alpha.4ff5aae → 7.6.0-alpha.f80fd11

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