@justair/justair-library 7.3.0-alpha.e85bbda → 7.3.0

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.
@@ -1,2 +1,2 @@
1
- export const DATA_DOWNLOAD_TYPES: string[];
1
+ export const DATA_DOWNLOAD_TYPES: readonly string[];
2
2
  //# sourceMappingURL=dataDownloads.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/constants/dataDownloads.js"],"names":[],"mappings":"AAeA,2CAAqD"}
1
+ {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/constants/dataDownloads.js"],"names":[],"mappings":"AAkBA,oDAAoE"}
@@ -1 +1 @@
1
- {"version":3,"file":"timezones.d.ts","sourceRoot":"","sources":["../../src/constants/timezones.js"],"names":[],"mappings":"AAKA,kDAAmD"}
1
+ {"version":3,"file":"timezones.d.ts","sourceRoot":"","sources":["../../src/constants/timezones.js"],"names":[],"mappings":"AAWA,kDAAmD"}
package/dist/index.d.ts CHANGED
@@ -97,6 +97,8 @@ import { validateDateOrder as validateDataDownloadDateOrder } from "./utils/data
97
97
  import { validateMaxDateRange as validateDataDownloadMaxDateRange } from "./utils/dataDownloadValidation.js";
98
98
  import { validateDownloadEmail as validateDataDownloadEmail } from "./utils/dataDownloadValidation.js";
99
99
  import { normalizeDownloadEmail as normalizeDataDownloadEmail } from "./utils/dataDownloadValidation.js";
100
+ import { validateDownloadTimezone as validateDataDownloadTimezone } from "./utils/dataDownloadValidation.js";
101
+ import { normalizeDownloadTimezone as normalizeDataDownloadTimezone } from "./utils/dataDownloadValidation.js";
100
102
  import { PARAMETERS } from "./constants/pollutants.js";
101
103
  import { HEAVY_METALS } from "./constants/pollutants.js";
102
104
  import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
@@ -111,5 +113,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
111
113
  import { getZoneMinutes } from "./utils/quietHours.js";
112
114
  import { isInQuietWindow } from "./utils/quietHours.js";
113
115
  import { resolveQuietHours } from "./utils/quietHours.js";
114
- 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, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
116
+ export { Database, adminSchema, Admin, configurationsSchema, Configurations, measurementsSchema, Measurements, monitorRequestsSchema, MonitorRequests, monitorsSchema, Monitors, organizationsSchema, Organizations, usersSchema, Users, COOKIE_CONSENT_CATEGORIES, COOKIE_CONSENT_VALUES, validateCookieConsent, eventsSchema, Events, lightMonitorSchema, LightMonitors, monitorSuppliersSchema, MonitorSuppliers, contextsSchema, Contexts, parametersSchema, Parameters, announcementSchema, Announcements, jobsSchema, Jobs, apiKeySchema, ApiKey, UsageMetrics, usageMetricsSchema, Audit, auditSchema, EventsAudit, eventsAuditSchema, MonitorAudit, monitorAuditSchema, parametersEnum, deploymentTypesEnum, AlertsAudit, Alerts, alertsSchema, alertsAuditSchema, Features, featuresSchema, dataCompletenessSchema, DataCompleteness, networkMetricsSchema, NetworkMetrics, rateOfChangeSchema, RateOfChange, changeStreamCheckpointsSchema, ChangeStreamCheckpoints, sitesSchema, Sites, sampleSitesSchema, SampleSites, sampleSiteAuditSchema, SampleSiteAudit, sampleParametersEnum, samplesSchema, Samples, samplesAuditSchema, SamplesAudit, sampleParameterReferenceConcentrations, passwordResetTokenSchema, PasswordResetToken, monitorEpisodesSchema, MonitorEpisode, userNotificationsSchema, UserNotification, AqiAlertNotification, DeviceOfflineNotification, WeeklyDigestNotification, AccountNotification, NOTIFICATION_KINDS, NOTIFICATION_CHANNELS, DELIVERY_STATUSES, DATA_DOWNLOAD_TYPES, dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload, DATA_DOWNLOAD_MAX_RANGE_DAYS, DATA_DOWNLOAD_ERRORS, validateDataDownloadDateOrder, validateDataDownloadMaxDateRange, validateDataDownloadEmail, normalizeDataDownloadEmail, validateDataDownloadTimezone, normalizeDataDownloadTimezone, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
115
117
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAkJA;;;iBAEC;yBAlFwB,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;+DAoB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;2BA7ED,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
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAoJA;;;iBAEC;yBApFwB,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;+DAsB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;mEAAnC,mCAAmC;qEAAnC,mCAAmC;yEAAnC,mCAAmC;2EAAnC,mCAAmC;2BA/ED,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 +1 @@
1
- {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/models/dataDownloads.js"],"names":[],"mappings":"AAuBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DE;AAqDF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAA0E;AAY1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAEG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAIE;qBA/JmB,UAAU"}
1
+ {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/models/dataDownloads.js"],"names":[],"mappings":"AAwBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DE;AA2DF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAA0E;AAY1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAEG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAIE;qBAtKmB,UAAU"}
@@ -18,4 +18,9 @@ export function validateDownloadEmail({ email }?: {}): {
18
18
  code: "invalid_email";
19
19
  message: string;
20
20
  }[];
21
+ export function normalizeDownloadTimezone(value: any): any;
22
+ export function validateDownloadTimezone({ timezone }?: {}): {
23
+ code: "invalid_timezone";
24
+ message: string;
25
+ }[];
21
26
  //# 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;AAgBM,wDAMN;AAIM;;;IAqBN"}
1
+ {"version":3,"file":"dataDownloadValidation.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadValidation.js"],"names":[],"mappings":"AAYA,gCAAiC;AAUjC;;;;;GAKG;AAmBI;;;IAcN;AAIM;;;IAcN;AAgBM,wDAMN;AAIM;;;IAqBN;AAOM,2DAWN;AAOM;;;IAaN"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.3.0-alpha.e85bbda",
3
+ "version": "7.3.0",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -13,4 +13,7 @@
13
13
  // as a third type — add it here and register its discriminator in
14
14
  // models/dataDownloads.js, so no consumer has to hardcode the string.
15
15
 
16
- export const DATA_DOWNLOAD_TYPES = ["AQI", "Sample"];
16
+ // Frozen because every importer in a process gets this same instance — one
17
+ // consumer calling .sort() or .push() would silently reorder the picker for
18
+ // all of them. Matches DATA_DOWNLOAD_ERRORS in dataDownloadValidation.js.
19
+ export const DATA_DOWNLOAD_TYPES = Object.freeze(["AQI", "Sample"]);
@@ -3,4 +3,10 @@
3
3
  // Originally lived in utils/quietHours.js (JA-3054); moved here once a
4
4
  // second, unrelated domain (dataDownloads.js, JA-3080) needed it too.
5
5
 
6
+ // Deliberately no DOWNLOAD_TIMEZONES / TIMEZONE_OPTIONS list here. The
7
+ // download modal's "Set Timezone" dropdown offers display labels ("Eastern
8
+ // Standard Time (EST)") over IANA values, and labels are a view concern:
9
+ // this library has no i18n and no view layer, so a label map would drift from
10
+ // design the first time the copy changed. The validator accepts any zone Intl
11
+ // accepts (utils/timezone.js), so the consumer owns which subset it offers.
6
12
  export const DEFAULT_TIMEZONE = "America/New_York";
package/src/index.js CHANGED
@@ -142,6 +142,8 @@ import {
142
142
  validateMaxDateRange as validateDataDownloadMaxDateRange,
143
143
  validateDownloadEmail as validateDataDownloadEmail,
144
144
  normalizeDownloadEmail as normalizeDataDownloadEmail,
145
+ validateDownloadTimezone as validateDataDownloadTimezone,
146
+ normalizeDownloadTimezone as normalizeDataDownloadTimezone,
145
147
  } from "./utils/dataDownloadValidation.js";
146
148
 
147
149
  export function createLoggerInstance({ DATADOG_API_KEY, APPLICATION_NAME }) {
@@ -249,6 +251,8 @@ export {
249
251
  validateDataDownloadMaxDateRange,
250
252
  validateDataDownloadEmail,
251
253
  normalizeDataDownloadEmail,
254
+ validateDataDownloadTimezone,
255
+ normalizeDataDownloadTimezone,
252
256
  // Pollutant constants
253
257
  PARAMETERS,
254
258
  HEAVY_METALS,
@@ -1,13 +1,14 @@
1
1
  import mongoose from "mongoose";
2
2
  import { DATA_DOWNLOAD_TYPES } from "../constants/dataDownloads.js";
3
3
  import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
4
- import { isValidIANATimezone } from "../utils/timezone.js";
5
4
  import {
6
5
  DATA_DOWNLOAD_ERRORS,
7
6
  validateDateOrder,
8
7
  validateMaxDateRange,
9
8
  validateDownloadEmail,
10
9
  normalizeDownloadEmail,
10
+ validateDownloadTimezone,
11
+ normalizeDownloadTimezone,
11
12
  } from "../utils/dataDownloadValidation.js";
12
13
 
13
14
  // DataDownloads — one doc per data download request (JA-3080). Written by
@@ -30,10 +31,10 @@ const dataDownloadSchema = new mongoose.Schema(
30
31
  },
31
32
  dateRangeStart: { type: Date, required: [true, "dateRangeStart is required"] },
32
33
  dateRangeEnd: { type: Date, required: [true, "dateRangeEnd is required"] },
33
- // `default` only applies to `undefined`, not `null`, so an explicit
34
- // `timezone: null` (the normal shape of "didn't pick one" in a JSON body)
35
- // is coerced in a setter — setters run on query updates, the pre('validate')
36
- // hook below does not.
34
+ // `default` only applies to `undefined`, not `null` or "", so both of the
35
+ // other "didn't pick one" shapes (a JSON body's null, an untouched HTML
36
+ // <select>'s empty string) are coerced in a setter — setters run on query
37
+ // updates, the pre('validate') hook below does not.
37
38
  //
38
39
  // This guarantees less than "always valid": `runValidators` defaults to
39
40
  // false on query updates, so an updateOne can still persist a bad zone.
@@ -42,9 +43,9 @@ const dataDownloadSchema = new mongoose.Schema(
42
43
  type: String,
43
44
  trim: true,
44
45
  default: DEFAULT_TIMEZONE,
45
- set: (value) => (value === null ? DEFAULT_TIMEZONE : value),
46
+ set: normalizeDownloadTimezone,
46
47
  validate: {
47
- validator: isValidIANATimezone,
48
+ validator: (value) => validateDownloadTimezone({ timezone: value }).length === 0,
48
49
  message: "timezone must be a valid IANA timezone identifier",
49
50
  type: DATA_DOWNLOAD_ERRORS.INVALID_TIMEZONE,
50
51
  },
@@ -93,6 +94,12 @@ const dataDownloadSchema = new mongoose.Schema(
93
94
  //
94
95
  // Passes the error code as invalidate()'s `kind` so a caught ValidationError
95
96
  // can be classified via err.errors.<path>.kind, not by message wording.
97
+ //
98
+ // Same caveat the `timezone` field carries: pre('validate') never runs on a
99
+ // query update, so a `$set` of dateRangeStart/dateRangeEnd can persist a
100
+ // reversed or over-length range even with { runValidators: true }. Deliberate
101
+ // — the rules are cross-field, and query updates have no full document to
102
+ // check. Writers should go through create()/save(); readers must still defend.
96
103
  dataDownloadSchema.pre("validate", function () {
97
104
  const rangeErrors = validateDateOrder({ start: this.dateRangeStart, end: this.dateRangeEnd });
98
105
  for (const error of rangeErrors) {
@@ -1,10 +1,12 @@
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";
9
+ import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
8
10
 
9
11
  // Messaging only ("range cannot exceed 31 days"). validateMaxDateRange is the
10
12
  // sole authority on the enforced bound; don't rebuild the cap from this.
@@ -122,3 +124,41 @@ export const validateDownloadEmail = ({ email } = {}) => {
122
124
  },
123
125
  ];
124
126
  };
127
+
128
+ // Every shape of "didn't pick one" resolves to the default, mirroring
129
+ // normalizeDownloadEmail's blank-collapses-to-null contract. An untouched HTML
130
+ // <select> posts "" and a JSON body usually carries null, so treating only one
131
+ // of them as unset rejects a download for a field the user never touched.
132
+ // `undefined` passes through so the schema's own `default` still applies.
133
+ export const normalizeDownloadTimezone = (value) => {
134
+ if (value === undefined) {
135
+ return value;
136
+ }
137
+ if (value === null) {
138
+ return DEFAULT_TIMEZONE;
139
+ }
140
+ if (typeof value === "string" && value.trim() === "") {
141
+ return DEFAULT_TIMEZONE;
142
+ }
143
+ return value;
144
+ };
145
+
146
+ // Returns { code, message }[]; empty = valid. Anything normalizeDownloadTimezone
147
+ // resolves to the default is valid here too, so a consumer pre-validating a
148
+ // request body accepts exactly what the model stores. isValidIANATimezone alone
149
+ // rejects null and "", which would fail a request the model saves happily —
150
+ // the write-path/read-path split the email rules exist to avoid.
151
+ export const validateDownloadTimezone = ({ timezone } = {}) => {
152
+ if (normalizeDownloadTimezone(timezone) !== timezone) {
153
+ return [];
154
+ }
155
+ if (timezone === undefined || isValidIANATimezone(timezone)) {
156
+ return [];
157
+ }
158
+ return [
159
+ {
160
+ code: DATA_DOWNLOAD_ERRORS.INVALID_TIMEZONE,
161
+ message: "timezone must be a valid IANA timezone identifier",
162
+ },
163
+ ];
164
+ };
@@ -4,8 +4,11 @@ import {
4
4
  validateDateOrder,
5
5
  validateMaxDateRange,
6
6
  validateDownloadEmail,
7
+ validateDownloadTimezone,
8
+ normalizeDownloadTimezone,
7
9
  normalizeDownloadEmail,
8
10
  } from "../dataDownloadValidation.js";
11
+ import { DEFAULT_TIMEZONE } from "../../constants/timezones.js";
9
12
 
10
13
  const DAY_MS = 24 * 60 * 60 * 1000;
11
14
  const HOUR_MS = 60 * 60 * 1000;
@@ -155,3 +158,56 @@ describe("validateDownloadEmail (email is optional — JA-3080)", () => {
155
158
  ]);
156
159
  });
157
160
  });
161
+
162
+ describe("validateDownloadTimezone", () => {
163
+ test.each(["America/New_York", "America/Detroit", "UTC", "Asia/Tokyo"])(
164
+ "accepts %s",
165
+ (timezone) => {
166
+ expect(validateDownloadTimezone({ timezone })).toEqual([]);
167
+ }
168
+ );
169
+
170
+ // The schema setter resolves both to DEFAULT_TIMEZONE, so a consumer
171
+ // pre-validating a request body must accept what the model stores.
172
+ // An untouched <select> posts "", a JSON body usually carries null. Both mean
173
+ // "didn't pick one", so both must validate the way the setter resolves them.
174
+ test.each([null, undefined, "", " "])(
175
+ "treats %p as valid (the setter fills the default)",
176
+ (timezone) => {
177
+ expect(validateDownloadTimezone({ timezone })).toEqual([]);
178
+ }
179
+ );
180
+
181
+ test("accepts a call with no argument at all", () => {
182
+ expect(validateDownloadTimezone()).toEqual([]);
183
+ });
184
+
185
+ test.each(["America/Fake_City", "Not/A_Zone", 5, {}])(
186
+ "flags %p with INVALID_TIMEZONE",
187
+ (timezone) => {
188
+ expect(validateDownloadTimezone({ timezone })).toEqual([
189
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.INVALID_TIMEZONE }),
190
+ ]);
191
+ }
192
+ );
193
+ });
194
+
195
+ describe("normalizeDownloadTimezone", () => {
196
+ test.each([null, "", " "])("resolves %p to the default", (value) => {
197
+ expect(normalizeDownloadTimezone(value)).toBe(DEFAULT_TIMEZONE);
198
+ });
199
+
200
+ // Passes through so the schema's own `default` still applies.
201
+ test("leaves undefined alone", () => {
202
+ expect(normalizeDownloadTimezone(undefined)).toBeUndefined();
203
+ });
204
+
205
+ test("leaves a real zone untouched", () => {
206
+ expect(normalizeDownloadTimezone("Asia/Tokyo")).toBe("Asia/Tokyo");
207
+ });
208
+
209
+ // Validation, not normalization, is what rejects these.
210
+ test.each(["Not/A_Zone", 5])("leaves invalid input %p for the validator", (value) => {
211
+ expect(normalizeDownloadTimezone(value)).toBe(value);
212
+ });
213
+ });