Add the ability to auto-resolve tickets

This commit is contained in:
Moshfegh Hamedani 2026-02-27 08:57:43 -08:00
parent 4d65778d59
commit 9d30a8ce80
14 changed files with 362 additions and 71 deletions

View file

@ -76,7 +76,7 @@ export default function ReplyThread({ ticket }: ReplyThreadProps) {
}} }}
/> />
) : ( ) : (
<p>{reply.body}</p> <p className="whitespace-pre-line">{reply.body}</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View file

@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { type Ticket } from "core/constants/ticket.ts"; import { type Ticket } from "core/constants/ticket.ts";
import { ticketStatuses, statusLabel } from "core/constants/ticket-status.ts"; import { agentTicketStatuses, statusLabel } from "core/constants/ticket-status.ts";
import { ticketCategories, categoryLabel } from "core/constants/ticket-category.ts"; import { ticketCategories, categoryLabel } from "core/constants/ticket-category.ts";
import { import {
Select, Select,
@ -53,7 +53,7 @@ export default function UpdateTicket({ ticket }: UpdateTicketProps) {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{ticketStatuses.map((s) => ( {agentTicketStatuses.map((s) => (
<SelectItem key={s} value={s}> <SelectItem key={s} value={s}>
{statusLabel[s]} {statusLabel[s]}
</SelectItem> </SelectItem>

View file

@ -7,6 +7,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { agentTicketStatuses, statusLabel } from "core/constants/ticket-status.ts";
import type { TicketFilters } from "./TicketsPage"; import type { TicketFilters } from "./TicketsPage";
const ALL = "__all__"; const ALL = "__all__";
@ -43,9 +44,11 @@ export default function TicketsFilters({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={ALL}>All statuses</SelectItem> <SelectItem value={ALL}>All statuses</SelectItem>
<SelectItem value="open">Open</SelectItem> {agentTicketStatuses.map((s) => (
<SelectItem value="resolved">Resolved</SelectItem> <SelectItem key={s} value={s}>
<SelectItem value="closed">Closed</SelectItem> {statusLabel[s]}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>

View file

@ -1,14 +1,22 @@
export const ticketStatuses = ["open", "resolved", "closed"] as const; export const ticketStatuses = ["new", "processing", "open", "resolved", "closed"] as const;
export type TicketStatus = (typeof ticketStatuses)[number]; export type TicketStatus = (typeof ticketStatuses)[number];
export const agentTicketStatuses = ["open", "resolved", "closed"] as const;
export type AgentTicketStatus = (typeof agentTicketStatuses)[number];
export const statusLabel: Record<TicketStatus, string> = { export const statusLabel: Record<TicketStatus, string> = {
new: "New",
processing: "Processing",
open: "Open", open: "Open",
resolved: "Resolved", resolved: "Resolved",
closed: "Closed", closed: "Closed",
}; };
export const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> = { export const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> = {
new: "outline",
processing: "outline",
open: "default", open: "default",
resolved: "secondary", resolved: "secondary",
closed: "outline", closed: "outline",

View file

@ -1,5 +1,5 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { ticketStatuses } from "../constants/ticket-status"; import { agentTicketStatuses } from "../constants/ticket-status";
import { ticketCategories } from "../constants/ticket-category"; import { ticketCategories } from "../constants/ticket-category";
export const inboundEmailSchema = z.object({ export const inboundEmailSchema = z.object({
@ -24,14 +24,14 @@ export type TicketSortField = (typeof sortableColumns)[number];
export const updateTicketSchema = z.object({ export const updateTicketSchema = z.object({
assignedToId: z.string().nullable().optional(), assignedToId: z.string().nullable().optional(),
status: z.enum(ticketStatuses).optional(), status: z.enum(agentTicketStatuses).optional(),
category: z.enum(ticketCategories).nullable().optional(), category: z.enum(ticketCategories).nullable().optional(),
}); });
export const ticketListQuerySchema = z.object({ export const ticketListQuerySchema = z.object({
sortBy: z.enum(sortableColumns).default("createdAt"), sortBy: z.enum(sortableColumns).default("createdAt"),
sortOrder: z.enum(["asc", "desc"]).default("desc"), sortOrder: z.enum(["asc", "desc"]).default("desc"),
status: z.enum(ticketStatuses).optional(), status: z.enum(agentTicketStatuses).optional(),
category: z.enum(ticketCategories).optional(), category: z.enum(ticketCategories).optional(),
search: z.string().optional(), search: z.string().optional(),
page: z.coerce.number().int().min(1).default(1), page: z.coerce.number().int().min(1).default(1),

150
server/knowledge-base.md Normal file
View file

@ -0,0 +1,150 @@
# Code with Mosh -- Support Knowledge Base
*Last Updated: 2026*
This document contains official support policies and troubleshooting
guides for Code with Mosh courses.
------------------------------------------------------------------------
## 1. Account & Login Issues
### Q: I forgot my password. What should I do?
1. Go to the login page.
2. Click **Forgot Password**.
3. Enter your registered email address.
4. Follow the instructions in the reset email.
If you do not receive the email, check your spam or promotions folder.
------------------------------------------------------------------------
### Q: I'm not receiving the password reset email.
Possible reasons: - The email was entered incorrectly. - The account was
created using a different email. - The email is in your spam folder.
If the email does not arrive within 10 minutes, contact support.
------------------------------------------------------------------------
## 2. Course Access & Purchases
### Q: I purchased a course but cannot see it.
Possible reasons: - You are logged in with a different email address. -
The payment is still processing. - The purchase was not completed
successfully.
Check your receipt email for confirmation.
------------------------------------------------------------------------
### Q: Can I transfer a purchased course to another account?
Courses are non-transferable and tied to the purchasing account.
------------------------------------------------------------------------
## 3. Lifetime Access
### Q: What does Lifetime Access mean?
Lifetime Access means: - You pay once. - You keep access permanently. -
You receive future updates for that course.
Lifetime Access applies only to the purchased course.
------------------------------------------------------------------------
## 4. Refund Policy
### Q: What is the refund policy?
- 30-day money-back guarantee.
- Full refund if requested within 30 days of purchase.
- Partial refund available if less than 80% of the course has been
completed.
- No refund if 80% or more of the course has been completed.
Refunds are processed within 5--10 business days.
------------------------------------------------------------------------
### Q: How do I request a refund?
To request a refund: 1. Contact support within 30 days of purchase. 2.
Provide your order receipt. 3. Include the reason for your request.
------------------------------------------------------------------------
## 5. Certificates
### Q: Do you provide certificates?
Yes. A certificate is issued upon course completion.
Certificates are available inside your dashboard.
Certificates are not accredited degrees.
------------------------------------------------------------------------
## 6. Downloading Content
### Q: Can I download videos?
- Videos are streamed online.
- Source code is downloadable.
- Offline video downloads are not supported.
------------------------------------------------------------------------
## 7. Technical Issues
### Q: Videos are not playing.
Try the following: - Clear your browser cache. - Use the latest version
of Chrome or Edge. - Disable browser extensions. - Check your internet
connection.
------------------------------------------------------------------------
### Q: The video quality is low.
Video quality adjusts automatically based on internet speed. Ensure you
have a stable internet connection.
------------------------------------------------------------------------
## 8. Coupon Codes
### Q: My coupon code doesn't work.
Possible reasons: - The coupon has expired. - The coupon was already
used. - The coupon is not valid for the selected course.
Only one coupon may be applied per purchase.
------------------------------------------------------------------------
## 9. Account Changes
### Q: How do I change my email address?
Contact support and provide: - Your current email - Your new email -
Proof of purchase (if requested)
------------------------------------------------------------------------
## 10. Escalation Rules (Internal Policy)
The system must escalate to a human agent if: - The user threatens legal
action. - The user requests a refund outside the 30-day window. - The
user disputes a charge or mentions a chargeback. - The issue involves
account security concerns. - The system confidence score is low.
------------------------------------------------------------------------
End of Knowledge Base

View file

@ -0,0 +1,3 @@
-- AlterEnum
ALTER TYPE "TicketStatus" ADD VALUE 'new';
ALTER TYPE "TicketStatus" ADD VALUE 'processing';

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ticket" ALTER COLUMN "status" SET DEFAULT 'new';

View file

@ -19,6 +19,8 @@ enum Role {
} }
enum TicketStatus { enum TicketStatus {
new
processing
open open
resolved resolved
closed closed
@ -91,7 +93,7 @@ model Ticket {
subject String subject String
body String body String
bodyHtml String? bodyHtml String?
status TicketStatus @default(open) status TicketStatus @default(new)
category TicketCategory? category TicketCategory?
senderName String senderName String
senderEmail String senderEmail String

View file

@ -0,0 +1,105 @@
import fs from "fs";
import path from "path";
import type { PgBoss } from "pg-boss";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import prisma from "../db";
const QUEUE_NAME = "auto-resolve-ticket";
const knowledgeBase = fs.readFileSync(
path.join(import.meta.dirname, "../../knowledge-base.md"),
"utf-8"
);
interface AutoResolveJobData {
ticketId: number;
subject: string;
body: string;
senderName: string;
}
export async function registerAutoResolveWorker(boss: PgBoss): Promise<void> {
await boss.createQueue(QUEUE_NAME, {
retryLimit: 3,
retryDelay: 30,
retryBackoff: true,
});
await boss.work<AutoResolveJobData>(QUEUE_NAME, async (jobs) => {
const { ticketId, subject, body, senderName } = jobs[0]!.data;
const firstName = senderName.split(" ")[0];
await prisma.ticket.update({
where: { id: ticketId },
data: { status: "processing" },
});
let response: string;
try {
const { text } = await generateText({
model: openai("gpt-5-nano"),
system:
"You are a friendly and professional support agent for Code with Mosh. " +
"Use ONLY the following knowledge base to answer the customer's question.\n\n" +
knowledgeBase +
"\n\n" +
"Guidelines for your response:\n" +
`- Address the customer by their first name: ${firstName}\n` +
"- Use a warm, professional, and customer-friendly tone\n" +
"- Format the response clearly with line breaks between paragraphs\n" +
"- Use bullet points or numbered lists when listing steps or multiple items\n" +
"- End with an offer to help further, e.g. 'If you have any other questions, feel free to reach out.'\n" +
"- Sign off with:\n\nBest regards,\nCode with Mosh Support\n\n" +
"If the knowledge base does NOT contain enough information to fully resolve the question, " +
'respond with exactly "ESCALATE" and nothing else.',
prompt: `Subject: ${subject}\n\nBody: ${body}`,
});
response = text.trim();
} catch (error) {
console.error(`Auto-resolve AI call failed for ticket ${ticketId}:`, error);
await prisma.ticket.update({
where: { id: ticketId },
data: { status: "open" },
});
return;
}
if (response === "ESCALATE") {
await prisma.ticket.update({
where: { id: ticketId },
data: { status: "open" },
});
} else {
await prisma.$transaction([
prisma.reply.create({
data: {
body: response,
senderType: "agent",
ticketId,
userId: null,
},
}),
prisma.ticket.update({
where: { id: ticketId },
data: { status: "resolved" },
}),
]);
}
});
}
export async function sendAutoResolveJob(ticket: {
id: number;
subject: string;
body: string;
senderName: string;
}): Promise<void> {
const { boss } = await import("./queue");
await boss.send(QUEUE_NAME, {
ticketId: ticket.id,
subject: ticket.subject,
body: ticket.body,
senderName: ticket.senderName,
});
}

View file

@ -0,0 +1,65 @@
import type { PgBoss } from "pg-boss";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import {
ticketCategories,
type TicketCategory,
} from "core/constants/ticket-category.ts";
import prisma from "../db";
const QUEUE_NAME = "classify-ticket";
interface ClassifyJobData {
ticketId: number;
subject: string;
body: string;
}
export async function registerClassifyWorker(boss: PgBoss): Promise<void> {
await boss.createQueue(QUEUE_NAME, {
retryLimit: 3,
retryDelay: 30,
retryBackoff: true,
});
await boss.work<ClassifyJobData>(QUEUE_NAME, async (jobs) => {
const { ticketId, subject, body } = jobs[0]!.data;
const { text } = await generateText({
model: openai("gpt-5-nano"),
system:
"You are a support ticket classifier. " +
"Classify the ticket into exactly one of these categories: " +
`${ticketCategories.join(", ")}. ` +
"Return only the category value with no extra text.",
prompt: `Subject: ${subject}\n\nBody: ${body}`,
});
const category = text.trim() as TicketCategory;
if (!ticketCategories.includes(category)) {
console.warn(
`Invalid category "${text.trim()}" returned for ticket ${ticketId}`
);
return;
}
await prisma.ticket.update({
where: { id: ticketId },
data: { category },
});
});
}
export async function sendClassifyJob(ticket: {
id: number;
subject: string;
body: string;
}): Promise<void> {
const { boss } = await import("./queue");
await boss.send(QUEUE_NAME, {
ticketId: ticket.id,
subject: ticket.subject,
body: ticket.body,
});
}

View file

@ -1,13 +1,6 @@
import { PgBoss } from "pg-boss"; import { PgBoss } from "pg-boss";
import { generateText } from "ai"; import { registerClassifyWorker } from "./classify-ticket";
import { openai } from "@ai-sdk/openai"; import { registerAutoResolveWorker } from "./auto-resolve-ticket";
import {
ticketCategories,
type TicketCategory,
} from "core/constants/ticket-category.ts";
import prisma from "../db";
const QUEUE_NAME = "classify-ticket";
const boss = new PgBoss({ const boss = new PgBoss({
connectionString: process.env.DATABASE_URL!, connectionString: process.env.DATABASE_URL!,
@ -15,48 +8,13 @@ const boss = new PgBoss({
boss.on("error", console.error); boss.on("error", console.error);
interface ClassifyJobData { export { boss };
ticketId: number;
subject: string;
body: string;
}
export async function startQueue(): Promise<void> { export async function startQueue(): Promise<void> {
await boss.start(); await boss.start();
await boss.createQueue(QUEUE_NAME, { await registerClassifyWorker(boss);
retryLimit: 3, await registerAutoResolveWorker(boss);
retryDelay: 30,
retryBackoff: true,
});
await boss.work<ClassifyJobData>(QUEUE_NAME, async (jobs) => {
const { ticketId, subject, body } = jobs[0]!.data;
const { text } = await generateText({
model: openai("gpt-5-nano"),
system:
"You are a support ticket classifier. " +
"Classify the ticket into exactly one of these categories: " +
`${ticketCategories.join(", ")}. ` +
"Return only the category value with no extra text.",
prompt: `Subject: ${subject}\n\nBody: ${body}`,
});
const category = text.trim() as TicketCategory;
if (!ticketCategories.includes(category)) {
console.warn(
`Invalid category "${text.trim()}" returned for ticket ${ticketId}`
);
return;
}
await prisma.ticket.update({
where: { id: ticketId },
data: { category },
});
});
console.log("Job queue started"); console.log("Job queue started");
} }
@ -64,15 +22,3 @@ export async function startQueue(): Promise<void> {
export async function stopQueue(): Promise<void> { export async function stopQueue(): Promise<void> {
await boss.stop({ graceful: true, timeout: 30000 }); await boss.stop({ graceful: true, timeout: 30000 });
} }
export async function sendClassifyJob(ticket: {
id: number;
subject: string;
body: string;
}): Promise<void> {
await boss.send(QUEUE_NAME, {
ticketId: ticket.id,
subject: ticket.subject,
body: ticket.body,
});
}

View file

@ -16,6 +16,8 @@ router.get("/", requireAuth, async (req, res) => {
if (query.status) { if (query.status) {
where.status = query.status; where.status = query.status;
} else {
where.status = { in: ["open", "resolved", "closed"] };
} }
if (query.category) { if (query.category) {

View file

@ -3,7 +3,8 @@ import { inboundEmailSchema } from "core/schemas/tickets.ts";
import { requireWebhookSecret } from "../middleware/require-webhook-secret"; import { requireWebhookSecret } from "../middleware/require-webhook-secret";
import { validate } from "../lib/validate"; import { validate } from "../lib/validate";
import prisma from "../db"; import prisma from "../db";
import { sendClassifyJob } from "../lib/queue"; import { sendClassifyJob } from "../lib/classify-ticket";
import { sendAutoResolveJob } from "../lib/auto-resolve-ticket";
function stripSubjectPrefixes(subject: string): string { function stripSubjectPrefixes(subject: string): string {
return subject.replace(/^(Re:\s*|Fwd:\s*)+/i, "").trim(); return subject.replace(/^(Re:\s*|Fwd:\s*)+/i, "").trim();
@ -55,6 +56,10 @@ router.post("/inbound-email", requireWebhookSecret, async (req, res) => {
sendClassifyJob(ticket).catch((error) => sendClassifyJob(ticket).catch((error) =>
console.error(`Failed to enqueue classify job for ticket ${ticket.id}:`, error) console.error(`Failed to enqueue classify job for ticket ${ticket.id}:`, error)
); );
sendAutoResolveJob(ticket).catch((error) =>
console.error(`Failed to enqueue auto-resolve job for ticket ${ticket.id}:`, error)
);
}); });
export default router; export default router;