@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.
- package/dist/constants/deviceTypes.d.ts +8 -0
- package/dist/constants/deviceTypes.d.ts.map +1 -0
- package/dist/constants/index.d.ts +1 -0
- package/dist/constants/tests/deviceTypes.test.d.ts +2 -0
- package/dist/constants/tests/deviceTypes.test.d.ts.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/models/monitorEpisodes.d.ts +12 -12
- package/dist/models/users.d.ts.map +1 -1
- package/dist/utils/subscribedDevices.d.ts +3 -0
- package/dist/utils/subscribedDevices.d.ts.map +1 -0
- package/dist/utils/tests/subscribedDevices.test.d.ts +2 -0
- package/dist/utils/tests/subscribedDevices.test.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/constants/deviceTypes.js +44 -0
- package/src/constants/index.js +1 -0
- package/src/constants/tests/deviceTypes.test.js +53 -0
- package/src/index.js +18 -0
- package/src/models/users.js +70 -0
- package/src/utils/subscribedDevices.js +168 -0
- package/src/utils/tests/subscribedDevices.test.js +306 -0
|
@@ -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"}
|
|
@@ -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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"
|
|
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":"
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"subscribedDevices.d.ts","sourceRoot":"","sources":["../../src/utils/subscribedDevices.js"],"names":[],"mappings":"AAyIO,gHAoBN;AAOM,uEAGuD"}
|
|
@@ -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
|
@@ -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;
|
package/src/constants/index.js
CHANGED
|
@@ -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
|
package/src/models/users.js
CHANGED
|
@@ -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
|
+
});
|