diff --git a/prisma/migrations/20260412180000_supplier_automations/migration.sql b/prisma/migrations/20260412180000_supplier_automations/migration.sql new file mode 100644 index 0000000..af2972d --- /dev/null +++ b/prisma/migrations/20260412180000_supplier_automations/migration.sql @@ -0,0 +1,50 @@ +-- CreateEnum +CREATE TYPE "SupplierAutomationKind" AS ENUM ('LOW_STOCK', 'ASK_SUPPLIER'); + +-- AlterTable +ALTER TABLE "Store" ADD COLUMN "lowStockThreshold" INTEGER; + +-- CreateTable +CREATE TABLE "LowStockAlertState" ( + "id" TEXT NOT NULL, + "storeId" TEXT NOT NULL, + "shopifyProductId" TEXT NOT NULL, + "lastSeenTotalQty" INTEGER, + "lastNotifiedBelowAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LowStockAlertState_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SupplierAutomationLog" ( + "id" TEXT NOT NULL, + "storeId" TEXT NOT NULL, + "kind" "SupplierAutomationKind" NOT NULL, + "shopifyProductId" TEXT, + "supplierUserId" TEXT, + "ticketKey" TEXT, + "messagePreview" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SupplierAutomationLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "LowStockAlertState_storeId_shopifyProductId_key" ON "LowStockAlertState"("storeId", "shopifyProductId"); + +-- CreateIndex +CREATE INDEX "LowStockAlertState_storeId_idx" ON "LowStockAlertState"("storeId"); + +-- CreateIndex +CREATE INDEX "SupplierAutomationLog_storeId_kind_createdAt_idx" ON "SupplierAutomationLog"("storeId", "kind", "createdAt"); + +-- CreateIndex +CREATE INDEX "SupplierAutomationLog_storeId_shopifyProductId_supplierUserId_createdAt_idx" ON "SupplierAutomationLog"("storeId", "shopifyProductId", "supplierUserId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "LowStockAlertState" ADD CONSTRAINT "LowStockAlertState_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SupplierAutomationLog" ADD CONSTRAINT "SupplierAutomationLog_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index af3f2d4..a37e3aa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -132,6 +132,12 @@ model Store { /** Optional Gmail auto-reply HTML theming (colors, font, logo URL). Merged with defaults server-side. */ gmailReplyTheme Json? + /** + * When set (non-null), low-stock automation is on: sum of variant inventory_quantity below this + * triggers a B2B chat message to the ProductSync-linked supplier(s). + */ + lowStockThreshold Int? + // Relations connections Connection[] cachedProducts CachedProduct[] @@ -150,6 +156,8 @@ model Store { gmailInboundAutoReplies GmailInboundAutoReply[] templates Template[] chatMessages ChatMessage[] + lowStockAlertStates LowStockAlertState[] + supplierAutomationLogs SupplierAutomationLog[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -157,6 +165,44 @@ model Store { @@index([userId]) // Add index for performance } +/** Dedupes low-stock B2B notifications per store + Shopify product while stock stays below threshold. */ +model LowStockAlertState { + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + shopifyProductId String + lastSeenTotalQty Int? + /** Set when we sent a low-stock alert; cleared when total qty is back >= threshold. */ + lastNotifiedBelowAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([storeId, shopifyProductId]) + @@index([storeId]) +} + +enum SupplierAutomationKind { + LOW_STOCK + ASK_SUPPLIER +} + +model SupplierAutomationLog { + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + kind SupplierAutomationKind + shopifyProductId String? + supplierUserId String? + ticketKey String? + messagePreview String @db.Text + + createdAt DateTime @default(now()) + + @@index([storeId, kind, createdAt]) + @@index([storeId, shopifyProductId, supplierUserId, createdAt]) +} + model CachedProduct { id String @id @default(cuid()) shopifyProductId String diff --git a/src/app/api/chat/messages/route.ts b/src/app/api/chat/messages/route.ts index a10eb2e..fd62023 100644 --- a/src/app/api/chat/messages/route.ts +++ b/src/app/api/chat/messages/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" -import { sendPushNotification } from "@/lib/push" import { countConnectionsForPair, hasChatConnectionForStore, resolveB2bChatPair, } from "@/lib/chat/chatThreadAccess" +import { postB2bChatMessage } from "@/lib/chat/postB2bChatMessage" import { applyRateLimit } from "@/lib/rate-limit" import { chatMessagePostSchema } from "@/lib/api-schemas/public" import { validationErrorResponse } from "@/lib/api-schemas/validate" @@ -38,56 +38,30 @@ export async function POST(req: Request) { const { receiverId, content, kind, attachment, storeId: storeIdRaw } = parsed.data const normalizedKind = kind ?? "TEXT" - const pair = await resolveB2bChatPair(session.user.id, receiverId) - if (!pair) { - return new NextResponse("Invalid chat participant", { status: 403 }) - } - - const allowed = await hasChatConnectionForStore( - pair.vendorUserId, - pair.supplierUserId, - storeIdRaw - ) - if (!allowed) { - return new NextResponse("Store not connected for this chat", { status: 403 }) - } - - const sessionUser = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { role: true }, + const posted = await postB2bChatMessage({ + senderId: session.user.id, + receiverId, + storeId: storeIdRaw, + kind: normalizedKind, + content, + attachment: normalizedKind === "TEXT" ? null : attachment, + notify: true, }) - if ( - sessionUser?.role === "VENDOR" && - (normalizedKind === "PRODUCT" || normalizedKind === "ORDER") - ) { - const att = attachment as { storeId?: string } - if (!att.storeId || att.storeId !== storeIdRaw) { - return new NextResponse("Attachment store does not match chat store", { status: 400 }) + if (!posted.ok) { + if (posted.reason === "invalid_pair") { + return new NextResponse("Invalid chat participant", { status: 403 }) } + if (posted.reason === "store_not_connected") { + return new NextResponse("Store not connected for this chat", { status: 403 }) + } + return new NextResponse("Attachment store does not match chat store", { status: 400 }) } - const message = await prisma.chatMessage.create({ - data: { - senderId: session.user.id, - receiverId, - storeId: storeIdRaw, - kind: normalizedKind as any, - content: content as any, - attachment: (normalizedKind === "TEXT" ? null : (attachment as any)) as any, - } as any, + const message = await prisma.chatMessage.findUniqueOrThrow({ + where: { id: posted.messageId }, }) - try { - await sendPushNotification(receiverId, { - title: "New Message", - body: `${session.user.name || "A user"} sent you a message.`, - url: "/dashboard/chat", - }) - } catch (pushError) { - console.error("Failed to send push notification", pushError) - } - return NextResponse.json(message) } catch (error) { console.error("[CHAT_MESSAGES_POST]", error) diff --git a/src/app/api/juno-engine/tickets/ask-supplier/draft/route.ts b/src/app/api/juno-engine/tickets/ask-supplier/draft/route.ts new file mode 100644 index 0000000..8d54558 --- /dev/null +++ b/src/app/api/juno-engine/tickets/ask-supplier/draft/route.ts @@ -0,0 +1,116 @@ +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 { + findCachedOrderForStore, + groupLineItemsBySupplier, + type LineItemForRouting, +} from "@/lib/automation/ticketSupplierRouting" +import { draftAskSupplierMessage } from "@/lib/automation/askSupplierDraft" + +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 +} + +const bodySchema = z.object({ + orderRef: z.string().trim().min(1).max(120), + supplierUserId: z.string().trim().min(1).max(128), + ticketSnippet: z.string().max(8000).optional(), + lineIndices: z.array(z.number().int().nonnegative()).optional(), +}) + +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 storeId = await getActiveStoreId(session.user.id) + if (!storeId) { + return NextResponse.json({ message: "No active store" }, { status: 400 }) + } + + 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 prisma.store.findFirst({ + where: { id: storeId, userId: session.user.id }, + select: { businessName: true }, + }) + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + const { orderRef, supplierUserId, ticketSnippet, lineIndices } = parsed.data + + const order = await findCachedOrderForStore({ storeId, orderRef }) + if (!order) { + return NextResponse.json({ message: "Order not found in cache" }, { status: 404 }) + } + + const groups = await groupLineItemsBySupplier({ + storeId, + lines: order.lineItems, + }) + const group = groups.find((g) => g.supplierUserId === supplierUserId) + if (!group) { + return NextResponse.json( + { message: "No linked supplier for this order" }, + { status: 400 }, + ) + } + + let lines: LineItemForRouting[] = group.lines + if (lineIndices?.length) { + const set = new Set(lineIndices) + lines = group.lines.filter((l) => set.has(l.lineIndex)) + if (!lines.length) { + return NextResponse.json( + { message: "No matching line items" }, + { status: 400 }, + ) + } + } + + const draft = await draftAskSupplierMessage({ + storeName: store.businessName, + orderNumber: order.orderNumber, + shopifyOrderId: order.shopifyOrderId, + supplierCompanyName: group.companyName, + lines, + ticketSnippet: ticketSnippet?.trim() || "(no snippet)", + }) + + if (!draft) { + return NextResponse.json( + { message: "Draft unavailable (check GROQ_API_KEY)" }, + { status: 503 }, + ) + } + + return NextResponse.json({ draft }) + } catch (error) { + logger.error("ask-supplier draft failed", error) + return NextResponse.json({ message: "Failed to draft" }, { status: 500 }) + } +} diff --git a/src/app/api/juno-engine/tickets/ask-supplier/route.ts b/src/app/api/juno-engine/tickets/ask-supplier/route.ts new file mode 100644 index 0000000..2af5aed --- /dev/null +++ b/src/app/api/juno-engine/tickets/ask-supplier/route.ts @@ -0,0 +1,217 @@ +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 { + findCachedOrderForStore, + groupLineItemsBySupplier, + type LineItemForRouting, +} from "@/lib/automation/ticketSupplierRouting" +import { postB2bChatMessage } from "@/lib/chat/postB2bChatMessage" +import { + isAskSupplierTicketRateLimited, + logSupplierAutomation, +} from "@/lib/automation/supplierAutomationRate" + +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 +} + +const ASK_SUPPLIER_MAX_PER_TICKET_HOUR = 20 + +const postBodySchema = z.object({ + orderRef: z.string().trim().min(1).max(120), + ticketKey: z.string().trim().min(1).max(600), + supplierUserId: z.string().trim().min(1).max(128), + message: z.string().min(1).max(16_000), + /** If set, only include these line indices for this supplier (must appear in their group). */ + lineIndices: z.array(z.number().int().nonnegative()).optional(), +}) + +/** + * GET — preview supplier groups for an order reference. + */ +export async function GET(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 storeId = await getActiveStoreId(session.user.id) + if (!storeId) { + return NextResponse.json({ message: "No active store" }, { status: 400 }) + } + + const { searchParams } = new URL(req.url) + const orderRef = (searchParams.get("orderRef") || "").trim() + if (!orderRef) { + return NextResponse.json({ message: "orderRef is required" }, { status: 400 }) + } + + const order = await findCachedOrderForStore({ storeId, orderRef }) + if (!order) { + return NextResponse.json({ + order: null, + groups: [], + message: "Order not found in cache. Sync orders or check the order number.", + }) + } + + const groups = await groupLineItemsBySupplier({ + storeId, + lines: order.lineItems, + }) + + return NextResponse.json({ + order: { + shopifyOrderId: order.shopifyOrderId, + orderNumber: order.orderNumber, + }, + groups: groups.map((g) => ({ + supplierUserId: g.supplierUserId, + companyName: g.companyName, + lines: g.lines, + })), + }) + } catch (error) { + logger.error("ask-supplier GET failed", error) + return NextResponse.json({ message: "Failed to load preview" }, { status: 500 }) + } +} + +/** + * POST — send vendor message to one supplier (scoped lines appended in header). + */ +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 storeId = await getActiveStoreId(session.user.id) + if (!storeId) { + return NextResponse.json({ message: "No active store" }, { status: 400 }) + } + + const raw = await req.json().catch(() => ({})) + const parsed = postBodySchema.safeParse(raw) + if (!parsed.success) { + return NextResponse.json( + { message: "Invalid body", issues: parsed.error.flatten() }, + { status: 400 }, + ) + } + + const { orderRef, ticketKey, supplierUserId, message, lineIndices } = parsed.data + + const limited = await isAskSupplierTicketRateLimited( + storeId, + ticketKey, + ASK_SUPPLIER_MAX_PER_TICKET_HOUR, + ) + if (limited) { + return NextResponse.json( + { message: "Too many supplier messages for this ticket. Try again later." }, + { status: 429 }, + ) + } + + const order = await findCachedOrderForStore({ storeId, orderRef }) + if (!order) { + return NextResponse.json({ message: "Order not found in cache" }, { status: 404 }) + } + + const groups = await groupLineItemsBySupplier({ + storeId, + lines: order.lineItems, + }) + const group = groups.find((g) => g.supplierUserId === supplierUserId) + if (!group) { + return NextResponse.json( + { message: "No linked supplier found for this order and supplier" }, + { status: 400 }, + ) + } + + let linesForMessage: LineItemForRouting[] = group.lines + if (lineIndices?.length) { + const set = new Set(lineIndices) + linesForMessage = group.lines.filter((l) => set.has(l.lineIndex)) + if (!linesForMessage.length) { + return NextResponse.json( + { message: "No matching line items for the given indices" }, + { status: 400 }, + ) + } + } + + const linesBlock = linesForMessage + .map( + (l) => + ` - ${l.title}${l.sku ? ` (SKU ${l.sku})` : ""} × ${l.quantity} [product ${l.shopifyProductId ?? "?"}]`, + ) + .join("\n") + + const header = [ + `[Ticket → supplier inquiry]`, + `Ticket: ${ticketKey.slice(0, 400)}`, + `Order #${order.orderNumber} (Shopify id ${order.shopifyOrderId})`, + `Items discussed:`, + linesBlock, + "", + ].join("\n") + + const fullMessage = `${header}${message.replace(/\0/g, "").trim()}` + + const posted = await postB2bChatMessage({ + senderId: session.user.id, + receiverId: supplierUserId, + storeId, + kind: "TEXT", + content: fullMessage, + notify: true, + }) + + if (!posted.ok) { + logger.warn("ask-supplier POST chat failed", { + reason: posted.reason, + storeId, + supplierUserId, + }) + return NextResponse.json( + { message: "Could not send chat (check supplier connection)" }, + { status: 400 }, + ) + } + + await logSupplierAutomation({ + storeId, + kind: "ASK_SUPPLIER", + supplierUserId, + ticketKey, + messagePreview: fullMessage.slice(0, 500), + }) + + return NextResponse.json({ success: true, messageId: posted.messageId }) + } catch (error) { + logger.error("ask-supplier POST failed", error) + return NextResponse.json({ message: "Failed to send" }, { status: 500 }) + } +} diff --git a/src/app/api/stores/[storeId]/route.ts b/src/app/api/stores/[storeId]/route.ts index c6139b5..ad58d52 100644 --- a/src/app/api/stores/[storeId]/route.ts +++ b/src/app/api/stores/[storeId]/route.ts @@ -560,6 +560,7 @@ export async function GET( modulePolicies?: unknown sharedFieldAnswers?: unknown gmailReplyTheme?: unknown + lowStockThreshold?: number | null } let store: StoreGetRow | null = null @@ -574,6 +575,7 @@ export async function GET( modulePolicies: true, sharedFieldAnswers: true, gmailReplyTheme: true, + lowStockThreshold: true, }, }) } catch (firstErr) { @@ -589,6 +591,7 @@ export async function GET( categoryMetadetailsEnabled: true, aiModules: true, gmailReplyTheme: true, + lowStockThreshold: true, }, }) } @@ -610,6 +613,7 @@ export async function GET( : store.gmailReplyTheme === null ? null : parseGmailReplyThemeJson(store.gmailReplyTheme), + lowStockThreshold: store.lowStockThreshold ?? null, }) } catch (error) { console.error("Error reading store settings:", error) @@ -730,6 +734,24 @@ export async function PATCH( } } + if (data.lowStockThreshold !== undefined) { + if (data.lowStockThreshold === null) { + updatePayload.lowStockThreshold = null + } else if ( + typeof data.lowStockThreshold === "number" && + Number.isInteger(data.lowStockThreshold) && + data.lowStockThreshold >= 0 && + data.lowStockThreshold <= 1_000_000 + ) { + updatePayload.lowStockThreshold = data.lowStockThreshold + } else { + return NextResponse.json( + { message: "lowStockThreshold must be null or an integer 0–1000000" }, + { status: 400 }, + ) + } + } + const updatedStore = await prisma.store.update({ where: { id: storeId }, data: updatePayload, @@ -781,6 +803,7 @@ export async function PATCH( updatedStore.gmailReplyTheme == null ? null : parseGmailReplyThemeJson(updatedStore.gmailReplyTheme), + lowStockThreshold: updatedStore.lowStockThreshold ?? null, }) } catch (error) { console.error("Error updating store settings:", error) diff --git a/src/app/api/webhooks/shopify/route.ts b/src/app/api/webhooks/shopify/route.ts index 8f4f3c3..288fdfd 100644 --- a/src/app/api/webhooks/shopify/route.ts +++ b/src/app/api/webhooks/shopify/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server" import { prisma } from "@/lib/prisma" import { updateSingleProduct, updateSingleOrder } from "@/lib/cache" +import { sumVariantInventoryQuantity } from "@/lib/products/shopifyInventorySum" +import { evaluateLowStockAfterProductWebhook } from "@/lib/automation/lowStock" import { autoLinkOfferToShopifyProduct } from "@/lib/products/autoLinkOfferToShopifyProduct" import crypto from "crypto" import { logger } from "@/lib/logger" @@ -93,7 +95,33 @@ export async function POST(req: NextRequest) { case "products/update": case "products/create": logger.info(`[Webhook] Processing product ${topic}: ${body.id}`) - await updateSingleProduct(store.id, body) + { + const productNumericId = + body?.id !== undefined && body?.id !== null ? String(body.id) : null + let prevTotalQty: number | null = null + if (productNumericId) { + const prevRow = await prisma.cachedProduct.findUnique({ + where: { + shopifyProductId_storeId: { + shopifyProductId: productNumericId, + storeId: store.id, + }, + }, + select: { variants: true }, + }) + if (prevRow) { + prevTotalQty = sumVariantInventoryQuantity(prevRow.variants) + } + } + await updateSingleProduct(store.id, body) + evaluateLowStockAfterProductWebhook({ + storeId: store.id, + shopifyProductBody: body, + prevTotalQty, + }).catch((lowStockErr) => { + logger.error("[Webhook] lowStock evaluation failed", lowStockErr) + }) + } try { const linkResult = await autoLinkOfferToShopifyProduct(store.id, body) if (linkResult.linked) { diff --git a/src/app/dashboard/inventory/page.tsx b/src/app/dashboard/inventory/page.tsx index 6526a6d..2338879 100644 --- a/src/app/dashboard/inventory/page.tsx +++ b/src/app/dashboard/inventory/page.tsx @@ -20,7 +20,8 @@ import { CheckCircle2, ChevronLeft, ChevronRight, - Plus + Plus, + BellRing } from "lucide-react" import { createPortal } from "react-dom" import { motion, AnimatePresence } from "framer-motion" @@ -112,6 +113,10 @@ export default function InventoryPage() { const { theme } = useTheme() const isLight = theme === "light" + const [activeStoreId, setActiveStoreId] = useState(null) + const [lowStockDraft, setLowStockDraft] = useState("") + const [lowStockSaving, setLowStockSaving] = useState(false) + useEffect(() => { setMounted(true) }, []) @@ -123,6 +128,23 @@ export default function InventoryPage() { if (res.ok) { const data = await res.json() const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive) + if (activeStore?.id) { + setActiveStoreId(activeStore.id) + try { + const settingsRes = await fetch(`/api/stores/${activeStore.id}`, { + cache: "no-store", + }) + if (settingsRes.ok) { + const settingsData = (await settingsRes.json()) as { + lowStockThreshold?: number | null + } + const t = settingsData.lowStockThreshold + setLowStockDraft(typeof t === "number" ? String(t) : "") + } + } catch { + // ignore + } + } if (activeStore?.shopifyDomain) { const cleanDomain = activeStore.shopifyDomain.replace(/^https?:\/\//, "").replace(/\/$/, "") setStoreDomain(cleanDomain) @@ -343,6 +365,46 @@ export default function InventoryPage() { } } + const saveLowStockSettings = async () => { + if (!activeStoreId) { + toast.error("No active store") + return + } + const trimmed = lowStockDraft.trim() + let payload: { lowStockThreshold: number | null } + if (trimmed === "") { + payload = { lowStockThreshold: null } + } else { + const n = Number.parseInt(trimmed, 10) + if (!Number.isFinite(n) || n < 0 || n > 1_000_000) { + toast.error("Enter a whole number from 0 to 1000000, or leave empty to turn off.") + return + } + payload = { lowStockThreshold: n } + } + setLowStockSaving(true) + try { + const res = await fetch(`/api/stores/${activeStoreId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + throw new Error((data as { message?: string }).message || "Save failed") + } + toast.success( + payload.lowStockThreshold === null + ? "Low-stock supplier alerts turned off." + : `Alerts on when total variant stock drops below ${payload.lowStockThreshold}.`, + ) + } catch (e) { + toast.error(e instanceof Error ? e.message : "Could not save settings") + } finally { + setLowStockSaving(false) + } + } + const handleExport = () => { const csvHeaders = ["ID", "Title", "Vendor", "Status", "Variants", "Created At"] const csvRows = filteredProducts.map(p => [ @@ -399,6 +461,63 @@ export default function InventoryPage() { +
+
+
+ +
+
+

+ Supplier low-stock alerts +

+

+ When enabled, synced products (ACCEPTED) message the linked supplier in JUNO chat if + total inventory drops below your threshold. Leave empty to turn off. +

+
+
+
+ setLowStockDraft(e.target.value.replace(/[^\d]/g, ""))} + className={`h-10 w-full sm:w-28 px-3 rounded-xl border text-sm font-bold ${ + isLight + ? "bg-white border-indigo-200 text-slate-900 placeholder:text-slate-400" + : "bg-white/5 border-white/10 text-white placeholder:text-slate-500" + }`} + /> + +
+
+
diff --git a/src/app/dashboard/juno-engine/tickets/[ticketId]/page.tsx b/src/app/dashboard/juno-engine/tickets/[ticketId]/page.tsx index c45331c..2a67193 100644 --- a/src/app/dashboard/juno-engine/tickets/[ticketId]/page.tsx +++ b/src/app/dashboard/juno-engine/tickets/[ticketId]/page.tsx @@ -2,7 +2,14 @@ import { useEffect, useState } from "react" import { useParams, useRouter } from "next/navigation" -import { ArrowLeft, Ticket, Loader2, MoreHorizontal } from "lucide-react" +import { + ArrowLeft, + Ticket, + Loader2, + MessageCircleQuestion, + Send, + Sparkles, +} from "lucide-react" import { cn } from "@/lib/utils" type TicketStatus = "open" | "pending" | "resolved" | "closed" @@ -28,6 +35,24 @@ interface TicketData { messages: TicketMessage[] } +type AskSupplierPreviewGroup = { + supplierUserId: string + companyName: string + lines: Array<{ + lineIndex: number + title: string + sku: string | null + quantity: number + shopifyProductId: string | null + }> +} + +type AskSupplierPreview = { + order: { shopifyOrderId: string; orderNumber: string } | null + groups: AskSupplierPreviewGroup[] + message?: string +} + import { useTheme } from "@/components/ThemeProvider" export default function TicketDetailPage() { @@ -40,6 +65,14 @@ export default function TicketDetailPage() { const [error, setError] = useState(null) const [expandedMessages, setExpandedMessages] = useState>({}) + const [orderRefInput, setOrderRefInput] = useState("") + const [askPreviewLoading, setAskPreviewLoading] = useState(false) + const [askPreview, setAskPreview] = useState(null) + const [askPreviewError, setAskPreviewError] = useState(null) + const [msgBySupplier, setMsgBySupplier] = useState>({}) + const [draftingSupplierId, setDraftingSupplierId] = useState(null) + const [sendingSupplierId, setSendingSupplierId] = useState(null) + useEffect(() => { const loadTicket = async () => { try { @@ -71,6 +104,111 @@ export default function TicketDetailPage() { void loadTicket() }, [params.ticketId]) + const loadAskSupplierPreview = async () => { + const ref = orderRefInput.trim() + if (!ref) { + setAskPreviewError("Enter an order number (e.g. 1002 or #1002).") + return + } + setAskPreviewLoading(true) + setAskPreviewError(null) + try { + const res = await fetch( + `/api/juno-engine/tickets/ask-supplier?orderRef=${encodeURIComponent(ref)}`, + { cache: "no-store" }, + ) + const data = (await res.json()) as AskSupplierPreview + if (!res.ok) { + throw new Error((data as { message?: string }).message || "Preview failed") + } + setAskPreview(data) + if (!data.order) { + setAskPreviewError( + data.message || + "Order not found in cache. Sync orders or check the order number.", + ) + } else if (data.groups.length === 0) { + setAskPreviewError( + "No suppliers linked for these line items. Ensure orders are synced and products are ACCEPTED with a connected supplier.", + ) + } else { + setAskPreviewError(null) + } + } catch (e) { + setAskPreview(null) + setAskPreviewError(e instanceof Error ? e.message : "Preview failed") + } finally { + setAskPreviewLoading(false) + } + } + + const draftForSupplier = async (supplierUserId: string) => { + const ref = orderRefInput.trim() + if (!ref || !ticket) return + const snippet = [ + `Subject: ${ticket.subject}`, + ticket.lastMessageSnippet ? `Latest: ${ticket.lastMessageSnippet}` : "", + ] + .filter(Boolean) + .join("\n") + setDraftingSupplierId(supplierUserId) + try { + const res = await fetch("/api/juno-engine/tickets/ask-supplier/draft", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + orderRef: ref, + supplierUserId, + ticketSnippet: snippet.slice(0, 6000), + }), + }) + const data = await res.json() + if (!res.ok) { + throw new Error(data.message || "Draft failed") + } + setMsgBySupplier((prev) => ({ + ...prev, + [supplierUserId]: typeof data.draft === "string" ? data.draft : "", + })) + } catch (e) { + setAskPreviewError(e instanceof Error ? e.message : "Draft failed") + } finally { + setDraftingSupplierId(null) + } + } + + const sendToSupplier = async (supplierUserId: string) => { + const ref = orderRefInput.trim() + const text = (msgBySupplier[supplierUserId] || "").trim() + if (!ref || !ticket || !text) { + setAskPreviewError("Enter a message to send.") + return + } + setSendingSupplierId(supplierUserId) + setAskPreviewError(null) + try { + const res = await fetch("/api/juno-engine/tickets/ask-supplier", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + orderRef: ref, + ticketKey: ticket.id, + supplierUserId, + message: text, + }), + }) + const data = await res.json() + if (!res.ok) { + throw new Error(data.message || "Send failed") + } + setMsgBySupplier((prev) => ({ ...prev, [supplierUserId]: "" })) + } catch (e) { + setAskPreviewError(e instanceof Error ? e.message : "Send failed") + } finally { + setSendingSupplierId(null) + } + } + const getStatusStyles = (status: TicketStatus | string) => { switch (status) { case "open": @@ -162,6 +300,155 @@ export default function TicketDetailPage() {
+ {/* Ask supplier (per supplier, optional) */} +
+
+ +

+ Ask supplier +

+
+

+ Link this ticket to a Shopify order, then send a separate chat message to each supplier + that provides products on that order. You can reply to the customer in Gmail yourself once + the supplier responds. +

+
+ 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} +
  • + ))} +
+