@justair/justair-library 7.5.0 → 7.6.0-alpha.4ff5aae
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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/utils/subscribedDevices.d.ts +18 -0
- package/dist/utils/subscribedDevices.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.js +21 -6
- package/src/utils/subscribedDevices.js +241 -67
- package/src/utils/tests/subscribedDevices.test.js +250 -0
- package/src/utils/tests/subscribedDevicesBarrel.test.js +4 -0
package/dist/index.d.ts
CHANGED
|
@@ -107,6 +107,8 @@ 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 { resolveMonitorIdsByUser } from "./utils/subscribedDevices.js";
|
|
111
|
+
import { VISIBLE_SITE_MONITOR_FILTER } from "./utils/subscribedDevices.js";
|
|
110
112
|
import { SUBSCRIBED_DEVICES_ERRORS } from "./utils/subscribedDevices.js";
|
|
111
113
|
import { splitName } from "./utils/splitName.js";
|
|
112
114
|
import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
|
|
@@ -118,5 +120,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
|
|
|
118
120
|
import { getZoneMinutes } from "./utils/quietHours.js";
|
|
119
121
|
import { isInQuietWindow } from "./utils/quietHours.js";
|
|
120
122
|
import { resolveQuietHours } from "./utils/quietHours.js";
|
|
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 };
|
|
123
|
+
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, resolveMonitorIdsByUser, VISIBLE_SITE_MONITOR_FILTER, SUBSCRIBED_DEVICES_ERRORS, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
|
|
122
124
|
//# 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":"AA+JA;;;iBAEC;yBA/FwB,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;4BA8CT,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;sCAlDtF,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;mCAiE3D,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAsB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;yEAAnC,mCAAmC;2EAAnC,mCAAmC;2BA1FD,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;6BAOzE,4BAA4B;mCAA5B,4BAA4B;uCAA5B,4BAA4B;0CAgC5B,8BAA8B;wCAA9B,8BAA8B;4CAA9B,8BAA8B;0CAA9B,8BAA8B;0BAhBX,sBAAsB;oCASzC,uBAAuB;iCA/BG,0BAA0B;mCA+BpD,uBAAuB;kCAAvB,uBAAuB;oCACM,qBAAqB;mCADlD,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
|
|
@@ -3,6 +3,15 @@ export const SUBSCRIBED_DEVICES_ERRORS: Readonly<{
|
|
|
3
3
|
RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array";
|
|
4
4
|
RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays";
|
|
5
5
|
RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents";
|
|
6
|
+
MISSING_COHORT_SITE_RESOLVER: "missing_cohort_site_resolver";
|
|
7
|
+
COHORT_RESOLVER_RETURNED_NON_MAP: "cohort_resolver_returned_non_map";
|
|
8
|
+
COHORT_USER_MISSING_ID: "cohort_user_missing_id";
|
|
9
|
+
}>;
|
|
10
|
+
export const VISIBLE_SITE_MONITOR_FILTER: Readonly<{
|
|
11
|
+
isPrivate: {
|
|
12
|
+
$ne: boolean;
|
|
13
|
+
};
|
|
14
|
+
isActive: true;
|
|
6
15
|
}>;
|
|
7
16
|
export function projectSubscribedMonitors(subscribedDevices?: Array<{
|
|
8
17
|
deviceId: import("mongoose").Types.ObjectId;
|
|
@@ -10,4 +19,13 @@ export function projectSubscribedMonitors(subscribedDevices?: Array<{
|
|
|
10
19
|
}>, { resolveSiteMonitorIds }?: {
|
|
11
20
|
resolveSiteMonitorIds?: (siteIds: import("mongoose").Types.ObjectId[]) => Promise<Array<import("mongoose").Types.ObjectId | null | undefined>>;
|
|
12
21
|
}): Promise<import("mongoose").Types.ObjectId[]>;
|
|
22
|
+
export function resolveMonitorIdsByUser(users?: Array<{
|
|
23
|
+
_id: any;
|
|
24
|
+
subscribedDevices?: Array<{
|
|
25
|
+
deviceId: import("mongoose").Types.ObjectId;
|
|
26
|
+
deviceType: import("../constants/deviceTypes.js").DeviceType;
|
|
27
|
+
}>;
|
|
28
|
+
}>, { resolveVisibleSiteMonitors }?: {
|
|
29
|
+
resolveVisibleSiteMonitors?: (siteIds: import("mongoose").Types.ObjectId[]) => Promise<Map<string, import("mongoose").Types.ObjectId[]>>;
|
|
30
|
+
}): Promise<Map<string, import("mongoose").Types.ObjectId[]>>;
|
|
13
31
|
//# 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":"AAmEA;;;;;;;;GASG;AAyBH;;;;;GAGG;AAiLI,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;AAkEM,gDAPI,KAAK,CAAC;IAAE,GAAG,EAAE,GAAG,CAAC;IAAC,iBAAiB,CAAC,EAAE,KAAK,CAAC;QAClD,QAAQ,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,CAAC;QAC5C,UAAU,EAAE,OAAO,6BAA6B,EAAE,UAAU,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,mCAC5D;IAAE,0BAA0B,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,KAClF,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAA;CAAE,GACnD,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CA2DrE"}
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -102,6 +102,8 @@ import {
|
|
|
102
102
|
import { isValidIANATimezone } from "./utils/timezone.js";
|
|
103
103
|
import {
|
|
104
104
|
projectSubscribedMonitors,
|
|
105
|
+
resolveMonitorIdsByUser,
|
|
106
|
+
VISIBLE_SITE_MONITOR_FILTER,
|
|
105
107
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
106
108
|
} from "./utils/subscribedDevices.js";
|
|
107
109
|
|
|
@@ -272,13 +274,26 @@ export {
|
|
|
272
274
|
DEVICE_TYPES,
|
|
273
275
|
DEVICE_TYPE_VALUES,
|
|
274
276
|
isAqiCapableDeviceType,
|
|
275
|
-
// subscribedDevices →
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
277
|
+
// subscribedDevices → monitor ids, resolved at READ time (JA-3110 / JA-3335).
|
|
278
|
+
// Users.subscribedDevices is the source of truth; Users.subscribedMonitors is
|
|
279
|
+
// being retired rather than kept in step with it. Nothing should store this
|
|
280
|
+
// result — see the module header for the five bugs a stored copy caused.
|
|
281
|
+
//
|
|
282
|
+
// resolveMonitorIdsByUser — the batch form. One Sites lookup for a whole
|
|
283
|
+
// cohort. What alert runs, org pages and session
|
|
284
|
+
// responses should call.
|
|
285
|
+
// projectSubscribedMonitors — the single-user rule the batch form is built
|
|
286
|
+
// on. Named after the field being retired; it
|
|
287
|
+
// gets renamed when that field is dropped.
|
|
288
|
+
// VISIBLE_SITE_MONITOR_FILTER — the privacy predicate every consumer must
|
|
289
|
+
// apply when turning a Site into monitor ids.
|
|
290
|
+
// Sites.monitorIds is unfiltered; reading it
|
|
291
|
+
// straight through leaks private monitors.
|
|
292
|
+
// SUBSCRIBED_DEVICES_ERRORS — stable `err.code` vocabulary; consumers
|
|
293
|
+
// branch on it, never on message wording.
|
|
281
294
|
projectSubscribedMonitors,
|
|
295
|
+
resolveMonitorIdsByUser,
|
|
296
|
+
VISIBLE_SITE_MONITOR_FILTER,
|
|
282
297
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
283
298
|
// Utilities
|
|
284
299
|
splitName,
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
// Subscribed devices (JA-3110 / JA-3335):
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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":
|
|
1
|
+
// Subscribed devices (JA-3110 / JA-3335): resolving Users.subscribedDevices —
|
|
2
|
+
// the source of truth for what a user follows, across all three device types —
|
|
3
|
+
// down to the flat monitor ids that make up their AQI-alert audience.
|
|
13
4
|
//
|
|
14
5
|
// monitor → contributes its own deviceId
|
|
15
6
|
// linked-device → contributes the monitorIds of its Site (none if the Site
|
|
@@ -18,65 +9,56 @@
|
|
|
18
9
|
// sample-site → contributes nothing; no live AQI, so it can never trigger
|
|
19
10
|
// an AQI alert
|
|
20
11
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
12
|
+
// Pure: no DB, no logger, no clock. The one thing this module cannot know on
|
|
13
|
+
// its own is which monitors a Site links, so that lookup is injected.
|
|
14
|
+
//
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// THIS IS A READ-TIME RESOLUTION, NOT A STORED PROJECTION
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// `Users.subscribedMonitors` used to be a denormalized copy of this result,
|
|
19
|
+
// written on every subscribe and unsubscribe. It is being removed
|
|
20
|
+
// (JA-3110 "Retire subscribedMonitors"), and this module is what replaces it:
|
|
21
|
+
// consumers resolve the audience when they need it and store nothing.
|
|
22
|
+
//
|
|
23
|
+
// That decision came out of the JA-3110 review, where five separate
|
|
24
|
+
// silent-failure bugs all traced back to keeping a stored copy in step with
|
|
25
|
+
// the source of truth:
|
|
26
|
+
//
|
|
27
|
+
// * a legacy id with no subscribedDevices row being dropped when a Site that
|
|
28
|
+
// linked the same monitor was unfollowed
|
|
29
|
+
// * "half-migrated" users — a stored copy holding ids the source of truth
|
|
30
|
+
// did not — passing a migration gate that checked presence, not content
|
|
31
|
+
// * unsubscribe computing a removal set from a snapshot, so a concurrent
|
|
32
|
+
// subscribe lost its contribution
|
|
33
|
+
// * a hard-deleted Site leaving its monitors in the stored copy forever
|
|
34
|
+
// * a Site gaining or losing a monitor never reaching its existing followers
|
|
35
|
+
//
|
|
36
|
+
// None of those exist here. There is no snapshot to go stale, nothing to
|
|
37
|
+
// reproject when a Site changes, and unsubscribe is a plain `$pull` of one
|
|
38
|
+
// subscribedDevices entry. If you find yourself reintroducing a stored copy of
|
|
39
|
+
// this result, you are reintroducing all five.
|
|
27
40
|
//
|
|
28
41
|
// ---------------------------------------------------------------------------
|
|
29
|
-
//
|
|
42
|
+
// PRIVACY IS THE CALLER'S JOB, AND IT IS NOT OPTIONAL
|
|
30
43
|
// ---------------------------------------------------------------------------
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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 — 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
|
|
59
|
-
// overwrite. Removing a subscription is not correct any other way.
|
|
60
|
-
//
|
|
61
|
-
// This module deliberately does NOT offer a `union: true` flag. A blanket
|
|
62
|
-
// union is correct for subscribe and silently wrong for unsubscribe — it would
|
|
63
|
-
// make removal a no-op — so the choice belongs at the call site, which knows
|
|
64
|
-
// which operation it is performing. The backfill itself is consumer-side
|
|
65
|
-
// (JustAirUserService owns the write path) and is not shipped in this library;
|
|
66
|
-
// it needs its own ticket under JA-3110 before any consumer moves to
|
|
67
|
-
// overwrite-always.
|
|
44
|
+
// A Site's `monitorIds` is a raw membership list with no visibility filtering.
|
|
45
|
+
// Reading it straight through enrols a Site's followers in alerts for any
|
|
46
|
+
// private monitor it links — a real leak, not a theoretical one, because the
|
|
47
|
+
// notification service filters monitors on `monitorState`/`isActive` and not on
|
|
48
|
+
// `isPrivate`.
|
|
49
|
+
//
|
|
50
|
+
// So the injected resolver must return only VISIBLE monitors, which is why it
|
|
51
|
+
// is named `resolveVisibleSiteMonitors` and why VISIBLE_SITE_MONITOR_FILTER
|
|
52
|
+
// exists below: three repos need the same predicate, and `$ne: true` (rather
|
|
53
|
+
// than `false`) is the half that is easy to get wrong on documents written
|
|
54
|
+
// before `isPrivate` existed.
|
|
68
55
|
//
|
|
69
56
|
// ---------------------------------------------------------------------------
|
|
70
|
-
//
|
|
57
|
+
// NAMING
|
|
71
58
|
// ---------------------------------------------------------------------------
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// wrong — a monitor added to a Site never alerts its existing subscribers, and
|
|
76
|
-
// a removed one keeps alerting them, in both cases silently. Any code that
|
|
77
|
-
// mutates Sites.monitorIds is therefore responsible for reprojecting the
|
|
78
|
-
// affected subscribers. That reprojection is not implemented anywhere yet and
|
|
79
|
-
// needs a follow-up ticket under JA-3110.
|
|
59
|
+
// `projectSubscribedMonitors` is named after the field being retired. It is
|
|
60
|
+
// kept under that name while consumers migrate; renaming it to something that
|
|
61
|
+
// does not reference a dead field belongs in the step that drops the field.
|
|
80
62
|
|
|
81
63
|
import { DEVICE_TYPES } from "../constants/deviceTypes.js";
|
|
82
64
|
|
|
@@ -88,6 +70,38 @@ export const SUBSCRIBED_DEVICES_ERRORS = Object.freeze({
|
|
|
88
70
|
RESOLVER_RETURNED_NON_ARRAY: "resolver_returned_non_array",
|
|
89
71
|
RESOLVER_RETURNED_NESTED_ARRAYS: "resolver_returned_nested_arrays",
|
|
90
72
|
RESOLVER_RETURNED_DOCUMENTS: "resolver_returned_documents",
|
|
73
|
+
// resolveMonitorIdsByUser only
|
|
74
|
+
MISSING_COHORT_SITE_RESOLVER: "missing_cohort_site_resolver",
|
|
75
|
+
COHORT_RESOLVER_RETURNED_NON_MAP: "cohort_resolver_returned_non_map",
|
|
76
|
+
COHORT_USER_MISSING_ID: "cohort_user_missing_id",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// The predicate that makes a Site's monitor visible to its followers. Frozen
|
|
80
|
+
// and exported because JustAirService, JustAirUserService and
|
|
81
|
+
// JustAirNotificationService all need the same one, and a fourth hand-written
|
|
82
|
+
// copy is how one of them quietly starts leaking private monitors.
|
|
83
|
+
//
|
|
84
|
+
// `isPrivate: { $ne: true }` rather than `{ $eq: false }`: the field has a
|
|
85
|
+
// schema default but that only governs writes made through mongoose, so
|
|
86
|
+
// documents written before it existed have no key at all and would vanish
|
|
87
|
+
// under an exact match. Same rule the public-map path uses.
|
|
88
|
+
//
|
|
89
|
+
// Spread it into the Monitors query the resolver is built from:
|
|
90
|
+
//
|
|
91
|
+
// const monitors = await Monitors.find(
|
|
92
|
+
// { _id: { $in: siteMonitorIds }, ...VISIBLE_SITE_MONITOR_FILTER },
|
|
93
|
+
// "_id",
|
|
94
|
+
// ).lean();
|
|
95
|
+
//
|
|
96
|
+
// `monitorState: "Deployed"` is deliberately NOT here. That is an
|
|
97
|
+
// alerting-eligibility rule, not a visibility rule, and it belongs to the
|
|
98
|
+
// notification service alone — the org admin page and the session response
|
|
99
|
+
// must still show a follower the monitors they follow while those monitors sit
|
|
100
|
+
// in a non-Deployed state. Do not consolidate the two filters; keep
|
|
101
|
+
// JustAirNotificationService's own `monitorState` predicate where it is.
|
|
102
|
+
export const VISIBLE_SITE_MONITOR_FILTER = Object.freeze({
|
|
103
|
+
isPrivate: { $ne: true },
|
|
104
|
+
isActive: true,
|
|
91
105
|
});
|
|
92
106
|
|
|
93
107
|
// Dedupe by string form, keep the caller's ObjectId instances. Two equal
|
|
@@ -189,6 +203,38 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
189
203
|
return resolved;
|
|
190
204
|
};
|
|
191
205
|
|
|
206
|
+
// The cohort resolver's return, checked the same way and for the same reason:
|
|
207
|
+
// it is consumer-supplied, and every bad shape below would otherwise resolve to
|
|
208
|
+
// an empty audience rather than an error — a user silently stops getting
|
|
209
|
+
// alerts, which is the one outcome this module exists to prevent.
|
|
210
|
+
//
|
|
211
|
+
// Keys are re-derived with String() rather than trusted. A consumer who builds
|
|
212
|
+
// `new Map([[site._id, ids]])` with raw ObjectId keys produces a Map that looks
|
|
213
|
+
// correct, passes every type check, and misses on every `.get(String(id))`
|
|
214
|
+
// because two equal ObjectIds are different object identities. Normalising is
|
|
215
|
+
// forgiving in precisely the place where strictness would fail silently.
|
|
216
|
+
const validateResolvedSiteMonitorMap = (resolved) => {
|
|
217
|
+
if (resolved === null || resolved === undefined) {
|
|
218
|
+
return new Map();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!(resolved instanceof Map)) {
|
|
222
|
+
const error = new TypeError(
|
|
223
|
+
"resolveVisibleSiteMonitors must resolve to a Map of site id to monitor ids"
|
|
224
|
+
);
|
|
225
|
+
error.code = SUBSCRIBED_DEVICES_ERRORS.COHORT_RESOLVER_RETURNED_NON_MAP;
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return new Map(
|
|
230
|
+
[...resolved.entries()].map(([siteId, monitorIds]) => [
|
|
231
|
+
String(siteId),
|
|
232
|
+
validateResolvedSiteMonitorIds(monitorIds),
|
|
233
|
+
])
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
|
|
192
238
|
// Returns the monitor ids that should be stored in Users.subscribedMonitors for
|
|
193
239
|
// the given subscribedDevices array. Order is deterministic — direct `monitor`
|
|
194
240
|
// subscriptions in array order, then Site-derived ids in resolver order, first
|
|
@@ -219,8 +265,13 @@ const validateResolvedSiteMonitorIds = (resolved) => {
|
|
|
219
265
|
// Site has no array — and they are filtered out here rather than trusted to
|
|
220
266
|
// the caller.
|
|
221
267
|
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
268
|
+
// Do NOT store this result. Read the "THIS IS A READ-TIME RESOLUTION" note at
|
|
269
|
+
// the top of this file — a stored copy is where all five JA-3110 bugs came
|
|
270
|
+
// from. Its privacy obligation applies here too: the ids this returns are only
|
|
271
|
+
// as safe to alert on as the resolver that produced them.
|
|
272
|
+
//
|
|
273
|
+
// For a whole cohort of users, prefer resolveMonitorIdsByUser below — it does
|
|
274
|
+
// the Sites lookup once for the batch instead of once per user.
|
|
224
275
|
/**
|
|
225
276
|
* @param {Array<{ deviceId: import("mongoose").Types.ObjectId,
|
|
226
277
|
* deviceType: import("../constants/deviceTypes.js").DeviceType }>} [subscribedDevices]
|
|
@@ -253,3 +304,126 @@ export const projectSubscribedMonitors = async (
|
|
|
253
304
|
);
|
|
254
305
|
return dedupeById([...direct, ...siteMonitorIds]);
|
|
255
306
|
};
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Cohort-scoped resolution
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// The batch form of the above, and the one every read-time consumer should
|
|
312
|
+
// reach for: an alert run, an org admin page, a session response. Resolves a
|
|
313
|
+
// whole list of users with ONE Sites lookup covering every Site anyone in the
|
|
314
|
+
// cohort follows, rather than one lookup per user.
|
|
315
|
+
//
|
|
316
|
+
// That distinction is the difference between a constant-cost alert run and an
|
|
317
|
+
// N+1 across the entire user base, which is the shape this codebase keeps
|
|
318
|
+
// re-learning. The lookup is not performed at all when nobody in the cohort
|
|
319
|
+
// follows a linked-device.
|
|
320
|
+
//
|
|
321
|
+
// resolveVisibleSiteMonitors(siteIds) -> Map<string siteId, ObjectId[]>
|
|
322
|
+
//
|
|
323
|
+
// The resolver is keyed per Site rather than returning a flat union, because
|
|
324
|
+
// each user needs their own Sites' monitors, not the cohort's. It must return
|
|
325
|
+
// only VISIBLE monitors — see the privacy note at the top of this file, and
|
|
326
|
+
// build it from VISIBLE_SITE_MONITOR_FILTER:
|
|
327
|
+
//
|
|
328
|
+
// const sites = await Sites.find(
|
|
329
|
+
// { _id: { $in: siteIds } }, "monitorIds",
|
|
330
|
+
// ).lean();
|
|
331
|
+
// const monitors = await Monitors.find(
|
|
332
|
+
// {
|
|
333
|
+
// _id: { $in: sites.flatMap((s) => s.monitorIds ?? []) },
|
|
334
|
+
// ...VISIBLE_SITE_MONITOR_FILTER,
|
|
335
|
+
// },
|
|
336
|
+
// "_id",
|
|
337
|
+
// ).lean();
|
|
338
|
+
// const visible = new Set(monitors.map((m) => String(m._id)));
|
|
339
|
+
// return new Map(sites.map((s) => [
|
|
340
|
+
// String(s._id),
|
|
341
|
+
// (s.monitorIds ?? []).filter((id) => visible.has(String(id))),
|
|
342
|
+
// ]));
|
|
343
|
+
//
|
|
344
|
+
// A Site id the resolver omits resolves to no monitors for that user rather
|
|
345
|
+
// than throwing — a followed Site that has since been deleted is an ordinary
|
|
346
|
+
// state, not an error. What that user is left holding (an entry that resolves
|
|
347
|
+
// to nothing, so their alerts stop) is the subscription-identity question
|
|
348
|
+
// tracked in TODOS, and is deliberately not papered over here.
|
|
349
|
+
//
|
|
350
|
+
// Users are keyed by `String(user._id)`, so callers look up with the same. A
|
|
351
|
+
// user with no `_id` throws rather than being skipped: skipping would silently
|
|
352
|
+
// remove a real person from an alert audience, which is the failure mode this
|
|
353
|
+
// whole module exists to avoid.
|
|
354
|
+
//
|
|
355
|
+
// Consumers can shrink the cohort by pushing the AQI-capability predicate into
|
|
356
|
+
// the Users query, so rows that can never alert are not loaded at all:
|
|
357
|
+
//
|
|
358
|
+
// { "subscribedDevices.deviceType": { $in: AQI_CAPABLE_DEVICE_TYPES } }
|
|
359
|
+
//
|
|
360
|
+
// There is no index on `subscribedDevices.deviceType` (only on
|
|
361
|
+
// `subscribedDevices.deviceId`), so this narrows the documents returned rather
|
|
362
|
+
// than the documents scanned — still worth it on a cohort query that is already
|
|
363
|
+
// scanning, not worth building a JS-side filter for.
|
|
364
|
+
/**
|
|
365
|
+
* @param {Array<{ _id: any, subscribedDevices?: Array<{
|
|
366
|
+
* deviceId: import("mongoose").Types.ObjectId,
|
|
367
|
+
* deviceType: import("../constants/deviceTypes.js").DeviceType }> }>} [users]
|
|
368
|
+
* @param {{ resolveVisibleSiteMonitors?: (siteIds: import("mongoose").Types.ObjectId[]) =>
|
|
369
|
+
* Promise<Map<string, import("mongoose").Types.ObjectId[]>> }} [options]
|
|
370
|
+
* @returns {Promise<Map<string, import("mongoose").Types.ObjectId[]>>}
|
|
371
|
+
*/
|
|
372
|
+
export const resolveMonitorIdsByUser = async (
|
|
373
|
+
users,
|
|
374
|
+
{ resolveVisibleSiteMonitors } = {}
|
|
375
|
+
) => {
|
|
376
|
+
const cohort = (users ?? []).filter(isPlainObject);
|
|
377
|
+
|
|
378
|
+
const missing = cohort.find(
|
|
379
|
+
(user) => user._id === null || user._id === undefined
|
|
380
|
+
);
|
|
381
|
+
if (missing) {
|
|
382
|
+
const error = new TypeError(
|
|
383
|
+
"resolveMonitorIdsByUser requires every user to carry an _id"
|
|
384
|
+
);
|
|
385
|
+
error.code = SUBSCRIBED_DEVICES_ERRORS.COHORT_USER_MISSING_ID;
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Every Site anyone in the cohort follows, deduped across users — the point
|
|
390
|
+
// of the batch form. Two users following the same Site cost one entry.
|
|
391
|
+
const siteIds = dedupeById(
|
|
392
|
+
cohort.flatMap((user) =>
|
|
393
|
+
linkedSiteIds((user.subscribedDevices ?? []).filter(isPlainObject))
|
|
394
|
+
)
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
let monitorIdsBySite = new Map();
|
|
398
|
+
if (siteIds.length > 0) {
|
|
399
|
+
if (typeof resolveVisibleSiteMonitors !== "function") {
|
|
400
|
+
const error = new TypeError(
|
|
401
|
+
"resolveMonitorIdsByUser requires resolveVisibleSiteMonitors when anyone in the cohort follows a linked-device"
|
|
402
|
+
);
|
|
403
|
+
error.code = SUBSCRIBED_DEVICES_ERRORS.MISSING_COHORT_SITE_RESOLVER;
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
monitorIdsBySite = validateResolvedSiteMonitorMap(
|
|
407
|
+
await resolveVisibleSiteMonitors(siteIds)
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Synchronous and Map-backed, so the per-user projections below issue no
|
|
412
|
+
// further lookups no matter how large the cohort is. Per-entry validation
|
|
413
|
+
// (arrays, not nested arrays, ids rather than documents) is inherited from
|
|
414
|
+
// projectSubscribedMonitors rather than duplicated.
|
|
415
|
+
const resolveSiteMonitorIds = (ids) =>
|
|
416
|
+
ids.flatMap((id) => monitorIdsBySite.get(String(id)) ?? []);
|
|
417
|
+
|
|
418
|
+
const resolved = await Promise.all(
|
|
419
|
+
cohort.map((user) =>
|
|
420
|
+
projectSubscribedMonitors(user.subscribedDevices, {
|
|
421
|
+
resolveSiteMonitorIds,
|
|
422
|
+
})
|
|
423
|
+
)
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
return new Map(
|
|
427
|
+
cohort.map((user, index) => [String(user._id), resolved[index]])
|
|
428
|
+
);
|
|
429
|
+
};
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
} from '../../constants/deviceTypes.js';
|
|
7
7
|
import {
|
|
8
8
|
projectSubscribedMonitors,
|
|
9
|
+
resolveMonitorIdsByUser,
|
|
10
|
+
VISIBLE_SITE_MONITOR_FILTER,
|
|
9
11
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
10
12
|
} from '../subscribedDevices.js';
|
|
11
13
|
|
|
@@ -413,3 +415,251 @@ describe('isAqiCapableDeviceType agrees with the projection', () => {
|
|
|
413
415
|
expect(projected.length > 0).toBe(isAqiCapableDeviceType(deviceType));
|
|
414
416
|
});
|
|
415
417
|
});
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// resolveMonitorIdsByUser — the cohort form
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// The batch form exists for exactly two reasons, and both are asserted here
|
|
423
|
+
// rather than just described: it must issue ONE Sites lookup for a whole
|
|
424
|
+
// cohort (never one per user), and each user must get THEIR OWN Sites'
|
|
425
|
+
// monitors, not the cohort's union. Get the second wrong and every follower of
|
|
426
|
+
// any Site is enrolled in every other Site's alerts — a bug that looks like
|
|
427
|
+
// working code on a one-user fixture.
|
|
428
|
+
|
|
429
|
+
// Records what the cohort resolver was asked for, so the tests can assert the
|
|
430
|
+
// call shape and not just the result.
|
|
431
|
+
const recordingSiteMap = (byId) => {
|
|
432
|
+
const calls = [];
|
|
433
|
+
const resolve = async (siteIds) => {
|
|
434
|
+
calls.push(siteIds);
|
|
435
|
+
return new Map(siteIds.map((id) => [String(id), byId[String(id)] ?? []]));
|
|
436
|
+
};
|
|
437
|
+
return { resolve, calls };
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const user = (subscribedDevices) => ({ _id: oid(), subscribedDevices });
|
|
441
|
+
|
|
442
|
+
describe('resolveMonitorIdsByUser', () => {
|
|
443
|
+
test('keys the result by String(user._id)', async () => {
|
|
444
|
+
const u = user([]);
|
|
445
|
+
|
|
446
|
+
const result = await resolveMonitorIdsByUser([u]);
|
|
447
|
+
|
|
448
|
+
expect([...result.keys()]).toEqual([String(u._id)]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('a monitor-only cohort never touches the Sites resolver', async () => {
|
|
452
|
+
// The cheap path: a consumer whose users follow no Sites pays no lookup.
|
|
453
|
+
const { resolve, calls } = recordingSiteMap({});
|
|
454
|
+
const [m1, m2] = [oid(), oid()];
|
|
455
|
+
const [u1, u2] = [
|
|
456
|
+
user([{ deviceId: m1, deviceType: 'monitor' }]),
|
|
457
|
+
user([{ deviceId: m2, deviceType: 'monitor' }]),
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
const result = await resolveMonitorIdsByUser([u1, u2], {
|
|
461
|
+
resolveVisibleSiteMonitors: resolve,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
expect(calls).toEqual([]);
|
|
465
|
+
expect(result.get(String(u1._id))).toEqual([m1]);
|
|
466
|
+
expect(result.get(String(u2._id))).toEqual([m2]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('issues exactly ONE resolver call for the whole cohort, deduped', async () => {
|
|
470
|
+
// Two users following the same Site must cost one entry, not two — this is
|
|
471
|
+
// the entire point of the batch form.
|
|
472
|
+
const [siteA, siteB] = [oid(), oid()];
|
|
473
|
+
const { resolve, calls } = recordingSiteMap({
|
|
474
|
+
[siteA]: [oid()],
|
|
475
|
+
[siteB]: [oid()],
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await resolveMonitorIdsByUser(
|
|
479
|
+
[
|
|
480
|
+
user([{ deviceId: siteA, deviceType: 'linked-device' }]),
|
|
481
|
+
user([{ deviceId: siteA, deviceType: 'linked-device' }]),
|
|
482
|
+
user([{ deviceId: siteB, deviceType: 'linked-device' }]),
|
|
483
|
+
],
|
|
484
|
+
{ resolveVisibleSiteMonitors: resolve },
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
expect(calls).toHaveLength(1);
|
|
488
|
+
expect(calls[0].map(String).sort()).toEqual(
|
|
489
|
+
[String(siteA), String(siteB)].sort(),
|
|
490
|
+
);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test('each user gets only THEIR sites monitors, never the cohort union', async () => {
|
|
494
|
+
const [siteA, siteB] = [oid(), oid()];
|
|
495
|
+
const [a1, b1] = [oid(), oid()];
|
|
496
|
+
const { resolve } = recordingSiteMap({ [siteA]: [a1], [siteB]: [b1] });
|
|
497
|
+
const [ua, ub] = [
|
|
498
|
+
user([{ deviceId: siteA, deviceType: 'linked-device' }]),
|
|
499
|
+
user([{ deviceId: siteB, deviceType: 'linked-device' }]),
|
|
500
|
+
];
|
|
501
|
+
|
|
502
|
+
const result = await resolveMonitorIdsByUser([ua, ub], {
|
|
503
|
+
resolveVisibleSiteMonitors: resolve,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
expect(result.get(String(ua._id))).toEqual([a1]);
|
|
507
|
+
expect(result.get(String(ub._id))).toEqual([b1]);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('dedupes a monitor followed both directly and through a Site', async () => {
|
|
511
|
+
const siteId = oid();
|
|
512
|
+
const shared = oid();
|
|
513
|
+
const { resolve } = recordingSiteMap({ [siteId]: [shared] });
|
|
514
|
+
const u = user([
|
|
515
|
+
{ deviceId: shared, deviceType: 'monitor' },
|
|
516
|
+
{ deviceId: siteId, deviceType: 'linked-device' },
|
|
517
|
+
]);
|
|
518
|
+
|
|
519
|
+
const result = await resolveMonitorIdsByUser([u], {
|
|
520
|
+
resolveVisibleSiteMonitors: resolve,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
expect(result.get(String(u._id))).toEqual([shared]);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('a sample-site follower resolves to no monitors', async () => {
|
|
527
|
+
const u = user([{ deviceId: oid(), deviceType: 'sample-site' }]);
|
|
528
|
+
|
|
529
|
+
const result = await resolveMonitorIdsByUser([u]);
|
|
530
|
+
|
|
531
|
+
expect(result.get(String(u._id))).toEqual([]);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test('a Site the resolver omits resolves to nothing, not an error', async () => {
|
|
535
|
+
// A followed Site that has since been hard-deleted. Their alerts stopping
|
|
536
|
+
// is the subscription-identity question, not a crash in the resolver.
|
|
537
|
+
const deletedSite = oid();
|
|
538
|
+
const { resolve } = recordingSiteMap({});
|
|
539
|
+
const u = user([{ deviceId: deletedSite, deviceType: 'linked-device' }]);
|
|
540
|
+
|
|
541
|
+
const result = await resolveMonitorIdsByUser([u], {
|
|
542
|
+
resolveVisibleSiteMonitors: resolve,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
expect(result.get(String(u._id))).toEqual([]);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('normalises ObjectId keys in the returned Map', async () => {
|
|
549
|
+
// The silent-miss trap: `new Map([[site._id, ids]])` type-checks, looks
|
|
550
|
+
// correct, and misses every lookup because two equal ObjectIds are
|
|
551
|
+
// different object identities. Forgiven here rather than failing quietly.
|
|
552
|
+
const siteId = oid();
|
|
553
|
+
const monitorId = oid();
|
|
554
|
+
const u = user([{ deviceId: siteId, deviceType: 'linked-device' }]);
|
|
555
|
+
|
|
556
|
+
const result = await resolveMonitorIdsByUser([u], {
|
|
557
|
+
resolveVisibleSiteMonitors: async () => new Map([[siteId, [monitorId]]]),
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
expect(result.get(String(u._id))).toEqual([monitorId]);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('tolerates a user with no subscribedDevices key at all', async () => {
|
|
564
|
+
const u = { _id: oid() };
|
|
565
|
+
|
|
566
|
+
const result = await resolveMonitorIdsByUser([u]);
|
|
567
|
+
|
|
568
|
+
expect(result.get(String(u._id))).toEqual([]);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('an empty or nullish cohort resolves to an empty Map', async () => {
|
|
572
|
+
expect(await resolveMonitorIdsByUser([])).toEqual(new Map());
|
|
573
|
+
expect(await resolveMonitorIdsByUser(undefined)).toEqual(new Map());
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
describe('resolveMonitorIdsByUser — rejects what would otherwise fail silently', () => {
|
|
578
|
+
test('throws MISSING_COHORT_SITE_RESOLVER when anyone follows a linked-device', async () => {
|
|
579
|
+
await expect(
|
|
580
|
+
resolveMonitorIdsByUser([
|
|
581
|
+
user([{ deviceId: oid(), deviceType: 'linked-device' }]),
|
|
582
|
+
]),
|
|
583
|
+
).rejects.toMatchObject({
|
|
584
|
+
code: SUBSCRIBED_DEVICES_ERRORS.MISSING_COHORT_SITE_RESOLVER,
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('does NOT require a resolver when nobody follows a linked-device', async () => {
|
|
589
|
+
const monitorId = oid();
|
|
590
|
+
const u = user([{ deviceId: monitorId, deviceType: 'monitor' }]);
|
|
591
|
+
|
|
592
|
+
const result = await resolveMonitorIdsByUser([u]);
|
|
593
|
+
|
|
594
|
+
expect(result.get(String(u._id))).toEqual([monitorId]);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test('throws COHORT_RESOLVER_RETURNED_NON_MAP for an array of entries', async () => {
|
|
598
|
+
// The natural mistake: returning `[[siteId, ids]]` instead of a Map.
|
|
599
|
+
const siteId = oid();
|
|
600
|
+
|
|
601
|
+
await expect(
|
|
602
|
+
resolveMonitorIdsByUser(
|
|
603
|
+
[user([{ deviceId: siteId, deviceType: 'linked-device' }])],
|
|
604
|
+
{ resolveVisibleSiteMonitors: async () => [[String(siteId), [oid()]]] },
|
|
605
|
+
),
|
|
606
|
+
).rejects.toMatchObject({
|
|
607
|
+
code: SUBSCRIBED_DEVICES_ERRORS.COHORT_RESOLVER_RETURNED_NON_MAP,
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('a nullish resolver return is an empty audience, not a throw', async () => {
|
|
612
|
+
const u = user([{ deviceId: oid(), deviceType: 'linked-device' }]);
|
|
613
|
+
|
|
614
|
+
const result = await resolveMonitorIdsByUser([u], {
|
|
615
|
+
resolveVisibleSiteMonitors: async () => null,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
expect(result.get(String(u._id))).toEqual([]);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test('inherits the per-value guard: Site documents in a value are rejected', async () => {
|
|
622
|
+
// Same failure projectSubscribedMonitors guards against, reached through
|
|
623
|
+
// the Map. Left unchecked each doc casts to its own _id and Site ids land
|
|
624
|
+
// in the alert audience.
|
|
625
|
+
const siteId = oid();
|
|
626
|
+
|
|
627
|
+
await expect(
|
|
628
|
+
resolveMonitorIdsByUser(
|
|
629
|
+
[user([{ deviceId: siteId, deviceType: 'linked-device' }])],
|
|
630
|
+
{
|
|
631
|
+
resolveVisibleSiteMonitors: async () =>
|
|
632
|
+
new Map([[String(siteId), [{ _id: oid(), monitorIds: [] }]]]),
|
|
633
|
+
},
|
|
634
|
+
),
|
|
635
|
+
).rejects.toMatchObject({
|
|
636
|
+
code: SUBSCRIBED_DEVICES_ERRORS.RESOLVER_RETURNED_DOCUMENTS,
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('throws COHORT_USER_MISSING_ID rather than silently dropping the user', async () => {
|
|
641
|
+
// Skipping would remove a real person from an alert audience with no error
|
|
642
|
+
// anywhere — the exact failure mode this module exists to prevent.
|
|
643
|
+
await expect(
|
|
644
|
+
resolveMonitorIdsByUser([{ subscribedDevices: [] }]),
|
|
645
|
+
).rejects.toMatchObject({
|
|
646
|
+
code: SUBSCRIBED_DEVICES_ERRORS.COHORT_USER_MISSING_ID,
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
describe('VISIBLE_SITE_MONITOR_FILTER', () => {
|
|
652
|
+
test('excludes private monitors with $ne: true, not an exact false match', () => {
|
|
653
|
+
// $ne matches both `false` and a MISSING key. Documents written before
|
|
654
|
+
// isPrivate existed carry no key, and an exact { isPrivate: false } match
|
|
655
|
+
// would drop them from every Site's audience.
|
|
656
|
+
expect(VISIBLE_SITE_MONITOR_FILTER).toEqual({
|
|
657
|
+
isPrivate: { $ne: true },
|
|
658
|
+
isActive: true,
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test('is frozen — one consumer mutating it must not change it for the rest', () => {
|
|
663
|
+
expect(Object.isFrozen(VISIBLE_SITE_MONITOR_FILTER)).toBe(true);
|
|
664
|
+
});
|
|
665
|
+
});
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// deliberate contract change, not a test fix.
|
|
10
10
|
import {
|
|
11
11
|
projectSubscribedMonitors,
|
|
12
|
+
resolveMonitorIdsByUser,
|
|
13
|
+
VISIBLE_SITE_MONITOR_FILTER,
|
|
12
14
|
SUBSCRIBED_DEVICES_ERRORS,
|
|
13
15
|
} from '../subscribedDevices.js';
|
|
14
16
|
import {
|
|
@@ -21,6 +23,8 @@ test('barrel re-exports the subscribedDevices surface unchanged (identity, not c
|
|
|
21
23
|
const barrel = await import('../../index.js');
|
|
22
24
|
|
|
23
25
|
expect(barrel.projectSubscribedMonitors).toBe(projectSubscribedMonitors);
|
|
26
|
+
expect(barrel.resolveMonitorIdsByUser).toBe(resolveMonitorIdsByUser);
|
|
27
|
+
expect(barrel.VISIBLE_SITE_MONITOR_FILTER).toBe(VISIBLE_SITE_MONITOR_FILTER);
|
|
24
28
|
expect(barrel.SUBSCRIBED_DEVICES_ERRORS).toBe(SUBSCRIBED_DEVICES_ERRORS);
|
|
25
29
|
});
|
|
26
30
|
|