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")) +
+ "
" +
+ '' +
+ ("| " +
+ escapeHTML(configFunctions.getProperty("aliases.occupancy")) +
+ " Type | ") +
+ ("" +
+ escapeHTML(configFunctions.getProperty("aliases.lot")) +
+ " | ") +
+ "Start Date | " +
+ "End Date | " +
+ ("" +
+ escapeHTML(configFunctions.getProperty("aliases.occupants")) +
+ " | ") +
+ "
" +
+ "";
+ for (const occupancy of milestone.workOrder
+ .workOrderLotOccupancies) {
+ descriptionHTML +=
+ "" +
+ ("| " +
+ '' +
+ 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 += " | " + "
";
+ }
+ descriptionHTML += "
";
+ }
+ if (milestone.workOrder.workOrderLots.length > 0) {
+ descriptionHTML +=
+ "Related " +
+ escapeHTML(configFunctions.getProperty("aliases.lots")) +
+ "
" +
+ '' +
+ ("| " +
+ escapeHTML(configFunctions.getProperty("aliases.lot")) +
+ " Type | ") +
+ ("" +
+ escapeHTML(configFunctions.getProperty("aliases.map")) +
+ " | ") +
+ ("" +
+ escapeHTML(configFunctions.getProperty("aliases.lot")) +
+ " Type" +
+ " | ") +
+ "Status | " +
+ "
" +
+ "";
+ for (const lot of milestone.workOrder.workOrderLots) {
+ descriptionHTML +=
+ "" +
+ ("| " +
+ '' +
+ escapeHTML(lot.lotName) +
+ " | ") +
+ ("" + escapeHTML(lot.mapName) + " | ") +
+ ("" + escapeHTML(lot.lotType) + " | ") +
+ ("" + escapeHTML(lot.lotStatus) + " | ") +
+ "
";
+ }
+ descriptionHTML += "
";
+ }
+ 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")) +
+ "
" +
+ '' +
+ ("| " +
+ escapeHTML(
+ configFunctions.getProperty("aliases.occupancy")
+ ) +
+ " Type | ") +
+ ("" +
+ escapeHTML(configFunctions.getProperty("aliases.lot")) +
+ " | ") +
+ "Start Date | " +
+ "End Date | " +
+ ("" +
+ escapeHTML(
+ configFunctions.getProperty("aliases.occupants")
+ ) +
+ " | ") +
+ "
" +
+ "";
+
+ for (const occupancy of milestone.workOrder
+ .workOrderLotOccupancies) {
+ descriptionHTML +=
+ "" +
+ ("| " +
+ '' +
+ 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 += " | " + "
";
+ }
+
+ descriptionHTML += "
";
+ }
+
+ if (milestone.workOrder.workOrderLots.length > 0) {
+ descriptionHTML +=
+ "Related " +
+ escapeHTML(configFunctions.getProperty("aliases.lots")) +
+ "
" +
+ '' +
+ ("| " +
+ escapeHTML(configFunctions.getProperty("aliases.lot")) +
+ " Type | ") +
+ ("" +
+ escapeHTML(configFunctions.getProperty("aliases.map")) +
+ " | ") +
+ ("" +
+ escapeHTML(configFunctions.getProperty("aliases.lot")) +
+ " Type" +
+ " | ") +
+ "Status | " +
+ "
" +
+ "";
+
+ for (const lot of milestone.workOrder.workOrderLots) {
+ descriptionHTML +=
+ "" +
+ ("| " +
+ '' +
+ escapeHTML(lot.lotName) +
+ " | ") +
+ ("" + escapeHTML(lot.mapName) + " | ") +
+ ("" + escapeHTML(lot.lotType) + " | ") +
+ ("" + escapeHTML(lot.lotStatus) + " | ") +
+ "
";
+ }
+
+ descriptionHTML += "
";
+ }
+
+ 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