diff --git a/.gitignore b/.gitignore index a1a6ba9..3d1d85c 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ next-env.d.ts *.log docker-data/ +/Documents diff --git a/prisma/migrations/20260511215500_storefront_chat_message_actions/migration.sql b/prisma/migrations/20260511215500_storefront_chat_message_actions/migration.sql new file mode 100644 index 0000000..0d3e946 --- /dev/null +++ b/prisma/migrations/20260511215500_storefront_chat_message_actions/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "StorefrontChatMessage" ADD COLUMN "actions" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7f1ab55..d9949e5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -744,6 +744,7 @@ model StorefrontChatMessage { conversation StorefrontChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) senderType StorefrontChatSenderType content String @db.Text + actions Json? createdAt DateTime @default(now()) diff --git a/src/app/api/storefront-chat/messages/route.ts b/src/app/api/storefront-chat/messages/route.ts index 53250fc..1390415 100644 --- a/src/app/api/storefront-chat/messages/route.ts +++ b/src/app/api/storefront-chat/messages/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server" +import type { Prisma } from "@prisma/client" import { prisma } from "@/lib/prisma" import { storeSelectStorefrontChatApi, @@ -7,9 +8,23 @@ import { import { generateStorefrontReply } from "@/lib/ai/storefrontGroq" import { applyRateLimit } from "@/lib/rate-limit" import { storefrontChatPostSchema } from "@/lib/api-schemas/public" +import type { StorefrontChatActionInput } from "@/lib/api-schemas/public" import { validationErrorResponse } from "@/lib/api-schemas/validate" import { extractOrderNumberHints } from "@/lib/ai/gmailContext" import { getOrderDetailForVerifiedEmail } from "@/lib/ai/tools/orderTools" +import { + buildOpenCartAction, + normalizeActionProduct, + type StorefrontActionProduct, + type StorefrontChatAction, +} from "@/lib/storefront-chat/actions" +import { + buildCartPermalink, + extractCartableVariants, + formatVariantChoices, + selectVariantForCart, +} from "@/lib/storefront-chat/cartLinks" +import { productSearchHitToActionProduct, searchProductsForStore } from "@/lib/ai/tools/productTools" function normalizeShopDomain(input: string) { return input @@ -72,6 +87,196 @@ function shouldCreateEscalationTicket(ai: { content: string; meta?: { usedTools? ) } +type CartResponse = { + content: string + actions: StorefrontChatAction[] + pendingCartProduct: StorefrontActionProduct | null +} + +function describeStorefrontAction(action: StorefrontChatActionInput | undefined) { + if (!action) return "" + return action.type === "upsell_suggestion" ? "Add suggested item to cart" : "Add to cart" +} + +function isCartConfirmationMessage(content: string) { + const text = content.trim().toLowerCase() + if (!text) return false + return /^(yes|yeah|yep|sure|ok|okay|please|do it|go ahead|add it|add to cart|buy it|i want to buy|i want it|yes please)[.! ]*$/i.test(text) +} + +function isDirectAddToCartRequest(content: string) { + const text = content.trim().toLowerCase() + return /\b(add|put)\b.+\b(cart|bag)\b/.test(text) || /\b(buy|purchase)\b.+\b(now|it|this|product)\b/.test(text) +} + +function parsePendingCartProduct(raw: unknown): StorefrontActionProduct | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null + const state = raw as Record + return normalizeActionProduct(state.pendingCartProduct) +} + +function nextL1State(raw: unknown, pendingCartProduct: StorefrontActionProduct | null) { + const current = + raw && typeof raw === "object" && !Array.isArray(raw) + ? { ...(raw as Record) } + : {} + if (pendingCartProduct) { + current.pendingCartProduct = pendingCartProduct + current.pendingCartUpdatedAt = new Date().toISOString() + } else { + delete current.pendingCartProduct + delete current.pendingCartUpdatedAt + } + return current +} + +function toJsonCompatible(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + +function toJsonInput(value: unknown): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue +} + +function imageUrlFromImages(images: unknown): string | null { + const rows = typeof images === "string" + ? (() => { + try { + return JSON.parse(images) as unknown + } catch { + return [] + } + })() + : images + if (!Array.isArray(rows)) return null + for (const row of rows) { + if (!row || typeof row !== "object") continue + const image = row as { src?: unknown; url?: unknown } + const src = typeof image.src === "string" ? image.src : typeof image.url === "string" ? image.url : "" + if (src.trim()) return src.trim() + } + return null +} + +async function buildCartResponse(params: { + storeId: string + shopifyDomain: string + productId: string + requestedVariantId?: string | null + quantity?: number + message: string +}): Promise { + const product = await prisma.cachedProduct.findFirst({ + where: { storeId: params.storeId, shopifyProductId: params.productId }, + select: { + shopifyProductId: true, + title: true, + productType: true, + variants: true, + images: true, + }, + }) + + if (!product) { + return { + content: "I could not find that product in this store right now.", + actions: [], + pendingCartProduct: null, + } + } + + const variants = extractCartableVariants(product.variants) + const selected = selectVariantForCart({ + variants, + requestedVariantId: params.requestedVariantId, + message: params.message, + }) + + if (selected.status === "invalid_variant") { + return { + content: "I could not verify that product option for this store. Please choose the product again.", + actions: [], + pendingCartProduct: null, + } + } + + if (selected.status === "unavailable") { + return { + content: `${product.title} is not available to add to cart right now.`, + actions: [], + pendingCartProduct: null, + } + } + + if (selected.status === "needs_selection") { + return { + content: `Which option would you like for ${product.title}? Available options include: ${formatVariantChoices(selected.variants)}.`, + actions: [], + pendingCartProduct: { + productId: product.shopifyProductId, + title: product.title, + imageUrl: imageUrlFromImages(product.images), + productType: product.productType, + quantity: params.quantity ?? 1, + }, + } + } + + const quantity = Math.max(1, Math.min(10, Math.trunc(Number(params.quantity) || 1))) + const cartUrl = buildCartPermalink(params.shopifyDomain, [ + { variantId: selected.variant.id, quantity }, + ]) + if (!cartUrl) { + return { + content: "I could not create a cart link for that product right now.", + actions: [], + pendingCartProduct: null, + } + } + + const actionProduct: StorefrontActionProduct = { + productId: product.shopifyProductId, + title: product.title, + variantId: selected.variant.id, + variantTitle: selected.variant.title, + sku: selected.variant.sku, + price: selected.variant.price, + imageUrl: imageUrlFromImages(product.images), + productType: product.productType, + quantity, + } + const variantText = + selected.variant.title && !/^default title$/i.test(selected.variant.title) + ? ` (${selected.variant.title})` + : "" + + return { + content: `Here is your cart link with ${product.title}${variantText} added: ${cartUrl}`, + actions: [buildOpenCartAction(cartUrl, actionProduct)], + pendingCartProduct: null, + } +} + +async function resolveDirectAddToCart(params: { + storeId: string + shopifyDomain: string + message: string +}): Promise { + if (!isDirectAddToCartRequest(params.message)) return null + const hits = await searchProductsForStore(params.storeId, params.message, 1) + const hit = hits[0] + if (!hit) return null + const product = productSearchHitToActionProduct(hit) + return buildCartResponse({ + storeId: params.storeId, + shopifyDomain: params.shopifyDomain, + productId: hit.shopifyProductId, + requestedVariantId: product?.variantId, + quantity: product?.quantity ?? 1, + message: params.message, + }) +} + export async function POST(req: Request) { try { const rate = await applyRateLimit(req, "public:storefront-chat", { @@ -90,7 +295,8 @@ export async function POST(req: Request) { if (!parsed.success) { return validationErrorResponse(parsed.error) } - const { shop, visitorId, content: trimmedContent, customerEmail } = parsed.data + const { shop, visitorId, content: trimmedContent, customerEmail, action } = parsed.data + const customerContent = trimmedContent || describeStorefrontAction(action) const normalizedShop = normalizeShopDomain(shop) @@ -143,11 +349,65 @@ export async function POST(req: Request) { data: { conversationId: conversation.id, senderType: "CUSTOMER", - content: trimmedContent + content: customerContent }, - select: { id: true, createdAt: true, senderType: true, content: true } + select: { id: true, createdAt: true, senderType: true, content: true, actions: true } }) + const pendingCartProduct = parsePendingCartProduct(conversation.l1State) + const cartResponse = action + ? await buildCartResponse({ + storeId: store.id, + shopifyDomain: store.shopifyDomain, + productId: action.productId, + requestedVariantId: action.variantId, + quantity: action.quantity, + message: customerContent, + }) + : pendingCartProduct && isCartConfirmationMessage(customerContent) + ? await buildCartResponse({ + storeId: store.id, + shopifyDomain: store.shopifyDomain, + productId: pendingCartProduct.productId, + requestedVariantId: pendingCartProduct.variantId, + quantity: pendingCartProduct.quantity, + message: customerContent, + }) + : await resolveDirectAddToCart({ + storeId: store.id, + shopifyDomain: store.shopifyDomain, + message: customerContent, + }) + + if (cartResponse) { + const aiMessage = await prisma.storefrontChatMessage.create({ + data: { + conversationId: conversation.id, + senderType: "AI", + content: cartResponse.content, + actions: cartResponse.actions.length ? toJsonCompatible(cartResponse.actions) : undefined, + }, + select: { id: true, createdAt: true, senderType: true, content: true, actions: true }, + }) + + await prisma.storefrontChatConversation.update({ + where: { id: conversation.id }, + data: { l1State: toJsonInput(nextL1State(conversation.l1State, cartResponse.pendingCartProduct)) }, + }) + + return NextResponse.json({ + success: true, + messages: [customerMessage, aiMessage], + meta: { + module: "PRODUCT_FIT", + pillar: "K", + usedTools: ["cart_permalink"], + kbHits: 0, + productHits: 1, + }, + }) + } + const recentHistory = await prisma.storefrontChatMessage.findMany({ where: { conversationId: conversation.id }, orderBy: { createdAt: "desc" }, @@ -228,16 +488,25 @@ export async function POST(req: Request) { : alreadyEscalated ? ESCALATION_ALREADY_DISPATCHED_REPLY : ai.content + const aiActions = aiContent === ai.content ? ai.actions ?? [] : [] const aiMessage = await prisma.storefrontChatMessage.create({ data: { conversationId: conversation.id, senderType: "AI", - content: aiContent + content: aiContent, + actions: aiActions.length ? toJsonCompatible(aiActions) : undefined, }, - select: { id: true, createdAt: true, senderType: true, content: true } + select: { id: true, createdAt: true, senderType: true, content: true, actions: true } }) + if (aiContent === ai.content) { + await prisma.storefrontChatConversation.update({ + where: { id: conversation.id }, + data: { l1State: toJsonInput(nextL1State(conversation.l1State, ai.pendingCartProduct ?? null)) }, + }) + } + if ( isEscalation && !alreadyEscalated && @@ -256,7 +525,7 @@ export async function POST(req: Request) { storeId: store.id, to: ticketTo, subject: ticketSubject, - bodyPreview: trimmedContent.slice(0, 2000), + bodyPreview: customerContent.slice(0, 2000), isAutomated: true, trigger: STOREFRONT_TICKET_TRIGGER, status: "SENT", @@ -322,7 +591,7 @@ export async function GET(req: Request) { where: { conversationId: conversation.id }, orderBy: { createdAt: "asc" }, take: 120, - select: { id: true, content: true, senderType: true, createdAt: true } + select: { id: true, content: true, senderType: true, createdAt: true, actions: true } }) return NextResponse.json({ success: true, messages }) diff --git a/src/app/storefront-chat/widget/page.tsx b/src/app/storefront-chat/widget/page.tsx index 60c637f..da21045 100644 --- a/src/app/storefront-chat/widget/page.tsx +++ b/src/app/storefront-chat/widget/page.tsx @@ -9,6 +9,47 @@ type WidgetMessage = { content: string createdAt: string senderType: "CUSTOMER" | "AI" + actions?: WidgetAction[] +} + +type WidgetActionProduct = { + productId: string + title: string + variantId?: string + variantTitle?: string | null + sku?: string | null + price?: string | null + imageUrl?: string | null + productType?: string | null + quantity?: number +} + +type WidgetAction = + | { + type: "add_to_cart" + label: string + source?: "primary" | "upsell" + product: WidgetActionProduct + } + | { + type: "upsell_suggestion" + label: string + message: string + product: WidgetActionProduct + } + | { + type: "open_cart" + label: string + cartUrl: string + product?: WidgetActionProduct + } + +type WidgetPostAction = { + type: "add_to_cart" | "upsell_suggestion" + productId: string + variantId?: string + quantity?: number + sourceMessageId?: string } function isLikelyHttpUrl(value: string) { @@ -63,6 +104,48 @@ function renderMessageContent(content: string) { return nodes } +function normalizeWidgetActions(raw: unknown): WidgetAction[] { + if (!Array.isArray(raw)) return [] + return raw.filter((action): action is WidgetAction => { + if (!action || typeof action !== "object" || Array.isArray(action)) return false + const item = action as { type?: unknown; product?: unknown; cartUrl?: unknown } + if (item.type === "open_cart") return typeof item.cartUrl === "string" + if (item.type === "add_to_cart" || item.type === "upsell_suggestion") { + return Boolean(item.product && typeof item.product === "object") + } + return false + }) +} + +function apiErrorMessage(raw: unknown, fallback: string) { + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + const message = (raw as { message?: unknown }).message + if (typeof message === "string" && message.trim()) return message + } + return fallback +} + +function apiMessages(raw: unknown): unknown[] { + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + const messages = (raw as { messages?: unknown }).messages + return Array.isArray(messages) ? messages : [] + } + return [] +} + +function toWidgetMessage(raw: unknown, fallbackId?: string): WidgetMessage { + const message = raw && typeof raw === "object" && !Array.isArray(raw) + ? raw as Record + : {} + return { + id: String(message.id || fallbackId || `local_${Date.now()}`), + content: String(message.content || ""), + senderType: (message.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER", + createdAt: String(message.createdAt || new Date().toISOString()), + actions: normalizeWidgetActions(message.actions), + } +} + function getOrCreateVisitorId(shop: string) { const key = `juno_storefront_visitor_id_${shop}` try { @@ -167,22 +250,15 @@ export default function StorefrontChatWidgetPage() { ) const data = await res.json().catch(() => ({})) if (!res.ok) { - throw new Error(data?.message || "Failed to load history") + throw new Error(apiErrorMessage(data, "Failed to load history")) } - const nextMessages = Array.isArray(data?.messages) ? data.messages : [] + const nextMessages = apiMessages(data) if (!cancelled) { - setMessages( - nextMessages.map((m: any) => ({ - id: String(m.id), - content: String(m.content || ""), - senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER", - createdAt: String(m.createdAt || new Date().toISOString()) - })) - ) + setMessages(nextMessages.map((m) => toWidgetMessage(m))) } - } catch (e: any) { + } catch (e: unknown) { if (!cancelled) { - setSendError(e?.message || "Failed to load chat history") + setSendError(e instanceof Error ? e.message : "Failed to load chat history") } } finally { if (!cancelled) { @@ -299,10 +375,10 @@ export default function StorefrontChatWidgetPage() { } } - const send = async () => { + const send = async (postAction?: WidgetPostAction, actionLabel?: string) => { if (!shop || !visitorId) return - const text = composer.trim() - if (!text) return + const text = postAction ? actionLabel || "Add to cart" : composer.trim() + if (!text && !postAction) return const typingStartedAt = Date.now() const optimisticCustomerMessage: WidgetMessage = { @@ -313,7 +389,9 @@ export default function StorefrontChatWidgetPage() { } setMessages((prev) => [...prev, optimisticCustomerMessage]) - setComposer("") + if (!postAction) { + setComposer("") + } setAiTyping(true) setTimeout(() => composerRef.current?.focus(), 0) @@ -323,22 +401,17 @@ export default function StorefrontChatWidgetPage() { const res = await fetch("/api/storefront-chat/messages", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ shop, visitorId, content: text }) + body: JSON.stringify({ shop, visitorId, content: text, action: postAction }) }) const data = await res.json().catch(() => ({})) if (!res.ok) { - throw new Error(data?.message || "Failed to send message") + throw new Error(apiErrorMessage(data, "Failed to send message")) } - const returned = Array.isArray(data?.messages) ? data.messages : [] + const returned = apiMessages(data) if (returned.length) { - const returnedTyped = returned.map((m: any) => ({ - id: String(m.id || `local_${Date.now()}`), - content: String(m.content || ""), - senderType: (m.senderType === "AI" ? "AI" : "CUSTOMER") as "AI" | "CUSTOMER", - createdAt: String(m.createdAt || new Date().toISOString()) - })) + const returnedTyped = returned.map((m) => toWidgetMessage(m)) const aiReplies = returnedTyped.filter((m: WidgetMessage) => m.senderType === "AI") const elapsed = Date.now() - typingStartedAt const minTypingMs = 1000 @@ -347,8 +420,8 @@ export default function StorefrontChatWidgetPage() { } setMessages(prev => [...prev, ...aiReplies]) } - } catch (e: any) { - setSendError(e?.message || "Failed to send message") + } catch (e: unknown) { + setSendError(e instanceof Error ? e.message : "Failed to send message") setMessages((prev) => prev.filter((m) => m.id !== optimisticCustomerMessage.id)) } finally { setAiTyping(false) @@ -356,6 +429,94 @@ export default function StorefrontChatWidgetPage() { } } + const submitCartAction = (message: WidgetMessage, action: Extract) => { + void send( + { + type: action.type, + productId: action.product.productId, + variantId: action.product.variantId, + quantity: action.product.quantity ?? 1, + sourceMessageId: message.id, + }, + action.label, + ) + } + + const renderMessageActions = (message: WidgetMessage) => { + if (message.senderType !== "AI" || !message.actions?.length) return null + + return ( +
+ {message.actions.map((action, index) => { + if (action.type === "open_cart") { + return ( + + ) + } + + if (action.type === "upsell_suggestion") { + return ( +
+
+ {action.product.imageUrl ? ( + + ) : null} +
+

+ {action.message} +

+ {action.product.price ? ( +

+ {action.product.price} +

+ ) : null} +
+
+ +
+ ) + } + + return ( + + ) + })} +
+ ) + } + // Require shop context; otherwise render nothing. if (!shop) { return null @@ -395,14 +556,17 @@ export default function StorefrontChatWidgetPage() { key={m.id} className={`flex ${m.senderType === "AI" ? "justify-start" : "justify-end"}`} > -
- {renderMessageContent(m.content)} +
+
+ {renderMessageContent(m.content)} +
+ {renderMessageActions(m)}
))} diff --git a/src/lib/ai/orchestrator/runL1.ts b/src/lib/ai/orchestrator/runL1.ts index 7f0aac8..11879c6 100644 --- a/src/lib/ai/orchestrator/runL1.ts +++ b/src/lib/ai/orchestrator/runL1.ts @@ -9,7 +9,18 @@ import { orderNeedsDisambiguation, } from "@/lib/ai/tools/orderTools" import type { OrderListCandidate } from "@/lib/ai/tools/orderTools" -import { searchProductsForStore } from "@/lib/ai/tools/productTools" +import { + productSearchHitToActionProduct, + searchProductsForStore, + type ProductSearchHit, +} from "@/lib/ai/tools/productTools" +import { + buildAddToCartAction, + buildUpsellSuggestionAction, + type StorefrontActionProduct, + type StorefrontChatAction, +} from "@/lib/storefront-chat/actions" +import { findAutomaticUpsellCandidates } from "@/lib/storefront-chat/upsells" import { isAutomationEnabled, isModuleEnabled, @@ -167,6 +178,8 @@ export type RunL1Input = { export type RunL1Result = { text: string meta: L1RunMeta + actions?: StorefrontChatAction[] + pendingCartProduct?: StorefrontActionProduct | null } function historyToGroq( @@ -701,18 +714,23 @@ export async function runL1Orchestrator(input: RunL1Input): Promise } let productFacts = "" + let productHits: ProductSearchHit[] = [] const runProductSearch = (activeModule === "PRODUCT_FIT" && isAutomationEnabled(modules, activeModule, "knowledge")) || - (activeModule === "INVENTORY_STOCK" && isModuleEnabled(modules, activeModule)) + (activeModule === "INVENTORY_STOCK" && isModuleEnabled(modules, activeModule)) || + (input.channel === "widget" && + retrieval.productCount > 0 && + !ORDER_DETAIL_MODULES.includes(activeModule)) if (runProductSearch) { const hits = await searchProductsForStore(input.storeId, effectiveMessage, 5) + productHits = hits usedTools.push("search_products") if (hits.length) { productFacts = hits .map( (h) => - `- ${h.title} (SKU ${h.sku ?? "n/a"}) | ${h.inventorySummary ?? "inventory n/a"}${h.metafieldsSummary ? ` | ${h.metafieldsSummary}` : ""}`, + `- ${h.title} (SKU ${h.sku ?? "n/a"}) | ${h.inventorySummary ?? "inventory n/a"}${h.price ? ` | Price ${h.price}` : ""}${h.requiresVariantSelection ? " | Cart: ask the customer which option/variant they want before adding to cart." : h.variantId ? " | Cart: this product can be added to cart if the customer wants it." : ""}${h.metafieldsSummary ? ` | ${h.metafieldsSummary}` : ""}`, ) .join("\n") } @@ -727,6 +745,27 @@ export async function runL1Orchestrator(input: RunL1Input): Promise } } + const actions: StorefrontChatAction[] = [] + let pendingCartProduct: StorefrontActionProduct | null = null + if (input.channel === "widget" && productHits.length > 0) { + const primaryHit = productHits[0] + const primaryProduct = productSearchHitToActionProduct(primaryHit) + if (primaryProduct) { + pendingCartProduct = primaryProduct + actions.push(buildAddToCartAction(primaryProduct, "primary")) + const upsells = await findAutomaticUpsellCandidates({ + storeId: input.storeId, + baseProduct: primaryProduct, + query: effectiveMessage, + limit: 1, + }) + if (upsells[0]) { + usedTools.push("automatic_upsell") + actions.push(buildUpsellSuggestionAction(upsells[0], primaryProduct.title)) + } + } + } + const facts = [ type1Facts, kbContext ? `Knowledge:\n${kbContext}` : "", @@ -753,6 +792,8 @@ export async function runL1Orchestrator(input: RunL1Input): Promise return { text, + actions, + pendingCartProduct, meta: { module: activeModule, pillar, diff --git a/src/lib/ai/storefrontGroq.ts b/src/lib/ai/storefrontGroq.ts index e5d4fb1..3c1cfe3 100644 --- a/src/lib/ai/storefrontGroq.ts +++ b/src/lib/ai/storefrontGroq.ts @@ -63,6 +63,8 @@ export async function generateStorefrontReply(params: { return { content: result.text, + actions: result.actions ?? [], + pendingCartProduct: result.pendingCartProduct ?? null, meta: { kbHits: result.meta.kbHits, productHits: result.meta.productHits, diff --git a/src/lib/ai/tools/productTools.ts b/src/lib/ai/tools/productTools.ts index 0ae680d..aae11d4 100644 --- a/src/lib/ai/tools/productTools.ts +++ b/src/lib/ai/tools/productTools.ts @@ -1,5 +1,7 @@ import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" +import type { StorefrontActionProduct } from "@/lib/storefront-chat/actions" +import { extractCartableVariants, selectVariantForCart } from "@/lib/storefront-chat/cartLinks" export type ProductSearchHit = { shopifyProductId: string @@ -7,6 +9,15 @@ export type ProductSearchHit = { sku: string | null inventorySummary: string | null metafieldsSummary: string | null + productType: string | null + status: string | null + variantId: string | null + variantTitle: string | null + price: string | null + inventoryQuantity: number | null + imageUrl: string | null + tokens: string[] + requiresVariantSelection: boolean } function metafieldsToSummary(metafields: unknown): string | null { @@ -41,6 +52,49 @@ function inventoryFromVariants(variants: unknown): string | null { } } +function imageUrlFromImages(images: unknown): string | null { + const rows = typeof images === "string" + ? (() => { + try { + return JSON.parse(images) as unknown + } catch { + return [] + } + })() + : images + if (!Array.isArray(rows)) return null + for (const row of rows) { + if (!row || typeof row !== "object") continue + const image = row as { src?: unknown; url?: unknown } + const src = typeof image.src === "string" ? image.src : typeof image.url === "string" ? image.url : "" + if (src.trim()) return src.trim() + } + return null +} + +function termsFromText(text: string) { + return text + .toLowerCase() + .split(/[^a-z0-9]+/g) + .map((t) => t.trim()) + .filter((t) => t.length >= 2) +} + +export function productSearchHitToActionProduct(hit: ProductSearchHit): StorefrontActionProduct | null { + if (!hit.variantId) return null + return { + productId: hit.shopifyProductId, + title: hit.title, + variantId: hit.variantId, + variantTitle: hit.variantTitle, + sku: hit.sku, + price: hit.price, + imageUrl: hit.imageUrl, + productType: hit.productType, + quantity: 1, + } +} + /** * Keyword search by title / SKU across cached products (MVP; no full-text index). */ @@ -49,12 +103,7 @@ export async function searchProductsForStore( query: string, limit = 8, ): Promise { - const terms = query - .toLowerCase() - .split(/[^a-z0-9]+/g) - .map((t) => t.trim()) - .filter((t) => t.length >= 2) - .slice(0, 6) + const terms = termsFromText(query).slice(0, 6) if (!terms.length) return [] @@ -66,14 +115,17 @@ export async function searchProductsForStore( select: { shopifyProductId: true, title: true, + productType: true, + status: true, variants: true, + images: true, metafields: true, }, }) const scored: { p: (typeof products)[0]; score: number }[] = [] for (const p of products) { - const hay = `${p.title} ${JSON.stringify(p.variants)}`.toLowerCase() + const hay = `${p.title} ${p.productType ?? ""} ${JSON.stringify(p.variants)} ${metafieldsToSummary(p.metafields) ?? ""}`.toLowerCase() let score = 0 for (const t of terms) { if (hay.includes(t)) score += 1 @@ -83,21 +135,34 @@ export async function searchProductsForStore( scored.sort((a, b) => b.score - a.score) return scored.slice(0, limit).map(({ p }) => { - const variants = p.variants + const variants = extractCartableVariants(p.variants) + const selected = selectVariantForCart({ variants, message: query }) + const selectedVariant = selected.status === "selected" ? selected.variant : null + const firstVariant = variants[0] ?? null let sku: string | null = null - try { - const arr = - typeof variants === "string" ? JSON.parse(variants) : variants - if (Array.isArray(arr) && arr[0]?.sku) sku = String(arr[0].sku) - } catch { - /* ignore */ - } + if (selectedVariant?.sku) sku = selectedVariant.sku + else if (firstVariant?.sku) sku = firstVariant.sku + + const tokens = Array.from(new Set([ + ...termsFromText(p.title), + ...termsFromText(p.productType ?? ""), + ])).slice(0, 16) + return { shopifyProductId: p.shopifyProductId, title: p.title, sku, inventorySummary: inventoryFromVariants(p.variants), metafieldsSummary: metafieldsToSummary(p.metafields), + productType: p.productType, + status: p.status, + variantId: selectedVariant?.id ?? null, + variantTitle: selectedVariant?.title ?? null, + price: selectedVariant?.price ?? firstVariant?.price ?? null, + inventoryQuantity: selectedVariant?.inventoryQuantity ?? firstVariant?.inventoryQuantity ?? null, + imageUrl: imageUrlFromImages(p.images), + tokens, + requiresVariantSelection: selected.status === "needs_selection", } }) } catch (e) { diff --git a/src/lib/api-schemas/public.ts b/src/lib/api-schemas/public.ts index a3c8276..4924dcc 100644 --- a/src/lib/api-schemas/public.ts +++ b/src/lib/api-schemas/public.ts @@ -39,18 +39,42 @@ const storefrontVisitorIdSchema = z .min(1, "Missing visitorId") .max(300, "visitorId too long") -/** Anonymous storefront widget message */ -export const storefrontChatPostSchema = z.object({ - shop: storefrontShopSchema, - visitorId: storefrontVisitorIdSchema, - content: z +export const storefrontChatActionSchema = z.object({ + type: z.enum(["add_to_cart", "upsell_suggestion"]), + productId: z.string().trim().min(1, "Missing productId").max(128), + variantId: z .string() - .max(2000, "Message too long") - .transform((s) => s.trim()) - .refine((s) => s.length >= 1, "Empty message"), - customerEmail: optionalEmailField(), + .trim() + .min(1, "Missing variantId") + .max(128) + .optional(), + quantity: z.coerce.number().int().min(1).max(10).default(1), + sourceMessageId: z.string().trim().max(128).optional(), }) +/** Anonymous storefront widget message */ +export const storefrontChatPostSchema = z + .object({ + shop: storefrontShopSchema, + visitorId: storefrontVisitorIdSchema, + content: z + .string() + .max(2000, "Message too long") + .optional() + .default("") + .transform((s) => s.trim()), + customerEmail: optionalEmailField(), + action: storefrontChatActionSchema.optional(), + }) + .superRefine((data, ctx) => { + if (data.content || data.action) return + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content"], + message: "Empty message", + }) + }) + export const CHAT_MESSAGE_CONTENT_MAX = 16_000 /** Serialized JSON size cap for PRODUCT/ORDER attachments (DoS guard). */ @@ -124,6 +148,7 @@ export const storefrontOtpVerifySchema = z.object({ export type NewsletterSubscribeInput = z.infer export type StorefrontChatPostInput = z.infer +export type StorefrontChatActionInput = z.infer export type ChatMessagePostInput = z.infer export type StorefrontOtpRequestInput = z.infer export type StorefrontOtpVerifyInput = z.infer diff --git a/src/lib/api-schemas/schemas.test.ts b/src/lib/api-schemas/schemas.test.ts index f98cedf..74121b2 100644 --- a/src/lib/api-schemas/schemas.test.ts +++ b/src/lib/api-schemas/schemas.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest" import { newsletterSubscribeSchema, storefrontChatPostSchema, + storefrontChatActionSchema, chatMessagePostSchema, CHAT_MESSAGE_CONTENT_MAX, CHAT_ATTACHMENT_JSON_MAX, @@ -45,6 +46,33 @@ describe("storefrontChatPostSchema", () => { }).success, ).toBe(false) }) + + it("accepts action-only add-to-cart payloads", () => { + const result = storefrontChatPostSchema.safeParse({ + shop: "x.myshopify.com", + visitorId: "v1", + action: { + type: "add_to_cart", + productId: "123", + variantId: "456", + quantity: 1, + }, + }) + expect(result.success).toBe(true) + if (result.success) expect(result.data.content).toBe("") + }) +}) + +describe("storefrontChatActionSchema", () => { + it("bounds action quantity", () => { + expect( + storefrontChatActionSchema.safeParse({ + type: "add_to_cart", + productId: "123", + quantity: 11, + }).success, + ).toBe(false) + }) }) describe("chatMessagePostSchema", () => { diff --git a/src/lib/prisma/storeSelects.ts b/src/lib/prisma/storeSelects.ts index b3f16a3..b29869b 100644 --- a/src/lib/prisma/storeSelects.ts +++ b/src/lib/prisma/storeSelects.ts @@ -11,6 +11,7 @@ export const storeSelectStorefrontChatApi = { id: true, storefrontChatEnabled: true, businessName: true, + shopifyDomain: true, aiModules: true, categoryMetadetailsEnabled: true, sharedFieldAnswers: true, @@ -83,4 +84,5 @@ export const storeSelectStorefrontConversationSession = { emailVerifiedAt: true, sessionExpiresAt: true, boundShopifyOrderId: true, + l1State: true, } as const diff --git a/src/lib/storefront-chat/actions.ts b/src/lib/storefront-chat/actions.ts new file mode 100644 index 0000000..2c094cc --- /dev/null +++ b/src/lib/storefront-chat/actions.ts @@ -0,0 +1,147 @@ +export type StorefrontActionProduct = { + productId: string + title: string + variantId?: string + variantTitle?: string | null + sku?: string | null + price?: string | null + imageUrl?: string | null + productType?: string | null + quantity?: number +} + +export type StorefrontAddToCartAction = { + type: "add_to_cart" + label: string + source: "primary" | "upsell" + product: StorefrontActionProduct +} + +export type StorefrontUpsellSuggestionAction = { + type: "upsell_suggestion" + label: string + message: string + product: StorefrontActionProduct +} + +export type StorefrontOpenCartAction = { + type: "open_cart" + label: string + cartUrl: string + product?: StorefrontActionProduct +} + +export type StorefrontChatAction = + | StorefrontAddToCartAction + | StorefrontUpsellSuggestionAction + | StorefrontOpenCartAction + +function clampQuantity(quantity: unknown): number { + const n = Number(quantity) + if (!Number.isFinite(n)) return 1 + return Math.max(1, Math.min(10, Math.trunc(n))) +} + +export function normalizeActionProduct(raw: unknown): StorefrontActionProduct | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null + const product = raw as Record + const productId = typeof product.productId === "string" ? product.productId.trim() : "" + const title = typeof product.title === "string" ? product.title.trim() : "" + if (!productId || !title) return null + + const variantId = typeof product.variantId === "string" ? product.variantId.trim() : "" + const variantTitle = typeof product.variantTitle === "string" ? product.variantTitle.trim() : null + const sku = typeof product.sku === "string" ? product.sku.trim() : null + const price = typeof product.price === "string" ? product.price.trim() : null + const imageUrl = typeof product.imageUrl === "string" ? product.imageUrl.trim() : null + const productType = typeof product.productType === "string" ? product.productType.trim() : null + + return { + productId, + title, + ...(variantId ? { variantId } : {}), + variantTitle, + sku, + price, + imageUrl, + productType, + quantity: clampQuantity(product.quantity), + } +} + +export function parseStorefrontChatActions(raw: unknown): StorefrontChatAction[] { + if (!Array.isArray(raw)) return [] + const actions: StorefrontChatAction[] = [] + + for (const item of raw.slice(0, 6)) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue + const action = item as Record + const type = action.type + const label = typeof action.label === "string" ? action.label.trim() : "" + + if (type === "open_cart") { + const cartUrl = typeof action.cartUrl === "string" ? action.cartUrl.trim() : "" + if (!label || !/^https?:\/\//i.test(cartUrl)) continue + actions.push({ + type, + label, + cartUrl, + product: normalizeActionProduct(action.product) ?? undefined, + }) + continue + } + + const product = normalizeActionProduct(action.product) + if (!product || !label) continue + + if (type === "add_to_cart") { + const source = action.source === "upsell" ? "upsell" : "primary" + actions.push({ type, label, source, product }) + continue + } + + if (type === "upsell_suggestion") { + const message = typeof action.message === "string" ? action.message.trim() : "" + if (!message) continue + actions.push({ type, label, message, product }) + } + } + + return actions +} + +export function buildAddToCartAction( + product: StorefrontActionProduct, + source: "primary" | "upsell" = "primary", +): StorefrontAddToCartAction { + return { + type: "add_to_cart", + label: source === "upsell" ? "Add this too" : "Add to cart", + source, + product: { ...product, quantity: clampQuantity(product.quantity) }, + } +} + +export function buildUpsellSuggestionAction( + product: StorefrontActionProduct, + baseTitle: string, +): StorefrontUpsellSuggestionAction { + return { + type: "upsell_suggestion", + label: "Add suggested item", + message: `Would you like to add ${product.title} with ${baseTitle}?`, + product: { ...product, quantity: clampQuantity(product.quantity) }, + } +} + +export function buildOpenCartAction( + cartUrl: string, + product?: StorefrontActionProduct, +): StorefrontOpenCartAction { + return { + type: "open_cart", + label: "Open cart", + cartUrl, + product: product ? { ...product, quantity: clampQuantity(product.quantity) } : undefined, + } +} diff --git a/src/lib/storefront-chat/cartLinks.test.ts b/src/lib/storefront-chat/cartLinks.test.ts new file mode 100644 index 0000000..93aecd1 --- /dev/null +++ b/src/lib/storefront-chat/cartLinks.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest" +import { + buildCartPermalink, + extractCartableVariants, + selectVariantForCart, +} from "./cartLinks" + +describe("storefront cart links", () => { + const variants = [ + { + id: 111, + title: "Default Title", + sku: "SHOE-1", + price: "25.00", + inventory_quantity: 4, + }, + ] + + it("selects a single cartable variant", () => { + const result = selectVariantForCart({ + variants: extractCartableVariants(variants), + message: "add it", + }) + expect(result.status).toBe("selected") + if (result.status === "selected") { + expect(result.variant.id).toBe("111") + } + }) + + it("asks for a variant when multiple meaningful options exist", () => { + const result = selectVariantForCart({ + variants: extractCartableVariants([ + { id: 1, title: "Black / 8", option1: "Black", option2: "8", inventory_quantity: 2 }, + { id: 2, title: "Black / 9", option1: "Black", option2: "9", inventory_quantity: 2 }, + ]), + message: "yes", + }) + expect(result.status).toBe("needs_selection") + }) + + it("rejects requested variants that are not in the cached product", () => { + const result = selectVariantForCart({ + variants: extractCartableVariants(variants), + requestedVariantId: "999", + }) + expect(result.status).toBe("invalid_variant") + }) + + it("builds Shopify cart permalinks with normalized domains and bounded quantities", () => { + expect( + buildCartPermalink("https://demo.myshopify.com/", [ + { variantId: "111", quantity: 2 }, + { variantId: "222", quantity: 25 }, + ]), + ).toBe("https://demo.myshopify.com/cart/111:2,222:10") + }) +}) diff --git a/src/lib/storefront-chat/cartLinks.ts b/src/lib/storefront-chat/cartLinks.ts new file mode 100644 index 0000000..2902c6c --- /dev/null +++ b/src/lib/storefront-chat/cartLinks.ts @@ -0,0 +1,169 @@ +export type CartableVariant = { + id: string + title: string | null + sku: string | null + price: string | null + inventoryQuantity: number | null + optionValues: string[] +} + +export type VariantSelectionResult = + | { status: "selected"; variant: CartableVariant } + | { status: "needs_selection"; variants: CartableVariant[] } + | { status: "invalid_variant" } + | { status: "unavailable"; variant?: CartableVariant } + +export function normalizeShopDomain(input: string) { + return input.trim().toLowerCase().replace(/^https?:\/\//i, "").replace(/\/$/, "") +} + +function cleanString(value: unknown): string | null { + if (value === null || value === undefined) return null + const text = String(value).trim() + return text ? text : null +} + +function cleanInventory(value: unknown): number | null { + if (value === null || value === undefined || value === "") return null + const n = Number(value) + return Number.isFinite(n) ? n : null +} + +function isDefaultVariantTitle(title: string | null) { + return !title || /^default title$/i.test(title.trim()) +} + +function optionValuesFromVariant(variant: Record, title: string | null) { + const values = [variant.option1, variant.option2, variant.option3] + .map(cleanString) + .filter((v): v is string => Boolean(v)) + + if (values.length > 0) return values + return isDefaultVariantTitle(title) || !title ? [] : [title] +} + +export function extractCartableVariants(rawVariants: unknown): CartableVariant[] { + const variants = + typeof rawVariants === "string" + ? (() => { + try { + return JSON.parse(rawVariants) as unknown + } catch { + return [] + } + })() + : rawVariants + + if (!Array.isArray(variants)) return [] + + return variants + .map((variant): CartableVariant | null => { + if (!variant || typeof variant !== "object" || Array.isArray(variant)) return null + const v = variant as Record + const id = cleanString(v.id) + if (!id) return null + const title = cleanString(v.title) + return { + id, + title, + sku: cleanString(v.sku), + price: cleanString(v.price), + inventoryQuantity: cleanInventory(v.inventory_quantity), + optionValues: optionValuesFromVariant(v, title), + } + }) + .filter((variant): variant is CartableVariant => Boolean(variant)) +} + +function isKnownUnavailable(variant: CartableVariant) { + return variant.inventoryQuantity !== null && variant.inventoryQuantity <= 0 +} + +function scoreVariantByMessage(variant: CartableVariant, message: string) { + const haystack = [ + variant.title, + variant.sku, + ...variant.optionValues, + ] + .filter(Boolean) + .join(" ") + .toLowerCase() + const words = message + .toLowerCase() + .split(/[^a-z0-9]+/g) + .map((word) => word.trim()) + .filter((word) => word.length >= 2) + + let score = 0 + for (const word of words) { + if (haystack.includes(word)) score += 1 + } + return score +} + +export function selectVariantForCart(params: { + variants: CartableVariant[] + requestedVariantId?: string | null + message?: string | null +}): VariantSelectionResult { + const { variants, requestedVariantId, message } = params + if (variants.length === 0) return { status: "unavailable" } + + if (requestedVariantId?.trim()) { + const variant = variants.find((v) => v.id === requestedVariantId.trim()) + if (!variant) return { status: "invalid_variant" } + if (isKnownUnavailable(variant)) return { status: "unavailable", variant } + return { status: "selected", variant } + } + + const availableVariants = variants.filter((variant) => !isKnownUnavailable(variant)) + if (availableVariants.length === 0) { + return { status: "unavailable", variant: variants[0] } + } + + if (availableVariants.length === 1) { + return { status: "selected", variant: availableVariants[0] } + } + + const allDefaultTitles = availableVariants.every((variant) => isDefaultVariantTitle(variant.title)) + if (allDefaultTitles) { + return { status: "selected", variant: availableVariants[0] } + } + + const cleanMessage = message?.trim() ?? "" + if (cleanMessage) { + const scored = availableVariants + .map((variant) => ({ variant, score: scoreVariantByMessage(variant, cleanMessage) })) + .sort((a, b) => b.score - a.score) + if (scored[0]?.score && scored[0].score > (scored[1]?.score ?? 0)) { + return { status: "selected", variant: scored[0].variant } + } + } + + return { status: "needs_selection", variants: availableVariants.slice(0, 8) } +} + +export function buildCartPermalink( + shopifyDomain: string, + lines: Array<{ variantId: string; quantity?: number }>, +) { + const cleanDomain = normalizeShopDomain(shopifyDomain) + const encodedLines = lines + .map((line) => { + const variantId = line.variantId.trim() + if (!variantId) return null + const quantity = Math.max(1, Math.min(10, Math.trunc(Number(line.quantity) || 1))) + return `${encodeURIComponent(variantId)}:${quantity}` + }) + .filter((line): line is string => Boolean(line)) + + if (!cleanDomain || encodedLines.length === 0) return null + return `https://${cleanDomain}/cart/${encodedLines.join(",")}` +} + +export function formatVariantChoices(variants: CartableVariant[]) { + return variants + .slice(0, 5) + .map((variant) => variant.optionValues.join(" / ") || variant.title || variant.sku || variant.id) + .join(", ") +} diff --git a/src/lib/storefront-chat/upsells.test.ts b/src/lib/storefront-chat/upsells.test.ts new file mode 100644 index 0000000..11e63a3 --- /dev/null +++ b/src/lib/storefront-chat/upsells.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" +import { rankAutomaticUpsellCandidates } from "./upsells" + +describe("automatic storefront upsells", () => { + it("suggests school socks for school shoes and excludes the base product", () => { + const results = rankAutomaticUpsellCandidates({ + baseProduct: { + productId: "shoe-1", + title: "School Shoes", + variantId: "v-shoe", + productType: "Shoes", + }, + query: "Do you have school shoes?", + candidates: [ + { + shopifyProductId: "shoe-1", + title: "School Shoes", + productType: "Shoes", + status: "active", + variants: [{ id: "v-shoe", title: "Default Title", inventory_quantity: 5 }], + }, + { + shopifyProductId: "sock-1", + title: "School Socks", + productType: "Accessories", + status: "active", + variants: [{ id: "v-sock", title: "Default Title", inventory_quantity: 8, price: "5.00" }], + }, + { + shopifyProductId: "bag-1", + title: "Travel Bag", + productType: "Bags", + status: "active", + variants: [{ id: "v-bag", title: "Default Title", inventory_quantity: 8 }], + }, + ], + }) + + expect(results).toHaveLength(1) + expect(results[0].productId).toBe("sock-1") + expect(results[0].variantId).toBe("v-sock") + }) +}) diff --git a/src/lib/storefront-chat/upsells.ts b/src/lib/storefront-chat/upsells.ts new file mode 100644 index 0000000..e42c293 --- /dev/null +++ b/src/lib/storefront-chat/upsells.ts @@ -0,0 +1,167 @@ +import { prisma } from "@/lib/prisma" +import type { StorefrontActionProduct } from "@/lib/storefront-chat/actions" +import { extractCartableVariants, selectVariantForCart } from "@/lib/storefront-chat/cartLinks" + +type CachedProductLike = { + shopifyProductId: string + title: string + productType?: string | null + status?: string | null + variants?: unknown + images?: unknown + metafields?: unknown +} + +const STOPWORDS = new Set([ + "the", + "and", + "for", + "with", + "that", + "this", + "want", + "need", + "product", + "item", + "buy", + "cart", + "add", +]) + +const ACCESSORY_PAIRS: Array<{ base: RegExp; accessory: RegExp; score: number }> = [ + { base: /\bshoe|shoes|sneaker|sneakers|boot|boots\b/i, accessory: /\bsock|socks|laces?|insole|insoles\b/i, score: 8 }, + { base: /\bshirt|uniform|dress\b/i, accessory: /\btie|belt|cap|scarf\b/i, score: 5 }, + { base: /\bbag|backpack\b/i, accessory: /\bbottle|lunch|pouch|case\b/i, score: 5 }, + { base: /\bphone|mobile\b/i, accessory: /\bcase|cover|charger|cable\b/i, score: 6 }, +] + +function tokenize(text: string) { + return text + .toLowerCase() + .split(/[^a-z0-9]+/g) + .map((token) => token.trim()) + .filter((token) => token.length >= 2 && !STOPWORDS.has(token)) +} + +function uniqueTokens(...texts: Array) { + return Array.from(new Set(texts.flatMap((text) => tokenize(text ?? "")))) +} + +function imageUrlFromImages(images: unknown): string | null { + const rows = typeof images === "string" + ? (() => { + try { + return JSON.parse(images) as unknown + } catch { + return [] + } + })() + : images + if (!Array.isArray(rows)) return null + for (const row of rows) { + if (!row || typeof row !== "object") continue + const image = row as { src?: unknown; url?: unknown } + const src = typeof image.src === "string" ? image.src : typeof image.url === "string" ? image.url : "" + if (src.trim()) return src.trim() + } + return null +} + +function productText(product: CachedProductLike) { + return [ + product.title, + product.productType, + JSON.stringify(product.metafields ?? ""), + ] + .filter(Boolean) + .join(" ") +} + +function accessoryScore(baseText: string, candidateText: string) { + let score = 0 + for (const pair of ACCESSORY_PAIRS) { + if (pair.base.test(baseText) && pair.accessory.test(candidateText)) { + score += pair.score + } + } + return score +} + +function toUpsellActionProduct(product: CachedProductLike, query: string): StorefrontActionProduct | null { + const variants = extractCartableVariants(product.variants) + const selected = selectVariantForCart({ variants, message: query }) + if (selected.status !== "selected") return null + + return { + productId: product.shopifyProductId, + title: product.title, + variantId: selected.variant.id, + variantTitle: selected.variant.title, + sku: selected.variant.sku, + price: selected.variant.price, + imageUrl: imageUrlFromImages(product.images), + productType: product.productType ?? null, + quantity: 1, + } +} + +export function rankAutomaticUpsellCandidates(params: { + baseProduct: StorefrontActionProduct + query: string + candidates: CachedProductLike[] + limit?: number +}): StorefrontActionProduct[] { + const { baseProduct, query, candidates, limit = 1 } = params + const baseText = [baseProduct.title, baseProduct.productType, query].filter(Boolean).join(" ") + const baseTokens = new Set(uniqueTokens(baseText)) + + const ranked = candidates + .filter((candidate) => candidate.shopifyProductId !== baseProduct.productId) + .filter((candidate) => !candidate.status || candidate.status.toLowerCase() === "active") + .map((candidate) => { + const candidateText = productText(candidate) + const candidateTokens = uniqueTokens(candidateText) + let score = accessoryScore(baseText, candidateText) + for (const token of candidateTokens) { + if (baseTokens.has(token)) score += 2 + } + if (candidate.productType && baseProduct.productType && candidate.productType !== baseProduct.productType) { + score += 1 + } + const product = toUpsellActionProduct(candidate, query) + return { product, score } + }) + .filter((row): row is { product: StorefrontActionProduct; score: number } => Boolean(row.product) && row.score > 0) + .sort((a, b) => b.score - a.score) + + return ranked.slice(0, limit).map((row) => row.product) +} + +export async function findAutomaticUpsellCandidates(params: { + storeId: string + baseProduct: StorefrontActionProduct + query: string + limit?: number +}): Promise { + const products = await prisma.cachedProduct.findMany({ + where: { storeId: params.storeId }, + orderBy: { updatedAt: "desc" }, + take: 300, + select: { + shopifyProductId: true, + title: true, + productType: true, + status: true, + variants: true, + images: true, + metafields: true, + }, + }) + + return rankAutomaticUpsellCandidates({ + baseProduct: params.baseProduct, + query: params.query, + candidates: products, + limit: params.limit, + }) +}