diff --git a/handlers/api-get/milestoneICS.js b/handlers/api-get/milestoneICS.js index 8c009471..40f57764 100644 --- a/handlers/api-get/milestoneICS.js +++ b/handlers/api-get/milestoneICS.js @@ -1,7 +1,17 @@ import ical, { ICalEventStatus } from "ical-generator"; import { getWorkOrderMilestones } from "../../helpers/lotOccupancyDB/getWorkOrderMilestones.js"; +import * as configFunctions from "../../helpers/functions.config.js"; const timeStringSplitRegex = /[ :-]/; +function escapeHTML(stringToEscape) { + return stringToEscape.replace(/[^\d A-Za-z]/g, (c) => "&#" + c.codePointAt(0) + ";"); +} export const handler = (request, response) => { + const urlRoot = "http://" + + request.hostname + + (configFunctions.getProperty("application.httpPort") === 80 + ? "" + : ":" + configFunctions.getProperty("application.httpPort")) + + configFunctions.getProperty("reverseProxy.urlPrefix"); const workOrderMilestones = getWorkOrderMilestones({ workOrderMilestoneDateFilter: "recent", workOrderTypeIds: request.query.workOrderTypeIds, @@ -9,21 +19,153 @@ export const handler = (request, response) => { .workOrderMilestoneTypeIds }, { includeWorkOrders: true, orderBy: "date" }); const calendar = ical({ - name: "Work Order Milestone Calendar" + name: "Work Order Milestone Calendar", + url: urlRoot + "/workOrders" + }); + calendar.prodId({ + company: "cityssm.github.io", + product: configFunctions.getProperty("application.applicationName") }); for (const milestone of workOrderMilestones) { const milestoneTimePieces = (milestone.workOrderMilestoneDateString + " " + milestone.workOrderMilestoneTimeString).split(timeStringSplitRegex); const milestoneDate = new Date(Number.parseInt(milestoneTimePieces[0], 10), Number.parseInt(milestoneTimePieces[1], 10) - 1, Number.parseInt(milestoneTimePieces[2], 10), Number.parseInt(milestoneTimePieces[3], 10), Number.parseInt(milestoneTimePieces[4], 10)); + let summary = (milestone.workOrderMilestoneTypeId + ? milestone.workOrderMilestoneType + : milestone.workOrderMilestoneDescription).trim(); + if (milestone.workOrder.workOrderLotOccupancies.length > 0) { + let occupantCount = 0; + for (const lotOccupancy of milestone.workOrder + .workOrderLotOccupancies) { + for (const occupant of lotOccupancy.lotOccupancyOccupants) { + occupantCount += 1; + if (occupantCount === 1) { + if (summary !== "") { + summary += ": "; + } + summary += occupant.occupantName; + } + } + } + if (occupantCount > 1) { + summary += " plus " + (occupantCount - 1); + } + } + const workOrderURL = urlRoot + "/workOrders/" + milestone.workOrderId; const eventData = { start: milestoneDate, + created: new Date(milestone.recordCreate_timeMillis), stamp: new Date(milestone.recordCreate_timeMillis), - lastModified: new Date(milestone.recordUpdate_timeMillis), + lastModified: new Date(Math.max(milestone.recordUpdate_timeMillis, milestone.workOrder.recordUpdate_timeMillis)), allDay: !milestone.workOrderMilestoneTime, - summary: milestone.workOrderMilestoneDescription + summary, + url: workOrderURL }; const calendarEvent = calendar.createEvent(eventData); + let descriptionHTML = "

Milestone Description

" + + "

" + + escapeHTML(milestone.workOrderMilestoneDescription) + + "

" + + "

Work Order #" + + milestone.workOrder.workOrderNumber + + "

" + + ("

" + + escapeHTML(milestone.workOrder.workOrderDescription) + + "

") + + ('

' + workOrderURL + "

"); + if (milestone.workOrder.workOrderLotOccupancies.length > 0) { + descriptionHTML += + "

Related " + + escapeHTML(configFunctions.getProperty("aliases.occupancies")) + + "

" + + '' + + ("") + + ("") + + "" + + "" + + ("") + + "" + + ""; + for (const occupancy of milestone.workOrder + .workOrderLotOccupancies) { + descriptionHTML += + "" + + ("") + + ("") + + ("") + + "" + + "" + ""; + } + descriptionHTML += "
" + + escapeHTML(configFunctions.getProperty("aliases.occupancy")) + + " Type" + + escapeHTML(configFunctions.getProperty("aliases.lot")) + + "Start DateEnd Date" + + escapeHTML(configFunctions.getProperty("aliases.occupants")) + + "
" + + '' + + escapeHTML(occupancy.occupancyType) + + "" + + (occupancy.lotName + ? escapeHTML(occupancy.lotName) + : "(Not Set)") + + "" + occupancy.occupancyStartDateString + "" + + (occupancy.occupancyEndDate + ? occupancy.occupancyEndDateString + : "(No End Date)") + + ""; + for (const occupant of occupancy.lotOccupancyOccupants) { + descriptionHTML += + escapeHTML(occupant.occupantName) + "
"; + } + descriptionHTML += "
"; + } + if (milestone.workOrder.workOrderLots.length > 0) { + descriptionHTML += + "

Related " + + escapeHTML(configFunctions.getProperty("aliases.lots")) + + "

" + + '' + + ("") + + ("") + + ("") + + "" + + "" + + ""; + for (const lot of milestone.workOrder.workOrderLots) { + descriptionHTML += + "" + + ("") + + ("") + + ("") + + ("") + + ""; + } + descriptionHTML += "
" + + escapeHTML(configFunctions.getProperty("aliases.lot")) + + " Type" + + escapeHTML(configFunctions.getProperty("aliases.map")) + + "" + + escapeHTML(configFunctions.getProperty("aliases.lot")) + + " Type" + + "Status
" + + '' + + escapeHTML(lot.lotName) + + "" + escapeHTML(lot.mapName) + "" + escapeHTML(lot.lotType) + "" + escapeHTML(lot.lotStatus) + "
"; + } + calendarEvent.description({ + plain: workOrderURL, + html: descriptionHTML + }); if (milestone.workOrderMilestoneCompletionDate) { calendarEvent.status(ICalEventStatus.CONFIRMED); } @@ -31,6 +173,9 @@ export const handler = (request, response) => { calendarEvent.createCategory({ name: milestone.workOrderMilestoneType }); + calendarEvent.createCategory({ + name: milestone.workOrder.workOrderType + }); } if (milestone.workOrder.workOrderLots.length > 0) { const lotNames = []; @@ -40,16 +185,32 @@ export const handler = (request, response) => { calendarEvent.location(lotNames.join(", ")); } if (milestone.workOrder.workOrderLotOccupancies.length > 0) { + let organizerSet = false; for (const lotOccupancy of milestone.workOrder .workOrderLotOccupancies) { - for (const occupants of lotOccupancy.lotOccupancyOccupants) { - calendarEvent.createAttendee({ - name: occupants.occupantName, - email: "no-reply@example.com" - }); + for (const occupant of lotOccupancy.lotOccupancyOccupants) { + if (organizerSet) { + calendarEvent.createAttendee({ + name: occupant.occupantName, + email: "no-reply@127.0.0.1" + }); + } + else { + calendarEvent.organizer({ + name: occupant.occupantName, + email: "no-reply@127.0.0.1" + }); + organizerSet = true; + } } } } + else { + calendarEvent.organizer({ + name: milestone.recordCreate_userName, + email: "no-reply@127.0.0.1" + }); + } } calendar.serve(response); }; diff --git a/handlers/api-get/milestoneICS.ts b/handlers/api-get/milestoneICS.ts index 24f9a638..bfc2e4ad 100644 --- a/handlers/api-get/milestoneICS.ts +++ b/handlers/api-get/milestoneICS.ts @@ -7,9 +7,26 @@ import { getWorkOrderMilestones } from "../../helpers/lotOccupancyDB/getWorkOrde import type { RequestHandler } from "express"; import { dateIntegerToString } from "@cityssm/expressjs-server-js/dateTimeFns.js"; +import * as configFunctions from "../../helpers/functions.config.js"; + const timeStringSplitRegex = /[ :-]/; +function escapeHTML(stringToEscape: string) { + return stringToEscape.replace( + /[^\d A-Za-z]/g, + (c) => "&#" + c.codePointAt(0) + ";" + ); +} + export const handler: RequestHandler = (request, response) => { + const urlRoot = + "http://" + + request.hostname + + (configFunctions.getProperty("application.httpPort") === 80 + ? "" + : ":" + configFunctions.getProperty("application.httpPort")) + + configFunctions.getProperty("reverseProxy.urlPrefix"); + const workOrderMilestones = getWorkOrderMilestones( { workOrderMilestoneDateFilter: "recent", @@ -21,7 +38,13 @@ export const handler: RequestHandler = (request, response) => { ); const calendar = ical({ - name: "Work Order Milestone Calendar" + name: "Work Order Milestone Calendar", + url: urlRoot + "/workOrders" + }); + + calendar.prodId({ + company: "cityssm.github.io", + product: configFunctions.getProperty("application.applicationName") }); for (const milestone of workOrderMilestones) { @@ -39,26 +62,200 @@ export const handler: RequestHandler = (request, response) => { Number.parseInt(milestoneTimePieces[4], 10) ); + // Build summary (title in Outlook) + + let summary = ( + milestone.workOrderMilestoneTypeId + ? milestone.workOrderMilestoneType + : milestone.workOrderMilestoneDescription + ).trim(); + + if (milestone.workOrder.workOrderLotOccupancies.length > 0) { + let occupantCount = 0; + + for (const lotOccupancy of milestone.workOrder + .workOrderLotOccupancies) { + for (const occupant of lotOccupancy.lotOccupancyOccupants) { + occupantCount += 1; + + if (occupantCount === 1) { + if (summary !== "") { + summary += ": "; + } + + summary += occupant.occupantName; + } + } + } + + if (occupantCount > 1) { + summary += " plus " + (occupantCount - 1); + } + } + + // Build URL + + const workOrderURL = urlRoot + "/workOrders/" + milestone.workOrderId; + + // Create event + const eventData: ICalEventData = { start: milestoneDate, + created: new Date(milestone.recordCreate_timeMillis), stamp: new Date(milestone.recordCreate_timeMillis), - lastModified: new Date(milestone.recordUpdate_timeMillis), + lastModified: new Date( + Math.max( + milestone.recordUpdate_timeMillis, + milestone.workOrder.recordUpdate_timeMillis + ) + ), allDay: !milestone.workOrderMilestoneTime, - summary: milestone.workOrderMilestoneDescription + summary, + url: workOrderURL }; const calendarEvent = calendar.createEvent(eventData); + // Build description + + let descriptionHTML = + "

Milestone Description

" + + "

" + + escapeHTML(milestone.workOrderMilestoneDescription) + + "

" + + "

Work Order #" + + milestone.workOrder.workOrderNumber + + "

" + + ("

" + + escapeHTML(milestone.workOrder.workOrderDescription) + + "

") + + ('

' + workOrderURL + "

"); + + if (milestone.workOrder.workOrderLotOccupancies.length > 0) { + descriptionHTML += + "

Related " + + escapeHTML(configFunctions.getProperty("aliases.occupancies")) + + "

" + + '' + + ("") + + ("") + + "" + + "" + + ("") + + "" + + ""; + + for (const occupancy of milestone.workOrder + .workOrderLotOccupancies) { + descriptionHTML += + "" + + ("") + + ("") + + ("") + + "" + + "" + ""; + } + + descriptionHTML += "
" + + escapeHTML( + configFunctions.getProperty("aliases.occupancy") + ) + + " Type" + + escapeHTML(configFunctions.getProperty("aliases.lot")) + + "Start DateEnd Date" + + escapeHTML( + configFunctions.getProperty("aliases.occupants") + ) + + "
" + + '' + + escapeHTML(occupancy.occupancyType) + + "" + + (occupancy.lotName + ? escapeHTML(occupancy.lotName) + : "(Not Set)") + + "" + occupancy.occupancyStartDateString + "" + + (occupancy.occupancyEndDate + ? occupancy.occupancyEndDateString + : "(No End Date)") + + ""; + + for (const occupant of occupancy.lotOccupancyOccupants) { + descriptionHTML += + escapeHTML(occupant.occupantName) + "
"; + } + + descriptionHTML += "
"; + } + + if (milestone.workOrder.workOrderLots.length > 0) { + descriptionHTML += + "

Related " + + escapeHTML(configFunctions.getProperty("aliases.lots")) + + "

" + + '' + + ("") + + ("") + + ("") + + "" + + "" + + ""; + + for (const lot of milestone.workOrder.workOrderLots) { + descriptionHTML += + "" + + ("") + + ("") + + ("") + + ("") + + ""; + } + + descriptionHTML += "
" + + escapeHTML(configFunctions.getProperty("aliases.lot")) + + " Type" + + escapeHTML(configFunctions.getProperty("aliases.map")) + + "" + + escapeHTML(configFunctions.getProperty("aliases.lot")) + + " Type" + + "Status
" + + '' + + escapeHTML(lot.lotName) + + "" + escapeHTML(lot.mapName) + "" + escapeHTML(lot.lotType) + "" + escapeHTML(lot.lotStatus) + "
"; + } + + calendarEvent.description({ + plain: workOrderURL, + html: descriptionHTML + }); + + // Set status + if (milestone.workOrderMilestoneCompletionDate) { calendarEvent.status(ICalEventStatus.CONFIRMED); } + // Add categories + if (milestone.workOrderMilestoneTypeId) { calendarEvent.createCategory({ name: milestone.workOrderMilestoneType }); + + calendarEvent.createCategory({ + name: milestone.workOrder.workOrderType + }); } + // Set location + if (milestone.workOrder.workOrderLots.length > 0) { const lotNames = []; @@ -69,16 +266,32 @@ export const handler: RequestHandler = (request, response) => { calendarEvent.location(lotNames.join(", ")); } + // Set organizer / attendees + if (milestone.workOrder.workOrderLotOccupancies.length > 0) { + let organizerSet = false; for (const lotOccupancy of milestone.workOrder .workOrderLotOccupancies) { - for (const occupants of lotOccupancy.lotOccupancyOccupants) { - calendarEvent.createAttendee({ - name: occupants.occupantName, - email: "no-reply@example.com" - }); + for (const occupant of lotOccupancy.lotOccupancyOccupants) { + if (organizerSet) { + calendarEvent.createAttendee({ + name: occupant.occupantName, + email: "no-reply@127.0.0.1" + }); + } else { + calendarEvent.organizer({ + name: occupant.occupantName, + email: "no-reply@127.0.0.1" + }); + organizerSet = true; + } } } + } else { + calendarEvent.organizer({ + name: milestone.recordCreate_userName, + email: "no-reply@127.0.0.1" + }); } } diff --git a/helpers/lotOccupancyDB/getWorkOrder.js b/helpers/lotOccupancyDB/getWorkOrder.js index f3710800..26f37829 100644 --- a/helpers/lotOccupancyDB/getWorkOrder.js +++ b/helpers/lotOccupancyDB/getWorkOrder.js @@ -9,7 +9,8 @@ const baseSQL = "select w.workOrderId," + " w.workOrderTypeId, t.workOrderType," + " w.workOrderNumber, w.workOrderDescription," + " w.workOrderOpenDate, userFn_dateIntegerToString(w.workOrderOpenDate) as workOrderOpenDateString," + - " w.workOrderCloseDate, userFn_dateIntegerToString(w.workOrderCloseDate) as workOrderCloseDateString" + + " w.workOrderCloseDate, userFn_dateIntegerToString(w.workOrderCloseDate) as workOrderCloseDateString," + + " w.recordUpdate_timeMillis" + " from WorkOrders w" + " left join WorkOrderTypes t on w.workOrderTypeId = t.workOrderTypeId" + " where w.recordDelete_timeMillis is null"; diff --git a/helpers/lotOccupancyDB/getWorkOrder.ts b/helpers/lotOccupancyDB/getWorkOrder.ts index 49214108..285d6513 100644 --- a/helpers/lotOccupancyDB/getWorkOrder.ts +++ b/helpers/lotOccupancyDB/getWorkOrder.ts @@ -25,7 +25,8 @@ const baseSQL = " w.workOrderTypeId, t.workOrderType," + " w.workOrderNumber, w.workOrderDescription," + " w.workOrderOpenDate, userFn_dateIntegerToString(w.workOrderOpenDate) as workOrderOpenDateString," + - " w.workOrderCloseDate, userFn_dateIntegerToString(w.workOrderCloseDate) as workOrderCloseDateString" + + " w.workOrderCloseDate, userFn_dateIntegerToString(w.workOrderCloseDate) as workOrderCloseDateString," + + " w.recordUpdate_timeMillis" + " from WorkOrders w" + " left join WorkOrderTypes t on w.workOrderTypeId = t.workOrderTypeId" + " where w.recordDelete_timeMillis is null"; diff --git a/views/workOrder-outlook.ejs b/views/workOrder-outlook.ejs index e1f059d9..528181fa 100644 --- a/views/workOrder-outlook.ejs +++ b/views/workOrder-outlook.ejs @@ -27,6 +27,7 @@
+

Outlook Calendar (ICS) Integration