@justair/justair-library 7.4.0 → 7.5.0-alpha.45d4756

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.
@@ -0,0 +1,8 @@
1
+ export const DEVICE_TYPES: Readonly<{
2
+ MONITOR: "monitor";
3
+ SAMPLE_SITE: "sample-site";
4
+ LINKED_DEVICE: "linked-device";
5
+ }>;
6
+ export const DEVICE_TYPE_VALUES: readonly ("monitor" | "sample-site" | "linked-device")[];
7
+ export function isAqiCapableDeviceType(deviceType: any): boolean;
8
+ //# sourceMappingURL=deviceTypes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deviceTypes.d.ts","sourceRoot":"","sources":["../../src/constants/deviceTypes.js"],"names":[],"mappings":"AA0BA;;;;GAIG;AAEH,0FAA6E;AAStE,iEAEoC"}
@@ -1,6 +1,7 @@
1
1
  export * from "./pollutants.js";
2
2
  export * from "./alertLevels.js";
3
3
  export * from "./userNotifications.js";
4
+ export * from "./deviceTypes.js";
4
5
  export * from "./timezones.js";
5
6
  export * from "./dataDownloads.js";
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=deviceTypes.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deviceTypes.test.d.ts","sourceRoot":"","sources":["../../../src/constants/tests/deviceTypes.test.js"],"names":[],"mappings":""}
package/dist/index.d.ts CHANGED
@@ -103,6 +103,10 @@ import { PARAMETERS } from "./constants/pollutants.js";
103
103
  import { HEAVY_METALS } from "./constants/pollutants.js";
104
104
  import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
105
105
  import { getAlertWeight } from "./constants/alertLevels.js";
106
+ import { DEVICE_TYPES } from "./constants/deviceTypes.js";
107
+ import { DEVICE_TYPE_VALUES } from "./constants/deviceTypes.js";
108
+ import { isAqiCapableDeviceType } from "./constants/deviceTypes.js";
109
+ import { projectSubscribedMonitors } from "./utils/subscribedDevices.js";
106
110
  import { splitName } from "./utils/splitName.js";
107
111
  import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
108
112
  import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
@@ -113,5 +117,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
113
117
  import { getZoneMinutes } from "./utils/quietHours.js";
114
118
  import { isInQuietWindow } from "./utils/quietHours.js";
115
119
  import { resolveQuietHours } from "./utils/quietHours.js";
116
- 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, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
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 };
117
121
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAoJA;;;iBAEC;yBApFwB,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;4BAmCT,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;sCA5CtF,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;oCAfF,8BAA8B;mCAsD3D,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAsB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;yEAAnC,mCAAmC;2EAAnC,mCAAmC;2BA/ED,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;0BAkBtD,sBAAsB;oCASzC,uBAAuB;iCA1BG,0BAA0B;mCA0BpD,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":"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,8 +1,8 @@
1
1
  export const monitorEpisodesSchema: mongoose.Schema<any, mongoose.Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
2
2
  timestamps: true;
3
3
  }, {
4
- user: mongoose.Types.ObjectId;
5
4
  monitor: mongoose.Types.ObjectId;
5
+ user: mongoose.Types.ObjectId;
6
6
  open: boolean;
7
7
  startedAt: NativeDate;
8
8
  alertCount: number;
@@ -10,8 +10,8 @@ export const monitorEpisodesSchema: mongoose.Schema<any, mongoose.Model<any, any
10
10
  endedAt?: NativeDate;
11
11
  peakStatus?: string;
12
12
  } & mongoose.DefaultTimestampProps, mongoose.Document<unknown, {}, {
13
- user: mongoose.Types.ObjectId;
14
13
  monitor: mongoose.Types.ObjectId;
14
+ user: mongoose.Types.ObjectId;
15
15
  open: boolean;
16
16
  startedAt: NativeDate;
17
17
  alertCount: number;
@@ -23,8 +23,8 @@ export const monitorEpisodesSchema: mongoose.Schema<any, mongoose.Model<any, any
23
23
  }, Omit<mongoose.DefaultSchemaOptions, "timestamps"> & {
24
24
  timestamps: true;
25
25
  }> & Omit<{
26
- user: mongoose.Types.ObjectId;
27
26
  monitor: mongoose.Types.ObjectId;
27
+ user: mongoose.Types.ObjectId;
28
28
  open: boolean;
29
29
  startedAt: NativeDate;
30
30
  alertCount: number;
@@ -38,8 +38,8 @@ export const monitorEpisodesSchema: mongoose.Schema<any, mongoose.Model<any, any
38
38
  }, "id"> & mongoose.HydratedDocumentOverrides<{
39
39
  id: string;
40
40
  }>, unknown, {
41
- user: mongoose.Types.ObjectId;
42
41
  monitor: mongoose.Types.ObjectId;
42
+ user: mongoose.Types.ObjectId;
43
43
  open: boolean;
44
44
  startedAt: NativeDate;
45
45
  alertCount: number;
@@ -54,8 +54,8 @@ export const monitorEpisodesSchema: mongoose.Schema<any, mongoose.Model<any, any
54
54
  __v: number;
55
55
  }>;
56
56
  export const MonitorEpisode: mongoose.Model<{
57
- user: mongoose.Types.ObjectId;
58
57
  monitor: mongoose.Types.ObjectId;
58
+ user: mongoose.Types.ObjectId;
59
59
  open: boolean;
60
60
  startedAt: NativeDate;
61
61
  alertCount: number;
@@ -65,8 +65,8 @@ export const MonitorEpisode: mongoose.Model<{
65
65
  } & mongoose.DefaultTimestampProps, {}, {}, {
66
66
  id: string;
67
67
  }, mongoose.Document<unknown, {}, {
68
- user: mongoose.Types.ObjectId;
69
68
  monitor: mongoose.Types.ObjectId;
69
+ user: mongoose.Types.ObjectId;
70
70
  open: boolean;
71
71
  startedAt: NativeDate;
72
72
  alertCount: number;
@@ -78,8 +78,8 @@ export const MonitorEpisode: mongoose.Model<{
78
78
  }, {
79
79
  timestamps: true;
80
80
  }> & Omit<{
81
- user: mongoose.Types.ObjectId;
82
81
  monitor: mongoose.Types.ObjectId;
82
+ user: mongoose.Types.ObjectId;
83
83
  open: boolean;
84
84
  startedAt: NativeDate;
85
85
  alertCount: number;
@@ -95,8 +95,8 @@ export const MonitorEpisode: mongoose.Model<{
95
95
  }>, mongoose.Schema<any, mongoose.Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
96
96
  timestamps: true;
97
97
  }, {
98
- user: mongoose.Types.ObjectId;
99
98
  monitor: mongoose.Types.ObjectId;
99
+ user: mongoose.Types.ObjectId;
100
100
  open: boolean;
101
101
  startedAt: NativeDate;
102
102
  alertCount: number;
@@ -104,8 +104,8 @@ export const MonitorEpisode: mongoose.Model<{
104
104
  endedAt?: NativeDate;
105
105
  peakStatus?: string;
106
106
  } & mongoose.DefaultTimestampProps, mongoose.Document<unknown, {}, {
107
- user: mongoose.Types.ObjectId;
108
107
  monitor: mongoose.Types.ObjectId;
108
+ user: mongoose.Types.ObjectId;
109
109
  open: boolean;
110
110
  startedAt: NativeDate;
111
111
  alertCount: number;
@@ -117,8 +117,8 @@ export const MonitorEpisode: mongoose.Model<{
117
117
  }, Omit<mongoose.DefaultSchemaOptions, "timestamps"> & {
118
118
  timestamps: true;
119
119
  }> & Omit<{
120
- user: mongoose.Types.ObjectId;
121
120
  monitor: mongoose.Types.ObjectId;
121
+ user: mongoose.Types.ObjectId;
122
122
  open: boolean;
123
123
  startedAt: NativeDate;
124
124
  alertCount: number;
@@ -132,8 +132,8 @@ export const MonitorEpisode: mongoose.Model<{
132
132
  }, "id"> & mongoose.HydratedDocumentOverrides<{
133
133
  id: string;
134
134
  }>, unknown, {
135
- user: mongoose.Types.ObjectId;
136
135
  monitor: mongoose.Types.ObjectId;
136
+ user: mongoose.Types.ObjectId;
137
137
  open: boolean;
138
138
  startedAt: NativeDate;
139
139
  alertCount: number;
@@ -147,8 +147,8 @@ export const MonitorEpisode: mongoose.Model<{
147
147
  } & {
148
148
  __v: number;
149
149
  }>, {
150
- user: mongoose.Types.ObjectId;
151
150
  monitor: mongoose.Types.ObjectId;
151
+ user: mongoose.Types.ObjectId;
152
152
  open: boolean;
153
153
  startedAt: NativeDate;
154
154
  alertCount: number;
@@ -1 +1 @@
1
- {"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/models/users.js"],"names":[],"mappings":"AA2CA,8BAiFE;AA8BF,wBAAmD;AAlJnD,iDAA4D;AAC5D,6CAAmD;AAUnD,4DAsBC"}
1
+ {"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/models/users.js"],"names":[],"mappings":"AAkFA,8BA6GE;AAiCF,wBAAmD;AAvNnD,iDAA4D;AAC5D,6CAAmD;AAUnD,4DAsBC"}
@@ -0,0 +1,3 @@
1
+ export function projectSubscribedMonitors(subscribedDevices: any, { resolveSiteMonitorIds }?: {}): Promise<any>;
2
+ export function hasAqiCapableSubscription(subscribedDevices: any): any;
3
+ //# sourceMappingURL=subscribedDevices.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscribedDevices.d.ts","sourceRoot":"","sources":["../../src/utils/subscribedDevices.js"],"names":[],"mappings":"AAyIO,gHAoBN;AAOM,uEAGuD"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=subscribedDevices.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscribedDevices.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/subscribedDevices.test.js"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.4.0",
3
+ "version": "7.5.0-alpha.45d4756",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,44 @@
1
+ // V2 device-type vocabulary — single source of truth (JA-3110 / JA-3335).
2
+ //
3
+ // Every device a user can see or subscribe to is exactly one of three things,
4
+ // each backed by a different collection:
5
+ // monitor — a standalone Monitors doc (siteId: null). Reports live AQI.
6
+ // sample-site — a standalone SampleSites doc (siteId: null). Lab/filter
7
+ // sample data only, no live AQI, so it can never trigger an
8
+ // AQI alert.
9
+ // linked-device — a Sites doc joining one or more Monitors and/or one
10
+ // SampleSite at a single physical location. Reports live AQI
11
+ // when it links at least one monitor.
12
+ //
13
+ // Consumers: JustAirService fetchDevices/getDeviceById stamp these strings onto
14
+ // every device response; JustAirUserService validates the subscribe body
15
+ // against them and persists them in Users.subscribedDevices; JustAir-Client
16
+ // passes back verbatim the value it received. Keep the string values
17
+ // byte-identical to what the devices API emits — the FE does no mapping, and a
18
+ // casing drift here is a silent 404 on subscribe.
19
+ //
20
+ // DO NOT rename these values: they are persisted in Users.subscribedDevices.
21
+
22
+ // Frozen because every importer in a process gets this same instance — one
23
+ // consumer reassigning a key, or calling .push()/.sort() on the values array,
24
+ // would silently change the vocabulary (and the Users.subscribedDevices enum
25
+ // derived from it) for every other consumer in that process. Matches
26
+ // DATA_DOWNLOAD_TYPES in dataDownloads.js.
27
+ export const DEVICE_TYPES = Object.freeze({
28
+ MONITOR: "monitor",
29
+ SAMPLE_SITE: "sample-site",
30
+ LINKED_DEVICE: "linked-device",
31
+ });
32
+
33
+ export const DEVICE_TYPE_VALUES = Object.freeze(Object.values(DEVICE_TYPES));
34
+
35
+ // Whether a device of this type can ever report AQI, and therefore whether a
36
+ // subscription to it can contribute to AQI alert delivery.
37
+ //
38
+ // Note the asymmetry: `sample-site` is unconditionally false, but a true result
39
+ // for `linked-device` is necessary and NOT sufficient — a Site is only actually
40
+ // AQI-capable when it links at least one monitor, which the type alone cannot
41
+ // tell you. Callers must still check the Site's monitorIds.
42
+ export const isAqiCapableDeviceType = (deviceType) =>
43
+ deviceType === DEVICE_TYPES.MONITOR ||
44
+ deviceType === DEVICE_TYPES.LINKED_DEVICE;
@@ -10,5 +10,6 @@
10
10
  export * from "./pollutants.js";
11
11
  export * from "./alertLevels.js";
12
12
  export * from "./userNotifications.js";
13
+ export * from "./deviceTypes.js";
13
14
  export * from "./timezones.js";
14
15
  export * from "./dataDownloads.js";
@@ -0,0 +1,53 @@
1
+ // Pins the device-type vocabulary shared across JustAirService (which stamps
2
+ // deviceType onto every device response), JustAirUserService (which validates
3
+ // the subscribe body and persists it), and JustAir-Client (which passes it
4
+ // back verbatim). A casing drift in any value is a silent subscribe failure,
5
+ // so the exact strings are asserted rather than derived.
6
+ import * as barrel from '../index.js';
7
+ import {
8
+ DEVICE_TYPES,
9
+ DEVICE_TYPE_VALUES,
10
+ isAqiCapableDeviceType,
11
+ } from '../deviceTypes.js';
12
+
13
+ test('barrel exports the device-type constants', () => {
14
+ expect(barrel.DEVICE_TYPES).toBe(DEVICE_TYPES);
15
+ expect(barrel.DEVICE_TYPE_VALUES).toBe(DEVICE_TYPE_VALUES);
16
+ expect(barrel.isAqiCapableDeviceType).toBe(isAqiCapableDeviceType);
17
+ });
18
+
19
+ test('DEVICE_TYPES values match the strings the devices API emits', () => {
20
+ expect(DEVICE_TYPES).toEqual({
21
+ MONITOR: 'monitor',
22
+ SAMPLE_SITE: 'sample-site',
23
+ LINKED_DEVICE: 'linked-device',
24
+ });
25
+ });
26
+
27
+ test('DEVICE_TYPE_VALUES covers every type exactly once', () => {
28
+ expect(DEVICE_TYPE_VALUES).toEqual(['monitor', 'sample-site', 'linked-device']);
29
+ expect(new Set(DEVICE_TYPE_VALUES).size).toBe(DEVICE_TYPE_VALUES.length);
30
+ });
31
+
32
+ test('the vocabulary is frozen so no consumer can mutate it for the others', () => {
33
+ expect(Object.isFrozen(DEVICE_TYPES)).toBe(true);
34
+ expect(Object.isFrozen(DEVICE_TYPE_VALUES)).toBe(true);
35
+ });
36
+
37
+ describe('isAqiCapableDeviceType', () => {
38
+ test('monitor and linked-device can report AQI', () => {
39
+ expect(isAqiCapableDeviceType('monitor')).toBe(true);
40
+ expect(isAqiCapableDeviceType('linked-device')).toBe(true);
41
+ });
42
+
43
+ test('sample-site never reports AQI, so it never alerts', () => {
44
+ expect(isAqiCapableDeviceType('sample-site')).toBe(false);
45
+ });
46
+
47
+ test('unknown and missing types are not AQI-capable', () => {
48
+ expect(isAqiCapableDeviceType('sampleSite')).toBe(false);
49
+ expect(isAqiCapableDeviceType(undefined)).toBe(false);
50
+ expect(isAqiCapableDeviceType(null)).toBe(false);
51
+ expect(isAqiCapableDeviceType('')).toBe(false);
52
+ });
53
+ });
package/src/index.js CHANGED
@@ -69,6 +69,11 @@ import { PARAMETERS, HEAVY_METALS } from "./constants/pollutants.js";
69
69
  import { ALERT_LEVEL_WEIGHTS, getAlertWeight } from "./constants/alertLevels.js";
70
70
  import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
71
71
  import { DATA_DOWNLOAD_TYPES } from "./constants/dataDownloads.js";
72
+ import {
73
+ DEVICE_TYPES,
74
+ DEVICE_TYPE_VALUES,
75
+ isAqiCapableDeviceType,
76
+ } from "./constants/deviceTypes.js";
72
77
  import {
73
78
  monitorEpisodesSchema,
74
79
  MonitorEpisode,
@@ -95,6 +100,7 @@ import {
95
100
  resolveQuietHours,
96
101
  } from "./utils/quietHours.js";
97
102
  import { isValidIANATimezone } from "./utils/timezone.js";
103
+ import { projectSubscribedMonitors } from "./utils/subscribedDevices.js";
98
104
 
99
105
  // Sites related imports
100
106
  import { sitesSchema, Sites } from "./models/sites.js";
@@ -259,6 +265,18 @@ export {
259
265
  // AQI alert-level hierarchy (single source of truth)
260
266
  ALERT_LEVEL_WEIGHTS,
261
267
  getAlertWeight,
268
+ // V2 device-type vocabulary (single source of truth) — JA-3110 / JA-3335
269
+ DEVICE_TYPES,
270
+ DEVICE_TYPE_VALUES,
271
+ isAqiCapableDeviceType,
272
+ // subscribedDevices → subscribedMonitors projection (JA-3110 / JA-3335).
273
+ // Every consumer that writes a subscription recomputes the AQI audience with
274
+ // 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.
279
+ projectSubscribedMonitors,
262
280
  // Utilities
263
281
  splitName,
264
282
  // Quiet hours (JA-3054): shared validation + window evaluation
@@ -5,6 +5,7 @@ import {
5
5
  validateQuietHours,
6
6
  QUIET_HOURS_ERRORS,
7
7
  } from "../utils/quietHours.js";
8
+ import { DEVICE_TYPE_VALUES } from "../constants/deviceTypes.js";
8
9
 
9
10
  const COOKIE_CONSENT_CATEGORIES = ["analytics_performance"];
10
11
  const COOKIE_CONSENT_VALUES = ["accept", "reject"];
@@ -41,6 +42,44 @@ const validateCookieConsent = (consent) => {
41
42
  return null;
42
43
  };
43
44
 
45
+ // One entry in Users.subscribedDevices (JA-3110 / JA-3335). deviceId points
46
+ // into a different collection per deviceType (Monitors / SampleSites / Sites),
47
+ // so there is deliberately no `ref` to declare here.
48
+ //
49
+ // `_id: false` is load-bearing, not cosmetic: it makes the pair itself the
50
+ // identity of an element, so `$addToSet` dedupes and re-subscribing is
51
+ // idempotent. With per-subdocument _ids every push would cast to a brand-new
52
+ // object, `$addToSet` would never match, and the array would accumulate a
53
+ // duplicate on every click of the subscribe button.
54
+ //
55
+ // Note what that dedupe is keyed on: the whole PAIR, not the deviceId. One id
56
+ // stored once as `monitor` and once as `linked-device` is two distinct entries
57
+ // and both survive. A given deviceId must therefore never carry two types —
58
+ // projectSubscribedMonitors would read the `monitor` row as a monitor id and
59
+ // write a Site id into subscribedMonitors, which is precisely the polymorphic
60
+ // id the subscribedMonitors/subscribedDevices split exists to keep out, and it
61
+ // fails silently (the notification service just filters it away). Consumers on
62
+ // the subscribe path should `$pull { deviceId }` before `$addToSet` so a
63
+ // re-subscribe with a corrected type replaces the row instead of adding one.
64
+ const subscribedDeviceSchema = mongoose.Schema(
65
+ {
66
+ deviceId: { type: mongoose.Types.ObjectId, required: true },
67
+ // `enum` and `required` are document validators: they run on
68
+ // create()/save(), NOT on the `$addToSet` query update the subscribe
69
+ // path uses — `runValidators` defaults to false there. So an
70
+ // updateOne can persist `sampleSite`, `Monitor`, or any other string
71
+ // with no error.
72
+ //
73
+ // This fails silently and loudly nowhere: a bad-cased value makes
74
+ // isAqiCapableDeviceType() return false, so a user who subscribed to a
75
+ // monitor just never receives AQI alerts. Consumers on the subscribe
76
+ // path MUST check the request body against DEVICE_TYPE_VALUES before
77
+ // writing (and/or pass `{ runValidators: true }` to the update).
78
+ deviceType: { type: String, enum: DEVICE_TYPE_VALUES, required: true },
79
+ },
80
+ { _id: false }
81
+ );
82
+
44
83
  const usersSchema = mongoose.Schema(
45
84
  {
46
85
  name: String,
@@ -54,6 +93,34 @@ const usersSchema = mongoose.Schema(
54
93
  alertPhone: String,
55
94
  alertCount: { type: Number, default: 0 },
56
95
  subscribedMonitors: [{ type: mongoose.Types.ObjectId, ref: "Monitors" }],
96
+ // Subscribed devices (JA-3110 / JA-3335) — the source of truth for what the
97
+ // user has added to "My Monitors", across all three device types.
98
+ //
99
+ // Relationship to subscribedMonitors above: that array stays monitor ids
100
+ // ONLY, and is the AQI-alert projection of this one. A subscribed
101
+ // linked-device contributes the monitorIds of its Site to it; a subscribed
102
+ // sample-site contributes nothing, having no live AQI. The two must be
103
+ // written together on every subscribe/unsubscribe path.
104
+ //
105
+ // Do not re-implement that rule per consumer: recompute the projection with
106
+ // `projectSubscribedMonitors` from src/utils/subscribedDevices.js (exported
107
+ // from src/index.js). It is deliberately not a schema validator — a
108
+ // projection is a write-path computation and needs a Site lookup, which the
109
+ // schema has no business doing.
110
+ //
111
+ // Writing subscribedMonitors has a hard precondition (a blanket overwrite
112
+ // drops the subscriptions of any user who predates this field) and a known
113
+ // staleness gap. Both are specified once, in the PRECONDITION and STALENESS
114
+ // notes at the top of src/utils/subscribedDevices.js — read them there
115
+ // rather than trusting a summary here.
116
+ //
117
+ // Why the split matters: JustAirNotificationService reads subscribedMonitors
118
+ // and drops ids that do not resolve to a Monitor (statusAlerts.js
119
+ // `.filter(monitor => monitor)`). Writing a Site or SampleSite id in there
120
+ // therefore throws no error — it silently delivers no alerts. Keeping the
121
+ // polymorphic ids in their own field preserves that invariant for every
122
+ // library consumer.
123
+ subscribedDevices: [subscribedDeviceSchema],
57
124
  currentAlertMode: {
58
125
  type: String,
59
126
  enum: [
@@ -151,6 +218,9 @@ usersSchema.pre("validate", function () {
151
218
  });
152
219
 
153
220
  usersSchema.index({ phone: 1 });
221
+ // Supports the "is this user subscribed to device X?" lookup behind the V2
222
+ // device-card subscribed state and the My Monitors map filter.
223
+ usersSchema.index({ "subscribedDevices.deviceId": 1 });
154
224
 
155
225
  const Users = mongoose.model("Users", usersSchema);
156
226
 
@@ -0,0 +1,168 @@
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":
13
+ //
14
+ // monitor → contributes its own deviceId
15
+ // linked-device → contributes the monitorIds of its Site (none if the Site
16
+ // links only a SampleSite, which is the sample-only case
17
+ // JA-3110 adds)
18
+ // sample-site → contributes nothing; no live AQI, so it can never trigger
19
+ // an AQI alert
20
+ //
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.
27
+ //
28
+ // ---------------------------------------------------------------------------
29
+ // PRECONDITION — do not overwrite subscribedMonitors before the backfill
30
+ // ---------------------------------------------------------------------------
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.
46
+ //
47
+ // This module deliberately does NOT offer a `union: true` flag. A blanket
48
+ // union is correct for subscribe and silently wrong for unsubscribe — it would
49
+ // make removal a no-op — so the choice belongs at the call site, which knows
50
+ // which operation it is performing. The backfill itself is consumer-side
51
+ // (JustAirUserService owns the write path) and is not shipped in this library;
52
+ // it needs its own ticket under JA-3110 before any consumer moves to
53
+ // overwrite-always.
54
+ //
55
+ // ---------------------------------------------------------------------------
56
+ // STALENESS — this is a write-path projection only
57
+ // ---------------------------------------------------------------------------
58
+ // The result is only as current as the last time a caller ran it. Nothing here
59
+ // watches Sites: if a Site gains or loses a monitor after a user subscribed to
60
+ // it, that user's subscribedMonitors stays as it was and their AQI audience is
61
+ // wrong — a monitor added to a Site never alerts its existing subscribers, and
62
+ // a removed one keeps alerting them, in both cases silently. Any code that
63
+ // mutates Sites.monitorIds is therefore responsible for reprojecting the
64
+ // affected subscribers. That reprojection is not implemented anywhere yet and
65
+ // needs a follow-up ticket under JA-3110.
66
+
67
+ import {
68
+ DEVICE_TYPES,
69
+ isAqiCapableDeviceType,
70
+ } from "../constants/deviceTypes.js";
71
+
72
+ // Dedupe by string form, keep the caller's ObjectId instances. Two equal
73
+ // ObjectIds are different object identities, so Set/includes on the raw values
74
+ // would let a monitor through twice — once from a direct `monitor`
75
+ // subscription and again from a Site that links it.
76
+ //
77
+ // Nullish ids are dropped rather than deduped. `Sites.monitorIds` is declared
78
+ // `default: undefined` (models/sites.js), so a sample-only Site has no array at
79
+ // all and the documented resolver shape can hand back `[undefined]`. Without
80
+ // this guard `String(undefined)` becomes the key "undefined" and a null lands
81
+ // in subscribedMonitors — exactly the polymorphic junk the field split exists
82
+ // to keep out.
83
+ const dedupeById = (ids) => {
84
+ const seen = new Set();
85
+ return ids.filter((id) => {
86
+ if (id === null || id === undefined) {
87
+ return false;
88
+ }
89
+ const key = String(id);
90
+ if (seen.has(key)) {
91
+ return false;
92
+ }
93
+ seen.add(key);
94
+ return true;
95
+ });
96
+ };
97
+
98
+ const isPlainObject = (value) =>
99
+ typeof value === "object" && value !== null && !Array.isArray(value);
100
+
101
+ // Both selectors match on an exact DEVICE_TYPES value, so an entry whose
102
+ // deviceType is outside the vocabulary contributes nothing. That is deliberate
103
+ // rather than an error: `$addToSet` skips document validators (see the comment
104
+ // on subscribedDeviceSchema in models/users.js), so a mis-cased `Monitor` can
105
+ // already be persisted. Projecting it as "no AQI" keeps this function total,
106
+ // and matches isAqiCapableDeviceType's answer for the same value.
107
+ const directMonitorIds = (entries) =>
108
+ entries
109
+ .filter((entry) => entry.deviceType === DEVICE_TYPES.MONITOR)
110
+ .map((entry) => entry.deviceId);
111
+
112
+ const linkedSiteIds = (entries) =>
113
+ entries
114
+ .filter((entry) => entry.deviceType === DEVICE_TYPES.LINKED_DEVICE)
115
+ .map((entry) => entry.deviceId);
116
+
117
+ // Returns the monitor ids that should be stored in Users.subscribedMonitors for
118
+ // the given subscribedDevices array. Order is deterministic — direct `monitor`
119
+ // subscriptions in array order, then Site-derived ids in resolver order, first
120
+ // occurrence wins — but carries no meaning; treat the result as a set.
121
+ //
122
+ // `subscribedDevices` tolerates undefined/null: a user row written before this
123
+ // field existed has no key at all on a `.lean()` read (pinned in
124
+ // models/tests/users.test.js), and a projection over "no subscriptions" is an
125
+ // empty audience, not a crash.
126
+ //
127
+ // `resolveSiteMonitorIds(siteIds)` is called at most once, with every
128
+ // linked-device id at a time, and must resolve to the flat union of those
129
+ // Sites' monitorIds (`Sites.find({ _id: { $in: siteIds } }, "monitorIds")`).
130
+ // It is not called at all when nothing is subscribed to a linked-device, so a
131
+ // consumer that only handles monitors pays no query. A resolver may return
132
+ // nullish entries — Sites.monitorIds is `default: undefined`, so a sample-only
133
+ // Site has no array — and they are filtered out here rather than trusted to
134
+ // the caller.
135
+ //
136
+ // Read the PRECONDITION and STALENESS notes at the top of this file before
137
+ // writing the result to Users.subscribedMonitors.
138
+ export const projectSubscribedMonitors = async (
139
+ subscribedDevices,
140
+ { resolveSiteMonitorIds } = {}
141
+ ) => {
142
+ const entries = (subscribedDevices ?? []).filter(isPlainObject);
143
+ const direct = directMonitorIds(entries);
144
+ const siteIds = dedupeById(linkedSiteIds(entries));
145
+
146
+ if (siteIds.length === 0) {
147
+ return dedupeById(direct);
148
+ }
149
+
150
+ if (typeof resolveSiteMonitorIds !== "function") {
151
+ throw new TypeError(
152
+ "projectSubscribedMonitors requires resolveSiteMonitorIds when a linked-device is subscribed"
153
+ );
154
+ }
155
+
156
+ const siteMonitorIds = (await resolveSiteMonitorIds(siteIds)) ?? [];
157
+ return dedupeById([...direct, ...siteMonitorIds]);
158
+ };
159
+
160
+ // Whether a subscribedDevices array can produce any AQI audience at all,
161
+ // without resolving Sites. `false` is certain (nothing subscribed can ever
162
+ // alert); `true` only means a linked-device is present and its Site still has
163
+ // to be checked — the same necessary-but-not-sufficient asymmetry as
164
+ // isAqiCapableDeviceType.
165
+ export const hasAqiCapableSubscription = (subscribedDevices) =>
166
+ (subscribedDevices ?? [])
167
+ .filter(isPlainObject)
168
+ .some((entry) => isAqiCapableDeviceType(entry.deviceType));
@@ -0,0 +1,306 @@
1
+ import mongoose from 'mongoose';
2
+ import {
3
+ projectSubscribedMonitors,
4
+ hasAqiCapableSubscription,
5
+ } from '../subscribedDevices.js';
6
+
7
+ const oid = () => new mongoose.Types.ObjectId();
8
+
9
+ // A resolver that records what it was asked for, so the tests can assert the
10
+ // call shape (one batched call, or none at all) and not just the result.
11
+ const recordingResolver = (byId) => {
12
+ const calls = [];
13
+ const resolve = async (siteIds) => {
14
+ calls.push(siteIds);
15
+ return siteIds.flatMap((id) => byId[String(id)] ?? []);
16
+ };
17
+ return { resolve, calls };
18
+ };
19
+
20
+ describe('projectSubscribedMonitors', () => {
21
+ test('a monitor subscription contributes its own id', async () => {
22
+ const monitorId = oid();
23
+
24
+ const result = await projectSubscribedMonitors([
25
+ { deviceId: monitorId, deviceType: 'monitor' },
26
+ ]);
27
+
28
+ expect(result).toEqual([monitorId]);
29
+ });
30
+
31
+ test('a sample-site subscription contributes nothing', async () => {
32
+ // The JA-3110 "Site with Sample" case: subscribable, never AQI-alerting.
33
+ const result = await projectSubscribedMonitors([
34
+ { deviceId: oid(), deviceType: 'sample-site' },
35
+ ]);
36
+
37
+ expect(result).toEqual([]);
38
+ });
39
+
40
+ test('a linked-device contributes the monitorIds of its Site', async () => {
41
+ const siteId = oid();
42
+ const [m1, m2] = [oid(), oid()];
43
+ const { resolve, calls } = recordingResolver({ [siteId]: [m1, m2] });
44
+
45
+ const result = await projectSubscribedMonitors(
46
+ [{ deviceId: siteId, deviceType: 'linked-device' }],
47
+ { resolveSiteMonitorIds: resolve },
48
+ );
49
+
50
+ expect(result).toEqual([m1, m2]);
51
+ expect(calls).toEqual([[siteId]]);
52
+ });
53
+
54
+ test('a linked-device whose Site links no monitor contributes nothing', async () => {
55
+ // Sample-only Site — the new JA-3110 case reached through a Site rather
56
+ // than a standalone SampleSite. Subscribing must not add an AQI audience.
57
+ const siteId = oid();
58
+ const { resolve } = recordingResolver({});
59
+
60
+ const result = await projectSubscribedMonitors(
61
+ [{ deviceId: siteId, deviceType: 'linked-device' }],
62
+ { resolveSiteMonitorIds: resolve },
63
+ );
64
+
65
+ expect(result).toEqual([]);
66
+ });
67
+
68
+ test('resolves every linked-device in one batched call', async () => {
69
+ const [siteA, siteB] = [oid(), oid()];
70
+ const [m1, m2] = [oid(), oid()];
71
+ const { resolve, calls } = recordingResolver({
72
+ [siteA]: [m1],
73
+ [siteB]: [m2],
74
+ });
75
+
76
+ const result = await projectSubscribedMonitors(
77
+ [
78
+ { deviceId: siteA, deviceType: 'linked-device' },
79
+ { deviceId: siteB, deviceType: 'linked-device' },
80
+ ],
81
+ { resolveSiteMonitorIds: resolve },
82
+ );
83
+
84
+ expect(result).toEqual([m1, m2]);
85
+ expect(calls).toHaveLength(1);
86
+ expect(calls[0]).toEqual([siteA, siteB]);
87
+ });
88
+
89
+ test('does not call the resolver when no linked-device is subscribed', async () => {
90
+ const { resolve, calls } = recordingResolver({});
91
+
92
+ await projectSubscribedMonitors(
93
+ [
94
+ { deviceId: oid(), deviceType: 'monitor' },
95
+ { deviceId: oid(), deviceType: 'sample-site' },
96
+ ],
97
+ { resolveSiteMonitorIds: resolve },
98
+ );
99
+
100
+ expect(calls).toEqual([]);
101
+ });
102
+
103
+ test('omits the resolver entirely when nothing needs it', async () => {
104
+ const monitorId = oid();
105
+
106
+ await expect(
107
+ projectSubscribedMonitors([{ deviceId: monitorId, deviceType: 'monitor' }]),
108
+ ).resolves.toEqual([monitorId]);
109
+ });
110
+
111
+ test('throws when a linked-device is subscribed and no resolver is injected', async () => {
112
+ // Loud on the one input it genuinely cannot project alone, rather than
113
+ // silently returning a short audience and dropping real alerts.
114
+ await expect(
115
+ projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }]),
116
+ ).rejects.toThrow(/resolveSiteMonitorIds/);
117
+ });
118
+
119
+ test('dedupes a monitor reached both directly and through a Site', async () => {
120
+ const monitorId = oid();
121
+ const siteId = oid();
122
+ const { resolve } = recordingResolver({ [siteId]: [monitorId] });
123
+
124
+ const result = await projectSubscribedMonitors(
125
+ [
126
+ { deviceId: monitorId, deviceType: 'monitor' },
127
+ { deviceId: siteId, deviceType: 'linked-device' },
128
+ ],
129
+ { resolveSiteMonitorIds: resolve },
130
+ );
131
+
132
+ // Equal ObjectIds are distinct object identities, so this is the case a
133
+ // plain Set over the raw values would miss.
134
+ expect(result).toEqual([monitorId]);
135
+ });
136
+
137
+ test('dedupes repeated site ids before resolving them', async () => {
138
+ const siteId = oid();
139
+ const monitorId = oid();
140
+ const { resolve, calls } = recordingResolver({ [siteId]: [monitorId] });
141
+
142
+ const result = await projectSubscribedMonitors(
143
+ [
144
+ { deviceId: siteId, deviceType: 'linked-device' },
145
+ { deviceId: siteId, deviceType: 'linked-device' },
146
+ ],
147
+ { resolveSiteMonitorIds: resolve },
148
+ );
149
+
150
+ expect(calls[0]).toEqual([siteId]);
151
+ expect(result).toEqual([monitorId]);
152
+ });
153
+
154
+ test('drops nullish ids a resolver hands back for a sample-only Site', async () => {
155
+ // Sites.monitorIds is `default: undefined` (models/sites.js), so the
156
+ // documented resolver shape (Sites.find(..., 'monitorIds') + flatMap)
157
+ // returns [undefined] for a sample-only Site. Left in, that writes a null
158
+ // into subscribedMonitors — the polymorphic junk the field split prevents.
159
+ const siteId = new mongoose.Types.ObjectId();
160
+ const monitorId = new mongoose.Types.ObjectId();
161
+
162
+ const result = await projectSubscribedMonitors(
163
+ [{ deviceId: siteId, deviceType: 'linked-device' }],
164
+ { resolveSiteMonitorIds: async () => [undefined, monitorId, null] },
165
+ );
166
+
167
+ expect(result).toEqual([monitorId]);
168
+ });
169
+
170
+ test('returns an empty audience when every resolved id is nullish', async () => {
171
+ const result = await projectSubscribedMonitors(
172
+ [{ deviceId: new mongoose.Types.ObjectId(), deviceType: 'linked-device' }],
173
+ { resolveSiteMonitorIds: async () => [undefined, null] },
174
+ );
175
+
176
+ expect(result).toEqual([]);
177
+ // Not [null] and not ['undefined'] — nothing at all reaches the caller.
178
+ expect(result).toHaveLength(0);
179
+ });
180
+
181
+ test('a nullish deviceId on a monitor entry never reaches the projection', async () => {
182
+ const monitorId = new mongoose.Types.ObjectId();
183
+
184
+ const result = await projectSubscribedMonitors([
185
+ { deviceId: undefined, deviceType: 'monitor' },
186
+ { deviceId: monitorId, deviceType: 'monitor' },
187
+ ]);
188
+
189
+ expect(result).toEqual([monitorId]);
190
+ });
191
+
192
+ test('ignores an entry whose deviceType is outside the vocabulary', async () => {
193
+ // Reachable in production: $addToSet skips document validators, so a
194
+ // mis-cased 'Monitor' can already be stored. Project it as no-AQI, the
195
+ // same answer isAqiCapableDeviceType gives.
196
+ const result = await projectSubscribedMonitors([
197
+ { deviceId: oid(), deviceType: 'Monitor' },
198
+ { deviceId: oid(), deviceType: 'sampleSite' },
199
+ ]);
200
+
201
+ expect(result).toEqual([]);
202
+ });
203
+
204
+ test('treats a missing or empty subscribedDevices as an empty audience', async () => {
205
+ // A user row written before the field existed has no key at all on a
206
+ // .lean() read (pinned in models/tests/users.test.js).
207
+ await expect(projectSubscribedMonitors(undefined)).resolves.toEqual([]);
208
+ await expect(projectSubscribedMonitors(null)).resolves.toEqual([]);
209
+ await expect(projectSubscribedMonitors([])).resolves.toEqual([]);
210
+ });
211
+
212
+ test('does not mutate the array it is given', async () => {
213
+ const entries = [
214
+ { deviceId: oid(), deviceType: 'monitor' },
215
+ { deviceId: oid(), deviceType: 'sample-site' },
216
+ ];
217
+ const snapshot = [...entries];
218
+
219
+ await projectSubscribedMonitors(entries);
220
+
221
+ expect(entries).toEqual(snapshot);
222
+ });
223
+
224
+ test('is idempotent — reprojecting the same input gives the same output', async () => {
225
+ const siteId = oid();
226
+ const monitorId = oid();
227
+ const { resolve } = recordingResolver({ [siteId]: [monitorId] });
228
+ const entries = [
229
+ { deviceId: monitorId, deviceType: 'monitor' },
230
+ { deviceId: siteId, deviceType: 'linked-device' },
231
+ ];
232
+
233
+ const first = await projectSubscribedMonitors(entries, {
234
+ resolveSiteMonitorIds: resolve,
235
+ });
236
+ const second = await projectSubscribedMonitors(entries, {
237
+ resolveSiteMonitorIds: resolve,
238
+ });
239
+
240
+ expect(second).toEqual(first);
241
+ });
242
+
243
+ test('unsubscribing a Site drops only the monitors nothing else contributes', async () => {
244
+ // The recompute-whole contract: the caller removes the entry and
245
+ // reprojects, rather than diffing monitor ids by hand.
246
+ const [siteA, siteB] = [oid(), oid()];
247
+ const [shared, onlyA] = [oid(), oid()];
248
+ const { resolve } = recordingResolver({
249
+ [siteA]: [shared, onlyA],
250
+ [siteB]: [shared],
251
+ });
252
+
253
+ const subscribed = [
254
+ { deviceId: siteA, deviceType: 'linked-device' },
255
+ { deviceId: siteB, deviceType: 'linked-device' },
256
+ ];
257
+ const before = await projectSubscribedMonitors(subscribed, {
258
+ resolveSiteMonitorIds: resolve,
259
+ });
260
+ expect(before).toEqual([shared, onlyA]);
261
+
262
+ const after = await projectSubscribedMonitors(
263
+ subscribed.filter((entry) => String(entry.deviceId) !== String(siteA)),
264
+ { resolveSiteMonitorIds: resolve },
265
+ );
266
+
267
+ expect(after).toEqual([shared]);
268
+ });
269
+ });
270
+
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
+ });
284
+
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
+ });
291
+
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);
299
+ });
300
+
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);
305
+ });
306
+ });