tabletcommand-backend-models 7.4.99 → 7.4.100

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 (32) hide show
  1. package/build/index.js +8 -5
  2. package/build/index.js.map +1 -1
  3. package/build/models/cad-interface-meta-data.js +17 -0
  4. package/build/models/cad-interface-meta-data.js.map +1 -0
  5. package/build/models/department.js +11 -0
  6. package/build/models/department.js.map +1 -1
  7. package/build/test/cad-interface-meta-data.js +51 -0
  8. package/build/test/cad-interface-meta-data.js.map +1 -0
  9. package/build/types/cad-interface-meta-data.js +3 -0
  10. package/build/types/cad-interface-meta-data.js.map +1 -0
  11. package/build/types/department.js +2 -0
  12. package/build/types/department.js.map +1 -1
  13. package/cad-interface-metadata-plan.md +327 -0
  14. package/definitions/index.d.ts +9 -1
  15. package/definitions/index.d.ts.map +1 -1
  16. package/definitions/models/cad-interface-meta-data.d.ts +13 -0
  17. package/definitions/models/cad-interface-meta-data.d.ts.map +1 -0
  18. package/definitions/models/department.d.ts.map +1 -1
  19. package/definitions/test/cad-interface-meta-data.d.ts +2 -0
  20. package/definitions/test/cad-interface-meta-data.d.ts.map +1 -0
  21. package/definitions/types/cad-interface-meta-data.d.ts +8 -0
  22. package/definitions/types/cad-interface-meta-data.d.ts.map +1 -0
  23. package/definitions/types/department.d.ts +4 -0
  24. package/definitions/types/department.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/sc-33945-plan.md +189 -0
  27. package/src/index.ts +7 -0
  28. package/src/models/cad-interface-meta-data.ts +27 -0
  29. package/src/models/department.ts +12 -1
  30. package/src/test/cad-interface-meta-data.ts +53 -0
  31. package/src/types/cad-interface-meta-data.ts +9 -0
  32. package/src/types/department.ts +5 -0
@@ -0,0 +1,189 @@
1
+ # Plan — sc-33945: Update Endpoint to include "Can Approve Sharing Agreements" permission
2
+
3
+ **Story:** [sc-33945](https://app.shortcut.com/tabletcommand/story/33945) (chore)
4
+ **Parent:** [sc-33735](https://app.shortcut.com/tabletcommand/story/33735) — FE checkbox on Superuser User Details page
5
+ **Epic:** [Data Sharing Agreement (33722)](https://app.shortcut.com/tabletcommand/epic/33722)
6
+ **Iteration:** Sprint 2026/14 (2026-07-05 → 2026-07-19)
7
+ **Owner:** Kevin Yoshiyama
8
+
9
+ ## Goal
10
+
11
+ Extend the admin user-update API so a **superadmin** can grant/revoke a new boolean
12
+ permission `canApproveSharingAgreements` on a user, subject to guardrails, with the
13
+ change written to the searchable action log. This is the backend half; the parent
14
+ story (33735) is the frontend checkbox.
15
+
16
+ ## Acceptance criteria (from the story)
17
+
18
+ - [ ] API rejects `canApproveSharingAgreements: true` for non-admin target users with `400`.
19
+ - [ ] Non-superadmin API callers receive `403` when attempting to set this field.
20
+ - [ ] The user-detail `GET` response includes `canApproveSharingAgreements` for superadmin callers.
21
+ - [ ] Grant/revoke is written to the audit log with the acting superadmin's ID + timestamp.
22
+ - [ ] The action is searchable in the superadmin action log.
23
+ - [ ] BE unit tests complete.
24
+
25
+ ---
26
+
27
+ ## Current state (what I found in the code)
28
+
29
+ | Concern | Location |
30
+ |---|---|
31
+ | `GET /api/a1/admin/user/:userId` | [server-admin/src/routes/admin.ts:342](server-admin/src/routes/admin.ts) |
32
+ | `POST /api/a1/admin/user/:userId/:departmentId?` (the "update" endpoint) | [server-admin/src/routes/admin.ts:374](server-admin/src/routes/admin.ts) |
33
+ | Request-body decode / field allow-list | `decodeUpdateUser()` [server-admin/src/lib/domain.ts:4201](server-admin/src/lib/domain.ts) |
34
+ | Auth resolution (`isSuper`, `isAdmin`, …) | `decodeAuth()` [server-admin/src/lib/domain.ts:389](server-admin/src/lib/domain.ts) |
35
+ | Persist + restricted-field revert + action log | `updateUserForDepartment()` [server-admin/src/lib/store/index.ts:2010](server-admin/src/lib/store/index.ts) |
36
+ | GET data fetch | `getUserForDepartment()` [server-admin/src/lib/store/index.ts:~1961](server-admin/src/lib/store/index.ts) |
37
+ | Action-log write helper | `logAction()` / `models.ActionLog` [server-admin/src/lib/store/index.ts:418](server-admin/src/lib/store/index.ts) |
38
+ | Action-log message formatter | [server-admin/src/lib/actionLogMessages.ts:287](server-admin/src/lib/actionLogMessages.ts) |
39
+ | BE tests | [server-admin/src/test/routes/admin.ts](server-admin/src/test/routes/admin.ts) (`node:test` + `chai` + `node-mocks-http`) |
40
+ | **User model / schema (EXTERNAL)** | `tabletcommand-backend-models` `~7.4.94` — `src/models/user.ts`, `src/types/user.ts`, collection `sys_user` |
41
+
42
+ ### Key facts that shape the plan
43
+
44
+ 1. **The User model is an external npm package**, not in this repo. The Mongoose schema is
45
+ **strict by default** (no `strict: false`), so a field absent from the schema is silently
46
+ dropped on save. `canApproveSharingAgreements` **must be added to
47
+ `tabletcommand-backend-models` first**, published, then the dependency bumped here. This is
48
+ a hard prerequisite — without it, saves will no-op.
49
+
50
+ 2. **The existing pattern for privileged flags is a silent revert, not a 403.** For `admin` and
51
+ `canBlockIncidents`, a non-superadmin's edits are reverted to the previous value
52
+ ([store/index.ts:2039-2044](server-admin/src/lib/store/index.ts)) — no error is returned.
53
+ The story asks for an explicit **`403`**. See Decision 1.
54
+
55
+ 3. **Auth failures in these routes currently return `401`, not `403`** (see admin.ts:353/383).
56
+ The story says `403`. See Decision 1.
57
+
58
+ 4. **The endpoint is `POST` (not `PUT`) at `/user/:userId/:departmentId?`** — the story's
59
+ `api/a1/admin/user/` shorthand omits this. No route change needed.
60
+
61
+ 5. **Action logging is already generic.** `updateUserForDepartment` builds a `propertiesChanged`
62
+ array and writes one `ActionLog` (`action: "adminModifyUser"`) with `email: user.email`
63
+ (the actor) and `createdAt` timestamp. Adding a `canApproveSharingAgreements` diff to that
64
+ array satisfies the "audit log + searchable" criteria for free. Note the actor is recorded by
65
+ **email**, not `_id` — see Decision 2.
66
+
67
+ ---
68
+
69
+ ## Decisions (confirmed)
70
+
71
+ **Decision 1 — Return `403` for unauthorized writes. ✅ CONFIRMED.**
72
+ Return `403` when a non-superadmin includes `canApproveSharingAgreements` in the body. Implemented
73
+ as an early guard in the route so it doesn't disturb the existing revert behavior for other flags.
74
+ (This intentionally diverges from the surrounding `401` convention for auth failures — accepted.)
75
+
76
+ **Decision 2 — Log both email and `_id` in the audit log. ✅ CONFIRMED.**
77
+ Existing `adminModifyUser` logs store the actor as `email`. Record **both** the acting user's
78
+ `email` and `_id` on the action item.
79
+
80
+ **Decision 3 — "target user is admin" check basis.**
81
+ `canApproveSharingAgreements: true` is only allowed if the target is an admin. Because admin status
82
+ can change in the same request, validate against the **effective** admin value:
83
+ `userData.admin` if present in the body, else `previousUser.admin`. Also mirror the parent story's
84
+ rule: **if admin is being set false, force `canApproveSharingAgreements = false`.**
85
+
86
+ **Decision 4 — Include a descriptive `message` in error response bodies. ✅ CONFIRMED.**
87
+ The `400` (non-admin target) and `403` (non-superadmin caller) responses should return a `message`
88
+ field explaining the rejection, e.g. `{ ok: false, message: "..." }`, rather than bare
89
+ `{ ok: false }`.
90
+
91
+ ---
92
+
93
+ ## Implementation steps
94
+
95
+ ### Step 0 — External model change (prerequisite, separate repo)
96
+ In `tabletcommand-backend-models`:
97
+ - Add to `src/models/user.ts` (next to `canBlockIncidents`, ~line 123):
98
+ ```ts
99
+ canApproveSharingAgreements: {
100
+ type: Boolean,
101
+ default: false,
102
+ },
103
+ ```
104
+ - Add `canApproveSharingAgreements: boolean` to the `User` type in `src/types/user.ts` (~line 103).
105
+ - Add it to the test mock (`src/test/mock.ts`) if permission flags are enumerated there.
106
+ - Publish a new version; bump `server-admin/package.json` (`~7.4.94` → new) and `npm install`.
107
+
108
+ > If publishing a new models version is out of scope for this sprint, coordinate with whoever
109
+ > owns that package. Nothing below persists without it.
110
+
111
+ ### Step 1 — Route guard for superadmin-only write (`403`)
112
+ In `POST /user/:userId/:departmentId?` ([routes/admin.ts:374](server-admin/src/routes/admin.ts)),
113
+ after `decodeAuth`: if `req.body` contains the key `canApproveSharingAgreements` **and** `!isSuper`,
114
+ return `403` with `{ ok: false, message: "..." }` (Decision 4). Do this before
115
+ `decodeUpdateUser`/store call.
116
+
117
+ ### Step 2 — Preserve the field through decode
118
+ `decodeUpdateUser()` ([domain.ts:4201](server-admin/src/lib/domain.ts)) uses an `ignoredFields`
119
+ allow-list; `canApproveSharingAgreements` is **not** in it, so it passes through — no change needed.
120
+ Add a brief comment noting it's intentionally allowed (superadmin-gated upstream). Do **not** add it
121
+ to `ignoredFields`.
122
+
123
+ ### Step 3 — Validation + revert logic in the store
124
+ In `updateUserForDepartment()` ([store/index.ts:2010](server-admin/src/lib/store/index.ts)), after
125
+ `previousUser` is loaded (~line 2038):
126
+ - **400 guard:** if `userData.canApproveSharingAgreements === true`, compute effective admin
127
+ (`userData.admin ?? previousUser.admin`); if not truthy, reject with `400` and a descriptive
128
+ `message` in the body (Decision 4), e.g.
129
+ `{ ok: false, message: "canApproveSharingAgreements requires the target user to be an admin" }`.
130
+ Implement via a typed error the route maps to `400`, or short-circuit in the route before the
131
+ store call — either way the message must reach the body (the existing `catch` returns a bare
132
+ `{ ok: false }`, so adjust it or return earlier).
133
+ - **Cascade:** if effective admin is false, force `userData.canApproveSharingAgreements = false`
134
+ (mirrors parent-story rule and prevents orphaned permission).
135
+ - **Non-superadmin safety net:** extend the existing block at
136
+ [store/index.ts:2039-2044](server-admin/src/lib/store/index.ts) to also revert
137
+ `canApproveSharingAgreements` to `previousUser.canApproveSharingAgreements` when
138
+ `user.superuser !== true` (defense in depth behind the Step 1 route guard).
139
+
140
+ ### Step 4 — Audit log entry
141
+ In the `propertiesChanged` block (~[store/index.ts:2099](server-admin/src/lib/store/index.ts)),
142
+ mirroring the `admin` diff:
143
+ ```ts
144
+ if (foundUser.canApproveSharingAgreements !== userData.canApproveSharingAgreements) {
145
+ propertiesChanged.push({ canApproveSharingAgreements: userData.canApproveSharingAgreements });
146
+ }
147
+ ```
148
+ This flows into the existing `adminModifyUser` `ActionLog` (actor email + `createdAt`), making it
149
+ searchable. Record both acting `user.email` (already present) and `user._id` per Decision 2.
150
+ Confirm `formatAdminModifyUserMessage`
151
+ ([actionLogMessages.ts:287](server-admin/src/lib/actionLogMessages.ts)) renders the new key
152
+ acceptably (its generic loop already handles arbitrary keys).
153
+
154
+ ### Step 5 — GET response
155
+ `getUserForDepartment()` returns the full Mongoose doc, so once the schema field exists (Step 0)
156
+ the value is included automatically. **Verify** no projection/`select` or serializer strips it, and
157
+ that it's only meaningfully surfaced for superadmin callers (the field is superadmin-managed; the FE
158
+ in 33735 only renders it for superadmins). No code change expected — confirm with a test.
159
+
160
+ ### Step 6 — Backend unit tests
161
+ Add to [server-admin/src/test/routes/admin.ts](server-admin/src/test/routes/admin.ts), following
162
+ the existing `superUserTest` / `requestUser2` fixtures and `httpMocks` patterns:
163
+ 1. Superadmin sets `canApproveSharingAgreements: true` on an admin target → `200`; DB value `true`;
164
+ `ActionLog` row written with the diff.
165
+ 2. Set `true` on a **non-admin** target → `400`.
166
+ 3. **Non-superadmin** (regular admin) sends the field → `403`.
167
+ 4. Setting `admin: false` cascades `canApproveSharingAgreements` → `false`.
168
+ 5. `GET /user/:userId` as superadmin includes the field.
169
+ 6. Revoke (`true` → `false`) writes an audit-log diff.
170
+
171
+ Run: `gulp test` (or the `tsx --test` next-suite) in `server-admin`.
172
+
173
+ ---
174
+
175
+ ## Out of scope
176
+ - Frontend checkbox / Data Sharing tab visibility — parent story [sc-33735](https://app.shortcut.com/tabletcommand/story/33735).
177
+ - Bulk-edit endpoint (`/user-bulk-edit`) — **out of scope for this story** (confirmed).
178
+
179
+ ## Risks / open questions
180
+ - **Models package release** (Step 0) is the critical path and lives in another repo.
181
+ - Whether the 400 error message should appear in the response body (current pattern returns
182
+ `{ ok: false }` only).
183
+
184
+ ## Suggested commits / PR
185
+ 1. (models repo) add `canApproveSharingAgreements` to schema + type + mock; publish.
186
+ 2. `server-admin`: bump models dep; route `403` guard; store 400/cascade/revert; audit-log diff.
187
+ 3. `server-admin`: unit tests.
188
+
189
+ Branch suggestion: `feature/sc-33945/can-approve-sharing-agreements`.
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ async function wireModels(mongoose: MongooseModule) {
35
35
  CADIncident: await getModel(import("./models/cad-incident")),
36
36
  CADIncidentBlock: await getModel(import("./models/cad-incident-block")),
37
37
  CADIncidentStream: await getModel(import("./models/cad-incident-stream")),
38
+ CADInterfaceMetaData: await getModel(import("./models/cad-interface-meta-data")),
38
39
  CADSimulation: await getModel(import("./models/cad-simulation")),
39
40
  CADStatus: await getModel(import("./models/cad-status")),
40
41
  CADStatusMap: await getModel(import("./models/cad-status-map")),
@@ -100,6 +101,7 @@ export { BeaconLog, BeaconLogModel } from "./models/beacon-log";
100
101
  export { CADIncident, CADIncidentModel } from "./models/cad-incident";
101
102
  export { CADIncidentBlock, CADIncidentBlockModel } from "./models/cad-incident-block";
102
103
  export { CADIncidentStream, CADIncidentStreamModel } from "./models/cad-incident-stream";
104
+ export { CADInterfaceMetaData, CADInterfaceMetaDataModel } from "./models/cad-interface-meta-data";
103
105
  export { CADSimulation, CADSimulationModel } from "./models/cad-simulation";
104
106
  export { CADStatus, CADStatusModel } from "./models/cad-status";
105
107
  export { CADStatusMap, CADStatusMapModel } from "./models/cad-status-map";
@@ -154,6 +156,7 @@ export { UserRegistration, UserRegistrationModel } from "./models/user-registrat
154
156
  export { ValidationReport, ValidationReportModel } from "./models/validation-report";
155
157
 
156
158
  export * from "./helpers";
159
+ export { IntegrationTypes } from "./types/department";
157
160
 
158
161
  export * from "./checklist-item-event-message";
159
162
 
@@ -162,7 +165,11 @@ export type {
162
165
  ForwardingConnectionType,
163
166
  ForwardingConfigType,
164
167
  ForwardFieldsType,
168
+ IntegrationType,
165
169
  } from "./types/department";
170
+ export type {
171
+ CADInterfaceMetaDataType,
172
+ } from "./types/cad-interface-meta-data";
166
173
 
167
174
  // Export default vars
168
175
  export {
@@ -0,0 +1,27 @@
1
+ import { Model, Schema } from "mongoose";
2
+ import { MongooseModule } from "../helpers";
3
+ import { CADInterfaceMetaDataType } from "../types/cad-interface-meta-data";
4
+
5
+ export interface CADInterfaceMetaData extends CADInterfaceMetaDataType {}
6
+
7
+ export default async function CADInterfaceMetaDataModule(mongoose: MongooseModule) {
8
+ const modelSchema = new Schema<CADInterfaceMetaDataType>({
9
+ systemType: { type: String, required: true, trim: true },
10
+ }, {
11
+ autoIndex: false,
12
+ timestamps: true, // manages createdAt / updatedAt automatically (per story)
13
+ toJSON: { virtuals: true, versionKey: false },
14
+ });
15
+
16
+ // Uniqueness so the same CAD system can't be catalogued twice.
17
+ modelSchema.index({ systemType: 1 }, { unique: true });
18
+
19
+ return mongoose.model<CADInterfaceMetaData>(
20
+ "CADInterfaceMetaData",
21
+ modelSchema,
22
+ "massive_cad_interface_meta_data",
23
+ { overwriteModels: true }
24
+ );
25
+ }
26
+
27
+ export interface CADInterfaceMetaDataModel extends Model<CADInterfaceMetaData> {}
@@ -50,6 +50,7 @@ import {
50
50
  IntterraConnectionType,
51
51
  IntterraFieldsType,
52
52
  LicensingType,
53
+ IntegrationTypes,
53
54
  ManagedIncidentMode,
54
55
  ManagedIncidentModeItem,
55
56
  ManagedIncidentPriority,
@@ -1741,7 +1742,7 @@ export default async function DepartmentModule(mongoose: MongooseModule) {
1741
1742
  });
1742
1743
 
1743
1744
  // Main schema
1744
- const modelSchema = new Schema<DepartmentType>({
1745
+ const modelSchema = new Schema<Department>({
1745
1746
  _id: {
1746
1747
  type: Schema.Types.ObjectId,
1747
1748
  auto: true,
@@ -2080,6 +2081,16 @@ export default async function DepartmentModule(mongoose: MongooseModule) {
2080
2081
  ref: "Agency",
2081
2082
  default: [],
2082
2083
  }],
2084
+ cadInterfaceMetaDataId: {
2085
+ type: mongoose.Schema.Types.ObjectId,
2086
+ ref: "CADInterfaceMetaData",
2087
+ default: null,
2088
+ },
2089
+ integrationType: {
2090
+ type: String,
2091
+ enum: [...IntegrationTypes, ""],
2092
+ default: "",
2093
+ },
2083
2094
 
2084
2095
  // Signup
2085
2096
  signupKey: {
@@ -0,0 +1,53 @@
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("CADInterfaceMetaData", function() {
8
+ let models: m.BackendModels, mongoose: m.MongooseModule;
9
+ let testItem: Partial<m.CADInterfaceMetaData>;
10
+ beforeEach(async function() {
11
+ const c = await m.connect(config.url);
12
+ models = c.models;
13
+ mongoose = c.mongoose;
14
+
15
+ const mock = mockModule({
16
+ mongoose
17
+ });
18
+ testItem = {
19
+ systemType: "Tritech Inform"
20
+ };
21
+ await mock.beforeEach();
22
+ });
23
+ afterEach(async function() {
24
+ await mongoose.disconnect();
25
+ });
26
+
27
+ it("is saved", async function() {
28
+ const item = new models.CADInterfaceMetaData(testItem);
29
+ const sut = await item.save();
30
+
31
+ assert.isNotNull(sut._id);
32
+ assert.equal(sut.systemType, testItem.systemType);
33
+ assert.isNotNull(sut.createdAt);
34
+ assert.isNotNull(sut.updatedAt);
35
+ });
36
+
37
+ it("enforces unique systemType index", async function() {
38
+ const item1 = new models.CADInterfaceMetaData(testItem);
39
+ await item1.save();
40
+
41
+ const item2 = new models.CADInterfaceMetaData({
42
+ systemType: testItem.systemType
43
+ });
44
+ try {
45
+ await item2.save();
46
+ assert.fail("Should have failed unique index constraint");
47
+ } catch (err: any) {
48
+ assert.isNotNull(err);
49
+ // Mongo unique index error code is 11000
50
+ assert.equal(err.code, 11000);
51
+ }
52
+ });
53
+ });
@@ -0,0 +1,9 @@
1
+ import { Types } from "mongoose";
2
+
3
+ export interface CADInterfaceMetaDataType {
4
+ _id: Types.ObjectId;
5
+ systemType: string;
6
+ // timestamps managed by mongoose:
7
+ createdAt?: Date;
8
+ updatedAt?: Date;
9
+ }
@@ -9,6 +9,9 @@ import {
9
9
  import { ReportNumberSchemaType } from "./common-incident";
10
10
  import { GeoPolygon } from "./geojson";
11
11
 
12
+ export const IntegrationTypes = ["TC", "Vendor", "Hybrid"] as const;
13
+ export type IntegrationType = typeof IntegrationTypes[number];
14
+
12
15
  export interface WSFeedConnectionOptionsType {
13
16
  enabled: boolean;
14
17
  url: string;
@@ -588,6 +591,8 @@ export interface DepartmentType {
588
591
  addUserInstructions: string,
589
592
  adsb: ADSBConfigType,
590
593
  agencyIds: Types.ObjectId[],
594
+ cadInterfaceMetaDataId?: Types.ObjectId | null,
595
+ integrationType?: IntegrationType | "",
591
596
  allowShareWith3rdParties: boolean,
592
597
  apikey: string,
593
598
  arcGISMapsEnabled: boolean,