@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.
@@ -0,0 +1,2 @@
1
+ export const DATA_DOWNLOAD_TYPES: string[];
2
+ //# sourceMappingURL=dataDownloads.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/constants/dataDownloads.js"],"names":[],"mappings":"AAeA,2CAAqD"}
@@ -2,4 +2,5 @@ export * from "./pollutants.js";
2
2
  export * from "./alertLevels.js";
3
3
  export * from "./userNotifications.js";
4
4
  export * from "./timezones.js";
5
+ export * from "./dataDownloads.js";
5
6
  //# sourceMappingURL=index.d.ts.map
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAoIA;;;iBAEC;yBApEwB,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;4BAkCT,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;mCAuC/B,2BAA2B;8BAA3B,2BAA2B;gCAA3B,2BAA2B;mCAA3B,2BAA2B;+DAO3B,mCAAmC;qCAAnC,mCAAmC;2BA/DD,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;0BAiBtD,sBAAsB;oCASzC,uBAAuB;iCAzBG,0BAA0B;mCAyBpD,uBAAuB;kCAAvB,uBAAuB;oCACM,qBAAqB;mCADlD,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
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
- export const DataDownloads: any;
3
- export const AqiDataDownload: any;
4
- export const SampleDataDownload: any;
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":"AAsBA,qCAwDE;AAqDF,gCAA0E;AAM1E,kCAEG;AAEH,qCAIE"}
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":"AAUA,gCAAiC;AAUjC;;;;;GAKG;AAmBI;;;IAcN;AAIM;;;IAcN;AAMM,wDAC0D;AAI1D;;;IAUN"}
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":"AAaO,iDAYN;AAEM,4DAUN"}
1
+ {"version":3,"file":"timezone.d.ts","sourceRoot":"","sources":["../../src/utils/timezone.js"],"names":[],"mappings":"AAoBO,iDAaN;AAEM,4DAUN"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.3.0-alpha.756c1bd",
3
+ "version": "7.3.0-alpha.80c577a",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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"];
@@ -11,3 +11,4 @@ export * from "./pollutants.js";
11
11
  export * from "./alertLevels.js";
12
12
  export * from "./userNotifications.js";
13
13
  export * from "./timezones.js";
14
+ export * from "./dataDownloads.js";
@@ -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: isValidIANATimezone,
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 round
53
- // 5, S5). Stored as the acceptance timestamp rather than a boolean so the
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
- message: "email must be a valid email address",
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: "AQI",
159
+ value: AQI_TYPE,
140
160
  });
141
161
 
142
162
  const SampleDataDownload = DataDownloads.discriminator(
143
163
  "SampleDataDownload",
144
164
  sampleDataDownloadSchema,
145
- { value: "Sample" }
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, and the optional email. Pure functions, no DB, no clock reads. Same
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
- export const normalizeDownloadEmail = (value) =>
84
- typeof value === "string" && value.trim() === "" ? null : value;
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 || EMAIL_PATTERN.test(email)) {
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
  });
@@ -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
- let formatter = formatterCache.get(timeZone);
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(timeZone, formatter);
31
+ formatterCache.set(cacheKey, formatter);
24
32
  }
25
33
  return formatter;
26
34
  };