@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.
@@ -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,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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AA6JA;;;iBAEC;yBA7FwB,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;4BA4CT,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;sCAhDtF,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;mCA+D3D,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAsB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;yEAAnC,mCAAmC;2EAAnC,mCAAmC;2BAxFD,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;6BAOzE,4BAA4B;mCAA5B,4BAA4B;uCAA5B,4BAA4B;0CA8B5B,8BAA8B;0CAA9B,8BAA8B;0BAdX,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"}
@@ -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":"AAqFA;;;;;GAKG;AA4II,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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.5.0",
3
+ "version": "7.6.0-alpha.2ec581a",
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,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 { passwordResetTokenSchema, PasswordResetToken } from "./models/passwordResetToken.js";
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
- // subscribedDevices subscribedMonitors projection (JA-3110 / JA-3335).
276
- // Every consumer that writes a subscription recomputes the AQI audience with
277
- // this instead of re-implementing the per-device-type rule.
278
- // SUBSCRIBED_DEVICES_ERRORS is the stable `err.code` vocabulary for the
279
- // errors projectSubscribedMonitors throws; consumers branch on it rather
280
- // than on message wording.
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,
@@ -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
@@ -1,15 +1,6 @@
1
- // Subscribed devices (JA-3110 / JA-3335): the shared projection from
2
- // Users.subscribedDevices (source of truth, all three device types) to
3
- // Users.subscribedMonitors (monitor ids only, the AQI-alert audience).
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
- // Recompute-whole, not delta: callers pass the complete post-change
22
- // subscribedDevices array and overwrite subscribedMonitors with the result.
23
- // That makes unsubscribe fall out for free — a Site's monitors disappear from
24
- // the projection only when no other subscribed device still contributes them —
25
- // and makes the function idempotent, so a repair run over existing users is
26
- // safe to repeat.
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
- // PRECONDITION do not overwrite subscribedMonitors before the backfill
65
+ // PRIVACY IS THE CALLER'S JOB, AND IT IS NOT OPTIONAL
30
66
  // ---------------------------------------------------------------------------
31
- // Every user who existed before this field shipped has a populated
32
- // subscribedMonitors and an empty or absent subscribedDevices (pinned by the
33
- // "reads pre-backfill users cleanly" case in models/tests/users.test.js). For
34
- // such a user this function correctly returns a projection of *nothing they
35
- // have migrated yet* — so a consumer that overwrites subscribedMonitors with
36
- // it on the user's next subscribe silently drops every monitor they were
37
- // already subscribed to, and their AQI alerts stop with no error anywhere.
38
- //
39
- // Callers MUST therefore do one of these, per user, and the ordering is not
40
- // optional:
41
- // 1. Backfill subscribedDevices from subscribedMonitors first (one
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
- // STALENESS — this is a write-path projection only
80
+ // NAMING
71
81
  // ---------------------------------------------------------------------------
72
- // The result is only as current as the last time a caller ran it. Nothing here
73
- // watches Sites: if a Site gains or loses a monitor after a user subscribed to
74
- // it, that user's subscribedMonitors stays as it was and their AQI audience is
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
- // Returns the monitor ids that should be stored in Users.subscribedMonitors for
193
- // the given subscribedDevices array. Order is deterministic direct `monitor`
194
- // subscriptions in array order, then Site-derived ids in resolver order, first
195
- // occurrence wins but carries no meaning; treat the result as a set.
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
- // return sites.flatMap((site) => site.monitorIds ?? []);
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
- // Read the PRECONDITION and STALENESS notes at the top of this file before
223
- // writing the result to Users.subscribedMonitors.
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
  });