tabletcommand-backend-models 7.4.112 → 7.4.114

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 (51) hide show
  1. package/build/index.js +5 -1
  2. package/build/index.js.map +1 -1
  3. package/build/models/data-sharing-agreement.js +161 -0
  4. package/build/models/data-sharing-agreement.js.map +1 -0
  5. package/build/models/department.js +4 -0
  6. package/build/models/department.js.map +1 -1
  7. package/build/models/zonehaven-account.js +67 -0
  8. package/build/models/zonehaven-account.js.map +1 -0
  9. package/build/test/0index.js +2 -0
  10. package/build/test/0index.js.map +1 -1
  11. package/build/test/data-sharing-agreement.js +220 -0
  12. package/build/test/data-sharing-agreement.js.map +1 -0
  13. package/build/test/mock.js +38 -0
  14. package/build/test/mock.js.map +1 -1
  15. package/build/test/zonehaven-account.js +64 -0
  16. package/build/test/zonehaven-account.js.map +1 -0
  17. package/build/types/data-sharing-agreement.js +8 -0
  18. package/build/types/data-sharing-agreement.js.map +1 -0
  19. package/build/types/zonehaven-account.js +3 -0
  20. package/build/types/zonehaven-account.js.map +1 -0
  21. package/definitions/index.d.ts +16 -1
  22. package/definitions/index.d.ts.map +1 -1
  23. package/definitions/models/data-sharing-agreement.d.ts +13 -0
  24. package/definitions/models/data-sharing-agreement.d.ts.map +1 -0
  25. package/definitions/models/department.d.ts.map +1 -1
  26. package/definitions/models/zonehaven-account.d.ts +13 -0
  27. package/definitions/models/zonehaven-account.d.ts.map +1 -0
  28. package/definitions/test/data-sharing-agreement.d.ts +2 -0
  29. package/definitions/test/data-sharing-agreement.d.ts.map +1 -0
  30. package/definitions/test/mock.d.ts +24 -0
  31. package/definitions/test/mock.d.ts.map +1 -1
  32. package/definitions/test/zonehaven-account.d.ts +2 -0
  33. package/definitions/test/zonehaven-account.d.ts.map +1 -0
  34. package/definitions/types/data-sharing-agreement.d.ts +30 -0
  35. package/definitions/types/data-sharing-agreement.d.ts.map +1 -0
  36. package/definitions/types/department.d.ts +1 -0
  37. package/definitions/types/department.d.ts.map +1 -1
  38. package/definitions/types/zonehaven-account.d.ts +15 -0
  39. package/definitions/types/zonehaven-account.d.ts.map +1 -0
  40. package/package.json +1 -1
  41. package/src/index.ts +14 -0
  42. package/src/models/data-sharing-agreement.ts +182 -0
  43. package/src/models/department.ts +4 -0
  44. package/src/models/zonehaven-account.ts +78 -0
  45. package/src/test/0index.ts +2 -0
  46. package/src/test/data-sharing-agreement.ts +238 -0
  47. package/src/test/mock.ts +41 -0
  48. package/src/test/zonehaven-account.ts +70 -0
  49. package/src/types/data-sharing-agreement.ts +49 -0
  50. package/src/types/department.ts +2 -0
  51. package/src/types/zonehaven-account.ts +15 -0
@@ -0,0 +1,238 @@
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("DataSharingAgreement", function() {
8
+ let models: m.BackendModels, mongoose: m.MongooseModule;
9
+ let testItem: ReturnType<typeof mockModule>["dataSharingAgreement"];
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 }).dataSharingAgreement;
16
+ await models.DataSharingAgreement.deleteMany({});
17
+ });
18
+ afterEach(async function() {
19
+ await models.DataSharingAgreement.deleteMany({});
20
+ await mongoose.disconnect();
21
+ });
22
+
23
+ it("is saved and reads back every field", async function() {
24
+ const sut = await new models.DataSharingAgreement(testItem).save();
25
+
26
+ assert.isNotNull(sut._id);
27
+ assert.equal(sut.id, sut._id.toHexString());
28
+ assert.isString(sut.uuid);
29
+ assert.isTrue(sut.uuid !== "");
30
+
31
+ assert.equal(sut.departmentId, testItem.departmentId);
32
+ assert.equal(sut.departmentName, testItem.departmentName);
33
+ assert.equal(sut.agencyId?.toHexString(), testItem.agencyId.toHexString());
34
+ assert.equal(sut.agencyName, testItem.agencyName);
35
+ assert.equal(sut.userId, testItem.userId);
36
+ assert.equal(sut.email, testItem.email);
37
+ assert.equal(sut.signerName, testItem.signerName);
38
+
39
+ assert.equal(sut.agreementTitle, testItem.agreementTitle);
40
+ assert.equal(sut.agreementVersion, testItem.agreementVersion);
41
+ assert.equal(sut.agreementText, testItem.agreementText);
42
+ assert.equal(sut.action, "signed");
43
+ assert.equal(sut.consents.length, 2);
44
+ assert.equal(sut.consents[0]?.key, "shareAVL");
45
+ assert.equal(sut.consents[0]?.label, testItem.consents[0]?.label);
46
+ assert.isTrue(sut.consents[0]?.checked);
47
+ assert.equal(sut.consents[1]?.key, "intterra");
48
+ assert.isFalse(sut.consents[1]?.checked);
49
+
50
+ assert.equal(sut.remoteAddress, testItem.remoteAddress);
51
+ assert.equal(sut.userAgent, testItem.userAgent);
52
+ assert.equal(sut.appVersion, testItem.appVersion);
53
+ assert.equal(sut.createdAt.toISOString(), testItem.createdAt.toISOString());
54
+ assert.instanceOf(sut.modified, Date);
55
+ });
56
+
57
+ it("defaults optional fields", async function() {
58
+ const sut = await new models.DataSharingAgreement({
59
+ departmentId: "d1",
60
+ userId: "u1",
61
+ email: "u1@example.com",
62
+ agreementTitle: "Data Sharing Addendum",
63
+ agreementVersion: "2026-08-1",
64
+ agreementText: "text",
65
+ action: "revoked",
66
+ }).save();
67
+
68
+ assert.isString(sut.uuid);
69
+ assert.isTrue(sut.uuid !== "");
70
+ assert.equal(sut.departmentName, "");
71
+ assert.isNull(sut.agencyId);
72
+ assert.equal(sut.agencyName, "");
73
+ assert.equal(sut.signerName, "");
74
+ assert.deepEqual(sut.consents, []);
75
+ assert.equal(sut.remoteAddress, "");
76
+ assert.equal(sut.userAgent, "");
77
+ assert.equal(sut.appVersion, "");
78
+ assert.instanceOf(sut.createdAt, Date);
79
+ assert.instanceOf(sut.modified, Date);
80
+ });
81
+
82
+ it("defaults consent label and checked", async function() {
83
+ const sut = await new models.DataSharingAgreement({
84
+ ...testItem,
85
+ consents: [{ key: "firstArriving" }],
86
+ }).save();
87
+
88
+ assert.equal(sut.consents.length, 1);
89
+ assert.equal(sut.consents[0]?.key, "firstArriving");
90
+ assert.equal(sut.consents[0]?.label, "");
91
+ assert.isFalse(sut.consents[0]?.checked);
92
+ });
93
+
94
+ it("requires the legal-record fields", async function() {
95
+ const required = [
96
+ "departmentId",
97
+ "userId",
98
+ "email",
99
+ "agreementTitle",
100
+ "agreementVersion",
101
+ "agreementText",
102
+ "action",
103
+ ];
104
+ for (const field of required) {
105
+ const item: Record<string, unknown> = { ...testItem };
106
+ delete item[field];
107
+ try {
108
+ await new models.DataSharingAgreement(item).save();
109
+ assert.isFalse(true, `Expecting missing ${field} to fail validation.`);
110
+ } catch (error) {
111
+ assert.match((error as Error).message, new RegExp(`${field}.*required`, "i"));
112
+ }
113
+ }
114
+ });
115
+
116
+ it("rejects a consent without a key", async function() {
117
+ try {
118
+ await new models.DataSharingAgreement({
119
+ ...testItem,
120
+ consents: [{ label: "No key", checked: true }],
121
+ }).save();
122
+ assert.isFalse(true, "Expecting consent without key to fail validation.");
123
+ } catch (error) {
124
+ assert.match((error as Error).message, /key.*required/i);
125
+ }
126
+ });
127
+
128
+ it("only accepts known actions", async function() {
129
+ for (const action of m.DataSharingAgreementActions) {
130
+ const sut = await new models.DataSharingAgreement({ ...testItem, action }).save();
131
+ assert.equal(sut.action, action);
132
+ }
133
+
134
+ try {
135
+ await new models.DataSharingAgreement({ ...testItem, action: "deleted" }).save();
136
+ assert.isFalse(true, "Expecting unknown action to fail validation.");
137
+ } catch (error) {
138
+ assert.match((error as Error).message, /action/);
139
+ assert.match((error as Error).message, /enum/i);
140
+ }
141
+ });
142
+
143
+ it("does not change record fields on a re-save", async function() {
144
+ const saved = await new models.DataSharingAgreement(testItem).save();
145
+
146
+ const loaded = await models.DataSharingAgreement.findById(saved._id);
147
+ assert.isNotNull(loaded);
148
+ if (!loaded) {
149
+ return;
150
+ }
151
+ loaded.set({
152
+ departmentId: "other-department",
153
+ action: "revoked",
154
+ agreementText: "tampered",
155
+ agreementVersion: "9999-01-1",
156
+ consents: [],
157
+ remoteAddress: "10.0.0.1",
158
+ createdAt: new Date("2000-01-01T00:00:00.000Z"),
159
+ });
160
+ await loaded.save();
161
+
162
+ const reloaded = await models.DataSharingAgreement.findById(saved._id);
163
+ assert.isNotNull(reloaded);
164
+ assert.equal(reloaded?.departmentId, testItem.departmentId);
165
+ assert.equal(reloaded?.action, "signed");
166
+ assert.equal(reloaded?.agreementText, testItem.agreementText);
167
+ assert.equal(reloaded?.agreementVersion, testItem.agreementVersion);
168
+ assert.equal(reloaded?.consents.length, 2);
169
+ assert.equal(reloaded?.remoteAddress, testItem.remoteAddress);
170
+ assert.equal(reloaded?.createdAt.toISOString(), testItem.createdAt.toISOString());
171
+ });
172
+
173
+ it("keeps every record as history, newest first", async function() {
174
+ const signed = await new models.DataSharingAgreement({
175
+ ...testItem,
176
+ action: "signed",
177
+ createdAt: new Date("2026-08-20T17:30:00.000Z"),
178
+ }).save();
179
+ const updated = await new models.DataSharingAgreement({
180
+ ...testItem,
181
+ action: "updated",
182
+ consents: [
183
+ { key: "shareAVL", label: "AVL Sharing (CAL FIRE Op Area)", checked: true },
184
+ { key: "intterra", label: "Intterra", checked: true },
185
+ ],
186
+ createdAt: new Date("2026-08-21T09:00:00.000Z"),
187
+ }).save();
188
+ const revoked = await new models.DataSharingAgreement({
189
+ ...testItem,
190
+ action: "revoked",
191
+ consents: [
192
+ { key: "shareAVL", label: "AVL Sharing (CAL FIRE Op Area)", checked: false },
193
+ { key: "intterra", label: "Intterra", checked: false },
194
+ ],
195
+ createdAt: new Date("2026-08-22T09:00:00.000Z"),
196
+ }).save();
197
+ // Another department must not leak into the history
198
+ await new models.DataSharingAgreement({
199
+ ...testItem,
200
+ departmentId: "other-department",
201
+ }).save();
202
+
203
+ const history = await models.DataSharingAgreement.find({
204
+ departmentId: testItem.departmentId,
205
+ }).sort({ createdAt: -1 });
206
+
207
+ assert.equal(history.length, 3);
208
+ assert.equal(history[0]?.id, revoked.id);
209
+ assert.equal(history[1]?.id, updated.id);
210
+ assert.equal(history[2]?.id, signed.id);
211
+ assert.isFalse(history[0]?.consents.every((c) => c.checked));
212
+ assert.isTrue(history[1]?.consents.every((c) => c.checked));
213
+ });
214
+
215
+ it("declares only the history index and no TTL index", async function() {
216
+ await models.DataSharingAgreement.syncIndexes();
217
+ const indexes = await models.DataSharingAgreement.collection.indexes();
218
+
219
+ const names = indexes.map((i) => i.name).sort();
220
+ assert.deepEqual(names, ["_id_", "departmentId_1_createdAt_-1"]);
221
+
222
+ const history = indexes.find((i) => i.name === "departmentId_1_createdAt_-1");
223
+ assert.deepEqual(history?.key, { departmentId: 1, createdAt: -1 });
224
+
225
+ for (const index of indexes) {
226
+ assert.isUndefined(index.expireAfterSeconds, `Unexpected TTL on ${index.name}`);
227
+ }
228
+ });
229
+
230
+ it("serializes with id and without version key", async function() {
231
+ const sut = await new models.DataSharingAgreement(testItem).save();
232
+ const json = sut.toJSON() as Record<string, unknown>;
233
+
234
+ assert.equal(json.id, sut._id.toHexString());
235
+ assert.isUndefined(json.__v);
236
+ assert.equal(json.agreementText, testItem.agreementText);
237
+ });
238
+ });
package/src/test/mock.ts CHANGED
@@ -18,6 +18,7 @@ import { IncidentNotified } from "../models/incident-notified";
18
18
  import { Location } from "../models/location";
19
19
  import { ManagedIncident } from "../models/managed-incident";
20
20
  import { SAML } from "../models/saml";
21
+ import { ZonehavenAccountType } from "../types/zonehaven-account";
21
22
 
22
23
  export default function mockModule(dependencies: { mongoose: Mongoose; }) {
23
24
  function shouldRun() {
@@ -1031,6 +1032,44 @@ export default function mockModule(dependencies: { mongoose: Mongoose; }) {
1031
1032
  watchDutyModifiedAt: new Date("2025-08-02T11:00:00.000Z"),
1032
1033
  };
1033
1034
 
1035
+ const zonehavenAccount: ZonehavenAccountType = {
1036
+ _id: new mongoose.Types.ObjectId(),
1037
+ name: "default",
1038
+ url: "https://agservergenasys.zonehaven.com/arcgis/rest/services/tc_data/FeatureServer/0",
1039
+ username: "zonehaven-user",
1040
+ password: {
1041
+ iv: "18f17",
1042
+ encryptedData: "dee54",
1043
+ },
1044
+ token: "test-zonehaven-token",
1045
+ expireAt: new Date("2025-12-12T01:02:03.000Z"),
1046
+ referer: "tabletcommand.com",
1047
+ createdAt: new Date("2025-11-10T20:01:01.111+0000"),
1048
+ updatedAt: new Date("2025-11-20T20:02:02.222+0000"),
1049
+ };
1050
+
1051
+ const dataSharingAgreement = {
1052
+ departmentId: "5195426cc4e016a988000965",
1053
+ departmentName: "Test Department",
1054
+ agencyId: new dependencies.mongoose.Types.ObjectId("5195426cc4e016a988000966"),
1055
+ agencyName: "Test Agency",
1056
+ userId: "5195426cc4e016a988000967",
1057
+ email: "signer@example.com",
1058
+ signerName: "Signer Person",
1059
+ agreementTitle: "Data Sharing Addendum",
1060
+ agreementVersion: "2026-08-1",
1061
+ agreementText: "By checking a vendor below you authorize Tablet Command to share this account's data with that vendor.",
1062
+ action: "signed",
1063
+ consents: [
1064
+ { key: "shareAVL", label: "AVL Sharing (CAL FIRE Op Area)", checked: true },
1065
+ { key: "intterra", label: "Intterra", checked: false },
1066
+ ],
1067
+ remoteAddress: "203.0.113.10",
1068
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15",
1069
+ appVersion: "1.2.3",
1070
+ createdAt: new Date("2026-08-20T17:30:00.000Z"),
1071
+ };
1072
+
1034
1073
  const audioStreamAuthentication = {
1035
1074
  _id: new mongoose.Types.ObjectId(),
1036
1075
  name: "test-stream",
@@ -1685,6 +1724,7 @@ export default function mockModule(dependencies: { mongoose: Mongoose; }) {
1685
1724
  checklist,
1686
1725
  checklistItem,
1687
1726
  csvImport,
1727
+ dataSharingAgreement,
1688
1728
  department,
1689
1729
  deviceMapping,
1690
1730
  deviceMappingWithWhiteSpaces,
@@ -1716,5 +1756,6 @@ export default function mockModule(dependencies: { mongoose: Mongoose; }) {
1716
1756
  userRegistration,
1717
1757
  validationReport,
1718
1758
  watchDutyEvent,
1759
+ zonehavenAccount,
1719
1760
  };
1720
1761
  }
@@ -0,0 +1,70 @@
1
+ import { assert } from "chai";
2
+ import { describe, it, beforeEach, afterEach } from "node:test";
3
+ import { isDate } from "lodash";
4
+
5
+ import * as m from "../index";
6
+ import * as config from "./config";
7
+ import mockModule from "./mock";
8
+
9
+ describe("ZonehavenAccount", function() {
10
+ let models: m.BackendModels, mongoose: m.MongooseModule;
11
+ let testItem: Partial<m.ZonehavenAccount>;
12
+ beforeEach(async function() {
13
+ const c = await m.connect(config.url);
14
+ models = c.models;
15
+ mongoose = c.mongoose;
16
+ const mock = mockModule({
17
+ mongoose
18
+ });
19
+ testItem = mock.zonehavenAccount;
20
+ await mock.beforeEach();
21
+ });
22
+
23
+ afterEach(async function() {
24
+ await mongoose.disconnect();
25
+ });
26
+
27
+ it("is saved", async function() {
28
+ assert.isObject(testItem);
29
+ const item = new models.ZonehavenAccount(testItem);
30
+ const sut = await item.save();
31
+ assert.isNotNull(sut._id);
32
+ assert.isNotNull(sut.id);
33
+ assert.equal(sut._id.toString(), testItem._id?.toString());
34
+
35
+ assert.equal(sut.name, "default");
36
+ assert.equal(sut.url, testItem.url);
37
+ assert.equal(sut.username, testItem.username);
38
+
39
+ assert.isObject(sut.password);
40
+ assert.equal(sut.password?.iv, testItem.password?.iv);
41
+ assert.equal(sut.password?.encryptedData, testItem.password?.encryptedData);
42
+
43
+ assert.equal(sut.token, testItem.token);
44
+ assert.isTrue(isDate(sut.expireAt));
45
+ assert.equal(sut.expireAt?.toISOString(), testItem.expireAt?.toISOString());
46
+ assert.equal(sut.referer, testItem.referer);
47
+ });
48
+
49
+ it("enforces a unique name", async function() {
50
+ const item = new models.ZonehavenAccount(testItem);
51
+ await item.save();
52
+
53
+ const duplicate = new models.ZonehavenAccount({ ...testItem, _id: new mongoose.Types.ObjectId() });
54
+ try {
55
+ await duplicate.save();
56
+ assert.fail("Should have thrown duplicate key error");
57
+ } catch (error) {
58
+ assert.include(String((error as Error).message), "duplicate key");
59
+ }
60
+ });
61
+
62
+ it("does not allow changing name after creation", async function() {
63
+ const item = new models.ZonehavenAccount(testItem);
64
+ const sut = await item.save();
65
+
66
+ sut.name = "demo";
67
+ const saved = await sut.save();
68
+ assert.equal(saved.name, "default");
69
+ });
70
+ });
@@ -0,0 +1,49 @@
1
+ import { Types } from "mongoose";
2
+
3
+ // "signed" - first acceptance of the agreement text for this department
4
+ // "updated" - vendor checkbox state changed under an already-accepted agreement
5
+ // "revoked" - data sharing cancelled; the record captures the final (all-off) state
6
+ export const DataSharingAgreementActions = ["signed", "updated", "revoked"] as const;
7
+ export type DataSharingAgreementAction = typeof DataSharingAgreementActions[number];
8
+
9
+ // One vendor / config checkbox as it was shown to the signer.
10
+ // `checked` is the state AFTER the action this record describes.
11
+ export interface DataSharingVendorConsentType {
12
+ key: string,
13
+ label: string,
14
+ checked: boolean,
15
+ }
16
+
17
+ // Append-only legal record: every signature, change and revocation is a new document.
18
+ // Nothing here is ever updated or deleted through the API - the history is the collection.
19
+ // Names and agreement text are denormalized on purpose so the record cannot drift from
20
+ // what the signer actually saw.
21
+ export interface DataSharingAgreementType {
22
+ _id: Types.ObjectId,
23
+ uuid: string,
24
+
25
+ // Who / where. "Account" in the ticket == Department; departmentId is a string
26
+ // (hex of Department._id) per codebase convention.
27
+ departmentId: string,
28
+ departmentName: string,
29
+ agencyId: Types.ObjectId | null,
30
+ agencyName: string,
31
+ userId: string,
32
+ email: string,
33
+ signerName: string,
34
+
35
+ // What they signed
36
+ agreementTitle: string,
37
+ agreementVersion: string,
38
+ agreementText: string,
39
+ action: DataSharingAgreementAction,
40
+ consents: DataSharingVendorConsentType[],
41
+
42
+ // Evidence captured server-side from the request
43
+ remoteAddress: string,
44
+ userAgent: string,
45
+ appVersion: string,
46
+
47
+ createdAt: Date,
48
+ modified: Date,
49
+ }
@@ -567,6 +567,8 @@ export type AccountConfigurationZonehaven = {
567
567
  visible: boolean,
568
568
  layerUrl: string,
569
569
  fadeZoomLevel: number,
570
+ // References ZonehavenAccount.name - empty string means unassigned/legacy fallback.
571
+ accountName: string,
570
572
  };
571
573
 
572
574
  export type AccountConfigurationWatchDuty = {
@@ -0,0 +1,15 @@
1
+ import { Types } from "mongoose";
2
+ import { EncryptedDataType } from "./common";
3
+
4
+ export interface ZonehavenAccountType {
5
+ _id: Types.ObjectId,
6
+ name: string, // "default", "demo" - unique, immutable once set
7
+ url: string,
8
+ username: string,
9
+ password: EncryptedDataType | null,
10
+ token: string | null,
11
+ expireAt: Date | null,
12
+ referer: string | null,
13
+ createdAt: Date,
14
+ updatedAt: Date,
15
+ }