@justair/justair-library 7.2.0 → 7.3.0-alpha.002ee84

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 namespace DEVICE_TYPES {
2
+ let MONITOR: string;
3
+ let SAMPLE_SITE: string;
4
+ let LINKED_DEVICE: string;
5
+ }
6
+ export const DEVICE_TYPE_VALUES: string[];
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":";;;;;AA2BA,0CAA8D;AASvD,iEAEoC"}
@@ -1,4 +1,5 @@
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
  //# 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
@@ -90,6 +90,9 @@ import { PARAMETERS } from "./constants/pollutants.js";
90
90
  import { HEAVY_METALS } from "./constants/pollutants.js";
91
91
  import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
92
92
  import { getAlertWeight } from "./constants/alertLevels.js";
93
+ import { DEVICE_TYPES } from "./constants/deviceTypes.js";
94
+ import { DEVICE_TYPE_VALUES } from "./constants/deviceTypes.js";
95
+ import { isAqiCapableDeviceType } from "./constants/deviceTypes.js";
93
96
  import { splitName } from "./utils/splitName.js";
94
97
  import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
95
98
  import { DEFAULT_TIMEZONE } from "./utils/quietHours.js";
@@ -100,5 +103,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
100
103
  import { getZoneMinutes } from "./utils/quietHours.js";
101
104
  import { isInQuietWindow } from "./utils/quietHours.js";
102
105
  import { resolveQuietHours } from "./utils/quietHours.js";
103
- 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, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
106
+ 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, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, DEVICE_TYPES, DEVICE_TYPE_VALUES, isAqiCapableDeviceType, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
104
107
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAuHA;;;iBAEC;yBAvDwB,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;4BAkCT,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;2BAhBG,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;0BAgBtD,sBAAsB;oCAWzC,uBAAuB;iCAAvB,uBAAuB;mCAAvB,uBAAuB;kCAAvB,uBAAuB;oCAAvB,uBAAuB;mCAAvB,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AA4HA;;;iBAEC;yBA5DwB,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;4BAuCT,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;2BArBG,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;6BAKzE,4BAA4B;mCAA5B,4BAA4B;uCAA5B,4BAA4B;0BAgBT,sBAAsB;oCAWzC,uBAAuB;iCAAvB,uBAAuB;mCAAvB,uBAAuB;kCAAvB,uBAAuB;oCAAvB,uBAAuB;mCAAvB,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":"AA6DA,8BAiGE;AAiCF,wBAAmD;AAtLnD,iDAA4D;AAC5D,6CAAmD;AAUnD,4DAsBC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.2.0",
3
+ "version": "7.3.0-alpha.002ee84",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,39 @@
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
+ export const DEVICE_TYPES = {
23
+ MONITOR: "monitor",
24
+ SAMPLE_SITE: "sample-site",
25
+ LINKED_DEVICE: "linked-device",
26
+ };
27
+
28
+ export const DEVICE_TYPE_VALUES = Object.values(DEVICE_TYPES);
29
+
30
+ // Whether a device of this type can ever report AQI, and therefore whether a
31
+ // subscription to it can contribute to AQI alert delivery.
32
+ //
33
+ // Note the asymmetry: `sample-site` is unconditionally false, but a true result
34
+ // for `linked-device` is necessary and NOT sufficient — a Site is only actually
35
+ // AQI-capable when it links at least one monitor, which the type alone cannot
36
+ // tell you. Callers must still check the Site's monitorIds.
37
+ export const isAqiCapableDeviceType = (deviceType) =>
38
+ deviceType === DEVICE_TYPES.MONITOR ||
39
+ deviceType === DEVICE_TYPES.LINKED_DEVICE;
@@ -10,3 +10,4 @@
10
10
  export * from "./pollutants.js";
11
11
  export * from "./alertLevels.js";
12
12
  export * from "./userNotifications.js";
13
+ export * from "./deviceTypes.js";
@@ -0,0 +1,48 @@
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
+ describe('isAqiCapableDeviceType', () => {
33
+ test('monitor and linked-device can report AQI', () => {
34
+ expect(isAqiCapableDeviceType('monitor')).toBe(true);
35
+ expect(isAqiCapableDeviceType('linked-device')).toBe(true);
36
+ });
37
+
38
+ test('sample-site never reports AQI, so it never alerts', () => {
39
+ expect(isAqiCapableDeviceType('sample-site')).toBe(false);
40
+ });
41
+
42
+ test('unknown and missing types are not AQI-capable', () => {
43
+ expect(isAqiCapableDeviceType('sampleSite')).toBe(false);
44
+ expect(isAqiCapableDeviceType(undefined)).toBe(false);
45
+ expect(isAqiCapableDeviceType(null)).toBe(false);
46
+ expect(isAqiCapableDeviceType('')).toBe(false);
47
+ });
48
+ });
package/src/index.js CHANGED
@@ -67,6 +67,11 @@ import Database from "./config/db.js"; // Import the new Database class
67
67
  import CustomLogger from "./config/logger.js";
68
68
  import { PARAMETERS, HEAVY_METALS } from "./constants/pollutants.js";
69
69
  import { ALERT_LEVEL_WEIGHTS, getAlertWeight } from "./constants/alertLevels.js";
70
+ import {
71
+ DEVICE_TYPES,
72
+ DEVICE_TYPE_VALUES,
73
+ isAqiCapableDeviceType,
74
+ } from "./constants/deviceTypes.js";
70
75
  import {
71
76
  monitorEpisodesSchema,
72
77
  MonitorEpisode,
@@ -216,6 +221,10 @@ export {
216
221
  // AQI alert-level hierarchy (single source of truth)
217
222
  ALERT_LEVEL_WEIGHTS,
218
223
  getAlertWeight,
224
+ // V2 device-type vocabulary (single source of truth) — JA-3110 / JA-3335
225
+ DEVICE_TYPES,
226
+ DEVICE_TYPE_VALUES,
227
+ isAqiCapableDeviceType,
219
228
  // Utilities
220
229
  splitName,
221
230
  // 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,23 @@ 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
+ const subscribedDeviceSchema = mongoose.Schema(
55
+ {
56
+ deviceId: { type: mongoose.Types.ObjectId, required: true },
57
+ deviceType: { type: String, enum: DEVICE_TYPE_VALUES, required: true },
58
+ },
59
+ { _id: false }
60
+ );
61
+
44
62
  const usersSchema = mongoose.Schema(
45
63
  {
46
64
  name: String,
@@ -54,6 +72,22 @@ const usersSchema = mongoose.Schema(
54
72
  alertPhone: String,
55
73
  alertCount: { type: Number, default: 0 },
56
74
  subscribedMonitors: [{ type: mongoose.Types.ObjectId, ref: "Monitors" }],
75
+ // Subscribed devices (JA-3110 / JA-3335) — the source of truth for what the
76
+ // user has added to "My Monitors", across all three device types.
77
+ //
78
+ // Relationship to subscribedMonitors above: that array stays monitor ids
79
+ // ONLY, and is the AQI-alert projection of this one. A subscribed
80
+ // linked-device contributes the monitorIds of its Site to it; a subscribed
81
+ // sample-site contributes nothing, having no live AQI. The two must be
82
+ // written together on every subscribe/unsubscribe path.
83
+ //
84
+ // Why the split matters: JustAirNotificationService reads subscribedMonitors
85
+ // and drops ids that do not resolve to a Monitor (statusAlerts.js
86
+ // `.filter(monitor => monitor)`). Writing a Site or SampleSite id in there
87
+ // therefore throws no error — it silently delivers no alerts. Keeping the
88
+ // polymorphic ids in their own field preserves that invariant for every
89
+ // library consumer.
90
+ subscribedDevices: [subscribedDeviceSchema],
57
91
  currentAlertMode: {
58
92
  type: String,
59
93
  enum: [
@@ -151,6 +185,9 @@ usersSchema.pre("validate", function () {
151
185
  });
152
186
 
153
187
  usersSchema.index({ phone: 1 });
188
+ // Supports the "is this user subscribed to device X?" lookup behind the V2
189
+ // device-card subscribed state and the My Monitors map filter.
190
+ usersSchema.index({ "subscribedDevices.deviceId": 1 });
154
191
 
155
192
  const Users = mongoose.model("Users", usersSchema);
156
193