tabletcommand-location 2.3.8 → 2.4.1

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 (43) hide show
  1. package/build/audience.js +38 -0
  2. package/build/audience.js.map +1 -0
  3. package/build/index.js +38 -20
  4. package/build/index.js.map +1 -1
  5. package/build/location.js +63 -6
  6. package/build/location.js.map +1 -1
  7. package/build/query.js +373 -0
  8. package/build/query.js.map +1 -0
  9. package/build/store.js +54 -5
  10. package/build/store.js.map +1 -1
  11. package/build/test/location.js +313 -0
  12. package/build/test/location.js.map +1 -1
  13. package/build/test/mock.js +5 -0
  14. package/build/test/mock.js.map +1 -1
  15. package/build/test/query.js +174 -0
  16. package/build/test/query.js.map +1 -0
  17. package/cspell.json +5 -1
  18. package/definitions/audience.d.ts +18 -0
  19. package/definitions/audience.d.ts.map +1 -0
  20. package/definitions/index.d.ts +1 -0
  21. package/definitions/index.d.ts.map +1 -1
  22. package/definitions/location.d.ts.map +1 -1
  23. package/definitions/query.d.ts +36 -0
  24. package/definitions/query.d.ts.map +1 -0
  25. package/definitions/store.d.ts +5 -0
  26. package/definitions/store.d.ts.map +1 -1
  27. package/definitions/test/mock.d.ts +15 -0
  28. package/definitions/test/mock.d.ts.map +1 -1
  29. package/definitions/test/query.d.ts +2 -0
  30. package/definitions/test/query.d.ts.map +1 -0
  31. package/definitions/types.d.ts +5 -0
  32. package/definitions/types.d.ts.map +1 -1
  33. package/package.json +18 -14
  34. package/src/audience.ts +54 -0
  35. package/src/index.ts +5 -20
  36. package/src/location.ts +67 -6
  37. package/src/query.ts +492 -0
  38. package/src/store.ts +55 -5
  39. package/src/test/location.ts +306 -1
  40. package/src/test/mock.ts +6 -0
  41. package/src/test/query.ts +209 -0
  42. package/src/types.ts +15 -0
  43. package/test.sh +1 -1
@@ -5,7 +5,7 @@ import storeModule, { StoreModule } from "../store";
5
5
  import mockModule from "./mock";
6
6
  import locationModule, { LocationModule } from "../location";
7
7
  import { LocationPayload } from "../types";
8
- import { BackendModels } from "tabletcommand-backend-models";
8
+ import { AccountIndustry, BackendModels } from "tabletcommand-backend-models";
9
9
 
10
10
  let location: LocationModule;
11
11
  let store: StoreModule;
@@ -150,6 +150,259 @@ describe("location", () => {
150
150
  assert.equal(locationRecord[0]?.visibility.includes("shared"), true);
151
151
  /* cSpell:enable */
152
152
  });
153
+ it("applies person invariants on the geotab path, ignoring device mapping", async () => {
154
+ const department = mockDepartment;
155
+ // BEEDOO3 has a device mapping with mapId BOOPBEEP99 and locationToCAD:
156
+ // true; a person payload must not pick either of those up.
157
+ const items = [{
158
+ vehicleId: "BEEDOO3",
159
+ radioName: "Smokey Bear",
160
+ latitude: 34.35264,
161
+ longitude: -119.0669,
162
+ avlTime: "2026-07-10T15:43:36.816Z",
163
+ kindType: "person",
164
+ locationType: "person",
165
+ source: "TXRX",
166
+ transponderUUID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
167
+ userId: "TXRX-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
168
+ session: "TXRX-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
169
+ username: "Smokey Bear",
170
+ visibility: [],
171
+ }] as unknown as LocationPayload[];
172
+ await location.processItemsDeviceMappingGeotab(department, items, new Date());
173
+ const recs = await models.Location.find({});
174
+ assert.equal(recs.length, 1);
175
+ assert.equal(recs[0]?.username, "Smokey Bear");
176
+ assert.equal(recs[0]?.session, "TXRX-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
177
+ assert.equal(recs[0]?.transponderUUID, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
178
+ assert.equal(recs[0]?.active, true);
179
+ assert.equal(recs[0]?.shared, false);
180
+ assert.equal(recs[0]?.sendToCAD, false);
181
+ assert.deepEqual(recs[0]?.visibility, ["visible"]);
182
+ });
183
+ it("skips geotab person locations with neither personnelUUID nor transponderUUID", async () => {
184
+ const department = mockDepartment;
185
+ const items = [{
186
+ vehicleId: "BEEDOO3",
187
+ radioName: "Smokey Bear",
188
+ latitude: 34.35264,
189
+ longitude: -119.0669,
190
+ avlTime: "2026-07-10T15:43:36.816Z",
191
+ kindType: "person",
192
+ locationType: "person",
193
+ source: "TXRX",
194
+ visibility: [],
195
+ }] as unknown as LocationPayload[];
196
+ await location.processItemsDeviceMappingGeotab(department, items, new Date());
197
+ const recs = await models.Location.find({});
198
+ assert.equal(recs.length, 0);
199
+ });
200
+ it("saves person locations keyed by session with person invariants, no CAD vehicle", async () => {
201
+ const atDate = new Date();
202
+ const department = mockDepartment;
203
+ const items = [{
204
+ vehicleId: "12345",
205
+ radioName: "SmokeyBear",
206
+ latitude: 34.35264,
207
+ longitude: -119.0669,
208
+ avlTime: "2026-07-10T15:43:36.816Z",
209
+ kindType: "person",
210
+ locationType: "person",
211
+ kind: "",
212
+ type: "",
213
+ source: "TXRX",
214
+ personnelUUID: "11111111-2222-3333-4444-555555555555",
215
+ userId: "TXRX-12345",
216
+ session: "TXRX-12345",
217
+ username: "SmokeyBear",
218
+ visibility: [],
219
+ }] as unknown as LocationPayload[];
220
+ await location.processItems(department, items, atDate);
221
+ const recs = await models.Location.find({});
222
+ assert.equal(recs.length, 1);
223
+ assert.equal(recs[0]?.kindType, "person");
224
+ assert.equal(recs[0]?.locationType, "person");
225
+ assert.equal(recs[0]?.personnelUUID, "11111111-2222-3333-4444-555555555555");
226
+ assert.equal(recs[0]?.transponderUUID, "");
227
+ assert.equal(recs[0]?.username, "SmokeyBear");
228
+ assert.equal(recs[0]?.userId, "TXRX-12345");
229
+ assert.equal(recs[0]?.session, "TXRX-12345");
230
+ assert.equal(recs[0]?.active, true);
231
+ assert.equal(recs[0]?.shared, false);
232
+ assert.equal(recs[0]?.sendToCAD, false);
233
+ assert.deepEqual(recs[0]?.visibility, ["visible"]);
234
+ // Person records must not create phantom CAD vehicles
235
+ const vehicles = await models.CADVehicle.find({});
236
+ assert.equal(vehicles.length, 0);
237
+ });
238
+ it("stamps kindType on person locations classified by locationType alone", async () => {
239
+ const atDate = new Date();
240
+ const department = mockDepartment;
241
+ const items = [{
242
+ vehicleId: "12345",
243
+ radioName: "SmokeyBear",
244
+ latitude: 34.35264,
245
+ longitude: -119.0669,
246
+ avlTime: "2026-07-10T15:43:36.816Z",
247
+ locationType: "person",
248
+ source: "TXRX",
249
+ personnelUUID: "11111111-2222-3333-4444-555555555555",
250
+ userId: "TXRX-12345",
251
+ session: "TXRX-12345",
252
+ username: "SmokeyBear",
253
+ visibility: [],
254
+ }] as unknown as LocationPayload[];
255
+ await location.processItems(department, items, atDate);
256
+ const recs = await models.Location.find({});
257
+ assert.equal(recs.length, 1);
258
+ // The person feed queries kindType: "person", so the record must not
259
+ // rely on the payload having set it
260
+ assert.equal(recs[0]?.kindType, "person");
261
+ assert.equal(recs[0]?.locationType, "person");
262
+ assert.equal(recs[0]?.personnelUUID, "11111111-2222-3333-4444-555555555555");
263
+ const vehicles = await models.CADVehicle.find({});
264
+ assert.equal(vehicles.length, 0);
265
+ });
266
+ it("accepts transponder person locations on transponderUUID alone", async () => {
267
+ const atDate = new Date();
268
+ const department = mockDepartment;
269
+ const items = [{
270
+ vehicleId: "FB:9D:2C:3E:C9:E4",
271
+ radioName: "Fire Hose 1",
272
+ latitude: 34.35264,
273
+ longitude: -119.0669,
274
+ avlTime: "2026-07-10T15:43:36.816Z",
275
+ kindType: "person",
276
+ locationType: "person",
277
+ source: "TXRX",
278
+ transponderUUID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
279
+ userId: "TXRX-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
280
+ session: "TXRX-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
281
+ username: "Fire Hose 1",
282
+ visibility: [],
283
+ }] as unknown as LocationPayload[];
284
+ await location.processItems(department, items, atDate);
285
+ const recs = await models.Location.find({});
286
+ assert.equal(recs.length, 1);
287
+ assert.equal(recs[0]?.transponderUUID, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
288
+ assert.equal(recs[0]?.personnelUUID, "");
289
+ assert.equal(recs[0]?.session, "TXRX-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
290
+ assert.equal(recs[0]?.active, true);
291
+ assert.equal(recs[0]?.shared, false);
292
+ assert.equal(recs[0]?.sendToCAD, false);
293
+ assert.deepEqual(recs[0]?.visibility, ["visible"]);
294
+ // Person records must not create phantom CAD vehicles
295
+ const vehicles = await models.CADVehicle.find({});
296
+ assert.equal(vehicles.length, 0);
297
+ });
298
+ it("updates person locations in place on rename, preserving person fields", async () => {
299
+ const department = mockDepartment;
300
+ const base = {
301
+ vehicleId: "FB:9D:2C:3E:C9:E4",
302
+ radioName: "William Jones",
303
+ latitude: 34.35264,
304
+ longitude: -119.0669,
305
+ avlTime: "2026-07-10T15:43:36.816Z",
306
+ kindType: "person",
307
+ locationType: "person",
308
+ source: "TXRX",
309
+ transponderUUID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
310
+ userId: "TXRX-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
311
+ session: "TXRX-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
312
+ username: "William Jones",
313
+ visibility: [],
314
+ };
315
+ await location.processItems(department, [base] as unknown as LocationPayload[], new Date());
316
+ const renamed = {
317
+ ...base,
318
+ radioName: "Bill Jones",
319
+ username: "Bill Jones",
320
+ latitude: 34.4,
321
+ };
322
+ await location.processItems(department, [renamed] as unknown as LocationPayload[], new Date());
323
+ const recs = await models.Location.find({});
324
+ assert.equal(recs.length, 1);
325
+ assert.equal(recs[0]?.username, "Bill Jones");
326
+ assert.equal(recs[0]?.session, "TXRX-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
327
+ assert.equal(recs[0]?.locationGeoJSON.coordinates[1], 34.4);
328
+ // Update path must preserve the person fields
329
+ assert.equal(recs[0]?.kindType, "person");
330
+ assert.equal(recs[0]?.locationType, "person");
331
+ assert.equal(recs[0]?.transponderUUID, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
332
+ assert.deepEqual(recs[0]?.visibility, ["visible"]);
333
+ });
334
+ it("does not wipe transponderUUID when an update omits it", async () => {
335
+ const department = mockDepartment;
336
+ const base = {
337
+ vehicleId: "FB:9D:2C:3E:C9:E4",
338
+ radioName: "Fire Hose 1",
339
+ latitude: 34.35264,
340
+ longitude: -119.0669,
341
+ avlTime: "2026-07-10T15:43:36.816Z",
342
+ kindType: "person",
343
+ locationType: "person",
344
+ source: "TXRX",
345
+ transponderUUID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
346
+ userId: "TXRX-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
347
+ session: "TXRX-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
348
+ username: "Fire Hose 1",
349
+ visibility: [],
350
+ };
351
+ await location.processItems(department, [base] as unknown as LocationPayload[], new Date());
352
+ // Same session, but this update carries only a personnelUUID
353
+ const update = {
354
+ ...base,
355
+ transponderUUID: "",
356
+ personnelUUID: "11111111-2222-3333-4444-555555555555",
357
+ latitude: 34.4,
358
+ };
359
+ await location.processItems(department, [update] as unknown as LocationPayload[], new Date());
360
+ const recs = await models.Location.find({});
361
+ assert.equal(recs.length, 1);
362
+ assert.equal(recs[0]?.transponderUUID, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
363
+ assert.equal(recs[0]?.personnelUUID, "11111111-2222-3333-4444-555555555555");
364
+ assert.equal(recs[0]?.locationGeoJSON.coordinates[1], 34.4);
365
+ });
366
+ it("skips person locations with neither personnelUUID nor transponderUUID", async () => {
367
+ const department = mockDepartment;
368
+ const items = [{
369
+ vehicleId: "12345",
370
+ radioName: "William Jones",
371
+ latitude: 34.35264,
372
+ longitude: -119.0669,
373
+ avlTime: "2026-07-10T15:43:36.816Z",
374
+ kindType: "person",
375
+ locationType: "person",
376
+ source: "TXRX",
377
+ session: "TXRX-12345",
378
+ visibility: [],
379
+ }] as unknown as LocationPayload[];
380
+ await location.processItems(department, items, new Date());
381
+ const recs = await models.Location.find({});
382
+ assert.equal(recs.length, 0);
383
+ const vehicles = await models.CADVehicle.find({});
384
+ assert.equal(vehicles.length, 0);
385
+ });
386
+ it("continues processing the batch after an invalid item", async () => {
387
+ const department = mockDepartment;
388
+ const items = [{
389
+ vehicleId: "",
390
+ radioName: "",
391
+ latitude: 0,
392
+ longitude: 0,
393
+ avlTime: "1602563494",
394
+ }, {
395
+ vehicleId: "BEEDOO1",
396
+ radioName: "BEEDOO1",
397
+ latitude: 34.35264,
398
+ longitude: -119.0669,
399
+ avlTime: "1602563494",
400
+ }] as unknown as LocationPayload[];
401
+ await location.processItems(department, items, new Date());
402
+ const recs = await models.Location.find({});
403
+ assert.equal(recs.length, 1);
404
+ assert.equal(recs[0]?.username, "BEEDOO1");
405
+ });
153
406
  it("carries kind/type/locationType/kindColor onto the saved Location", async () => {
154
407
  const atDate = new Date();
155
408
  const department = mockDepartment;
@@ -176,5 +429,57 @@ describe("location", () => {
176
429
  assert.equal(recs[0]?.kindColor?.text, "#FFFFFF");
177
430
  assert.instanceOf(recs[0]?.kindColorChangedAt, Date);
178
431
  });
432
+ it("stamps the department's industry on the saved location", async () => {
433
+ const atDate = new Date();
434
+ const session = "item-with-industry-utility";
435
+ const items = [{
436
+ vehicleId: "BEEDOO1",
437
+ radioName: "BEEDOO1",
438
+ latitude: 34.35264,
439
+ longitude: -119.0669,
440
+ avlTime: "1602563494",
441
+ session,
442
+ }] as unknown as LocationPayload[];
443
+ await location.processItems(mockDepartment, items, atDate);
444
+ const sut = await models.Location.findOne({ session });
445
+ assert.equal(sut?.industry, AccountIndustry.Utility);
446
+ });
447
+ it("falls back to fire industry when the department has none set", async () => {
448
+ const atDate = new Date();
449
+ const session = "item-with-industry-fallback";
450
+ const department = { ...mockDepartment, industry: undefined };
451
+ const items = [{
452
+ vehicleId: "BEEDOO1",
453
+ radioName: "BEEDOO1",
454
+ latitude: 34.35264,
455
+ longitude: -119.0669,
456
+ avlTime: "1602563494",
457
+ session,
458
+ }] as unknown as LocationPayload[];
459
+ await location.processItems(department, items, atDate);
460
+ const sut = await models.Location.findOne({ session });
461
+ assert.equal(sut?.industry, AccountIndustry.Fire);
462
+ });
463
+ it("updates industry in place and stamps propsChangedAt when it changes", async () => {
464
+ const base = {
465
+ vehicleId: "BEEDOO1",
466
+ radioName: "BEEDOO1",
467
+ latitude: 34.35264,
468
+ longitude: -119.0669,
469
+ avlTime: "1602563494",
470
+ } as unknown as LocationPayload;
471
+ await location.processItems(mockDepartment, [base], new Date());
472
+ const initial = await models.Location.find({});
473
+ assert.equal(initial[0]?.industry, AccountIndustry.Utility);
474
+ const initialPropsChangedAt = initial[0]?.propsChangedAt;
475
+ assert.instanceOf(initialPropsChangedAt, Date);
476
+
477
+ const lawDepartment = { ...mockDepartment, industry: AccountIndustry.Law };
478
+ await location.processItems(lawDepartment, [base], new Date());
479
+ const recs = await models.Location.find({});
480
+ assert.equal(recs.length, 1);
481
+ assert.equal(recs[0]?.industry, AccountIndustry.Law);
482
+ assert.isAbove(recs[0]!.propsChangedAt.valueOf(), initialPropsChangedAt!.valueOf());
483
+ });
179
484
  });
180
485
  });
package/src/test/mock.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as mongoose from "mongoose";
2
2
  import {
3
3
  BackendModels,
4
+ AccountIndustry,
4
5
  Agency,
5
6
  Department,
6
7
  convertToObjectId as ObjectId
@@ -15,11 +16,16 @@ export default function(modelsModule: typeof import("tabletcommand-backend-model
15
16
  _id: ObjectId(departmentId),
16
17
  department: "Test Department",
17
18
  accountType: "production",
19
+ industry: AccountIndustry.Utility,
18
20
  shareAVL: {
19
21
  enabled: true,
20
22
  opAreaName: "Test Area",
21
23
  opAreaCode: "TEST",
22
24
  fadeZoomLevel: 45000,
25
+ area: {
26
+ type: "Polygon",
27
+ coordinates: [],
28
+ },
23
29
  },
24
30
  signupDomains: [],
25
31
  signupKey: "abcd",
@@ -0,0 +1,209 @@
1
+ import { assert } from "chai";
2
+ import { describe, it, describe as context } from "node:test";
3
+ import {
4
+ adminListLocations,
5
+ adminListPersonLocations,
6
+ adminListUAVLocations,
7
+ adminListAircraftLocations,
8
+ adminListSharedLocations,
9
+ adminListFocusedLocations,
10
+ syncListSharedLocations,
11
+ syncListLocations,
12
+ syncListPersonLocations,
13
+ syncListAircraftLocations,
14
+ syncListUAVLocations,
15
+ metersToRadians,
16
+ } from "../query";
17
+
18
+ const departmentId = "5195426cc4e016a988000969";
19
+ const movedAt = new Date("2024-01-01T00:00:00.000Z");
20
+ const modifiedDate = new Date("2024-01-02T00:00:00.000Z");
21
+ const longitude = -119.0669;
22
+ const latitude = 34.35264;
23
+ const diagonal = 5000;
24
+
25
+ describe("query", () => {
26
+ context("adminListLocations", () => {
27
+ it("Scopes to the department, excludes uav and person/transponder-tracked records", () => {
28
+ const { list } = adminListLocations({ departmentId, movedAt });
29
+ assert.equal(list.active, true);
30
+ assert.equal(list.departmentId, departmentId);
31
+ assert.deepEqual(list.kindType, { $ne: "uav" });
32
+ });
33
+
34
+ it("Drops the active filter when showAll is true", () => {
35
+ const { list } = adminListLocations({ departmentId, movedAt, showAll: true });
36
+ assert.isUndefined(list.active);
37
+ });
38
+
39
+ it("Keeps the active filter when showAll is not set", () => {
40
+ const { list } = adminListLocations({ departmentId, movedAt });
41
+ assert.property(list, "active");
42
+ });
43
+ });
44
+
45
+ context("adminListPersonLocations", () => {
46
+ it("Scopes to the department and person kindType", () => {
47
+ const { list } = adminListPersonLocations({ departmentId, movedAt });
48
+ assert.equal(list.active, true);
49
+ assert.equal(list.departmentId, departmentId);
50
+ assert.equal(list.kindType, "person");
51
+ assert.deepEqual(list.movedAt, { $gte: movedAt });
52
+ });
53
+
54
+ it("Drops the active filter when showAll is true", () => {
55
+ const { list } = adminListPersonLocations({ departmentId, movedAt, showAll: true });
56
+ assert.isUndefined(list.active);
57
+ });
58
+ });
59
+
60
+ context("adminListUAVLocations", () => {
61
+ it("Scopes to the department and uav kindType", () => {
62
+ const { list } = adminListUAVLocations({ departmentId, movedAt });
63
+ assert.equal(list.active, true);
64
+ assert.equal(list.departmentId, departmentId);
65
+ assert.equal(list.kindType, "uav");
66
+ assert.deepEqual(list.movedAt, { $gte: movedAt });
67
+ });
68
+
69
+ it("Drops the active filter when showAll is true", () => {
70
+ const { list } = adminListUAVLocations({ departmentId, movedAt, showAll: true });
71
+ assert.isUndefined(list.active);
72
+ });
73
+ });
74
+
75
+ context("adminListAircraftLocations", () => {
76
+ it("Excludes the caller's department and matches shared ADSB aircraft", () => {
77
+ const { list } = adminListAircraftLocations({ departmentId, movedAt, longitude, latitude, diagonal });
78
+ assert.equal(list.shared, true);
79
+ assert.equal(list.active, true);
80
+ assert.equal(list.source, "ADSB");
81
+ assert.deepEqual(list.kindType, { $in: ["fixed-wing", "helicopter"] });
82
+ assert.deepEqual(list.departmentId, { $ne: departmentId });
83
+ });
84
+
85
+ it("Sends movedAt as an ISO string", () => {
86
+ const { list } = adminListAircraftLocations({ departmentId, movedAt, longitude, latitude, diagonal });
87
+ assert.deepEqual(list.movedAt, { $gte: movedAt.toISOString() });
88
+ });
89
+
90
+ it("Builds a $centerSphere filter as [longitude, latitude] with the diagonal converted to radians", () => {
91
+ const { list } = adminListAircraftLocations({ departmentId, movedAt, longitude, latitude, diagonal });
92
+ assert.deepEqual(list.locationGeoJSON.$geoWithin.$centerSphere, [
93
+ [longitude, latitude],
94
+ diagonal / metersToRadians,
95
+ ]);
96
+ });
97
+ });
98
+
99
+ context("adminListSharedLocations", () => {
100
+ it("Excludes the caller's department, ADSB source, and uav kindType", () => {
101
+ const { list } = adminListSharedLocations({ departmentId, movedAt, longitude, latitude, diagonal });
102
+ assert.equal(list.shared, true);
103
+ assert.equal(list.active, true);
104
+ assert.deepEqual(list.source, { $ne: "ADSB" });
105
+ assert.deepEqual(list.kindType, { $ne: "uav" });
106
+ assert.deepEqual(list.departmentId, { $ne: departmentId });
107
+ });
108
+ });
109
+
110
+ context("adminListFocusedLocations", () => {
111
+ it("Scopes to the caller's department with a geo filter", () => {
112
+ const { list } = adminListFocusedLocations({ departmentId, movedAt, longitude, latitude, diagonal });
113
+ assert.equal(list.active, true);
114
+ assert.equal(list.departmentId, departmentId);
115
+ assert.property(list, "locationGeoJSON");
116
+ });
117
+
118
+ it("Drops the geo filter (not active) when showAll is true", () => {
119
+ const { list } = adminListFocusedLocations({ departmentId, movedAt, longitude, latitude, diagonal, showAll: true });
120
+ assert.isUndefined(list.locationGeoJSON);
121
+ assert.equal(list.active, true);
122
+ });
123
+ });
124
+
125
+ context("syncListSharedLocations", () => {
126
+ it("Excludes the caller's department and ADSB/DroneSense sources", () => {
127
+ const { list } = syncListSharedLocations({ departmentId, movedAt, modifiedDate, longitude, latitude, diagonal });
128
+ assert.equal(list.shared, true);
129
+ assert.deepEqual(list.source, { $nin: ["ADSB", "DroneSense"] });
130
+ assert.deepEqual(list.departmentId, { $ne: departmentId });
131
+ assert.deepEqual(list.modified, { $gte: modifiedDate.toISOString() });
132
+ assert.deepEqual(list.movedAt, { $gte: movedAt.toISOString() });
133
+ });
134
+ });
135
+
136
+ context("syncListLocations", () => {
137
+ it("Scopes to the department, excludes uav and person/transponder-tracked records", () => {
138
+ const { list } = syncListLocations({ departmentId, movedAt, modifiedDate });
139
+ assert.equal(list.departmentId, departmentId);
140
+ assert.deepEqual(list.kindType, { $ne: "uav" });
141
+ assert.deepEqual(list.modified, { $gte: modifiedDate.toISOString() });
142
+ assert.deepEqual(list.movedAt, { $gte: movedAt.toISOString() });
143
+ assert.deepEqual(list.$and, [
144
+ { $or: [{ personnelUUID: "" }, { personnelUUID: { $exists: false } }] },
145
+ { $or: [{ transponderUUID: "" }, { transponderUUID: { $exists: false } }] },
146
+ ]);
147
+ });
148
+ });
149
+
150
+ context("syncListPersonLocations", () => {
151
+ it("Scopes to the department and person kindType", () => {
152
+ const { list } = syncListPersonLocations({ departmentId, movedAt, modifiedDate });
153
+ assert.equal(list.departmentId, departmentId);
154
+ assert.equal(list.kindType, "person");
155
+ assert.deepEqual(list.modified, { $gte: modifiedDate.toISOString() });
156
+ assert.deepEqual(list.movedAt, { $gte: movedAt.toISOString() });
157
+ });
158
+ });
159
+
160
+ context("syncListUAVLocations", () => {
161
+ it("Scopes to the department and uav kindType", () => {
162
+ const { list } = syncListUAVLocations({ departmentId, movedAt, modifiedDate });
163
+ assert.equal(list.departmentId, departmentId);
164
+ assert.equal(list.kindType, "uav");
165
+ assert.deepEqual(list.modified, { $gte: modifiedDate.toISOString() });
166
+ assert.deepEqual(list.movedAt, { $gte: movedAt.toISOString() });
167
+ });
168
+ });
169
+
170
+ context("syncListAircraftLocations", () => {
171
+ const base = { departmentId, movedAt, modifiedDate };
172
+
173
+ it("Matches shared ADSB aircraft, scoped away from the caller's department", () => {
174
+ const { list } = syncListAircraftLocations({ ...base, longitude, latitude, diagonal });
175
+ assert.equal(list.shared, true);
176
+ assert.equal(list.source, "ADSB");
177
+ assert.deepEqual(list.kindType, { $in: ["fixed-wing", "helicopter"] });
178
+ assert.deepEqual(list.departmentId, { $ne: departmentId });
179
+ });
180
+
181
+ it("Adds a geo filter when given valid, non-zero coordinates", () => {
182
+ const { list } = syncListAircraftLocations({ ...base, longitude, latitude, diagonal });
183
+ assert.deepEqual(list.locationGeoJSON.$geoWithin.$centerSphere, [
184
+ [longitude, latitude],
185
+ diagonal / metersToRadians,
186
+ ]);
187
+ });
188
+
189
+ it("Omits the geo filter when coordinates are at the origin", () => {
190
+ const { list } = syncListAircraftLocations({ ...base, longitude: 0, latitude: 0, diagonal });
191
+ assert.isUndefined(list.locationGeoJSON);
192
+ });
193
+
194
+ it("Omits the geo filter when coordinates are within the origin (<= 0.01)", () => {
195
+ const { list } = syncListAircraftLocations({ ...base, longitude: 0.01, latitude: 0.01, diagonal });
196
+ assert.isUndefined(list.locationGeoJSON);
197
+ });
198
+
199
+ it("Omits the geo filter when latitude is out of range", () => {
200
+ const { list } = syncListAircraftLocations({ ...base, longitude, latitude: 90, diagonal });
201
+ assert.isUndefined(list.locationGeoJSON);
202
+ });
203
+
204
+ it("Omits the geo filter when longitude is out of range", () => {
205
+ const { list } = syncListAircraftLocations({ ...base, longitude: 180, latitude, diagonal });
206
+ assert.isUndefined(list.locationGeoJSON);
207
+ });
208
+ });
209
+ });
package/src/types.ts CHANGED
@@ -27,6 +27,21 @@ export interface LocationPayload {
27
27
  heading?: number;
28
28
  vehicleId: string;
29
29
  visibility: LocationVisibility[];
30
+ // Person records: link to PersonnelKnown.uuid. A person Location needs a
31
+ // non-empty personnelUUID or transponderUUID with both empty it would leak
32
+ // into the vehicle sync endpoints (filters on personnelUUID: "").
33
+ personnelUUID?: string;
34
+ // TrackerResponder.uuid for transponder-tracked seats; who occupies the seat
35
+ // is resolved at read time from the responder's assignment, personnelUUID
36
+ // stays empty on these records.
37
+ transponderUUID?: string;
38
+ // Identity overrides. When set, these replace the derived radioName-vehicleId
39
+ // identity so display-name changes don't fork records (e.g. "TXRX-<uuid>").
40
+ // Caused because up to this point these were derived/generated and changing
41
+ // would then change the record and break lookups causing a new record.
42
+ userId?: string;
43
+ session?: string;
44
+ username?: string;
30
45
  }
31
46
 
32
47
  export interface Vehicle {
package/test.sh CHANGED
@@ -17,7 +17,7 @@ if hash brew 2>/dev/null; then
17
17
  fi
18
18
  fi
19
19
 
20
- NODE_VERSION="v24.13.0"
20
+ NODE_VERSION="v24.15.0"
21
21
 
22
22
  nvm use $NODE_VERSION || nvm install $NODE_VERSION
23
23