@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.
- package/dist/constants/dataDownloads.d.ts +2 -0
- package/dist/constants/dataDownloads.d.ts.map +1 -0
- package/dist/constants/index.d.ts +1 -0
- package/dist/constants/timezones.d.ts.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/models/dataDownloads.d.ts.map +1 -1
- package/dist/utils/dataDownloadValidation.d.ts +5 -0
- package/dist/utils/dataDownloadValidation.d.ts.map +1 -1
- package/dist/utils/timezone.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants/dataDownloads.js +19 -0
- package/src/constants/index.js +1 -0
- package/src/constants/tests/constantsBarrel.test.js +12 -0
- package/src/constants/timezones.js +6 -0
- package/src/index.js +15 -5
- package/src/models/dataDownloads.js +27 -13
- package/src/utils/dataDownloadValidation.js +44 -4
- package/src/utils/tests/dataDownloadValidation.test.js +58 -2
- package/src/utils/tests/timezone.test.js +14 -0
- package/src/utils/timezone.js +10 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/constants/dataDownloads.js"],"names":[],"mappings":"AAkBA,oDAAoE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"timezones.d.ts","sourceRoot":"","sources":["../../src/constants/timezones.js"],"names":[],"mappings":"
|
|
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,
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"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":"
|
|
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":"
|
|
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":"
|
|
1
|
+
{"version":3,"file":"timezone.d.ts","sourceRoot":"","sources":["../../src/utils/timezone.js"],"names":[],"mappings":"AAoBO,iDAaN;AAEM,4DAUN"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,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"]);
|
package/src/constants/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import * as barrel from '../index.js';
|
|
|
7
7
|
import { PARAMETERS, HEAVY_METALS } from '../pollutants.js';
|
|
8
8
|
import { ALERT_LEVEL_WEIGHTS, getAlertWeight } from '../alertLevels.js';
|
|
9
9
|
import { DEFAULT_TIMEZONE } from '../timezones.js';
|
|
10
|
+
import { DATA_DOWNLOAD_TYPES } from '../dataDownloads.js';
|
|
10
11
|
|
|
11
12
|
test('barrel re-exports PARAMETERS and HEAVY_METALS unchanged (identity, not copies)', () => {
|
|
12
13
|
expect(barrel.PARAMETERS).toBe(PARAMETERS);
|
|
@@ -50,3 +51,14 @@ test('barrel exports DEFAULT_TIMEZONE (browser-safe, no Mongoose)', () => {
|
|
|
50
51
|
expect(barrel.DEFAULT_TIMEZONE).toBe(DEFAULT_TIMEZONE);
|
|
51
52
|
expect(DEFAULT_TIMEZONE).toBe('America/New_York');
|
|
52
53
|
});
|
|
54
|
+
|
|
55
|
+
test('barrel exports DATA_DOWNLOAD_TYPES (browser-safe, no Mongoose)', () => {
|
|
56
|
+
expect(barrel.DATA_DOWNLOAD_TYPES).toBe(DATA_DOWNLOAD_TYPES);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// models/dataDownloads.js destructures this list positionally to build the two
|
|
60
|
+
// discriminators, so a reorder would silently swap the persisted dataType
|
|
61
|
+
// values on every future download. Pin contents AND order.
|
|
62
|
+
test('DATA_DOWNLOAD_TYPES pins the persisted dataType values in order', () => {
|
|
63
|
+
expect(DATA_DOWNLOAD_TYPES).toEqual(['AQI', 'Sample']);
|
|
64
|
+
});
|
|
@@ -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
|
|
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
|
-
|
|
245
|
-
|
|
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
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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:
|
|
46
|
+
set: normalizeDownloadTimezone,
|
|
45
47
|
validate: {
|
|
46
|
-
validator:
|
|
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
|
|
54
|
-
//
|
|
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
|
|
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:
|
|
160
|
+
value: AQI_TYPE,
|
|
147
161
|
});
|
|
148
162
|
|
|
149
163
|
const SampleDataDownload = DataDownloads.discriminator(
|
|
150
164
|
"SampleDataDownload",
|
|
151
165
|
sampleDataDownloadSchema,
|
|
152
|
-
{ value:
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
});
|
package/src/utils/timezone.js
CHANGED
|
@@ -9,10 +9,18 @@
|
|
|
9
9
|
// on a cached formatter is cheap. The cache also makes validation and
|
|
10
10
|
// zone-minute lookups (quietHours.js's getZoneMinutes) share one authority:
|
|
11
11
|
// a timezone is valid iff Intl accepts it.
|
|
12
|
+
//
|
|
13
|
+
// Keyed lowercase because Intl treats IANA names case-insensitively:
|
|
14
|
+
// "America/New_York", "america/new_york" and "AMERICA/NEW_YORK" all resolve to
|
|
15
|
+
// the same zone, so keying on the raw string would cache a separate permanent
|
|
16
|
+
// formatter per casing variant. dataDownloads.js validates a per-request
|
|
17
|
+
// `timezone`, so that string can come from a client (JA-3081) — one entry per
|
|
18
|
+
// real zone rather than per spelling keeps the map bounded.
|
|
12
19
|
const formatterCache = new Map();
|
|
13
20
|
|
|
14
21
|
export const getFormatter = (timeZone) => {
|
|
15
|
-
|
|
22
|
+
const cacheKey = typeof timeZone === "string" ? timeZone.toLowerCase() : timeZone;
|
|
23
|
+
let formatter = formatterCache.get(cacheKey);
|
|
16
24
|
if (formatter === undefined) {
|
|
17
25
|
formatter = new Intl.DateTimeFormat("en-US", {
|
|
18
26
|
timeZone,
|
|
@@ -20,7 +28,7 @@ export const getFormatter = (timeZone) => {
|
|
|
20
28
|
hour: "2-digit",
|
|
21
29
|
minute: "2-digit",
|
|
22
30
|
});
|
|
23
|
-
formatterCache.set(
|
|
31
|
+
formatterCache.set(cacheKey, formatter);
|
|
24
32
|
}
|
|
25
33
|
return formatter;
|
|
26
34
|
};
|