tabletcommand-backend-models 7.4.105 → 7.4.107

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.
Files changed (81) hide show
  1. package/build/constants.js +1 -0
  2. package/build/constants.js.map +1 -1
  3. package/build/index.js +2 -0
  4. package/build/index.js.map +1 -1
  5. package/build/models/department.js +28 -1
  6. package/build/models/department.js.map +1 -1
  7. package/build/models/global-settings.js +11 -0
  8. package/build/models/global-settings.js.map +1 -1
  9. package/build/models/location.js +14 -0
  10. package/build/models/location.js.map +1 -1
  11. package/build/models/tracker-responder.js +96 -0
  12. package/build/models/tracker-responder.js.map +1 -0
  13. package/build/models/watchduty-event.js +124 -0
  14. package/build/models/watchduty-event.js.map +1 -0
  15. package/build/test/0index.js +1 -0
  16. package/build/test/0index.js.map +1 -1
  17. package/build/test/department.js +2 -0
  18. package/build/test/department.js.map +1 -1
  19. package/build/test/global-settings.js +4 -1
  20. package/build/test/global-settings.js.map +1 -1
  21. package/build/test/location.js +20 -0
  22. package/build/test/location.js.map +1 -1
  23. package/build/test/mock.js +28 -0
  24. package/build/test/mock.js.map +1 -1
  25. package/build/test/tracker-responder.js +136 -0
  26. package/build/test/tracker-responder.js.map +1 -0
  27. package/build/test/watchduty-event.js +153 -0
  28. package/build/test/watchduty-event.js.map +1 -0
  29. package/build/types/tracker-responder.js +3 -0
  30. package/build/types/tracker-responder.js.map +1 -0
  31. package/build/types/watchduty-event.js +3 -0
  32. package/build/types/watchduty-event.js.map +1 -0
  33. package/cspell.json +1 -0
  34. package/definitions/constants.d.ts +1 -0
  35. package/definitions/constants.d.ts.map +1 -1
  36. package/definitions/index.d.ts +12 -0
  37. package/definitions/index.d.ts.map +1 -1
  38. package/definitions/models/department.d.ts +3 -0
  39. package/definitions/models/department.d.ts.map +1 -1
  40. package/definitions/models/global-settings.d.ts.map +1 -1
  41. package/definitions/models/location.d.ts.map +1 -1
  42. package/definitions/models/tracker-responder.d.ts +13 -0
  43. package/definitions/models/tracker-responder.d.ts.map +1 -0
  44. package/definitions/models/watchduty-event.d.ts +13 -0
  45. package/definitions/models/watchduty-event.d.ts.map +1 -0
  46. package/definitions/test/mock.d.ts +25 -0
  47. package/definitions/test/mock.d.ts.map +1 -1
  48. package/definitions/test/tracker-responder.d.ts +2 -0
  49. package/definitions/test/tracker-responder.d.ts.map +1 -0
  50. package/definitions/test/watchduty-event.d.ts +2 -0
  51. package/definitions/test/watchduty-event.d.ts.map +1 -0
  52. package/definitions/types/department.d.ts +9 -0
  53. package/definitions/types/department.d.ts.map +1 -1
  54. package/definitions/types/global-settings.d.ts +4 -0
  55. package/definitions/types/global-settings.d.ts.map +1 -1
  56. package/definitions/types/location.d.ts +1 -0
  57. package/definitions/types/location.d.ts.map +1 -1
  58. package/definitions/types/tracker-responder.d.ts +20 -0
  59. package/definitions/types/tracker-responder.d.ts.map +1 -0
  60. package/definitions/types/watchduty-event.d.ts +29 -0
  61. package/definitions/types/watchduty-event.d.ts.map +1 -0
  62. package/package.json +1 -1
  63. package/src/constants.ts +1 -0
  64. package/src/index.ts +4 -0
  65. package/src/models/department.ts +31 -0
  66. package/src/models/global-settings.ts +13 -0
  67. package/src/models/location.ts +15 -0
  68. package/src/models/tracker-responder.ts +107 -0
  69. package/src/models/watchduty-event.ts +135 -0
  70. package/src/test/0index.ts +1 -0
  71. package/src/test/department.ts +2 -0
  72. package/src/test/global-settings.ts +4 -0
  73. package/src/test/location.ts +22 -0
  74. package/src/test/mock.ts +29 -0
  75. package/src/test/tracker-responder.ts +144 -0
  76. package/src/test/watchduty-event.ts +163 -0
  77. package/src/types/department.ts +11 -0
  78. package/src/types/global-settings.ts +8 -0
  79. package/src/types/location.ts +1 -0
  80. package/src/types/tracker-responder.ts +28 -0
  81. package/src/types/watchduty-event.ts +36 -0
package/src/test/mock.ts CHANGED
@@ -996,6 +996,34 @@ export default function mockModule(dependencies: { mongoose: Mongoose; }) {
996
996
  expireAt: new Date("2025-12-12T01:02:03.000Z"),
997
997
  referer: "tabletcommand.com",
998
998
  },
999
+ watchDutySync: {
1000
+ lastModifiedAt: new Date("2025-11-11T04:05:06.000Z"),
1001
+ },
1002
+ };
1003
+
1004
+ const watchDutyEvent = {
1005
+ watchDutyId: 123456,
1006
+ name: "Line Fire",
1007
+ description: "Vegetation fire off Highway 38",
1008
+ geoEventType: "wildfire",
1009
+ locationGeoJSON: {
1010
+ type: "Point",
1011
+ coordinates: [-117.1234, 34.1234],
1012
+ },
1013
+ address: "Highway 38, Angelus Oaks, CA",
1014
+ isPrescribed: false,
1015
+ areaSquareMeters: 5060594,
1016
+ containment: 42,
1017
+ evacuationOrders: "Zone A, Zone B",
1018
+ evacuationWarnings: "Zone C",
1019
+ evacuationAdvisories: "Zone D",
1020
+ evacuationShelterInPlace: "Zone E",
1021
+ regions: [
1022
+ { name: "San Bernardino County", state: "CA" },
1023
+ ],
1024
+ active: true,
1025
+ watchDutyCreatedAt: new Date("2025-08-01T10:00:00.000Z"),
1026
+ watchDutyModifiedAt: new Date("2025-08-02T11:00:00.000Z"),
999
1027
  };
1000
1028
 
1001
1029
  const audioStreamAuthentication = {
@@ -1682,5 +1710,6 @@ export default function mockModule(dependencies: { mongoose: Mongoose; }) {
1682
1710
  userDevice,
1683
1711
  userRegistration,
1684
1712
  validationReport,
1713
+ watchDutyEvent,
1685
1714
  };
1686
1715
  }
@@ -0,0 +1,144 @@
1
+ import { assert } from "chai";
2
+ import { describe, it, beforeEach, afterEach } from "node:test";
3
+ import * as m from "../index";
4
+ import * as config from "./config";
5
+ import mockModule from "./mock";
6
+
7
+ describe("TrackerResponder", function() {
8
+ let models: m.BackendModels, mongoose: m.MongooseModule;
9
+ beforeEach(async function() {
10
+ const c = await m.connect(config.url);
11
+ models = c.models;
12
+ mongoose = c.mongoose;
13
+
14
+ const mock = mockModule({
15
+ mongoose
16
+ });
17
+ await mock.beforeEach();
18
+ await models.TrackerResponder.deleteMany({});
19
+ });
20
+ afterEach(async function() {
21
+ await mongoose.disconnect();
22
+ });
23
+
24
+ it("is saved with defaults", async function() {
25
+ const item = new models.TrackerResponder({
26
+ departmentId: "d1",
27
+ mac: "aa:bb:cc:dd:ee:01",
28
+ });
29
+ const sut = await item.save();
30
+
31
+ assert.isNotNull(sut._id);
32
+ assert.equal(sut.departmentId, "d1");
33
+ assert.equal(sut.mac, "AA:BB:CC:DD:EE:01");
34
+ assert.isTrue(sut.uuid !== "");
35
+ assert.equal(sut.trackerPersonnelId, "");
36
+ assert.equal(sut.personnelName, "");
37
+ assert.equal(sut.friendlyName, "");
38
+ assert.equal(sut.displayName, "");
39
+ assert.equal(sut.seatPosition, "");
40
+ assert.equal(sut.personnelUUID, "");
41
+ assert.equal(sut.assignedBy, "");
42
+ assert.isNull(sut.assignedAt);
43
+ assert.isTrue(sut.active);
44
+ assert.equal(sut.source, "");
45
+ assert.instanceOf(sut.createdAt, Date);
46
+ assert.instanceOf(sut.modified, Date);
47
+ });
48
+
49
+ it("persists vendor and user-owned fields", async function() {
50
+ const item = new models.TrackerResponder({
51
+ departmentId: "d1",
52
+ mac: "AA:BB:CC:DD:EE:02",
53
+ trackerPersonnelId: "12345",
54
+ personnelName: "Fire Hose 1",
55
+ friendlyName: "FH-1",
56
+ displayName: "Engine 1 Nozzle",
57
+ seatPosition: "Fire Hose 1",
58
+ personnelUUID: "pk-uuid-1",
59
+ assignedBy: "user-1",
60
+ assignedAt: new Date("2026-07-23T00:00:00.000Z"),
61
+ source: "TXRX",
62
+ });
63
+ const sut = await item.save();
64
+
65
+ assert.equal(sut.trackerPersonnelId, "12345");
66
+ assert.equal(sut.personnelName, "Fire Hose 1");
67
+ assert.equal(sut.friendlyName, "FH-1");
68
+ assert.equal(sut.displayName, "Engine 1 Nozzle");
69
+ assert.equal(sut.seatPosition, "Fire Hose 1");
70
+ assert.equal(sut.personnelUUID, "pk-uuid-1");
71
+ assert.equal(sut.assignedBy, "user-1");
72
+ assert.equal(sut.assignedAt?.toISOString(), "2026-07-23T00:00:00.000Z");
73
+ assert.equal(sut.source, "TXRX");
74
+ });
75
+
76
+ it("enforces unique (departmentId, mac)", async function() {
77
+ await models.TrackerResponder.createIndexes();
78
+
79
+ await new models.TrackerResponder({
80
+ departmentId: "d1",
81
+ mac: "AA:BB:CC:DD:EE:03",
82
+ }).save();
83
+
84
+ // Same MAC in another department is allowed
85
+ const other = await new models.TrackerResponder({
86
+ departmentId: "d2",
87
+ mac: "AA:BB:CC:DD:EE:03",
88
+ }).save();
89
+ assert.isNotNull(other._id);
90
+
91
+ try {
92
+ await new models.TrackerResponder({
93
+ departmentId: "d1",
94
+ mac: "aa:bb:cc:dd:ee:03",
95
+ }).save();
96
+ assert.isFalse(true, "Expecting duplicate (departmentId, mac) to fail.");
97
+ } catch (error) {
98
+ assert.match((error as Error).message, /E11000/);
99
+ }
100
+ });
101
+
102
+ it("enforces unique (departmentId, uuid)", async function() {
103
+ await models.TrackerResponder.createIndexes();
104
+
105
+ const first = await new models.TrackerResponder({
106
+ departmentId: "d1",
107
+ mac: "AA:BB:CC:DD:EE:04",
108
+ }).save();
109
+
110
+ // Same uuid in another department is allowed
111
+ const other = await new models.TrackerResponder({
112
+ departmentId: "d2",
113
+ mac: "AA:BB:CC:DD:EE:04",
114
+ uuid: first.uuid,
115
+ }).save();
116
+ assert.isNotNull(other._id);
117
+
118
+ try {
119
+ await new models.TrackerResponder({
120
+ departmentId: "d1",
121
+ mac: "AA:BB:CC:DD:EE:05",
122
+ uuid: first.uuid,
123
+ }).save();
124
+ assert.isFalse(true, "Expecting duplicate (departmentId, uuid) to fail.");
125
+ } catch (error) {
126
+ assert.match((error as Error).message, /E11000/);
127
+ }
128
+ });
129
+
130
+ it("rejects an empty or null uuid", async function() {
131
+ for (const uuid of ["", null]) {
132
+ try {
133
+ await new models.TrackerResponder({
134
+ departmentId: "d1",
135
+ mac: "AA:BB:CC:DD:EE:06",
136
+ uuid,
137
+ }).save();
138
+ assert.isFalse(true, `Expecting uuid ${JSON.stringify(uuid)} to fail validation.`);
139
+ } catch (error) {
140
+ assert.match((error as Error).message, /uuid.*required/i);
141
+ }
142
+ }
143
+ });
144
+ });
@@ -0,0 +1,163 @@
1
+ import { assert } from "chai";
2
+ import { describe, it, beforeEach, afterEach } from "node:test";
3
+ import * as m from "../index";
4
+ import * as config from "./config";
5
+ import mockModule from "./mock";
6
+
7
+ describe("WatchDutyEvent", function() {
8
+ let models: m.BackendModels, mongoose: m.MongooseModule;
9
+ let testItem: ReturnType<typeof mockModule>["watchDutyEvent"];
10
+
11
+ beforeEach(async function() {
12
+ const c = await m.connect(config.url);
13
+ models = c.models;
14
+ mongoose = c.mongoose;
15
+ testItem = mockModule({ mongoose }).watchDutyEvent;
16
+ await models.WatchDutyEvent.syncIndexes();
17
+ });
18
+ afterEach(async function() {
19
+ await models.WatchDutyEvent.deleteMany({});
20
+ await mongoose.disconnect();
21
+ });
22
+
23
+ it("is saved and reads back every field", async function() {
24
+ const sut = await new models.WatchDutyEvent(testItem).save();
25
+
26
+ assert.isNotNull(sut._id);
27
+ assert.equal(sut.watchDutyId, testItem.watchDutyId);
28
+ assert.equal(sut.name, testItem.name);
29
+ assert.equal(sut.description, testItem.description);
30
+ assert.equal(sut.geoEventType, testItem.geoEventType);
31
+ assert.equal(sut.locationGeoJSON?.type, "Point");
32
+ assert.equal(sut.locationGeoJSON?.coordinates[0], testItem.locationGeoJSON.coordinates[0]);
33
+ assert.equal(sut.locationGeoJSON?.coordinates[1], testItem.locationGeoJSON.coordinates[1]);
34
+ assert.equal(sut.address, testItem.address);
35
+ assert.isFalse(sut.isPrescribed);
36
+ assert.equal(sut.areaSquareMeters, testItem.areaSquareMeters);
37
+ assert.equal(sut.containment, testItem.containment);
38
+ assert.equal(sut.evacuationOrders, testItem.evacuationOrders);
39
+ assert.equal(sut.evacuationWarnings, testItem.evacuationWarnings);
40
+ assert.equal(sut.evacuationAdvisories, testItem.evacuationAdvisories);
41
+ assert.equal(sut.evacuationShelterInPlace, testItem.evacuationShelterInPlace);
42
+ assert.equal(sut.regions.length, 1);
43
+ assert.equal(sut.regions[0]?.name, testItem.regions[0]?.name);
44
+ assert.equal(sut.regions[0]?.state, testItem.regions[0]?.state);
45
+ assert.isTrue(sut.active);
46
+ assert.equal(sut.watchDutyCreatedAt.toISOString(), testItem.watchDutyCreatedAt.toISOString());
47
+ assert.equal(sut.watchDutyModifiedAt.toISOString(), testItem.watchDutyModifiedAt.toISOString());
48
+ assert.instanceOf(sut.created, Date);
49
+ assert.instanceOf(sut.modified, Date);
50
+ });
51
+
52
+ it("defaults optional fields", async function() {
53
+ const sut = await new models.WatchDutyEvent({
54
+ watchDutyId: 654321,
55
+ watchDutyCreatedAt: new Date(),
56
+ watchDutyModifiedAt: new Date(),
57
+ }).save();
58
+
59
+ assert.equal(sut.name, "");
60
+ assert.equal(sut.description, "");
61
+ assert.equal(sut.geoEventType, "");
62
+ assert.isNull(sut.locationGeoJSON);
63
+ assert.equal(sut.address, "");
64
+ assert.isFalse(sut.isPrescribed);
65
+ assert.isNull(sut.areaSquareMeters);
66
+ assert.isNull(sut.containment);
67
+ assert.equal(sut.evacuationOrders, "");
68
+ assert.equal(sut.evacuationWarnings, "");
69
+ assert.equal(sut.evacuationAdvisories, "");
70
+ assert.equal(sut.evacuationShelterInPlace, "");
71
+ assert.deepEqual(sut.regions, []);
72
+ assert.isTrue(sut.active);
73
+ assert.instanceOf(sut.created, Date);
74
+ assert.instanceOf(sut.modified, Date);
75
+ });
76
+
77
+ // timestamps defaults createdAt to true, which would store an undeclared createdAt field.
78
+ it("stores no timestamp field outside the schema", async function() {
79
+ await new models.WatchDutyEvent({
80
+ watchDutyId: 333,
81
+ watchDutyCreatedAt: new Date(),
82
+ watchDutyModifiedAt: new Date(),
83
+ }).save();
84
+
85
+ const raw = await models.WatchDutyEvent.collection.findOne({ watchDutyId: 333 });
86
+ assert.isNotNull(raw);
87
+ assert.notProperty(raw, "createdAt");
88
+ assert.notProperty(raw, "updatedAt");
89
+ assert.property(raw, "created");
90
+ assert.property(raw, "modified");
91
+ });
92
+
93
+ // The retention sweep deletes inactive events by `modified`, and the cron upserts through
94
+ // findOneAndUpdate, which skips the schema default. Only the timestamps option keeps the
95
+ // field current.
96
+ it("advances modified on findOneAndUpdate", async function() {
97
+ const staleDate = new Date("2020-01-02T03:04:05.000Z");
98
+ await new models.WatchDutyEvent({
99
+ watchDutyId: 222,
100
+ watchDutyCreatedAt: new Date(),
101
+ watchDutyModifiedAt: new Date(),
102
+ }).save();
103
+ await models.WatchDutyEvent.collection.updateOne({
104
+ watchDutyId: 222,
105
+ }, {
106
+ $set: {
107
+ modified: staleDate,
108
+ },
109
+ });
110
+
111
+ const updated = await models.WatchDutyEvent.findOneAndUpdate({
112
+ watchDutyId: 222,
113
+ }, {
114
+ $set: {
115
+ active: false,
116
+ },
117
+ }, {
118
+ new: true,
119
+ });
120
+
121
+ assert.isNotNull(updated);
122
+ assert.isFalse(updated?.active);
123
+ assert.isAbove(Number(updated?.modified), Number(staleDate));
124
+ });
125
+
126
+ it("enforces a unique watchDutyId index", async function() {
127
+ await new models.WatchDutyEvent({
128
+ watchDutyId: 111,
129
+ watchDutyCreatedAt: new Date(),
130
+ watchDutyModifiedAt: new Date(),
131
+ }).save();
132
+
133
+ let thrownError: unknown = null;
134
+ try {
135
+ await new models.WatchDutyEvent({
136
+ watchDutyId: 111,
137
+ watchDutyCreatedAt: new Date(),
138
+ watchDutyModifiedAt: new Date(),
139
+ }).save();
140
+ } catch (e) {
141
+ thrownError = e;
142
+ }
143
+
144
+ assert.isNotNull(thrownError, "expected duplicate watchDutyId to be rejected");
145
+ assert.equal((thrownError as { code?: number }).code, 11000);
146
+ assert.include(String((thrownError as Error).message), "E11000");
147
+ });
148
+
149
+ it("requires watchDutyId", async function() {
150
+ let thrownError: unknown = null;
151
+ try {
152
+ await new models.WatchDutyEvent({
153
+ watchDutyCreatedAt: new Date(),
154
+ watchDutyModifiedAt: new Date(),
155
+ }).save();
156
+ } catch (e) {
157
+ thrownError = e;
158
+ }
159
+
160
+ assert.isNotNull(thrownError, "expected missing watchDutyId to be rejected");
161
+ assert.include(String((thrownError as Error).message), "watchDutyId");
162
+ });
163
+ });
@@ -205,6 +205,10 @@ export interface SomewearType {
205
205
  enabled: boolean
206
206
  }
207
207
 
208
+ export interface PersonnelWebhookType {
209
+ enabled: boolean
210
+ }
211
+
208
212
  export interface SkytracConfigType {
209
213
  enabled: boolean
210
214
  licenseKey: string,
@@ -546,6 +550,11 @@ export type AccountConfigurationZonehaven = {
546
550
  fadeZoomLevel: number,
547
551
  };
548
552
 
553
+ export type AccountConfigurationWatchDuty = {
554
+ enabled: boolean,
555
+ visible: boolean,
556
+ };
557
+
549
558
  export type ManagedIncidentPriorityItem = {
550
559
  title: string,
551
560
  color: ColorSchemaType,
@@ -667,6 +676,7 @@ export interface DepartmentType {
667
676
  orientationMarkerColor: ColorSchemaType,
668
677
  partialApiKey: string,
669
678
  personnelStaffingEnabled: boolean,
679
+ personnelWebhook: PersonnelWebhookType,
670
680
  pubNubV3: PubNubTokenSchemaType,
671
681
  remoteLoggingEnabled: boolean,
672
682
  reportNumberEnabled: boolean,
@@ -705,4 +715,5 @@ export interface DepartmentType {
705
715
  webhook: WebhookConfigType,
706
716
  wsFeed: WSFeedConfigType,
707
717
  zonehaven: AccountConfigurationZonehaven,
718
+ watchDuty: AccountConfigurationWatchDuty,
708
719
  }
@@ -12,10 +12,18 @@ export type ZonehavenTokenType = {
12
12
  referer: string | null,
13
13
  };
14
14
 
15
+ // Incremental pull cursor for the Watch Duty events cron. One Watch Duty account feeds every
16
+ // department, so the cursor is global. The cron holds it back when a fetch is incomplete, which
17
+ // is why it is stored and not derived from the events themselves.
18
+ export type WatchDutySyncType = {
19
+ lastModifiedAt: Date | null,
20
+ };
21
+
15
22
  export interface GlobalSettingsType {
16
23
  _id: Types.ObjectId,
17
24
  name: "global",
18
25
  arcgisApiKey: ArcGISKeyType,
19
26
  zonehavenToken: ZonehavenTokenType,
27
+ watchDutySync: WatchDutySyncType,
20
28
  updatedAt: Date,
21
29
  }
@@ -40,6 +40,7 @@ export interface LocationType {
40
40
  username: string,
41
41
  uuid: string,
42
42
  personnelUUID: string,
43
+ transponderUUID: string,
43
44
  version: number,
44
45
  visibility: string[],
45
46
  }
@@ -0,0 +1,28 @@
1
+ import { Types } from "mongoose";
2
+
3
+ export interface TrackerResponderType {
4
+ _id: Types.ObjectId,
5
+ departmentId: string,
6
+ // Transponder MAC address, normalized uppercase. Unique per department.
7
+ mac: string,
8
+ // Stable identity carried by Location.transponderUUID.
9
+ uuid: string,
10
+ // Vendor personnelId verbatim (not a PersonnelKnown reference).
11
+ trackerPersonnelId: string,
12
+ // Vendor-owned names, taken verbatim from the TXRX webhook body.
13
+ personnelName: string,
14
+ friendlyName: string,
15
+ // User-owned display name, editable on our end (eventually via iOS);
16
+ // the webhook never touches this.
17
+ displayName: string,
18
+ seatPosition: string,
19
+ // "" = unassigned, otherwise PersonnelKnown.uuid.
20
+ personnelUUID: string,
21
+ assignedBy: string,
22
+ // null while unassigned
23
+ assignedAt: Date | null,
24
+ active: boolean,
25
+ source: string,
26
+ createdAt: Date,
27
+ modified: Date,
28
+ }
@@ -0,0 +1,36 @@
1
+ import { Types } from "mongoose";
2
+ import { GeoPoint } from "./geojson";
3
+
4
+ export interface WatchDutyRegionType {
5
+ name: string,
6
+ state: string,
7
+ }
8
+
9
+ export interface WatchDutyEventType {
10
+ _id: Types.ObjectId,
11
+ watchDutyId: number,
12
+ name: string,
13
+ description: string,
14
+ geoEventType: string,
15
+ // Null when Watch Duty reports no usable lat/lng. The event is still stored, it just cannot
16
+ // be drawn.
17
+ locationGeoJSON: GeoPoint | null,
18
+ address: string,
19
+ isPrescribed: boolean,
20
+ // Watch Duty reports the burned area in acres. It is converted on the way in.
21
+ areaSquareMeters: number | null,
22
+ // Percent of the fire perimeter contained, 0 to 100.
23
+ containment: number | null,
24
+ evacuationOrders: string,
25
+ evacuationWarnings: string,
26
+ evacuationAdvisories: string,
27
+ evacuationShelterInPlace: string,
28
+ regions: WatchDutyRegionType[],
29
+ active: boolean,
30
+ // Watch Duty's own timestamps. `created` and `modified` are ours, and mean the first and the
31
+ // last time we wrote the record.
32
+ watchDutyCreatedAt: Date,
33
+ watchDutyModifiedAt: Date,
34
+ created: Date,
35
+ modified: Date,
36
+ }