@justair/justair-library 7.3.0-alpha.756c1bd → 7.3.0-alpha.80c577a
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/dataDownloads.d.ts +2 -0
- package/dist/constants/dataDownloads.d.ts.map +1 -0
- package/dist/constants/index.d.ts +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/models/dataDownloads.d.ts +260 -4
- package/dist/models/dataDownloads.d.ts.map +1 -1
- package/dist/utils/dataDownloadValidation.d.ts +4 -0
- package/dist/utils/dataDownloadValidation.d.ts.map +1 -1
- package/dist/utils/timezone.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants/dataDownloads.js +16 -0
- package/src/constants/index.js +1 -0
- package/src/constants/tests/constantsBarrel.test.js +12 -0
- package/src/index.js +21 -0
- package/src/models/dataDownloads.js +29 -9
- package/src/utils/dataDownloadValidation.js +54 -6
- package/src/utils/tests/dataDownloadValidation.test.js +53 -0
- package/src/utils/tests/timezone.test.js +14 -0
- package/src/utils/timezone.js +10 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/constants/dataDownloads.js"],"names":[],"mappings":"AAeA,2CAAqD"}
|
package/dist/index.d.ts
CHANGED
|
@@ -86,12 +86,18 @@ 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 { DATA_DOWNLOAD_TYPES } from "./constants/dataDownloads.js";
|
|
89
90
|
import { dataDownloadSchema } from "./models/dataDownloads.js";
|
|
90
91
|
import { DataDownloads } from "./models/dataDownloads.js";
|
|
91
92
|
import { AqiDataDownload } from "./models/dataDownloads.js";
|
|
92
93
|
import { SampleDataDownload } from "./models/dataDownloads.js";
|
|
93
94
|
import { MAX_RANGE_DAYS as DATA_DOWNLOAD_MAX_RANGE_DAYS } from "./utils/dataDownloadValidation.js";
|
|
94
95
|
import { DATA_DOWNLOAD_ERRORS } from "./utils/dataDownloadValidation.js";
|
|
96
|
+
import { validateDateOrder as validateDataDownloadDateOrder } from "./utils/dataDownloadValidation.js";
|
|
97
|
+
import { validateMaxDateRange as validateDataDownloadMaxDateRange } from "./utils/dataDownloadValidation.js";
|
|
98
|
+
import { validateDownloadEmail as validateDataDownloadEmail } from "./utils/dataDownloadValidation.js";
|
|
99
|
+
import { normalizeDownloadEmail as normalizeDataDownloadEmail } from "./utils/dataDownloadValidation.js";
|
|
100
|
+
import { validateDownloadTimezone as validateDataDownloadTimezone } from "./utils/dataDownloadValidation.js";
|
|
95
101
|
import { PARAMETERS } from "./constants/pollutants.js";
|
|
96
102
|
import { HEAVY_METALS } from "./constants/pollutants.js";
|
|
97
103
|
import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
|
|
@@ -106,5 +112,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
|
|
|
106
112
|
import { getZoneMinutes } from "./utils/quietHours.js";
|
|
107
113
|
import { isInQuietWindow } from "./utils/quietHours.js";
|
|
108
114
|
import { resolveQuietHours } from "./utils/quietHours.js";
|
|
109
|
-
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, dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload, DATA_DOWNLOAD_MAX_RANGE_DAYS, DATA_DOWNLOAD_ERRORS, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
|
|
115
|
+
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, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
|
|
110
116
|
//# 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":"AAmJA;;;iBAEC;yBAnFwB,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;4BAmCT,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;sCA5CtF,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;oCAfF,8BAA8B;mCAsD3D,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAqB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;yEAAnC,mCAAmC;2BA9ED,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;0BAkBtD,sBAAsB;oCASzC,uBAAuB;iCA1BG,0BAA0B;mCA0BpD,uBAAuB;kCAAvB,uBAAuB;oCACM,qBAAqB;mCADlD,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
|
|
@@ -1,5 +1,261 @@
|
|
|
1
|
-
export const dataDownloadSchema: any
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export const dataDownloadSchema: mongoose.Schema<any, mongoose.Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
|
|
2
|
+
timestamps: true;
|
|
3
|
+
discriminatorKey: string;
|
|
4
|
+
}, {
|
|
5
|
+
timezone: string;
|
|
6
|
+
orgId: mongoose.Types.ObjectId;
|
|
7
|
+
dateRangeStart: NativeDate;
|
|
8
|
+
dateRangeEnd: NativeDate;
|
|
9
|
+
downloadedAt: NativeDate;
|
|
10
|
+
email?: string;
|
|
11
|
+
agreementAcceptedAt?: NativeDate;
|
|
12
|
+
} & mongoose.DefaultTimestampProps, mongoose.Document<unknown, {}, {
|
|
13
|
+
timezone: string;
|
|
14
|
+
orgId: mongoose.Types.ObjectId;
|
|
15
|
+
dateRangeStart: NativeDate;
|
|
16
|
+
dateRangeEnd: NativeDate;
|
|
17
|
+
downloadedAt: NativeDate;
|
|
18
|
+
email?: string;
|
|
19
|
+
agreementAcceptedAt?: NativeDate;
|
|
20
|
+
} & mongoose.DefaultTimestampProps, {
|
|
21
|
+
id: string;
|
|
22
|
+
}, Omit<mongoose.DefaultSchemaOptions, "timestamps" | "discriminatorKey"> & {
|
|
23
|
+
timestamps: true;
|
|
24
|
+
discriminatorKey: string;
|
|
25
|
+
}> & Omit<{
|
|
26
|
+
timezone: string;
|
|
27
|
+
orgId: mongoose.Types.ObjectId;
|
|
28
|
+
dateRangeStart: NativeDate;
|
|
29
|
+
dateRangeEnd: NativeDate;
|
|
30
|
+
downloadedAt: NativeDate;
|
|
31
|
+
email?: string;
|
|
32
|
+
agreementAcceptedAt?: NativeDate;
|
|
33
|
+
} & mongoose.DefaultTimestampProps & {
|
|
34
|
+
_id: mongoose.Types.ObjectId;
|
|
35
|
+
} & {
|
|
36
|
+
__v: number;
|
|
37
|
+
}, "id"> & mongoose.HydratedDocumentOverrides<{
|
|
38
|
+
id: string;
|
|
39
|
+
}>, unknown, {
|
|
40
|
+
timezone: string;
|
|
41
|
+
orgId: mongoose.Types.ObjectId;
|
|
42
|
+
dateRangeStart: NativeDate;
|
|
43
|
+
dateRangeEnd: NativeDate;
|
|
44
|
+
downloadedAt: NativeDate;
|
|
45
|
+
email?: string;
|
|
46
|
+
agreementAcceptedAt?: NativeDate;
|
|
47
|
+
createdAt: NativeDate;
|
|
48
|
+
updatedAt: NativeDate;
|
|
49
|
+
} & {
|
|
50
|
+
_id: mongoose.Types.ObjectId;
|
|
51
|
+
} & {
|
|
52
|
+
__v: number;
|
|
53
|
+
}>;
|
|
54
|
+
export const DataDownloads: mongoose.Model<{
|
|
55
|
+
timezone: string;
|
|
56
|
+
orgId: mongoose.Types.ObjectId;
|
|
57
|
+
dateRangeStart: NativeDate;
|
|
58
|
+
dateRangeEnd: NativeDate;
|
|
59
|
+
downloadedAt: NativeDate;
|
|
60
|
+
email?: string;
|
|
61
|
+
agreementAcceptedAt?: NativeDate;
|
|
62
|
+
} & mongoose.DefaultTimestampProps, {}, {}, {
|
|
63
|
+
id: string;
|
|
64
|
+
}, mongoose.Document<unknown, {}, {
|
|
65
|
+
timezone: string;
|
|
66
|
+
orgId: mongoose.Types.ObjectId;
|
|
67
|
+
dateRangeStart: NativeDate;
|
|
68
|
+
dateRangeEnd: NativeDate;
|
|
69
|
+
downloadedAt: NativeDate;
|
|
70
|
+
email?: string;
|
|
71
|
+
agreementAcceptedAt?: NativeDate;
|
|
72
|
+
} & mongoose.DefaultTimestampProps, {
|
|
73
|
+
id: string;
|
|
74
|
+
}, {
|
|
75
|
+
timestamps: true;
|
|
76
|
+
discriminatorKey: string;
|
|
77
|
+
}> & Omit<{
|
|
78
|
+
timezone: string;
|
|
79
|
+
orgId: mongoose.Types.ObjectId;
|
|
80
|
+
dateRangeStart: NativeDate;
|
|
81
|
+
dateRangeEnd: NativeDate;
|
|
82
|
+
downloadedAt: NativeDate;
|
|
83
|
+
email?: string;
|
|
84
|
+
agreementAcceptedAt?: NativeDate;
|
|
85
|
+
} & mongoose.DefaultTimestampProps & {
|
|
86
|
+
_id: mongoose.Types.ObjectId;
|
|
87
|
+
} & {
|
|
88
|
+
__v: number;
|
|
89
|
+
}, "id"> & mongoose.HydratedDocumentOverrides<{
|
|
90
|
+
id: string;
|
|
91
|
+
}>, mongoose.Schema<any, mongoose.Model<any, any, any, any, any, any, any>, {}, {}, {}, {}, {
|
|
92
|
+
timestamps: true;
|
|
93
|
+
discriminatorKey: string;
|
|
94
|
+
}, {
|
|
95
|
+
timezone: string;
|
|
96
|
+
orgId: mongoose.Types.ObjectId;
|
|
97
|
+
dateRangeStart: NativeDate;
|
|
98
|
+
dateRangeEnd: NativeDate;
|
|
99
|
+
downloadedAt: NativeDate;
|
|
100
|
+
email?: string;
|
|
101
|
+
agreementAcceptedAt?: NativeDate;
|
|
102
|
+
} & mongoose.DefaultTimestampProps, mongoose.Document<unknown, {}, {
|
|
103
|
+
timezone: string;
|
|
104
|
+
orgId: mongoose.Types.ObjectId;
|
|
105
|
+
dateRangeStart: NativeDate;
|
|
106
|
+
dateRangeEnd: NativeDate;
|
|
107
|
+
downloadedAt: NativeDate;
|
|
108
|
+
email?: string;
|
|
109
|
+
agreementAcceptedAt?: NativeDate;
|
|
110
|
+
} & mongoose.DefaultTimestampProps, {
|
|
111
|
+
id: string;
|
|
112
|
+
}, Omit<mongoose.DefaultSchemaOptions, "timestamps" | "discriminatorKey"> & {
|
|
113
|
+
timestamps: true;
|
|
114
|
+
discriminatorKey: string;
|
|
115
|
+
}> & Omit<{
|
|
116
|
+
timezone: string;
|
|
117
|
+
orgId: mongoose.Types.ObjectId;
|
|
118
|
+
dateRangeStart: NativeDate;
|
|
119
|
+
dateRangeEnd: NativeDate;
|
|
120
|
+
downloadedAt: NativeDate;
|
|
121
|
+
email?: string;
|
|
122
|
+
agreementAcceptedAt?: NativeDate;
|
|
123
|
+
} & mongoose.DefaultTimestampProps & {
|
|
124
|
+
_id: mongoose.Types.ObjectId;
|
|
125
|
+
} & {
|
|
126
|
+
__v: number;
|
|
127
|
+
}, "id"> & mongoose.HydratedDocumentOverrides<{
|
|
128
|
+
id: string;
|
|
129
|
+
}>, unknown, {
|
|
130
|
+
timezone: string;
|
|
131
|
+
orgId: mongoose.Types.ObjectId;
|
|
132
|
+
dateRangeStart: NativeDate;
|
|
133
|
+
dateRangeEnd: NativeDate;
|
|
134
|
+
downloadedAt: NativeDate;
|
|
135
|
+
email?: string;
|
|
136
|
+
agreementAcceptedAt?: NativeDate;
|
|
137
|
+
createdAt: NativeDate;
|
|
138
|
+
updatedAt: NativeDate;
|
|
139
|
+
} & {
|
|
140
|
+
_id: mongoose.Types.ObjectId;
|
|
141
|
+
} & {
|
|
142
|
+
__v: number;
|
|
143
|
+
}>, {
|
|
144
|
+
timezone: string;
|
|
145
|
+
orgId: mongoose.Types.ObjectId;
|
|
146
|
+
dateRangeStart: NativeDate;
|
|
147
|
+
dateRangeEnd: NativeDate;
|
|
148
|
+
downloadedAt: NativeDate;
|
|
149
|
+
email?: string;
|
|
150
|
+
agreementAcceptedAt?: NativeDate;
|
|
151
|
+
createdAt: NativeDate;
|
|
152
|
+
updatedAt: NativeDate;
|
|
153
|
+
} & {
|
|
154
|
+
_id: mongoose.Types.ObjectId;
|
|
155
|
+
} & {
|
|
156
|
+
__v: number;
|
|
157
|
+
}>;
|
|
158
|
+
export const AqiDataDownload: mongoose.Model<{
|
|
159
|
+
timezone: string;
|
|
160
|
+
orgId: mongoose.Types.ObjectId;
|
|
161
|
+
dateRangeStart: NativeDate;
|
|
162
|
+
dateRangeEnd: NativeDate;
|
|
163
|
+
downloadedAt: NativeDate;
|
|
164
|
+
email?: string;
|
|
165
|
+
agreementAcceptedAt?: NativeDate;
|
|
166
|
+
} & mongoose.DefaultTimestampProps & {
|
|
167
|
+
monitorId: mongoose.Types.ObjectId;
|
|
168
|
+
}, {}, {}, {
|
|
169
|
+
id: string;
|
|
170
|
+
}, mongoose.Document<unknown, {}, {
|
|
171
|
+
timezone: string;
|
|
172
|
+
orgId: mongoose.Types.ObjectId;
|
|
173
|
+
dateRangeStart: NativeDate;
|
|
174
|
+
dateRangeEnd: NativeDate;
|
|
175
|
+
downloadedAt: NativeDate;
|
|
176
|
+
email?: string;
|
|
177
|
+
agreementAcceptedAt?: NativeDate;
|
|
178
|
+
} & mongoose.DefaultTimestampProps & {
|
|
179
|
+
monitorId: mongoose.Types.ObjectId;
|
|
180
|
+
}, {
|
|
181
|
+
id: string;
|
|
182
|
+
}, mongoose.DefaultSchemaOptions> & Omit<{
|
|
183
|
+
timezone: string;
|
|
184
|
+
orgId: mongoose.Types.ObjectId;
|
|
185
|
+
dateRangeStart: NativeDate;
|
|
186
|
+
dateRangeEnd: NativeDate;
|
|
187
|
+
downloadedAt: NativeDate;
|
|
188
|
+
email?: string;
|
|
189
|
+
agreementAcceptedAt?: NativeDate;
|
|
190
|
+
} & mongoose.DefaultTimestampProps & {
|
|
191
|
+
monitorId: mongoose.Types.ObjectId;
|
|
192
|
+
} & {
|
|
193
|
+
_id: mongoose.Types.ObjectId;
|
|
194
|
+
} & {
|
|
195
|
+
__v: number;
|
|
196
|
+
}, "id"> & mongoose.HydratedDocumentOverrides<{
|
|
197
|
+
id: string;
|
|
198
|
+
}>, any, {
|
|
199
|
+
timezone: string;
|
|
200
|
+
orgId: mongoose.Types.ObjectId;
|
|
201
|
+
dateRangeStart: NativeDate;
|
|
202
|
+
dateRangeEnd: NativeDate;
|
|
203
|
+
downloadedAt: NativeDate;
|
|
204
|
+
email?: string;
|
|
205
|
+
agreementAcceptedAt?: NativeDate;
|
|
206
|
+
} & mongoose.DefaultTimestampProps & {
|
|
207
|
+
monitorId: mongoose.Types.ObjectId;
|
|
208
|
+
}>;
|
|
209
|
+
export const SampleDataDownload: mongoose.Model<{
|
|
210
|
+
timezone: string;
|
|
211
|
+
orgId: mongoose.Types.ObjectId;
|
|
212
|
+
dateRangeStart: NativeDate;
|
|
213
|
+
dateRangeEnd: NativeDate;
|
|
214
|
+
downloadedAt: NativeDate;
|
|
215
|
+
email?: string;
|
|
216
|
+
agreementAcceptedAt?: NativeDate;
|
|
217
|
+
} & mongoose.DefaultTimestampProps & {
|
|
218
|
+
siteId: mongoose.Types.ObjectId;
|
|
219
|
+
}, {}, {}, {
|
|
220
|
+
id: string;
|
|
221
|
+
}, mongoose.Document<unknown, {}, {
|
|
222
|
+
timezone: string;
|
|
223
|
+
orgId: mongoose.Types.ObjectId;
|
|
224
|
+
dateRangeStart: NativeDate;
|
|
225
|
+
dateRangeEnd: NativeDate;
|
|
226
|
+
downloadedAt: NativeDate;
|
|
227
|
+
email?: string;
|
|
228
|
+
agreementAcceptedAt?: NativeDate;
|
|
229
|
+
} & mongoose.DefaultTimestampProps & {
|
|
230
|
+
siteId: mongoose.Types.ObjectId;
|
|
231
|
+
}, {
|
|
232
|
+
id: string;
|
|
233
|
+
}, mongoose.DefaultSchemaOptions> & Omit<{
|
|
234
|
+
timezone: string;
|
|
235
|
+
orgId: mongoose.Types.ObjectId;
|
|
236
|
+
dateRangeStart: NativeDate;
|
|
237
|
+
dateRangeEnd: NativeDate;
|
|
238
|
+
downloadedAt: NativeDate;
|
|
239
|
+
email?: string;
|
|
240
|
+
agreementAcceptedAt?: NativeDate;
|
|
241
|
+
} & mongoose.DefaultTimestampProps & {
|
|
242
|
+
siteId: mongoose.Types.ObjectId;
|
|
243
|
+
} & {
|
|
244
|
+
_id: mongoose.Types.ObjectId;
|
|
245
|
+
} & {
|
|
246
|
+
__v: number;
|
|
247
|
+
}, "id"> & mongoose.HydratedDocumentOverrides<{
|
|
248
|
+
id: string;
|
|
249
|
+
}>, any, {
|
|
250
|
+
timezone: string;
|
|
251
|
+
orgId: mongoose.Types.ObjectId;
|
|
252
|
+
dateRangeStart: NativeDate;
|
|
253
|
+
dateRangeEnd: NativeDate;
|
|
254
|
+
downloadedAt: NativeDate;
|
|
255
|
+
email?: string;
|
|
256
|
+
agreementAcceptedAt?: NativeDate;
|
|
257
|
+
} & mongoose.DefaultTimestampProps & {
|
|
258
|
+
siteId: mongoose.Types.ObjectId;
|
|
259
|
+
}>;
|
|
260
|
+
import mongoose from "mongoose";
|
|
5
261
|
//# sourceMappingURL=dataDownloads.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/models/dataDownloads.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/models/dataDownloads.js"],"names":[],"mappings":"AAuBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DE;AA2DF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAA0E;AAY1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAEG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAIE;qBArKmB,UAAU"}
|
|
@@ -18,4 +18,8 @@ export function validateDownloadEmail({ email }?: {}): {
|
|
|
18
18
|
code: "invalid_email";
|
|
19
19
|
message: string;
|
|
20
20
|
}[];
|
|
21
|
+
export function validateDownloadTimezone({ timezone }?: {}): {
|
|
22
|
+
code: "invalid_timezone";
|
|
23
|
+
message: string;
|
|
24
|
+
}[];
|
|
21
25
|
//# sourceMappingURL=dataDownloadValidation.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dataDownloadValidation.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadValidation.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dataDownloadValidation.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadValidation.js"],"names":[],"mappings":"AAWA,gCAAiC;AAUjC;;;;;GAKG;AAmBI;;;IAcN;AAIM;;;IAcN;AAgBM,wDAMN;AAIM;;;IAqBN;AAQM;;;IAaN"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"timezone.d.ts","sourceRoot":"","sources":["../../src/utils/timezone.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"timezone.d.ts","sourceRoot":"","sources":["../../src/utils/timezone.js"],"names":[],"mappings":"AAoBO,iDAaN;AAEM,4DAUN"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// DataDownloads vocabulary — source of truth for the `dataType` discriminator
|
|
2
|
+
// values (JA-3080/JA-3245). Kept here (Mongoose-free) so JustAirService and
|
|
3
|
+
// JustAir-Client can import the list without pulling in the models: the client
|
|
4
|
+
// drives the download modal's data-type picker from it, and the service
|
|
5
|
+
// branches on them when building the export.
|
|
6
|
+
//
|
|
7
|
+
// These are persisted values, NOT display labels. Figma labels the second
|
|
8
|
+
// option "Sample Data" while the stored value is "Sample", so a consumer that
|
|
9
|
+
// renders the array directly shows the wrong copy. Labels belong to the
|
|
10
|
+
// consumer; this library has no i18n and no view layer to own them.
|
|
11
|
+
//
|
|
12
|
+
// Order is the order the picker shows. JA-3080 notes concentration is coming
|
|
13
|
+
// as a third type — add it here and register its discriminator in
|
|
14
|
+
// models/dataDownloads.js, so no consumer has to hardcode the string.
|
|
15
|
+
|
|
16
|
+
export const DATA_DOWNLOAD_TYPES = ["AQI", "Sample"];
|
package/src/constants/index.js
CHANGED
|
@@ -7,6 +7,7 @@ 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
9
|
import { DEFAULT_TIMEZONE } from '../timezones.js';
|
|
10
|
+
import { DATA_DOWNLOAD_TYPES } from '../dataDownloads.js';
|
|
10
11
|
|
|
11
12
|
test('barrel re-exports PARAMETERS and HEAVY_METALS unchanged (identity, not copies)', () => {
|
|
12
13
|
expect(barrel.PARAMETERS).toBe(PARAMETERS);
|
|
@@ -50,3 +51,14 @@ test('barrel exports DEFAULT_TIMEZONE (browser-safe, no Mongoose)', () => {
|
|
|
50
51
|
expect(barrel.DEFAULT_TIMEZONE).toBe(DEFAULT_TIMEZONE);
|
|
51
52
|
expect(DEFAULT_TIMEZONE).toBe('America/New_York');
|
|
52
53
|
});
|
|
54
|
+
|
|
55
|
+
test('barrel exports DATA_DOWNLOAD_TYPES (browser-safe, no Mongoose)', () => {
|
|
56
|
+
expect(barrel.DATA_DOWNLOAD_TYPES).toBe(DATA_DOWNLOAD_TYPES);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// models/dataDownloads.js destructures this list positionally to build the two
|
|
60
|
+
// discriminators, so a reorder would silently swap the persisted dataType
|
|
61
|
+
// values on every future download. Pin contents AND order.
|
|
62
|
+
test('DATA_DOWNLOAD_TYPES pins the persisted dataType values in order', () => {
|
|
63
|
+
expect(DATA_DOWNLOAD_TYPES).toEqual(['AQI', 'Sample']);
|
|
64
|
+
});
|
package/src/index.js
CHANGED
|
@@ -68,6 +68,7 @@ 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
70
|
import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
|
|
71
|
+
import { DATA_DOWNLOAD_TYPES } from "./constants/dataDownloads.js";
|
|
71
72
|
import {
|
|
72
73
|
monitorEpisodesSchema,
|
|
73
74
|
MonitorEpisode,
|
|
@@ -128,6 +129,20 @@ import {
|
|
|
128
129
|
// data downloads. Module-internal name is unchanged.
|
|
129
130
|
MAX_RANGE_DAYS as DATA_DOWNLOAD_MAX_RANGE_DAYS,
|
|
130
131
|
DATA_DOWNLOAD_ERRORS,
|
|
132
|
+
// The rule ships with the number. The enforced bound is
|
|
133
|
+
// MAX_RANGE_DAYS plus a 1-hour DST grace, so a consumer rebuilding the cap
|
|
134
|
+
// as `DATA_DOWNLOAD_MAX_RANGE_DAYS * 86400000` builds a stricter limit than
|
|
135
|
+
// the model and rejects a 745-hour range the schema saves. Same reason
|
|
136
|
+
// validateQuietHours is exported rather than just DEFAULT_QUIET_HOURS.
|
|
137
|
+
//
|
|
138
|
+
// DateOrder applies to every download kind; MaxDateRange applies to AQI
|
|
139
|
+
// only, since Sample downloads have no length cap. Running both against a
|
|
140
|
+
// Sample request rejects year-long ranges the model saves without complaint.
|
|
141
|
+
validateDateOrder as validateDataDownloadDateOrder,
|
|
142
|
+
validateMaxDateRange as validateDataDownloadMaxDateRange,
|
|
143
|
+
validateDownloadEmail as validateDataDownloadEmail,
|
|
144
|
+
normalizeDownloadEmail as normalizeDataDownloadEmail,
|
|
145
|
+
validateDownloadTimezone as validateDataDownloadTimezone,
|
|
131
146
|
} from "./utils/dataDownloadValidation.js";
|
|
132
147
|
|
|
133
148
|
export function createLoggerInstance({ DATADOG_API_KEY, APPLICATION_NAME }) {
|
|
@@ -224,12 +239,18 @@ export {
|
|
|
224
239
|
NOTIFICATION_CHANNELS,
|
|
225
240
|
DELIVERY_STATUSES,
|
|
226
241
|
// Data downloads (JA-3080)
|
|
242
|
+
DATA_DOWNLOAD_TYPES,
|
|
227
243
|
dataDownloadSchema,
|
|
228
244
|
DataDownloads,
|
|
229
245
|
AqiDataDownload,
|
|
230
246
|
SampleDataDownload,
|
|
231
247
|
DATA_DOWNLOAD_MAX_RANGE_DAYS,
|
|
232
248
|
DATA_DOWNLOAD_ERRORS,
|
|
249
|
+
validateDataDownloadDateOrder,
|
|
250
|
+
validateDataDownloadMaxDateRange,
|
|
251
|
+
validateDataDownloadEmail,
|
|
252
|
+
normalizeDataDownloadEmail,
|
|
253
|
+
validateDataDownloadTimezone,
|
|
233
254
|
// Pollutant constants
|
|
234
255
|
PARAMETERS,
|
|
235
256
|
HEAVY_METALS,
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import mongoose from "mongoose";
|
|
2
|
+
import { DATA_DOWNLOAD_TYPES } from "../constants/dataDownloads.js";
|
|
2
3
|
import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
|
|
3
|
-
import { isValidIANATimezone } from "../utils/timezone.js";
|
|
4
4
|
import {
|
|
5
5
|
DATA_DOWNLOAD_ERRORS,
|
|
6
6
|
validateDateOrder,
|
|
7
7
|
validateMaxDateRange,
|
|
8
8
|
validateDownloadEmail,
|
|
9
9
|
normalizeDownloadEmail,
|
|
10
|
+
validateDownloadTimezone,
|
|
10
11
|
} from "../utils/dataDownloadValidation.js";
|
|
11
12
|
|
|
12
13
|
// DataDownloads — one doc per data download request (JA-3080). Written by
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
// — same property `userNotifications.js`'s base model has. Write through
|
|
21
22
|
// AqiDataDownload/SampleDataDownload, not DataDownloads, so every stored
|
|
22
23
|
// document has the ref field the admin screen needs to render it.
|
|
23
|
-
const dataDownloadSchema = mongoose.Schema(
|
|
24
|
+
const dataDownloadSchema = new mongoose.Schema(
|
|
24
25
|
{
|
|
25
26
|
orgId: {
|
|
26
27
|
type: mongoose.Types.ObjectId,
|
|
@@ -39,18 +40,19 @@ const dataDownloadSchema = mongoose.Schema(
|
|
|
39
40
|
// Readers feeding this to Intl.DateTimeFormat must still defend.
|
|
40
41
|
timezone: {
|
|
41
42
|
type: String,
|
|
43
|
+
trim: true,
|
|
42
44
|
default: DEFAULT_TIMEZONE,
|
|
43
45
|
set: (value) => (value === null ? DEFAULT_TIMEZONE : value),
|
|
44
46
|
validate: {
|
|
45
|
-
validator:
|
|
47
|
+
validator: (value) => validateDownloadTimezone({ timezone: value }).length === 0,
|
|
46
48
|
message: "timezone must be a valid IANA timezone identifier",
|
|
47
49
|
type: DATA_DOWNLOAD_ERRORS.INVALID_TIMEZONE,
|
|
48
50
|
},
|
|
49
51
|
},
|
|
50
|
-
downloadedAt: { type: Date, required: true, default: Date.now },
|
|
52
|
+
downloadedAt: { type: Date, required: [true, "downloadedAt is required"], default: Date.now },
|
|
51
53
|
// Figma's download modal requires checking "I have reviewed and agree to
|
|
52
|
-
// the Data Sharing Agreement" before the download proceeds (JA-3245
|
|
53
|
-
//
|
|
54
|
+
// the Data Sharing Agreement" before the download proceeds (JA-3245).
|
|
55
|
+
// Stored as the acceptance timestamp rather than a boolean so the
|
|
54
56
|
// record answers "when did they agree", which is what an audit trail of a
|
|
55
57
|
// consent decision needs. null means no acceptance was recorded — neither
|
|
56
58
|
// JA-3245 nor JA-3080 lists this field, so it stays optional until the
|
|
@@ -67,7 +69,13 @@ const dataDownloadSchema = mongoose.Schema(
|
|
|
67
69
|
set: normalizeDownloadEmail,
|
|
68
70
|
validate: {
|
|
69
71
|
validator: (value) => validateDownloadEmail({ email: value }).length === 0,
|
|
70
|
-
|
|
72
|
+
// Resolved from the rule rather than a static string: validateDownloadEmail
|
|
73
|
+
// distinguishes "too long" from "malformed", and a static message threw
|
|
74
|
+
// that away, so a 300-character address came back as "invalid" with no
|
|
75
|
+
// hint of the real problem.
|
|
76
|
+
message: (props) =>
|
|
77
|
+
validateDownloadEmail({ email: props.value })[0]?.message ??
|
|
78
|
+
"email must be a valid email address",
|
|
71
79
|
type: DATA_DOWNLOAD_ERRORS.INVALID_EMAIL,
|
|
72
80
|
},
|
|
73
81
|
},
|
|
@@ -85,6 +93,12 @@ const dataDownloadSchema = mongoose.Schema(
|
|
|
85
93
|
//
|
|
86
94
|
// Passes the error code as invalidate()'s `kind` so a caught ValidationError
|
|
87
95
|
// can be classified via err.errors.<path>.kind, not by message wording.
|
|
96
|
+
//
|
|
97
|
+
// Same caveat the `timezone` field carries: pre('validate') never runs on a
|
|
98
|
+
// query update, so a `$set` of dateRangeStart/dateRangeEnd can persist a
|
|
99
|
+
// reversed or over-length range even with { runValidators: true }. Deliberate
|
|
100
|
+
// — the rules are cross-field, and query updates have no full document to
|
|
101
|
+
// check. Writers should go through create()/save(); readers must still defend.
|
|
88
102
|
dataDownloadSchema.pre("validate", function () {
|
|
89
103
|
const rangeErrors = validateDateOrder({ start: this.dateRangeStart, end: this.dateRangeEnd });
|
|
90
104
|
for (const error of rangeErrors) {
|
|
@@ -131,18 +145,24 @@ sampleDataDownloadSchema.index({ siteId: 1, downloadedAt: -1 });
|
|
|
131
145
|
|
|
132
146
|
const DataDownloads = mongoose.model("DataDownloads", dataDownloadSchema);
|
|
133
147
|
|
|
148
|
+
// Derived from the constant per CLAUDE.md, so adding a type (JA-3080 expects
|
|
149
|
+
// concentration next) is one edit in one file. Positional, and pinned by
|
|
150
|
+
// constantsBarrel.test.js — reordering DATA_DOWNLOAD_TYPES fails that test
|
|
151
|
+
// before it can silently swap the two persisted values.
|
|
152
|
+
const [AQI_TYPE, SAMPLE_TYPE] = DATA_DOWNLOAD_TYPES;
|
|
153
|
+
|
|
134
154
|
// Registry names are namespaced (matches userNotifications.js): bare "AQI" /
|
|
135
155
|
// "Sample" model-registry keys would collide with a consuming service's own
|
|
136
156
|
// models of those names. { value } keeps the persisted dataType as JA-3245
|
|
137
157
|
// specifies regardless of the registry name.
|
|
138
158
|
const AqiDataDownload = DataDownloads.discriminator("AqiDataDownload", aqiDataDownloadSchema, {
|
|
139
|
-
value:
|
|
159
|
+
value: AQI_TYPE,
|
|
140
160
|
});
|
|
141
161
|
|
|
142
162
|
const SampleDataDownload = DataDownloads.discriminator(
|
|
143
163
|
"SampleDataDownload",
|
|
144
164
|
sampleDataDownloadSchema,
|
|
145
|
-
{ value:
|
|
165
|
+
{ value: SAMPLE_TYPE }
|
|
146
166
|
);
|
|
147
167
|
|
|
148
168
|
export { dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload };
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Data download validation (JA-3080/JA-3245): the shared field and cross-field
|
|
2
2
|
// rules for a DataDownloads document — date range order, the AQI-only length
|
|
3
|
-
// cap,
|
|
4
|
-
// shape as quietHours.js's cross-field validator.
|
|
3
|
+
// cap, the optional email, and the timezone. Pure functions, no DB, no clock
|
|
4
|
+
// reads. Same shape as quietHours.js's cross-field validator.
|
|
5
5
|
//
|
|
6
6
|
// Two separate rules, deliberately not one function: end-must-not-precede-
|
|
7
7
|
// start applies to every download kind, but the 31-day cap is AQI-only.
|
|
8
|
+
import { isValidIANATimezone } from "./timezone.js";
|
|
8
9
|
|
|
9
10
|
// Messaging only ("range cannot exceed 31 days"). validateMaxDateRange is the
|
|
10
11
|
// sole authority on the enforced bound; don't rebuild the cap from this.
|
|
@@ -78,15 +79,41 @@ export const validateMaxDateRange = ({ start, end } = {}) => {
|
|
|
78
79
|
|
|
79
80
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
80
81
|
|
|
82
|
+
// RFC 5321's maximum forward-path length. Enforced here rather than as a
|
|
83
|
+
// schema `maxlength` so the exported rule and the model agree: a schema-only
|
|
84
|
+
// cap let validateDownloadEmail pass a 300-character address that create()
|
|
85
|
+
// then rejected with kind "maxlength", which isn't a DATA_DOWNLOAD_ERRORS
|
|
86
|
+
// code.
|
|
87
|
+
const MAX_EMAIL_LENGTH = 254;
|
|
88
|
+
|
|
81
89
|
// Email is optional (JA-3080): a blank form field means "not sharing my email",
|
|
82
|
-
// not "invalid email", so a blank string collapses to null.
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
// not "invalid email", so a blank string collapses to null. Trims and
|
|
91
|
+
// lowercases to match the schema's `trim`/`lowercase` setters — without that,
|
|
92
|
+
// a consumer normalizing then validating " A@B.co " gets INVALID_EMAIL for
|
|
93
|
+
// input the model would have stored.
|
|
94
|
+
export const normalizeDownloadEmail = (value) => {
|
|
95
|
+
if (typeof value !== "string") {
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
const normalized = value.trim().toLowerCase();
|
|
99
|
+
return normalized === "" ? null : normalized;
|
|
100
|
+
};
|
|
85
101
|
|
|
86
102
|
// Returns { code, message }[]; empty = valid. null/undefined is valid — call
|
|
87
103
|
// normalizeDownloadEmail first so a blank string arrives as null.
|
|
88
104
|
export const validateDownloadEmail = ({ email } = {}) => {
|
|
89
|
-
if (email === null || email === undefined
|
|
105
|
+
if (email === null || email === undefined) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
if (typeof email === "string" && email.length > MAX_EMAIL_LENGTH) {
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
code: DATA_DOWNLOAD_ERRORS.INVALID_EMAIL,
|
|
112
|
+
message: `email must be ${MAX_EMAIL_LENGTH} characters or fewer`,
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
if (EMAIL_PATTERN.test(email)) {
|
|
90
117
|
return [];
|
|
91
118
|
}
|
|
92
119
|
return [
|
|
@@ -96,3 +123,24 @@ export const validateDownloadEmail = ({ email } = {}) => {
|
|
|
96
123
|
},
|
|
97
124
|
];
|
|
98
125
|
};
|
|
126
|
+
|
|
127
|
+
// Returns { code, message }[]; empty = valid. null/undefined is valid: the
|
|
128
|
+
// schema's setter resolves either to DEFAULT_TIMEZONE, so a consumer
|
|
129
|
+
// pre-validating a request body must accept the same "didn't pick one" shape
|
|
130
|
+
// the model stores. isValidIANATimezone alone rejects null, which would fail
|
|
131
|
+
// a request the model saves happily — the write-path/read-path split the
|
|
132
|
+
// email rules exist to avoid.
|
|
133
|
+
export const validateDownloadTimezone = ({ timezone } = {}) => {
|
|
134
|
+
if (timezone === null || timezone === undefined) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
if (isValidIANATimezone(timezone)) {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
code: DATA_DOWNLOAD_ERRORS.INVALID_TIMEZONE,
|
|
143
|
+
message: "timezone must be a valid IANA timezone identifier",
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
};
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
validateDateOrder,
|
|
5
5
|
validateMaxDateRange,
|
|
6
6
|
validateDownloadEmail,
|
|
7
|
+
validateDownloadTimezone,
|
|
7
8
|
normalizeDownloadEmail,
|
|
8
9
|
} from "../dataDownloadValidation.js";
|
|
9
10
|
|
|
@@ -100,6 +101,15 @@ describe("normalizeDownloadEmail (blank means 'not shared', not invalid)", () =>
|
|
|
100
101
|
expect(normalizeDownloadEmail("someone@example.com")).toBe("someone@example.com");
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
// Matches the schema's trim/lowercase setters. Without this the exported
|
|
105
|
+
// pair rejects padded input that AqiDataDownload.create() stores fine.
|
|
106
|
+
test("trims and lowercases so it agrees with the schema setters", () => {
|
|
107
|
+
expect(normalizeDownloadEmail(" Someone@Example.com ")).toBe("someone@example.com");
|
|
108
|
+
expect(validateDownloadEmail({ email: normalizeDownloadEmail(" Someone@Example.com ") })).toEqual(
|
|
109
|
+
[]
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
103
113
|
test("passes through non-string values unchanged — not this function's job to type-check", () => {
|
|
104
114
|
expect(normalizeDownloadEmail(null)).toBeNull();
|
|
105
115
|
expect(normalizeDownloadEmail(undefined)).toBeUndefined();
|
|
@@ -107,6 +117,21 @@ describe("normalizeDownloadEmail (blank means 'not shared', not invalid)", () =>
|
|
|
107
117
|
});
|
|
108
118
|
|
|
109
119
|
describe("validateDownloadEmail (email is optional — JA-3080)", () => {
|
|
120
|
+
// The 254-character bound is enforced here, not as a schema maxlength,
|
|
121
|
+
// so the exported rule and the model can't disagree on the same address.
|
|
122
|
+
test("flags an address longer than 254 characters", () => {
|
|
123
|
+
const tooLong = `${"a".repeat(250)}@b.co`;
|
|
124
|
+
expect(validateDownloadEmail({ email: tooLong })).toEqual([
|
|
125
|
+
{ code: DATA_DOWNLOAD_ERRORS.INVALID_EMAIL, message: "email must be 254 characters or fewer" },
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("accepts an address at exactly 254 characters", () => {
|
|
130
|
+
const atBound = `${"a".repeat(249)}@b.co`;
|
|
131
|
+
expect(atBound.length).toBe(254);
|
|
132
|
+
expect(validateDownloadEmail({ email: atBound })).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
110
135
|
test("returns no errors when email is null or undefined", () => {
|
|
111
136
|
expect(validateDownloadEmail({})).toEqual([]);
|
|
112
137
|
expect(validateDownloadEmail({ email: null })).toEqual([]);
|
|
@@ -131,3 +156,31 @@ describe("validateDownloadEmail (email is optional — JA-3080)", () => {
|
|
|
131
156
|
]);
|
|
132
157
|
});
|
|
133
158
|
});
|
|
159
|
+
|
|
160
|
+
describe("validateDownloadTimezone", () => {
|
|
161
|
+
test.each(["America/New_York", "America/Detroit", "UTC", "Asia/Tokyo"])(
|
|
162
|
+
"accepts %s",
|
|
163
|
+
(timezone) => {
|
|
164
|
+
expect(validateDownloadTimezone({ timezone })).toEqual([]);
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// The schema setter resolves both to DEFAULT_TIMEZONE, so a consumer
|
|
169
|
+
// pre-validating a request body must accept what the model stores.
|
|
170
|
+
test.each([null, undefined])("treats %p as valid (the setter fills the default)", (timezone) => {
|
|
171
|
+
expect(validateDownloadTimezone({ timezone })).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("accepts a call with no argument at all", () => {
|
|
175
|
+
expect(validateDownloadTimezone()).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test.each(["America/Fake_City", "Not/A_Zone", " ", 5, {}])(
|
|
179
|
+
"flags %p with INVALID_TIMEZONE",
|
|
180
|
+
(timezone) => {
|
|
181
|
+
expect(validateDownloadTimezone({ timezone })).toEqual([
|
|
182
|
+
expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.INVALID_TIMEZONE }),
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
});
|
|
@@ -39,6 +39,20 @@ describe('getFormatter', () => {
|
|
|
39
39
|
expect(getFormatter('America/New_York')).toBe(getFormatter('America/New_York'));
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
test('shares one cached formatter across casing variants of the same zone', () => {
|
|
43
|
+
// Intl accepts any casing, so a raw-string cache key would grow one
|
|
44
|
+
// permanent entry per spelling of a client-supplied zone (JA-3081).
|
|
45
|
+
const canonical = getFormatter('America/New_York');
|
|
46
|
+
expect(getFormatter('america/new_york')).toBe(canonical);
|
|
47
|
+
expect(getFormatter('AMERICA/NEW_YORK')).toBe(canonical);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a cached lowercase key still formats in the right zone', () => {
|
|
51
|
+
const parts = getFormatter('asia/tokyo').formatToParts(new Date('2026-01-01T00:00:00.000Z'));
|
|
52
|
+
const hour = parts.find((p) => p.type === 'hour').value;
|
|
53
|
+
expect(hour).toBe('09');
|
|
54
|
+
});
|
|
55
|
+
|
|
42
56
|
test('throws for an invalid timezone — isValidIANATimezone exists to guard this', () => {
|
|
43
57
|
expect(() => getFormatter('Not/A_Zone')).toThrow();
|
|
44
58
|
});
|
package/src/utils/timezone.js
CHANGED
|
@@ -9,10 +9,18 @@
|
|
|
9
9
|
// on a cached formatter is cheap. The cache also makes validation and
|
|
10
10
|
// zone-minute lookups (quietHours.js's getZoneMinutes) share one authority:
|
|
11
11
|
// a timezone is valid iff Intl accepts it.
|
|
12
|
+
//
|
|
13
|
+
// Keyed lowercase because Intl treats IANA names case-insensitively:
|
|
14
|
+
// "America/New_York", "america/new_york" and "AMERICA/NEW_YORK" all resolve to
|
|
15
|
+
// the same zone, so keying on the raw string would cache a separate permanent
|
|
16
|
+
// formatter per casing variant. dataDownloads.js validates a per-request
|
|
17
|
+
// `timezone`, so that string can come from a client (JA-3081) — one entry per
|
|
18
|
+
// real zone rather than per spelling keeps the map bounded.
|
|
12
19
|
const formatterCache = new Map();
|
|
13
20
|
|
|
14
21
|
export const getFormatter = (timeZone) => {
|
|
15
|
-
|
|
22
|
+
const cacheKey = typeof timeZone === "string" ? timeZone.toLowerCase() : timeZone;
|
|
23
|
+
let formatter = formatterCache.get(cacheKey);
|
|
16
24
|
if (formatter === undefined) {
|
|
17
25
|
formatter = new Intl.DateTimeFormat("en-US", {
|
|
18
26
|
timeZone,
|
|
@@ -20,7 +28,7 @@ export const getFormatter = (timeZone) => {
|
|
|
20
28
|
hour: "2-digit",
|
|
21
29
|
minute: "2-digit",
|
|
22
30
|
});
|
|
23
|
-
formatterCache.set(
|
|
31
|
+
formatterCache.set(cacheKey, formatter);
|
|
24
32
|
}
|
|
25
33
|
return formatter;
|
|
26
34
|
};
|