mirror of
https://github.com/mosh-hamedani/helpdesk.git
synced 2026-05-21 11:58:19 +02:00
Add the ability to auto-resolve tickets
This commit is contained in:
parent
4d65778d59
commit
9d30a8ce80
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
150
server/knowledge-base.md
Normal 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
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "TicketStatus" ADD VALUE 'new';
|
||||||
|
ALTER TYPE "TicketStatus" ADD VALUE 'processing';
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ticket" ALTER COLUMN "status" SET DEFAULT 'new';
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
105
server/src/lib/auto-resolve-ticket.ts
Normal file
105
server/src/lib/auto-resolve-ticket.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
65
server/src/lib/classify-ticket.ts
Normal file
65
server/src/lib/classify-ticket.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue