@justair/justair-library 7.2.0-alpha.a00effb → 7.2.0-alpha.e76a08c

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,4 +1,5 @@
1
1
  export * from "./pollutants.js";
2
2
  export * from "./alertLevels.js";
3
3
  export * from "./userNotifications.js";
4
+ export * from "./timezones.js";
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export const DEFAULT_TIMEZONE: "America/New_York";
2
+ //# sourceMappingURL=timezones.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timezones.d.ts","sourceRoot":"","sources":["../../src/constants/timezones.js"],"names":[],"mappings":"AAMA,kDAAmD"}
package/dist/index.d.ts CHANGED
@@ -77,8 +77,6 @@ import { passwordResetTokenSchema } from "./models/passwordResetToken.js";
77
77
  import { PasswordResetToken } from "./models/passwordResetToken.js";
78
78
  import { monitorEpisodesSchema } from "./models/monitorEpisodes.js";
79
79
  import { MonitorEpisode } from "./models/monitorEpisodes.js";
80
- import { dataDownloadSchema } from "./models/dataDownloads.js";
81
- import { DataDownloads } from "./models/dataDownloads.js";
82
80
  import { userNotificationsSchema } from "./models/userNotifications.js";
83
81
  import { UserNotification } from "./models/userNotifications.js";
84
82
  import { AqiAlertNotification } from "./models/userNotifications.js";
@@ -88,13 +86,21 @@ import { AccountNotification } from "./models/userNotifications.js";
88
86
  import { NOTIFICATION_KINDS } from "./models/userNotifications.js";
89
87
  import { NOTIFICATION_CHANNELS } from "./models/userNotifications.js";
90
88
  import { DELIVERY_STATUSES } from "./models/userNotifications.js";
89
+ import { dataDownloadSchema } from "./models/dataDownloads.js";
90
+ import { DataDownloads } from "./models/dataDownloads.js";
91
+ import { AqiDataDownload } from "./models/dataDownloads.js";
92
+ import { SampleDataDownload } from "./models/dataDownloads.js";
93
+ import { MAX_RANGE_DAYS } from "./utils/dataDownloadRange.js";
94
+ import { DATA_DOWNLOAD_ERRORS } from "./utils/dataDownloadRange.js";
95
+ import { validateDateOrder } from "./utils/dataDownloadRange.js";
96
+ import { validateMaxDateRange } from "./utils/dataDownloadRange.js";
91
97
  import { PARAMETERS } from "./constants/pollutants.js";
92
98
  import { HEAVY_METALS } from "./constants/pollutants.js";
93
99
  import { ALERT_LEVEL_WEIGHTS } from "./constants/alertLevels.js";
94
100
  import { getAlertWeight } from "./constants/alertLevels.js";
95
101
  import { splitName } from "./utils/splitName.js";
96
102
  import { DEFAULT_QUIET_HOURS } from "./utils/quietHours.js";
97
- import { DEFAULT_TIMEZONE } from "./utils/quietHours.js";
103
+ import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
98
104
  import { QUIET_HOURS_ERRORS } from "./utils/quietHours.js";
99
105
  import { isValidTimeString } from "./utils/quietHours.js";
100
106
  import { isValidIANATimezone } from "./utils/quietHours.js";
@@ -102,5 +108,5 @@ import { validateQuietHours } from "./utils/quietHours.js";
102
108
  import { getZoneMinutes } from "./utils/quietHours.js";
103
109
  import { isInQuietWindow } from "./utils/quietHours.js";
104
110
  import { resolveQuietHours } from "./utils/quietHours.js";
105
- 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, dataDownloadSchema, DataDownloads, userNotificationsSchema, UserNotification, AqiAlertNotification, DeviceOfflineNotification, WeeklyDigestNotification, AccountNotification, NOTIFICATION_KINDS, NOTIFICATION_CHANNELS, DELIVERY_STATUSES, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
111
+ 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, DATA_DOWNLOAD_ERRORS, validateDateOrder, validateMaxDateRange, PARAMETERS, HEAVY_METALS, ALERT_LEVEL_WEIGHTS, getAlertWeight, splitName, DEFAULT_QUIET_HOURS, DEFAULT_TIMEZONE, QUIET_HOURS_ERRORS, isValidTimeString, isValidIANATimezone, validateQuietHours, getZoneMinutes, isInQuietWindow, resolveQuietHours };
106
112
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAwHA;;;iBAEC;yBAxDwB,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;sCA7CtF,6BAA6B;+BAA7B,6BAA6B;mCA8Cc,2BAA2B;8BAA3B,2BAA2B;wCAnCtE,+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;2BAhBG,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;0BAgBtD,sBAAsB;oCAWzC,uBAAuB;iCAAvB,uBAAuB;mCAAvB,uBAAuB;kCAAvB,uBAAuB;oCAAvB,uBAAuB;mCAAvB,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAmIA;;;iBAEC;yBAnEwB,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;+BAM3B,8BAA8B;qCAA9B,8BAA8B;kCAA9B,8BAA8B;qCAA9B,8BAA8B;2BA9DI,2BAA2B;6BAA3B,2BAA2B;oCAChB,4BAA4B;+BAA5B,4BAA4B;0BAiBtD,sBAAsB;oCAUzC,uBAAuB;iCA1BG,0BAA0B;mCA0BpD,uBAAuB;kCAAvB,uBAAuB;oCAAvB,uBAAuB;mCAAvB,uBAAuB;+BAAvB,uBAAuB;gCAAvB,uBAAuB;kCAAvB,uBAAuB"}
@@ -1,3 +1,5 @@
1
1
  export const dataDownloadSchema: any;
2
2
  export const DataDownloads: any;
3
+ export const AqiDataDownload: any;
4
+ export const SampleDataDownload: any;
3
5
  //# sourceMappingURL=dataDownloads.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/models/dataDownloads.js"],"names":[],"mappings":"AAEA,qCAwBE;AAEF,gCAA0E"}
1
+ {"version":3,"file":"dataDownloads.d.ts","sourceRoot":"","sources":["../../src/models/dataDownloads.js"],"names":[],"mappings":"AAkBA,qCAkCE;AA+CF,gCAA0E;AAO1E,kCAEG;AAEH,qCAIE"}
@@ -0,0 +1,14 @@
1
+ export const MAX_RANGE_DAYS: 31;
2
+ export const DATA_DOWNLOAD_ERRORS: Readonly<{
3
+ END_BEFORE_START: "end_before_start";
4
+ RANGE_TOO_LONG: "range_too_long";
5
+ }>;
6
+ export function validateDateOrder({ start, end }?: {}): {
7
+ code: "end_before_start";
8
+ message: string;
9
+ }[];
10
+ export function validateMaxDateRange({ start, end }?: {}): {
11
+ code: "range_too_long";
12
+ message: string;
13
+ }[];
14
+ //# sourceMappingURL=dataDownloadRange.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataDownloadRange.d.ts","sourceRoot":"","sources":["../../src/utils/dataDownloadRange.js"],"names":[],"mappings":"AAYA,gCAAiC;AASjC;;;GAGG;AA0BI;;;IAcN;AAMM;;;IAcN"}
@@ -1,8 +1,8 @@
1
+ export { DEFAULT_TIMEZONE };
1
2
  export const DEFAULT_QUIET_HOURS: Readonly<{
2
3
  start: "22:00";
3
4
  end: "07:00";
4
5
  }>;
5
- export const DEFAULT_TIMEZONE: "America/New_York";
6
6
  export const QUIET_HOURS_ERRORS: Readonly<{
7
7
  HALF_PAIR: "half_pair";
8
8
  FORMAT: "format";
@@ -30,4 +30,5 @@ export function resolveQuietHours(user: any): {
30
30
  timezone: string;
31
31
  warnings: string[];
32
32
  };
33
+ import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
33
34
  //# sourceMappingURL=quietHours.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"quietHours.d.ts","sourceRoot":"","sources":["../../src/utils/quietHours.js"],"names":[],"mappings":"AAkBA;;;GAGG;AAEH,kDAAmD;AAKnD;;;;GAIG;AAuBI,uDACgD;AAEhD,4DAUN;AAOM;;;;;;;;;KAsCN;AASM,iEASN;AAKM,uEAaN;AAeM;;;;;;;EA6CN"}
1
+ {"version":3,"file":"quietHours.d.ts","sourceRoot":"","sources":["../../src/utils/quietHours.js"],"names":[],"mappings":";AAwBA;;;GAGG;AAKH;;;;GAIG;AAuBI,uDACgD;AAEhD,4DAUN;AAOM;;;;;;;;;KAsCN;AASM,iEASN;AAKM,uEAaN;AAeM;;;;;;;EA6CN;iCAnMgC,2BAA2B"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dataDownloadRange.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dataDownloadRange.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/dataDownloadRange.test.js"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justair/justair-library",
3
- "version": "7.2.0-alpha.a00effb",
3
+ "version": "7.2.0-alpha.e76a08c",
4
4
  "description": "JustAir Internal Library",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -10,3 +10,4 @@
10
10
  export * from "./pollutants.js";
11
11
  export * from "./alertLevels.js";
12
12
  export * from "./userNotifications.js";
13
+ export * from "./timezones.js";
@@ -6,6 +6,7 @@
6
6
  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
+ import { DEFAULT_TIMEZONE } from '../timezones.js';
9
10
 
10
11
  test('barrel re-exports PARAMETERS and HEAVY_METALS unchanged (identity, not copies)', () => {
11
12
  expect(barrel.PARAMETERS).toBe(PARAMETERS);
@@ -44,3 +45,8 @@ test('unknown and legacy statuses fall back to the Good weight (never alert, nev
44
45
  expect(getAlertWeight(null)).toBe(1);
45
46
  expect(getAlertWeight('')).toBe(1);
46
47
  });
48
+
49
+ test('barrel exports DEFAULT_TIMEZONE (browser-safe, no Mongoose)', () => {
50
+ expect(barrel.DEFAULT_TIMEZONE).toBe(DEFAULT_TIMEZONE);
51
+ expect(DEFAULT_TIMEZONE).toBe('America/New_York');
52
+ });
@@ -0,0 +1,7 @@
1
+ // Mongoose-free timezone defaults, so a browser-safe consumer (e.g.
2
+ // JustAir-Client) can use the same default other domains store server-side.
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".
6
+
7
+ export const DEFAULT_TIMEZONE = "America/New_York";
package/src/index.js CHANGED
@@ -67,6 +67,7 @@ import Database from "./config/db.js"; // Import the new Database class
67
67
  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
+ import { DEFAULT_TIMEZONE } from "./constants/timezones.js";
70
71
  import {
71
72
  monitorEpisodesSchema,
72
73
  MonitorEpisode,
@@ -85,7 +86,6 @@ import {
85
86
  import { splitName } from "./utils/splitName.js";
86
87
  import {
87
88
  DEFAULT_QUIET_HOURS,
88
- DEFAULT_TIMEZONE,
89
89
  QUIET_HOURS_ERRORS,
90
90
  isValidTimeString,
91
91
  isValidIANATimezone,
@@ -116,7 +116,18 @@ import {
116
116
  sampleParameterReferenceConcentrations,
117
117
  } from "./models/samples.js";
118
118
  import { passwordResetTokenSchema, PasswordResetToken } from "./models/passwordResetToken.js";
119
- import { dataDownloadSchema, DataDownloads } from "./models/dataDownloads.js";
119
+ import {
120
+ dataDownloadSchema,
121
+ DataDownloads,
122
+ AqiDataDownload,
123
+ SampleDataDownload,
124
+ } from "./models/dataDownloads.js";
125
+ import {
126
+ MAX_RANGE_DAYS,
127
+ DATA_DOWNLOAD_ERRORS,
128
+ validateDateOrder,
129
+ validateMaxDateRange,
130
+ } from "./utils/dataDownloadRange.js";
120
131
 
121
132
  export function createLoggerInstance({ DATADOG_API_KEY, APPLICATION_NAME }) {
122
133
  return new CustomLogger({ DATADOG_API_KEY, APPLICATION_NAME });
@@ -202,9 +213,6 @@ export {
202
213
  // Notification episodes + send log (JA-2658 / JA-2691 stack)
203
214
  monitorEpisodesSchema,
204
215
  MonitorEpisode,
205
- // Data downloads (JA-3080)
206
- dataDownloadSchema,
207
- DataDownloads,
208
216
  userNotificationsSchema,
209
217
  UserNotification,
210
218
  AqiAlertNotification,
@@ -214,6 +222,15 @@ export {
214
222
  NOTIFICATION_KINDS,
215
223
  NOTIFICATION_CHANNELS,
216
224
  DELIVERY_STATUSES,
225
+ // Data downloads (JA-3080)
226
+ dataDownloadSchema,
227
+ DataDownloads,
228
+ AqiDataDownload,
229
+ SampleDataDownload,
230
+ MAX_RANGE_DAYS,
231
+ DATA_DOWNLOAD_ERRORS,
232
+ validateDateOrder,
233
+ validateMaxDateRange,
217
234
  // Pollutant constants
218
235
  PARAMETERS,
219
236
  HEAVY_METALS,
@@ -1,31 +1,117 @@
1
1
  import mongoose from "mongoose";
2
+ import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
3
+ import { isValidIANATimezone } from "../utils/quietHours.js";
4
+ import { validateDateOrder, validateMaxDateRange } from "../utils/dataDownloadRange.js";
2
5
 
6
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
7
+
8
+ // DataDownloads — one doc per data download request (JA-3080). Written by
9
+ // JustAirService on download (JA-3081); read by the org-level admin
10
+ // communication screen. AQI downloads are monitor-scoped, Sample downloads
11
+ // are site-scoped (samples.js keys samples to SampleSites, not Monitors) —
12
+ // modeled as discriminators per CLAUDE.md's multi-kind pattern rather than
13
+ // a flat schema with a monitorId/siteId pair that's nullable either way.
14
+ //
15
+ // The base model permits a kind-less document (create() without `dataType`)
16
+ // — same property `userNotifications.js`'s base model has. Write through
17
+ // AqiDataDownload/SampleDataDownload, not DataDownloads, so every stored
18
+ // document has the ref field the admin screen needs to render it.
3
19
  const dataDownloadSchema = mongoose.Schema(
4
20
  {
5
- monitorId: {
6
- type: mongoose.Types.ObjectId,
7
- ref: "Monitors",
8
- required: [true, "monitorId is required"],
9
- },
10
21
  orgId: {
11
22
  type: mongoose.Types.ObjectId,
12
23
  ref: "Organizations",
13
24
  required: [true, "orgId is required"],
14
25
  },
15
- dataType: {
26
+ dateRangeStart: { type: Date, required: [true, "dateRangeStart is required"] },
27
+ dateRangeEnd: { type: Date, required: [true, "dateRangeEnd is required"] },
28
+ timezone: {
29
+ type: String,
30
+ default: DEFAULT_TIMEZONE,
31
+ validate: {
32
+ validator: isValidIANATimezone,
33
+ message: "timezone must be a valid IANA timezone identifier",
34
+ },
35
+ },
36
+ downloadedAt: { type: Date, required: true, default: Date.now },
37
+ email: {
16
38
  type: String,
17
- enum: ["AQI", "Sample"],
18
- required: [true, "dataType is required"],
39
+ trim: true,
40
+ lowercase: true,
41
+ default: null,
42
+ set: (value) => (typeof value === "string" && value.trim() === "" ? null : value),
43
+ validate: {
44
+ validator: (value) => value === null || EMAIL_PATTERN.test(value),
45
+ message: "email must be a valid email address",
46
+ },
19
47
  },
20
- startDate: { type: Date, required: [true, "startDate is required"] },
21
- endDate: { type: Date, required: [true, "endDate is required"] },
22
- email: { type: String, default: null },
23
48
  },
24
49
  {
25
50
  timestamps: true,
51
+ discriminatorKey: "dataType",
26
52
  }
27
53
  );
28
54
 
55
+ // Cross-field rule (quiet-hours shape, CLAUDE.md): delegated to
56
+ // validateDateOrder so this schema and JA-3081's write-path validation can
57
+ // never drift. Applies to every kind — the 31-day cap is AQI-only and lives
58
+ // on AqiDataDownload's own schema below, not here. Lives in pre('validate')
59
+ // so create()/save() can't persist an invalid range even if a caller
60
+ // forgets to check.
61
+ dataDownloadSchema.pre("validate", function () {
62
+ const errors = validateDateOrder({ start: this.dateRangeStart, end: this.dateRangeEnd });
63
+ for (const error of errors) {
64
+ this.invalidate("dateRangeEnd", error.message);
65
+ }
66
+ });
67
+
68
+ // Org-level admin screen lists a given org's downloads, most recent first,
69
+ // by the domain-meaningful "date of the download" (JA-3080), not createdAt.
70
+ dataDownloadSchema.index({ orgId: 1, downloadedAt: -1 });
71
+
72
+ const aqiDataDownloadSchema = new mongoose.Schema({
73
+ monitorId: {
74
+ type: mongoose.Types.ObjectId,
75
+ ref: "Monitors",
76
+ required: [true, "monitorId is required"],
77
+ },
78
+ });
79
+ aqiDataDownloadSchema.index({ monitorId: 1, downloadedAt: -1 });
80
+
81
+ // AQI-only: the 31-day cap does not apply to Sample downloads. Mongoose
82
+ // merges base + discriminator schema hooks by default (mergeHooks), so this
83
+ // runs alongside (not instead of) the base's validateDateOrder check above.
84
+ aqiDataDownloadSchema.pre("validate", function () {
85
+ const errors = validateMaxDateRange({ start: this.dateRangeStart, end: this.dateRangeEnd });
86
+ for (const error of errors) {
87
+ this.invalidate("dateRangeEnd", error.message);
88
+ }
89
+ });
90
+
91
+ const sampleDataDownloadSchema = new mongoose.Schema({
92
+ siteId: {
93
+ type: mongoose.Types.ObjectId,
94
+ ref: "SampleSites",
95
+ required: [true, "siteId is required"],
96
+ },
97
+ });
98
+ sampleDataDownloadSchema.index({ siteId: 1, downloadedAt: -1 });
99
+
29
100
  const DataDownloads = mongoose.model("DataDownloads", dataDownloadSchema);
30
101
 
31
- export { dataDownloadSchema, DataDownloads };
102
+ // Discriminator model names are namespaced (matches userNotifications.js's
103
+ // aqi_alert/device_offline/weekly_digest) — "AQI"/"Sample" as bare mongoose
104
+ // model-registry keys would collide with any consuming service's own models
105
+ // of those names. { value } keeps the persisted dataType exactly what
106
+ // JA-3245 specifies regardless of the registry name.
107
+ const AqiDataDownload = DataDownloads.discriminator("AqiDataDownload", aqiDataDownloadSchema, {
108
+ value: "AQI",
109
+ });
110
+
111
+ const SampleDataDownload = DataDownloads.discriminator(
112
+ "SampleDataDownload",
113
+ sampleDataDownloadSchema,
114
+ { value: "Sample" }
115
+ );
116
+
117
+ export { dataDownloadSchema, DataDownloads, AqiDataDownload, SampleDataDownload };
@@ -0,0 +1,85 @@
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.
7
+ //
8
+ // 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.
12
+
13
+ export const MAX_RANGE_DAYS = 31;
14
+
15
+ // DST slack: a calendar-accurate 31-day span crossing a fall-back transition
16
+ // is 745 (not 744) hours long. Allow it rather than rejecting a range the
17
+ // product spec (Figma) explicitly promises as valid.
18
+ const MAX_RANGE_MS = MAX_RANGE_DAYS * 24 * 60 * 60 * 1000 + 60 * 60 * 1000;
19
+
20
+ // Stable error codes for validateDateOrder/validateMaxDateRange. Consumers
21
+ // filter on `code` — never on message wording, which is free to change.
22
+ export const DATA_DOWNLOAD_ERRORS = Object.freeze({
23
+ END_BEFORE_START: "end_before_start",
24
+ RANGE_TOO_LONG: "range_too_long",
25
+ });
26
+
27
+ // Coerces both ends through `new Date(...)` before comparing/subtracting: a
28
+ // JSON request body (JA-3081's intake endpoint) hands these functions raw
29
+ // strings, not Date instances, and comparing/subtracting un-coerced strings
30
+ // silently produces NaN (`NaN > x` is always false) — the caps would never
31
+ // fire for exactly the caller these functions exist to serve. An unparsable
32
+ // value coerces to an Invalid Date (NaN time); format validation is the
33
+ // schema/type layer's job, not a cross-field rule's, so that's treated the
34
+ // same as "not this validator's concern" and returns null rather than
35
+ // throwing or reporting a spurious range error.
36
+ const toTimes = (start, end) => {
37
+ if (!start || !end) {
38
+ return null;
39
+ }
40
+ const startTime = new Date(start).getTime();
41
+ const endTime = new Date(end).getTime();
42
+ if (Number.isNaN(startTime) || Number.isNaN(endTime)) {
43
+ return null;
44
+ }
45
+ return { startTime, endTime };
46
+ };
47
+
48
+ // Returns an array of { code, message } errors; empty array = valid. Applies
49
+ // to every download kind (AQI and Sample) — the Figma "31 days" cap is
50
+ // AQI-only and is NOT enforced here, see validateMaxDateRange.
51
+ export const validateDateOrder = ({ start, end } = {}) => {
52
+ const times = toTimes(start, end);
53
+ if (!times) {
54
+ return [];
55
+ }
56
+ if (times.endTime < times.startTime) {
57
+ return [
58
+ {
59
+ code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START,
60
+ message: "dateRangeEnd must not be before dateRangeStart",
61
+ },
62
+ ];
63
+ }
64
+ return [];
65
+ };
66
+
67
+ // Returns an array of { code, message } errors; empty array = valid. AQI
68
+ // downloads only — Sample downloads have no range limit. Assumes the caller
69
+ // has already confirmed order via validateDateOrder (a reversed range isn't
70
+ // re-reported here as "too long").
71
+ export const validateMaxDateRange = ({ start, end } = {}) => {
72
+ const times = toTimes(start, end);
73
+ if (!times || times.endTime < times.startTime) {
74
+ return [];
75
+ }
76
+ if (times.endTime - times.startTime > MAX_RANGE_MS) {
77
+ return [
78
+ {
79
+ code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG,
80
+ message: `date range cannot exceed ${MAX_RANGE_DAYS} days`,
81
+ },
82
+ ];
83
+ }
84
+ return [];
85
+ };
@@ -16,13 +16,17 @@
16
16
  // Boundary rule: start is inclusive, end is exclusive — a 22:00–07:00 user
17
17
  // is suppressed at 22:00 sharp and receives alerts again at 07:00 sharp.
18
18
 
19
+ import { DEFAULT_TIMEZONE } from "../constants/timezones.js";
20
+
21
+ // Re-exported for existing backend imports — canonical definition now lives
22
+ // in src/constants/timezones.js (Mongoose-free, browser-safe).
23
+ export { DEFAULT_TIMEZONE };
24
+
19
25
  export const DEFAULT_QUIET_HOURS = Object.freeze({
20
26
  start: "22:00",
21
27
  end: "07:00",
22
28
  });
23
29
 
24
- export const DEFAULT_TIMEZONE = "America/New_York";
25
-
26
30
  // Stable error codes for validateQuietHours. Consumers filter on `code`
27
31
  // (e.g. the users schema pre-validate hook re-attaches only cross-field
28
32
  // errors) — never on message wording, which is free to change.
@@ -0,0 +1,89 @@
1
+ import {
2
+ MAX_RANGE_DAYS,
3
+ DATA_DOWNLOAD_ERRORS,
4
+ validateDateOrder,
5
+ validateMaxDateRange,
6
+ } from "../dataDownloadRange.js";
7
+
8
+ const DAY_MS = 24 * 60 * 60 * 1000;
9
+ const HOUR_MS = 60 * 60 * 1000;
10
+ const start = new Date("2026-01-01T00:00:00.000Z");
11
+
12
+ describe("validateDateOrder (applies to every download kind)", () => {
13
+ test("returns no errors when start/end are missing", () => {
14
+ expect(validateDateOrder({})).toEqual([]);
15
+ expect(validateDateOrder({ start })).toEqual([]);
16
+ expect(validateDateOrder({ end: start })).toEqual([]);
17
+ });
18
+
19
+ test("returns no errors for a valid order", () => {
20
+ const end = new Date(start.getTime() + DAY_MS);
21
+ expect(validateDateOrder({ start, end })).toEqual([]);
22
+ });
23
+
24
+ test("flags end before start with END_BEFORE_START", () => {
25
+ const end = new Date(start.getTime() - DAY_MS);
26
+ expect(validateDateOrder({ start, end })).toEqual([
27
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START }),
28
+ ]);
29
+ });
30
+
31
+ test("does not flag a range far longer than MAX_RANGE_DAYS — that's validateMaxDateRange's job", () => {
32
+ const end = new Date(start.getTime() + 365 * DAY_MS);
33
+ expect(validateDateOrder({ start, end })).toEqual([]);
34
+ });
35
+
36
+ // JA-3081's intake endpoint hands this raw JSON request-body strings, not
37
+ // Date instances — must not silently misjudge order via NaN-producing
38
+ // string arithmetic (see dataDownloadRange.js's toTimes coercion comment).
39
+ test("flags end before start given as raw date strings", () => {
40
+ expect(
41
+ validateDateOrder({ start: "2026-05-01", end: "2026-01-01" })
42
+ ).toEqual([expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.END_BEFORE_START })]);
43
+ });
44
+
45
+ test("returns no errors for an unparsable date value", () => {
46
+ expect(validateDateOrder({ start: "not-a-date", end: start })).toEqual([]);
47
+ });
48
+ });
49
+
50
+ describe("validateMaxDateRange (AQI only — Sample downloads have no cap)", () => {
51
+ test("returns no errors when start/end are missing", () => {
52
+ expect(validateMaxDateRange({})).toEqual([]);
53
+ });
54
+
55
+ test("accepts exactly MAX_RANGE_DAYS", () => {
56
+ const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS);
57
+ expect(validateMaxDateRange({ start, end })).toEqual([]);
58
+ });
59
+
60
+ // The +1h grace exists so a calendar-accurate 31-day span crossing a DST
61
+ // fall-back transition (745 real hours, not 744) still validates — see
62
+ // dataDownloadRange.js's MAX_RANGE_MS comment.
63
+ test("accepts MAX_RANGE_DAYS plus the 1-hour DST grace", () => {
64
+ const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS + HOUR_MS);
65
+ expect(validateMaxDateRange({ start, end })).toEqual([]);
66
+ });
67
+
68
+ test("flags a range longer than MAX_RANGE_DAYS plus the DST grace with RANGE_TOO_LONG", () => {
69
+ const end = new Date(start.getTime() + MAX_RANGE_DAYS * DAY_MS + HOUR_MS + 1);
70
+ expect(validateMaxDateRange({ start, end })).toEqual([
71
+ expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG }),
72
+ ]);
73
+ });
74
+
75
+ test("flags a too-long range given as raw date strings (JSON request-body shape)", () => {
76
+ expect(
77
+ validateMaxDateRange({ start: "2026-01-01", end: "2027-06-01" })
78
+ ).toEqual([expect.objectContaining({ code: DATA_DOWNLOAD_ERRORS.RANGE_TOO_LONG })]);
79
+ });
80
+
81
+ test("does not re-report a reversed range as RANGE_TOO_LONG — validateDateOrder owns that", () => {
82
+ const end = new Date(start.getTime() - DAY_MS);
83
+ expect(validateMaxDateRange({ start, end })).toEqual([]);
84
+ });
85
+
86
+ test("returns no errors for an unparsable date value", () => {
87
+ expect(validateMaxDateRange({ start: "not-a-date", end: start })).toEqual([]);
88
+ });
89
+ });