mirror of
https://github.com/mosh-hamedani/helpdesk.git
synced 2026-05-21 11:58:19 +02:00
Add the ability to receive tickets
This commit is contained in:
parent
2c0af42e20
commit
f76a78dd1b
|
|
@ -44,6 +44,7 @@ The client proxies `/api/*` requests to the server via Vite config (target is co
|
|||
- Organize server endpoints into Express `Router` modules under `server/src/routes/` (e.g. `routes/users.ts`), mounted in `index.ts`
|
||||
- Define shared Zod schemas in the `core` package under `core/schemas/` (e.g. `core/schemas/users.ts`) and import them in both client and server (e.g. `import { createUserSchema } from "core/schemas/users"`)
|
||||
- Use Zod for validation (import from `zod/v4`)
|
||||
- Validate request bodies in route handlers using the shared `validate` helper (`import { validate } from "../lib/validate"`). It takes a Zod schema, the request body, and the `res` object — returns parsed data or `null` (after sending a 400 response).
|
||||
- Do not wrap async route handlers in try/catch — Express 5 automatically catches rejected promises
|
||||
- Use the shared `Role` constant instead of hardcoded `"admin"` / `"agent"` strings (import from `core/constants/role.ts`, e.g. `import { Role } from "core/constants/role.ts"`)
|
||||
- Use React Hook Form with Zod resolver for client-side form validation (`useForm` + `zodResolver` from `@hookform/resolvers/zod`)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
export const Role = {
|
||||
admin: "admin",
|
||||
agent: "agent",
|
||||
} as const;
|
||||
|
||||
export type Role = (typeof Role)[keyof typeof Role];
|
||||
export enum Role {
|
||||
admin = "admin",
|
||||
agent = "agent",
|
||||
}
|
||||
|
|
|
|||
5
core/constants/ticket-category.ts
Normal file
5
core/constants/ticket-category.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum TicketCategory {
|
||||
general_question = "general_question",
|
||||
technical_question = "technical_question",
|
||||
refund_request = "refund_request",
|
||||
}
|
||||
5
core/constants/ticket-status.ts
Normal file
5
core/constants/ticket-status.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum TicketStatus {
|
||||
open = "open",
|
||||
resolved = "resolved",
|
||||
closed = "closed",
|
||||
}
|
||||
11
core/schemas/tickets.ts
Normal file
11
core/schemas/tickets.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { z } from "zod/v4";
|
||||
|
||||
export const inboundEmailSchema = z.object({
|
||||
from: z.email("Invalid email address"),
|
||||
fromName: z.string().trim().min(1, "Sender name is required"),
|
||||
subject: z.string().trim().min(1, "Subject is required"),
|
||||
body: z.string().min(1, "Body is required"),
|
||||
bodyHtml: z.string().optional(),
|
||||
});
|
||||
|
||||
export type InboundEmailInput = z.infer<typeof inboundEmailSchema>;
|
||||
|
|
@ -12,5 +12,7 @@ BETTER_AUTH_URL="http://localhost:3000"
|
|||
|
||||
TRUSTED_ORIGINS="http://localhost:5173"
|
||||
|
||||
WEBHOOK_SECRET="" # Required for inbound email webhook
|
||||
|
||||
SEED_ADMIN_EMAIL="admin@example.com"
|
||||
SEED_ADMIN_PASSWORD="" # Use a strong password
|
||||
|
|
@ -7,5 +7,7 @@ TRUSTED_ORIGINS="http://localhost:5174"
|
|||
|
||||
PORT=3001
|
||||
|
||||
WEBHOOK_SECRET="test-webhook-secret"
|
||||
|
||||
SEED_ADMIN_EMAIL="admin@example.com"
|
||||
SEED_ADMIN_PASSWORD="password123"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "TicketStatus" AS ENUM ('open', 'resolved', 'closed');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TicketCategory" AS ENUM ('general_question', 'technical_question', 'refund_request');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ticket" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"bodyHtml" TEXT,
|
||||
"status" "TicketStatus" NOT NULL DEFAULT 'open',
|
||||
"category" "TicketCategory",
|
||||
"senderName" TEXT NOT NULL,
|
||||
"senderEmail" TEXT NOT NULL,
|
||||
"assignedToId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ticket_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ticket" ADD CONSTRAINT "ticket_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -18,6 +18,18 @@ enum Role {
|
|||
agent
|
||||
}
|
||||
|
||||
enum TicketStatus {
|
||||
open
|
||||
resolved
|
||||
closed
|
||||
}
|
||||
|
||||
enum TicketCategory {
|
||||
general_question
|
||||
technical_question
|
||||
refund_request
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String
|
||||
|
|
@ -28,8 +40,9 @@ model User {
|
|||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
deletedAt DateTime?
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
assignedTickets Ticket[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
|
@ -67,6 +80,23 @@ model Account {
|
|||
@@map("account")
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
id Int @id @default(autoincrement())
|
||||
subject String
|
||||
body String
|
||||
bodyHtml String?
|
||||
status TicketStatus @default(open)
|
||||
category TicketCategory?
|
||||
senderName String
|
||||
senderEmail String
|
||||
assignedToId String?
|
||||
assignedTo User? @relation(fields: [assignedToId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("ticket")
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id
|
||||
identifier String
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { toNodeHandler } from "better-auth/node";
|
|||
import { auth } from "./lib/auth";
|
||||
import { requireAuth } from "./middleware/require-auth";
|
||||
import usersRouter from "./routes/users";
|
||||
import webhooksRouter from "./routes/webhooks";
|
||||
|
||||
if (!process.env.BETTER_AUTH_SECRET) {
|
||||
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
||||
|
|
@ -52,6 +53,11 @@ app.get("/api/me", requireAuth, (req, res) => {
|
|||
});
|
||||
|
||||
app.use("/api/users", usersRouter);
|
||||
app.use("/api/webhooks", webhooksRouter);
|
||||
|
||||
if (!process.env.WEBHOOK_SECRET) {
|
||||
console.warn("Warning: WEBHOOK_SECRET is not set. Webhook endpoints will return 500.");
|
||||
}
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on http://localhost:${port}`);
|
||||
|
|
|
|||
17
server/src/lib/validate.ts
Normal file
17
server/src/lib/validate.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Response } from "express";
|
||||
import type { ZodType } from "zod/v4";
|
||||
|
||||
export function validate<T>(
|
||||
schema: ZodType<T>,
|
||||
body: unknown,
|
||||
res: Response
|
||||
): T | null {
|
||||
const result = schema.safeParse(body);
|
||||
if (!result.success) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: result.error.issues[0]?.message ?? "Validation failed" });
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
19
server/src/middleware/require-webhook-secret.ts
Normal file
19
server/src/middleware/require-webhook-secret.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { RequestHandler } from "express";
|
||||
|
||||
export const requireWebhookSecret: RequestHandler = (req, res, next) => {
|
||||
const secret = process.env.WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
res.status(500).json({ error: "Webhook secret is not configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const provided =
|
||||
req.headers["x-webhook-secret"] || req.query.secret;
|
||||
|
||||
if (provided !== secret) {
|
||||
res.status(401).json({ error: "Invalid webhook secret" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
|
@ -1,21 +1,12 @@
|
|||
import { Router, type Response } from "express";
|
||||
import type { ZodType } from "zod/v4";
|
||||
import { Router } from "express";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
import { createUserSchema, updateUserSchema } from "core/schemas/users.ts";
|
||||
import { Role } from "core/constants/role.ts";
|
||||
import { requireAuth } from "../middleware/require-auth";
|
||||
import { requireAdmin } from "../middleware/require-admin";
|
||||
import { validate } from "../lib/validate";
|
||||
import prisma from "../db";
|
||||
|
||||
function validate<T>(schema: ZodType<T>, body: unknown, res: Response): T | null {
|
||||
const result = schema.safeParse(body);
|
||||
if (!result.success) {
|
||||
res.status(400).json({ error: result.error.issues[0]?.message ?? "Validation failed" });
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", requireAuth, requireAdmin, async (req, res) => {
|
||||
|
|
|
|||
46
server/src/routes/webhooks.ts
Normal file
46
server/src/routes/webhooks.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Router } from "express";
|
||||
import { inboundEmailSchema } from "core/schemas/tickets.ts";
|
||||
import { requireWebhookSecret } from "../middleware/require-webhook-secret";
|
||||
import { validate } from "../lib/validate";
|
||||
import prisma from "../db";
|
||||
|
||||
function stripSubjectPrefixes(subject: string): string {
|
||||
return subject.replace(/^(Re:\s*|Fwd:\s*)+/i, "").trim();
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/inbound-email", requireWebhookSecret, async (req, res) => {
|
||||
const data = validate(inboundEmailSchema, req.body, res);
|
||||
if (!data) return;
|
||||
|
||||
const normalizedSubject = stripSubjectPrefixes(data.subject);
|
||||
|
||||
// Check for existing open ticket from same sender with matching subject
|
||||
const existingTicket = await prisma.ticket.findFirst({
|
||||
where: {
|
||||
senderEmail: data.from,
|
||||
status: "open",
|
||||
subject: { equals: normalizedSubject, mode: "insensitive" },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTicket) {
|
||||
res.status(200).json({ ticket: existingTicket });
|
||||
return;
|
||||
}
|
||||
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: {
|
||||
subject: normalizedSubject,
|
||||
body: data.body,
|
||||
bodyHtml: data.bodyHtml ?? null,
|
||||
senderName: data.fromName,
|
||||
senderEmail: data.from,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({ ticket });
|
||||
});
|
||||
|
||||
export default router;
|
||||
Loading…
Reference in a new issue