tabletcommand-location 2.4.0 → 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.
@@ -0,0 +1,54 @@
1
+ import {
2
+ AccountIndustry,
3
+ } from "tabletcommand-backend-models";
4
+
5
+ export type AvlAudienceKind = "always" | "near" | "geo";
6
+
7
+ export interface AvlNearArea {
8
+ /** Radius around an anchor incident. Per-pair — deliberately not a shared constant. */
9
+ radiusMeters: number;
10
+ }
11
+
12
+ export type AvlAudienceEntry =
13
+ | { kind: "always" }
14
+ | { kind: "near"; area: AvlNearArea } // area required exactly when near
15
+ | { kind: "geo" }; // geometry comes from the OWNER's shareAVL.area
16
+
17
+ /** owner industry -> reader industry -> entry, or null for denied. Every pair present. */
18
+ export type AvlAudienceMatrix = Record<AccountIndustry, Record<AccountIndustry, AvlAudienceEntry | null>>;
19
+
20
+ const ALWAYS: AvlAudienceEntry = { kind: "always" };
21
+ // eslint-ignore-next-line
22
+ // const GEO: AvlAudienceEntry = { kind: "geo" };
23
+ const DENY = null;
24
+ const NEAR: AvlAudienceEntry = { kind: "near", area: { radiusMeters: 5000 } }; // 5km ~ 3.1mi
25
+
26
+ export const AVL_AUDIENCE_MATRIX: AvlAudienceMatrix = {
27
+ // owner => reader
28
+ [AccountIndustry.Fire]: {
29
+ [AccountIndustry.Fire]: ALWAYS,
30
+ [AccountIndustry.Law]: ALWAYS,
31
+ [AccountIndustry.Campus]: DENY,
32
+ [AccountIndustry.Utility]: NEAR,
33
+ },
34
+ [AccountIndustry.Law]: {
35
+ [AccountIndustry.Fire]: NEAR,
36
+ [AccountIndustry.Law]: ALWAYS,
37
+ [AccountIndustry.Campus]: DENY,
38
+ [AccountIndustry.Utility]: DENY,
39
+ },
40
+ [AccountIndustry.Campus]: {
41
+ [AccountIndustry.Fire]: ALWAYS,
42
+ [AccountIndustry.Law]: ALWAYS,
43
+ [AccountIndustry.Campus]: DENY, // deliberate — see above
44
+ [AccountIndustry.Utility]: DENY,
45
+ },
46
+ [AccountIndustry.Utility]: {
47
+ [AccountIndustry.Fire]: NEAR,
48
+ [AccountIndustry.Law]: DENY,
49
+ [AccountIndustry.Campus]: DENY,
50
+ [AccountIndustry.Utility]: ALWAYS,
51
+ },
52
+ };
53
+
54
+ // Unused/Incomplete
package/src/index.ts CHANGED
@@ -23,6 +23,11 @@ export {
23
23
  LocationVisibility,
24
24
  };
25
25
 
26
+ // When used externally
27
+ // import { query } from "tabletcommand-location";
28
+ // query.adminListLocations({ departmentId, movedAt });
29
+ export * as query from "./query";
30
+
26
31
  export function lib(
27
32
  departmentModel: DepartmentModel,
28
33
  locationModel: LocationModel,
@@ -31,9 +36,6 @@ export function lib(
31
36
  ) {
32
37
  const store = storeModule(departmentModel, locationModel, cadVehicleModel, deviceMappingModel);
33
38
  const location = locationModule(store);
34
- // async function processVehicle(department: Partial<Department>, item: Partial<CADVehicle>) {
35
- // // await location.processVehicle()
36
- // }
37
39
 
38
40
  async function processLocations(department: Partial<Department>, items: LocationPayload[] | null | undefined) {
39
41
  const atDate = new Date();
@@ -69,26 +71,9 @@ export function lib(
69
71
  }
70
72
  }
71
73
 
72
- // async function processLocationsOnly(department: Partial<Department>, items: Partial<Location>[] | null | undefined, agency: Partial<Agency>) {
73
- // try {
74
- // if (_.isUndefined(items) || _.isNull(items)) {
75
- // return [];
76
- // }
77
- // if (_.isObject(items) && !_.isArray(items)) {
78
- // items = [items];
79
- // }
80
- // const result = await location.processItemsNoVehicle(department, items, agency);
81
- // return result;
82
- // } catch (err) {
83
- // throw err;
84
- // }
85
- // }
86
-
87
74
  return {
88
- // processVehicle,
89
75
  processLocations,
90
76
  processLocationsDeviceMapping,
91
- // processLocationsOnly,
92
77
  };
93
78
  }
94
79
 
package/src/location.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import _ from "lodash";
2
2
  import {
3
+ AccountIndustry,
3
4
  Department,
4
5
  Location,
5
6
  CADVehicle,
@@ -88,6 +89,7 @@ export function location(store: StoreModule) {
88
89
  const deleteAfterDate = new Date(atDate.valueOf() + 1000 * 60 * locationStaleMinutes);
89
90
  const personnelUUID = _.isString(item.personnelUUID) ? item.personnelUUID : "";
90
91
  const transponderUUID = _.isString(item.transponderUUID) ? item.transponderUUID : "";
92
+ const industry: AccountIndustry = department?.industry ?? AccountIndustry.Fire; // If not set, fallback to fire
91
93
 
92
94
  const cadAVL: Partial<Location> = {
93
95
  device_type: deviceType,
@@ -114,6 +116,7 @@ export function location(store: StoreModule) {
114
116
  visibility: [], // will be updated after .active is set
115
117
  personnelUUID,
116
118
  transponderUUID,
119
+ industry,
117
120
  };
118
121
 
119
122
  if (_.isNumber(item.speed)) {
package/src/query.ts ADDED
@@ -0,0 +1,492 @@
1
+ import {
2
+ FilterQuery,
3
+ ProjectionType,
4
+ SortOrder,
5
+ } from "mongoose";
6
+
7
+ import {
8
+ Location,
9
+ } from "tabletcommand-backend-models";
10
+
11
+ export type MQP = {
12
+ list: FilterQuery<Location>,
13
+ projection: ProjectionType<Location>,
14
+ sortBy?: Record<string, SortOrder>,
15
+ };
16
+
17
+ export type AdminListLocationParams1 = {
18
+ departmentId: string,
19
+ movedAt: Date,
20
+ showAll?: boolean,
21
+ };
22
+
23
+ export type AdminListLocationParams2 = {
24
+ departmentId: string,
25
+ movedAt: Date,
26
+ longitude: number,
27
+ latitude: number,
28
+ diagonal: number,
29
+ showAll?: boolean,
30
+ };
31
+
32
+ export type SyncModifiedDate = {
33
+ modifiedDate: Date,
34
+ };
35
+
36
+ export const metersToRadians = 6378100; // The equatorial radius of the Earth in meters
37
+
38
+ function centerSphereFilter(longitude: number, latitude: number, diagonal: number) {
39
+ return {
40
+ $geoWithin: {
41
+ $centerSphere: [
42
+ [longitude, latitude],
43
+ diagonal / metersToRadians,
44
+ ] as [[number, number], number],
45
+ },
46
+ };
47
+ }
48
+
49
+ // Transponder-tracked person records carry an empty personnelUUID;
50
+ // they surface via the person/responder feeds, never in unit/vehicle lists
51
+ const excludePersonAndTransponderRecords = [
52
+ {
53
+ $or: [
54
+ { personnelUUID: "" },
55
+ { personnelUUID: { $exists: false } }, // TODO: Remove $exists check once all location services are running the latest
56
+ ],
57
+ },
58
+ {
59
+ $or: [
60
+ { transponderUUID: "" },
61
+ { transponderUUID: { $exists: false } },
62
+ ],
63
+ },
64
+ ];
65
+
66
+ // Same fields defined in the frontend
67
+ // api/a1/location/units and api/a1/location/units-shared
68
+ const projectionAdmin = {
69
+ _id: 1,
70
+ active: 1,
71
+ altitude: 1,
72
+ color: 1,
73
+ device_type: 1,
74
+ kindType: 1,
75
+ location: 1,
76
+ locationGeoJSON: 1,
77
+ modified: 1,
78
+ movedAt: 1,
79
+ sendToCAD: 1,
80
+ source: 1,
81
+ speed: 1,
82
+ uuid: 1, // Legacy, remove later
83
+ };
84
+
85
+ export function adminListLocations(params: AdminListLocationParams1): MQP {
86
+ const {
87
+ departmentId,
88
+ movedAt,
89
+ showAll,
90
+ } = params;
91
+
92
+ const query: FilterQuery<Location> = {
93
+ // TODO: Replace models.Location active: true with visibility in "visible"
94
+ active: true,
95
+ departmentId,
96
+ $and: excludePersonAndTransponderRecords,
97
+ kindType: {
98
+ $ne: "uav",
99
+ },
100
+ // 03/14/23 (Joe) - Trying movedAt only [sc-11794] - determined modified was being overwritten by cad vehicle status updates
101
+ movedAt: {
102
+ $gte: movedAt,
103
+ },
104
+ };
105
+ if (showAll) {
106
+ delete query.active;
107
+ }
108
+
109
+ return {
110
+ list: query,
111
+ projection: projectionAdmin,
112
+ };
113
+ }
114
+
115
+ export function adminListPersonLocations(params: AdminListLocationParams1): MQP {
116
+ const {
117
+ departmentId,
118
+ movedAt,
119
+ showAll
120
+ } = params;
121
+
122
+ const query: FilterQuery<Location> = {
123
+ active: true,
124
+ departmentId,
125
+ kindType: "person",
126
+ movedAt: {
127
+ $gte: movedAt,
128
+ },
129
+ };
130
+ if (showAll) {
131
+ delete query.active;
132
+ }
133
+
134
+ // Same fields defined in the frontend
135
+ // api/a1/location/person
136
+ const projection = {
137
+ _id: 1,
138
+ active: 1,
139
+ altitude: 1,
140
+ color: 1,
141
+ device_type: 1,
142
+ kindType: 1,
143
+ location: 1,
144
+ locationGeoJSON: 1,
145
+ modified: 1,
146
+ movedAt: 1,
147
+ personnelUUID: 1,
148
+ sendToCAD: 1,
149
+ source: 1,
150
+ speed: 1,
151
+ transponderUUID: 1,
152
+ uuid: 1, // Legacy, remove later
153
+ };
154
+
155
+ return {
156
+ list: query,
157
+ projection,
158
+ };
159
+ }
160
+
161
+ export function adminListUAVLocations(params: AdminListLocationParams1): MQP {
162
+ const {
163
+ departmentId,
164
+ movedAt,
165
+ showAll,
166
+ } = params;
167
+
168
+ const query: FilterQuery<Location> = {
169
+ active: true,
170
+ departmentId,
171
+ kindType: "uav",
172
+ movedAt: {
173
+ $gte: movedAt,
174
+ },
175
+ };
176
+ if (showAll) {
177
+ delete query.active;
178
+ }
179
+
180
+ const projection = {
181
+ _id: 1,
182
+ active: 1,
183
+ agencyCode: 1,
184
+ agencyName: 1,
185
+ altitude: 1,
186
+ color: 1,
187
+ device_type: 1,
188
+ heading: 1,
189
+ kindType: 1,
190
+ locationGeoJSON: 1,
191
+ modified: 1,
192
+ movedAt: 1,
193
+ opAreaCode: 1,
194
+ opAreaName: 1,
195
+ sendToCAD: 1,
196
+ source: 1,
197
+ speed: 1,
198
+ state: 1,
199
+ userId: 1,
200
+ username: 1,
201
+ uuid: 1, // Legacy, remove later
202
+ vehicleId: 1,
203
+ };
204
+
205
+ return {
206
+ list: query,
207
+ projection,
208
+ };
209
+ }
210
+
211
+ export function adminListAircraftLocations(params: AdminListLocationParams2): MQP {
212
+ const {
213
+ departmentId,
214
+ movedAt,
215
+ longitude,
216
+ latitude,
217
+ diagonal
218
+ } = params;
219
+
220
+ const list = {
221
+ shared: true,
222
+ active: true,
223
+ source: "ADSB",
224
+ kindType: {
225
+ $in: ["fixed-wing", "helicopter"],
226
+ },
227
+ departmentId: {
228
+ $ne: departmentId,
229
+ },
230
+ movedAt: {
231
+ $gte: movedAt.toISOString(),
232
+ },
233
+ locationGeoJSON: centerSphereFilter(longitude, latitude, diagonal),
234
+ };
235
+
236
+ return {
237
+ list,
238
+ projection: projectionAdmin,
239
+ };
240
+ }
241
+
242
+ export function adminListSharedLocations(params: AdminListLocationParams2): MQP {
243
+ const {
244
+ departmentId,
245
+ movedAt,
246
+ longitude,
247
+ latitude,
248
+ diagonal
249
+ } = params;
250
+
251
+ const listSharedAVLQuery = {
252
+ shared: true,
253
+ active: true,
254
+ source: {
255
+ $ne: "ADSB"
256
+ },
257
+ departmentId: {
258
+ $ne: departmentId,
259
+ },
260
+ kindType: {
261
+ $ne: "uav",
262
+ },
263
+ movedAt: {
264
+ $gte: movedAt.toISOString(),
265
+ },
266
+ locationGeoJSON: centerSphereFilter(longitude, latitude, diagonal),
267
+ };
268
+
269
+ return {
270
+ list: listSharedAVLQuery,
271
+ projection: projectionAdmin,
272
+ };
273
+ }
274
+
275
+ export function adminListFocusedLocations(params: AdminListLocationParams2): MQP {
276
+ const {
277
+ departmentId,
278
+ movedAt,
279
+ longitude,
280
+ latitude,
281
+ diagonal,
282
+ showAll
283
+ } = params;
284
+
285
+ const listFocusedAVLQuery: FilterQuery<Location> = {
286
+ active: true,
287
+ departmentId,
288
+ movedAt: {
289
+ $gte: movedAt.toISOString(),
290
+ },
291
+ locationGeoJSON: centerSphereFilter(longitude, latitude, diagonal),
292
+ };
293
+
294
+ if (showAll) {
295
+ delete listFocusedAVLQuery.locationGeoJSON;
296
+ }
297
+
298
+ return {
299
+ list: listFocusedAVLQuery,
300
+ projection: projectionAdmin,
301
+ };
302
+ }
303
+
304
+ // Sync queries
305
+
306
+ const projectionSync: ProjectionType<Location> = {
307
+ active: 1,
308
+ departmentId: 1,
309
+ device_type: 1, // used in web ui
310
+ location: 1,
311
+ locationGeoJSON: 1,
312
+ modified: 1,
313
+ userId: 1,
314
+ username: 1,
315
+ uuid: 1, // used in iOS
316
+ // both used by AVL sharing
317
+ opAreaName: 1,
318
+ opAreaCode: 1,
319
+ shared: 1,
320
+ state: 1,
321
+ // v3
322
+ altitude: 1,
323
+ speed: 1,
324
+ movedAt: 1,
325
+ agencyName: 1,
326
+ agencyCode: 1,
327
+ // v4
328
+ color: 1,
329
+ colorChangedAt: 1,
330
+ propsChangedAt: 1,
331
+ heading: 1,
332
+ // v4 extra
333
+ kindType: 1,
334
+ source: 1,
335
+ personnelUUID: 1,
336
+ transponderUUID: 1,
337
+ // v4 kind/type/locationType
338
+ kind: 1,
339
+ type: 1,
340
+ locationType: 1,
341
+ kindColor: 1,
342
+ };
343
+
344
+ export function syncListSharedLocations(params: AdminListLocationParams2 & SyncModifiedDate): MQP {
345
+ const {
346
+ departmentId,
347
+ modifiedDate,
348
+ movedAt,
349
+ longitude,
350
+ latitude,
351
+ diagonal,
352
+ } = params;
353
+
354
+ const list: FilterQuery<Location> = {
355
+ shared: true,
356
+ source: {
357
+ $nin: ["ADSB", "DroneSense"]
358
+ },
359
+ departmentId: {
360
+ $ne: departmentId,
361
+ },
362
+ modified: {
363
+ $gte: modifiedDate.toISOString(),
364
+ },
365
+ movedAt: {
366
+ $gte: movedAt.toISOString(),
367
+ },
368
+ locationGeoJSON: centerSphereFilter(longitude, latitude, diagonal),
369
+ };
370
+
371
+ return {
372
+ list,
373
+ projection: projectionSync,
374
+ };
375
+ }
376
+
377
+ export function syncListLocations(params: AdminListLocationParams1 & SyncModifiedDate): MQP {
378
+ const {
379
+ departmentId,
380
+ modifiedDate,
381
+ movedAt,
382
+ } = params;
383
+
384
+ const list: FilterQuery<Location> = {
385
+ departmentId,
386
+ $and: excludePersonAndTransponderRecords,
387
+ kindType: {
388
+ $ne: "uav"
389
+ },
390
+ modified: {
391
+ $gte: modifiedDate.toISOString(),
392
+ },
393
+ movedAt: {
394
+ $gte: movedAt.toISOString(),
395
+ }
396
+ };
397
+
398
+ return {
399
+ list,
400
+ projection: projectionSync,
401
+ };
402
+ }
403
+
404
+ export function syncListPersonLocations(params: AdminListLocationParams1 & SyncModifiedDate): MQP {
405
+ const {
406
+ departmentId,
407
+ modifiedDate,
408
+ movedAt,
409
+ } = params;
410
+
411
+ const list: FilterQuery<Location> = {
412
+ departmentId,
413
+ kindType: "person",
414
+ modified: {
415
+ $gte: modifiedDate.toISOString(),
416
+ },
417
+ movedAt: {
418
+ $gte: movedAt.toISOString(),
419
+ },
420
+ };
421
+
422
+ return {
423
+ list,
424
+ projection: projectionSync,
425
+ };
426
+ }
427
+
428
+ export function syncListAircraftLocations(params: AdminListLocationParams2 & SyncModifiedDate): MQP {
429
+ const {
430
+ departmentId,
431
+ movedAt,
432
+ modifiedDate,
433
+ // Possibly missing
434
+ longitude,
435
+ latitude,
436
+ diagonal
437
+ } = params;
438
+
439
+ const list: FilterQuery<Location> = {
440
+ shared: true,
441
+ source: "ADSB",
442
+ kindType: {
443
+ $in: ["fixed-wing", "helicopter"]
444
+ },
445
+ departmentId: {
446
+ $ne: departmentId,
447
+ },
448
+ modified: {
449
+ $gte: modifiedDate.toISOString(),
450
+ },
451
+ movedAt: {
452
+ $gte: movedAt.toISOString(),
453
+ }
454
+ };
455
+
456
+ const hasCoordinate = Math.abs(latitude) > 0.01 || Math.abs(longitude) > 0.01;
457
+ const validCoordinate = Math.abs(latitude) < 90 && Math.abs(longitude) < 180;
458
+ const filterOnLocation = hasCoordinate && validCoordinate;
459
+ if (filterOnLocation) {
460
+ list.locationGeoJSON = centerSphereFilter(longitude, latitude, diagonal);
461
+ }
462
+
463
+ return {
464
+ list,
465
+ projection: projectionSync,
466
+ };
467
+ }
468
+
469
+ export function syncListUAVLocations(params: AdminListLocationParams1 & SyncModifiedDate): MQP {
470
+ const {
471
+ departmentId,
472
+ modifiedDate,
473
+ movedAt,
474
+
475
+ } = params;
476
+
477
+ const list: FilterQuery<Location> = {
478
+ departmentId,
479
+ kindType: "uav",
480
+ modified: {
481
+ $gte: modifiedDate.toISOString(),
482
+ },
483
+ movedAt: {
484
+ $gte: movedAt.toISOString(),
485
+ },
486
+ };
487
+
488
+ return {
489
+ list,
490
+ projection: projectionSync,
491
+ };
492
+ }
package/src/store.ts CHANGED
@@ -125,6 +125,7 @@ export function store(
125
125
  dbItem.locationGeoJSON = modelItem.locationGeoJSON;
126
126
  dbItem.source = modelItem.source;
127
127
  dbItem.kindType = modelItem.kindType;
128
+ dbItem.industry = modelItem.industry;
128
129
  // Carry person/category fields on the update path; only overwrite when the
129
130
  // incoming payload actually provides a value, so sources that don't send
130
131
  // these fields don't wipe values written by other sources.
@@ -183,6 +184,7 @@ export function store(
183
184
  "shared",
184
185
  "state",
185
186
  "username",
187
+ "industry",
186
188
  ];
187
189
  if (_.intersection(updatedKeys, propsKeys).length > 0) {
188
190
  locationDelta.propsChangedAt = atDate;
@@ -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;
@@ -429,5 +429,57 @@ describe("location", () => {
429
429
  assert.equal(recs[0]?.kindColor?.text, "#FFFFFF");
430
430
  assert.instanceOf(recs[0]?.kindColorChangedAt, Date);
431
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
+ });
432
484
  });
433
485
  });