@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.
@@ -1 +1 @@
1
- {"version":3,"file":"timezones.d.ts","sourceRoot":"","sources":["../../src/constants/timezones.js"],"names":[],"mappings":"AAMA,kDAAmD"}
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, MAX_RANGE_DAYS, MAX_RANGE_MS, DATA_DOWNLOAD_ERRORS, validateDateOrder, validateMaxDateRange, validateDownloadEmail, normalizeDownloadEmail, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAsIA;;;iBAEC;yBAtEwB,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;+BAS3B,8BAA8B;6BAA9B,8BAA8B;qCAA9B,8BAA8B;kCAA9B,8BAA8B;qCAA9B,8BAA8B;sCAA9B,8BAA8B;uCAA9B,8BAA8B;2BAjEI,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":"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":"AAqBA,qCAmCE;AAoEF,gCAA0E;AAO1E,kCAEG;AAEH,qCAIE"}
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,5 +1,4 @@
1
1
  export const MAX_RANGE_DAYS: 31;
2
- export const MAX_RANGE_MS: number;
3
2
  export const DATA_DOWNLOAD_ERRORS: Readonly<{
4
3
  END_BEFORE_START: "end_before_start";
5
4
  RANGE_TOO_LONG: "range_too_long";
@@ -1 +1 @@
1
- {"version":3,"file":"dataDownloadRange.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadRange.js"],"names":[],"mappings":"AAmBA,gCAAiC;AAMjC,kCAAkF;AAKlF;;;;GAIG;AA0BI;;;IAcN;AAMM;;;IAcN;AASM,wDAC0D;AAK1D;;;IAUN"}
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.2.0-alpha.29d5ea3",
3
+ "version": "7.2.0-alpha.4a114f2",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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 — see
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
- MAX_RANGE_DAYS,
127
- MAX_RANGE_MS,
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
- MAX_RANGE_DAYS,
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
- // Format validation is delegated to validateDownloadEmail in the
41
- // pre('validate') hook below, not an inline schema validator, so this
42
- // and JA-3081's intake endpoint (validating the request body before a
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): delegated to
60
- // validateDateOrder so this schema and JA-3081's write-path validation can
61
- // never drift. Applies to every kind — the 31-day cap is AQI-only and lives
62
- // on AqiDataDownload's own schema below, not here. Lives in pre('validate')
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
- // Email is validated here too (not an inline schema validator) for the same
67
- // shared-module reason. Both hooks pass the DATA_DOWNLOAD_ERRORS code as
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 (node 4748:16300) titles the AQI variant
89
- // "Download Data for: {Site Name}". Resolve that label via
90
- // monitorId -> Monitors.monitorCode, NOT a Monitors -> Sites.name join:
91
- // Monitors don't carry their own name and monitorCode is JustAirService's
92
- // existing human-readable identifier for a monitor (already used in
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
- // Discriminator model names are namespaced (matches userNotifications.js's
128
- // aqi_alert/device_offline/weekly_digest) — "AQI"/"Sample" as bare mongoose
129
- // model-registry keys would collide with any consuming service's own models
130
- // of those names. { value } keeps the persisted dataType exactly what
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. Single source
3
- // of truth for every consumer — this library's schema enforces these on
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
- // Display/messaging only ("range cannot exceed 31 days"). NOT the enforced
14
- // bound — a consumer clamping a date picker with
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. Allow it rather than rejecting a range the
24
- // product spec (Figma) explicitly promises as valid. This, not
25
- // MAX_RANGE_DAYS, is the number validateMaxDateRange actually enforces.
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(...)` before comparing/subtracting: a
38
- // JSON request body (JA-3081's intake endpoint) hands these functions raw
39
- // strings, not Date instances, and comparing/subtracting un-coerced strings
40
- // silently produces NaN (`NaN > x` is always false) — the caps would never
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 an array of { code, message } errors; empty array = valid. Applies
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 an array of { code, message } errors; empty array = valid. AQI
78
- // downloads only — Sample downloads have no range limit. Assumes the caller
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) — a blank form field means "not sharing my
100
- // email", not "invalid email". Both the schema (as a Mongoose `set`) and
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 an array of { code, message } errors; empty array = valid. null/
108
- // undefined is valid — call normalizeDownloadEmail first so a blank string
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([]);