diff --git a/src/app/api/juno-engine/tickets/email-customer/draft/route.ts b/src/app/api/juno-engine/tickets/email-customer/draft/route.ts new file mode 100644 index 0000000..bb41ad8 --- /dev/null +++ b/src/app/api/juno-engine/tickets/email-customer/draft/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" +import { draftCustomerReply } from "@/lib/automation/draftCustomerReply" + +const bodySchema = z.object({ + ticketId: z.string().trim().min(1).max(600), +}) + +function normalizeSubject(subject: string | null): string { + if (!subject) return "No subject" + let s = subject.trim() + while (true) { + const match = s.match(/^(re|fw|fwd):\s*/i) + if (!match) break + s = s.slice(match[0].length).trim() + } + return s || "No subject" +} + +function stripStorefrontEscalationPrefix(text: string): string { + return text.replace(/^Storefront escalation from visitor [^:]+:\s*/i, "").trim() +} + +async function getActiveStoreForUser(userId: string) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + activeStoreId: true, + stores: { + where: { isActive: true }, + take: 1, + select: { id: true, businessName: true }, + }, + }, + }) + const storeId = user?.activeStoreId ?? user?.stores[0]?.id ?? null + if (!storeId) return null + const store = await prisma.store.findUnique({ + where: { id: storeId }, + select: { id: true, businessName: true }, + }) + return store +} + +export async function POST(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + if (!isVendorPortalRole(session.user.role)) { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }) + } + + const raw = await req.json().catch(() => ({})) + const parsed = bodySchema.safeParse(raw) + if (!parsed.success) { + return NextResponse.json( + { message: "Invalid body", issues: parsed.error.flatten() }, + { status: 400 }, + ) + } + + const store = await getActiveStoreForUser(session.user.id) + if (!store) { + return NextResponse.json({ message: "No active store" }, { status: 400 }) + } + + const splitAt = parsed.data.ticketId.indexOf("::") + const customerEmailRaw = + splitAt >= 0 ? parsed.data.ticketId.slice(0, splitAt) : parsed.data.ticketId + const rootSubjectRaw = splitAt >= 0 ? parsed.data.ticketId.slice(splitAt + 2) : "" + const customerEmail = (customerEmailRaw || "").trim().toLowerCase() + const rootSubject = (rootSubjectRaw || "").trim() + if (!customerEmail || !rootSubject) { + return NextResponse.json({ message: "Invalid ticket id" }, { status: 400 }) + } + + const logs = await prisma.$queryRaw< + Array<{ subject: string | null; bodyPreview: string | null; sentAt: Date; trigger: string | null }> + >` + SELECT subject, "bodyPreview", "sentAt", trigger + FROM "EmailLog" + WHERE "storeId" = ${store.id} + AND LOWER(TRIM("to")) = ${customerEmail} + ORDER BY "sentAt" DESC + LIMIT 80 + ` + + const matched = logs.filter((l) => normalizeSubject(l.subject) === rootSubject).slice(0, 12) + if (matched.length === 0) { + return NextResponse.json({ message: "No ticket messages found" }, { status: 404 }) + } + + const conversation = matched + .reverse() + .map((m, idx) => { + const body = stripStorefrontEscalationPrefix((m.bodyPreview || "").trim()) + const role = m.trigger === "GMAIL_INCOMING" ? "Customer" : "Vendor" + return `${idx + 1}. ${role}: ${body || "(empty)"}` + }) + .join("\n") + + const drafted = await draftCustomerReply({ + storeName: store.businessName || "Our store", + customerEmail, + ticketSubject: rootSubject, + conversation, + }) + + if (!drafted) { + return NextResponse.json({ message: "Draft unavailable" }, { status: 503 }) + } + + return NextResponse.json(drafted) + } catch (error) { + logger.error("email-customer draft failed", error) + return NextResponse.json({ message: "Failed to draft email" }, { status: 500 }) + } +} diff --git a/src/app/api/juno-engine/tickets/email-customer/send/route.ts b/src/app/api/juno-engine/tickets/email-customer/send/route.ts new file mode 100644 index 0000000..bab9ace --- /dev/null +++ b/src/app/api/juno-engine/tickets/email-customer/send/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { auth } from "@/auth" +import { isVendorPortalRole } from "@/lib/auth-roles" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" +import { sendGmailReply } from "@/lib/gmail/emailService" +import { + buildL1HtmlEmail, + parseGmailReplyThemeJson, + wrapGmailHtmlDocument, +} from "@/lib/gmail/emailReplyTheme" + +const bodySchema = z.object({ + ticketId: z.string().trim().min(1).max(600), + to: z.string().email(), + subject: z.string().trim().min(1).max(300), + body: z.string().trim().min(1).max(12000), +}) + +async function getActiveStoreId(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + activeStoreId: true, + stores: { where: { isActive: true }, take: 1, select: { id: true } }, + }, + }) + return user?.activeStoreId ?? user?.stores[0]?.id ?? null +} + +export async function POST(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + if (!isVendorPortalRole(session.user.role)) { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }) + } + + const raw = await req.json().catch(() => ({})) + const parsed = bodySchema.safeParse(raw) + if (!parsed.success) { + return NextResponse.json( + { message: "Invalid body", issues: parsed.error.flatten() }, + { status: 400 }, + ) + } + + const storeId = await getActiveStoreId(session.user.id) + if (!storeId) { + return NextResponse.json({ message: "No active store" }, { status: 400 }) + } + + const { to, subject, body } = parsed.data + const storeTheme = await prisma.store.findUnique({ + where: { id: storeId }, + select: { businessName: true, gmailReplyTheme: true }, + }) + const storeName = storeTheme?.businessName || "Our store" + const theme = parseGmailReplyThemeJson(storeTheme?.gmailReplyTheme) + const htmlBody = wrapGmailHtmlDocument(buildL1HtmlEmail(body, storeName, theme), theme) + const sent = await sendGmailReply( + session.user.id, + to.trim().toLowerCase(), + subject.trim(), + htmlBody, + undefined, + { + storeId, + isAutomated: false, + trigger: "TICKET_CUSTOMER_REPLY", + }, + ) + + if (!sent.success) { + return NextResponse.json( + { message: sent.error || "Failed to send email" }, + { status: 400 }, + ) + } + + return NextResponse.json({ success: true, messageId: sent.messageId }) + } catch (error) { + logger.error("email-customer send failed", error) + return NextResponse.json({ message: "Failed to send email" }, { status: 500 }) + } +} diff --git a/src/app/api/juno-engine/tickets/route.ts b/src/app/api/juno-engine/tickets/route.ts index 3e4a3bb..e64de01 100644 --- a/src/app/api/juno-engine/tickets/route.ts +++ b/src/app/api/juno-engine/tickets/route.ts @@ -20,6 +20,26 @@ function normalizeSubject(subject: string | null): string { return s || "No subject" } +function decodeHtmlEntities(input: string | null | undefined): string { + const text = (input || "").trim() + if (!text) return "" + return text + .replace(/&#(\d+);/g, (_, n: string) => { + const code = Number.parseInt(n, 10) + return Number.isFinite(code) ? String.fromCharCode(code) : _ + }) + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex: string) => { + const code = Number.parseInt(hex, 16) + return Number.isFinite(code) ? String.fromCharCode(code) : _ + }) + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") +} + export async function GET() { try { const session = await auth() @@ -84,6 +104,7 @@ export async function GET() { AND ( "isAutomated" = TRUE OR "trigger" = 'GMAIL_INCOMING' + OR "trigger" = 'TICKET_CUSTOMER_REPLY' ) ORDER BY "sentAt" DESC LIMIT 200 @@ -120,7 +141,7 @@ export async function GET() { const baseMessage: TicketMessage = { id: log.id, subject: log.subject || rootSubject, - bodyPreview: log.bodyPreview, + bodyPreview: decodeHtmlEntities(log.bodyPreview), status: log.status, trigger: log.trigger, sentAt: log.sentAt.toISOString(), @@ -136,7 +157,7 @@ export async function GET() { status: log.status, updatedAt: log.sentAt.toISOString(), messagesCount: 1, - lastMessageSnippet: log.bodyPreview || "", + lastMessageSnippet: decodeHtmlEntities(log.bodyPreview), messages: [baseMessage], }) } else { @@ -147,7 +168,17 @@ export async function GET() { } } - const tickets = Array.from(ticketsMap.values()) + const tickets = Array.from(ticketsMap.values()).filter((t) => { + const latest = t.messages[0] + const storefrontEscalationThread = t.messages.some((m) => m.trigger === "STOREFRONT_TICKET") + const resolvedByVendorEmail = latest?.trigger === "TICKET_CUSTOMER_REPLY" + // Hide resolved storefront escalation threads after vendor sends customer email. + // If customer replies later (GMAIL_INCOMING), latest trigger changes and thread reappears. + if (storefrontEscalationThread && resolvedByVendorEmail) { + return false + } + return true + }) return NextResponse.json({ tickets }) } catch (error) { diff --git a/src/app/api/storefront-chat/messages/route.ts b/src/app/api/storefront-chat/messages/route.ts index 13330ea..2090c7c 100644 --- a/src/app/api/storefront-chat/messages/route.ts +++ b/src/app/api/storefront-chat/messages/route.ts @@ -27,6 +27,23 @@ function shopVariants(cleanDomain: string) { return Array.from(v) } +const STOREFRONT_TICKET_TRIGGER = "STOREFRONT_TICKET" + +function shouldCreateEscalationTicket(ai: { content: string; meta?: { usedTools?: string[]; module?: string } }): boolean { + if (Array.isArray(ai.meta?.usedTools) && ai.meta.usedTools.includes("ticket_intake")) { + return true + } + if (ai.meta?.module === "HUMAN_ESCALATION") { + return true + } + const text = (ai.content || "").toLowerCase() + return ( + text.includes("human will follow up") || + text.includes("passed your request to our team") || + text.includes("passed your request to the team") + ) +} + export async function POST(req: Request) { try { const rate = await applyRateLimit(req, "public:storefront-chat", { @@ -141,6 +158,60 @@ export async function POST(req: Request) { select: { id: true, createdAt: true, senderType: true, content: true } }) + if (shouldCreateEscalationTicket(ai)) { + try { + const owner = await prisma.store.findUnique({ + where: { id: store.id }, + select: { userId: true }, + }) + const convoEmail = await prisma.storefrontChatConversation.findUnique({ + where: { id: conversation.id }, + select: { customerEmail: true }, + }) + const ticketTo = + convoEmail?.customerEmail?.trim().toLowerCase() || + customerEmail?.trim().toLowerCase() || + `visitor+${visitorId}@storefront.local` + const ticketSubject = `[Storefront] ${ai.meta.module} · ${conversation.id}` + + const existing = await prisma.emailLog.findFirst({ + where: { + storeId: store.id, + to: ticketTo, + subject: ticketSubject, + trigger: STOREFRONT_TICKET_TRIGGER, + }, + select: { id: true }, + }) + + if (existing) { + await prisma.emailLog.update({ + where: { id: existing.id }, + data: { + bodyPreview: trimmedContent.slice(0, 2000), + sentAt: new Date(), + status: "SENT", + }, + }) + } else if (owner?.userId) { + await prisma.emailLog.create({ + data: { + userId: owner.userId, + storeId: store.id, + to: ticketTo, + subject: ticketSubject, + bodyPreview: trimmedContent.slice(0, 2000), + isAutomated: true, + trigger: STOREFRONT_TICKET_TRIGGER, + status: "SENT", + }, + }) + } + } catch (ticketErr) { + console.error("[STOREFRONT_TICKET_CREATE_ERR]", ticketErr) + } + } + return NextResponse.json({ success: true, messages: [customerMessage, aiMessage], diff --git a/src/app/dashboard/juno-engine/tickets/[ticketId]/page.tsx b/src/app/dashboard/juno-engine/tickets/[ticketId]/page.tsx index 2a67193..9a12c45 100644 --- a/src/app/dashboard/juno-engine/tickets/[ticketId]/page.tsx +++ b/src/app/dashboard/juno-engine/tickets/[ticketId]/page.tsx @@ -9,6 +9,8 @@ import { MessageCircleQuestion, Send, Sparkles, + Mail, + X, } from "lucide-react" import { cn } from "@/lib/utils" @@ -72,6 +74,14 @@ export default function TicketDetailPage() { const [msgBySupplier, setMsgBySupplier] = useState>({}) const [draftingSupplierId, setDraftingSupplierId] = useState(null) const [sendingSupplierId, setSendingSupplierId] = useState(null) + const [supplierModalOpen, setSupplierModalOpen] = useState(false) + const [customerModalOpen, setCustomerModalOpen] = useState(false) + const [customerTo, setCustomerTo] = useState("") + const [customerSubject, setCustomerSubject] = useState("") + const [customerBody, setCustomerBody] = useState("") + const [draftingCustomer, setDraftingCustomer] = useState(false) + const [sendingCustomer, setSendingCustomer] = useState(false) + const [customerError, setCustomerError] = useState(null) useEffect(() => { const loadTicket = async () => { @@ -104,6 +114,12 @@ export default function TicketDetailPage() { void loadTicket() }, [params.ticketId]) + useEffect(() => { + if (!ticket) return + setCustomerTo(ticket.customerEmail || "") + setCustomerSubject(`Re: ${ticket.rootSubject || ticket.subject || "Your support request"}`) + }, [ticket]) + const loadAskSupplierPreview = async () => { const ref = orderRefInput.trim() if (!ref) { @@ -209,6 +225,68 @@ export default function TicketDetailPage() { } } + const draftCustomerEmail = async () => { + if (!ticket) return + setDraftingCustomer(true) + setCustomerError(null) + try { + const res = await fetch("/api/juno-engine/tickets/email-customer/draft", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ticketId: ticket.id }), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data?.message || "Draft failed") + if (typeof data.subject === "string" && data.subject.trim()) { + setCustomerSubject(data.subject) + } + if (typeof data.body === "string") { + setCustomerBody(data.body) + } + } catch (e) { + setCustomerError(e instanceof Error ? e.message : "Draft failed") + } finally { + setDraftingCustomer(false) + } + } + + const sendCustomerEmail = async () => { + if (!ticket) return + const to = customerTo.trim() + const subject = customerSubject.trim() + const body = customerBody.trim() + if (!to || !subject || !body) { + setCustomerError("To, subject, and body are required.") + return + } + setSendingCustomer(true) + setCustomerError(null) + try { + const res = await fetch("/api/juno-engine/tickets/email-customer/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ticketId: ticket.id, + to, + subject, + body, + }), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data?.message || "Send failed") + setCustomerModalOpen(false) + setCustomerBody("") + setCustomerError(null) + } catch (e) { + setCustomerError(e instanceof Error ? e.message : "Send failed") + } finally { + setSendingCustomer(false) + } + } + + const cleanDisplayText = (text: string): string => + text.replace(/^Storefront escalation from visitor [^:]+:\s*/i, "").trim() + const getStatusStyles = (status: TicketStatus | string) => { switch (status) { case "open": @@ -300,7 +378,247 @@ export default function TicketDetailPage() { - {/* Ask supplier (per supplier, optional) */} +
+ + +
+ + {/* Ask supplier modal */} + {supplierModalOpen && ( +
+
+
+

+ Chat with supplier +

+ +
+

+ Load suppliers for an order, then draft/send a per-supplier message. +

+
+ setOrderRefInput(e.target.value)} + className={cn( + "flex-1 h-10 px-3 rounded-xl border text-sm font-medium", + isLight + ? "bg-slate-50 border-slate-200 text-slate-900" + : "bg-white/5 border-white/10 text-white placeholder:text-slate-500", + )} + /> + +
+ {askPreviewError && ( +

{askPreviewError}

+ )} + {askPreview?.order && askPreview.groups.length > 0 && ( +
+

+ Order #{askPreview.order.orderNumber} — message each supplier separately. +

+ {askPreview.groups.map((g) => ( +
+
+ + {g.companyName} + +
+ + +
+
+
    + {g.lines.map((l) => ( +
  • + {l.title} + {l.sku ? ` (${l.sku})` : ""} × {l.quantity} +
  • + ))} +
+