@justair/justair-library 7.2.0-alpha.29d5ea3 → 7.2.0-alpha.4a114f2
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/timezones.d.ts.map +1 -1
- package/dist/index.d.ts +2 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/models/dataDownloads.d.ts.map +1 -1
- package/dist/utils/dataDownloadRange.d.ts +0 -1
- package/dist/utils/dataDownloadRange.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants/timezones.js +1 -2
- package/src/index.js +5 -12
- package/src/models/dataDownloads.js +33 -36
- package/src/utils/dataDownloadRange.js +19 -43
- package/src/utils/tests/dataDownloadRange.test.js +0 -9
|
@@ -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":"AAKA,kDAAmD"}
|
package/dist/index.d.ts
CHANGED
|
@@ -90,13 +90,8 @@ import { dataDownloadSchema } from "./models/dataDownloads.js";
|
|
|
90
90
|
import { DataDownloads } from "./models/dataDownloads.js";
|
|
91
91
|
import { AqiDataDownload } from "./models/dataDownloads.js";
|
|
92
92
|
import { SampleDataDownload } from "./models/dataDownloads.js";
|
|
93
|
-
import { MAX_RANGE_DAYS } from "./utils/dataDownloadRange.js";
|
|
94
|
-
import { MAX_RANGE_MS } from "./utils/dataDownloadRange.js";
|
|
93
|
+
import { MAX_RANGE_DAYS as DATA_DOWNLOAD_MAX_RANGE_DAYS } from "./utils/dataDownloadRange.js";
|
|
95
94
|
import { DATA_DOWNLOAD_ERRORS } from "./utils/dataDownloadRange.js";
|
|
96
|
-
import { validateDateOrder } from "./utils/dataDownloadRange.js";
|
|
97
|
-
import { validateMaxDateRange } from "./utils/dataDownloadRange.js";
|
|
98
|
-
import { validateDownloadEmail } from "./utils/dataDownloadRange.js";
|
|
99
|
-
import { normalizeDownloadEmail } from "./utils/dataDownloadRange.js";
|
|
100
95
|
import { PARAMETERS } from "./constants/pollutants.js";
|
|
101
96
|
import { HEAVY_METALS } from "./constants/pollutants.js";
|
|
102
97
|
import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
|
|
@@ -111,5 +106,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
|
|
|
111
106
|
import { getZoneMinutes } from "./utils/quietHours.js";
|
|
112
107
|
import { isInQuietWindow } from "./utils/quietHours.js";
|
|
113
108
|
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, dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload,
|
|
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
110
|
//# 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":"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,8BAA8B;qCAA9B,8BAA8B;2BA/DI,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 +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":"AAsBA,qCA+CE;AAqDF,gCAA0E;AAM1E,kCAEG;AAEH,qCAIE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dataDownloadRange.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadRange.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dataDownloadRange.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadRange.js"],"names":[],"mappings":"AASA,gCAAiC;AAUjC;;;;GAIG;AAmBI;;;IAcN;AAIM;;;IAcN;AAMM,wDAC0D;AAI1D;;;IAUN"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Mongoose-free timezone defaults, so a browser-safe consumer (e.g.
|
|
2
2
|
// JustAir-Client) can use the same default other domains store server-side.
|
|
3
3
|
// Originally lived in utils/quietHours.js (JA-3054); moved here once a
|
|
4
|
-
// second, unrelated domain (dataDownloads.js, JA-3080) needed it too
|
|
5
|
-
// CLAUDE.md's "Note on constants placement".
|
|
4
|
+
// second, unrelated domain (dataDownloads.js, JA-3080) needed it too.
|
|
6
5
|
|
|
7
6
|
export const DEFAULT_TIMEZONE = "America/New_York";
|
package/src/index.js
CHANGED
|
@@ -123,13 +123,11 @@ import {
|
|
|
123
123
|
SampleDataDownload,
|
|
124
124
|
} from "./models/dataDownloads.js";
|
|
125
125
|
import {
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
// Renamed at the barrel only: the root export namespace is shared by every
|
|
127
|
+
// consuming service, so a bare MAX_RANGE_DAYS gives no hint it's specific to
|
|
128
|
+
// data downloads. Module-internal name is unchanged.
|
|
129
|
+
MAX_RANGE_DAYS as DATA_DOWNLOAD_MAX_RANGE_DAYS,
|
|
128
130
|
DATA_DOWNLOAD_ERRORS,
|
|
129
|
-
validateDateOrder,
|
|
130
|
-
validateMaxDateRange,
|
|
131
|
-
validateDownloadEmail,
|
|
132
|
-
normalizeDownloadEmail,
|
|
133
131
|
} from "./utils/dataDownloadRange.js";
|
|
134
132
|
|
|
135
133
|
export function createLoggerInstance({ DATADOG_API_KEY, APPLICATION_NAME }) {
|
|
@@ -230,13 +228,8 @@ export {
|
|
|
230
228
|
DataDownloads,
|
|
231
229
|
AqiDataDownload,
|
|
232
230
|
SampleDataDownload,
|
|
233
|
-
|
|
234
|
-
MAX_RANGE_MS,
|
|
231
|
+
DATA_DOWNLOAD_MAX_RANGE_DAYS,
|
|
235
232
|
DATA_DOWNLOAD_ERRORS,
|
|
236
|
-
validateDateOrder,
|
|
237
|
-
validateMaxDateRange,
|
|
238
|
-
validateDownloadEmail,
|
|
239
|
-
normalizeDownloadEmail,
|
|
240
233
|
// Pollutant constants
|
|
241
234
|
PARAMETERS,
|
|
242
235
|
HEAVY_METALS,
|
|
@@ -2,6 +2,7 @@ import mongoose from "mongoose";
|
|
|
2
2
|
import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
|
|
3
3
|
import { isValidIANATimezone } from "../utils/timezone.js";
|
|
4
4
|
import {
|
|
5
|
+
DATA_DOWNLOAD_ERRORS,
|
|
5
6
|
validateDateOrder,
|
|
6
7
|
validateMaxDateRange,
|
|
7
8
|
validateDownloadEmail,
|
|
@@ -28,26 +29,38 @@ const dataDownloadSchema = mongoose.Schema(
|
|
|
28
29
|
},
|
|
29
30
|
dateRangeStart: { type: Date, required: [true, "dateRangeStart is required"] },
|
|
30
31
|
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.
|
|
36
|
+
//
|
|
37
|
+
// This guarantees less than "always valid": `runValidators` defaults to
|
|
38
|
+
// false on query updates, so an updateOne can still persist a bad zone.
|
|
39
|
+
// Readers feeding this to Intl.DateTimeFormat must still defend.
|
|
31
40
|
timezone: {
|
|
32
41
|
type: String,
|
|
33
42
|
default: DEFAULT_TIMEZONE,
|
|
43
|
+
set: (value) => (value === null ? DEFAULT_TIMEZONE : value),
|
|
34
44
|
validate: {
|
|
35
45
|
validator: isValidIANATimezone,
|
|
36
46
|
message: "timezone must be a valid IANA timezone identifier",
|
|
37
47
|
},
|
|
38
48
|
},
|
|
39
49
|
downloadedAt: { type: Date, required: true, default: Date.now },
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// document exists) can never enforce two different email rules. See
|
|
44
|
-
// dataDownloadRange.js.
|
|
50
|
+
// Per-field validator rather than a pre('validate') hook, so it still
|
|
51
|
+
// applies on updateOne()/findOneAndUpdate() with { runValidators: true }.
|
|
52
|
+
// `type` surfaces as ValidatorError.kind, keeping the stable-code contract.
|
|
45
53
|
email: {
|
|
46
54
|
type: String,
|
|
47
55
|
trim: true,
|
|
48
56
|
lowercase: true,
|
|
49
57
|
default: null,
|
|
50
58
|
set: normalizeDownloadEmail,
|
|
59
|
+
validate: {
|
|
60
|
+
validator: (value) => validateDownloadEmail({ email: value }).length === 0,
|
|
61
|
+
message: "email must be a valid email address",
|
|
62
|
+
type: DATA_DOWNLOAD_ERRORS.INVALID_EMAIL,
|
|
63
|
+
},
|
|
51
64
|
},
|
|
52
65
|
},
|
|
53
66
|
{
|
|
@@ -56,28 +69,18 @@ const dataDownloadSchema = mongoose.Schema(
|
|
|
56
69
|
}
|
|
57
70
|
);
|
|
58
71
|
|
|
59
|
-
// Cross-field rule (quiet-hours shape, CLAUDE.md)
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// so create()/save() can't persist an invalid range even if a caller
|
|
64
|
-
// forgets to check.
|
|
72
|
+
// Cross-field rule (quiet-hours shape, CLAUDE.md). Applies to every kind; the
|
|
73
|
+
// 31-day cap is AQI-only and lives on AqiDataDownload below. Only genuinely
|
|
74
|
+
// cross-field rules belong here — per-field rules stay on their fields, since
|
|
75
|
+
// pre('validate') hooks don't run on query updates.
|
|
65
76
|
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// invalidate()'s `kind` argument, not just the message, so a caught
|
|
69
|
-
// ValidationError can be classified via err.errors.<path>.kind without
|
|
70
|
-
// string-matching wording that's free to change.
|
|
77
|
+
// Passes the error code as invalidate()'s `kind` so a caught ValidationError
|
|
78
|
+
// can be classified via err.errors.<path>.kind, not by message wording.
|
|
71
79
|
dataDownloadSchema.pre("validate", function () {
|
|
72
80
|
const rangeErrors = validateDateOrder({ start: this.dateRangeStart, end: this.dateRangeEnd });
|
|
73
81
|
for (const error of rangeErrors) {
|
|
74
82
|
this.invalidate("dateRangeEnd", error.message, this.dateRangeEnd, error.code);
|
|
75
83
|
}
|
|
76
|
-
|
|
77
|
-
const emailErrors = validateDownloadEmail({ email: this.email });
|
|
78
|
-
for (const error of emailErrors) {
|
|
79
|
-
this.invalidate("email", error.message, this.email, error.code);
|
|
80
|
-
}
|
|
81
84
|
});
|
|
82
85
|
|
|
83
86
|
// Org-level admin screen lists a given org's downloads, most recent first,
|
|
@@ -85,16 +88,11 @@ dataDownloadSchema.pre("validate", function () {
|
|
|
85
88
|
dataDownloadSchema.index({ orgId: 1, downloadedAt: -1 });
|
|
86
89
|
|
|
87
90
|
const aqiDataDownloadSchema = new mongoose.Schema({
|
|
88
|
-
// Figma's download modal
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// admin views and Slack alerts), whereas Monitors.siteId is not
|
|
94
|
-
// guaranteed set — it's only populated for monitors created through
|
|
95
|
-
// POST /devices's "linked device" flow, not plain POST /monitors — so a
|
|
96
|
-
// Sites join would render blank for a meaningful share of monitors.
|
|
97
|
-
// JA-3246/JA-3081 should populate this via monitorId, not siteId.
|
|
91
|
+
// Figma's download modal titles the AQI variant "Download Data for:
|
|
92
|
+
// {Site Name}". Resolve that label via monitorId -> Monitors.monitorCode,
|
|
93
|
+
// not a Monitors -> Sites.name join: Monitors.siteId is only populated by
|
|
94
|
+
// POST /devices's linked-device flow, so a Sites join renders blank for a
|
|
95
|
+
// meaningful share of monitors.
|
|
98
96
|
monitorId: {
|
|
99
97
|
type: mongoose.Types.ObjectId,
|
|
100
98
|
ref: "Monitors",
|
|
@@ -124,11 +122,10 @@ sampleDataDownloadSchema.index({ siteId: 1, downloadedAt: -1 });
|
|
|
124
122
|
|
|
125
123
|
const DataDownloads = mongoose.model("DataDownloads", dataDownloadSchema);
|
|
126
124
|
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// JA-3245 specifies regardless of the registry name.
|
|
125
|
+
// Registry names are namespaced (matches userNotifications.js): bare "AQI" /
|
|
126
|
+
// "Sample" model-registry keys would collide with a consuming service's own
|
|
127
|
+
// models of those names. { value } keeps the persisted dataType as JA-3245
|
|
128
|
+
// specifies regardless of the registry name.
|
|
132
129
|
const AqiDataDownload = DataDownloads.discriminator("AqiDataDownload", aqiDataDownloadSchema, {
|
|
133
130
|
value: "AQI",
|
|
134
131
|
});
|
|
@@ -1,29 +1,18 @@
|
|
|
1
1
|
// Data download date range validation (JA-3080/JA-3245): shared cross-field
|
|
2
|
-
// rules for the requested range on a DataDownloads document.
|
|
3
|
-
//
|
|
4
|
-
// write, JustAirService's intake endpoint (JA-3081) validates the same
|
|
5
|
-
// request shape before ever constructing a document. Pure functions, no DB,
|
|
6
|
-
// no clock reads. Same shape as quietHours.js's cross-field validator.
|
|
2
|
+
// rules for the requested range on a DataDownloads document. Pure functions,
|
|
3
|
+
// no DB, no clock reads. Same shape as quietHours.js's cross-field validator.
|
|
7
4
|
//
|
|
8
5
|
// Two separate rules, deliberately not one function: end-must-not-precede-
|
|
9
|
-
// start applies to every download kind, but the 31-day cap is AQI-only
|
|
10
|
-
// Sample downloads have no range limit. Compose the one(s) that apply to
|
|
11
|
-
// the kind being validated; don't call validateMaxDateRange for Sample.
|
|
6
|
+
// start applies to every download kind, but the 31-day cap is AQI-only.
|
|
12
7
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// `MAX_RANGE_DAYS * 24 * 60 * 60 * 1000` builds a stricter cap than the one
|
|
16
|
-
// actually enforced (see MAX_RANGE_MS below) and would reject the exact
|
|
17
|
-
// DST-crossing range the grace exists to allow. Import MAX_RANGE_MS instead
|
|
18
|
-
// for anything that needs the real bound; validateMaxDateRange is the sole
|
|
19
|
-
// authority either way.
|
|
8
|
+
// Messaging only ("range cannot exceed 31 days"). validateMaxDateRange is the
|
|
9
|
+
// sole authority on the enforced bound; don't rebuild the cap from this.
|
|
20
10
|
export const MAX_RANGE_DAYS = 31;
|
|
21
11
|
|
|
22
12
|
// DST slack: a calendar-accurate 31-day span crossing a fall-back transition
|
|
23
|
-
// is 745 (not 744) hours long
|
|
24
|
-
// product spec
|
|
25
|
-
|
|
26
|
-
export const MAX_RANGE_MS = MAX_RANGE_DAYS * 24 * 60 * 60 * 1000 + 60 * 60 * 1000;
|
|
13
|
+
// is 745 (not 744) hours long, so allow the extra hour rather than rejecting
|
|
14
|
+
// a range the product spec promises as valid.
|
|
15
|
+
const MAX_RANGE_MS = MAX_RANGE_DAYS * 24 * 60 * 60 * 1000 + 60 * 60 * 1000;
|
|
27
16
|
|
|
28
17
|
// Stable error codes for validateDateOrder/validateMaxDateRange/
|
|
29
18
|
// validateDownloadEmail. Consumers filter on `code` — never on message
|
|
@@ -34,15 +23,10 @@ export const DATA_DOWNLOAD_ERRORS = Object.freeze({
|
|
|
34
23
|
INVALID_EMAIL: "invalid_email",
|
|
35
24
|
});
|
|
36
25
|
|
|
37
|
-
// Coerces both ends through `new Date(...)
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// fire for exactly the caller these functions exist to serve. An unparsable
|
|
42
|
-
// value coerces to an Invalid Date (NaN time); format validation is the
|
|
43
|
-
// schema/type layer's job, not a cross-field rule's, so that's treated the
|
|
44
|
-
// same as "not this validator's concern" and returns null rather than
|
|
45
|
-
// throwing or reporting a spurious range error.
|
|
26
|
+
// Coerces both ends through `new Date(...)`: a JSON request body hands these
|
|
27
|
+
// raw strings, and comparing un-coerced strings silently produces NaN, so the
|
|
28
|
+
// caps would never fire. Unparsable input returns null — format validation is
|
|
29
|
+
// the schema layer's job, not a cross-field rule's.
|
|
46
30
|
const toTimes = (start, end) => {
|
|
47
31
|
if (!start || !end) {
|
|
48
32
|
return null;
|
|
@@ -55,9 +39,7 @@ const toTimes = (start, end) => {
|
|
|
55
39
|
return { startTime, endTime };
|
|
56
40
|
};
|
|
57
41
|
|
|
58
|
-
// Returns
|
|
59
|
-
// to every download kind (AQI and Sample) — the Figma "31 days" cap is
|
|
60
|
-
// AQI-only and is NOT enforced here, see validateMaxDateRange.
|
|
42
|
+
// Returns { code, message }[]; empty = valid. Applies to every download kind.
|
|
61
43
|
export const validateDateOrder = ({ start, end } = {}) => {
|
|
62
44
|
const times = toTimes(start, end);
|
|
63
45
|
if (!times) {
|
|
@@ -74,10 +56,8 @@ export const validateDateOrder = ({ start, end } = {}) => {
|
|
|
74
56
|
return [];
|
|
75
57
|
};
|
|
76
58
|
|
|
77
|
-
// Returns
|
|
78
|
-
//
|
|
79
|
-
// has already confirmed order via validateDateOrder (a reversed range isn't
|
|
80
|
-
// re-reported here as "too long").
|
|
59
|
+
// Returns { code, message }[]; empty = valid. AQI only — Sample downloads have
|
|
60
|
+
// no range limit. Assumes order was already checked by validateDateOrder.
|
|
81
61
|
export const validateMaxDateRange = ({ start, end } = {}) => {
|
|
82
62
|
const times = toTimes(start, end);
|
|
83
63
|
if (!times || times.endTime < times.startTime) {
|
|
@@ -96,17 +76,13 @@ export const validateMaxDateRange = ({ start, end } = {}) => {
|
|
|
96
76
|
|
|
97
77
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
98
78
|
|
|
99
|
-
// Email is optional (JA-3080)
|
|
100
|
-
//
|
|
101
|
-
// JA-3081's intake endpoint (before ever constructing a document) need this
|
|
102
|
-
// same "blank string collapses to null" rule, so it lives here rather than
|
|
103
|
-
// only on the schema.
|
|
79
|
+
// Email is optional (JA-3080): a blank form field means "not sharing my email",
|
|
80
|
+
// not "invalid email", so a blank string collapses to null.
|
|
104
81
|
export const normalizeDownloadEmail = (value) =>
|
|
105
82
|
typeof value === "string" && value.trim() === "" ? null : value;
|
|
106
83
|
|
|
107
|
-
// Returns
|
|
108
|
-
//
|
|
109
|
-
// reaches this as null instead of failing the pattern check.
|
|
84
|
+
// Returns { code, message }[]; empty = valid. null/undefined is valid — call
|
|
85
|
+
// normalizeDownloadEmail first so a blank string arrives as null.
|
|
110
86
|
export const validateDownloadEmail = ({ email } = {}) => {
|
|
111
87
|
if (email === null || email === undefined || EMAIL_PATTERN.test(email)) {
|
|
112
88
|
return [];
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MAX_RANGE_DAYS,
|
|
3
|
-
MAX_RANGE_MS,
|
|
4
3
|
DATA_DOWNLOAD_ERRORS,
|
|
5
4
|
validateDateOrder,
|
|
6
5
|
validateMaxDateRange,
|
|
@@ -12,14 +11,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
12
11
|
const HOUR_MS = 60 * 60 * 1000;
|
|
13
12
|
const start = new Date("2026-01-01T00:00:00.000Z");
|
|
14
13
|
|
|
15
|
-
// S1: MAX_RANGE_DAYS alone under-describes the enforced bound. A consumer
|
|
16
|
-
// needing the real cap (e.g. to clamp a date picker) should import
|
|
17
|
-
// MAX_RANGE_MS directly rather than recomputing MAX_RANGE_DAYS * 24h and
|
|
18
|
-
// silently building a stricter limit than validateMaxDateRange enforces.
|
|
19
|
-
test("MAX_RANGE_MS is MAX_RANGE_DAYS plus the 1-hour DST grace, not a plain day-to-ms conversion", () => {
|
|
20
|
-
expect(MAX_RANGE_MS).toBe(MAX_RANGE_DAYS * DAY_MS + HOUR_MS);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
14
|
describe("validateDateOrder (applies to every download kind)", () => {
|
|
24
15
|
test("returns no errors when start/end are missing", () => {
|
|
25
16
|
expect(validateDateOrder({})).toEqual([]);
|