@plusscommunities/pluss-maintenance-aws-forms 2.1.44 → 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.44",
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,21 +3,18 @@ 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,
@@ -30,151 +27,220 @@ const MIN_RESULT_COUNT = 250;
30
27
  const MIN_RESULT_COUNT_FILTERED = Infinity;
31
28
 
32
29
  const hasActiveFilters = (qParams) =>
33
- qParams.status || qParams.priority || qParams.type || qParams.search || qParams.startTime || qParams.endTime;
30
+ qParams.status ||
31
+ qParams.priority ||
32
+ qParams.type ||
33
+ qParams.search ||
34
+ qParams.startTime ||
35
+ qParams.endTime;
34
36
 
35
37
  /**
36
38
  * Apply all post-query filters to a set of jobs.
37
39
  * Extracted so the same logic runs on every auto-fill page.
38
40
  */
39
41
  const filterJobs = (jobs, qParams, authorised, assigneeTracking, userId) => {
40
- let filtered = jobs;
41
-
42
- if (qParams.status) {
43
- if (qParams.status === "Incomplete") {
44
- filtered = filtered.filter((j) => !isCompleted(normalizeStatus(j.status)));
45
- } else {
46
- filtered = filtered.filter((j) => qParams.status.includes(normalizeStatus(j.status)));
47
- }
48
- }
49
-
50
- if (qParams.priority) {
51
- filtered = filtered.filter((j) => qParams.priority.includes(normalizePriority(j.priority)));
52
- }
53
-
54
- if (qParams.type) {
55
- filtered = filtered.filter((j) => qParams.type.includes(j.type));
56
- }
57
-
58
- if (!authorised && assigneeTracking) {
59
- filtered = filtered.filter((j) => j.AssigneeId === userId || j.userID === userId);
60
- }
61
-
62
- if (qParams.startTime) {
63
- const startTime = parseInt(qParams.startTime, 10);
64
- filtered = filtered.filter((j) => j.createdUnix >= startTime);
65
- }
66
- if (qParams.endTime) {
67
- const endTime = parseInt(qParams.endTime, 10);
68
- filtered = filtered.filter((j) => j.createdUnix <= endTime);
69
- }
70
-
71
- if (qParams.search) {
72
- const searchLower = qParams.search.toLowerCase();
73
- filtered = filtered.filter((j) => {
74
- if (j.jobId && j.jobId === qParams.search) return true;
75
- if (j.room && j.room.toLowerCase().indexOf(searchLower) > -1) return true;
76
- if (j.title && j.title.toLowerCase().indexOf(searchLower) > -1) return true;
77
- return false;
78
- });
79
- }
80
-
81
- 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;
82
87
  };
83
88
 
84
89
  module.exports = async (event) => {
85
- const qParams = event.queryStringParameters;
86
- const logId = log("getRequests", "Params", qParams);
87
-
88
- // insufficient input
89
- if (!qParams.site) {
90
- return { status: 422, data: { error: "Insufficient input" } };
91
- }
92
- log("getRequests", "SufficientInput", true, logId);
93
-
94
- // no access to site
95
- const valid = await validateSiteAccess(event, qParams.site);
96
- log("getRequests", "valid", valid, logId);
97
- if (!valid) {
98
- return { status: 403, data: { error: "Not authorised" } };
99
- }
100
-
101
- // check auth level to determine whether to fetch all requests or only matching requests
102
- const authorised = await validateMasterAuth(
103
- event,
104
- values.permissionMaintenanceTracking,
105
- qParams.site
106
- );
107
- let assigneeTracking = false;
108
- if (!authorised) {
109
- assigneeTracking = await validateMasterAuth(
110
- event,
111
- values.permissionMaintenanceAssignment,
112
- qParams.site
113
- );
114
- }
115
-
116
- log("getRequests", "authorised", authorised, logId);
117
- const userId = authorised ? null : await getSessionUserFromReqAuthKey(event);
118
-
119
- log("getRequests", "userId", userId, logId);
120
-
121
- const query =
122
- authorised || assigneeTracking
123
- ? {
124
- IndexName: "MaintenanceSiteIndex",
125
- KeyConditionExpression: "site = :site",
126
- ExpressionAttributeValues: {
127
- ":site": qParams.site,
128
- },
129
- }
130
- : {
131
- IndexName: "MaintenanceSiteUserIdIndex",
132
- KeyConditionExpression: "site = :site AND userID = :userId",
133
- ExpressionAttributeValues: {
134
- ":site": qParams.site,
135
- ":userId": userId,
136
- },
137
- };
138
-
139
- // Use the assignee GSI when filtering by assignee (avoids fetching the entire site's jobs)
140
- if (qParams.assignee && (authorised || assigneeTracking)) {
141
- query.IndexName = "MaintenanceSiteAssigneeIndex";
142
- query.KeyConditionExpression = "site = :site AND AssigneeId = :assigneeId";
143
- query.ExpressionAttributeValues[":assigneeId"] = qParams.assignee;
144
- }
145
- log("getRequests", "query", query, logId);
146
-
147
- // check whether pagination is applied
148
- if (qParams.lastKey) {
149
- try {
150
- query.ExclusiveStartKey = JSON.parse(qParams.lastKey);
151
- } catch (e) {}
152
- }
153
-
154
- // get first page of jobs
155
- let result = await indexQuery(values.tableNameMaintenance, query);
156
- let allJobs = filterJobs(result.Items, qParams, authorised, assigneeTracking, userId);
157
- let lastKey = result.LastEvaluatedKey;
158
-
159
- log("getRequests", "LastEvaluatedKey", lastKey, logId);
160
- log("getRequests", "FirstPageFiltered", allJobs.length, logId);
161
-
162
- const minResults = hasActiveFilters(qParams) ? MIN_RESULT_COUNT_FILTERED : MIN_RESULT_COUNT;
163
-
164
- // auto-fill: keep fetching pages until we have enough filtered results
165
- while (lastKey && allJobs.length < minResults) {
166
- const nextQuery = { ...query, ExclusiveStartKey: lastKey };
167
- result = await indexQuery(values.tableNameMaintenance, nextQuery);
168
- const filtered = filterJobs(result.Items, qParams, authorised, assigneeTracking, userId);
169
- allJobs = [...allJobs, ...filtered];
170
- lastKey = result.LastEvaluatedKey;
171
- }
172
-
173
- log("getRequests", "TotalFiltered", allJobs.length, logId);
174
- log("getRequests", "PagesFetched", lastKey ? "more available" : "exhausted", logId);
175
-
176
- // compile results
177
- const results = { Items: allJobs, LastKey: lastKey };
178
- log("getRequests", "Done", true, logId);
179
- 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 };
180
246
  };