@plusscommunities/pluss-maintenance-aws-forms 2.1.43 → 2.1.45

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/feature.config.js CHANGED
@@ -212,6 +212,7 @@ exports.serverless = {
212
212
  { name: "jobNo", type: "N" },
213
213
  { name: "userID", type: "S" },
214
214
  { name: "AssigneeId", type: "S" },
215
+ { name: "status", type: "S" },
215
216
  ],
216
217
  id: "id",
217
218
  indexes: [
@@ -247,6 +248,13 @@ exports.serverless = {
247
248
  { name: "AssigneeId", type: "RANGE" },
248
249
  ],
249
250
  },
251
+ {
252
+ name: "MaintenanceSiteStatusIndex",
253
+ keys: [
254
+ { name: "site", type: "HASH" },
255
+ { name: "status", type: "RANGE" },
256
+ ],
257
+ },
250
258
  ],
251
259
  },
252
260
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plusscommunities/pluss-maintenance-aws-forms",
3
- "version": "2.1.43",
3
+ "version": "2.1.45",
4
4
  "description": "Extension package to enable maintenance on Pluss Communities Platform",
5
5
  "scripts": {
6
6
  "gc": "node ../../tools/gc ./",
@@ -3,169 +3,244 @@ const getSessionUserFromReqAuthKey = require("@plusscommunities/pluss-core-aws/h
3
3
  const validateMasterAuth = require("@plusscommunities/pluss-core-aws/helper/auth/validateMasterAuth");
4
4
  const validateSiteAccess = require("@plusscommunities/pluss-core-aws/helper/auth/validateSiteAccess");
5
5
  const indexQuery = require("@plusscommunities/pluss-core-aws/db/common/indexQuery");
6
+ const indexQueryRecursive = require("@plusscommunities/pluss-core-aws/db/common/indexQueryRecursive");
6
7
  const { values } = require("../values.config");
7
8
 
8
9
  // Normalise legacy/null status to "Open" (null = very old jobs, "Unassigned" = pre-rename)
9
10
  const STATUS_OPEN = "Open";
10
11
  const normalizeStatus = (status) =>
11
- !status || status === "Unassigned" ? STATUS_OPEN : status;
12
-
13
- // HACK: Lax "complete" match handles minor label variations across sites
14
- const isCompleted = (status) =>
15
- status && status.toLowerCase().includes("complete");
12
+ !status || status === "Unassigned" ? STATUS_OPEN : status;
16
13
 
17
14
  // Priority defaults to "Low" when not set (not set on creation)
18
15
  const DEFAULT_PRIORITY = "Low";
19
16
  const normalizePriority = (priority) =>
20
- priority == null ? DEFAULT_PRIORITY : priority;
17
+ priority == null ? DEFAULT_PRIORITY : priority;
21
18
 
22
19
  // Minimum number of filtered results to return before stopping.
23
20
  // Because DynamoDB pages are unfiltered and we filter after query,
24
21
  // a single page may yield very few matching results. We keep
25
22
  // fetching pages until we have this many items to return.
26
- const MIN_RESULT_COUNT = 50;
23
+ const MIN_RESULT_COUNT = 250;
24
+
25
+ // When filters are active, fetch all matching results so the
26
+ // client receives a complete set rather than a truncated page.
27
+ const MIN_RESULT_COUNT_FILTERED = Infinity;
28
+
29
+ const hasActiveFilters = (qParams) =>
30
+ qParams.status ||
31
+ qParams.priority ||
32
+ qParams.type ||
33
+ qParams.search ||
34
+ qParams.startTime ||
35
+ qParams.endTime;
27
36
 
28
37
  /**
29
38
  * Apply all post-query filters to a set of jobs.
30
39
  * Extracted so the same logic runs on every auto-fill page.
31
40
  */
32
41
  const filterJobs = (jobs, qParams, authorised, assigneeTracking, userId) => {
33
- let filtered = jobs;
34
-
35
- if (qParams.status) {
36
- if (qParams.status === "Incomplete") {
37
- filtered = filtered.filter((j) => !isCompleted(normalizeStatus(j.status)));
38
- } else {
39
- filtered = filtered.filter((j) => qParams.status.includes(normalizeStatus(j.status)));
40
- }
41
- }
42
-
43
- if (qParams.priority) {
44
- filtered = filtered.filter((j) => qParams.priority.includes(normalizePriority(j.priority)));
45
- }
46
-
47
- if (qParams.type) {
48
- filtered = filtered.filter((j) => qParams.type.includes(j.type));
49
- }
50
-
51
- if (!authorised && assigneeTracking) {
52
- filtered = filtered.filter((j) => j.AssigneeId === userId || j.userID === userId);
53
- }
54
-
55
- if (qParams.startTime) {
56
- const startTime = parseInt(qParams.startTime, 10);
57
- filtered = filtered.filter((j) => j.createdUnix >= startTime);
58
- }
59
- if (qParams.endTime) {
60
- const endTime = parseInt(qParams.endTime, 10);
61
- filtered = filtered.filter((j) => j.createdUnix <= endTime);
62
- }
63
-
64
- if (qParams.search) {
65
- const searchLower = qParams.search.toLowerCase();
66
- filtered = filtered.filter((j) => {
67
- if (j.jobId && j.jobId === qParams.search) return true;
68
- if (j.room && j.room.toLowerCase().indexOf(searchLower) > -1) return true;
69
- if (j.title && j.title.toLowerCase().indexOf(searchLower) > -1) return true;
70
- return false;
71
- });
72
- }
73
-
74
- return filtered;
42
+ let filtered = jobs;
43
+
44
+ if (qParams.status) {
45
+ filtered = filtered.filter((j) =>
46
+ qParams.status.includes(normalizeStatus(j.status)),
47
+ );
48
+ }
49
+
50
+ if (qParams.priority) {
51
+ filtered = filtered.filter((j) =>
52
+ qParams.priority.includes(normalizePriority(j.priority)),
53
+ );
54
+ }
55
+
56
+ if (qParams.type) {
57
+ filtered = filtered.filter((j) => qParams.type.includes(j.type));
58
+ }
59
+
60
+ if (!authorised && assigneeTracking) {
61
+ filtered = filtered.filter(
62
+ (j) => j.AssigneeId === userId || j.userID === userId,
63
+ );
64
+ }
65
+
66
+ if (qParams.startTime) {
67
+ const startTime = parseInt(qParams.startTime, 10);
68
+ filtered = filtered.filter((j) => j.createdUnix >= startTime);
69
+ }
70
+ if (qParams.endTime) {
71
+ const endTime = parseInt(qParams.endTime, 10);
72
+ filtered = filtered.filter((j) => j.createdUnix <= endTime);
73
+ }
74
+
75
+ if (qParams.search) {
76
+ const searchLower = qParams.search.toLowerCase();
77
+ filtered = filtered.filter((j) => {
78
+ if (j.jobId && j.jobId === qParams.search) return true;
79
+ if (j.room && j.room.toLowerCase().indexOf(searchLower) > -1) return true;
80
+ if (j.title && j.title.toLowerCase().indexOf(searchLower) > -1)
81
+ return true;
82
+ return false;
83
+ });
84
+ }
85
+
86
+ return filtered;
75
87
  };
76
88
 
77
89
  module.exports = async (event) => {
78
- const qParams = event.queryStringParameters;
79
- const logId = log("getRequests", "Params", qParams);
80
-
81
- // insufficient input
82
- if (!qParams.site) {
83
- return { status: 422, data: { error: "Insufficient input" } };
84
- }
85
- log("getRequests", "SufficientInput", true, logId);
86
-
87
- // no access to site
88
- const valid = await validateSiteAccess(event, qParams.site);
89
- log("getRequests", "valid", valid, logId);
90
- if (!valid) {
91
- return { status: 403, data: { error: "Not authorised" } };
92
- }
93
-
94
- // check auth level to determine whether to fetch all requests or only matching requests
95
- const authorised = await validateMasterAuth(
96
- event,
97
- values.permissionMaintenanceTracking,
98
- qParams.site
99
- );
100
- let assigneeTracking = false;
101
- if (!authorised) {
102
- assigneeTracking = await validateMasterAuth(
103
- event,
104
- values.permissionMaintenanceAssignment,
105
- qParams.site
106
- );
107
- }
108
-
109
- log("getRequests", "authorised", authorised, logId);
110
- const userId = authorised ? null : await getSessionUserFromReqAuthKey(event);
111
-
112
- log("getRequests", "userId", userId, logId);
113
-
114
- const query =
115
- authorised || assigneeTracking
116
- ? {
117
- IndexName: "MaintenanceSiteIndex",
118
- KeyConditionExpression: "site = :site",
119
- ExpressionAttributeValues: {
120
- ":site": qParams.site,
121
- },
122
- }
123
- : {
124
- IndexName: "MaintenanceSiteUserIdIndex",
125
- KeyConditionExpression: "site = :site AND userID = :userId",
126
- ExpressionAttributeValues: {
127
- ":site": qParams.site,
128
- ":userId": userId,
129
- },
130
- };
131
-
132
- // Use the assignee GSI when filtering by assignee (avoids fetching the entire site's jobs)
133
- if (qParams.assignee && (authorised || assigneeTracking)) {
134
- query.IndexName = "MaintenanceSiteAssigneeIndex";
135
- query.KeyConditionExpression = "site = :site AND AssigneeId = :assigneeId";
136
- query.ExpressionAttributeValues[":assigneeId"] = qParams.assignee;
137
- }
138
- log("getRequests", "query", query, logId);
139
-
140
- // check whether pagination is applied
141
- if (qParams.lastKey) {
142
- try {
143
- query.ExclusiveStartKey = JSON.parse(qParams.lastKey);
144
- } catch (e) {}
145
- }
146
-
147
- // get first page of jobs
148
- let result = await indexQuery(values.tableNameMaintenance, query);
149
- let allJobs = filterJobs(result.Items, qParams, authorised, assigneeTracking, userId);
150
- let lastKey = result.LastEvaluatedKey;
151
-
152
- log("getRequests", "LastEvaluatedKey", lastKey, logId);
153
- log("getRequests", "FirstPageFiltered", allJobs.length, logId);
154
-
155
- // auto-fill: keep fetching pages until we have MIN_RESULT_COUNT filtered results
156
- while (lastKey && allJobs.length < MIN_RESULT_COUNT) {
157
- const nextQuery = { ...query, ExclusiveStartKey: lastKey };
158
- result = await indexQuery(values.tableNameMaintenance, nextQuery);
159
- const filtered = filterJobs(result.Items, qParams, authorised, assigneeTracking, userId);
160
- allJobs = [...allJobs, ...filtered];
161
- lastKey = result.LastEvaluatedKey;
162
- }
163
-
164
- log("getRequests", "TotalFiltered", allJobs.length, logId);
165
- log("getRequests", "PagesFetched", lastKey ? "more available" : "exhausted", logId);
166
-
167
- // compile results
168
- const results = { Items: allJobs, LastKey: lastKey };
169
- log("getRequests", "Done", true, logId);
170
- return { status: 200, data: results };
90
+ const qParams = event.queryStringParameters;
91
+ const logId = log("getRequests", "Params", qParams);
92
+
93
+ // insufficient input
94
+ if (!qParams.site) {
95
+ return { status: 422, data: { error: "Insufficient input" } };
96
+ }
97
+ log("getRequests", "SufficientInput", true, logId);
98
+
99
+ // no access to site
100
+ const valid = await validateSiteAccess(event, qParams.site);
101
+ log("getRequests", "valid", valid, logId);
102
+ if (!valid) {
103
+ return { status: 403, data: { error: "Not authorised" } };
104
+ }
105
+
106
+ // check auth level to determine whether to fetch all requests or only matching requests
107
+ const authorised = await validateMasterAuth(
108
+ event,
109
+ values.permissionMaintenanceTracking,
110
+ qParams.site,
111
+ );
112
+ let assigneeTracking = false;
113
+ if (!authorised) {
114
+ assigneeTracking = await validateMasterAuth(
115
+ event,
116
+ values.permissionMaintenanceAssignment,
117
+ qParams.site,
118
+ );
119
+ }
120
+
121
+ log("getRequests", "authorised", authorised, logId);
122
+ const userId = authorised ? null : await getSessionUserFromReqAuthKey(event);
123
+
124
+ log("getRequests", "userId", userId, logId);
125
+
126
+ // Queries each status value in parallel for speed.
127
+ // Each job has one status, so no dedup needed.
128
+ const useStatusGsi =
129
+ qParams.status && !qParams.assignee && (authorised || assigneeTracking);
130
+
131
+ if (useStatusGsi) {
132
+ const statusValues = Array.isArray(qParams.status)
133
+ ? qParams.status
134
+ : qParams.status.split(",");
135
+
136
+ // "Open" matches both "Open" and legacy "Unassigned" in the DB
137
+ const dbStatuses = statusValues.flatMap((s) =>
138
+ s === STATUS_OPEN ? [STATUS_OPEN, "Unassigned"] : [s],
139
+ );
140
+
141
+ log("getRequests", "StatusGsi", dbStatuses, logId);
142
+
143
+ const allResults = await Promise.all(
144
+ dbStatuses.map((s) =>
145
+ indexQueryRecursive(values.tableNameMaintenance, {
146
+ IndexName: "MaintenanceSiteStatusIndex",
147
+ KeyConditionExpression: "site = :site AND #status = :status",
148
+ ExpressionAttributeNames: { "#status": "status" },
149
+ ExpressionAttributeValues: {
150
+ ":site": qParams.site,
151
+ ":status": s,
152
+ },
153
+ }),
154
+ ),
155
+ );
156
+
157
+ const allJobs = filterJobs(
158
+ allResults.flat(),
159
+ qParams,
160
+ authorised,
161
+ assigneeTracking,
162
+ userId,
163
+ );
164
+ log("getRequests", "StatusGsiResult", allJobs.length, logId);
165
+ return { status: 200, data: { Items: allJobs } };
166
+ }
167
+
168
+ const query =
169
+ authorised || assigneeTracking
170
+ ? {
171
+ IndexName: "MaintenanceSiteJobNoIndex",
172
+ KeyConditionExpression: "site = :site",
173
+ ExpressionAttributeValues: {
174
+ ":site": qParams.site,
175
+ },
176
+ ScanIndexForward: false,
177
+ }
178
+ : {
179
+ IndexName: "MaintenanceSiteUserIdIndex",
180
+ KeyConditionExpression: "site = :site AND userID = :userId",
181
+ ExpressionAttributeValues: {
182
+ ":site": qParams.site,
183
+ ":userId": userId,
184
+ },
185
+ };
186
+
187
+ // Use the assignee GSI when filtering by assignee (avoids fetching the entire site's jobs)
188
+ if (qParams.assignee && (authorised || assigneeTracking)) {
189
+ query.IndexName = "MaintenanceSiteAssigneeIndex";
190
+ query.KeyConditionExpression = "site = :site AND AssigneeId = :assigneeId";
191
+ query.ExpressionAttributeValues[":assigneeId"] = qParams.assignee;
192
+ }
193
+ log("getRequests", "query", query, logId);
194
+
195
+ // check whether pagination is applied
196
+ if (qParams.lastKey) {
197
+ try {
198
+ query.ExclusiveStartKey = JSON.parse(qParams.lastKey);
199
+ } catch (e) {}
200
+ }
201
+
202
+ // get first page of jobs
203
+ let result = await indexQuery(values.tableNameMaintenance, query);
204
+ let allJobs = filterJobs(
205
+ result.Items,
206
+ qParams,
207
+ authorised,
208
+ assigneeTracking,
209
+ userId,
210
+ );
211
+ let lastKey = result.LastEvaluatedKey;
212
+
213
+ log("getRequests", "LastEvaluatedKey", lastKey, logId);
214
+ log("getRequests", "FirstPageFiltered", allJobs.length, logId);
215
+
216
+ const minResults = hasActiveFilters(qParams)
217
+ ? MIN_RESULT_COUNT_FILTERED
218
+ : MIN_RESULT_COUNT;
219
+
220
+ // auto-fill: keep fetching pages until we have enough filtered results
221
+ while (lastKey && allJobs.length < minResults) {
222
+ const nextQuery = { ...query, ExclusiveStartKey: lastKey };
223
+ result = await indexQuery(values.tableNameMaintenance, nextQuery);
224
+ const filtered = filterJobs(
225
+ result.Items,
226
+ qParams,
227
+ authorised,
228
+ assigneeTracking,
229
+ userId,
230
+ );
231
+ allJobs = [...allJobs, ...filtered];
232
+ lastKey = result.LastEvaluatedKey;
233
+ }
234
+
235
+ log("getRequests", "TotalFiltered", allJobs.length, logId);
236
+ log(
237
+ "getRequests",
238
+ "PagesFetched",
239
+ lastKey ? "more available" : "exhausted",
240
+ logId,
241
+ );
242
+
243
+ const results = { Items: allJobs, LastKey: lastKey };
244
+ log("getRequests", "Done", true, logId);
245
+ return { status: 200, data: results };
171
246
  };