@justair/justair-library 7.2.0-alpha.e76a08c → 7.3.0-alpha.002ee84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants/deviceTypes.d.ts +8 -0
- package/dist/constants/deviceTypes.d.ts.map +1 -0
- package/dist/constants/index.d.ts +1 -1
- 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 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/models/monitorEpisodes.d.ts +12 -12
- package/dist/models/monitors.d.ts.map +1 -1
- package/dist/models/users.d.ts.map +1 -1
- package/dist/utils/quietHours.d.ts +1 -2
- package/dist/utils/quietHours.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants/deviceTypes.js +39 -0
- package/src/constants/index.js +1 -1
- package/src/constants/tests/constantsBarrel.test.js +0 -6
- package/src/constants/tests/deviceTypes.test.js +48 -0
- package/src/index.js +10 -22
- package/src/models/monitors.js +1 -0
- package/src/models/users.js +37 -0
- package/src/utils/quietHours.js +2 -6
- package/dist/constants/timezones.d.ts +0 -2
- package/dist/constants/timezones.d.ts.map +0 -1
- package/dist/models/dataDownloads.d.ts +0 -5
- package/dist/models/dataDownloads.d.ts.map +0 -1
- package/dist/models/tests/dataDownloads.test.d.ts +0 -2
- package/dist/models/tests/dataDownloads.test.d.ts.map +0 -1
- package/dist/utils/dataDownloadRange.d.ts +0 -14
- package/dist/utils/dataDownloadRange.d.ts.map +0 -1
- package/dist/utils/tests/dataDownloadRange.test.d.ts +0 -2
- package/dist/utils/tests/dataDownloadRange.test.d.ts.map +0 -1
- package/src/constants/timezones.js +0 -7
- package/src/models/dataDownloads.js +0 -117
- package/src/utils/dataDownloadRange.js +0 -85
- package/src/utils/tests/dataDownloadRange.test.js +0 -89
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export namespace DEVICE_TYPES {
|
|
2
|
+
let MONITOR: string;
|
|
3
|
+
let SAMPLE_SITE: string;
|
|
4
|
+
let LINKED_DEVICE: string;
|
|
5
|
+
}
|
|
6
|
+
export const DEVICE_TYPE_VALUES: string[];
|
|
7
|
+
export function isAqiCapableDeviceType(deviceType: any): boolean;
|
|
8
|
+
//# sourceMappingURL=deviceTypes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deviceTypes.d.ts","sourceRoot":"","sources":["../../src/constants/deviceTypes.js"],"names":[],"mappings":";;;;;AA2BA,0CAA8D;AASvD,iEAEoC"}
|
|
@@ -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
|
@@ -86,21 +86,16 @@ import { AccountNotification } from "./models/userNotifications.js";
|
|
|
86
86
|
import { NOTIFICATION_KINDS } from "./models/userNotifications.js";
|
|
87
87
|
import { NOTIFICATION_CHANNELS } from "./models/userNotifications.js";
|
|
88
88
|
import { DELIVERY_STATUSES } from "./models/userNotifications.js";
|
|
89
|
-
import { dataDownloadSchema } from "./models/dataDownloads.js";
|
|
90
|
-
import { DataDownloads } from "./models/dataDownloads.js";
|
|
91
|
-
import { AqiDataDownload } from "./models/dataDownloads.js";
|
|
92
|
-
import { SampleDataDownload } from "./models/dataDownloads.js";
|
|
93
|
-
import { MAX_RANGE_DAYS } from "./utils/dataDownloadRange.js";
|
|
94
|
-
import { DATA_DOWNLOAD_ERRORS } from "./utils/dataDownloadRange.js";
|
|
95
|
-
import { validateDateOrder } from "./utils/dataDownloadRange.js";
|
|
96
|
-
import { validateMaxDateRange } from "./utils/dataDownloadRange.js";
|
|
97
89
|
import { PARAMETERS } from "./constants/pollutants.js";
|
|
98
90
|
import { HEAVY_METALS } from "./constants/pollutants.js";
|
|
99
91
|
import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
|
|
100
92
|
import { getAlertWeight } from "./constants/alertLevels.js";
|
|
93
|
+
import { DEVICE_TYPES } from "./constants/deviceTypes.js";
|
|
94
|
+
import { DEVICE_TYPE_VALUES } from "./constants/deviceTypes.js";
|
|
95
|
+
import { isAqiCapableDeviceType } from "./constants/deviceTypes.js";
|
|
101
96
|
import { splitName } from "./utils/splitName.js";
|
|
102
97
|
import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
|
|
103
|
-
import { DEFAULT_TIMEZONE } from "./
|
|
98
|
+
import { DEFAULT_TIMEZONE } from "./utils/quietHours.js";
|
|
104
99
|
import { QUIET_HOURS_ERRORS } from "./utils/quietHours.js";
|
|
105
100
|
import { isValidTimeString } from "./utils/quietHours.js";
|
|
106
101
|
import { isValidIANATimezone } from "./utils/quietHours.js";
|
|
@@ -108,5 +103,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
|
|
|
108
103
|
import { getZoneMinutes } from "./utils/quietHours.js";
|
|
109
104
|
import { isInQuietWindow } from "./utils/quietHours.js";
|
|
110
105
|
import { resolveQuietHours } from "./utils/quietHours.js";
|
|
111
|
-
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,
|
|
106
|
+
export { Database, adminSchema, Admin, configurationsSchema, Configurations, measurementsSchema, Measurements, monitorRequestsSchema, MonitorRequests, monitorsSchema, Monitors, organizationsSchema, Organizations, usersSchema, Users, COOKIE_CONSENT_CATEGORIES, COOKIE_CONSENT_VALUES, validateCookieConsent, eventsSchema, Events, lightMonitorSchema, LightMonitors, monitorSuppliersSchema, MonitorSuppliers, contextsSchema, Contexts, parametersSchema, Parameters, announcementSchema, Announcements, jobsSchema, Jobs, apiKeySchema, ApiKey, UsageMetrics, usageMetricsSchema, Audit, auditSchema, EventsAudit, eventsAuditSchema, MonitorAudit, monitorAuditSchema, parametersEnum, deploymentTypesEnum, AlertsAudit, Alerts, alertsSchema, alertsAuditSchema, Features, featuresSchema, dataCompletenessSchema, DataCompleteness, networkMetricsSchema, NetworkMetrics, rateOfChangeSchema, RateOfChange, changeStreamCheckpointsSchema, ChangeStreamCheckpoints, sitesSchema, Sites, sampleSitesSchema, SampleSites, sampleSiteAuditSchema, SampleSiteAudit, sampleParametersEnum, samplesSchema, Samples, samplesAuditSchema, SamplesAudit, sampleParameterReferenceConcentrations, passwordResetTokenSchema, PasswordResetToken, monitorEpisodesSchema, MonitorEpisode, userNotificationsSchema, UserNotification, AqiAlertNotification, DeviceOfflineNotification, WeeklyDigestNotification, AccountNotification, NOTIFICATION_KINDS, NOTIFICATION_CHANNELS, DELIVERY_STATUSES, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, DEVICE_TYPES, DEVICE_TYPE_VALUES, isAqiCapableDeviceType, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
|
|
112
107
|
//# 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":"AA4HA;;;iBAEC;yBA5DwB,oBAAoB;qBADxB,gBAAgB;4BAjEF,mBAAmB;sBAAnB,mBAAmB;qCAI/C,4BAA4B;+BAA5B,4BAA4B;mCAM5B,0BAA0B;6BAA1B,0BAA0B;sCAI1B,6BAA6B;gCAA7B,6BAA6B;+BAQ7B,sBAAsB;yBAAtB,sBAAsB;oCACsB,2BAA2B;8BAA3B,2BAA2B;4BAcvE,mBAAmB;sBAAnB,mBAAmB;0CAAnB,mBAAmB;sCAAnB,mBAAmB;sCAAnB,mBAAmB;6BAMnB,oBAAoB;uBAApB,oBAAoB;mCAnBuB,2BAA2B;8BAA3B,2BAA2B;uCAItE,8BAA8B;iCAA9B,8BAA8B;+BACI,sBAAsB;yBAAtB,sBAAsB;iCAClB,wBAAwB;2BAAxB,wBAAwB;mCAcnB,2BAA2B;8BAA3B,2BAA2B;2BAC5C,kBAAkB;qBAAlB,kBAAkB;6BACd,oBAAoB;uBAApB,oBAAoB;6BACR,0BAA0B;mCAA1B,0BAA0B;sBArCpE,0BAA0B;4BAA1B,0BAA0B;4BAiC1B,oBAAoB;kCAApB,oBAAoB;6BArBpB,sBAAsB;mCAAtB,sBAAsB;+BAAtB,sBAAsB;oCAAtB,sBAAsB;4BA+BtB,oBAAoB;uBAApB,oBAAoB;6BAApB,oBAAoB;kCAApB,oBAAoB;yBACc,sBAAsB;+BAAtB,sBAAsB;uCAIxD,8BAA8B;iCAA9B,8BAA8B;qCACgB,4BAA4B;+BAA5B,4BAA4B;mCAChC,0BAA0B;6BAA1B,0BAA0B;8CAIpE,qCAAqC;wCAArC,qCAAqC;4BAuCT,mBAAmB;sBAAnB,mBAAmB;kCAS/C,yBAAyB;4BAAzB,yBAAyB;sCAAzB,yBAAyB;gCAAzB,yBAAyB;qCAAzB,yBAAyB;8BASzB,qBAAqB;wBAArB,qBAAqB;mCAArB,qBAAqB;6BAArB,qBAAqB;uDAArB,qBAAqB;yCACiC,gCAAgC;mCAAhC,gCAAgC;sCA7CtF,6BAA6B;+BAA7B,6BAA6B;wCAW7B,+BAA+B;iCAA/B,+BAA+B;qCAA/B,+BAA+B;0CAA/B,+BAA+B;yCAA/B,+BAA+B;oCAA/B,+BAA+B;mCAA/B,+BAA+B;sCAA/B,+BAA+B;kCAA/B,+BAA+B;2BArBG,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;6BAKzE,4BAA4B;mCAA5B,4BAA4B;uCAA5B,4BAA4B;0BAgBT,sBAAsB;oCAWzC,uBAAuB;iCAAvB,uBAAuB;mCAAvB,uBAAuB;kCAAvB,uBAAuB;oCAAvB,uBAAuB;mCAAvB,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export const monitorEpisodesSchema: mongoose.Schema<any, mongoose.Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
|
|
2
2
|
timestamps: true;
|
|
3
3
|
}, {
|
|
4
|
-
user: mongoose.Types.ObjectId;
|
|
5
4
|
monitor: mongoose.Types.ObjectId;
|
|
5
|
+
user: mongoose.Types.ObjectId;
|
|
6
6
|
open: boolean;
|
|
7
7
|
startedAt: NativeDate;
|
|
8
8
|
alertCount: number;
|
|
@@ -10,8 +10,8 @@ export const monitorEpisodesSchema: mongoose.Schema<any, mongoose.Model<any, any
|
|
|
10
10
|
endedAt?: NativeDate;
|
|
11
11
|
peakStatus?: string;
|
|
12
12
|
} & mongoose.DefaultTimestampProps, mongoose.Document<unknown, {}, {
|
|
13
|
-
user: mongoose.Types.ObjectId;
|
|
14
13
|
monitor: mongoose.Types.ObjectId;
|
|
14
|
+
user: mongoose.Types.ObjectId;
|
|
15
15
|
open: boolean;
|
|
16
16
|
startedAt: NativeDate;
|
|
17
17
|
alertCount: number;
|
|
@@ -23,8 +23,8 @@ export const monitorEpisodesSchema: mongoose.Schema<any, mongoose.Model<any, any
|
|
|
23
23
|
}, Omit<mongoose.DefaultSchemaOptions, "timestamps"> & {
|
|
24
24
|
timestamps: true;
|
|
25
25
|
}> & Omit<{
|
|
26
|
-
user: mongoose.Types.ObjectId;
|
|
27
26
|
monitor: mongoose.Types.ObjectId;
|
|
27
|
+
user: mongoose.Types.ObjectId;
|
|
28
28
|
open: boolean;
|
|
29
29
|
startedAt: NativeDate;
|
|
30
30
|
alertCount: number;
|
|
@@ -38,8 +38,8 @@ export const monitorEpisodesSchema: mongoose.Schema<any, mongoose.Model<any, any
|
|
|
38
38
|
}, "id"> & mongoose.HydratedDocumentOverrides<{
|
|
39
39
|
id: string;
|
|
40
40
|
}>, unknown, {
|
|
41
|
-
user: mongoose.Types.ObjectId;
|
|
42
41
|
monitor: mongoose.Types.ObjectId;
|
|
42
|
+
user: mongoose.Types.ObjectId;
|
|
43
43
|
open: boolean;
|
|
44
44
|
startedAt: NativeDate;
|
|
45
45
|
alertCount: number;
|
|
@@ -54,8 +54,8 @@ export const monitorEpisodesSchema: mongoose.Schema<any, mongoose.Model<any, any
|
|
|
54
54
|
__v: number;
|
|
55
55
|
}>;
|
|
56
56
|
export const MonitorEpisode: mongoose.Model<{
|
|
57
|
-
user: mongoose.Types.ObjectId;
|
|
58
57
|
monitor: mongoose.Types.ObjectId;
|
|
58
|
+
user: mongoose.Types.ObjectId;
|
|
59
59
|
open: boolean;
|
|
60
60
|
startedAt: NativeDate;
|
|
61
61
|
alertCount: number;
|
|
@@ -65,8 +65,8 @@ export const MonitorEpisode: mongoose.Model<{
|
|
|
65
65
|
} & mongoose.DefaultTimestampProps, {}, {}, {
|
|
66
66
|
id: string;
|
|
67
67
|
}, mongoose.Document<unknown, {}, {
|
|
68
|
-
user: mongoose.Types.ObjectId;
|
|
69
68
|
monitor: mongoose.Types.ObjectId;
|
|
69
|
+
user: mongoose.Types.ObjectId;
|
|
70
70
|
open: boolean;
|
|
71
71
|
startedAt: NativeDate;
|
|
72
72
|
alertCount: number;
|
|
@@ -78,8 +78,8 @@ export const MonitorEpisode: mongoose.Model<{
|
|
|
78
78
|
}, {
|
|
79
79
|
timestamps: true;
|
|
80
80
|
}> & Omit<{
|
|
81
|
-
user: mongoose.Types.ObjectId;
|
|
82
81
|
monitor: mongoose.Types.ObjectId;
|
|
82
|
+
user: mongoose.Types.ObjectId;
|
|
83
83
|
open: boolean;
|
|
84
84
|
startedAt: NativeDate;
|
|
85
85
|
alertCount: number;
|
|
@@ -95,8 +95,8 @@ export const MonitorEpisode: mongoose.Model<{
|
|
|
95
95
|
}>, mongoose.Schema<any, mongoose.Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
|
|
96
96
|
timestamps: true;
|
|
97
97
|
}, {
|
|
98
|
-
user: mongoose.Types.ObjectId;
|
|
99
98
|
monitor: mongoose.Types.ObjectId;
|
|
99
|
+
user: mongoose.Types.ObjectId;
|
|
100
100
|
open: boolean;
|
|
101
101
|
startedAt: NativeDate;
|
|
102
102
|
alertCount: number;
|
|
@@ -104,8 +104,8 @@ export const MonitorEpisode: mongoose.Model<{
|
|
|
104
104
|
endedAt?: NativeDate;
|
|
105
105
|
peakStatus?: string;
|
|
106
106
|
} & mongoose.DefaultTimestampProps, mongoose.Document<unknown, {}, {
|
|
107
|
-
user: mongoose.Types.ObjectId;
|
|
108
107
|
monitor: mongoose.Types.ObjectId;
|
|
108
|
+
user: mongoose.Types.ObjectId;
|
|
109
109
|
open: boolean;
|
|
110
110
|
startedAt: NativeDate;
|
|
111
111
|
alertCount: number;
|
|
@@ -117,8 +117,8 @@ export const MonitorEpisode: mongoose.Model<{
|
|
|
117
117
|
}, Omit<mongoose.DefaultSchemaOptions, "timestamps"> & {
|
|
118
118
|
timestamps: true;
|
|
119
119
|
}> & Omit<{
|
|
120
|
-
user: mongoose.Types.ObjectId;
|
|
121
120
|
monitor: mongoose.Types.ObjectId;
|
|
121
|
+
user: mongoose.Types.ObjectId;
|
|
122
122
|
open: boolean;
|
|
123
123
|
startedAt: NativeDate;
|
|
124
124
|
alertCount: number;
|
|
@@ -132,8 +132,8 @@ export const MonitorEpisode: mongoose.Model<{
|
|
|
132
132
|
}, "id"> & mongoose.HydratedDocumentOverrides<{
|
|
133
133
|
id: string;
|
|
134
134
|
}>, unknown, {
|
|
135
|
-
user: mongoose.Types.ObjectId;
|
|
136
135
|
monitor: mongoose.Types.ObjectId;
|
|
136
|
+
user: mongoose.Types.ObjectId;
|
|
137
137
|
open: boolean;
|
|
138
138
|
startedAt: NativeDate;
|
|
139
139
|
alertCount: number;
|
|
@@ -147,8 +147,8 @@ export const MonitorEpisode: mongoose.Model<{
|
|
|
147
147
|
} & {
|
|
148
148
|
__v: number;
|
|
149
149
|
}>, {
|
|
150
|
-
user: mongoose.Types.ObjectId;
|
|
151
150
|
monitor: mongoose.Types.ObjectId;
|
|
151
|
+
user: mongoose.Types.ObjectId;
|
|
152
152
|
open: boolean;
|
|
153
153
|
startedAt: NativeDate;
|
|
154
154
|
alertCount: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"monitors.d.ts","sourceRoot":"","sources":["../../src/models/monitors.js"],"names":[],"mappings":"AA4KA,
|
|
1
|
+
{"version":3,"file":"monitors.d.ts","sourceRoot":"","sources":["../../src/models/monitors.js"],"names":[],"mappings":"AA4KA,iCAqJE;AAuIF,2BAA4D;AAnU5D,qCAiCE;AAGF,+BAAwE;AAtKxE,sCAAoD"}
|
|
@@ -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":"AA6DA,8BAiGE;AAiCF,wBAAmD;AAtLnD,iDAA4D;AAC5D,6CAAmD;AAUnD,4DAsBC"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { DEFAULT_TIMEZONE };
|
|
2
1
|
export const DEFAULT_QUIET_HOURS: Readonly<{
|
|
3
2
|
start: "22:00";
|
|
4
3
|
end: "07:00";
|
|
5
4
|
}>;
|
|
5
|
+
export const DEFAULT_TIMEZONE: "America/New_York";
|
|
6
6
|
export const QUIET_HOURS_ERRORS: Readonly<{
|
|
7
7
|
HALF_PAIR: "half_pair";
|
|
8
8
|
FORMAT: "format";
|
|
@@ -30,5 +30,4 @@ export function resolveQuietHours(user: any): {
|
|
|
30
30
|
timezone: string;
|
|
31
31
|
warnings: string[];
|
|
32
32
|
};
|
|
33
|
-
import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
|
|
34
33
|
//# sourceMappingURL=quietHours.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"quietHours.d.ts","sourceRoot":"","sources":["../../src/utils/quietHours.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"quietHours.d.ts","sourceRoot":"","sources":["../../src/utils/quietHours.js"],"names":[],"mappings":"AAkBA;;;GAGG;AAEH,kDAAmD;AAKnD;;;;GAIG;AAuBI,uDACgD;AAEhD,4DAUN;AAOM;;;;;;;;;KAsCN;AASM,iEASN;AAKM,uEAaN;AAeM;;;;;;;EA6CN"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// V2 device-type vocabulary — single source of truth (JA-3110 / JA-3335).
|
|
2
|
+
//
|
|
3
|
+
// Every device a user can see or subscribe to is exactly one of three things,
|
|
4
|
+
// each backed by a different collection:
|
|
5
|
+
// monitor — a standalone Monitors doc (siteId: null). Reports live AQI.
|
|
6
|
+
// sample-site — a standalone SampleSites doc (siteId: null). Lab/filter
|
|
7
|
+
// sample data only, no live AQI, so it can never trigger an
|
|
8
|
+
// AQI alert.
|
|
9
|
+
// linked-device — a Sites doc joining one or more Monitors and/or one
|
|
10
|
+
// SampleSite at a single physical location. Reports live AQI
|
|
11
|
+
// when it links at least one monitor.
|
|
12
|
+
//
|
|
13
|
+
// Consumers: JustAirService fetchDevices/getDeviceById stamp these strings onto
|
|
14
|
+
// every device response; JustAirUserService validates the subscribe body
|
|
15
|
+
// against them and persists them in Users.subscribedDevices; JustAir-Client
|
|
16
|
+
// passes back verbatim the value it received. Keep the string values
|
|
17
|
+
// byte-identical to what the devices API emits — the FE does no mapping, and a
|
|
18
|
+
// casing drift here is a silent 404 on subscribe.
|
|
19
|
+
//
|
|
20
|
+
// DO NOT rename these values: they are persisted in Users.subscribedDevices.
|
|
21
|
+
|
|
22
|
+
export const DEVICE_TYPES = {
|
|
23
|
+
MONITOR: "monitor",
|
|
24
|
+
SAMPLE_SITE: "sample-site",
|
|
25
|
+
LINKED_DEVICE: "linked-device",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const DEVICE_TYPE_VALUES = Object.values(DEVICE_TYPES);
|
|
29
|
+
|
|
30
|
+
// Whether a device of this type can ever report AQI, and therefore whether a
|
|
31
|
+
// subscription to it can contribute to AQI alert delivery.
|
|
32
|
+
//
|
|
33
|
+
// Note the asymmetry: `sample-site` is unconditionally false, but a true result
|
|
34
|
+
// for `linked-device` is necessary and NOT sufficient — a Site is only actually
|
|
35
|
+
// AQI-capable when it links at least one monitor, which the type alone cannot
|
|
36
|
+
// tell you. Callers must still check the Site's monitorIds.
|
|
37
|
+
export const isAqiCapableDeviceType = (deviceType) =>
|
|
38
|
+
deviceType === DEVICE_TYPES.MONITOR ||
|
|
39
|
+
deviceType === DEVICE_TYPES.LINKED_DEVICE;
|
package/src/constants/index.js
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
import * as barrel from '../index.js';
|
|
7
7
|
import { PARAMETERS, HEAVY_METALS } from '../pollutants.js';
|
|
8
8
|
import { ALERT_LEVEL_WEIGHTS, getAlertWeight } from '../alertLevels.js';
|
|
9
|
-
import { DEFAULT_TIMEZONE } from '../timezones.js';
|
|
10
9
|
|
|
11
10
|
test('barrel re-exports PARAMETERS and HEAVY_METALS unchanged (identity, not copies)', () => {
|
|
12
11
|
expect(barrel.PARAMETERS).toBe(PARAMETERS);
|
|
@@ -45,8 +44,3 @@ test('unknown and legacy statuses fall back to the Good weight (never alert, nev
|
|
|
45
44
|
expect(getAlertWeight(null)).toBe(1);
|
|
46
45
|
expect(getAlertWeight('')).toBe(1);
|
|
47
46
|
});
|
|
48
|
-
|
|
49
|
-
test('barrel exports DEFAULT_TIMEZONE (browser-safe, no Mongoose)', () => {
|
|
50
|
-
expect(barrel.DEFAULT_TIMEZONE).toBe(DEFAULT_TIMEZONE);
|
|
51
|
-
expect(DEFAULT_TIMEZONE).toBe('America/New_York');
|
|
52
|
-
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Pins the device-type vocabulary shared across JustAirService (which stamps
|
|
2
|
+
// deviceType onto every device response), JustAirUserService (which validates
|
|
3
|
+
// the subscribe body and persists it), and JustAir-Client (which passes it
|
|
4
|
+
// back verbatim). A casing drift in any value is a silent subscribe failure,
|
|
5
|
+
// so the exact strings are asserted rather than derived.
|
|
6
|
+
import * as barrel from '../index.js';
|
|
7
|
+
import {
|
|
8
|
+
DEVICE_TYPES,
|
|
9
|
+
DEVICE_TYPE_VALUES,
|
|
10
|
+
isAqiCapableDeviceType,
|
|
11
|
+
} from '../deviceTypes.js';
|
|
12
|
+
|
|
13
|
+
test('barrel exports the device-type constants', () => {
|
|
14
|
+
expect(barrel.DEVICE_TYPES).toBe(DEVICE_TYPES);
|
|
15
|
+
expect(barrel.DEVICE_TYPE_VALUES).toBe(DEVICE_TYPE_VALUES);
|
|
16
|
+
expect(barrel.isAqiCapableDeviceType).toBe(isAqiCapableDeviceType);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('DEVICE_TYPES values match the strings the devices API emits', () => {
|
|
20
|
+
expect(DEVICE_TYPES).toEqual({
|
|
21
|
+
MONITOR: 'monitor',
|
|
22
|
+
SAMPLE_SITE: 'sample-site',
|
|
23
|
+
LINKED_DEVICE: 'linked-device',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('DEVICE_TYPE_VALUES covers every type exactly once', () => {
|
|
28
|
+
expect(DEVICE_TYPE_VALUES).toEqual(['monitor', 'sample-site', 'linked-device']);
|
|
29
|
+
expect(new Set(DEVICE_TYPE_VALUES).size).toBe(DEVICE_TYPE_VALUES.length);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('isAqiCapableDeviceType', () => {
|
|
33
|
+
test('monitor and linked-device can report AQI', () => {
|
|
34
|
+
expect(isAqiCapableDeviceType('monitor')).toBe(true);
|
|
35
|
+
expect(isAqiCapableDeviceType('linked-device')).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('sample-site never reports AQI, so it never alerts', () => {
|
|
39
|
+
expect(isAqiCapableDeviceType('sample-site')).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('unknown and missing types are not AQI-capable', () => {
|
|
43
|
+
expect(isAqiCapableDeviceType('sampleSite')).toBe(false);
|
|
44
|
+
expect(isAqiCapableDeviceType(undefined)).toBe(false);
|
|
45
|
+
expect(isAqiCapableDeviceType(null)).toBe(false);
|
|
46
|
+
expect(isAqiCapableDeviceType('')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/index.js
CHANGED
|
@@ -67,7 +67,11 @@ import Database from "./config/db.js"; // Import the new Database class
|
|
|
67
67
|
import CustomLogger from "./config/logger.js";
|
|
68
68
|
import { PARAMETERS, HEAVY_METALS } from "./constants/pollutants.js";
|
|
69
69
|
import { ALERT_LEVEL_WEIGHTS, getAlertWeight } from "./constants/alertLevels.js";
|
|
70
|
-
import {
|
|
70
|
+
import {
|
|
71
|
+
DEVICE_TYPES,
|
|
72
|
+
DEVICE_TYPE_VALUES,
|
|
73
|
+
isAqiCapableDeviceType,
|
|
74
|
+
} from "./constants/deviceTypes.js";
|
|
71
75
|
import {
|
|
72
76
|
monitorEpisodesSchema,
|
|
73
77
|
MonitorEpisode,
|
|
@@ -86,6 +90,7 @@ import {
|
|
|
86
90
|
import { splitName } from "./utils/splitName.js";
|
|
87
91
|
import {
|
|
88
92
|
DEFAULT_QUIET_HOURS,
|
|
93
|
+
DEFAULT_TIMEZONE,
|
|
89
94
|
QUIET_HOURS_ERRORS,
|
|
90
95
|
isValidTimeString,
|
|
91
96
|
isValidIANATimezone,
|
|
@@ -116,18 +121,6 @@ import {
|
|
|
116
121
|
sampleParameterReferenceConcentrations,
|
|
117
122
|
} from "./models/samples.js";
|
|
118
123
|
import { passwordResetTokenSchema, PasswordResetToken } from "./models/passwordResetToken.js";
|
|
119
|
-
import {
|
|
120
|
-
dataDownloadSchema,
|
|
121
|
-
DataDownloads,
|
|
122
|
-
AqiDataDownload,
|
|
123
|
-
SampleDataDownload,
|
|
124
|
-
} from "./models/dataDownloads.js";
|
|
125
|
-
import {
|
|
126
|
-
MAX_RANGE_DAYS,
|
|
127
|
-
DATA_DOWNLOAD_ERRORS,
|
|
128
|
-
validateDateOrder,
|
|
129
|
-
validateMaxDateRange,
|
|
130
|
-
} from "./utils/dataDownloadRange.js";
|
|
131
124
|
|
|
132
125
|
export function createLoggerInstance({ DATADOG_API_KEY, APPLICATION_NAME }) {
|
|
133
126
|
return new CustomLogger({ DATADOG_API_KEY, APPLICATION_NAME });
|
|
@@ -222,21 +215,16 @@ export {
|
|
|
222
215
|
NOTIFICATION_KINDS,
|
|
223
216
|
NOTIFICATION_CHANNELS,
|
|
224
217
|
DELIVERY_STATUSES,
|
|
225
|
-
// Data downloads (JA-3080)
|
|
226
|
-
dataDownloadSchema,
|
|
227
|
-
DataDownloads,
|
|
228
|
-
AqiDataDownload,
|
|
229
|
-
SampleDataDownload,
|
|
230
|
-
MAX_RANGE_DAYS,
|
|
231
|
-
DATA_DOWNLOAD_ERRORS,
|
|
232
|
-
validateDateOrder,
|
|
233
|
-
validateMaxDateRange,
|
|
234
218
|
// Pollutant constants
|
|
235
219
|
PARAMETERS,
|
|
236
220
|
HEAVY_METALS,
|
|
237
221
|
// AQI alert-level hierarchy (single source of truth)
|
|
238
222
|
ALERT_LEVEL_WEIGHTS,
|
|
239
223
|
getAlertWeight,
|
|
224
|
+
// V2 device-type vocabulary (single source of truth) — JA-3110 / JA-3335
|
|
225
|
+
DEVICE_TYPES,
|
|
226
|
+
DEVICE_TYPE_VALUES,
|
|
227
|
+
isAqiCapableDeviceType,
|
|
240
228
|
// Utilities
|
|
241
229
|
splitName,
|
|
242
230
|
// Quiet hours (JA-3054): shared validation + window evaluation
|
package/src/models/monitors.js
CHANGED
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,23 @@ const validateCookieConsent = (consent) => {
|
|
|
41
42
|
return null;
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
// One entry in Users.subscribedDevices (JA-3110 / JA-3335). deviceId points
|
|
46
|
+
// into a different collection per deviceType (Monitors / SampleSites / Sites),
|
|
47
|
+
// so there is deliberately no `ref` to declare here.
|
|
48
|
+
//
|
|
49
|
+
// `_id: false` is load-bearing, not cosmetic: it makes the pair itself the
|
|
50
|
+
// identity of an element, so `$addToSet` dedupes and re-subscribing is
|
|
51
|
+
// idempotent. With per-subdocument _ids every push would cast to a brand-new
|
|
52
|
+
// object, `$addToSet` would never match, and the array would accumulate a
|
|
53
|
+
// duplicate on every click of the subscribe button.
|
|
54
|
+
const subscribedDeviceSchema = mongoose.Schema(
|
|
55
|
+
{
|
|
56
|
+
deviceId: { type: mongoose.Types.ObjectId, required: true },
|
|
57
|
+
deviceType: { type: String, enum: DEVICE_TYPE_VALUES, required: true },
|
|
58
|
+
},
|
|
59
|
+
{ _id: false }
|
|
60
|
+
);
|
|
61
|
+
|
|
44
62
|
const usersSchema = mongoose.Schema(
|
|
45
63
|
{
|
|
46
64
|
name: String,
|
|
@@ -54,6 +72,22 @@ const usersSchema = mongoose.Schema(
|
|
|
54
72
|
alertPhone: String,
|
|
55
73
|
alertCount: { type: Number, default: 0 },
|
|
56
74
|
subscribedMonitors: [{ type: mongoose.Types.ObjectId, ref: "Monitors" }],
|
|
75
|
+
// Subscribed devices (JA-3110 / JA-3335) — the source of truth for what the
|
|
76
|
+
// user has added to "My Monitors", across all three device types.
|
|
77
|
+
//
|
|
78
|
+
// Relationship to subscribedMonitors above: that array stays monitor ids
|
|
79
|
+
// ONLY, and is the AQI-alert projection of this one. A subscribed
|
|
80
|
+
// linked-device contributes the monitorIds of its Site to it; a subscribed
|
|
81
|
+
// sample-site contributes nothing, having no live AQI. The two must be
|
|
82
|
+
// written together on every subscribe/unsubscribe path.
|
|
83
|
+
//
|
|
84
|
+
// Why the split matters: JustAirNotificationService reads subscribedMonitors
|
|
85
|
+
// and drops ids that do not resolve to a Monitor (statusAlerts.js
|
|
86
|
+
// `.filter(monitor => monitor)`). Writing a Site or SampleSite id in there
|
|
87
|
+
// therefore throws no error — it silently delivers no alerts. Keeping the
|
|
88
|
+
// polymorphic ids in their own field preserves that invariant for every
|
|
89
|
+
// library consumer.
|
|
90
|
+
subscribedDevices: [subscribedDeviceSchema],
|
|
57
91
|
currentAlertMode: {
|
|
58
92
|
type: String,
|
|
59
93
|
enum: [
|
|
@@ -151,6 +185,9 @@ usersSchema.pre("validate", function () {
|
|
|
151
185
|
});
|
|
152
186
|
|
|
153
187
|
usersSchema.index({ phone: 1 });
|
|
188
|
+
// Supports the "is this user subscribed to device X?" lookup behind the V2
|
|
189
|
+
// device-card subscribed state and the My Monitors map filter.
|
|
190
|
+
usersSchema.index({ "subscribedDevices.deviceId": 1 });
|
|
154
191
|
|
|
155
192
|
const Users = mongoose.model("Users", usersSchema);
|
|
156
193
|
|
package/src/utils/quietHours.js
CHANGED
|
@@ -16,17 +16,13 @@
|
|
|
16
16
|
// Boundary rule: start is inclusive, end is exclusive — a 22:00–07:00 user
|
|
17
17
|
// is suppressed at 22:00 sharp and receives alerts again at 07:00 sharp.
|
|
18
18
|
|
|
19
|
-
import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
|
|
20
|
-
|
|
21
|
-
// Re-exported for existing backend imports — canonical definition now lives
|
|
22
|
-
// in src/constants/timezones.js (Mongoose-free, browser-safe).
|
|
23
|
-
export { DEFAULT_TIMEZONE };
|
|
24
|
-
|
|
25
19
|
export const DEFAULT_QUIET_HOURS = Object.freeze({
|
|
26
20
|
start: "22:00",
|
|
27
21
|
end: "07:00",
|
|
28
22
|
});
|
|
29
23
|
|
|
24
|
+
export const DEFAULT_TIMEZONE = "America/New_York";
|
|
25
|
+
|
|
30
26
|
// Stable error codes for validateQuietHours. Consumers filter on `code`
|
|
31
27
|
// (e.g. the users schema pre-validate hook re-attaches only cross-field
|
|
32
28
|
// errors) — never on message wording, which is free to change.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"timezones.d.ts","sourceRoot":"","sources":["../../src/constants/timezones.js"],"names":[],"mappings":"AAMA,kDAAmD"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/models/dataDownloads.js"],"names":[],"mappings":"AAkBA,qCAkCE;AA+CF,gCAA0E;AAO1E,kCAEG;AAEH,qCAIE"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dataDownloads.test.d.ts","sourceRoot":"","sources":["../../../src/models/tests/dataDownloads.test.js"],"names":[],"mappings":""}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export const MAX_RANGE_DAYS: 31;
|
|
2
|
-
export const DATA_DOWNLOAD_ERRORS: Readonly<{
|
|
3
|
-
END_BEFORE_START: "end_before_start";
|
|
4
|
-
RANGE_TOO_LONG: "range_too_long";
|
|
5
|
-
}>;
|
|
6
|
-
export function validateDateOrder({ start, end }?: {}): {
|
|
7
|
-
code: "end_before_start";
|
|
8
|
-
message: string;
|
|
9
|
-
}[];
|
|
10
|
-
export function validateMaxDateRange({ start, end }?: {}): {
|
|
11
|
-
code: "range_too_long";
|
|
12
|
-
message: string;
|
|
13
|
-
}[];
|
|
14
|
-
//# sourceMappingURL=dataDownloadRange.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dataDownloadRange.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadRange.js"],"names":[],"mappings":"AAYA,gCAAiC;AASjC;;;GAGG;AA0BI;;;IAcN;AAMM;;;IAcN"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dataDownloadRange.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/dataDownloadRange.test.js"],"names":[],"mappings":""}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
// Mongoose-free timezone defaults, so a browser-safe consumer (e.g.
|
|
2
|
-
// JustAir-Client) can use the same default other domains store server-side.
|
|
3
|
-
// Originally lived in utils/quietHours.js (JA-3054); moved here once a
|
|
4
|
-
// second, unrelated domain (dataDownloads.js, JA-3080) needed it too — see
|
|
5
|
-
// CLAUDE.md's "Note on constants placement".
|
|
6
|
-
|
|
7
|
-
export const DEFAULT_TIMEZONE = "America/New_York";
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import mongoose from "mongoose";
|
|
2
|
-
import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
|
|
3
|
-
import { isValidIANATimezone } from "../utils/quietHours.js";
|
|
4
|
-
import { validateDateOrder, validateMaxDateRange } from "../utils/dataDownloadRange.js";
|
|
5
|
-
|
|
6
|
-
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
7
|
-
|
|
8
|
-
// DataDownloads — one doc per data download request (JA-3080). Written by
|
|
9
|
-
// JustAirService on download (JA-3081); read by the org-level admin
|
|
10
|
-
// communication screen. AQI downloads are monitor-scoped, Sample downloads
|
|
11
|
-
// are site-scoped (samples.js keys samples to SampleSites, not Monitors) —
|
|
12
|
-
// modeled as discriminators per CLAUDE.md's multi-kind pattern rather than
|
|
13
|
-
// a flat schema with a monitorId/siteId pair that's nullable either way.
|
|
14
|
-
//
|
|
15
|
-
// The base model permits a kind-less document (create() without `dataType`)
|
|
16
|
-
// — same property `userNotifications.js`'s base model has. Write through
|
|
17
|
-
// AqiDataDownload/SampleDataDownload, not DataDownloads, so every stored
|
|
18
|
-
// document has the ref field the admin screen needs to render it.
|
|
19
|
-
const dataDownloadSchema = mongoose.Schema(
|
|
20
|
-
{
|
|
21
|
-
orgId: {
|
|
22
|
-
type: mongoose.Types.ObjectId,
|
|
23
|
-
ref: "Organizations",
|
|
24
|
-
required: [true, "orgId is required"],
|
|
25
|
-
},
|
|
26
|
-
dateRangeStart: { type: Date, required: [true, "dateRangeStart is required"] },
|
|
27
|
-
dateRangeEnd: { type: Date, required: [true, "dateRangeEnd is required"] },
|
|
28
|
-
timezone: {
|
|
29
|
-
type: String,
|
|
30
|
-
default: DEFAULT_TIMEZONE,
|
|
31
|
-
validate: {
|
|
32
|
-
validator: isValidIANATimezone,
|
|
33
|
-
message: "timezone must be a valid IANA timezone identifier",
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
downloadedAt: { type: Date, required: true, default: Date.now },
|
|
37
|
-
email: {
|
|
38
|
-
type: String,
|
|
39
|
-
trim: true,
|
|
40
|
-
lowercase: true,
|
|
41
|
-
default: null,
|
|
42
|
-
set: (value) => (typeof value === "string" && value.trim() === "" ? null : value),
|
|
43
|
-
validate: {
|
|
44
|
-
validator: (value) => value === null || EMAIL_PATTERN.test(value),
|
|
45
|
-
message: "email must be a valid email address",
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
timestamps: true,
|
|
51
|
-
discriminatorKey: "dataType",
|
|
52
|
-
}
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
// Cross-field rule (quiet-hours shape, CLAUDE.md): delegated to
|
|
56
|
-
// validateDateOrder so this schema and JA-3081's write-path validation can
|
|
57
|
-
// never drift. Applies to every kind — the 31-day cap is AQI-only and lives
|
|
58
|
-
// on AqiDataDownload's own schema below, not here. Lives in pre('validate')
|
|
59
|
-
// so create()/save() can't persist an invalid range even if a caller
|
|
60
|
-
// forgets to check.
|
|
61
|
-
dataDownloadSchema.pre("validate", function () {
|
|
62
|
-
const errors = validateDateOrder({ start: this.dateRangeStart, end: this.dateRangeEnd });
|
|
63
|
-
for (const error of errors) {
|
|
64
|
-
this.invalidate("dateRangeEnd", error.message);
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
// Org-level admin screen lists a given org's downloads, most recent first,
|
|
69
|
-
// by the domain-meaningful "date of the download" (JA-3080), not createdAt.
|
|
70
|
-
dataDownloadSchema.index({ orgId: 1, downloadedAt: -1 });
|
|
71
|
-
|
|
72
|
-
const aqiDataDownloadSchema = new mongoose.Schema({
|
|
73
|
-
monitorId: {
|
|
74
|
-
type: mongoose.Types.ObjectId,
|
|
75
|
-
ref: "Monitors",
|
|
76
|
-
required: [true, "monitorId is required"],
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
aqiDataDownloadSchema.index({ monitorId: 1, downloadedAt: -1 });
|
|
80
|
-
|
|
81
|
-
// AQI-only: the 31-day cap does not apply to Sample downloads. Mongoose
|
|
82
|
-
// merges base + discriminator schema hooks by default (mergeHooks), so this
|
|
83
|
-
// runs alongside (not instead of) the base's validateDateOrder check above.
|
|
84
|
-
aqiDataDownloadSchema.pre("validate", function () {
|
|
85
|
-
const errors = validateMaxDateRange({ start: this.dateRangeStart, end: this.dateRangeEnd });
|
|
86
|
-
for (const error of errors) {
|
|
87
|
-
this.invalidate("dateRangeEnd", error.message);
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const sampleDataDownloadSchema = new mongoose.Schema({
|
|
92
|
-
siteId: {
|
|
93
|
-
type: mongoose.Types.ObjectId,
|
|
94
|
-
ref: "SampleSites",
|
|
95
|
-
required: [true, "siteId is required"],
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
sampleDataDownloadSchema.index({ siteId: 1, downloadedAt: -1 });
|
|
99
|
-
|
|
100
|
-
const DataDownloads = mongoose.model("DataDownloads", dataDownloadSchema);
|
|
101
|
-
|
|
102
|
-
// Discriminator model names are namespaced (matches userNotifications.js's
|
|
103
|
-
// aqi_alert/device_offline/weekly_digest) — "AQI"/"Sample" as bare mongoose
|
|
104
|
-
// model-registry keys would collide with any consuming service's own models
|
|
105
|
-
// of those names. { value } keeps the persisted dataType exactly what
|
|
106
|
-
// JA-3245 specifies regardless of the registry name.
|
|
107
|
-
const AqiDataDownload = DataDownloads.discriminator("AqiDataDownload", aqiDataDownloadSchema, {
|
|
108
|
-
value: "AQI",
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const SampleDataDownload = DataDownloads.discriminator(
|
|
112
|
-
"SampleDataDownload",
|
|
113
|
-
sampleDataDownloadSchema,
|
|
114
|
-
{ value: "Sample" }
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
export { dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload };
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
// Data download date range validation (JA-3080/JA-3245): shared cross-field
|
|
2
|
-
// rules for the requested range on a DataDownloads document. Single source
|
|
3
|
-
// of truth for every consumer — this library's schema enforces these on
|
|
4
|
-
// write, JustAirService's intake endpoint (JA-3081) validates the same
|
|
5
|
-
// request shape before ever constructing a document. Pure functions, no DB,
|
|
6
|
-
// no clock reads. Same shape as quietHours.js's cross-field validator.
|
|
7
|
-
//
|
|
8
|
-
// Two separate rules, deliberately not one function: end-must-not-precede-
|
|
9
|
-
// start applies to every download kind, but the 31-day cap is AQI-only —
|
|
10
|
-
// Sample downloads have no range limit. Compose the one(s) that apply to
|
|
11
|
-
// the kind being validated; don't call validateMaxDateRange for Sample.
|
|
12
|
-
|
|
13
|
-
export const MAX_RANGE_DAYS = 31;
|
|
14
|
-
|
|
15
|
-
// DST slack: a calendar-accurate 31-day span crossing a fall-back transition
|
|
16
|
-
// is 745 (not 744) hours long. Allow it rather than rejecting a range the
|
|
17
|
-
// product spec (Figma) explicitly promises as valid.
|
|
18
|
-
const MAX_RANGE_MS = MAX_RANGE_DAYS * 24 * 60 * 60 * 1000 + 60 * 60 * 1000;
|
|
19
|
-
|
|
20
|
-
// Stable error codes for validateDateOrder/validateMaxDateRange. Consumers
|
|
21
|
-
// filter on `code` — never on message wording, which is free to change.
|
|
22
|
-
export const DATA_DOWNLOAD_ERRORS = Object.freeze({
|
|
23
|
-
END_BEFORE_START: "end_before_start",
|
|
24
|
-
RANGE_TOO_LONG: "range_too_long",
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
// Coerces both ends through `new Date(...)` before comparing/subtracting: a
|
|
28
|
-
// JSON request body (JA-3081's intake endpoint) hands these functions raw
|
|
29
|
-
// strings, not Date instances, and comparing/subtracting un-coerced strings
|
|
30
|
-
// silently produces NaN (`NaN > x` is always false) — the caps would never
|
|
31
|
-
// fire for exactly the caller these functions exist to serve. An unparsable
|
|
32
|
-
// value coerces to an Invalid Date (NaN time); format validation is the
|
|
33
|
-
// schema/type layer's job, not a cross-field rule's, so that's treated the
|
|
34
|
-
// same as "not this validator's concern" and returns null rather than
|
|
35
|
-
// throwing or reporting a spurious range error.
|
|
36
|
-
const toTimes = (start, end) => {
|
|
37
|
-
if (!start || !end) {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
const startTime = new Date(start).getTime();
|
|
41
|
-
const endTime = new Date(end).getTime();
|
|
42
|
-
if (Number.isNaN(startTime) || Number.isNaN(endTime)) {
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
return { startTime, endTime };
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// Returns an array of { code, message } errors; empty array = valid. Applies
|
|
49
|
-
// to every download kind (AQI and Sample) — the Figma "31 days" cap is
|
|
50
|
-
// AQI-only and is NOT enforced here, see validateMaxDateRange.
|
|
51
|
-
export const validateDateOrder = ({ start, end } = {}) => {
|
|
52
|
-
const times = toTimes(start, end);
|
|
53
|
-
if (!times) {
|
|
54
|
-
return [];
|
|
55
|
-
}
|
|
56
|
-
if (times.endTime < times.startTime) {
|
|
57
|
-
return [
|
|
58
|
-
{
|
|
59
|
-
code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START,
|
|
60
|
-
message: "dateRangeEnd must not be before dateRangeStart",
|
|
61
|
-
},
|
|
62
|
-
];
|
|
63
|
-
}
|
|
64
|
-
return [];
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
// Returns an array of { code, message } errors; empty array = valid. AQI
|
|
68
|
-
// downloads only — Sample downloads have no range limit. Assumes the caller
|
|
69
|
-
// has already confirmed order via validateDateOrder (a reversed range isn't
|
|
70
|
-
// re-reported here as "too long").
|
|
71
|
-
export const validateMaxDateRange = ({ start, end } = {}) => {
|
|
72
|
-
const times = toTimes(start, end);
|
|
73
|
-
if (!times || times.endTime < times.startTime) {
|
|
74
|
-
return [];
|
|
75
|
-
}
|
|
76
|
-
if (times.endTime - times.startTime > MAX_RANGE_MS) {
|
|
77
|
-
return [
|
|
78
|
-
{
|
|
79
|
-
code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG,
|
|
80
|
-
message: `date range cannot exceed ${MAX_RANGE_DAYS} days`,
|
|
81
|
-
},
|
|
82
|
-
];
|
|
83
|
-
}
|
|
84
|
-
return [];
|
|
85
|
-
};
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
MAX_RANGE_DAYS,
|
|
3
|
-
DATA_DOWNLOAD_ERRORS,
|
|
4
|
-
validateDateOrder,
|
|
5
|
-
validateMaxDateRange,
|
|
6
|
-
} from "../dataDownloadRange.js";
|
|
7
|
-
|
|
8
|
-
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
|
-
const HOUR_MS = 60 * 60 * 1000;
|
|
10
|
-
const start = new Date("2026-01-01T00:00:00.000Z");
|
|
11
|
-
|
|
12
|
-
describe("validateDateOrder (applies to every download kind)", () => {
|
|
13
|
-
test("returns no errors when start/end are missing", () => {
|
|
14
|
-
expect(validateDateOrder({})).toEqual([]);
|
|
15
|
-
expect(validateDateOrder({ start })).toEqual([]);
|
|
16
|
-
expect(validateDateOrder({ end: start })).toEqual([]);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("returns no errors for a valid order", () => {
|
|
20
|
-
const end = new Date(start.getTime() + DAY_MS);
|
|
21
|
-
expect(validateDateOrder({ start, end })).toEqual([]);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("flags end before start with END_BEFORE_START", () => {
|
|
25
|
-
const end = new Date(start.getTime() - DAY_MS);
|
|
26
|
-
expect(validateDateOrder({ start, end })).toEqual([
|
|
27
|
-
expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START }),
|
|
28
|
-
]);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("does not flag a range far longer than MAX_RANGE_DAYS — that's validateMaxDateRange's job", () => {
|
|
32
|
-
const end = new Date(start.getTime() + 365 * DAY_MS);
|
|
33
|
-
expect(validateDateOrder({ start, end })).toEqual([]);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
// JA-3081's intake endpoint hands this raw JSON request-body strings, not
|
|
37
|
-
// Date instances — must not silently misjudge order via NaN-producing
|
|
38
|
-
// string arithmetic (see dataDownloadRange.js's toTimes coercion comment).
|
|
39
|
-
test("flags end before start given as raw date strings", () => {
|
|
40
|
-
expect(
|
|
41
|
-
validateDateOrder({ start: "2026-05-01", end: "2026-01-01" })
|
|
42
|
-
).toEqual([expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START })]);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("returns no errors for an unparsable date value", () => {
|
|
46
|
-
expect(validateDateOrder({ start: "not-a-date", end: start })).toEqual([]);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe("validateMaxDateRange (AQI only — Sample downloads have no cap)", () => {
|
|
51
|
-
test("returns no errors when start/end are missing", () => {
|
|
52
|
-
expect(validateMaxDateRange({})).toEqual([]);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("accepts exactly MAX_RANGE_DAYS", () => {
|
|
56
|
-
const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS);
|
|
57
|
-
expect(validateMaxDateRange({ start, end })).toEqual([]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
// The +1h grace exists so a calendar-accurate 31-day span crossing a DST
|
|
61
|
-
// fall-back transition (745 real hours, not 744) still validates — see
|
|
62
|
-
// dataDownloadRange.js's MAX_RANGE_MS comment.
|
|
63
|
-
test("accepts MAX_RANGE_DAYS plus the 1-hour DST grace", () => {
|
|
64
|
-
const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS + HOUR_MS);
|
|
65
|
-
expect(validateMaxDateRange({ start, end })).toEqual([]);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("flags a range longer than MAX_RANGE_DAYS plus the DST grace with RANGE_TOO_LONG", () => {
|
|
69
|
-
const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS + HOUR_MS + 1);
|
|
70
|
-
expect(validateMaxDateRange({ start, end })).toEqual([
|
|
71
|
-
expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG }),
|
|
72
|
-
]);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("flags a too-long range given as raw date strings (JSON request-body shape)", () => {
|
|
76
|
-
expect(
|
|
77
|
-
validateMaxDateRange({ start: "2026-01-01", end: "2027-06-01" })
|
|
78
|
-
).toEqual([expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG })]);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("does not re-report a reversed range as RANGE_TOO_LONG — validateDateOrder owns that", () => {
|
|
82
|
-
const end = new Date(start.getTime() - DAY_MS);
|
|
83
|
-
expect(validateMaxDateRange({ start, end })).toEqual([]);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("returns no errors for an unparsable date value", () => {
|
|
87
|
-
expect(validateMaxDateRange({ start: "not-a-date", end: start })).toEqual([]);
|
|
88
|
-
});
|
|
89
|
-
});
|