Add the ability to receive tickets

This commit is contained in:
Moshfegh Hamedani 2026-02-19 09:30:43 -08:00
parent 2c0af42e20
commit f76a78dd1b
14 changed files with 177 additions and 19 deletions

View file

@ -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`)

View file

@ -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",
}

View file

@ -0,0 +1,5 @@
export enum TicketCategory {
general_question = "general_question",
technical_question = "technical_question",
refund_request = "refund_request",
}

View file

@ -0,0 +1,5 @@
export enum TicketStatus {
open = "open",
resolved = "resolved",
closed = "closed",
}

11
core/schemas/tickets.ts Normal file
View 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>;

View file

@ -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

View file

@ -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"

View file

@ -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;

View file

@ -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

View file

@ -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}`);

View 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;
}

View 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();
};

View file

@ -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) => {

View 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;