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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -107,6 +107,8 @@ 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
109
  import { projectSubscribedMonitors } from "./utils/subscribedDevices.js";
110
+ import { resolveMonitorIdsByUser } from "./utils/subscribedDevices.js";
111
+ import { VISIBLE_SITE_MONITOR_FILTER } from "./utils/subscribedDevices.js";
110
112
  import { SUBSCRIBED_DEVICES_ERRORS } from "./utils/subscribedDevices.js";
111
113
  import { splitName } from "./utils/splitName.js";
112
114
  import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
@@ -118,5 +120,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
118
120
  import { getZoneMinutes } from "./utils/quietHours.js";
119
121
  import { isInQuietWindow } from "./utils/quietHours.js";
120
122
  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 };
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 };
122
124
  //# 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":"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"}
@@ -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: {
12
+ $ne: boolean;
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 resolveMonitorIdsByUser(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":"AAmEA;;;;;;;;GASG;AAyBH;;;;;GAGG;AAiLI,8DANI,KAAK,CAAC;IAAE,QAAQ,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,CAAC;IAC1D,UAAU,EAAE,OAAO,6BAA6B,EAAE,UAAU,CAAA;CAAE,CAAC,8BACzD;IAAE,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,KAC7E,OAAO,CAAC,KAAK,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC,CAAA;CAAE,GAC9D,OAAO,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CA0BxD;AAkEM,gDAPI,KAAK,CAAC;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,iBAAiB,CAAC,EAAE,KAAK,CAAC;QAClD,QAAQ,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,CAAC;QAC5C,UAAU,EAAE,OAAO,6BAA6B,EAAE,UAAU,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,mCAC5D;IAAE,0BAA0B,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,KAClF,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAA;CAAE,GACnD,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CA2DrE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.5.0",
3
+ "version": "7.6.0-alpha.4ff5aae",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -102,6 +102,8 @@ import {
102
102
  import { isValidIANATimezone } from "./utils/timezone.js";
103
103
  import {
104
104
  projectSubscribedMonitors,
105
+ resolveMonitorIdsByUser,
106
+ VISIBLE_SITE_MONITOR_FILTER,
105
107
  SUBSCRIBED_DEVICES_ERRORS,
106
108
  } from "./utils/subscribedDevices.js";
107
109
 
@@ -272,13 +274,26 @@ export {
272
274
  DEVICE_TYPES,
273
275
  DEVICE_TYPE_VALUES,
274
276
  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.
277
+ // subscribedDevices → monitor ids, resolved at READ time (JA-3110 / JA-3335).
278
+ // Users.subscribedDevices is the source of truth; Users.subscribedMonitors is
279
+ // being retired rather than kept in step with it. Nothing should store this
280
+ // result see the module header for the five bugs a stored copy caused.
281
+ //
282
+ // resolveMonitorIdsByUser — the batch form. One Sites lookup for a whole
283
+ // cohort. What alert runs, org pages and session
284
+ // responses should call.
285
+ // projectSubscribedMonitors — the single-user rule the batch form is built
286
+ // on. Named after the field being retired; it
287
+ // gets renamed when that field is dropped.
288
+ // VISIBLE_SITE_MONITOR_FILTER — the privacy predicate every consumer must
289
+ // apply when turning a Site into monitor ids.
290
+ // Sites.monitorIds is unfiltered; reading it
291
+ // straight through leaks private monitors.
292
+ // SUBSCRIBED_DEVICES_ERRORS — stable `err.code` vocabulary; consumers
293
+ // branch on it, never on message wording.
281
294
  projectSubscribedMonitors,
295
+ resolveMonitorIdsByUser,
296
+ VISIBLE_SITE_MONITOR_FILTER,
282
297
  SUBSCRIBED_DEVICES_ERRORS,
283
298
  // Utilities
284
299
  splitName,
@@ -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,56 @@
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.
27
40
  //
28
41
  // ---------------------------------------------------------------------------
29
- // PRECONDITION do not overwrite subscribedMonitors before the backfill
42
+ // PRIVACY IS THE CALLER'S JOB, AND IT IS NOT OPTIONAL
30
43
  // ---------------------------------------------------------------------------
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.
44
+ // A Site's `monitorIds` is a raw membership list with no visibility filtering.
45
+ // Reading it straight through enrols a Site's followers in alerts for any
46
+ // private monitor it links a real leak, not a theoretical one, because the
47
+ // notification service filters monitors on `monitorState`/`isActive` and not on
48
+ // `isPrivate`.
49
+ //
50
+ // So the injected resolver must return only VISIBLE monitors, which is why it
51
+ // is named `resolveVisibleSiteMonitors` and why VISIBLE_SITE_MONITOR_FILTER
52
+ // exists below: three repos need the same predicate, and `$ne: true` (rather
53
+ // than `false`) is the half that is easy to get wrong on documents written
54
+ // before `isPrivate` existed.
68
55
  //
69
56
  // ---------------------------------------------------------------------------
70
- // STALENESS — this is a write-path projection only
57
+ // NAMING
71
58
  // ---------------------------------------------------------------------------
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.
59
+ // `projectSubscribedMonitors` is named after the field being retired. It is
60
+ // kept under that name while consumers migrate; renaming it to something that
61
+ // does not reference a dead field belongs in the step that drops the field.
80
62
 
81
63
  import { DEVICE_TYPES } from "../constants/deviceTypes.js";
82
64
 
@@ -88,6 +70,38 @@ export const SUBSCRIBED_DEVICES_ERRORS = Object.freeze({
88
70
  RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array",
89
71
  RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays",
90
72
  RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents",
73
+ // resolveMonitorIdsByUser only
74
+ MISSING_COHORT_SITE_RESOLVER: "missing_cohort_site_resolver",
75
+ COHORT_RESOLVER_RETURNED_NON_MAP: "cohort_resolver_returned_non_map",
76
+ COHORT_USER_MISSING_ID: "cohort_user_missing_id",
77
+ });
78
+
79
+ // The predicate that makes a Site's monitor visible to its followers. Frozen
80
+ // and exported because JustAirService, JustAirUserService and
81
+ // JustAirNotificationService all need the same one, and a fourth hand-written
82
+ // copy is how one of them quietly starts leaking private monitors.
83
+ //
84
+ // `isPrivate: { $ne: true }` rather than `{ $eq: false }`: the field has a
85
+ // schema default but that only governs writes made through mongoose, so
86
+ // documents written before it existed have no key at all and would vanish
87
+ // under an exact match. Same rule the public-map path uses.
88
+ //
89
+ // Spread it into the Monitors query the resolver is built from:
90
+ //
91
+ // const monitors = await Monitors.find(
92
+ // { _id: { $in: siteMonitorIds }, ...VISIBLE_SITE_MONITOR_FILTER },
93
+ // "_id",
94
+ // ).lean();
95
+ //
96
+ // `monitorState: "Deployed"` is deliberately NOT here. That is an
97
+ // alerting-eligibility rule, not a visibility rule, and it belongs to the
98
+ // notification service alone — the org admin page and the session response
99
+ // must still show a follower the monitors they follow while those monitors sit
100
+ // in a non-Deployed state. Do not consolidate the two filters; keep
101
+ // JustAirNotificationService's own `monitorState` predicate where it is.
102
+ export const VISIBLE_SITE_MONITOR_FILTER = Object.freeze({
103
+ isPrivate: { $ne: true },
104
+ isActive: true,
91
105
  });
92
106
 
93
107
  // Dedupe by string form, keep the caller's ObjectId instances. Two equal
@@ -189,6 +203,38 @@ const validateResolvedSiteMonitorIds = (resolved) => {
189
203
  return resolved;
190
204
  };
191
205
 
206
+ // The cohort resolver's return, checked the same way and for the same reason:
207
+ // it is consumer-supplied, and every bad shape below would otherwise resolve to
208
+ // an empty audience rather than an error — a user silently stops getting
209
+ // alerts, which is the one outcome this module exists to prevent.
210
+ //
211
+ // Keys are re-derived with String() rather than trusted. A consumer who builds
212
+ // `new Map([[site._id, ids]])` with raw ObjectId keys produces a Map that looks
213
+ // correct, passes every type check, and misses on every `.get(String(id))`
214
+ // because two equal ObjectIds are different object identities. Normalising is
215
+ // forgiving in precisely the place where strictness would fail silently.
216
+ const validateResolvedSiteMonitorMap = (resolved) => {
217
+ if (resolved === null || resolved === undefined) {
218
+ return new Map();
219
+ }
220
+
221
+ if (!(resolved instanceof Map)) {
222
+ const error = new TypeError(
223
+ "resolveVisibleSiteMonitors must resolve to a Map of site id to monitor ids"
224
+ );
225
+ error.code = SUBSCRIBED_DEVICES_ERRORS.COHORT_RESOLVER_RETURNED_NON_MAP;
226
+ throw error;
227
+ }
228
+
229
+ return new Map(
230
+ [...resolved.entries()].map(([siteId, monitorIds]) => [
231
+ String(siteId),
232
+ validateResolvedSiteMonitorIds(monitorIds),
233
+ ])
234
+ );
235
+ };
236
+
237
+
192
238
  // Returns the monitor ids that should be stored in Users.subscribedMonitors for
193
239
  // the given subscribedDevices array. Order is deterministic — direct `monitor`
194
240
  // subscriptions in array order, then Site-derived ids in resolver order, first
@@ -219,8 +265,13 @@ const validateResolvedSiteMonitorIds = (resolved) => {
219
265
  // Site has no array — and they are filtered out here rather than trusted to
220
266
  // the caller.
221
267
  //
222
- // Read the PRECONDITION and STALENESS notes at the top of this file before
223
- // writing the result to Users.subscribedMonitors.
268
+ // Do NOT store this result. Read the "THIS IS A READ-TIME RESOLUTION" note at
269
+ // the top of this file — a stored copy is where all five JA-3110 bugs came
270
+ // from. Its privacy obligation applies here too: the ids this returns are only
271
+ // as safe to alert on as the resolver that produced them.
272
+ //
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.
224
275
  /**
225
276
  * @param {Array<{ deviceId: import("mongoose").Types.ObjectId,
226
277
  * deviceType: import("../constants/deviceTypes.js").DeviceType }>} [subscribedDevices]
@@ -253,3 +304,126 @@ export const projectSubscribedMonitors = async (
253
304
  );
254
305
  return dedupeById([...direct, ...siteMonitorIds]);
255
306
  };
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Cohort-scoped resolution
310
+ // ---------------------------------------------------------------------------
311
+ // The batch form of the above, and the one every read-time consumer should
312
+ // reach for: an alert run, an org admin page, a session response. Resolves a
313
+ // whole list of users with ONE Sites lookup covering every Site anyone in the
314
+ // cohort follows, rather than one lookup per user.
315
+ //
316
+ // That distinction is the difference between a constant-cost alert run and an
317
+ // N+1 across the entire user base, which is the shape this codebase keeps
318
+ // re-learning. The lookup is not performed at all when nobody in the cohort
319
+ // follows a linked-device.
320
+ //
321
+ // resolveVisibleSiteMonitors(siteIds) -> Map<string siteId, ObjectId[]>
322
+ //
323
+ // The resolver is keyed per Site rather than returning a flat union, because
324
+ // each user needs their own Sites' monitors, not the cohort's. It must return
325
+ // only VISIBLE monitors — see the privacy note at the top of this file, and
326
+ // build it from VISIBLE_SITE_MONITOR_FILTER:
327
+ //
328
+ // const sites = await Sites.find(
329
+ // { _id: { $in: siteIds } }, "monitorIds",
330
+ // ).lean();
331
+ // const monitors = await Monitors.find(
332
+ // {
333
+ // _id: { $in: sites.flatMap((s) => s.monitorIds ?? []) },
334
+ // ...VISIBLE_SITE_MONITOR_FILTER,
335
+ // },
336
+ // "_id",
337
+ // ).lean();
338
+ // const visible = new Set(monitors.map((m) => String(m._id)));
339
+ // return new Map(sites.map((s) => [
340
+ // String(s._id),
341
+ // (s.monitorIds ?? []).filter((id) => visible.has(String(id))),
342
+ // ]));
343
+ //
344
+ // A Site id the resolver omits resolves to no monitors for that user rather
345
+ // than throwing — a followed Site that has since been deleted is an ordinary
346
+ // state, not an error. What that user is left holding (an entry that resolves
347
+ // to nothing, so their alerts stop) is the subscription-identity question
348
+ // tracked in TODOS, and is deliberately not papered over here.
349
+ //
350
+ // Users are keyed by `String(user._id)`, so callers look up with the same. A
351
+ // user with no `_id` throws rather than being skipped: skipping would silently
352
+ // remove a real person from an alert audience, which is the failure mode this
353
+ // whole module exists to avoid.
354
+ //
355
+ // 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:
357
+ //
358
+ // { "subscribedDevices.deviceType": { $in: AQI_CAPABLE_DEVICE_TYPES } }
359
+ //
360
+ // There is no index on `subscribedDevices.deviceType` (only on
361
+ // `subscribedDevices.deviceId`), so this narrows the documents returned rather
362
+ // than the documents scanned — still worth it on a cohort query that is already
363
+ // scanning, not worth building a JS-side filter for.
364
+ /**
365
+ * @param {Array<{ _id: any, subscribedDevices?: Array<{
366
+ * deviceId: import("mongoose").Types.ObjectId,
367
+ * deviceType: import("../constants/deviceTypes.js").DeviceType }> }>} [users]
368
+ * @param {{ resolveVisibleSiteMonitors?: (siteIds: import("mongoose").Types.ObjectId[]) =>
369
+ * Promise<Map<string, import("mongoose").Types.ObjectId[]>> }} [options]
370
+ * @returns {Promise<Map<string, import("mongoose").Types.ObjectId[]>>}
371
+ */
372
+ export const resolveMonitorIdsByUser = async (
373
+ users,
374
+ { resolveVisibleSiteMonitors } = {}
375
+ ) => {
376
+ const cohort = (users ?? []).filter(isPlainObject);
377
+
378
+ const missing = cohort.find(
379
+ (user) => user._id === null || user._id === undefined
380
+ );
381
+ if (missing) {
382
+ const error = new TypeError(
383
+ "resolveMonitorIdsByUser requires every user to carry an _id"
384
+ );
385
+ error.code = SUBSCRIBED_DEVICES_ERRORS.COHORT_USER_MISSING_ID;
386
+ throw error;
387
+ }
388
+
389
+ // Every Site anyone in the cohort follows, deduped across users — the point
390
+ // of the batch form. Two users following the same Site cost one entry.
391
+ const siteIds = dedupeById(
392
+ cohort.flatMap((user) =>
393
+ linkedSiteIds((user.subscribedDevices ?? []).filter(isPlainObject))
394
+ )
395
+ );
396
+
397
+ let monitorIdsBySite = new Map();
398
+ if (siteIds.length > 0) {
399
+ if (typeof resolveVisibleSiteMonitors !== "function") {
400
+ const error = new TypeError(
401
+ "resolveMonitorIdsByUser requires resolveVisibleSiteMonitors when anyone in the cohort follows a linked-device"
402
+ );
403
+ error.code = SUBSCRIBED_DEVICES_ERRORS.MISSING_COHORT_SITE_RESOLVER;
404
+ throw error;
405
+ }
406
+ monitorIdsBySite = validateResolvedSiteMonitorMap(
407
+ await resolveVisibleSiteMonitors(siteIds)
408
+ );
409
+ }
410
+
411
+ // Synchronous and Map-backed, so the per-user projections below issue no
412
+ // further lookups no matter how large the cohort is. Per-entry validation
413
+ // (arrays, not nested arrays, ids rather than documents) is inherited from
414
+ // projectSubscribedMonitors rather than duplicated.
415
+ const resolveSiteMonitorIds = (ids) =>
416
+ ids.flatMap((id) => monitorIdsBySite.get(String(id)) ?? []);
417
+
418
+ const resolved = await Promise.all(
419
+ cohort.map((user) =>
420
+ projectSubscribedMonitors(user.subscribedDevices, {
421
+ resolveSiteMonitorIds,
422
+ })
423
+ )
424
+ );
425
+
426
+ return new Map(
427
+ cohort.map((user, index) => [String(user._id), resolved[index]])
428
+ );
429
+ };
@@ -6,6 +6,8 @@ import {
6
6
  } from '../../constants/deviceTypes.js';
7
7
  import {
8
8
  projectSubscribedMonitors,
9
+ resolveMonitorIdsByUser,
10
+ VISIBLE_SITE_MONITOR_FILTER,
9
11
  SUBSCRIBED_DEVICES_ERRORS,
10
12
  } from '../subscribedDevices.js';
11
13
 
@@ -413,3 +415,251 @@ describe('isAqiCapableDeviceType agrees with the projection', () => {
413
415
  expect(projected.length > 0).toBe(isAqiCapableDeviceType(deviceType));
414
416
  });
415
417
  });
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // resolveMonitorIdsByUser — 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('resolveMonitorIdsByUser', () => {
443
+ test('keys the result by String(user._id)', async () => {
444
+ const u = user([]);
445
+
446
+ const result = await resolveMonitorIdsByUser([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 resolveMonitorIdsByUser([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 resolveMonitorIdsByUser(
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 resolveMonitorIdsByUser([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 resolveMonitorIdsByUser([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 resolveMonitorIdsByUser([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 resolveMonitorIdsByUser([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 resolveMonitorIdsByUser([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 resolveMonitorIdsByUser([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 resolveMonitorIdsByUser([])).toEqual(new Map());
573
+ expect(await resolveMonitorIdsByUser(undefined)).toEqual(new Map());
574
+ });
575
+ });
576
+
577
+ describe('resolveMonitorIdsByUser — rejects what would otherwise fail silently', () => {
578
+ test('throws MISSING_COHORT_SITE_RESOLVER when anyone follows a linked-device', async () => {
579
+ await expect(
580
+ resolveMonitorIdsByUser([
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 resolveMonitorIdsByUser([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
+ resolveMonitorIdsByUser(
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 resolveMonitorIdsByUser([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
+ resolveMonitorIdsByUser(
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
+ resolveMonitorIdsByUser([{ subscribedDevices: [] }]),
645
+ ).rejects.toMatchObject({
646
+ code: SUBSCRIBED_DEVICES_ERRORS.COHORT_USER_MISSING_ID,
647
+ });
648
+ });
649
+ });
650
+
651
+ 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.
656
+ expect(VISIBLE_SITE_MONITOR_FILTER).toEqual({
657
+ isPrivate: { $ne: true },
658
+ isActive: true,
659
+ });
660
+ });
661
+
662
+ test('is frozen — one consumer mutating it must not change it for the rest', () => {
663
+ expect(Object.isFrozen(VISIBLE_SITE_MONITOR_FILTER)).toBe(true);
664
+ });
665
+ });
@@ -9,6 +9,8 @@
9
9
  // deliberate contract change, not a test fix.
10
10
  import {
11
11
  projectSubscribedMonitors,
12
+ resolveMonitorIdsByUser,
13
+ VISIBLE_SITE_MONITOR_FILTER,
12
14
  SUBSCRIBED_DEVICES_ERRORS,
13
15
  } from '../subscribedDevices.js';
14
16
  import {
@@ -21,6 +23,8 @@ test('barrel re-exports the subscribedDevices surface unchanged (identity, not c
21
23
  const barrel = await import('../../index.js');
22
24
 
23
25
  expect(barrel.projectSubscribedMonitors).toBe(projectSubscribedMonitors);
26
+ expect(barrel.resolveMonitorIdsByUser).toBe(resolveMonitorIdsByUser);
27
+ expect(barrel.VISIBLE_SITE_MONITOR_FILTER).toBe(VISIBLE_SITE_MONITOR_FILTER);
24
28
  expect(barrel.SUBSCRIBED_DEVICES_ERRORS).toBe(SUBSCRIBED_DEVICES_ERRORS);
25
29
  });
26
30