@justair/justair-library 7.5.0-alpha.c25c5a2 → 7.5.0-alpha.d0806df
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants/deviceTypes.d.ts +6 -0
- package/dist/constants/deviceTypes.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/models/users.d.ts.map +1 -1
- package/dist/utils/subscribedDevices.d.ts +7 -2
- package/dist/utils/subscribedDevices.d.ts.map +1 -1
- package/dist/utils/tests/subscribedDevicesBarrel.test.d.ts +2 -0
- package/dist/utils/tests/subscribedDevicesBarrel.test.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/constants/deviceTypes.js +8 -0
- package/src/index.js +8 -5
- package/src/models/users.js +10 -23
- package/src/utils/subscribedDevices.js +93 -26
- package/src/utils/tests/subscribedDevices.test.js +141 -32
- package/src/utils/tests/subscribedDevicesBarrel.test.js +38 -0
|
@@ -5,4 +5,10 @@ export const DEVICE_TYPES: Readonly<{
|
|
|
5
5
|
}>;
|
|
6
6
|
export const DEVICE_TYPE_VALUES: readonly ("monitor" | "sample-site" | "linked-device")[];
|
|
7
7
|
export function isAqiCapableDeviceType(deviceType: any): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* The device-type vocabulary as a type, so consumers get autocomplete and a
|
|
10
|
+
* compile error on a mis-cased value like "sampleSite" — the exact bug the
|
|
11
|
+
* runtime comments in utils/subscribedDevices.js warn about at length.
|
|
12
|
+
*/
|
|
13
|
+
export type DeviceType = (typeof DEVICE_TYPES)[keyof typeof DEVICE_TYPES];
|
|
8
14
|
//# sourceMappingURL=deviceTypes.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deviceTypes.d.ts","sourceRoot":"","sources":["../../src/constants/deviceTypes.js"],"names":[],"mappings":"AA0BA;;;;GAIG;AAEH,0FAA6E;
|
|
1
|
+
{"version":3,"file":"deviceTypes.d.ts","sourceRoot":"","sources":["../../src/constants/deviceTypes.js"],"names":[],"mappings":"AA0BA;;;;GAIG;AAEH,0FAA6E;AAiBtE,iEAEoC;;;;;;yBAZ9B,CAAA,OAAO,YAAY,EAAC,MAAM,OAAO,YAAY,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -107,6 +107,7 @@ import { DEVICE_TYPES } from "./constants/deviceTypes.js";
|
|
|
107
107
|
import { DEVICE_TYPE_VALUES } from "./constants/deviceTypes.js";
|
|
108
108
|
import { isAqiCapableDeviceType } from "./constants/deviceTypes.js";
|
|
109
109
|
import { projectSubscribedMonitors } from "./utils/subscribedDevices.js";
|
|
110
|
+
import { SUBSCRIBED_DEVICES_ERRORS } from "./utils/subscribedDevices.js";
|
|
110
111
|
import { splitName } from "./utils/splitName.js";
|
|
111
112
|
import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
|
|
112
113
|
import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
|
|
@@ -117,5 +118,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
|
|
|
117
118
|
import { getZoneMinutes } from "./utils/quietHours.js";
|
|
118
119
|
import { isInQuietWindow } from "./utils/quietHours.js";
|
|
119
120
|
import { resolveQuietHours } from "./utils/quietHours.js";
|
|
120
|
-
export { Database, adminSchema, Admin, configurationsSchema, Configurations, measurementsSchema, Measurements, monitorRequestsSchema, MonitorRequests, monitorsSchema, Monitors, organizationsSchema, Organizations, usersSchema, Users, COOKIE_CONSENT_CATEGORIES, COOKIE_CONSENT_VALUES, validateCookieConsent, eventsSchema, Events, lightMonitorSchema, LightMonitors, monitorSuppliersSchema, MonitorSuppliers, contextsSchema, Contexts, parametersSchema, Parameters, announcementSchema, Announcements, jobsSchema, Jobs, apiKeySchema, ApiKey, UsageMetrics, usageMetricsSchema, Audit, auditSchema, EventsAudit, eventsAuditSchema, MonitorAudit, monitorAuditSchema, parametersEnum, deploymentTypesEnum, AlertsAudit, Alerts, alertsSchema, alertsAuditSchema, Features, featuresSchema, dataCompletenessSchema, DataCompleteness, networkMetricsSchema, NetworkMetrics, rateOfChangeSchema, RateOfChange, changeStreamCheckpointsSchema, ChangeStreamCheckpoints, sitesSchema, Sites, sampleSitesSchema, SampleSites, sampleSiteAuditSchema, SampleSiteAudit, sampleParametersEnum, samplesSchema, Samples, samplesAuditSchema, SamplesAudit, sampleParameterReferenceConcentrations, passwordResetTokenSchema, PasswordResetToken, monitorEpisodesSchema, MonitorEpisode, userNotificationsSchema, UserNotification, AqiAlertNotification, DeviceOfflineNotification, WeeklyDigestNotification, AccountNotification, NOTIFICATION_KINDS, NOTIFICATION_CHANNELS, DELIVERY_STATUSES, DATA_DOWNLOAD_TYPES, dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload, DATA_DOWNLOAD_MAX_RANGE_DAYS, DATA_DOWNLOAD_ERRORS, validateDataDownloadDateOrder, validateDataDownloadMaxDateRange, validateDataDownloadEmail, normalizeDataDownloadEmail, validateDataDownloadTimezone, normalizeDataDownloadTimezone, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, DEVICE_TYPES, DEVICE_TYPE_VALUES, isAqiCapableDeviceType, projectSubscribedMonitors, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
|
|
121
|
+
export { Database, adminSchema, Admin, configurationsSchema, Configurations, measurementsSchema, Measurements, monitorRequestsSchema, MonitorRequests, monitorsSchema, Monitors, organizationsSchema, Organizations, usersSchema, Users, COOKIE_CONSENT_CATEGORIES, COOKIE_CONSENT_VALUES, validateCookieConsent, eventsSchema, Events, lightMonitorSchema, LightMonitors, monitorSuppliersSchema, MonitorSuppliers, contextsSchema, Contexts, parametersSchema, Parameters, announcementSchema, Announcements, jobsSchema, Jobs, apiKeySchema, ApiKey, UsageMetrics, usageMetricsSchema, Audit, auditSchema, EventsAudit, eventsAuditSchema, MonitorAudit, monitorAuditSchema, parametersEnum, deploymentTypesEnum, AlertsAudit, Alerts, alertsSchema, alertsAuditSchema, Features, featuresSchema, dataCompletenessSchema, DataCompleteness, networkMetricsSchema, NetworkMetrics, rateOfChangeSchema, RateOfChange, changeStreamCheckpointsSchema, ChangeStreamCheckpoints, sitesSchema, Sites, sampleSitesSchema, SampleSites, sampleSiteAuditSchema, SampleSiteAudit, sampleParametersEnum, samplesSchema, Samples, samplesAuditSchema, SamplesAudit, sampleParameterReferenceConcentrations, passwordResetTokenSchema, PasswordResetToken, monitorEpisodesSchema, MonitorEpisode, userNotificationsSchema, UserNotification, AqiAlertNotification, DeviceOfflineNotification, WeeklyDigestNotification, AccountNotification, NOTIFICATION_KINDS, NOTIFICATION_CHANNELS, DELIVERY_STATUSES, DATA_DOWNLOAD_TYPES, dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload, DATA_DOWNLOAD_MAX_RANGE_DAYS, DATA_DOWNLOAD_ERRORS, validateDataDownloadDateOrder, validateDataDownloadMaxDateRange, validateDataDownloadEmail, normalizeDataDownloadEmail, validateDataDownloadTimezone, normalizeDataDownloadTimezone, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, DEVICE_TYPES, DEVICE_TYPE_VALUES, isAqiCapableDeviceType, projectSubscribedMonitors, SUBSCRIBED_DEVICES_ERRORS, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
|
|
121
122
|
//# sourceMappingURL=index.d.ts.map
|
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":"AA6JA;;;iBAEC;yBA7FwB,oBAAoB;qBADxB,gBAAgB;4BAjEF,mBAAmB;sBAAnB,mBAAmB;qCAI/C,4BAA4B;+BAA5B,4BAA4B;mCAM5B,0BAA0B;6BAA1B,0BAA0B;sCAI1B,6BAA6B;gCAA7B,6BAA6B;+BAQ7B,sBAAsB;yBAAtB,sBAAsB;oCACsB,2BAA2B;8BAA3B,2BAA2B;4BAcvE,mBAAmB;sBAAnB,mBAAmB;0CAAnB,mBAAmB;sCAAnB,mBAAmB;sCAAnB,mBAAmB;6BAMnB,oBAAoB;uBAApB,oBAAoB;mCAnBuB,2BAA2B;8BAA3B,2BAA2B;uCAItE,8BAA8B;iCAA9B,8BAA8B;+BACI,sBAAsB;yBAAtB,sBAAsB;iCAClB,wBAAwB;2BAAxB,wBAAwB;mCAcnB,2BAA2B;8BAA3B,2BAA2B;2BAC5C,kBAAkB;qBAAlB,kBAAkB;6BACd,oBAAoB;uBAApB,oBAAoB;6BACR,0BAA0B;mCAA1B,0BAA0B;sBArCpE,0BAA0B;4BAA1B,0BAA0B;4BAiC1B,oBAAoB;kCAApB,oBAAoB;6BArBpB,sBAAsB;mCAAtB,sBAAsB;+BAAtB,sBAAsB;oCAAtB,sBAAsB;4BA+BtB,oBAAoB;uBAApB,oBAAoB;6BAApB,oBAAoB;kCAApB,oBAAoB;yBACc,sBAAsB;+BAAtB,sBAAsB;uCAIxD,8BAA8B;iCAA9B,8BAA8B;qCACgB,4BAA4B;+BAA5B,4BAA4B;mCAChC,0BAA0B;6BAA1B,0BAA0B;8CAIpE,qCAAqC;wCAArC,qCAAqC;4BA4CT,mBAAmB;sBAAnB,mBAAmB;kCAS/C,yBAAyB;4BAAzB,yBAAyB;sCAAzB,yBAAyB;gCAAzB,yBAAyB;qCAAzB,yBAAyB;8BASzB,qBAAqB;wBAArB,qBAAqB;mCAArB,qBAAqB;6BAArB,qBAAqB;uDAArB,qBAAqB;yCACiC,gCAAgC;mCAAhC,gCAAgC;sCAhDtF,6BAA6B;+BAA7B,6BAA6B;wCAW7B,+BAA+B;iCAA/B,+BAA+B;qCAA/B,+BAA+B;0CAA/B,+BAA+B;yCAA/B,+BAA+B;oCAA/B,+BAA+B;mCAA/B,+BAA+B;sCAA/B,+BAA+B;kCAA/B,+BAA+B;oCApBF,8BAA8B;mCA+D3D,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAsB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;yEAAnC,mCAAmC;2EAAnC,mCAAmC;2BAxFD,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;6BAOzE,4BAA4B;mCAA5B,4BAA4B;uCAA5B,4BAA4B;0CA8B5B,8BAA8B;0CAA9B,8BAA8B;0BAdX,sBAAsB;oCASzC,uBAAuB;iCA/BG,0BAA0B;mCA+BpD,uBAAuB;kCAAvB,uBAAuB;oCACM,qBAAqB;mCADlD,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/models/users.js"],"names":[],"mappings":"AAyFA,
|
|
1
|
+
{"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/models/users.js"],"names":[],"mappings":"AAyFA,8BAgGE;AAiCF,wBAAmD;AAjNnD,iDAA4D;AAC5D,6CAAmD;AAUnD,4DAsBC"}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
export const SUBSCRIBED_DEVICES_ERRORS: Readonly<{
|
|
2
|
+
MISSING_SITE_RESOLVER: "missing_site_resolver";
|
|
3
|
+
RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array";
|
|
4
|
+
RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays";
|
|
5
|
+
RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents";
|
|
6
|
+
}>;
|
|
1
7
|
export function projectSubscribedMonitors(subscribedDevices?: Array<{
|
|
2
8
|
deviceId: import("mongoose").Types.ObjectId;
|
|
3
|
-
deviceType:
|
|
9
|
+
deviceType: import("../constants/deviceTypes.js").DeviceType;
|
|
4
10
|
}>, { resolveSiteMonitorIds }?: {
|
|
5
11
|
resolveSiteMonitorIds?: (siteIds: import("mongoose").Types.ObjectId[]) => Promise<Array<import("mongoose").Types.ObjectId | null | undefined>>;
|
|
6
12
|
}): Promise<import("mongoose").Types.ObjectId[]>;
|
|
7
|
-
export function hasAqiCapableSubscription(subscribedDevices: any): any;
|
|
8
13
|
//# sourceMappingURL=subscribedDevices.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subscribedDevices.d.ts","sourceRoot":"","sources":["../../src/utils/subscribedDevices.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"subscribedDevices.d.ts","sourceRoot":"","sources":["../../src/utils/subscribedDevices.js"],"names":[],"mappings":"AAqFA;;;;;GAKG;AA4II,8DANI,KAAK,CAAC;IAAE,QAAQ,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,CAAC;IAC1D,UAAU,EAAE,OAAO,6BAA6B,EAAE,UAAU,CAAA;CAAE,CAAC,8BACzD;IAAE,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,KAC7E,OAAO,CAAC,KAAK,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC,CAAA;CAAE,GAC9D,OAAO,CAAC,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CA0BxD"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subscribedDevicesBarrel.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/subscribedDevicesBarrel.test.js"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -32,6 +32,14 @@ export const DEVICE_TYPES = Object.freeze({
|
|
|
32
32
|
|
|
33
33
|
export const DEVICE_TYPE_VALUES = Object.freeze(Object.values(DEVICE_TYPES));
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The device-type vocabulary as a type, so consumers get autocomplete and a
|
|
37
|
+
* compile error on a mis-cased value like "sampleSite" — the exact bug the
|
|
38
|
+
* runtime comments in utils/subscribedDevices.js warn about at length.
|
|
39
|
+
*
|
|
40
|
+
* @typedef {typeof DEVICE_TYPES[keyof typeof DEVICE_TYPES]} DeviceType
|
|
41
|
+
*/
|
|
42
|
+
|
|
35
43
|
// Whether a device of this type can ever report AQI, and therefore whether a
|
|
36
44
|
// subscription to it can contribute to AQI alert delivery.
|
|
37
45
|
//
|
package/src/index.js
CHANGED
|
@@ -100,7 +100,10 @@ import {
|
|
|
100
100
|
resolveQuietHours,
|
|
101
101
|
} from "./utils/quietHours.js";
|
|
102
102
|
import { isValidIANATimezone } from "./utils/timezone.js";
|
|
103
|
-
import {
|
|
103
|
+
import {
|
|
104
|
+
projectSubscribedMonitors,
|
|
105
|
+
SUBSCRIBED_DEVICES_ERRORS,
|
|
106
|
+
} from "./utils/subscribedDevices.js";
|
|
104
107
|
|
|
105
108
|
// Sites related imports
|
|
106
109
|
import { sitesSchema, Sites } from "./models/sites.js";
|
|
@@ -272,11 +275,11 @@ export {
|
|
|
272
275
|
// subscribedDevices → subscribedMonitors projection (JA-3110 / JA-3335).
|
|
273
276
|
// Every consumer that writes a subscription recomputes the AQI audience with
|
|
274
277
|
// this instead of re-implementing the per-device-type rule.
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
// It stays module-internal until a consumer needs it.
|
|
278
|
+
// SUBSCRIBED_DEVICES_ERRORS is the stable `err.code` vocabulary for the
|
|
279
|
+
// errors projectSubscribedMonitors throws; consumers branch on it rather
|
|
280
|
+
// than on message wording.
|
|
279
281
|
projectSubscribedMonitors,
|
|
282
|
+
SUBSCRIBED_DEVICES_ERRORS,
|
|
280
283
|
// Utilities
|
|
281
284
|
splitName,
|
|
282
285
|
// Quiet hours (JA-3054): shared validation + window evaluation
|
package/src/models/users.js
CHANGED
|
@@ -103,30 +103,17 @@ const usersSchema = mongoose.Schema(
|
|
|
103
103
|
// Subscribed devices (JA-3110 / JA-3335) — the source of truth for what the
|
|
104
104
|
// user has added to "My Monitors", across all three device types.
|
|
105
105
|
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
106
|
+
// subscribedMonitors above is its derived AQI-alert projection (monitor ids
|
|
107
|
+
// only). The two must be written together on every subscribe and
|
|
108
|
+
// unsubscribe path, using `projectSubscribedMonitors` rather than a
|
|
109
|
+
// per-consumer reimplementation. It is deliberately not a schema validator:
|
|
110
|
+
// the projection needs a Site lookup, which the schema has no business
|
|
111
|
+
// doing.
|
|
111
112
|
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// schema has no business doing.
|
|
117
|
-
//
|
|
118
|
-
// Writing subscribedMonitors has a hard precondition (a blanket overwrite
|
|
119
|
-
// drops the subscriptions of any user who predates this field) and a known
|
|
120
|
-
// staleness gap. Both are specified once, in the PRECONDITION and STALENESS
|
|
121
|
-
// notes at the top of src/utils/subscribedDevices.js — read them there
|
|
122
|
-
// rather than trusting a summary here.
|
|
123
|
-
//
|
|
124
|
-
// Why the split matters: JustAirNotificationService reads subscribedMonitors
|
|
125
|
-
// and drops ids that do not resolve to a Monitor (statusAlerts.js
|
|
126
|
-
// `.filter(monitor => monitor)`). Writing a Site or SampleSite id in there
|
|
127
|
-
// therefore throws no error — it silently delivers no alerts. Keeping the
|
|
128
|
-
// polymorphic ids in their own field preserves that invariant for every
|
|
129
|
-
// library consumer.
|
|
113
|
+
// Everything else — the per-device-type rule, the backfill PRECONDITION,
|
|
114
|
+
// the STALENESS gap, and why the field is split at all — is specified once,
|
|
115
|
+
// in the header of src/utils/subscribedDevices.js. Read it there; it is
|
|
116
|
+
// deliberately not summarised here.
|
|
130
117
|
subscribedDevices: [subscribedDeviceSchema],
|
|
131
118
|
currentAlertMode: {
|
|
132
119
|
type: String,
|
|
@@ -42,11 +42,20 @@
|
|
|
42
42
|
// { deviceId, deviceType: "monitor" } entry per existing id), then
|
|
43
43
|
// overwrite freely. This is the target state.
|
|
44
44
|
// 2. Until that backfill has run for the user, union the projection with the
|
|
45
|
-
// existing subscribedMonitors instead of overwriting
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
45
|
+
// existing subscribedMonitors instead of overwriting — AND write the
|
|
46
|
+
// per-user backfill in the same update, one
|
|
47
|
+
// { deviceId, deviceType: "monitor" } entry per id already in
|
|
48
|
+
// subscribedMonitors, alongside the device being subscribed. Both halves
|
|
49
|
+
// are required. Unioning without backfilling leaves a user who looks
|
|
50
|
+
// migrated (subscribedDevices is non-empty) but is not, so the obvious
|
|
51
|
+
// "has this user been backfilled?" check answers yes and the next
|
|
52
|
+
// unsubscribe overwrites subscribedMonitors with a projection missing
|
|
53
|
+
// every monitor they had before. A half-migrated user must never exist.
|
|
54
|
+
//
|
|
55
|
+
// This path is also SUBSCRIBE-ONLY. A union can only ever add ids, so
|
|
56
|
+
// using it for an unsubscribe makes the removal a silent no-op. There is
|
|
57
|
+
// deliberately no third option: an unsubscribe on a user who predates
|
|
58
|
+
// subscribedDevices MUST be preceded by the backfill, and only then
|
|
50
59
|
// overwrite. Removing a subscription is not correct any other way.
|
|
51
60
|
//
|
|
52
61
|
// This module deliberately does NOT offer a `union: true` flag. A blanket
|
|
@@ -69,10 +78,17 @@
|
|
|
69
78
|
// affected subscribers. That reprojection is not implemented anywhere yet and
|
|
70
79
|
// needs a follow-up ticket under JA-3110.
|
|
71
80
|
|
|
72
|
-
import {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
import { DEVICE_TYPES } from "../constants/deviceTypes.js";
|
|
82
|
+
|
|
83
|
+
// Stable error codes thrown by this module. Consumers filter on `err.code`
|
|
84
|
+
// — never on message wording, which is free to change. Same contract as
|
|
85
|
+
// QUIET_HOURS_ERRORS in utils/quietHours.js.
|
|
86
|
+
export const SUBSCRIBED_DEVICES_ERRORS = Object.freeze({
|
|
87
|
+
MISSING_SITE_RESOLVER: "missing_site_resolver",
|
|
88
|
+
RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array",
|
|
89
|
+
RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays",
|
|
90
|
+
RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents",
|
|
91
|
+
});
|
|
76
92
|
|
|
77
93
|
// Dedupe by string form, keep the caller's ObjectId instances. Two equal
|
|
78
94
|
// ObjectIds are different object identities, so Set/includes on the raw values
|
|
@@ -119,6 +135,60 @@ const linkedSiteIds = (entries) =>
|
|
|
119
135
|
.filter((entry) => entry.deviceType === DEVICE_TYPES.LINKED_DEVICE)
|
|
120
136
|
.map((entry) => entry.deviceId);
|
|
121
137
|
|
|
138
|
+
// The resolver is consumer-supplied, so its return is the one input this
|
|
139
|
+
// module cannot check at the type boundary. Validate it rather than trust it:
|
|
140
|
+
// both bad shapes below would otherwise corrupt subscribedMonitors silently,
|
|
141
|
+
// and silent is the failure mode this whole field split exists to avoid.
|
|
142
|
+
// Nullish stays tolerated — a consumer whose Sites resolved to nothing may
|
|
143
|
+
// legitimately return nothing.
|
|
144
|
+
const validateResolvedSiteMonitorIds = (resolved) => {
|
|
145
|
+
if (resolved === null || resolved === undefined) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!Array.isArray(resolved)) {
|
|
150
|
+
const error = new TypeError(
|
|
151
|
+
"resolveSiteMonitorIds must resolve to an array of monitor ids"
|
|
152
|
+
);
|
|
153
|
+
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NON_ARRAY;
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// `.map` instead of `.flatMap` yields [[m1, m2]]. An array is not a plain
|
|
158
|
+
// object, so the document check below never sees it, and it fails
|
|
159
|
+
// inconsistently downstream: one monitor per Site casts back to the id and
|
|
160
|
+
// silently works, two throws a CastError at write time, far from the cause.
|
|
161
|
+
if (resolved.some(Array.isArray)) {
|
|
162
|
+
const error = new TypeError(
|
|
163
|
+
"resolveSiteMonitorIds must resolve to a flat array of monitor ids"
|
|
164
|
+
);
|
|
165
|
+
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NESTED_ARRAYS;
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Discriminating a document from an id without importing mongoose: test for
|
|
170
|
+
// what only an id has. `toHexString` is on ObjectId (mongoose-patched or raw
|
|
171
|
+
// bson) and on nothing a Sites query returns, hydrated or lean.
|
|
172
|
+
//
|
|
173
|
+
// Deliberately not keyed on `_id`, which is wrong in both directions: every
|
|
174
|
+
// ObjectId appears to carry one (mongoose defines `_id` on ObjectId.prototype
|
|
175
|
+
// as a self-reference), and a lean read that projects it away
|
|
176
|
+
// (`Sites.find(..., "monitorIds -_id").lean()`) carries none.
|
|
177
|
+
if (
|
|
178
|
+
resolved.some(
|
|
179
|
+
(id) => isPlainObject(id) && typeof id.toHexString !== "function"
|
|
180
|
+
)
|
|
181
|
+
) {
|
|
182
|
+
const error = new TypeError(
|
|
183
|
+
"resolveSiteMonitorIds must resolve to monitor ids, not Site documents"
|
|
184
|
+
);
|
|
185
|
+
error.code = SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS;
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return resolved;
|
|
190
|
+
};
|
|
191
|
+
|
|
122
192
|
// Returns the monitor ids that should be stored in Users.subscribedMonitors for
|
|
123
193
|
// the given subscribedDevices array. Order is deterministic — direct `monitor`
|
|
124
194
|
// subscriptions in array order, then Site-derived ids in resolver order, first
|
|
@@ -136,10 +206,12 @@ const linkedSiteIds = (entries) =>
|
|
|
136
206
|
// const sites = await Sites.find({ _id: { $in: siteIds } }, "monitorIds").lean();
|
|
137
207
|
// return sites.flatMap((site) => site.monitorIds ?? []);
|
|
138
208
|
//
|
|
139
|
-
// Returning the documents is the easy mistake
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
209
|
+
// Returning the documents is the easy mistake, so it is rejected rather than
|
|
210
|
+
// merely documented: the return is validated and a document-shaped entry
|
|
211
|
+
// throws RESOLVER_RETURNED_DOCUMENTS, as a non-array return throws
|
|
212
|
+
// RESOLVER_RETURNED_NON_ARRAY. Left unchecked it would fail silently — each
|
|
213
|
+
// Site doc casts to its own _id on write and subscribedMonitors fills with
|
|
214
|
+
// Site ids, exactly the polymorphic id this field split exists to keep out.
|
|
143
215
|
//
|
|
144
216
|
// It is not called at all when nothing is subscribed to a linked-device, so a
|
|
145
217
|
// consumer that only handles monitors pays no query. A resolver may return
|
|
@@ -150,7 +222,8 @@ const linkedSiteIds = (entries) =>
|
|
|
150
222
|
// Read the PRECONDITION and STALENESS notes at the top of this file before
|
|
151
223
|
// writing the result to Users.subscribedMonitors.
|
|
152
224
|
/**
|
|
153
|
-
* @param {Array<{ deviceId: import("mongoose").Types.ObjectId,
|
|
225
|
+
* @param {Array<{ deviceId: import("mongoose").Types.ObjectId,
|
|
226
|
+
* deviceType: import("../constants/deviceTypes.js").DeviceType }>} [subscribedDevices]
|
|
154
227
|
* @param {{ resolveSiteMonitorIds?: (siteIds: import("mongoose").Types.ObjectId[]) =>
|
|
155
228
|
* Promise<Array<import("mongoose").Types.ObjectId | null | undefined>> }} [options]
|
|
156
229
|
* @returns {Promise<import("mongoose").Types.ObjectId[]>}
|
|
@@ -168,21 +241,15 @@ export const projectSubscribedMonitors = async (
|
|
|
168
241
|
}
|
|
169
242
|
|
|
170
243
|
if (typeof resolveSiteMonitorIds !== "function") {
|
|
171
|
-
|
|
244
|
+
const error = new TypeError(
|
|
172
245
|
"projectSubscribedMonitors requires resolveSiteMonitorIds when a linked-device is subscribed"
|
|
173
246
|
);
|
|
247
|
+
error.code = SUBSCRIBED_DEVICES_ERRORS.MISSING_SITE_RESOLVER;
|
|
248
|
+
throw error;
|
|
174
249
|
}
|
|
175
250
|
|
|
176
|
-
const siteMonitorIds = (
|
|
251
|
+
const siteMonitorIds = validateResolvedSiteMonitorIds(
|
|
252
|
+
await resolveSiteMonitorIds(siteIds)
|
|
253
|
+
);
|
|
177
254
|
return dedupeById([...direct, ...siteMonitorIds]);
|
|
178
255
|
};
|
|
179
|
-
|
|
180
|
-
// Whether a subscribedDevices array can produce any AQI audience at all,
|
|
181
|
-
// without resolving Sites. `false` is certain (nothing subscribed can ever
|
|
182
|
-
// alert); `true` only means a linked-device is present and its Site still has
|
|
183
|
-
// to be checked — the same necessary-but-not-sufficient asymmetry as
|
|
184
|
-
// isAqiCapableDeviceType.
|
|
185
|
-
export const hasAqiCapableSubscription = (subscribedDevices) =>
|
|
186
|
-
(subscribedDevices ?? [])
|
|
187
|
-
.filter(isPlainObject)
|
|
188
|
-
.some((entry) => isAqiCapableDeviceType(entry.deviceType));
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import mongoose from 'mongoose';
|
|
2
|
+
import { Users } from '../../models/users.js';
|
|
3
|
+
import {
|
|
4
|
+
DEVICE_TYPE_VALUES,
|
|
5
|
+
isAqiCapableDeviceType,
|
|
6
|
+
} from '../../constants/deviceTypes.js';
|
|
2
7
|
import {
|
|
3
8
|
projectSubscribedMonitors,
|
|
4
|
-
|
|
9
|
+
SUBSCRIBED_DEVICES_ERRORS,
|
|
5
10
|
} from '../subscribedDevices.js';
|
|
6
11
|
|
|
7
12
|
const oid = () => new mongoose.Types.ObjectId();
|
|
@@ -111,9 +116,104 @@ describe('projectSubscribedMonitors', () => {
|
|
|
111
116
|
test('throws when a linked-device is subscribed and no resolver is injected', async () => {
|
|
112
117
|
// Loud on the one input it genuinely cannot project alone, rather than
|
|
113
118
|
// silently returning a short audience and dropping real alerts.
|
|
119
|
+
// The `code` is the contract consumers branch on; the message is not.
|
|
114
120
|
await expect(
|
|
115
121
|
projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }]),
|
|
116
|
-
).rejects.
|
|
122
|
+
).rejects.toMatchObject({
|
|
123
|
+
code: SUBSCRIBED_DEVICES_ERRORS.MISSING_SITE_RESOLVER,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('rejects a resolver that returns Site documents rather than monitor ids', async () => {
|
|
128
|
+
// The silent-corruption case: without this guard each doc survives
|
|
129
|
+
// dedupeById and casts to its own _id on write, filling subscribedMonitors
|
|
130
|
+
// with Site ids that the notification service then quietly drops.
|
|
131
|
+
const siteId = oid();
|
|
132
|
+
await expect(
|
|
133
|
+
projectSubscribedMonitors([{ deviceId: siteId, deviceType: 'linked-device' }], {
|
|
134
|
+
resolveSiteMonitorIds: async () => [{ _id: siteId, monitorIds: [oid()] }],
|
|
135
|
+
}),
|
|
136
|
+
).rejects.toMatchObject({
|
|
137
|
+
code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('rejects a resolver that returns nested arrays (.map instead of .flatMap)', async () => {
|
|
142
|
+
const siteId = oid();
|
|
143
|
+
await expect(
|
|
144
|
+
projectSubscribedMonitors([{ deviceId: siteId, deviceType: 'linked-device' }], {
|
|
145
|
+
resolveSiteMonitorIds: async () => [[oid(), oid()]],
|
|
146
|
+
}),
|
|
147
|
+
).rejects.toMatchObject({
|
|
148
|
+
code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NESTED_ARRAYS,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('rejects a lean Site read that projected _id away', async () => {
|
|
153
|
+
// The `_id`-based discriminator missed this: `Sites.find(..., "monitorIds
|
|
154
|
+
// -_id").lean()` yields objects carrying no _id at all, which would
|
|
155
|
+
// otherwise key as "[object Object]" in dedupeById and land in
|
|
156
|
+
// subscribedMonitors as junk.
|
|
157
|
+
await expect(
|
|
158
|
+
projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }], {
|
|
159
|
+
resolveSiteMonitorIds: async () => [{ monitorIds: [oid()] }],
|
|
160
|
+
}),
|
|
161
|
+
).rejects.toMatchObject({
|
|
162
|
+
code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('rejects a hydrated Site document (the forgot-.lean() case)', async () => {
|
|
167
|
+
// The likeliest real-world shape of this mistake. A throwaway model keeps
|
|
168
|
+
// this file off the real Sites schema; hydration needs no connection.
|
|
169
|
+
const HydratedSite = mongoose.model(
|
|
170
|
+
'SubscribedDevicesProbeSite',
|
|
171
|
+
new mongoose.Schema({ monitorIds: [mongoose.Schema.Types.ObjectId] }),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await expect(
|
|
175
|
+
projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }], {
|
|
176
|
+
resolveSiteMonitorIds: async () => [new HydratedSite({ monitorIds: [oid()] })],
|
|
177
|
+
}),
|
|
178
|
+
).rejects.toMatchObject({
|
|
179
|
+
code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('does not mistake ordinary ObjectIds for documents', async () => {
|
|
184
|
+
// Regression guard for the discriminator: an id has `toHexString`, a Site
|
|
185
|
+
// document does not. Keying on `_id` instead gets it wrong both ways —
|
|
186
|
+
// mongoose defines `_id` on ObjectId.prototype as a self-reference, so a
|
|
187
|
+
// naive "has _id" check rejects every legitimate resolver result.
|
|
188
|
+
const siteId = oid();
|
|
189
|
+
const monitorId = oid();
|
|
190
|
+
|
|
191
|
+
expect(monitorId._id).toBe(monitorId);
|
|
192
|
+
await expect(
|
|
193
|
+
projectSubscribedMonitors([{ deviceId: siteId, deviceType: 'linked-device' }], {
|
|
194
|
+
resolveSiteMonitorIds: async () => [monitorId],
|
|
195
|
+
}),
|
|
196
|
+
).resolves.toEqual([monitorId]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('rejects a resolver that does not return an array', async () => {
|
|
200
|
+
await expect(
|
|
201
|
+
projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }], {
|
|
202
|
+
resolveSiteMonitorIds: async () => oid(),
|
|
203
|
+
}),
|
|
204
|
+
).rejects.toMatchObject({
|
|
205
|
+
code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_NON_ARRAY,
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('still tolerates a resolver that returns nothing', async () => {
|
|
210
|
+
// Distinct from the two rejections above: resolving to nothing is a real
|
|
211
|
+
// state (a sample-only Site links no monitors), not a consumer mistake.
|
|
212
|
+
await expect(
|
|
213
|
+
projectSubscribedMonitors([{ deviceId: oid(), deviceType: 'linked-device' }], {
|
|
214
|
+
resolveSiteMonitorIds: async () => undefined,
|
|
215
|
+
}),
|
|
216
|
+
).resolves.toEqual([]);
|
|
117
217
|
});
|
|
118
218
|
|
|
119
219
|
test('dedupes a monitor reached both directly and through a Site', async () => {
|
|
@@ -268,39 +368,48 @@ describe('projectSubscribedMonitors', () => {
|
|
|
268
368
|
});
|
|
269
369
|
});
|
|
270
370
|
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
371
|
+
// Two encodings of "which device types can produce AQI" exist: the predicate
|
|
372
|
+
// isAqiCapableDeviceType, and the selectors inside projectSubscribedMonitors.
|
|
373
|
+
// Nothing structural keeps them in step, so a fourth type added to one and not
|
|
374
|
+
// the other would report as AQI-capable while contributing no audience — the
|
|
375
|
+
// silent failure this module exists to prevent. This pins them together.
|
|
376
|
+
// Every other case here passes plain object literals, but the real call site
|
|
377
|
+
// hands in user.subscribedDevices — a mongoose DocumentArray of subdocuments,
|
|
378
|
+
// which has to survive the isPlainObject filter. If it ever stopped doing so
|
|
379
|
+
// every entry would be dropped and the projection would return an empty
|
|
380
|
+
// audience with no error. Hydration needs no connection, so this stays DB-free.
|
|
381
|
+
describe('accepts real mongoose subdocuments, not just object literals', () => {
|
|
382
|
+
test('projects a hydrated user.subscribedDevices the same as plain objects', async () => {
|
|
383
|
+
const monitorId = oid();
|
|
384
|
+
const siteId = oid();
|
|
385
|
+
const siteMonitorId = oid();
|
|
284
386
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
387
|
+
const user = new Users({
|
|
388
|
+
subscribedDevices: [
|
|
389
|
+
{ deviceId: monitorId, deviceType: 'monitor' },
|
|
390
|
+
{ deviceId: siteId, deviceType: 'linked-device' },
|
|
391
|
+
{ deviceId: oid(), deviceType: 'sample-site' },
|
|
392
|
+
],
|
|
393
|
+
});
|
|
291
394
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
hasAqiCapableSubscription([{ deviceId: oid(), deviceType: 'linked-device' }]),
|
|
298
|
-
).toBe(true);
|
|
395
|
+
await expect(
|
|
396
|
+
projectSubscribedMonitors(user.subscribedDevices, {
|
|
397
|
+
resolveSiteMonitorIds: async () => [siteMonitorId],
|
|
398
|
+
}),
|
|
399
|
+
).resolves.toEqual([monitorId, siteMonitorId]);
|
|
299
400
|
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe('isAqiCapableDeviceType agrees with the projection', () => {
|
|
404
|
+
test.each(DEVICE_TYPE_VALUES)('%s', async (deviceType) => {
|
|
405
|
+
const deviceId = oid();
|
|
406
|
+
const projected = await projectSubscribedMonitors(
|
|
407
|
+
[{ deviceId, deviceType }],
|
|
408
|
+
// Resolves as though the id were a Site linking exactly one monitor, so
|
|
409
|
+
// a linked-device yields an audience and a sample-site still yields none.
|
|
410
|
+
{ resolveSiteMonitorIds: async () => [oid()] },
|
|
411
|
+
);
|
|
300
412
|
|
|
301
|
-
|
|
302
|
-
expect(hasAqiCapableSubscription(undefined)).toBe(false);
|
|
303
|
-
expect(hasAqiCapableSubscription([])).toBe(false);
|
|
304
|
-
expect(hasAqiCapableSubscription([{ deviceType: 'Monitor' }])).toBe(false);
|
|
413
|
+
expect(projected.length > 0).toBe(isAqiCapableDeviceType(deviceType));
|
|
305
414
|
});
|
|
306
415
|
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Pins the public export surface for the subscribedDevices module, kept in its
|
|
2
|
+
// own file because asserting on the root barrel imports src/index.js, which
|
|
3
|
+
// registers every mongoose model. Mixing that into the pure unit tests would
|
|
4
|
+
// make them fail whenever any unrelated model failed to load. Same shape as
|
|
5
|
+
// constants/tests/constantsBarrel.test.js.
|
|
6
|
+
//
|
|
7
|
+
// src/index.js is the published surface of this package, so an export here
|
|
8
|
+
// cannot be withdrawn without a major bump. Flipping either assertion is a
|
|
9
|
+
// deliberate contract change, not a test fix.
|
|
10
|
+
import {
|
|
11
|
+
projectSubscribedMonitors,
|
|
12
|
+
SUBSCRIBED_DEVICES_ERRORS,
|
|
13
|
+
} from '../subscribedDevices.js';
|
|
14
|
+
import {
|
|
15
|
+
DEVICE_TYPES,
|
|
16
|
+
DEVICE_TYPE_VALUES,
|
|
17
|
+
isAqiCapableDeviceType,
|
|
18
|
+
} from '../../constants/deviceTypes.js';
|
|
19
|
+
|
|
20
|
+
test('barrel re-exports the subscribedDevices surface unchanged (identity, not copies)', async () => {
|
|
21
|
+
const barrel = await import('../../index.js');
|
|
22
|
+
|
|
23
|
+
expect(barrel.projectSubscribedMonitors).toBe(projectSubscribedMonitors);
|
|
24
|
+
expect(barrel.SUBSCRIBED_DEVICES_ERRORS).toBe(SUBSCRIBED_DEVICES_ERRORS);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// constants/tests/deviceTypes.test.js pins these on the `./constants` barrel;
|
|
28
|
+
// the root barrel re-exports them through a separate hand-written import and
|
|
29
|
+
// export pair, so a dropped line there publishes cleanly and only fails at
|
|
30
|
+
// runtime inside a consuming service. Pinned here rather than there because
|
|
31
|
+
// this is the file that already carries the root-barrel dependency.
|
|
32
|
+
test('barrel re-exports the device-type vocabulary unchanged (identity, not copies)', async () => {
|
|
33
|
+
const barrel = await import('../../index.js');
|
|
34
|
+
|
|
35
|
+
expect(barrel.DEVICE_TYPES).toBe(DEVICE_TYPES);
|
|
36
|
+
expect(barrel.DEVICE_TYPE_VALUES).toBe(DEVICE_TYPE_VALUES);
|
|
37
|
+
expect(barrel.isAqiCapableDeviceType).toBe(isAqiCapableDeviceType);
|
|
38
|
+
});
|