Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support #280

Merged
merged 3 commits into from
May 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/components/support/StaffDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ const StaffDashboard = () => {
title="Open/In Progress Tickets"
/>
<DashboardMetric
metric={supportMetrics?.avgDaysToClose?.toString() + " days"}
metric={supportMetrics?.avgDaysToClose?.toString() ?? 0 + " days"}
title="Average Time to Resolution"
/>
<DashboardMetric
Expand Down
19 changes: 18 additions & 1 deletion client/src/components/support/TicketComment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ interface TicketCommentProps {
}

const TicketComment: React.FC<TicketCommentProps> = ({ msg }) => {
const formatMessage = (message: string) => {
return message.split("\n").map((line, index) => {
// ignore trailing empty line
if (index === message.split("\n").length - 1 && line === "") {
return null;
}
return (
<span key={index}>
{line}
<br />
</span>
);
});
};

return (
<Comment className="flex flex-row w-full border-b items-center py-4 px-2">
<CommentAvatar
Expand All @@ -39,7 +54,9 @@ const TicketComment: React.FC<TicketCommentProps> = ({ msg }) => {
</p>
</CommentMetadata>
</div>
<CommentText className="!break-words">{msg.message}</CommentText>
<CommentText className="!break-words">
{formatMessage(msg.message)}
</CommentText>
</CommentContent>
</Comment>
);
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/support/TicketFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const TicketFeed: React.FC<TicketFeedProps> = ({ ticket }) => {

return (
<div className="flex flex-col w-full bg-white rounded-md">
<div className="flex flex-col border shadow-md rounded-md p-4">
<div className="flex flex-col border shadow-md rounded-md p-4 max-h-96 overflow-y-auto">
<p className="text-2xl font-semibold text-center mb-0">Activity Feed</p>
<div className="flex flex-col mt-2">
{ticket.feed?.length === 0 && (
Expand Down
4 changes: 2 additions & 2 deletions server/api/mail.js
Original file line number Diff line number Diff line change
Expand Up @@ -826,7 +826,7 @@ const sendNewTicketMessageNotification = (recipientAddresses, ticketID, message,
subject: `New Message on Support Ticket (ID #${ticketID.slice(-7)})`,
html: `
<p>Hi,</p>
<p>A new message has been posted to your support ticket.</p>
<p>A new message has been posted a support ticket you have subscribed to.</p>
<p><strong>${messageSender}</strong> said:</p>
<p>${message}</p>
<p>You can respond to this message at <a href="https://commons.libretexts.org/support/ticket/${ticketID}${params ? `?${params}` : ''}" target="_blank" rel="noopener noreferrer">https://commons.libretexts.org/support/ticket/${ticketID}${params ? `?${params}` : ''}</a>.</p>
Expand Down Expand Up @@ -882,7 +882,7 @@ const sendNewInternalTicketMessageAssignedStaffNotification = (recipientAddresse
subject: `New Internal Message on Support Ticket (P: ${priority}) (ID #${ticketID.slice(-7)})`,
html: `
<p>Hi,</p>
<p>A new internal message has been posted to a support ticket you are assigned to: "${subject}"</p>
<p>A new internal message has been posted to a support ticket you have subscribed to: "${subject}"</p>
<br />
<p><strong>${messageSender}</strong> said:</p>
<p>${message}</p>
Expand Down
142 changes: 113 additions & 29 deletions server/api/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { ZodReqWithFiles } from "../types/Express";
import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
import base64 from "base-64";
import Organization from "../models/organization.js";
import auth from "./auth";

export const SUPPORT_FILES_S3_CLIENT_CONFIG: S3ClientConfig = {
credentials: {
Expand Down Expand Up @@ -411,11 +412,27 @@ async function assignTicket(
.select("firstName lastName")
.orFail();

const ticket = await SupportTicket.findOne({ uuid }).orFail();

if (!assigned || assigned.length === 0) {
// If no assignees, remove all assignees and set status to open
await SupportTicket.updateOne(
{ uuid },
{
assignedUUIDs: [],
status: "in_progress",
}
).orFail();

return res.send({
err: false,
ticket,
});
}

const assignees = await User.find({ uuid: { $in: assigned } }).orFail();
const assigneeEmails = assignees.map((a) => a.email);

const ticket = await SupportTicket.findOne({ uuid }).orFail();

// Check that ticket is open or in progress
if (!["open", "in_progress"].includes(ticket.status)) {
return res.status(400).send({
Expand Down Expand Up @@ -852,42 +869,75 @@ async function createGeneralMessage(
type: "general",
});

// If user was found and user is not the ticket author, send a notification to the ticket author
if (foundUser && foundUser.uuid !== ticket.userUUID) {
const emailToNotify = await _getTicketAuthorEmail(ticket);
if (!emailToNotify) return conductor500Err(res);
// Notify all users who have previously commented on the ticket, are assigned to the ticket, or the ticket author
const previousCommenters = await SupportTicketMessage.find({
ticket: ticket.uuid,
}).distinct("senderUUID");

const allUUIDs = [
...new Set([
...(ticket.assignedUUIDs ?? []),
...previousCommenters,
ticket.userUUID,
]),
];

const emailsToNotify = await _getEmails(allUUIDs);

// Remove the comment author from the list of emails to notify
if (foundUser && foundUser.uuid) {
const index = emailsToNotify.indexOf(foundUser.email);
if (index > -1) {
emailsToNotify.splice(index, 1);
}
}

// If the user was not found and the ticket has a guest, assume the guest is the comment author
// if (!foundUser && ticket.guest?.email) {
// const index = emailsToNotify.indexOf(ticket.guest.email);
// if (index > -1) {
// emailsToNotify.splice(index, 1);
// }
// }

// Filter null or undefined emails
const filteredEmails = emailsToNotify.filter((e) => e);
if (!filteredEmails) return conductor500Err(res);

// If the ticket has a guest, but the commenter was found, send a notification to the guest
// otherwise, we assume the commenter is the guest
const senderName = foundUser
? `${foundUser?.firstName} ${foundUser?.lastName}`
: `${ticket.guest?.firstName} ${ticket.guest?.lastName}`;
if (ticket.guest?.email && foundUser?.uuid) {
const params = new URLSearchParams();
params.append("accessKey", ticket.guestAccessKey);
const addParams = !foundUser?.uuid ? true : false; // if guest, append access key to ticket path

const senderName = `${foundUser.firstName} ${foundUser.lastName}`;

await mailAPI.sendNewTicketMessageNotification(
[emailToNotify],
[ticket.guest.email],
ticket.uuid,
message,
senderName,
addParams ? params.toString() : ""
params.toString()
);
}

// If user was found and user is the ticket author, send a notification to assigned staff
if ((foundUser && foundUser.uuid === ticket.userUUID) || !foundUser) {
const teamToNotify = await _getAssignedStaffEmails(ticket.assignedUUIDs);
if (teamToNotify.length > 0) {
await mailAPI.sendNewTicketMessageAssignedStaffNotification(
teamToNotify,
ticket.uuid,
message,
foundUser
? `${foundUser.firstName} ${foundUser.lastName}`
: `${ticket.guest?.firstName} ${ticket.guest?.lastName}`,
capitalizeFirstLetter(ticket.priority),
ticket.title
);
}
// If no other emails to notify, return (ie ticket author commented on their own ticket before ticket was assigned to staff)
if (filteredEmails.length === 0) {
return res.send({
err: false,
message: ticketMessage,
});
}

await mailAPI.sendNewTicketMessageNotification(
filteredEmails,
ticket.uuid,
message,
senderName,
""
);

return res.send({
err: false,
message: ticketMessage,
Expand Down Expand Up @@ -945,16 +995,28 @@ async function createInternalMessage(
type: "internal",
});

const allTeamEmails = await _getAssignedStaffEmails(ticket.assignedUUIDs);
const teamToNotify = allTeamEmails.filter((e) => e !== foundUser.email); // remove the sender from the list of emails to notify
const previousCommenters = await SupportTicketMessage.find({
ticket: ticket.uuid,
}).distinct("senderUUID");

const allUUIDs = [
...new Set([
...(ticket.assignedUUIDs ?? []),
...previousCommenters,
ticket.userUUID,
]),
];

const emailsToNotify = await _getEmails(allUUIDs, true); // only notify staff
const teamToNotify = emailsToNotify.filter((e) => e !== foundUser.email); // remove the sender from the list of emails to notify
if (teamToNotify.length > 0) {
await mailAPI.sendNewInternalTicketMessageAssignedStaffNotification(
teamToNotify,
ticket.uuid,
message,
foundUser
? `${foundUser.firstName} ${foundUser.lastName}`
: `${ticket.guest?.firstName} ${ticket.guest?.lastName}`,
: "Unknown Commenter",
capitalizeFirstLetter(ticket.priority),
ticket.title
);
Expand Down Expand Up @@ -1065,6 +1127,28 @@ const _getTicketAuthorEmail = async (
return undefined;
};

const _getEmails = async (
uuids: string[],
staffOnly = false
): Promise<string[]> => {
try {
if (!uuids || uuids.length === 0) return [];
const users = await User.find({
uuid: { $in: uuids },
});

if (staffOnly) {
return users
.filter((u) => auth.checkHasRole(u, "libretexts", "support", true))
.map((u) => u.email);
}

return users.map((u) => u.email);
} catch (err) {
throw err;
}
};

const _getTicketAuthorString = (
emailToNotify: string,
foundUser?: UserInterface,
Expand Down
2 changes: 1 addition & 1 deletion server/api/validators/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const GetClosedTicketsValidator = z.object({
export const AssignTicketValidator = z
.object({
body: z.object({
assigned: z.array(z.string().uuid()).min(1).max(25),
assigned: z.array(z.string().uuid()).min(0).max(25),
}),
})
.merge(TicketUUIDParams);
Expand Down
Loading