+ savedReplies?: SavedReplySummary[]
+ savedReplyContext?: SavedReplyContext
}) {
const navigation = useNavigation()
const submit = useSubmit()
@@ -181,6 +187,9 @@ export function Ticket({
isInternal={msg.is_internal}
date={msg.created}
todayDate={todayDate}
+ canSaveReply={isAgent}
+ replyBody={msg.body}
+ replyContext={savedReplyContext}
>
- {/*
-
-
-
- Opgeslagen reacties
-
-
- */}
@@ -151,6 +141,14 @@ export function SidebarAdminHelpdesk({
+
+
+
+
+ Opgeslagen reacties
+
+
+
{isAdmin ? (
diff --git a/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx b/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx
index 65b46f569..d52d876c5 100644
--- a/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx
+++ b/fdm-app/app/routes/support._ticketviewer.ticket.$ticket_id.tsx
@@ -2,16 +2,21 @@ import { getFarm, getPrincipals } from "@nmi-agro/fdm-core"
import {
addMessage,
addTagToTicket,
+ applySavedReply,
assignTicket,
checkHelpdeskPermission,
+ createSavedReply,
createTag,
getAgentAvailabilityStatuses,
getAgents,
getAssigneesForTickets,
getMessagesForTicket,
+ getSavedReplies,
+ getSavedReply,
getTags,
getTagsForTickets,
getTicket,
+ makeSavedReplyBodySimple,
markTicketAsNotViewedByAll,
markTicketAsViewed,
removeTagFromTicket,
@@ -21,7 +26,7 @@ import {
updateTicketSubject,
} from "@nmi-agro/fdm-helpdesk"
import { useLoaderData } from "react-router"
-import { dataWithSuccess } from "remix-toast"
+import { dataWithSuccess, dataWithWarning } from "remix-toast"
import z from "zod"
import { getSession } from "@/app/lib/auth.server"
import { sendHelpdeskNewMessageEmail } from "@/app/lib/email.server"
@@ -32,6 +37,12 @@ import { AssigneeSchema } from "~/components/blocks/helpdesk/assignee-schema"
import { makeHelpdeskUser } from "~/components/blocks/helpdesk/helpdesk-user"
import { MessageSchema } from "~/components/blocks/helpdesk/message-schema"
import { notifyAboutReassignments } from "~/components/blocks/helpdesk/reassignment-notification.server"
+import {
+ ApplySavedReplySchema,
+ CreateSavedReplySchema,
+ FdmSavedReplyContext,
+ MakeSavedReplySchema,
+} from "~/components/blocks/helpdesk/saved-reply-schema"
import { TagSchema, TicketTagsSchema } from "~/components/blocks/helpdesk/tag-schema"
import { Ticket } from "~/components/blocks/helpdesk/ticket"
import {
@@ -81,7 +92,13 @@ export async function loader({ params, request }: Args) {
])
// If the user is able to change the agent stuff on the ticket, load the necessary data for forms
- const agents = isAgent ? await getAgents(fdm, session.principal_id, { isActive: true }) : []
+ const [agents, savedReplies] = isAgent
+ ? await Promise.all([
+ getAgents(fdm, session.principal_id),
+ getSavedReplies(fdm, session.principal_id),
+ ])
+ : [[], []]
+
// Combines absence + configured work days into one availability status per agent, so the UI
// can never show an agent as available on a day they're absent or not scheduled to work.
const agentAvailability = isAgent
@@ -147,6 +164,30 @@ export async function loader({ params, request }: Args) {
}
})
+ const contextFarmName = ticket.context_farm_id
+ ? await (async () => {
+ // This leaks the farm info to the agent, but it should be fine as long as the helpdesk permission checks are run properly.
+ const principal_id = [session.principal_id]
+ if (ticket.requester_id) principal_id.push(ticket.requester_id)
+ try {
+ const farm = await getFarm(fdm, principal_id, ticket.context_farm_id as string)
+ return farm.b_name_farm ?? null
+ } catch {
+ return null
+ }
+ })()
+ : null
+
+ // We could also make it an empty object for non-agents but it doesn't matter.
+ const savedReplyContext: Partial = {
+ farm_name: contextFarmName ?? undefined,
+ customer_name:
+ principalsSummarized.find((p) => p.principal_id === ticket.requester_id)?.displayUserName ??
+ undefined,
+ agent_name: session.user.name ?? undefined,
+ ticket_ref: ticket.ticket_ref,
+ }
+
return {
principal_id: session.principal_id,
ticket: ticket,
@@ -159,19 +200,9 @@ export async function loader({ params, request }: Args) {
agentAvailability: agentAvailability,
// To prevent hydration failed errors
todayDate: new Date(),
- contextFarmName: ticket.context_farm_id
- ? await (async () => {
- // This leaks the farm info to the agent, but it should be fine as long as the helpdesk permission checks are run properly.
- const principal_id = [session.principal_id]
- if (ticket.requester_id) principal_id.push(ticket.requester_id)
- try {
- const farm = await getFarm(fdm, principal_id, ticket.context_farm_id as string)
- return farm.b_name_farm ?? null
- } catch {
- return null
- }
- })()
- : null,
+ contextFarmName: contextFarmName,
+ savedReplies: savedReplies,
+ savedReplyContext: savedReplyContext,
}
} catch (err) {
throw handleLoaderError(err)
@@ -193,6 +224,9 @@ export const ActionSchema = z.discriminatedUnion("intent", [
MessageSchema.extend({ intent: z.literal("add_message") }),
TagSchema.extend({ intent: z.literal("create_tag") }),
TicketTagsSchema.extend({ intent: z.literal("set_tags") }),
+ MakeSavedReplySchema.extend({ intent: z.literal("make_saved_reply") }),
+ CreateSavedReplySchema.extend({ intent: z.literal("create_saved_reply") }),
+ ApplySavedReplySchema.extend({ intent: z.literal("apply_saved_reply") }),
])
export async function action({ params, request }: Args) {
@@ -437,10 +471,51 @@ export async function action({ params, request }: Args) {
void handleActionError(unreadError)
}
- return dataWithSuccess("Bericht ontvangen!", {
- message: "Bericht ontvangen!",
+ return dataWithSuccess(
+ { ok: true },
+ {
+ message: "Bericht ontvangen!",
+ },
+ )
+ }
+
+ if (formValues.intent === "make_saved_reply") {
+ const { body, ...context } = formValues
+
+ return {
+ body: makeSavedReplyBodySimple(body, context),
+ }
+ }
+
+ if (formValues.intent === "create_saved_reply") {
+ await createSavedReply(
+ fdm,
+ formValues.title,
+ formValues.body,
+ session.principal_id,
+ "generic",
+ formValues.is_shared,
+ )
+
+ return dataWithSuccess("Sjabloon is succesvol aangemaakt!", {
+ message: "Sjabloon is succesvol aangemaakt!",
})
}
+
+ if (formValues.intent === "apply_saved_reply") {
+ const { reply_id, ...context } = formValues
+ const savedReply = await getSavedReply(fdm, session.principal_id, reply_id)
+ const applied = applySavedReply(savedReply.body, context)
+ return dataWithWarning(
+ {
+ body: applied,
+ },
+ {
+ message:
+ "Hier is jouw berichtstext. Wees voorzichtig dat wat automatisch-ingevuld info onjuist kan zijn.",
+ },
+ )
+ }
} catch (err) {
// extractFormValuesFromRequest calls handleActionError itself, so if that is detected to be the case,
// return the response returned from it directly.
diff --git a/fdm-app/app/routes/support.settings.saved-replies.$reply_id.tsx b/fdm-app/app/routes/support.settings.saved-replies.$reply_id.tsx
new file mode 100644
index 000000000..23ccf4afd
--- /dev/null
+++ b/fdm-app/app/routes/support.settings.saved-replies.$reply_id.tsx
@@ -0,0 +1,86 @@
+import { checkHelpdeskPermission, getSavedReply, updateSavedReply } from "@nmi-agro/fdm-helpdesk"
+import { useLoaderData } from "react-router"
+import { redirectWithSuccess } from "remix-toast"
+import { FarmTitle } from "~/components/blocks/farm/farm-title"
+import { SavedReplyEditor } from "~/components/blocks/helpdesk/saved-reply-editor"
+import { CreateSavedReplySchema } from "~/components/blocks/helpdesk/saved-reply-schema"
+import { getSession } from "~/lib/auth.server"
+import { clientConfig } from "~/lib/config"
+import { handleActionError, handleLoaderError } from "~/lib/error"
+import { fdm } from "~/lib/fdm.server"
+import { extractFormValuesFromRequest } from "~/lib/form"
+import { Route } from "./+types/support.settings.saved-replies.$reply_id"
+
+// Meta
+export const meta: Route.MetaFunction = () => {
+ return [
+ {
+ title: `Opgeslagen reacties beheren - Ondersteuning | ${clientConfig.name}`,
+ },
+ {
+ name: "description",
+ content: "Bekijk de beschikbare opgeslagen reacties voor ondersteuningsreacties.",
+ },
+ ]
+}
+
+export async function loader({ params, request }: Route.LoaderArgs) {
+ try {
+ const session = await getSession(request)
+ const savedReply = await getSavedReply(fdm, session.principal_id, params.reply_id)
+ const savedReplyWritePermission = await checkHelpdeskPermission(
+ fdm,
+ "saved_reply",
+ "write",
+ params.reply_id,
+ session.principal_id,
+ "routes/support.settings.saved-replies.$reply_id",
+ false,
+ )
+
+ return { savedReply, savedReplyWritePermission }
+ } catch (err) {
+ throw handleLoaderError(err)
+ }
+}
+
+export async function action({ params, request }: Route.ActionArgs) {
+ try {
+ const formValues = await extractFormValuesFromRequest(request, CreateSavedReplySchema)
+
+ const session = await getSession(request)
+
+ await updateSavedReply(
+ fdm,
+ session.principal_id,
+ params.reply_id,
+ formValues.title,
+ formValues.body,
+ undefined,
+ formValues.is_shared,
+ )
+
+ return redirectWithSuccess("/support/settings/saved-replies", {
+ message: "Het sjabloon is bijgewerkt.",
+ })
+ } catch (err) {
+ throw handleActionError(err)
+ }
+}
+
+export default function ExistingSavedReply() {
+ const { savedReply, savedReplyWritePermission } = useLoaderData()
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/fdm-app/app/routes/support.settings.saved-replies._index.tsx b/fdm-app/app/routes/support.settings.saved-replies._index.tsx
new file mode 100644
index 000000000..8e2bb72be
--- /dev/null
+++ b/fdm-app/app/routes/support.settings.saved-replies._index.tsx
@@ -0,0 +1,115 @@
+import { getPrincipals } from "@nmi-agro/fdm-core"
+import {
+ checkHelpdeskPermission,
+ getSavedReplies,
+ deleteSavedReply,
+ getAgents,
+} from "@nmi-agro/fdm-helpdesk"
+import { useMemo } from "react"
+import { useLoaderData } from "react-router"
+import { dataWithSuccess } from "remix-toast"
+import z from "zod"
+import { FarmTitle } from "~/components/blocks/farm/farm-title"
+import { makeHelpdeskUser } from "~/components/blocks/helpdesk/helpdesk-user"
+import { DeleteSavedReplySchema } from "~/components/blocks/helpdesk/saved-reply-schema"
+import { HelpdeskSavedReplyTable } from "~/components/blocks/helpdesk/saved-reply-table"
+import { HelpdeskUser } from "~/components/blocks/helpdesk/types"
+import { getSession } from "~/lib/auth.server"
+import { clientConfig } from "~/lib/config"
+import { handleActionError, handleLoaderError } from "~/lib/error"
+import { fdm } from "~/lib/fdm.server"
+import { extractFormValuesFromRequest } from "~/lib/form"
+import type { Route } from "./+types/support.settings.saved-replies._index"
+
+// Meta
+export const meta: Route.MetaFunction = () => {
+ return [
+ {
+ title: `Opgeslagen reacties - Ondersteuning | ${clientConfig.name}`,
+ },
+ {
+ name: "description",
+ content: "Bekijk de beschikbare opgeslagen reacties voor ondersteuningstickets.",
+ },
+ ]
+}
+
+export async function loader({ request }: Route.LoaderArgs) {
+ try {
+ const session = await getSession(request)
+
+ const [savedReplies, helpdeskWritePermission] = await Promise.all([
+ getSavedReplies(fdm, session.principal_id),
+ checkHelpdeskPermission(
+ fdm,
+ "helpdesk",
+ "write",
+ "",
+ session.principal_id,
+ "routes/support.settings.saved-replies",
+ false,
+ ),
+ ])
+
+ const principalIds = new Set(savedReplies.map((reply) => reply.created_by))
+
+ const [agents, principals] = await Promise.all([
+ getAgents(fdm, session.principal_id),
+ getPrincipals(fdm, [...principalIds]),
+ ])
+
+ const helpdeskUsers: HelpdeskUser[] = agents.map((agent) => makeHelpdeskUser(agent, principals))
+
+ return {
+ savedReplies: savedReplies,
+ helpdeskWritePermission: helpdeskWritePermission,
+ principal_id: session.principal_id,
+ helpdeskUsers: helpdeskUsers,
+ }
+ } catch (err) {
+ throw handleLoaderError(err)
+ }
+}
+
+const ActionSchema = z.discriminatedUnion("intent", [DeleteSavedReplySchema])
+
+export async function action({ request }: Route.ActionArgs) {
+ try {
+ const session = await getSession(request)
+ const formValues = await extractFormValuesFromRequest(request, ActionSchema)
+
+ if (formValues.intent === "delete_saved_reply") {
+ await deleteSavedReply(fdm, session.principal_id, formValues.reply_id)
+
+ return dataWithSuccess(null, {
+ message: "Opgeslagen reactie is succesvol verwijderd!",
+ })
+ }
+ } catch (err) {
+ throw handleActionError(err)
+ }
+}
+
+export default function HelpdeskTagsSettings() {
+ const { savedReplies, helpdeskWritePermission, principal_id, helpdeskUsers } =
+ useLoaderData()
+
+ const principalLookup = useMemo(() => {
+ return new Map(helpdeskUsers.map((user) => [user.principal_id, user]))
+ }, [helpdeskUsers])
+
+ return (
+
+
+
+
+ )
+}
diff --git a/fdm-app/app/routes/support.settings.saved-replies.new.tsx b/fdm-app/app/routes/support.settings.saved-replies.new.tsx
new file mode 100644
index 000000000..1c99f0a20
--- /dev/null
+++ b/fdm-app/app/routes/support.settings.saved-replies.new.tsx
@@ -0,0 +1,77 @@
+import { checkHelpdeskPermission, createSavedReply } from "@nmi-agro/fdm-helpdesk"
+import { redirectWithSuccess } from "remix-toast"
+import { FarmTitle } from "~/components/blocks/farm/farm-title"
+import { SavedReplyEditor } from "~/components/blocks/helpdesk/saved-reply-editor"
+import { CreateSavedReplySchema } from "~/components/blocks/helpdesk/saved-reply-schema"
+import { getSession } from "~/lib/auth.server"
+import { clientConfig } from "~/lib/config"
+import { handleActionError, handleLoaderError } from "~/lib/error"
+import { fdm } from "~/lib/fdm.server"
+import { extractFormValuesFromRequest } from "~/lib/form"
+import { Route } from "./+types/support.settings.saved-replies.new"
+
+// Meta
+export const meta: Route.MetaFunction = () => {
+ return [
+ {
+ title: `Nieuwe opgeslagen reactie - Ondersteuning | ${clientConfig.name}`,
+ },
+ {
+ name: "description",
+ content: "Bekijk de beschikbare opgeslagen reacties voor ondersteuningstickets.",
+ },
+ ]
+}
+
+export async function loader({ request }: Route.LoaderArgs) {
+ try {
+ const session = await getSession(request)
+ await checkHelpdeskPermission(
+ fdm,
+ "helpdesk",
+ "read",
+ "",
+ session.principal_id,
+ "routes/support.settings.saved-replies.new",
+ true,
+ )
+ } catch (err) {
+ throw handleLoaderError(err)
+ }
+}
+
+export async function action({ request }: Route.ActionArgs) {
+ try {
+ const formValues = await extractFormValuesFromRequest(request, CreateSavedReplySchema)
+
+ const session = await getSession(request)
+
+ await createSavedReply(
+ fdm,
+ formValues.title,
+ formValues.body,
+ session.principal_id,
+ "generic",
+ formValues.is_shared,
+ )
+
+ return redirectWithSuccess("/support/settings/saved-replies", {
+ message: "Het sjabloon is succesvol aangemaakt! 🎉",
+ })
+ } catch (err) {
+ throw handleActionError(err)
+ }
+}
+
+export default function NewSavedReply() {
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/fdm-helpdesk/src/authorization.ts b/fdm-helpdesk/src/authorization.ts
index 31f9db3c2..5d544b0a2 100644
--- a/fdm-helpdesk/src/authorization.ts
+++ b/fdm-helpdesk/src/authorization.ts
@@ -178,7 +178,9 @@ export async function getHelpdeskPermission(
eq(schema.savedReplies.reply_id, resource_id),
or(
action === "read" ? schema.savedReplies.is_shared : sql`false`,
- inArray(schema.savedReplies.created_by, principal_ids),
+ role === "admin"
+ ? sql`true`
+ : inArray(schema.savedReplies.created_by, principal_ids),
),
),
)
diff --git a/fdm-helpdesk/src/index.ts b/fdm-helpdesk/src/index.ts
index 2b08da26c..181658050 100644
--- a/fdm-helpdesk/src/index.ts
+++ b/fdm-helpdesk/src/index.ts
@@ -7,6 +7,7 @@ export type * from "./fdm-helpdesk.types"
export type * from "./filter.types"
export * from "./message"
export * from "./migrate"
+export * from "./saved-reply"
export * from "./tag"
export * from "./ticket"
export * from "./ticket-assignment"
diff --git a/fdm-helpdesk/src/saved-reply.test.ts b/fdm-helpdesk/src/saved-reply.test.ts
new file mode 100644
index 000000000..7516062e6
--- /dev/null
+++ b/fdm-helpdesk/src/saved-reply.test.ts
@@ -0,0 +1,476 @@
+import { describe, expect } from "vitest"
+import { addAdminAgent, addAgent } from "./agent"
+import { createId } from "./id"
+import {
+ applySavedReply,
+ createSavedReply,
+ deleteSavedReply,
+ getSavedReplies,
+ getSavedReply,
+ makeSavedReplyBodySimple,
+ updateSavedReply,
+} from "./saved-reply"
+import { test, truncateAllTables } from "./test-util"
+
+describe("createSavedReply", () => {
+ let agent_id: string
+
+ test.beforeEach(async ({ fdm }) => {
+ agent_id = createId()
+ await addAdminAgent(fdm, agent_id, "Test Admin Agent")
+ })
+
+ test("should create a saved reply", async ({ fdm }) => {
+ await createSavedReply(
+ fdm,
+ "Polite Response",
+ "Dear sir/madam,\n\nWe are thankful to you for working with our company. We wish you are pleasant day.\n\nKind regards,\n{agent_name}",
+ agent_id,
+ )
+ })
+
+ test("should not let regular users create saved replies", async ({ fdm }) => {
+ await expect(
+ createSavedReply(
+ fdm,
+ "Polite Response",
+ "Dear sir/madam,\n\nWe are thankful to you for working with our company. We wish you are pleasant day.\n\nKind regards,\n{agent_name}",
+ createId(),
+ ),
+ ).rejects.toThrow("Principal does not have permission to perform this action")
+ })
+})
+
+describe("getSavedReplies", () => {
+ let admin_id: string
+ let agent_id: string
+
+ let reply_id_1: string
+ let reply_id_2: string
+ let reply_id_3: string
+
+ test.beforeEach(async ({ fdm }) => {
+ await truncateAllTables(fdm)
+
+ admin_id = createId()
+ await addAdminAgent(fdm, admin_id, "Test Admin Agent")
+ agent_id = createId()
+ await addAgent(fdm, admin_id, agent_id, "Test Regular Agent")
+
+ // Admin's private saved reply
+ reply_id_1 = await createSavedReply(
+ fdm,
+ "Impolite Response",
+ "Hey,\n\nCheers bro it was great knowing you m8.\n\nSee ya! ^o^/",
+ admin_id,
+ "common",
+ false,
+ )
+ // Admin's shared saved reply
+ reply_id_2 = await createSavedReply(
+ fdm,
+ "Polite Response",
+ "Dear sir/madam,\n\nWe are thankful to you for working with our company. We wish you are pleasant day.\n\nKind regards,\n{{agent_name}}",
+ admin_id,
+ "common",
+ true,
+ )
+ // Agent's private saved reply. The admin can also see this.
+ reply_id_3 = await createSavedReply(
+ fdm,
+ "Escalation",
+ "Dear {{customer_name}},\n\nI will be escalating this to one of our admins. Thank you for your patience.\n\nSincerely,\n{{agent_name}}",
+ agent_id,
+ "common",
+ false,
+ )
+ })
+
+ test("admins should be able to see all saved replies", async ({ fdm }) => {
+ const savedReplies = await getSavedReplies(fdm, admin_id)
+ expect(savedReplies).toHaveLength(3)
+ expect(savedReplies.some((r) => r.reply_id === reply_id_1)).toBe(true)
+ expect(savedReplies.some((r) => r.reply_id === reply_id_2)).toBe(true)
+ expect(savedReplies.some((r) => r.reply_id === reply_id_3)).toBe(true)
+ })
+
+ test("agents should be able to see only their own and shared saved replies", async ({ fdm }) => {
+ const savedReplies = await getSavedReplies(fdm, agent_id)
+ expect(savedReplies).toHaveLength(2)
+ expect(savedReplies.some((r) => r.reply_id === reply_id_2)).toBe(true)
+ expect(savedReplies.some((r) => r.reply_id === reply_id_3)).toBe(true)
+ })
+
+ test("regular users should get an exception when they try to get saved replies", async ({
+ fdm,
+ }) => {
+ await expect(getSavedReplies(fdm, createId())).rejects.toThrow(
+ "Principal does not have permission to perform this action",
+ )
+ })
+})
+
+describe("updateSavedReply", () => {
+ let agent_id: string
+ let other_agent_id: string
+ let other_admin_id: string
+ let private_reply_id: string
+ let shared_reply_id: string
+
+ test.beforeEach(async ({ fdm }) => {
+ other_admin_id = createId()
+ await addAdminAgent(fdm, other_admin_id, "Test Other Admin Agent")
+
+ agent_id = createId()
+ await addAgent(fdm, other_admin_id, agent_id, "Test Support Agent")
+
+ other_agent_id = createId()
+ await addAgent(fdm, other_admin_id, other_agent_id, "Test Other Support Agent")
+
+ private_reply_id = await createSavedReply(
+ fdm,
+ "Polite Response",
+ "Dear sir/madam,\n\nWe are thankful to you for working with our company. We wish you are pleasant day.\n\nKind regards,\n{agent_name}",
+ agent_id,
+ "generic",
+ false,
+ )
+
+ shared_reply_id = await createSavedReply(
+ fdm,
+ "Polite Response",
+ "Dear sir/madam,\n\nWe are thankful to you for working with our company. We wish you are pleasant day.\n\nKind regards,\n{agent_name}",
+ agent_id,
+ "generic",
+ true,
+ )
+ })
+
+ test("should let agents update their own saved replies", async ({ fdm }) => {
+ await updateSavedReply(
+ fdm,
+ agent_id,
+ private_reply_id,
+ "Greeting to the World",
+ "Hello, world",
+ "humanitarian",
+ true,
+ )
+
+ const updated = await getSavedReply(fdm, agent_id, private_reply_id)
+ expect(updated.body).toBe("Hello, world")
+ expect(updated.title).toBe("Greeting to the World")
+ expect(updated.category).toBe("humanitarian")
+ expect(updated.is_shared).toBe(true)
+ })
+
+ test("should let skip fields when updating", async ({ fdm }) => {
+ await updateSavedReply(fdm, agent_id, shared_reply_id)
+
+ const updated = await getSavedReply(fdm, agent_id, shared_reply_id)
+ expect(updated.title).toBe("Polite Response")
+ expect(updated.body).toBe(
+ "Dear sir/madam,\n\nWe are thankful to you for working with our company. We wish you are pleasant day.\n\nKind regards,\n{agent_name}",
+ )
+ expect(updated.category).toBe("generic")
+ expect(updated.is_shared).toBe(true)
+ })
+
+ test("should let admins edit any saved reply", async ({ fdm }) => {
+ await updateSavedReply(
+ fdm,
+ other_admin_id,
+ private_reply_id,
+ "Greeting to the World",
+ "Hello, world",
+ "humanitarian",
+ true,
+ )
+
+ const updated = await getSavedReply(fdm, other_admin_id, private_reply_id)
+ expect(updated.body).toBe("Hello, world")
+ expect(updated.title).toBe("Greeting to the World")
+ expect(updated.category).toBe("humanitarian")
+ expect(updated.is_shared).toBe(true)
+ })
+
+ test("should not let agents edit other agent's saved replies", async ({ fdm }) => {
+ await expect(
+ updateSavedReply(
+ fdm,
+ other_agent_id,
+ private_reply_id,
+ "Greeting to the World",
+ "Hello, world",
+ "humanitarian",
+ true,
+ ),
+ ).rejects.toThrow("Principal does not have permission to perform this action")
+ })
+
+ test("should not let agents delete other agent's shared saved reply", async ({ fdm }) => {
+ await expect(
+ updateSavedReply(
+ fdm,
+ other_agent_id,
+ shared_reply_id,
+ "Greeting to the World",
+ "Hello, world",
+ "humanitarian",
+ true,
+ ),
+ ).rejects.toThrow("Principal does not have permission to perform this action")
+ })
+
+ test("should not let regular users update saved replies", async ({ fdm }) => {
+ await expect(
+ updateSavedReply(
+ fdm,
+ createId(),
+ private_reply_id,
+ "Greeting to the World",
+ "Hello, world",
+ "humanitarian",
+ true,
+ ),
+ ).rejects.toThrow("Principal does not have permission to perform this action")
+ })
+
+ test("should not let update a nonexistent saved reply", async ({ fdm }) => {
+ await expect(
+ updateSavedReply(
+ fdm,
+ other_admin_id,
+ createId(),
+ "Greeting to the World",
+ "Hello, world",
+ "humanitarian",
+ true,
+ ),
+ ).rejects.toThrow("Principal does not have permission to perform this action")
+ })
+})
+
+describe("deleteSavedReply", () => {
+ let agent_id: string
+ let other_agent_id: string
+ let other_admin_id: string
+ let private_reply_id: string
+ let shared_reply_id: string
+
+ test.beforeEach(async ({ fdm }) => {
+ other_admin_id = createId()
+ await addAdminAgent(fdm, other_admin_id, "Test Other Admin Agent")
+
+ agent_id = createId()
+ await addAgent(fdm, other_admin_id, agent_id, "Test Support Agent")
+
+ other_agent_id = createId()
+ await addAgent(fdm, other_admin_id, other_agent_id, "Test Other Support Agent")
+
+ private_reply_id = await createSavedReply(
+ fdm,
+ "Polite Response",
+ "Dear sir/madam,\n\nWe are thankful to you for working with our company. We wish you are pleasant day.\n\nKind regards,\n{agent_name}",
+ agent_id,
+ "generic",
+ false,
+ )
+
+ shared_reply_id = await createSavedReply(
+ fdm,
+ "Polite Response",
+ "Dear sir/madam,\n\nWe are thankful to you for working with our company. We wish you are pleasant day.\n\nKind regards,\n{agent_name}",
+ agent_id,
+ "generic",
+ true,
+ )
+ })
+
+ test("should let agents delete their own saved replies", async ({ fdm }) => {
+ await deleteSavedReply(fdm, agent_id, private_reply_id)
+
+ await expect(getSavedReply(fdm, agent_id, private_reply_id)).rejects.toThrow(
+ "Principal does not have permission to perform this action",
+ )
+ })
+
+ test("should let admins delete any saved reply", async ({ fdm }) => {
+ await deleteSavedReply(fdm, other_admin_id, private_reply_id)
+
+ await expect(getSavedReply(fdm, other_admin_id, private_reply_id)).rejects.toThrow(
+ "Principal does not have permission to perform this action",
+ )
+ })
+
+ test("should not let agents delete other agent's saved replies", async ({ fdm }) => {
+ await expect(deleteSavedReply(fdm, other_agent_id, private_reply_id)).rejects.toThrow(
+ "Principal does not have permission to perform this action",
+ )
+ })
+
+ test("should not let agents delete other agent's shared saved replies", async ({ fdm }) => {
+ await expect(deleteSavedReply(fdm, other_agent_id, shared_reply_id)).rejects.toThrow(
+ "Principal does not have permission to perform this action",
+ )
+ })
+
+ test("should not let regular users delete saved replies", async ({ fdm }) => {
+ await expect(deleteSavedReply(fdm, createId(), private_reply_id)).rejects.toThrow(
+ "Principal does not have permission to perform this action",
+ )
+ })
+
+ test("should not let delete a nonexistent saved reply", async ({ fdm }) => {
+ await expect(deleteSavedReply(fdm, other_admin_id, createId())).rejects.toThrow(
+ "Principal does not have permission to perform this action",
+ )
+ })
+})
+
+describe("getSavedReply", () => {
+ let admin_id: string
+ let agent_id: string
+
+ let reply_id_1: string
+ let reply_id_2: string
+ let reply_id_3: string
+
+ test.beforeEach(async ({ fdm }) => {
+ await truncateAllTables(fdm)
+
+ admin_id = createId()
+ await addAdminAgent(fdm, admin_id, "Test Admin Agent")
+ agent_id = createId()
+ await addAgent(fdm, admin_id, agent_id, "Test Regular Agent")
+
+ // Admin's private saved reply
+ reply_id_1 = await createSavedReply(
+ fdm,
+ "Impolite Response",
+ "Hey,\n\nCheers bro it was great knowing you m8.\n\nSee ya! ^o^/",
+ admin_id,
+ "common",
+ false,
+ )
+ // Admin's shared saved reply
+ reply_id_2 = await createSavedReply(
+ fdm,
+ "Polite Response",
+ "Dear sir/madam,\n\nWe are thankful to you for working with our company. We wish you are pleasant day.\n\nKind regards,\n{{agent_name}}",
+ admin_id,
+ "common",
+ true,
+ )
+ // Agent's private saved reply. The admin can also see this.
+ reply_id_3 = await createSavedReply(
+ fdm,
+ "Escalation",
+ "Dear {{customer_name}},\n\nI will be escalating this to one of our admins. Thank you for your patience.\n\nSincerely,\n{{agent_name}}",
+ agent_id,
+ "common",
+ false,
+ )
+ })
+
+ test("should get agent's own saved reply", async ({ fdm }) => {
+ const savedReply = await getSavedReply(fdm, agent_id, reply_id_3)
+ expect(savedReply).toBeDefined()
+ expect(savedReply?.reply_id).toBe(reply_id_3)
+ })
+
+ test("should get other agent's shared saved reply", async ({ fdm }) => {
+ const savedReply = await getSavedReply(fdm, agent_id, reply_id_2)
+ expect(savedReply).toBeDefined()
+ expect(savedReply?.reply_id).toBe(reply_id_2)
+ })
+
+ test("should throw an error when getting a private saved reply of another agent", async ({
+ fdm,
+ }) => {
+ await expect(getSavedReply(fdm, agent_id, reply_id_1)).rejects.toThrow()
+ })
+})
+
+describe("makeSavedReplyBodySimple", () => {
+ test("should find the substitution points", () => {
+ const context = {
+ customer_name: "Jane Doe",
+ agent_name: "Bond, James Bond",
+ }
+
+ const message = `Hello Jane Doe,
+I hope you are having a great day. I won't help you.
+
+Sincerely,
+Bond,
+James Bond`
+ const savedReply = makeSavedReplyBodySimple(message, context)
+
+ expect(savedReply).toBe(`Hello {{customer_name}},
+I hope you are having a great day. I won't help you.
+
+Sincerely,
+{{agent_name}}`)
+ })
+
+ test("should handle substitutions that are punctuation-only", () => {
+ const context = {
+ signature: ",,,",
+ }
+
+ const message = "Greetings,,,"
+
+ expect(makeSavedReplyBodySimple(message, context)).toBe("Greetings,,,")
+ })
+
+ test("should handle empty substitutions", () => {
+ const context = {
+ empty: "",
+ }
+
+ const message = "This is an empty substitution: "
+
+ expect(makeSavedReplyBodySimple(message, context)).toBe("This is an empty substitution: ")
+ })
+
+ test("should handle invalid keys", () => {
+ const context = {
+ "invalid-key": "value",
+ }
+ const message = "This is an invalid key: "
+
+ expect(() => makeSavedReplyBodySimple(message, context)).toThrow()
+ })
+
+ test("should skip oversized substitution patterns and keep valid substitutions", () => {
+ const context = {
+ customer_name: "Jane Doe",
+ too_long: Array.from({ length: 200 }, (_, i) => `part${i}`).join(" "),
+ }
+
+ const message = "Hello Jane Doe"
+
+ expect(makeSavedReplyBodySimple(message, context)).toBe("Hello {{customer_name}}")
+ })
+})
+
+describe("applySavedReply", () => {
+ test("should replace the substitution points with the context values", () => {
+ const context = {
+ customer_name: "Jane Doe",
+ }
+ const savedReply = "Hello, {{customer_name}}!"
+
+ expect(applySavedReply(savedReply, context)).toBe("Hello, Jane Doe!")
+ })
+
+ test("should throw an error when there is a problem with a context key", () => {
+ const context = {
+ "@customer_name": "Jane Doe",
+ }
+ const savedReply = "Hello, {{@customer_name}}!"
+
+ expect(() => applySavedReply(savedReply, context)).toThrow("Exception for applySavedReply")
+ })
+})
diff --git a/fdm-helpdesk/src/saved-reply.ts b/fdm-helpdesk/src/saved-reply.ts
new file mode 100644
index 000000000..14be9cf91
--- /dev/null
+++ b/fdm-helpdesk/src/saved-reply.ts
@@ -0,0 +1,357 @@
+import { and, eq, inArray, or, sql } from "drizzle-orm"
+import type { HelpdeskPrincipalId } from "./authorization.types"
+import type { FdmHelpdeskType } from "./fdm-helpdesk.types"
+import { checkHelpdeskPermission } from "./authorization"
+import * as schema from "./db/schema-helpdesk"
+import { handleError } from "./error"
+import { createId } from "./id"
+
+export type SavedReply = schema.SavedReplyTypeSelect
+export type SavedReplySummary = Omit
+export type SavedReplyVariable = string
+export type SavedReplyContext = Partial<{ [k in SavedReplyVariable]: string }>
+
+/**
+ * Creates a new saved reply template that may be used to easily compose message text in the future.
+ *
+ * @param fdm The FDM instance providing the connection to the database. The instance can be created with
+ * {@link createFdmServer} of fdm-core.
+ * @param title Title for the saved reply. It is the label shown while choosing a saved reply.
+ * @param body Body of the saved reply. It contains the actual message text that will be used when composing
+ * a reply.
+ * @param createdBy Principal ID of the user who created the saved reply.
+ * @param category Optional category for the saved reply. It can be used to group similar replies together.
+ * @param isShared Indicates whether the saved reply is shared with other users.
+ * @returns The ID of the newly created saved reply.
+ */
+export async function createSavedReply(
+ fdm: FdmHelpdeskType,
+ title: schema.SavedReplyTypeInsert["title"],
+ body: schema.SavedReplyTypeInsert["body"],
+ createdBy: schema.SavedReplyTypeInsert["created_by"],
+ category?: schema.SavedReplyTypeInsert["category"],
+ isShared?: schema.SavedReplyTypeInsert["is_shared"],
+) {
+ try {
+ return await fdm.transaction(async (tx) => {
+ await checkHelpdeskPermission(tx, "helpdesk", "read", "", createdBy, "createSavedReply")
+ const reply_id = createId()
+ await tx.insert(schema.savedReplies).values({
+ reply_id: reply_id,
+ title: title,
+ body: body,
+ created_by: createdBy,
+ category: category,
+ is_shared: isShared,
+ })
+ return reply_id
+ })
+ } catch (err) {
+ throw handleError(err, "Exception for createSavedReply", {
+ title,
+ createdBy,
+ category,
+ isShared,
+ })
+ }
+}
+
+/**
+ * Updates an existing saved reply. Throws a permission error if the user does not have write access to the
+ * saved reply or if the saved reply does not exist.
+ *
+ * @param fdm The FDM instance providing the connection to the database. The instance can be created with
+ * {@link createFdmServer} of fdm-core.
+ * @param principal_id The principal identifier(s); supports a single ID or an array.
+ * @param reply_id ID of the reply to update.
+ * @param title New title. May be set to undefined to leave the title unchanged.
+ * @param body New body. May be set to undefined to leave the body unchanged.
+ * @param category New category. May be set to undefined to leave the category unchanged.
+ * @param is_shared New shared status. May be set to undefined to leave the shared status unchanged.
+ */
+export async function updateSavedReply(
+ fdm: FdmHelpdeskType,
+ principal_id: HelpdeskPrincipalId,
+ reply_id: schema.SavedReplyTypeSelect["reply_id"],
+ title?: schema.SavedReplyTypeInsert["title"],
+ body?: schema.SavedReplyTypeInsert["body"],
+ category?: schema.SavedReplyTypeInsert["category"],
+ is_shared?: schema.SavedReplyTypeInsert["is_shared"],
+) {
+ try {
+ await fdm.transaction(async (tx) => {
+ await checkHelpdeskPermission(
+ tx,
+ "saved_reply",
+ "write",
+ reply_id,
+ principal_id,
+ "updateSavedReply",
+ )
+
+ await tx
+ .update(schema.savedReplies)
+ .set({
+ title: title,
+ body: body,
+ category: category,
+ is_shared: is_shared,
+ updated: sql`now()`,
+ })
+ .where(eq(schema.savedReplies.reply_id, reply_id))
+ })
+ } catch (err) {
+ throw handleError(err, "Exception for updateSavedReply", {
+ principal_id,
+ reply_id,
+ title,
+ category,
+ is_shared,
+ })
+ }
+}
+
+/**
+ * Deletes an existing saved reply. Throws a permission error if the user does not have write access to the
+ * saved reply or if the saved reply does not exist.
+ *
+ * @param fdm The FDM instance providing the connection to the database. The instance can be created with
+ * {@link createFdmServer} of fdm-core.
+ * @param principal_id The principal identifier(s); supports a single ID or an array.
+ * @param reply_id ID of the reply to delete.
+ */
+export async function deleteSavedReply(
+ fdm: FdmHelpdeskType,
+ principal_id: HelpdeskPrincipalId,
+ reply_id: schema.SavedReplyTypeSelect["reply_id"],
+) {
+ try {
+ await fdm.transaction(async (tx) => {
+ await checkHelpdeskPermission(
+ tx,
+ "saved_reply",
+ "write",
+ reply_id,
+ principal_id,
+ "deleteSavedReply",
+ )
+
+ await tx.delete(schema.savedReplies).where(eq(schema.savedReplies.reply_id, reply_id))
+ })
+ } catch (err) {
+ throw handleError(err, "Exception for deleteSavedReply", {
+ principal_id,
+ reply_id,
+ })
+ }
+}
+
+/**
+ * Gets all reply templates that are saved on the helpdesk and the principal has access to. It throws a
+ * permission error if the principal is not an agent.
+ *
+ * @param fdm The FDM instance providing the connection to the database. The instance can be created with
+ * {@link createFdmServer} of fdm-core.
+ * @param principal_id The principal identifier(s); supports a single ID or an array.
+ * @param category Optional category to filter the saved replies.
+ * @returns A list of saved reply summaries that the principal has access to.
+ */
+export async function getSavedReplies(
+ fdm: FdmHelpdeskType,
+ principal_id: HelpdeskPrincipalId,
+ category?: schema.SavedReplyTypeSelect["category"],
+): Promise {
+ try {
+ await checkHelpdeskPermission(fdm, "helpdesk", "read", "", principal_id, "getSavedReplies")
+
+ const helpdeskWritePermission = await checkHelpdeskPermission(
+ fdm,
+ "helpdesk",
+ "write",
+ "",
+ principal_id,
+ "getSavedReplies",
+ false,
+ )
+
+ const principal_ids = Array.isArray(principal_id) ? principal_id : [principal_id]
+ return await fdm
+ .select({
+ reply_id: schema.savedReplies.reply_id,
+ title: schema.savedReplies.title,
+ category: schema.savedReplies.category,
+ created_by: schema.savedReplies.created_by,
+ is_shared: schema.savedReplies.is_shared,
+ usage_count: schema.savedReplies.usage_count,
+ created: schema.savedReplies.created,
+ updated: schema.savedReplies.updated,
+ })
+ .from(schema.savedReplies)
+ .where(
+ and(
+ sql`TRUE`,
+ !helpdeskWritePermission
+ ? or(
+ schema.savedReplies.is_shared,
+ inArray(schema.savedReplies.created_by, principal_ids),
+ )
+ : undefined,
+ typeof category === "string" ? eq(schema.savedReplies.category, category) : undefined,
+ ),
+ )
+ } catch (err) {
+ throw handleError(err, "Exception for getSavedReplies", { category })
+ }
+}
+
+/**
+ * Gets the saved reply with the given ID. Throws a permission error if the principal does not have access to
+ * the saved reply or if it does not exist.
+ *
+ * @param fdm The FDM instance providing the connection to the database. The instance can be created with
+ * {@link createFdmServer} of fdm-core.
+ * @param principal_id The principal identifier(s); supports a single ID or an array.
+ * @param reply_id ID of the reply to get.
+ * @returns a aved reply object with the body included.
+ */
+export async function getSavedReply(
+ fdm: FdmHelpdeskType,
+ principal_id: HelpdeskPrincipalId,
+ reply_id: string,
+) {
+ try {
+ await checkHelpdeskPermission(
+ fdm,
+ "saved_reply",
+ "read",
+ reply_id,
+ principal_id,
+ "getSavedReply",
+ )
+
+ return (
+ await fdm
+ .select()
+ .from(schema.savedReplies)
+ .where(and(eq(schema.savedReplies.reply_id, reply_id)))
+ )[0]
+ } catch (err) {
+ throw handleError(err, "Exception for getSavedReply", {
+ principal_id,
+ reply_id,
+ })
+ }
+}
+
+/**
+ * Ensures that the key is something that can be correctly substituted by replacing instances of {{key}} found
+ * in the saved reply body.
+ * @param expr Key to validate.
+ */
+function validateContextKey(expr: string) {
+ if (!/^[a-zA-Z0-9_]+$/.test(expr)) {
+ throw new Error(`Context key ${expr} is not in the expected format.`)
+ }
+}
+
+const punct = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
+ .split("")
+ .map((x) => `\\${x}`)
+ .join("")
+
+const savedReplyWordPattern = `([^\\s${punct}]+)`
+const savedReplySeparatorPattern = `(?:\\s|[${punct}])+`
+const maxSavedReplyRegexParts = 128
+const maxSavedReplyRegexPatternLength = 4096
+
+/**
+ * Builds the regex to match a given substitution value in the message body that is converted to a template.
+ *
+ * It tries to ignore whitespace differences and some punctuation in the source text, sometimes yielding more
+ * matches than just exact matching would.
+ *
+ * @param value The value to build the substitution regex for.
+ * @returns A RegExp object if successful, or null if the value cannot be used to build a regex.
+ */
+function buildSavedReplySubstitutionRegex(value: string): RegExp | null {
+ // If the matched expression is like Hello, World, it should match something like Hello\nworld too.
+ const parts = value.toLowerCase().match(new RegExp(savedReplyWordPattern, "ig"))
+ if (!parts) {
+ return null
+ }
+
+ const normalizedParts = parts.filter((x) => x.trim().length > 0)
+ if (normalizedParts.length === 0 || normalizedParts.length > maxSavedReplyRegexParts) {
+ return null
+ }
+
+ const pattern = normalizedParts.join(savedReplySeparatorPattern)
+ if (pattern.length > maxSavedReplyRegexPatternLength) {
+ return null
+ }
+
+ try {
+ return new RegExp(pattern, "ig")
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Replaces values found in the saved reply context with saved reply substitution points eagerly using regular
+ * expressions.
+ * This is intended as a substitute for AI-based saved reply generation until that is implemented.
+ *
+ * @param replyBody Reply body that normally contains the phrases found in the context
+ * @param context What would be passed to `applySavedReply` to obtain the provided message body.
+ * @returns String with substitution points like {{key_of_context}}. Might not be accurate so the user should
+ * be allowed to adjust the result before saving.
+ */
+export function makeSavedReplyBodySimple(replyBody: string, context: SavedReplyContext) {
+ for (const expr in context) {
+ validateContextKey(expr)
+ }
+
+ const entries = Object.entries(context)
+ .filter((a) => a[1] && a[1].trim().length > 1)
+ .map(([expr, value]) => {
+ return [expr, buildSavedReplySubstitutionRegex(value as string)]
+ })
+ .filter((a) => a[1] !== null) as [string, RegExp][]
+
+ // Try to find the longest substitutions first
+ entries.sort((a, b) => b[1].source.length - a[1].source.length)
+
+ let result = replyBody
+ for (const [expr, regExp] of entries) {
+ result = result.replaceAll(regExp, `{{${expr}}}`)
+ }
+
+ return result
+}
+
+/**
+ * Replaces instances of {{key}} in a saved reply body with the substitutions found in the context object.
+ * @param replyBody Saved reply body that contains the substitution points like {{key}}.
+ * @param context Map of keys to their substitutions. It is encouraged to standardize the set of available
+ * keys across the application.
+ * @returns Reply body with the substitutions applied. It might not be what the user has intended therefore
+ * they should be prompted to verify that the substitutions are in place before submitting. Substitution
+ * points with no value provided will be left as-is.
+ * @throws if the library is refusing to substitute one of the keys. Keys must consist of English alphanumeric
+ * characters and underscores, without any whitespace.
+ */
+export function applySavedReply(replyBody: string, context: SavedReplyContext): string {
+ try {
+ for (const expr in context) {
+ validateContextKey(expr)
+ }
+ let result = replyBody
+ for (const expr in context) {
+ result = result.replaceAll(`{{${expr}}}`, () => context[expr] ?? `{{${expr}}}`)
+ }
+ return result
+ } catch (err) {
+ throw handleError(err, "Exception for applySavedReply", { replyBody, context })
+ }
+}