@7365admin1/core 3.48.1-staging.158 → 3.48.1-staging.159
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.
- package/.changeset/work-order-site-scope.md +77 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +39 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +39 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/harness.mjs +26 -1
- package/test/e2e/work-order-scope.e2e.test.mjs +536 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/core": minor
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Scope the work order routes to the estate the job was raised at, and take the
|
|
6
|
+
author's identity from the session.
|
|
7
|
+
|
|
8
|
+
A work order is a job at one estate: the subject, the description, the block and
|
|
9
|
+
unit it sits in, the attachments, the assigned provider, and the name and
|
|
10
|
+
signature captured when it is signed off. Every route on `/api/work-orders`
|
|
11
|
+
carried `requireAuth` and nothing more, and `work-order.controller.ts` held no
|
|
12
|
+
authorization identifier of any kind. `requireAuth` proves a session exists; it
|
|
13
|
+
makes no organisation or site decision. So being signed in ANYWHERE on the
|
|
14
|
+
platform was enough to read another client's jobs, rewrite them, drive them
|
|
15
|
+
between columns, sign them off in a stranger's name, or delete them.
|
|
16
|
+
|
|
17
|
+
`DELETE /:id` is the sharp one, and for the same reason it was sharp on
|
|
18
|
+
`/api/feedbacks`: **it does not write to the v1 table at all.** `deleteWorkOrder`
|
|
19
|
+
in `work-order.repo.ts` updates `workOrders2Collection` — `work-orders2`, the
|
|
20
|
+
authoritative table every client actually reads. A guard written against the v1
|
|
21
|
+
row would have been the wrong guard, so this one is written against the row the
|
|
22
|
+
write really touches. `DELETE /:id` and `PUT /:id` are also the only two routes
|
|
23
|
+
on this mount with a live caller: `layer-common`'s
|
|
24
|
+
`useWorkOrder().deleteWorkOrder` and `updateWorkOrder`, behind the shared
|
|
25
|
+
`WorkOrder/Main.vue` and `WorkOrder/Detail.vue` screens that every web app draws.
|
|
26
|
+
|
|
27
|
+
All seven handlers now decide before any work is done:
|
|
28
|
+
|
|
29
|
+
- `GET /site/:site/status/:status` names the site in the URL, so it is checked
|
|
30
|
+
directly with `requireSiteReach`.
|
|
31
|
+
- `GET /:id`, `PUT /:id`, `PUT /:id/:status` and `PATCH /:id/completed` name only
|
|
32
|
+
a record, so the site is read off the STORED row through the new
|
|
33
|
+
`requireWorkOrderReach`. A `site` in the caller's own body or query cannot
|
|
34
|
+
stand in for it.
|
|
35
|
+
- `DELETE /:id` uses `requireWorkOrder2Reach`, against the `work-orders2` row.
|
|
36
|
+
- `POST /` reads the destination from the body.
|
|
37
|
+
|
|
38
|
+
Two readers were needed, not one, because the tables are not the same shape: v1
|
|
39
|
+
`work-orders` rows keep the site at `metadata.site`, `work-orders2` rows keep it
|
|
40
|
+
at the top level. Both are new raw readers on the repository —
|
|
41
|
+
`getRawWorkOrderById` and `getRawWorkOrder2ById`. The existing `getWorkOrderById`
|
|
42
|
+
is CACHED and shaped for the screen, so a reach check built on it would have
|
|
43
|
+
decided who may touch a job from a cache entry.
|
|
44
|
+
|
|
45
|
+
Two further defects fixed in the same pass:
|
|
46
|
+
|
|
47
|
+
1. **Identity from a cookie the caller writes.** `POST /` parsed `createdBy` out
|
|
48
|
+
of the raw `Cookie` header and never compared it to the session, so a job
|
|
49
|
+
could be raised in somebody else's name. It comes from `callerId(req)` now.
|
|
50
|
+
That also closes a latent crash: `cookies?.["user"].toString()`
|
|
51
|
+
short-circuits only on `cookies` being nullish, so any request carrying
|
|
52
|
+
cookies but no `user` cookie threw a TypeError.
|
|
53
|
+
2. **A body that names the site twice, with unequal weight.** `POST /` accepts
|
|
54
|
+
`metadata.site` AND a top-level `site`, and `work-order.service.ts` copies the
|
|
55
|
+
top-level one OVER `metadata.site` (`if (value.site) value.metadata.site =
|
|
56
|
+
value.site`). A caller could therefore name their own estate in `metadata`
|
|
57
|
+
while the row landed at somebody else's. The guard reads them in the order the
|
|
58
|
+
service resolves them.
|
|
59
|
+
|
|
60
|
+
`PUT /:id` is a write that can MOVE a record — the repository `$set`s whatever
|
|
61
|
+
the body carries, including `site` — so the DESTINATION is checked as well as the
|
|
62
|
+
origin. Without that, a legacy row carrying no `metadata.site` could be given a
|
|
63
|
+
top-level `site` of the caller's own and become theirs.
|
|
64
|
+
|
|
65
|
+
Each guard is placed AFTER the handler's own Joi validation, so a malformed id
|
|
66
|
+
still answers 400 exactly as before. A row that does not exist has no site, and
|
|
67
|
+
`requireSiteReach` refuses a missing site, so an id matching nothing is refused
|
|
68
|
+
rather than falling through.
|
|
69
|
+
|
|
70
|
+
The rule is the existing one, `requireSiteReach` -> `entitleSite`, already used
|
|
71
|
+
by `/api/feedbacks`, `/api/manpower-monitoring`, `/api/attendances`,
|
|
72
|
+
`/api/remarks`, `/api/vehicles`, `/api/documents` and `/api/incident-reports`.
|
|
73
|
+
The `customer.sites` branch is required, not incidental: the contracted provider
|
|
74
|
+
that does this work holds no membership in the estate's own organisation.
|
|
75
|
+
|
|
76
|
+
`/api/work-orders2` is a different mount, lives in API-core's own controller, and
|
|
77
|
+
is not touched here.
|
package/dist/index.d.ts
CHANGED
|
@@ -1797,6 +1797,8 @@ declare function useWorkOrderRepo(): {
|
|
|
1797
1797
|
category?: string | undefined;
|
|
1798
1798
|
}) => Promise<{}>;
|
|
1799
1799
|
getWorkOrderById: (_id: string | ObjectId) => Promise<{}>;
|
|
1800
|
+
getRawWorkOrderById: (_id: string | ObjectId) => Promise<mongodb.WithId<bson.Document> | null>;
|
|
1801
|
+
getRawWorkOrder2ById: (_id: string | ObjectId) => Promise<mongodb.WithId<bson.Document> | null>;
|
|
1800
1802
|
updateWorkOrder: (_id: string | ObjectId, value: TWorkOrderUpdate) => Promise<number>;
|
|
1801
1803
|
updateWorkOrderStatus: (_id: string | ObjectId, value: TWorkOrderUpdateStatus) => Promise<number>;
|
|
1802
1804
|
updateWorkOrderCreatedByName: (_id: string | ObjectId, value: string | ObjectId, session?: ClientSession) => Promise<number>;
|
|
@@ -1818,6 +1820,55 @@ declare function useWorkOrderService(): {
|
|
|
1818
1820
|
}) => Promise<{}>;
|
|
1819
1821
|
};
|
|
1820
1822
|
|
|
1823
|
+
/**
|
|
1824
|
+
* Who may see, or change, a site's work orders.
|
|
1825
|
+
*
|
|
1826
|
+
* A work order is a job raised at one estate - the subject, the description,
|
|
1827
|
+
* the block and unit it sits in, the attachments, the assigned provider, and
|
|
1828
|
+
* the name and signature captured when it is signed off. Every route on
|
|
1829
|
+
* `/api/work-orders` carried `requireAuth` and nothing more, and this
|
|
1830
|
+
* controller held no authorization identifier of any kind, so being signed in
|
|
1831
|
+
* anywhere on the platform was enough to read another client's jobs, edit
|
|
1832
|
+
* them, drive their status, sign them off in a stranger's name, or delete them.
|
|
1833
|
+
*
|
|
1834
|
+
* `DELETE /:id` is the sharp one, and for the same reason it was sharp on
|
|
1835
|
+
* `/api/feedbacks`: it does not write to the v1 table at all. `deleteWorkOrder`
|
|
1836
|
+
* in the repository updates `workOrders2Collection` - `work-orders2`, the
|
|
1837
|
+
* authoritative table every client actually reads. A guard written against the
|
|
1838
|
+
* v1 row would have been the wrong guard, so this one is written against the
|
|
1839
|
+
* row the write really touches.
|
|
1840
|
+
*
|
|
1841
|
+
* The estate is the scope. `requireSiteReach` is the rule `/api/feedbacks`,
|
|
1842
|
+
* `/api/manpower-monitoring`, `/api/attendances`, `/api/remarks`,
|
|
1843
|
+
* `/api/vehicles`, `/api/documents` and `/api/incident-reports` already use, so
|
|
1844
|
+
* the contracted provider that does this work reaches the estate through
|
|
1845
|
+
* `customer.sites` and needs no membership in its organisation.
|
|
1846
|
+
*
|
|
1847
|
+
* - `GET /site/:site/status/:status` names the site in the URL, so it is
|
|
1848
|
+
* checked before the read.
|
|
1849
|
+
* - The five record routes name only a record, so the site is read off the
|
|
1850
|
+
* STORED row and a `site` in the caller's own body cannot stand in for it.
|
|
1851
|
+
* - `POST /` names the site in the body - possibly twice. `metadata.site`
|
|
1852
|
+
* and a top-level `site` are not equal: the service copies the top-level one
|
|
1853
|
+
* OVER `metadata.site`, so the guard reads them in that order and a caller
|
|
1854
|
+
* cannot name their own site at the top level while the row lands somewhere
|
|
1855
|
+
* else. A create naming no site at all is refused.
|
|
1856
|
+
* - `POST /` also took the author's identity from a `user` cookie the caller
|
|
1857
|
+
* writes and never compared it to the session, so a job could be raised in
|
|
1858
|
+
* somebody else's name. It comes from `callerId(req)` now - which also
|
|
1859
|
+
* removes a latent crash, because `cookies?.["user"].toString()`
|
|
1860
|
+
* short-circuits only on `cookies` being nullish and threw on any request
|
|
1861
|
+
* that carried cookies but no `user` cookie.
|
|
1862
|
+
* - `PUT /:id` accepts `site` and `organization` in the body and the
|
|
1863
|
+
* repository `$set`s them, so where the body names a destination it is
|
|
1864
|
+
* checked as well as the origin. That closes the one way the fallback below
|
|
1865
|
+
* could be walked: a legacy row with no `metadata.site` would otherwise let
|
|
1866
|
+
* a caller write a top-level `site` of their own and make the row reachable.
|
|
1867
|
+
*
|
|
1868
|
+
* Two readers, not one, because the two tables are not the same shape: v1
|
|
1869
|
+
* `work_orders` rows keep the site at `metadata.site`, `work-orders2` rows keep
|
|
1870
|
+
* it at the top level.
|
|
1871
|
+
*/
|
|
1821
1872
|
declare function useWorkOrderController(): {
|
|
1822
1873
|
createWorkOrder: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
1823
1874
|
getWorkOrders: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -7855,6 +7855,22 @@ function useWorkOrderRepo() {
|
|
|
7855
7855
|
throw error;
|
|
7856
7856
|
}
|
|
7857
7857
|
}
|
|
7858
|
+
async function getRawWorkOrderById(_id) {
|
|
7859
|
+
try {
|
|
7860
|
+
_id = new import_mongodb6.ObjectId(_id);
|
|
7861
|
+
} catch (error) {
|
|
7862
|
+
throw new import_node_server_utils7.BadRequestError("Invalid work order ID format.");
|
|
7863
|
+
}
|
|
7864
|
+
return await collection.findOne({ _id });
|
|
7865
|
+
}
|
|
7866
|
+
async function getRawWorkOrder2ById(_id) {
|
|
7867
|
+
try {
|
|
7868
|
+
_id = new import_mongodb6.ObjectId(_id);
|
|
7869
|
+
} catch (error) {
|
|
7870
|
+
throw new import_node_server_utils7.BadRequestError("Invalid work order ID format.");
|
|
7871
|
+
}
|
|
7872
|
+
return await workOrders2Collection.findOne({ _id });
|
|
7873
|
+
}
|
|
7858
7874
|
async function updateWorkOrder(_id, value) {
|
|
7859
7875
|
try {
|
|
7860
7876
|
_id = new import_mongodb6.ObjectId(_id);
|
|
@@ -8046,6 +8062,8 @@ function useWorkOrderRepo() {
|
|
|
8046
8062
|
createWorkOrder,
|
|
8047
8063
|
getWorkOrders,
|
|
8048
8064
|
getWorkOrderById,
|
|
8065
|
+
getRawWorkOrderById,
|
|
8066
|
+
getRawWorkOrder2ById,
|
|
8049
8067
|
updateWorkOrder,
|
|
8050
8068
|
updateWorkOrderStatus,
|
|
8051
8069
|
updateWorkOrderCreatedByName,
|
|
@@ -32128,17 +32146,23 @@ function useWorkOrderController() {
|
|
|
32128
32146
|
const { createWorkOrder: _createWorkOrder, getWorkOrders: _getWorkOrders } = useWorkOrderService();
|
|
32129
32147
|
const {
|
|
32130
32148
|
getWorkOrderById: _getWorkOrderById,
|
|
32149
|
+
getRawWorkOrderById: _getRawWorkOrderById,
|
|
32150
|
+
getRawWorkOrder2ById: _getRawWorkOrder2ById,
|
|
32131
32151
|
updateWorkOrder: _updateWorkOrder,
|
|
32132
32152
|
updateWorkOrderStatus: _updateWorkOrderStatus,
|
|
32133
32153
|
updateWorkOrderToCompleted: _updateWorkOrderToCompleted,
|
|
32134
32154
|
deleteWorkOrder: _deleteWorkOrder
|
|
32135
32155
|
} = useWorkOrderRepo();
|
|
32156
|
+
async function requireWorkOrderReach(req, _id) {
|
|
32157
|
+
const row = await _getRawWorkOrderById(_id);
|
|
32158
|
+
await requireSiteReach(req, row?.metadata?.site ?? row?.site);
|
|
32159
|
+
}
|
|
32160
|
+
async function requireWorkOrder2Reach(req, _id) {
|
|
32161
|
+
const row = await _getRawWorkOrder2ById(_id);
|
|
32162
|
+
await requireSiteReach(req, row?.site ?? row?.metadata?.site);
|
|
32163
|
+
}
|
|
32136
32164
|
async function createWorkOrder(req, res, next) {
|
|
32137
|
-
const
|
|
32138
|
-
(acc, [key, value]) => ({ ...acc, [key]: value }),
|
|
32139
|
-
{}
|
|
32140
|
-
);
|
|
32141
|
-
const createdBy = cookies?.["user"].toString() ?? "";
|
|
32165
|
+
const createdBy = callerId(req);
|
|
32142
32166
|
const payload = { ...req.body, createdBy };
|
|
32143
32167
|
const { error } = workOrderSchema.validate({ ...payload });
|
|
32144
32168
|
if (error) {
|
|
@@ -32148,6 +32172,7 @@ function useWorkOrderController() {
|
|
|
32148
32172
|
}
|
|
32149
32173
|
const fullHost = req.headers.origin;
|
|
32150
32174
|
try {
|
|
32175
|
+
await requireSiteReach(req, payload.site || payload.metadata?.site);
|
|
32151
32176
|
const workOrder = await _createWorkOrder(payload, fullHost);
|
|
32152
32177
|
res.status(201).json({
|
|
32153
32178
|
message: "Successfully created work order.",
|
|
@@ -32188,6 +32213,7 @@ function useWorkOrderController() {
|
|
|
32188
32213
|
const serviceProvider = req.query.serviceProvider ?? "";
|
|
32189
32214
|
const category = req.query.category ?? "";
|
|
32190
32215
|
try {
|
|
32216
|
+
await requireSiteReach(req, site);
|
|
32191
32217
|
const data = await _getWorkOrders({
|
|
32192
32218
|
search,
|
|
32193
32219
|
page,
|
|
@@ -32215,6 +32241,7 @@ function useWorkOrderController() {
|
|
|
32215
32241
|
return;
|
|
32216
32242
|
}
|
|
32217
32243
|
try {
|
|
32244
|
+
await requireWorkOrderReach(req, _id);
|
|
32218
32245
|
const data = await _getWorkOrderById(_id);
|
|
32219
32246
|
res.json(data);
|
|
32220
32247
|
return;
|
|
@@ -32246,6 +32273,10 @@ function useWorkOrderController() {
|
|
|
32246
32273
|
return;
|
|
32247
32274
|
}
|
|
32248
32275
|
try {
|
|
32276
|
+
await requireWorkOrderReach(req, _id);
|
|
32277
|
+
if (payload.site) {
|
|
32278
|
+
await requireSiteReach(req, payload.site);
|
|
32279
|
+
}
|
|
32249
32280
|
await _updateWorkOrder(_id, payload);
|
|
32250
32281
|
res.json({ message: "Successfully updated work order." });
|
|
32251
32282
|
return;
|
|
@@ -32269,6 +32300,7 @@ function useWorkOrderController() {
|
|
|
32269
32300
|
const _id = req.params.id;
|
|
32270
32301
|
const status = req.params.status;
|
|
32271
32302
|
try {
|
|
32303
|
+
await requireWorkOrderReach(req, _id);
|
|
32272
32304
|
await _updateWorkOrderStatus(_id, { status });
|
|
32273
32305
|
res.json({ message: "Successfully updated work order status." });
|
|
32274
32306
|
return;
|
|
@@ -32294,6 +32326,7 @@ function useWorkOrderController() {
|
|
|
32294
32326
|
return;
|
|
32295
32327
|
}
|
|
32296
32328
|
try {
|
|
32329
|
+
await requireWorkOrderReach(req, _id);
|
|
32297
32330
|
await _updateWorkOrderToCompleted(_id, payload);
|
|
32298
32331
|
res.json({ message: "Successfully updated work order to completed." });
|
|
32299
32332
|
return;
|
|
@@ -32312,6 +32345,7 @@ function useWorkOrderController() {
|
|
|
32312
32345
|
return;
|
|
32313
32346
|
}
|
|
32314
32347
|
try {
|
|
32348
|
+
await requireWorkOrder2Reach(req, _id);
|
|
32315
32349
|
await _deleteWorkOrder(_id);
|
|
32316
32350
|
res.json({ message: "Successfully deleted work order." });
|
|
32317
32351
|
return;
|