@justair/justair-library 7.3.0-alpha.2a2e727 → 7.3.0-alpha.2a44b19

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: readonly 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":"AAkBA,oDAAoE"}
@@ -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
@@ -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
@@ -86,6 +86,7 @@ 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";
@@ -94,8 +95,10 @@ import { MAX_RANGE_DAYS as DATA_DOWNLOAD_MAX_RANGE_DAYS } from "./utils/dataDown
94
95
  import { DATA_DOWNLOAD_ERRORS } from "./utils/dataDownloadValidation.js";
95
96
  import { validateDateOrder as validateDataDownloadDateOrder } from "./utils/dataDownloadValidation.js";
96
97
  import { validateMaxDateRange as validateDataDownloadMaxDateRange } from "./utils/dataDownloadValidation.js";
97
- import { validateDownloadEmail } from "./utils/dataDownloadValidation.js";
98
- import { normalizeDownloadEmail } 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";
101
+ import { normalizeDownloadTimezone as normalizeDataDownloadTimezone } from "./utils/dataDownloadValidation.js";
99
102
  import { PARAMETERS } from "./constants/pollutants.js";
100
103
  import { HEAVY_METALS } from "./constants/pollutants.js";
101
104
  import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
@@ -110,5 +113,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
110
113
  import { getZoneMinutes } from "./utils/quietHours.js";
111
114
  import { isInQuietWindow } from "./utils/quietHours.js";
112
115
  import { resolveQuietHours } from "./utils/quietHours.js";
113
- 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, validateDataDownloadDateOrder, validateDataDownloadMaxDateRange, validateDownloadEmail, normalizeDownloadEmail, 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 };
114
117
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AA6IA;;;iBAEC;yBA7EwB,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;+DAgB3B,mCAAmC;qCAAnC,mCAAmC;mEAAnC,mCAAmC;yEAAnC,mCAAmC;sCAAnC,mCAAmC;uCAAnC,mCAAmC;2BAxED,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":"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":"AAsBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DE;AAqDF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAA0E;AAM1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAEG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAIE;qBAxJmB,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"}
@@ -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.2a2e727",
3
+ "version": "7.3.0-alpha.2a44b19",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,19 @@
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
+ // 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"]);
@@ -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
+ });
@@ -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
@@ -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,15 +129,21 @@ 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,
131
- // The rule ships with the number (round 6, I1). The enforced bound is
132
+ // The rule ships with the number. The enforced bound is
132
133
  // MAX_RANGE_DAYS plus a 1-hour DST grace, so a consumer rebuilding the cap
133
134
  // as `DATA_DOWNLOAD_MAX_RANGE_DAYS * 86400000` builds a stricter limit than
134
135
  // the model and rejects a 745-hour range the schema saves. Same reason
135
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.
136
141
  validateDateOrder as validateDataDownloadDateOrder,
137
142
  validateMaxDateRange as validateDataDownloadMaxDateRange,
138
- validateDownloadEmail,
139
- normalizeDownloadEmail,
143
+ validateDownloadEmail as validateDataDownloadEmail,
144
+ normalizeDownloadEmail as normalizeDataDownloadEmail,
145
+ validateDownloadTimezone as validateDataDownloadTimezone,
146
+ normalizeDownloadTimezone as normalizeDataDownloadTimezone,
140
147
  } from "./utils/dataDownloadValidation.js";
141
148
 
142
149
  export function createLoggerInstance({ DATADOG_API_KEY, APPLICATION_NAME }) {
@@ -233,6 +240,7 @@ export {
233
240
  NOTIFICATION_CHANNELS,
234
241
  DELIVERY_STATUSES,
235
242
  // Data downloads (JA-3080)
243
+ DATA_DOWNLOAD_TYPES,
236
244
  dataDownloadSchema,
237
245
  DataDownloads,
238
246
  AqiDataDownload,
@@ -241,8 +249,10 @@ export {
241
249
  DATA_DOWNLOAD_ERRORS,
242
250
  validateDataDownloadDateOrder,
243
251
  validateDataDownloadMaxDateRange,
244
- validateDownloadEmail,
245
- normalizeDownloadEmail,
252
+ validateDataDownloadEmail,
253
+ normalizeDataDownloadEmail,
254
+ validateDataDownloadTimezone,
255
+ normalizeDataDownloadTimezone,
246
256
  // Pollutant constants
247
257
  PARAMETERS,
248
258
  HEAVY_METALS,
@@ -1,12 +1,14 @@
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,
11
+ normalizeDownloadTimezone,
10
12
  } from "../utils/dataDownloadValidation.js";
11
13
 
12
14
  // DataDownloads — one doc per data download request (JA-3080). Written by
@@ -29,10 +31,10 @@ const dataDownloadSchema = new mongoose.Schema(
29
31
  },
30
32
  dateRangeStart: { type: Date, required: [true, "dateRangeStart is required"] },
31
33
  dateRangeEnd: { type: Date, required: [true, "dateRangeEnd is required"] },
32
- // `default` only applies to `undefined`, not `null`, so an explicit
33
- // `timezone: null` (the normal shape of "didn't pick one" in a JSON body)
34
- // is coerced in a setter — setters run on query updates, the pre('validate')
35
- // 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.
36
38
  //
37
39
  // This guarantees less than "always valid": `runValidators` defaults to
38
40
  // false on query updates, so an updateOne can still persist a bad zone.
@@ -41,17 +43,17 @@ const dataDownloadSchema = new mongoose.Schema(
41
43
  type: String,
42
44
  trim: true,
43
45
  default: DEFAULT_TIMEZONE,
44
- set: (value) => (value === null ? DEFAULT_TIMEZONE : value),
46
+ set: normalizeDownloadTimezone,
45
47
  validate: {
46
- validator: isValidIANATimezone,
48
+ validator: (value) => validateDownloadTimezone({ timezone: value }).length === 0,
47
49
  message: "timezone must be a valid IANA timezone identifier",
48
50
  type: DATA_DOWNLOAD_ERRORS.INVALID_TIMEZONE,
49
51
  },
50
52
  },
51
- downloadedAt: { type: Date, required: true, default: Date.now },
53
+ downloadedAt: { type: Date, required: [true, "downloadedAt is required"], default: Date.now },
52
54
  // Figma's download modal requires checking "I have reviewed and agree to
53
- // the Data Sharing Agreement" before the download proceeds (JA-3245 round
54
- // 5, S5). Stored as the acceptance timestamp rather than a boolean so the
55
+ // the Data Sharing Agreement" before the download proceeds (JA-3245).
56
+ // Stored as the acceptance timestamp rather than a boolean so the
55
57
  // record answers "when did they agree", which is what an audit trail of a
56
58
  // consent decision needs. null means no acceptance was recorded — neither
57
59
  // JA-3245 nor JA-3080 lists this field, so it stays optional until the
@@ -71,7 +73,7 @@ const dataDownloadSchema = new mongoose.Schema(
71
73
  // Resolved from the rule rather than a static string: validateDownloadEmail
72
74
  // distinguishes "too long" from "malformed", and a static message threw
73
75
  // that away, so a 300-character address came back as "invalid" with no
74
- // hint of the real problem (round 8, S1).
76
+ // hint of the real problem.
75
77
  message: (props) =>
76
78
  validateDownloadEmail({ email: props.value })[0]?.message ??
77
79
  "email must be a valid email address",
@@ -92,6 +94,12 @@ const dataDownloadSchema = new mongoose.Schema(
92
94
  //
93
95
  // Passes the error code as invalidate()'s `kind` so a caught ValidationError
94
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.
95
103
  dataDownloadSchema.pre("validate", function () {
96
104
  const rangeErrors = validateDateOrder({ start: this.dateRangeStart, end: this.dateRangeEnd });
97
105
  for (const error of rangeErrors) {
@@ -138,18 +146,24 @@ sampleDataDownloadSchema.index({ siteId: 1, downloadedAt: -1 });
138
146
 
139
147
  const DataDownloads = mongoose.model("DataDownloads", dataDownloadSchema);
140
148
 
149
+ // Derived from the constant per CLAUDE.md, so adding a type (JA-3080 expects
150
+ // concentration next) is one edit in one file. Positional, and pinned by
151
+ // constantsBarrel.test.js — reordering DATA_DOWNLOAD_TYPES fails that test
152
+ // before it can silently swap the two persisted values.
153
+ const [AQI_TYPE, SAMPLE_TYPE] = DATA_DOWNLOAD_TYPES;
154
+
141
155
  // Registry names are namespaced (matches userNotifications.js): bare "AQI" /
142
156
  // "Sample" model-registry keys would collide with a consuming service's own
143
157
  // models of those names. { value } keeps the persisted dataType as JA-3245
144
158
  // specifies regardless of the registry name.
145
159
  const AqiDataDownload = DataDownloads.discriminator("AqiDataDownload", aqiDataDownloadSchema, {
146
- value: "AQI",
160
+ value: AQI_TYPE,
147
161
  });
148
162
 
149
163
  const SampleDataDownload = DataDownloads.discriminator(
150
164
  "SampleDataDownload",
151
165
  sampleDataDownloadSchema,
152
- { value: "Sample" }
166
+ { value: SAMPLE_TYPE }
153
167
  );
154
168
 
155
169
  export { dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload };
@@ -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.
@@ -82,14 +84,14 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
82
84
  // schema `maxlength` so the exported rule and the model agree: a schema-only
83
85
  // cap let validateDownloadEmail pass a 300-character address that create()
84
86
  // then rejected with kind "maxlength", which isn't a DATA_DOWNLOAD_ERRORS
85
- // code (round 7, I2).
87
+ // code.
86
88
  const MAX_EMAIL_LENGTH = 254;
87
89
 
88
90
  // Email is optional (JA-3080): a blank form field means "not sharing my email",
89
91
  // not "invalid email", so a blank string collapses to null. Trims and
90
92
  // lowercases to match the schema's `trim`/`lowercase` setters — without that,
91
93
  // a consumer normalizing then validating " A@B.co " gets INVALID_EMAIL for
92
- // input the model would have stored (round 7, I3).
94
+ // input the model would have stored.
93
95
  export const normalizeDownloadEmail = (value) => {
94
96
  if (typeof value !== "string") {
95
97
  return value;
@@ -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;
@@ -100,7 +103,7 @@ describe("normalizeDownloadEmail (blank means 'not shared', not invalid)", () =>
100
103
  expect(normalizeDownloadEmail("someone@example.com")).toBe("someone@example.com");
101
104
  });
102
105
 
103
- // I3: matches the schema's trim/lowercase setters. Without this the exported
106
+ // Matches the schema's trim/lowercase setters. Without this the exported
104
107
  // pair rejects padded input that AqiDataDownload.create() stores fine.
105
108
  test("trims and lowercases so it agrees with the schema setters", () => {
106
109
  expect(normalizeDownloadEmail(" Someone@Example.com ")).toBe("someone@example.com");
@@ -116,7 +119,7 @@ describe("normalizeDownloadEmail (blank means 'not shared', not invalid)", () =>
116
119
  });
117
120
 
118
121
  describe("validateDownloadEmail (email is optional — JA-3080)", () => {
119
- // I2: the 254-character bound is enforced here, not as a schema maxlength,
122
+ // The 254-character bound is enforced here, not as a schema maxlength,
120
123
  // so the exported rule and the model can't disagree on the same address.
121
124
  test("flags an address longer than 254 characters", () => {
122
125
  const tooLong = `${"a".repeat(250)}@b.co`;
@@ -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
+ });
@@ -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
  };