@justair/justair-library 7.5.0-alpha.c25c5a2 → 7.5.0-alpha.d0806df

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,4 +5,10 @@ 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
+ /**
9
+ * The device-type vocabulary as a type, so consumers get autocomplete and a
10
+ * compile error on a mis-cased value like "sampleSite" — the exact bug the
11
+ * runtime comments in utils/subscribedDevices.js warn about at length.
12
+ */
13
+ export type DeviceType = (typeof DEVICE_TYPES)[keyof typeof DEVICE_TYPES];
8
14
  //# sourceMappingURL=deviceTypes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"deviceTypes.d.ts","sourceRoot":"","sources":["../../src/constants/deviceTypes.js"],"names":[],"mappings":"AA0BA;;;;GAIG;AAEH,0FAA6E;AAStE,iEAEoC"}
1
+ {"version":3,"file":"deviceTypes.d.ts","sourceRoot":"","sources":["../../src/constants/deviceTypes.js"],"names":[],"mappings":"AA0BA;;;;GAIG;AAEH,0FAA6E;AAiBtE,iEAEoC;;;;;;yBAZ9B,CAAA,OAAO,YAAY,EAAC,MAAM,OAAO,YAAY,CAAC"}
package/dist/index.d.ts CHANGED
@@ -107,6 +107,7 @@ 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 { SUBSCRIBED_DEVICES_ERRORS } from "./utils/subscribedDevices.js";
110
111
  import { splitName } from "./utils/splitName.js";
111
112
  import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
112
113
  import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
@@ -117,5 +118,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
117
118
  import { getZoneMinutes } from "./utils/quietHours.js";
118
119
  import { isInQuietWindow } from "./utils/quietHours.js";
119
120
  import { resolveQuietHours } from "./utils/quietHours.js";
120
- 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, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
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 };
121
122
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AA0JA;;;iBAEC;yBA1FwB,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;4BAyCT,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;sCA7CtF,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;mCA4D3D,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAsB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;yEAAnC,mCAAmC;2EAAnC,mCAAmC;2BArFD,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;6BAOzE,4BAA4B;mCAA5B,4BAA4B;uCAA5B,4BAA4B;0CA2BO,8BAA8B;0BAX9C,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":"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 +1 @@
1
- {"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/models/users.js"],"names":[],"mappings":"AAyFA,8BA6GE;AAiCF,wBAAmD;AA9NnD,iDAA4D;AAC5D,6CAAmD;AAUnD,4DAsBC"}
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,8 +1,13 @@
1
+ export const SUBSCRIBED_DEVICES_ERRORS: Readonly<{
2
+ MISSING_SITE_RESOLVER: "missing_site_resolver";
3
+ RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array";
4
+ RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays";
5
+ RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents";
6
+ }>;
1
7
  export function projectSubscribedMonitors(subscribedDevices?: Array<{
2
8
  deviceId: import("mongoose").Types.ObjectId;
3
- deviceType: string;
9
+ deviceType: import("../constants/deviceTypes.js").DeviceType;
4
10
  }>, { resolveSiteMonitorIds }?: {
5
11
  resolveSiteMonitorIds?: (siteIds: import("mongoose").Types.ObjectId[]) => Promise<Array<import("mongoose").Types.ObjectId | null | undefined>>;
6
12
  }): Promise<import("mongoose").Types.ObjectId[]>;
7
- export function hasAqiCapableSubscription(subscribedDevices: any): any;
8
13
  //# sourceMappingURL=subscribedDevices.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"subscribedDevices.d.ts","sourceRoot":"","sources":["../../src/utils/subscribedDevices.js"],"names":[],"mappings":"AA6JO,8DALI,KAAK,CAAC;IAAE,QAAQ,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,8BAC1E;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,CAsBxD;AAOM,uEAGuD"}
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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=subscribedDevicesBarrel.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscribedDevicesBarrel.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/subscribedDevicesBarrel.test.js"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.5.0-alpha.c25c5a2",
3
+ "version": "7.5.0-alpha.d0806df",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -32,6 +32,14 @@ export const DEVICE_TYPES = Object.freeze({
32
32
 
33
33
  export const DEVICE_TYPE_VALUES = Object.freeze(Object.values(DEVICE_TYPES));
34
34
 
35
+ /**
36
+ * The device-type vocabulary as a type, so consumers get autocomplete and a
37
+ * compile error on a mis-cased value like "sampleSite" — the exact bug the
38
+ * runtime comments in utils/subscribedDevices.js warn about at length.
39
+ *
40
+ * @typedef {typeof DEVICE_TYPES[keyof typeof DEVICE_TYPES]} DeviceType
41
+ */
42
+
35
43
  // Whether a device of this type can ever report AQI, and therefore whether a
36
44
  // subscription to it can contribute to AQI alert delivery.
37
45
  //
package/src/index.js CHANGED
@@ -100,7 +100,10 @@ import {
100
100
  resolveQuietHours,
101
101
  } from "./utils/quietHours.js";
102
102
  import { isValidIANATimezone } from "./utils/timezone.js";
103
- import { projectSubscribedMonitors } from "./utils/subscribedDevices.js";
103
+ import {
104
+ projectSubscribedMonitors,
105
+ SUBSCRIBED_DEVICES_ERRORS,
106
+ } from "./utils/subscribedDevices.js";
104
107
 
105
108
  // Sites related imports
106
109
  import { sitesSchema, Sites } from "./models/sites.js";
@@ -272,11 +275,11 @@ export {
272
275
  // subscribedDevices → subscribedMonitors projection (JA-3110 / JA-3335).
273
276
  // Every consumer that writes a subscription recomputes the AQI audience with
274
277
  // this instead of re-implementing the per-device-type rule.
275
- //
276
- // hasAqiCapableSubscription is deliberately NOT re-exported here: nothing
277
- // consumes it yet, and an export cannot be withdrawn without a major bump.
278
- // It stays module-internal until a consumer needs it.
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.
279
281
  projectSubscribedMonitors,
282
+ SUBSCRIBED_DEVICES_ERRORS,
280
283
  // Utilities
281
284
  splitName,
282
285
  // Quiet hours (JA-3054): shared validation + window evaluation
@@ -103,30 +103,17 @@ const usersSchema = mongoose.Schema(
103
103
  // Subscribed devices (JA-3110 / JA-3335) — the source of truth for what the
104
104
  // user has added to "My Monitors", across all three device types.
105
105
  //
106
- // Relationship to subscribedMonitors above: that array stays monitor ids
107
- // ONLY, and is the AQI-alert projection of this one. The two must be
108
- // written together on every subscribe/unsubscribe path. What each device
109
- // type contributes to the projection is specified once, in the header of
110
- // src/utils/subscribedDevices.js — read it there, not from a summary here.
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.
111
112
  //
112
- // Do not re-implement that rule per consumer: recompute the projection with
113
- // `projectSubscribedMonitors` from src/utils/subscribedDevices.js (exported
114
- // from src/index.js). It is deliberately not a schema validator — a
115
- // projection is a write-path computation and needs a Site lookup, which the
116
- // schema has no business doing.
117
- //
118
- // Writing subscribedMonitors has a hard precondition (a blanket overwrite
119
- // drops the subscriptions of any user who predates this field) and a known
120
- // staleness gap. Both are specified once, in the PRECONDITION and STALENESS
121
- // notes at the top of src/utils/subscribedDevices.js — read them there
122
- // rather than trusting a summary here.
123
- //
124
- // Why the split matters: JustAirNotificationService reads subscribedMonitors
125
- // and drops ids that do not resolve to a Monitor (statusAlerts.js
126
- // `.filter(monitor => monitor)`). Writing a Site or SampleSite id in there
127
- // therefore throws no error — it silently delivers no alerts. Keeping the
128
- // polymorphic ids in their own field preserves that invariant for every
129
- // library consumer.
113
+ // 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.
130
117
  subscribedDevices: [subscribedDeviceSchema],
131
118
  currentAlertMode: {
132
119
  type: String,
@@ -42,11 +42,20 @@
42
42
  // { deviceId, deviceType: "monitor" } entry per existing id), then
43
43
  // overwrite freely. This is the target state.
44
44
  // 2. Until that backfill has run for the user, union the projection with the
45
- // existing subscribedMonitors instead of overwriting. THIS IS
46
- // SUBSCRIBE-ONLY. A union can only ever add ids, so using it for an
47
- // unsubscribe makes the removal a silent no-op. There is deliberately no
48
- // third option: an unsubscribe on a user who predates subscribedDevices
49
- // MUST run the per-user backfill in option 1 first, and only then
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
50
59
  // overwrite. Removing a subscription is not correct any other way.
51
60
  //
52
61
  // This module deliberately does NOT offer a `union: true` flag. A blanket
@@ -69,10 +78,17 @@
69
78
  // affected subscribers. That reprojection is not implemented anywhere yet and
70
79
  // needs a follow-up ticket under JA-3110.
71
80
 
72
- import {
73
- DEVICE_TYPES,
74
- isAqiCapableDeviceType,
75
- } from "../constants/deviceTypes.js";
81
+ import { DEVICE_TYPES } from "../constants/deviceTypes.js";
82
+
83
+ // Stable error codes thrown by this module. Consumers filter on `err.code`
84
+ // — never on message wording, which is free to change. Same contract as
85
+ // QUIET_HOURS_ERRORS in utils/quietHours.js.
86
+ export const SUBSCRIBED_DEVICES_ERRORS = Object.freeze({
87
+ MISSING_SITE_RESOLVER: "missing_site_resolver",
88
+ RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array",
89
+ RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays",
90
+ RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents",
91
+ });
76
92
 
77
93
  // Dedupe by string form, keep the caller's ObjectId instances. Two equal
78
94
  // ObjectIds are different object identities, so Set/includes on the raw values
@@ -119,6 +135,60 @@ const linkedSiteIds = (entries) =>
119
135
  .filter((entry) => entry.deviceType === DEVICE_TYPES.LINKED_DEVICE)
120
136
  .map((entry) => entry.deviceId);
121
137
 
138
+ // The resolver is consumer-supplied, so its return is the one input this
139
+ // module cannot check at the type boundary. Validate it rather than trust it:
140
+ // both bad shapes below would otherwise corrupt subscribedMonitors silently,
141
+ // and silent is the failure mode this whole field split exists to avoid.
142
+ // Nullish stays tolerated — a consumer whose Sites resolved to nothing may
143
+ // legitimately return nothing.
144
+ const validateResolvedSiteMonitorIds = (resolved) => {
145
+ if (resolved === null || resolved === undefined) {
146
+ return [];
147
+ }
148
+
149
+ if (!Array.isArray(resolved)) {
150
+ const error = new TypeError(
151
+ "resolveSiteMonitorIds must resolve to an array of monitor ids"
152
+ );
153
+ error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NON_ARRAY;
154
+ throw error;
155
+ }
156
+
157
+ // `.map` instead of `.flatMap` yields [[m1, m2]]. An array is not a plain
158
+ // object, so the document check below never sees it, and it fails
159
+ // inconsistently downstream: one monitor per Site casts back to the id and
160
+ // silently works, two throws a CastError at write time, far from the cause.
161
+ if (resolved.some(Array.isArray)) {
162
+ const error = new TypeError(
163
+ "resolveSiteMonitorIds must resolve to a flat array of monitor ids"
164
+ );
165
+ error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NESTED_ARRAYS;
166
+ throw error;
167
+ }
168
+
169
+ // Discriminating a document from an id without importing mongoose: test for
170
+ // what only an id has. `toHexString` is on ObjectId (mongoose-patched or raw
171
+ // bson) and on nothing a Sites query returns, hydrated or lean.
172
+ //
173
+ // Deliberately not keyed on `_id`, which is wrong in both directions: every
174
+ // ObjectId appears to carry one (mongoose defines `_id` on ObjectId.prototype
175
+ // as a self-reference), and a lean read that projects it away
176
+ // (`Sites.find(..., "monitorIds -_id").lean()`) carries none.
177
+ if (
178
+ resolved.some(
179
+ (id) => isPlainObject(id) && typeof id.toHexString !== "function"
180
+ )
181
+ ) {
182
+ const error = new TypeError(
183
+ "resolveSiteMonitorIds must resolve to monitor ids, not Site documents"
184
+ );
185
+ error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS;
186
+ throw error;
187
+ }
188
+
189
+ return resolved;
190
+ };
191
+
122
192
  // Returns the monitor ids that should be stored in Users.subscribedMonitors for
123
193
  // the given subscribedDevices array. Order is deterministic — direct `monitor`
124
194
  // subscriptions in array order, then Site-derived ids in resolver order, first
@@ -136,10 +206,12 @@ const linkedSiteIds = (entries) =>
136
206
  // const sites = await Sites.find({ _id: { $in: siteIds } }, "monitorIds").lean();
137
207
  // return sites.flatMap((site) => site.monitorIds ?? []);
138
208
  //
139
- // Returning the documents is the easy mistake and it fails silently: dedupeById
140
- // only drops nullish, so each Site doc survives, mongoose casts it to its own
141
- // _id on write, and subscribedMonitors fills with Site ids — exactly the
142
- // polymorphic id this field split exists to keep out.
209
+ // Returning the documents is the easy mistake, so it is rejected rather than
210
+ // merely documented: the return is validated and a document-shaped entry
211
+ // throws RESOLVER_RETURNED_DOCUMENTS, as a non-array return throws
212
+ // RESOLVER_RETURNED_NON_ARRAY. Left unchecked it would fail silently — each
213
+ // Site doc casts to its own _id on write and subscribedMonitors fills with
214
+ // Site ids, exactly the polymorphic id this field split exists to keep out.
143
215
  //
144
216
  // It is not called at all when nothing is subscribed to a linked-device, so a
145
217
  // consumer that only handles monitors pays no query. A resolver may return
@@ -150,7 +222,8 @@ const linkedSiteIds = (entries) =>
150
222
  // Read the PRECONDITION and STALENESS notes at the top of this file before
151
223
  // writing the result to Users.subscribedMonitors.
152
224
  /**
153
- * @param {Array<{ deviceId: import("mongoose").Types.ObjectId, deviceType: string }>} [subscribedDevices]
225
+ * @param {Array<{ deviceId: import("mongoose").Types.ObjectId,
226
+ * deviceType: import("../constants/deviceTypes.js").DeviceType }>} [subscribedDevices]
154
227
  * @param {{ resolveSiteMonitorIds?: (siteIds: import("mongoose").Types.ObjectId[]) =>
155
228
  * Promise<Array<import("mongoose").Types.ObjectId | null | undefined>> }} [options]
156
229
  * @returns {Promise<import("mongoose").Types.ObjectId[]>}
@@ -168,21 +241,15 @@ export const projectSubscribedMonitors = async (
168
241
  }
169
242
 
170
243
  if (typeof resolveSiteMonitorIds !== "function") {
171
- throw new TypeError(
244
+ const error = new TypeError(
172
245
  "projectSubscribedMonitors requires resolveSiteMonitorIds when a linked-device is subscribed"
173
246
  );
247
+ error.code = SUBSCRIBED_DEVICES_ERRORS.MISSING_SITE_RESOLVER;
248
+ throw error;
174
249
  }
175
250
 
176
- const siteMonitorIds = (await resolveSiteMonitorIds(siteIds)) ?? [];
251
+ const siteMonitorIds = validateResolvedSiteMonitorIds(
252
+ await resolveSiteMonitorIds(siteIds)
253
+ );
177
254
  return dedupeById([...direct, ...siteMonitorIds]);
178
255
  };
179
-
180
- // Whether a subscribedDevices array can produce any AQI audience at all,
181
- // without resolving Sites. `false` is certain (nothing subscribed can ever
182
- // alert); `true` only means a linked-device is present and its Site still has
183
- // to be checked — the same necessary-but-not-sufficient asymmetry as
184
- // isAqiCapableDeviceType.
185
- export const hasAqiCapableSubscription = (subscribedDevices) =>
186
- (subscribedDevices ?? [])
187
- .filter(isPlainObject)
188
- .some((entry) => isAqiCapableDeviceType(entry.deviceType));
@@ -1,7 +1,12 @@
1
1
  import mongoose from 'mongoose';
2
+ import { Users } from '../../models/users.js';
3
+ import {
4
+ DEVICE_TYPE_VALUES,
5
+ isAqiCapableDeviceType,
6
+ } from '../../constants/deviceTypes.js';
2
7
  import {
3
8
  projectSubscribedMonitors,
4
- hasAqiCapableSubscription,
9
+ SUBSCRIBED_DEVICES_ERRORS,
5
10
  } from '../subscribedDevices.js';
6
11
 
7
12
  const oid = () => new mongoose.Types.ObjectId();
@@ -111,9 +116,104 @@ describe('projectSubscribedMonitors', () => {
111
116
  test('throws when a linked-device is subscribed and no resolver is injected', async () => {
112
117
  // Loud on the one input it genuinely cannot project alone, rather than
113
118
  // silently returning a short audience and dropping real alerts.
119
+ // The `code` is the contract consumers branch on; the message is not.
114
120
  await expect(
115
121
  projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }]),
116
- ).rejects.toThrow(/resolveSiteMonitorIds/);
122
+ ).rejects.toMatchObject({
123
+ code: SUBSCRIBED_DEVICES_ERRORS.MISSING_SITE_RESOLVER,
124
+ });
125
+ });
126
+
127
+ test('rejects a resolver that returns Site documents rather than monitor ids', async () => {
128
+ // The silent-corruption case: without this guard each doc survives
129
+ // dedupeById and casts to its own _id on write, filling subscribedMonitors
130
+ // with Site ids that the notification service then quietly drops.
131
+ const siteId = oid();
132
+ await expect(
133
+ projectSubscribedMonitors([{ deviceId: siteId, deviceType: 'linked-device' }], {
134
+ resolveSiteMonitorIds: async () => [{ _id: siteId, monitorIds: [oid()] }],
135
+ }),
136
+ ).rejects.toMatchObject({
137
+ code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS,
138
+ });
139
+ });
140
+
141
+ test('rejects a resolver that returns nested arrays (.map instead of .flatMap)', async () => {
142
+ const siteId = oid();
143
+ await expect(
144
+ projectSubscribedMonitors([{ deviceId: siteId, deviceType: 'linked-device' }], {
145
+ resolveSiteMonitorIds: async () => [[oid(), oid()]],
146
+ }),
147
+ ).rejects.toMatchObject({
148
+ code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NESTED_ARRAYS,
149
+ });
150
+ });
151
+
152
+ test('rejects a lean Site read that projected _id away', async () => {
153
+ // The `_id`-based discriminator missed this: `Sites.find(..., "monitorIds
154
+ // -_id").lean()` yields objects carrying no _id at all, which would
155
+ // otherwise key as "[object Object]" in dedupeById and land in
156
+ // subscribedMonitors as junk.
157
+ await expect(
158
+ projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }], {
159
+ resolveSiteMonitorIds: async () => [{ monitorIds: [oid()] }],
160
+ }),
161
+ ).rejects.toMatchObject({
162
+ code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS,
163
+ });
164
+ });
165
+
166
+ test('rejects a hydrated Site document (the forgot-.lean() case)', async () => {
167
+ // The likeliest real-world shape of this mistake. A throwaway model keeps
168
+ // this file off the real Sites schema; hydration needs no connection.
169
+ const HydratedSite = mongoose.model(
170
+ 'SubscribedDevicesProbeSite',
171
+ new mongoose.Schema({ monitorIds: [mongoose.Schema.Types.ObjectId] }),
172
+ );
173
+
174
+ await expect(
175
+ projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }], {
176
+ resolveSiteMonitorIds: async () => [new HydratedSite({ monitorIds: [oid()] })],
177
+ }),
178
+ ).rejects.toMatchObject({
179
+ code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS,
180
+ });
181
+ });
182
+
183
+ test('does not mistake ordinary ObjectIds for documents', async () => {
184
+ // Regression guard for the discriminator: an id has `toHexString`, a Site
185
+ // document does not. Keying on `_id` instead gets it wrong both ways —
186
+ // mongoose defines `_id` on ObjectId.prototype as a self-reference, so a
187
+ // naive "has _id" check rejects every legitimate resolver result.
188
+ const siteId = oid();
189
+ const monitorId = oid();
190
+
191
+ expect(monitorId._id).toBe(monitorId);
192
+ await expect(
193
+ projectSubscribedMonitors([{ deviceId: siteId, deviceType: 'linked-device' }], {
194
+ resolveSiteMonitorIds: async () => [monitorId],
195
+ }),
196
+ ).resolves.toEqual([monitorId]);
197
+ });
198
+
199
+ test('rejects a resolver that does not return an array', async () => {
200
+ await expect(
201
+ projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }], {
202
+ resolveSiteMonitorIds: async () => oid(),
203
+ }),
204
+ ).rejects.toMatchObject({
205
+ code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NON_ARRAY,
206
+ });
207
+ });
208
+
209
+ test('still tolerates a resolver that returns nothing', async () => {
210
+ // Distinct from the two rejections above: resolving to nothing is a real
211
+ // state (a sample-only Site links no monitors), not a consumer mistake.
212
+ await expect(
213
+ projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }], {
214
+ resolveSiteMonitorIds: async () => undefined,
215
+ }),
216
+ ).resolves.toEqual([]);
117
217
  });
118
218
 
119
219
  test('dedupes a monitor reached both directly and through a Site', async () => {
@@ -268,39 +368,48 @@ describe('projectSubscribedMonitors', () => {
268
368
  });
269
369
  });
270
370
 
271
- // Pins the export-contract decision, not behaviour: src/index.js is the public
272
- // surface of this package and an export there cannot be withdrawn without a
273
- // major bump. projectSubscribedMonitors is the sanctioned consumer entry point;
274
- // hasAqiCapableSubscription has no caller yet, so it stays module-internal
275
- // until one exists. Flipping either line is a deliberate contract change.
276
- describe('barrel surface', () => {
277
- test('exports projectSubscribedMonitors and withholds hasAqiCapableSubscription', async () => {
278
- const barrel = await import('../../index.js');
279
-
280
- expect(barrel.projectSubscribedMonitors).toBe(projectSubscribedMonitors);
281
- expect(barrel.hasAqiCapableSubscription).toBeUndefined();
282
- });
283
- });
371
+ // Two encodings of "which device types can produce AQI" exist: the predicate
372
+ // isAqiCapableDeviceType, and the selectors inside projectSubscribedMonitors.
373
+ // Nothing structural keeps them in step, so a fourth type added to one and not
374
+ // the other would report as AQI-capable while contributing no audience — the
375
+ // silent failure this module exists to prevent. This pins them together.
376
+ // Every other case here passes plain object literals, but the real call site
377
+ // hands in user.subscribedDevices — a mongoose DocumentArray of subdocuments,
378
+ // which has to survive the isPlainObject filter. If it ever stopped doing so
379
+ // every entry would be dropped and the projection would return an empty
380
+ // audience with no error. Hydration needs no connection, so this stays DB-free.
381
+ describe('accepts real mongoose subdocuments, not just object literals', () => {
382
+ test('projects a hydrated user.subscribedDevices the same as plain objects', async () => {
383
+ const monitorId = oid();
384
+ const siteId = oid();
385
+ const siteMonitorId = oid();
284
386
 
285
- describe('hasAqiCapableSubscription', () => {
286
- test('false when only sample-sites are subscribed', () => {
287
- expect(
288
- hasAqiCapableSubscription([{ deviceId: oid(), deviceType: 'sample-site' }]),
289
- ).toBe(false);
290
- });
387
+ const user = new Users({
388
+ subscribedDevices: [
389
+ { deviceId: monitorId, deviceType: 'monitor' },
390
+ { deviceId: siteId, deviceType: 'linked-device' },
391
+ { deviceId: oid(), deviceType: 'sample-site' },
392
+ ],
393
+ });
291
394
 
292
- test('true for a monitor, and for a linked-device pending its Site check', () => {
293
- expect(
294
- hasAqiCapableSubscription([{ deviceId: oid(), deviceType: 'monitor' }]),
295
- ).toBe(true);
296
- expect(
297
- hasAqiCapableSubscription([{ deviceId: oid(), deviceType: 'linked-device' }]),
298
- ).toBe(true);
395
+ await expect(
396
+ projectSubscribedMonitors(user.subscribedDevices, {
397
+ resolveSiteMonitorIds: async () => [siteMonitorId],
398
+ }),
399
+ ).resolves.toEqual([monitorId, siteMonitorId]);
299
400
  });
401
+ });
402
+
403
+ describe('isAqiCapableDeviceType agrees with the projection', () => {
404
+ test.each(DEVICE_TYPE_VALUES)('%s', async (deviceType) => {
405
+ const deviceId = oid();
406
+ const projected = await projectSubscribedMonitors(
407
+ [{ deviceId, deviceType }],
408
+ // Resolves as though the id were a Site linking exactly one monitor, so
409
+ // a linked-device yields an audience and a sample-site still yields none.
410
+ { resolveSiteMonitorIds: async () => [oid()] },
411
+ );
300
412
 
301
- test('false for a missing, empty, or unrecognised array', () => {
302
- expect(hasAqiCapableSubscription(undefined)).toBe(false);
303
- expect(hasAqiCapableSubscription([])).toBe(false);
304
- expect(hasAqiCapableSubscription([{ deviceType: 'Monitor' }])).toBe(false);
413
+ expect(projected.length > 0).toBe(isAqiCapableDeviceType(deviceType));
305
414
  });
306
415
  });
@@ -0,0 +1,38 @@
1
+ // Pins the public export surface for the subscribedDevices module, kept in its
2
+ // own file because asserting on the root barrel imports src/index.js, which
3
+ // registers every mongoose model. Mixing that into the pure unit tests would
4
+ // make them fail whenever any unrelated model failed to load. Same shape as
5
+ // constants/tests/constantsBarrel.test.js.
6
+ //
7
+ // src/index.js is the published surface of this package, so an export here
8
+ // cannot be withdrawn without a major bump. Flipping either assertion is a
9
+ // deliberate contract change, not a test fix.
10
+ import {
11
+ projectSubscribedMonitors,
12
+ SUBSCRIBED_DEVICES_ERRORS,
13
+ } from '../subscribedDevices.js';
14
+ import {
15
+ DEVICE_TYPES,
16
+ DEVICE_TYPE_VALUES,
17
+ isAqiCapableDeviceType,
18
+ } from '../../constants/deviceTypes.js';
19
+
20
+ test('barrel re-exports the subscribedDevices surface unchanged (identity, not copies)', async () => {
21
+ const barrel = await import('../../index.js');
22
+
23
+ expect(barrel.projectSubscribedMonitors).toBe(projectSubscribedMonitors);
24
+ expect(barrel.SUBSCRIBED_DEVICES_ERRORS).toBe(SUBSCRIBED_DEVICES_ERRORS);
25
+ });
26
+
27
+ // constants/tests/deviceTypes.test.js pins these on the `./constants` barrel;
28
+ // the root barrel re-exports them through a separate hand-written import and
29
+ // export pair, so a dropped line there publishes cleanly and only fails at
30
+ // runtime inside a consuming service. Pinned here rather than there because
31
+ // this is the file that already carries the root-barrel dependency.
32
+ test('barrel re-exports the device-type vocabulary unchanged (identity, not copies)', async () => {
33
+ const barrel = await import('../../index.js');
34
+
35
+ expect(barrel.DEVICE_TYPES).toBe(DEVICE_TYPES);
36
+ expect(barrel.DEVICE_TYPE_VALUES).toBe(DEVICE_TYPE_VALUES);
37
+ expect(barrel.isAqiCapableDeviceType).toBe(isAqiCapableDeviceType);
38
+ });