@plusscommunities/pluss-maintenance-aws-forms 2.1.20 → 2.1.21-auth.0

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.
@@ -1,6 +1,7 @@
1
1
  const getString = require("@plusscommunities/pluss-core-aws/db/strings/getString");
2
2
  const { getRowId, log } = require("@plusscommunities/pluss-core-aws/helper");
3
3
  const ArchibusStrategy = require("./archibus/ArchibusStrategy");
4
+ const SeeStuffStrategy = require("./seestuff/SeeStuffStrategy");
4
5
  const IntegrationStrategy = require("./IntegrationStrategy");
5
6
  const { values } = require("../values.config");
6
7
 
@@ -16,6 +17,8 @@ exports.getStrategy = async (site) => {
16
17
  switch (importConfig.Strategy) {
17
18
  case "Archibus":
18
19
  return new ArchibusStrategy(importConfig);
20
+ case "SeeStuff":
21
+ return new SeeStuffStrategy(importConfig);
19
22
  default:
20
23
  return new IntegrationStrategy();
21
24
  }
@@ -0,0 +1,298 @@
1
+ const axios = require("axios");
2
+ const moment = require("moment");
3
+ const _ = require("lodash");
4
+ const IntegrationStrategy = require("../IntegrationStrategy");
5
+ const { log, getRowId } = require("@plusscommunities/pluss-core-aws/helper");
6
+ const editRef = require("@plusscommunities/pluss-core-aws/db/common/editRef");
7
+ const updateRef = require("@plusscommunities/pluss-core-aws/db/common/updateRef");
8
+ const getRef = require("@plusscommunities/pluss-core-aws/db/common/getRef");
9
+ const indexQuery = require("@plusscommunities/pluss-core-aws/db/common/indexQuery");
10
+ const { values } = require("../../values.config");
11
+
12
+ class SeeStuffStrategy extends IntegrationStrategy {
13
+ constructor(config) {
14
+ super();
15
+ this.baseUrl = config.BaseUrl; // base URL for the API
16
+ this.apiKeyHeader = config.APIKeyHeader; // header to use for API key
17
+ this.apiKey = config.APIKey; // API key
18
+
19
+ const url = new URL(this.baseUrl);
20
+ this.host = url.host;
21
+
22
+ this.siteMap = config.SiteMap ?? {};
23
+ }
24
+
25
+ /**
26
+ * Gets the entity type for the integration
27
+ *
28
+ * @returns {String} The entity type for the integration
29
+ */
30
+ getEntityType = () => {
31
+ return `${values.serviceKey}_SeeStuff`;
32
+ };
33
+
34
+ /**
35
+ * Validates the integration
36
+ *
37
+ * @returns {Boolean} Whether the integration is valid
38
+ */
39
+ isValidIntegration = () => {
40
+ return true;
41
+ };
42
+
43
+ /**
44
+ * Gets the refresh interval for the SeeStuff system
45
+ *
46
+ * @returns {Number} The refresh interval in milliseconds
47
+ */
48
+ getRefreshInterval = () => {
49
+ return Number.MAX_SAFE_INTEGER; // should never refresh
50
+ };
51
+
52
+ /**
53
+ * Creates a request in the SeeStuff system
54
+ *
55
+ * @param {Object} request - Request definition on Pluss
56
+ * @param {Object} mockResponse - Mock response from SeeStuff to simulate response
57
+ * @returns {Boolean} Whether the request was created on SeeStuff
58
+ */
59
+ createRequest = async (request, mockResponse = null) => {
60
+ const logId = log("SeeStuff:CreateRequest", "Start", request);
61
+
62
+ try {
63
+ // Get user details for room/address and CRMID
64
+ let userRoom = request.room || "";
65
+ let crmResidentId = "";
66
+
67
+ // Map site to location code if available
68
+ const location = this.siteMap[request.site];
69
+ if (!location) {
70
+ log("SeeStuff:CreateRequest", "LocationNotFound", request.site, logId);
71
+ return false;
72
+ }
73
+
74
+ if (request.userID) {
75
+ try {
76
+ // Look up user from users table
77
+ const user = await getRef("users", "Id", request.userID);
78
+ log("SeeStuff:CreateRequest", "User", user, logId);
79
+
80
+ if (user) {
81
+ // Get user's address/room
82
+ if (user.unit) {
83
+ userRoom = user.unit;
84
+ }
85
+
86
+ // Get CRMID from user fields
87
+ try {
88
+ const crmIdField = await getRef(
89
+ "userfields",
90
+ "RowId",
91
+ getRowId(request.userID, "CRMID"),
92
+ );
93
+ if (crmIdField && crmIdField.Value) {
94
+ crmResidentId = crmIdField.Value;
95
+ }
96
+ log("SeeStuff:CreateRequest", "CRMID", crmResidentId, logId);
97
+ } catch (fieldError) {
98
+ log(
99
+ "SeeStuff:CreateRequest",
100
+ "CRMIDLookupError",
101
+ fieldError,
102
+ logId,
103
+ );
104
+ }
105
+ }
106
+ } catch (userError) {
107
+ log("SeeStuff:CreateRequest", "UserLookupError", userError, logId);
108
+ }
109
+ }
110
+
111
+ if (_.isEmpty(crmResidentId)) {
112
+ // log an issue in the history
113
+ throw new Error("NoCRMResidentId");
114
+ }
115
+
116
+ // Format description similar to Archibus
117
+ const description = `${request.title}${
118
+ _.isEmpty(request.description) ? "" : `\n\n${request.description}`
119
+ }${_.isEmpty(request.room) ? "" : `\n\nLocation: ${request.room}`}${
120
+ _.isEmpty(request.userName) ? "" : `\n\nJob Type: ${request.type}`
121
+ }${
122
+ _.isEmpty(request.images)
123
+ ? ""
124
+ : `\n\nImages: ${request.images.join("\n")}`
125
+ }`;
126
+
127
+ // Format date_reported
128
+ const dateReported = moment().format("YYYY-MM-DD HH:mm:ss");
129
+
130
+ // Build payload
131
+ const data = {
132
+ externalId: request.id,
133
+ description: description,
134
+ priority: "P3",
135
+ location: location,
136
+ room: userRoom,
137
+ date_reported: dateReported,
138
+ crmResidentId: crmResidentId,
139
+ };
140
+
141
+ log("SeeStuff:CreateRequest", "Request", data, logId);
142
+
143
+ const response =
144
+ mockResponse ??
145
+ (await axios({
146
+ method: "POST",
147
+ url: `${this.baseUrl}/assetmgmt/servicerequests`,
148
+ timeout: 30000,
149
+ headers: {
150
+ [this.apiKeyHeader]: this.apiKey,
151
+ "Content-Type": "application/json",
152
+ Host: this.host,
153
+ },
154
+ data,
155
+ }));
156
+
157
+ log("SeeStuff:CreateRequest", "Response", response.data, logId);
158
+
159
+ return true;
160
+ } catch (e) {
161
+ log("SeeStuff:CreateRequest", "Error", e, logId);
162
+
163
+ // Add history entry for failed integration
164
+ try {
165
+ const failedJob = await getRef(
166
+ values.tableNameMaintenance,
167
+ "id",
168
+ request.id,
169
+ );
170
+ if (!failedJob.history) failedJob.history = [];
171
+ failedJob.history.push({
172
+ timestamp: moment.utc().valueOf(),
173
+ EntryType: "ExternalIDSetFailed",
174
+ user: {
175
+ displayName: "SeeStuff Integration",
176
+ id: "system",
177
+ },
178
+ systemType: "SeeStuff",
179
+ error: e.message === "NoCRMResidentId" ? "No CRM ID found" : null,
180
+ });
181
+
182
+ await editRef(values.tableNameMaintenance, "id", request.id, {
183
+ history: failedJob.history,
184
+ });
185
+ } catch (historyError) {
186
+ log("SeeStuff:CreateRequest", "HistoryError", historyError, logId);
187
+ }
188
+ }
189
+ return false;
190
+ };
191
+
192
+ /**
193
+ * Fetches a request from the SeeStuff system
194
+ * Not implemented yet
195
+ *
196
+ * @param {String} externalId - Id of the request on SeeStuff
197
+ * @returns {Object} The request as it exists on SeeStuff
198
+ */
199
+ getRequest = async (externalId) => {
200
+ return null;
201
+ };
202
+
203
+ /**
204
+ * Refreshes a request from the SeeStuff system
205
+ * Not implemented yet
206
+ *
207
+ * @param {String} requestId - Id of the request on Pluss
208
+ * @param {String} externalId - Id of the request on SeeStuff
209
+ * @param {Object} trackedData - The set of fields tracked
210
+ * @returns {Boolean} Whether the request had any changes on SeeStuff
211
+ */
212
+ refreshFromSource = async (requestId, externalId, trackedData) => {
213
+ return false;
214
+ };
215
+
216
+ /**
217
+ * Get SeeStuff Id of the request
218
+ * Queries externalentities table to find the external ID
219
+ *
220
+ * @param {Object} request - Request definition on Pluss
221
+ * @returns {String} Id of the request on SeeStuff
222
+ */
223
+ getExternalId = async (request) => {
224
+ const logId = log("SeeStuff:GetExternalId", "Start", { Id: request.id });
225
+
226
+ // get external id
227
+ const externalEntityQuery = await indexQuery("externalentities", {
228
+ IndexName: "InternalIdIndex",
229
+ KeyConditionExpression:
230
+ "EntityType = :entityType AND InternalId = :internalId",
231
+ ExpressionAttributeValues: {
232
+ ":entityType": this.getEntityType(),
233
+ ":internalId": request.id,
234
+ },
235
+ });
236
+
237
+ log(
238
+ "SeeStuff:GetExternalId",
239
+ "ExternalLength",
240
+ externalEntityQuery.Items.length,
241
+ logId,
242
+ );
243
+ if (_.isEmpty(externalEntityQuery.Items)) {
244
+ return null;
245
+ }
246
+
247
+ const externalId = externalEntityQuery.Items[0].ExternalId;
248
+ log("SeeStuff:GetExternalId", "ExternalId", externalId, logId);
249
+
250
+ return externalId;
251
+ };
252
+
253
+ /**
254
+ * Perform actions when a task's status has changed
255
+ * Not implemented yet
256
+ *
257
+ * @param {Object} request - Request definition on Pluss
258
+ * @returns {Boolean} Represents whether the actions were successful
259
+ */
260
+ onStatusChanged = async (request) => {
261
+ return true;
262
+ };
263
+
264
+ /**
265
+ * Perform actions when a comment has been added to a task
266
+ * Not implemented yet
267
+ *
268
+ * @param {Object} request - Request definition on Pluss
269
+ * @returns {Boolean} Represents whether the actions were successful
270
+ */
271
+ onCommentAdded = async (request) => {
272
+ return true;
273
+ };
274
+
275
+ /**
276
+ * Perform actions when a note has been added to a task
277
+ * Not implemented yet
278
+ *
279
+ * @param {Object} request - Request definition on Pluss
280
+ * @returns {Boolean} Represents whether the actions were successful
281
+ */
282
+ onNotesAdded = async (request) => {
283
+ return true;
284
+ };
285
+
286
+ /**
287
+ * Perform completion actions when a task is completed
288
+ * Not implemented yet
289
+ *
290
+ * @param {Object} request - Request definition on Pluss
291
+ * @returns {Boolean} Represents whether the actions were successful
292
+ */
293
+ onCompleteRequest = async (request) => {
294
+ return true;
295
+ };
296
+ }
297
+
298
+ module.exports = SeeStuffStrategy;
package/jobChanged.js CHANGED
@@ -43,7 +43,7 @@ const onNotesAdded = async (request) => {
43
43
  const checkIntegrationActions = async (request, prevRequest) => {
44
44
  if (
45
45
  request.ExtCreateRetry &&
46
- request.ExtCreateRetry !== prevRequest.ExtCreateRetry
46
+ request.ExtCreateRetry > prevRequest.ExtCreateRetry
47
47
  ) {
48
48
  log("checkIntegrationActions", "Retrying", true);
49
49
  await pushRequestToIntegration(request);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * DynamoDB Stream Handler for JobTypes changes
3
+ *
4
+ * Republishes any templates using this site as source.
5
+ * Only notifies siteConfigs for variants with enableSiteConfigsPropagation: true
6
+ * (maintenance and maintenanceForms). Other variants (feedback, enquiry) are excluded.
7
+ */
8
+
9
+ const { Marshaller } = require("@aws/dynamodb-auto-marshaller");
10
+ const marshaller = new Marshaller();
11
+ const { log } = require("@plusscommunities/pluss-core-aws/helper");
12
+ const notifySiteConfigs = require("@plusscommunities/pluss-core-aws/helper/notifySiteConfigs");
13
+ const { values } = require("./values.config");
14
+
15
+ module.exports.jobTypesChanged = async (event, context, callback) => {
16
+ const logId = log("jobTypesChanged", "Start", { recordCount: event.Records.length, serviceKey: values.serviceKey });
17
+
18
+ // Check if this variant supports siteConfigs propagation
19
+ if (!values.enableSiteConfigsPropagation) {
20
+ log("jobTypesChanged", "SkipPropagation", { serviceKey: values.serviceKey }, logId);
21
+ return;
22
+ }
23
+
24
+ const promises = [];
25
+
26
+ event.Records.forEach((record) => {
27
+ log("jobTypesChanged", "Record", { eventName: record.eventName }, logId);
28
+
29
+ if (record.eventName === "INSERT") {
30
+ const data = marshaller.unmarshallItem(record.dynamodb.NewImage);
31
+ log("jobTypesChanged", "Insert", { id: data.id, site: data.site }, logId);
32
+
33
+ if (data.site) {
34
+ promises.push(notifySiteConfigs(data.site, logId));
35
+ }
36
+ } else if (record.eventName === "MODIFY") {
37
+ const data = marshaller.unmarshallItem(record.dynamodb.NewImage);
38
+ log("jobTypesChanged", "Modify", { id: data.id, site: data.site }, logId);
39
+
40
+ if (data.site) {
41
+ promises.push(notifySiteConfigs(data.site, logId));
42
+ }
43
+ } else if (record.eventName === "REMOVE") {
44
+ const previousData = marshaller.unmarshallItem(record.dynamodb.OldImage);
45
+ log("jobTypesChanged", "Remove", { id: previousData.id, site: previousData.site }, logId);
46
+
47
+ if (previousData.site) {
48
+ promises.push(notifySiteConfigs(previousData.site, logId));
49
+ }
50
+ }
51
+ });
52
+
53
+ await Promise.all(promises);
54
+ log("jobTypesChanged", "Complete", { promiseCount: promises.length }, logId);
55
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plusscommunities/pluss-maintenance-aws-forms",
3
- "version": "2.1.20",
3
+ "version": "2.1.21-auth.0",
4
4
  "description": "Extension package to enable maintenance on Pluss Communities Platform",
5
5
  "scripts": {
6
6
  "gc": "node ../../tools/gc ./",
@@ -10,6 +10,9 @@
10
10
  "deploy": "npm run gc && npm run gs && serverless deploy",
11
11
  "betaupload": "rm -rf .serverless && npm i && npm publish --access public --tag beta",
12
12
  "betaupload:p": "npm run betapatch && npm run betaupload",
13
+ "authpatch": "npm version prepatch --preid=auth",
14
+ "authupload": "npm i && npm i && npm publish --access public --tag auth",
15
+ "authupload:p": "npm run authpatch && npm run authupload",
13
16
  "upload": "rm -rf .serverless && npm i && npm publish --access public",
14
17
  "upload:p": "npm run patch && npm run upload",
15
18
  "copy:add": "run(){ ext=${1:-default}; test -f values.config.$ext.js || cp values.config.default.js values.config.$ext.js; }; run",
@@ -17,6 +20,7 @@
17
20
  "copy:set": "run(){ target='\\@plusscommunities\\/pluss-maintenance-aws'; ext=${1:-default}; [ $ext == 'default' ] && replace=$target || replace=$target'-'$ext; echo 'Setting target to '$replace; test -f values.config.$ext.js && cp -f values.config.$ext.js values.config.js; sed -i '' -e 's/'$target'.*\"/'$replace'\"/g' package.json; }; run",
18
21
  "copy:deploy": "for file in `ls ./values.config.*.js`; do dup=`echo $file | sed 's/.*values\\.config\\.\\(.*\\)\\.js/\\1/'`; npm run copy:set $dup; npm run deploy; done; npm run copy:set; npm run gs;",
19
22
  "copy:betaupload": "npm run betapatch; for file in `ls ./values.config.*.js`; do dup=`echo $file | sed 's/.*values\\.config\\.\\(.*\\)\\.js/\\1/'`; npm run copy:set $dup; npm run betaupload; done; npm run copy:set;",
23
+ "copy:authupload": "npm run authpatch; for file in `ls ./values.config.*.js`; do dup=`echo $file | sed 's/.*values\\.config\\.\\(.*\\)\\.js/\\1/'`; npm run copy:set $dup; npm run authupload; done; npm run copy:set;",
20
24
  "copy:upload": "npm run patch; for file in `ls ./values.config.*.js`; do dup=`echo $file | sed 's/.*values\\.config\\.\\(.*\\)\\.js/\\1/'`; npm run copy:set $dup; npm run upload; done; npm run copy:set;",
21
25
  "test": "jest tests -i"
22
26
  },
@@ -24,7 +28,7 @@
24
28
  "license": "ISC",
25
29
  "dependencies": {
26
30
  "@aws/dynamodb-auto-marshaller": "^0.7.1",
27
- "@plusscommunities/pluss-core-aws": "2.0.23",
31
+ "@plusscommunities/pluss-core-aws": "2.0.26-auth.0",
28
32
  "amazon-cognito-identity-js": "^2.0.19",
29
33
  "aws-sdk": "^2.1591.0",
30
34
  "axios": "^1.6.8",
@@ -0,0 +1,147 @@
1
+ const getRef = require("@plusscommunities/pluss-core-aws/db/common/getRef");
2
+ const indexQuery = require("@plusscommunities/pluss-core-aws/db/common/indexQuery");
3
+ const validateMasterAuth = require("@plusscommunities/pluss-core-aws/helper/auth/validateMasterAuth");
4
+ const isValidAssignee = require("./helper/isValidAssignee");
5
+ const getSessionUserFromReqAuthKey = require("@plusscommunities/pluss-core-aws/helper/auth/getSessionUserFromReqAuthKey");
6
+ const { log } = require("@plusscommunities/pluss-core-aws/helper");
7
+ const { values } = require("../values.config");
8
+
9
+ /**
10
+ * Retrieves external system sync information for a maintenance request
11
+ *
12
+ * Checks both SeeStuff and Archibus external entity tables to find
13
+ * if the request has been synced to an external system.
14
+ *
15
+ * @param {Object} event - Lambda event object
16
+ * @param {Object} params - Query parameters (optional, uses event.queryStringParameters if not provided)
17
+ * @returns {Object} Response object with status and data
18
+ *
19
+ * Response format (200):
20
+ * {
21
+ * externalSystem: "SeeStuff" | "Archibus",
22
+ * externalId: string,
23
+ * syncedAt: number (Unix timestamp),
24
+ * trackedData: object
25
+ * }
26
+ *
27
+ * Error responses:
28
+ * - 422: Missing required field (id)
29
+ * - 404: Request not found OR no external sync exists
30
+ * - 403: Not authorized (user doesn't have permission to view request)
31
+ * - 500: Internal server error
32
+ */
33
+ module.exports = async (event, params) => {
34
+ const data = params || event.queryStringParameters || {};
35
+ const logId = log("getExternalSync", "Params", data);
36
+
37
+ // Validate input
38
+ if (!data.id) {
39
+ log("getExternalSync", "MissingId", true, logId);
40
+ return {
41
+ status: 422,
42
+ data: { error: "Missing required field: id" },
43
+ };
44
+ }
45
+
46
+ try {
47
+ // Get the request to verify it exists and get site for permission check
48
+ let request;
49
+ try {
50
+ request = await getRef(values.tableNameMaintenance, "id", data.id);
51
+ } catch (error) {
52
+ log("getExternalSync", "RequestNotFound", { id: data.id }, logId);
53
+ return {
54
+ status: 404,
55
+ data: { error: "Request not found" },
56
+ };
57
+ }
58
+
59
+ if (!request) {
60
+ log("getExternalSync", "RequestNotFound", { id: data.id }, logId);
61
+ return {
62
+ status: 404,
63
+ data: { error: "Request not found" },
64
+ };
65
+ }
66
+
67
+ // Check permissions (same pattern as getRequest.js)
68
+ // Three levels of access:
69
+ // 1. Full admin with maintenance tracking permission
70
+ // 2. User assigned to the request
71
+ // 3. User who created the request
72
+ const authorised = await validateMasterAuth(
73
+ event,
74
+ values.permissionMaintenanceTracking,
75
+ request.site
76
+ );
77
+
78
+ const assignAuthorised = await isValidAssignee(
79
+ event,
80
+ request.site,
81
+ request.AssigneeId
82
+ );
83
+
84
+ if (!authorised && !assignAuthorised) {
85
+ // Check if user is the request owner
86
+ const userId = await getSessionUserFromReqAuthKey(event);
87
+ if (userId !== request.userID) {
88
+ log(
89
+ "getExternalSync",
90
+ "NotAuthorised",
91
+ { userId, requestOwner: request.userID },
92
+ logId
93
+ );
94
+ return {
95
+ status: 403,
96
+ data: { error: "Not authorised" },
97
+ };
98
+ }
99
+ }
100
+
101
+ log("getExternalSync", "Authorised", true, logId);
102
+
103
+ let externalSync = null;
104
+ const query = await indexQuery("externalentities", {
105
+ IndexName: "OnlyInternalIdIndex",
106
+ KeyConditionExpression: "InternalId = :internalId",
107
+ ExpressionAttributeValues: {
108
+ ":internalId": request.id,
109
+ },
110
+ });
111
+
112
+ log("getExternalSync", "Results", query.Items.length, logId);
113
+
114
+ if (query.Items && query.Items.length > 0) {
115
+ const item = query.Items[0];
116
+ externalSync = {
117
+ systemType: item.SystemType,
118
+ externalId: item.ExternalId,
119
+ syncedAt: item.LastUpdated,
120
+ trackedData: item.TrackedData || {},
121
+ };
122
+
123
+ log("getExternalSync", "FoundSync", externalSync, logId);
124
+ }
125
+
126
+ // If no external sync found, return 404
127
+ if (!externalSync) {
128
+ log("getExternalSync", "NoSyncFound", { id: data.id }, logId);
129
+ return {
130
+ status: 404,
131
+ data: { error: "No external sync found" },
132
+ };
133
+ }
134
+
135
+ log("getExternalSync", "Success", externalSync, logId);
136
+ return {
137
+ status: 200,
138
+ data: externalSync,
139
+ };
140
+ } catch (error) {
141
+ log("getExternalSync", "InternalError", error.toString(), logId);
142
+ return {
143
+ status: 500,
144
+ data: { error: "Internal error", message: error.message },
145
+ };
146
+ }
147
+ };
@@ -93,6 +93,12 @@ module.exports = async (event) => {
93
93
  log("getRequests", "FilteredOnType", jobs.length, logId);
94
94
  }
95
95
 
96
+ // filter on assignee (for users with tracking permission)
97
+ if (qParams.assignee) {
98
+ jobs = jobs.filter((j) => j.AssigneeId === qParams.assignee);
99
+ log("getRequests", "FilteredOnAssigneeFilter", jobs.length, logId);
100
+ }
101
+
96
102
  // filter to assigned jobs
97
103
  if (!authorised && assigneeTracking) {
98
104
  jobs = jobs.filter((j) => j.AssigneeId === userId || j.userID === userId);