import { NextResponse } from "next/server" import { auth } from "@/auth.vendor" import { prisma } from "@/lib/prisma" import { Prisma } from "@prisma/client" import { storeSelectPatchStorefrontAi, storeSelectVendorSettingsGetFull, storeSelectVendorSettingsGetMinimal, } from "@/lib/prisma/storeSelects" import { modulePoliciesBodySchema, parseModulePoliciesJson } from "@/lib/kb/modulePolicies" import { parseSharedFieldAnswersJson, sharedFieldAnswersBodySchema, sharedFieldAnswersPayload, } from "@/lib/kb/sharedFieldAnswers" import { customAiModulesBodySchema, customAiModulesPayload, parseCustomAiModulesJson, } from "@/lib/kb/customAiModules" import { replaceStructuredKnowledgeChunksForStore } from "@/lib/kb/structuredTemplateChunks" import { getPublishedThemeId, getThemeAsset, putThemeAsset, removeThemeLiquidInjection, upsertThemeLiquidInjection } from "@/lib/shopify/theme" import { gmailReplyThemeBodySchema, parseGmailReplyThemeJson, resolvedThemeFromBody, } from "@/lib/gmail/emailReplyTheme" import { reconcileLowStockForStore } from "@/lib/automation/lowStock" import { MAX_STOREFRONT_OTP_SESSION_MINUTES, MIN_STOREFRONT_OTP_SESSION_MINUTES, } from "@/lib/storefront-chat/otpCrypto" function parseStorefrontOtpSessionMinutes( value: unknown, ): { ok: true; value: number } | { ok: false; message: string } { if (typeof value !== "number" || !Number.isInteger(value)) { return { ok: false, message: "storefrontOtpSessionMinutes must be a whole number (total minutes).", } } if ( value < MIN_STOREFRONT_OTP_SESSION_MINUTES || value > MAX_STOREFRONT_OTP_SESSION_MINUTES ) { return { ok: false, message: `Session length must be between ${MIN_STOREFRONT_OTP_SESSION_MINUTES} and ${MAX_STOREFRONT_OTP_SESSION_MINUTES} minutes.`, } } return { ok: true, value } } function parseOptionalLowStockThreshold( value: unknown, fieldName: string, ): { ok: true; value: number | null } | { ok: false; message: string } { if (value === null) { return { ok: true, value: null } } if ( typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 1_000_000 ) { return { ok: true, value } } return { ok: false, message: `${fieldName} must be null or an integer 0–1000000`, } } /** * DELETE /api/stores/[storeId] * Delete a store for the authenticated user */ export async function DELETE( request: Request, { params }: { params: Promise<{ storeId: string }> } ) { try { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } const { storeId } = await params if (!storeId) { return NextResponse.json({ message: "Store ID is required" }, { status: 400 }) } // Verify the store belongs to the user const store = await prisma.store.findFirst({ where: { id: storeId, userId: session.user.id } }) if (!store) { return NextResponse.json({ message: "Store not found" }, { status: 404 }) } // Check if this is the only store const storeCount = await prisma.store.count({ where: { userId: session.user.id } }) if (storeCount === 1) { return NextResponse.json({ message: "Cannot delete your only store. Please add another store first." }, { status: 400 }) } // If deleting the active store, switch to another store first if (store.isActive) { const otherStore = await prisma.store.findFirst({ where: { userId: session.user.id, id: { not: storeId } }, orderBy: { createdAt: "desc" } }) if (otherStore) { // Switch to another store await prisma.store.updateMany({ where: { userId: session.user.id }, data: { isActive: false } }) await prisma.store.update({ where: { id: otherStore.id }, data: { isActive: true } }) await prisma.user.update({ where: { id: session.user.id }, data: { activeStoreId: otherStore.id } }) } } // Delete the store (cascade will handle related data) await prisma.store.delete({ where: { id: storeId } }) return NextResponse.json({ success: true, message: "Store deleted successfully" }) } catch (error) { console.error("Error deleting store:", error) return NextResponse.json( { message: "Failed to delete store" }, { status: 500 } ) } } /** * PATCH /api/stores/[storeId] * Update per-store configuration (vendor only). */ const ALLOWED_FONTS = new Set([ "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif", "Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif", "Poppins, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif", "Montserrat, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif", "Nunito, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif", ]) const DEFAULT_CHAT_NAME = "JUNO Chat" const DEFAULT_TAGLINE = "Ask about products" const DEFAULT_BRAND_COLOR = "#4f46e5" const DEFAULT_FONT = "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" function sanitizeColor(value: unknown) { if (typeof value !== "string") return DEFAULT_BRAND_COLOR const v = value.trim() return /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(v) ? v : DEFAULT_BRAND_COLOR } function sanitizeShortText(value: unknown, fallback: string, max = 64) { if (typeof value !== "string") return fallback const v = value.trim() if (!v) return fallback return v.slice(0, max) } function sanitizeFont(value: unknown) { if (typeof value !== "string") return DEFAULT_FONT const v = value.trim() return ALLOWED_FONTS.has(v) ? v : DEFAULT_FONT } function sanitizeLogoUrl(value: unknown) { if (typeof value !== "string") return null const v = value.trim() if (!v) return null if (v.startsWith("/") && !v.includes("..")) { return v.slice(0, 500) } try { const parsed = new URL(v) if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null return parsed.toString().slice(0, 500) } catch { return null } } /** Storefront script runs on Shopify origin; relative /uploads/... must become absolute JUNO app URLs. */ function resolveLogoUrlForInject(appBaseUrl: string, logoUrl: string | null) { if (!logoUrl) return null const v = logoUrl.trim() if (!v) return null if (/^https?:\/\//i.test(v)) return v if (v.startsWith("/") && !v.includes("..")) { const base = appBaseUrl.replace(/\/$/, "") return `${base}${v}` } return null } function buildStorefrontScript(params: { appBaseUrl: string chatName: string tagline: string brandColor: string fontFamily: string logoUrl: string | null }) { const { appBaseUrl, chatName, tagline, brandColor, fontFamily, logoUrl } = params return ` (function () { try { var appBaseUrl = ${JSON.stringify(appBaseUrl)}; if (!appBaseUrl) return; var shop = (window.Shopify && (window.Shopify.shop || window.Shopify.myshopifyDomain)) || window.location.hostname; var chatName = ${JSON.stringify(chatName)}; var tagline = ${JSON.stringify(tagline)}; var brandColor = ${JSON.stringify(brandColor)}; var fontFamily = ${JSON.stringify(fontFamily)}; var logoUrl = ${JSON.stringify(logoUrl)}; var containerId = "juno-storefront-chat-container"; var buttonId = "juno-storefront-chat-button"; var panelId = "juno-storefront-chat-panel"; var backdropId = "juno-storefront-chat-backdrop"; var closeId = "juno-storefront-chat-close"; var iframeId = "juno-storefront-chat-iframe"; function ensurePanel() { var existingPanel = document.getElementById(panelId); if (existingPanel) return; var backdrop = document.createElement("div"); backdrop.id = backdropId; backdrop.style.position = "fixed"; backdrop.style.inset = "0"; backdrop.style.background = "rgba(2,6,23,0.28)"; backdrop.style.backdropFilter = "blur(2px)"; backdrop.style.zIndex = "2147483645"; backdrop.style.display = "none"; backdrop.addEventListener("click", function () { closePanel(); }); document.body.appendChild(backdrop); var panel = document.createElement("div"); panel.id = panelId; panel.style.position = "fixed"; panel.style.bottom = "86px"; panel.style.right = "16px"; panel.style.width = "380px"; panel.style.maxWidth = "calc(100vw - 24px)"; panel.style.height = "620px"; panel.style.maxHeight = "calc(100vh - 110px)"; panel.style.background = "#fff"; panel.style.border = "1px solid rgba(15,23,42,0.12)"; panel.style.borderRadius = "16px"; panel.style.overflow = "hidden"; panel.style.boxShadow = "0 30px 60px rgba(2,6,23,0.28)"; panel.style.zIndex = "2147483646"; panel.style.display = "none"; panel.style.fontFamily = fontFamily; var header = document.createElement("div"); header.style.height = "56px"; header.style.display = "flex"; header.style.alignItems = "center"; header.style.gap = "10px"; header.style.padding = "0 12px"; header.style.borderBottom = "1px solid rgba(15,23,42,0.08)"; header.style.background = "#fff"; var logoWrap = document.createElement("div"); logoWrap.style.width = "30px"; logoWrap.style.height = "30px"; logoWrap.style.borderRadius = "10px"; logoWrap.style.flexShrink = "0"; logoWrap.style.display = "flex"; logoWrap.style.alignItems = "center"; logoWrap.style.justifyContent = "center"; logoWrap.style.background = "rgba(79,70,229,0.12)"; logoWrap.style.border = "1px solid rgba(79,70,229,0.2)"; if (logoUrl) { var logo = document.createElement("img"); logo.src = logoUrl; logo.alt = chatName; logo.style.width = "20px"; logo.style.height = "20px"; logo.style.objectFit = "contain"; logoWrap.appendChild(logo); } else { logoWrap.innerHTML = "J"; } header.appendChild(logoWrap); var headerText = document.createElement("div"); headerText.style.minWidth = "0"; var titleLine = document.createElement("div"); titleLine.style.fontSize = "13px"; titleLine.style.fontWeight = "800"; titleLine.style.color = "#111827"; titleLine.style.whiteSpace = "nowrap"; titleLine.style.overflow = "hidden"; titleLine.style.textOverflow = "ellipsis"; titleLine.textContent = String(chatName || "Chat"); var subLine = document.createElement("div"); subLine.style.fontSize = "11px"; subLine.style.color = "#64748b"; subLine.style.whiteSpace = "nowrap"; subLine.style.overflow = "hidden"; subLine.style.textOverflow = "ellipsis"; subLine.textContent = String(tagline || ""); headerText.appendChild(titleLine); headerText.appendChild(subLine); header.appendChild(headerText); panel.appendChild(header); var closeBtn = document.createElement("button"); closeBtn.id = closeId; closeBtn.type = "button"; closeBtn.innerHTML = "Close"; closeBtn.setAttribute("aria-label", "Close chat"); closeBtn.style.position = "absolute"; closeBtn.style.top = "10px"; closeBtn.style.right = "10px"; closeBtn.style.zIndex = "2"; closeBtn.style.border = "1px solid rgba(255,255,255,0.5)"; closeBtn.style.borderRadius = "10px"; closeBtn.style.padding = "6px 10px"; closeBtn.style.fontSize = "12px"; closeBtn.style.fontWeight = "700"; closeBtn.style.color = "#ffffff"; closeBtn.style.background = "rgba(15,23,42,0.45)"; closeBtn.style.cursor = "pointer"; closeBtn.addEventListener("click", function () { closePanel(); }); panel.appendChild(closeBtn); var iframe = document.createElement("iframe"); iframe.id = iframeId; iframe.title = chatName || "Chat"; iframe.src = appBaseUrl + "/storefront-chat/widget?shop=" + encodeURIComponent(shop); iframe.style.width = "100%"; iframe.style.height = "calc(100% - 56px)"; iframe.style.border = "0"; iframe.style.background = "white"; panel.appendChild(iframe); document.body.appendChild(panel); } function openPanel() { ensurePanel(); var panel = document.getElementById(panelId); var backdrop = document.getElementById(backdropId); if (panel) panel.style.display = "block"; if (backdrop) backdrop.style.display = "block"; } function closePanel() { var panel = document.getElementById(panelId); var backdrop = document.getElementById(backdropId); if (panel) panel.style.display = "none"; if (backdrop) backdrop.style.display = "none"; } function upsertButton() { var container = document.getElementById(containerId); if (!container) { container = document.createElement("div"); container.id = containerId; container.style.position = "fixed"; container.style.bottom = "16px"; container.style.right = "16px"; container.style.zIndex = "2147483647"; container.style.width = "56px"; container.style.height = "56px"; document.body.appendChild(container); } var existing = document.getElementById(buttonId); if (existing) return; var btn = document.createElement("button"); btn.id = buttonId; btn.type = "button"; btn.setAttribute("aria-label", "Open chat"); btn.style.width = "56px"; btn.style.height = "56px"; btn.style.borderRadius = "16px"; btn.style.cursor = "pointer"; btn.style.boxShadow = "0 12px 28px rgba(0,0,0,0.20)"; btn.style.display = "flex"; btn.style.alignItems = "center"; btn.style.justifyContent = "center"; btn.style.fontFamily = fontFamily; btn.style.fontWeight = "800"; btn.style.letterSpacing = "-0.02em"; btn.style.padding = "0"; btn.style.overflow = "hidden"; if (logoUrl) { btn.style.background = "#ffffff"; btn.style.border = "2px solid " + (brandColor || "#4f46e5"); var fabImg = document.createElement("img"); fabImg.src = logoUrl; fabImg.alt = chatName || "Chat"; fabImg.style.width = "36px"; fabImg.style.height = "36px"; fabImg.style.objectFit = "contain"; fabImg.style.display = "block"; btn.appendChild(fabImg); } else { btn.style.border = "0"; btn.style.background = brandColor || "#4f46e5"; btn.style.color = "#fff"; btn.innerHTML = "Chat"; } btn.addEventListener("click", function () { openPanel(); }); container.appendChild(btn); } function removeWidget() { var container = document.getElementById(containerId); var panel = document.getElementById(panelId); var backdrop = document.getElementById(backdropId); if (container && container.parentNode) container.parentNode.removeChild(container); if (panel && panel.parentNode) panel.parentNode.removeChild(panel); if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); } removeWidget(); upsertButton(); ensurePanel(); } catch (e) {} })(); `.trimStart() } async function syncStorefrontTheme(params: { storeId: string enabled: boolean shopifyDomain: string shopifyAccessToken: string chatName: string tagline: string brandColor: string fontFamily: string logoUrl: string | null }) { const apiVersion = "2024-01" const cleanDomain = (params.shopifyDomain || "") .trim() .replace(/^https?:\/\//, "") .replace(/\/$/, "") .trim() const accessToken = params.shopifyAccessToken if (!cleanDomain || !accessToken) { console.warn("[STOREFRONT_CHAT_THEME_SYNC_SKIP]", { storeId: params.storeId, cleanDomain, hasAccessToken: Boolean(accessToken), }) return } console.log("[STOREFRONT_CHAT_THEME_SYNC_START]", { storeId: params.storeId, enabled: params.enabled, shop: cleanDomain, }) const listLegacyTags = await fetch( `https://${cleanDomain}/admin/api/${apiVersion}/script_tags.json?limit=250&fields=id,src`, { headers: { "X-Shopify-Access-Token": accessToken, "Content-Type": "application/json", }, } ).then((r) => r.json().catch(() => ({}))).catch(() => ({})) const legacyTags: Array<{ id: number; src?: string }> = Array.isArray(listLegacyTags?.script_tags) ? listLegacyTags.script_tags : [] const legacyMatches = legacyTags.filter((t) => { const src = typeof t?.src === "string" ? t.src : "" return src.includes("/storefront-chat/widget-injector") || src.includes("/apps/juno-chat/widget-injector.js") }) for (const tag of legacyMatches) { if (!tag?.id) continue await fetch(`https://${cleanDomain}/admin/api/${apiVersion}/script_tags/${tag.id}.json`, { method: "DELETE", headers: { "X-Shopify-Access-Token": accessToken }, }).catch(() => {}) } const published = await getPublishedThemeId(cleanDomain, accessToken, apiVersion) if (!published.ok) { throw new Error(`Published theme fetch failed: ${published.status}`) } const themeId = published.data.themeId const themeLiquidKey = "layout/theme.liquid" const assetKey = "assets/juno-storefront-chat.js" const assetFilename = "juno-storefront-chat.js" const appBaseUrl = ( process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL || "" ).trim().replace(/\/$/, "") const js = buildStorefrontScript({ appBaseUrl, chatName: params.chatName, tagline: params.tagline, brandColor: params.brandColor, fontFamily: params.fontFamily, logoUrl: resolveLogoUrlForInject(appBaseUrl, params.logoUrl), }) const putAsset = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, assetKey, js) if (!putAsset.ok) { throw new Error(`Asset update failed: ${putAsset.status}`) } const themeLiquid = await getThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey) if (!themeLiquid.ok) { throw new Error(`Theme liquid read failed: ${themeLiquid.status}`) } const updated = params.enabled ? upsertThemeLiquidInjection(themeLiquid.data.value, assetFilename) : removeThemeLiquidInjection(themeLiquid.data.value) if (!updated.updated && "error" in updated) { throw new Error(String(updated.error)) } if (updated.updated) { const putThemeLiquid = await putThemeAsset(cleanDomain, accessToken, apiVersion, themeId, themeLiquidKey, updated.next) if (!putThemeLiquid.ok) { throw new Error(`Theme liquid update failed: ${putThemeLiquid.status}`) } } console.log("[STOREFRONT_CHAT_THEME_SYNC_OK]", { storeId: params.storeId, enabled: params.enabled, themeId }) } /** * GET /api/stores/[storeId] * Read storefront + L1 AI settings for the authenticated vendor. */ export async function GET( _request: Request, { params }: { params: Promise<{ storeId: string }> }, ) { try { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } const { storeId } = await params if (!storeId) { return NextResponse.json({ message: "Store ID is required" }, { status: 400 }) } type StoreGetRow = { id: string businessName: string categoryMetadetailsEnabled: boolean aiModules: unknown modulePolicies?: unknown sharedFieldAnswers?: unknown customAiModules?: unknown gmailReplyTheme?: unknown lowStockThreshold?: number | null lowStockShopifyThreshold?: number | null storefrontOtpSessionMinutes?: number } let store: StoreGetRow | null = null try { store = (await prisma.store.findFirst({ where: { id: storeId, userId: session.user.id }, select: storeSelectVendorSettingsGetFull as unknown as Prisma.StoreSelect, })) as StoreGetRow | null } catch (firstErr) { console.warn( "[STORE_GET] full select failed (run migrations if modulePolicies/sharedFieldAnswers are missing); retrying minimal", firstErr, ) store = (await prisma.store.findFirst({ where: { id: storeId, userId: session.user.id }, select: storeSelectVendorSettingsGetMinimal as unknown as Prisma.StoreSelect, })) as StoreGetRow | null } if (!store) { return NextResponse.json({ message: "Store not found" }, { status: 404 }) } return NextResponse.json({ id: store.id, businessName: store.businessName, categoryMetadetailsEnabled: store.categoryMetadetailsEnabled, aiModules: store.aiModules, modulePolicies: parseModulePoliciesJson(store.modulePolicies ?? null), sharedFieldAnswers: parseSharedFieldAnswersJson(store.sharedFieldAnswers ?? null), customAiModules: parseCustomAiModulesJson(store.customAiModules ?? null), gmailReplyTheme: store.gmailReplyTheme === undefined ? undefined : store.gmailReplyTheme === null ? null : parseGmailReplyThemeJson(store.gmailReplyTheme), lowStockThreshold: store.lowStockThreshold ?? null, lowStockShopifyThreshold: store.lowStockShopifyThreshold ?? null, storefrontOtpSessionMinutes: store.storefrontOtpSessionMinutes ?? 60, }) } catch (error) { console.error("Error reading store settings:", error) return NextResponse.json({ message: "Failed to read store" }, { status: 500 }) } } export async function PATCH( request: Request, { params }: { params: Promise<{ storeId: string }> } ) { try { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } const { storeId } = await params if (!storeId) { return NextResponse.json({ message: "Store ID is required" }, { status: 400 }) } const payload = await request.json().catch(() => ({})) const data: Record = (payload && typeof payload === "object") ? payload : {} // Verify store belongs to the user const store = await prisma.store.findFirst({ where: { id: storeId, userId: session.user.id } }) if (!store) { return NextResponse.json({ message: "Store not found" }, { status: 404 }) } const nextEnabled = typeof data.storefrontChatEnabled === "boolean" ? data.storefrontChatEnabled : store.storefrontChatEnabled const nextChatName = sanitizeShortText(data.storefrontChatName, store.storefrontChatName || DEFAULT_CHAT_NAME, 50) const nextTagline = sanitizeShortText(data.storefrontChatTagline, store.storefrontChatTagline || DEFAULT_TAGLINE, 90) const nextBrandColor = sanitizeColor(data.storefrontChatBrandColor ?? store.storefrontChatBrandColor) const nextFont = sanitizeFont(data.storefrontChatFontFamily ?? store.storefrontChatFontFamily) const nextLogoUrl = sanitizeLogoUrl(data.storefrontChatLogoUrl ?? store.storefrontChatLogoUrl) type StorePatchPayload = Prisma.StoreUpdateInput & { lowStockThreshold?: number | null lowStockShopifyThreshold?: number | null storefrontOtpSessionMinutes?: number customAiModules?: Prisma.InputJsonValue | typeof Prisma.JsonNull } const updatePayload: StorePatchPayload = { storefrontChatEnabled: nextEnabled, storefrontChatName: nextChatName, storefrontChatTagline: nextTagline, storefrontChatBrandColor: nextBrandColor, storefrontChatFontFamily: nextFont, storefrontChatLogoUrl: nextLogoUrl, } if (typeof data.categoryMetadetailsEnabled === "boolean") { updatePayload.categoryMetadetailsEnabled = data.categoryMetadetailsEnabled } if ( data.aiModules !== undefined && typeof data.aiModules === "object" && data.aiModules !== null && !Array.isArray(data.aiModules) ) { updatePayload.aiModules = data.aiModules as Prisma.InputJsonValue } let reembedStructured = false if (data.modulePolicies !== undefined) { const parsed = modulePoliciesBodySchema.safeParse(data.modulePolicies) if (!parsed.success) { return NextResponse.json( { message: "Invalid modulePolicies", issues: parsed.error.flatten() }, { status: 400 }, ) } const normalized = parseModulePoliciesJson(parsed.data) updatePayload.modulePolicies = Object.keys(normalized).length === 0 ? Prisma.JsonNull : (normalized as Prisma.InputJsonValue) reembedStructured = true } if (data.sharedFieldAnswers !== undefined) { const parsed = sharedFieldAnswersBodySchema.safeParse(data.sharedFieldAnswers) if (!parsed.success) { return NextResponse.json( { message: "Invalid sharedFieldAnswers", issues: parsed.error.flatten() }, { status: 400 }, ) } const normalized = sharedFieldAnswersPayload(parsed.data) updatePayload.sharedFieldAnswers = Object.keys(normalized).length === 0 ? Prisma.JsonNull : (normalized as Prisma.InputJsonValue) reembedStructured = true } if (data.customAiModules !== undefined) { const parsed = customAiModulesBodySchema.safeParse(data.customAiModules) if (!parsed.success) { return NextResponse.json( { message: "Invalid customAiModules", issues: parsed.error.flatten() }, { status: 400 }, ) } const normalized = customAiModulesPayload(parsed.data) updatePayload.customAiModules = normalized.length === 0 ? Prisma.JsonNull : (normalized as Prisma.InputJsonValue) reembedStructured = true } if (data.gmailReplyTheme !== undefined) { if (data.gmailReplyTheme === null) { updatePayload.gmailReplyTheme = Prisma.JsonNull } else { const parsed = gmailReplyThemeBodySchema.safeParse(data.gmailReplyTheme) if (!parsed.success) { return NextResponse.json( { message: "Invalid gmailReplyTheme", issues: parsed.error.flatten() }, { status: 400 }, ) } const normalized = resolvedThemeFromBody(parsed.data) // Plain JSON object for Prisma Json (avoids odd runtime serialization edge cases). updatePayload.gmailReplyTheme = JSON.parse( JSON.stringify(normalized), ) as Prisma.InputJsonValue } } const lowStockThresholdTouched = data.lowStockThreshold !== undefined const lowStockShopifyThresholdTouched = data.lowStockShopifyThreshold !== undefined if (lowStockThresholdTouched) { const parsed = parseOptionalLowStockThreshold(data.lowStockThreshold, "lowStockThreshold") if (!parsed.ok) { return NextResponse.json({ message: parsed.message }, { status: 400 }) } updatePayload.lowStockThreshold = parsed.value } if (lowStockShopifyThresholdTouched) { const parsed = parseOptionalLowStockThreshold( data.lowStockShopifyThreshold, "lowStockShopifyThreshold", ) if (!parsed.ok) { return NextResponse.json({ message: parsed.message }, { status: 400 }) } updatePayload.lowStockShopifyThreshold = parsed.value } if (data.storefrontOtpSessionMinutes !== undefined) { const parsed = parseStorefrontOtpSessionMinutes(data.storefrontOtpSessionMinutes) if (!parsed.ok) { return NextResponse.json({ message: parsed.message }, { status: 400 }) } updatePayload.storefrontOtpSessionMinutes = parsed.value } const lowStockSettingsTouched = lowStockThresholdTouched || lowStockShopifyThresholdTouched const updatedStore = await prisma.store.update({ where: { id: storeId }, data: updatePayload as Prisma.StoreUpdateInput, select: storeSelectPatchStorefrontAi as unknown as Prisma.StoreSelect, }) if (lowStockSettingsTouched) { await prisma.$executeRaw( Prisma.sql`DELETE FROM "LowStockAlertState" WHERE "storeId" = ${storeId}`, ) await prisma.$executeRaw( Prisma.sql`DELETE FROM "LowStockShopifyVendorAlertState" WHERE "storeId" = ${storeId}`, ) const patchLowStock = updatedStore as { lowStockThreshold?: number | null lowStockShopifyThreshold?: number | null } if ( patchLowStock.lowStockThreshold != null || patchLowStock.lowStockShopifyThreshold != null ) { void reconcileLowStockForStore(storeId).catch((err) => { console.error("[LOW_STOCK_RECONCILE_AFTER_THRESHOLD_SAVE]", err) }) } } // Explicit behavior change: // - Enable: save only (no auto inject). // - Disable: remove snippet immediately to hard-stop storefront widget. if (!updatedStore.storefrontChatEnabled) { try { await syncStorefrontTheme({ storeId, enabled: false, shopifyDomain: store.shopifyDomain, shopifyAccessToken: store.shopifyAccessToken, chatName: updatedStore.storefrontChatName || DEFAULT_CHAT_NAME, tagline: updatedStore.storefrontChatTagline || DEFAULT_TAGLINE, brandColor: updatedStore.storefrontChatBrandColor || DEFAULT_BRAND_COLOR, fontFamily: updatedStore.storefrontChatFontFamily || DEFAULT_FONT, logoUrl: updatedStore.storefrontChatLogoUrl || null, }) } catch (scriptError) { console.error("[STOREFRONT_CHAT_THEME_SYNC_ERR]", scriptError) } } if (reembedStructured) { try { await replaceStructuredKnowledgeChunksForStore(storeId) } catch (e) { console.error("[MODULE_POLICIES_REEMBED_ERR]", e) } } return NextResponse.json({ success: true, storefrontChatEnabled: updatedStore.storefrontChatEnabled, storefrontChatName: updatedStore.storefrontChatName, storefrontChatTagline: updatedStore.storefrontChatTagline, storefrontChatBrandColor: updatedStore.storefrontChatBrandColor, storefrontChatFontFamily: updatedStore.storefrontChatFontFamily, storefrontChatLogoUrl: updatedStore.storefrontChatLogoUrl, categoryMetadetailsEnabled: updatedStore.categoryMetadetailsEnabled, aiModules: updatedStore.aiModules, modulePolicies: parseModulePoliciesJson(updatedStore.modulePolicies), sharedFieldAnswers: parseSharedFieldAnswersJson(updatedStore.sharedFieldAnswers), customAiModules: parseCustomAiModulesJson( (updatedStore as { customAiModules?: unknown }).customAiModules, ), gmailReplyTheme: updatedStore.gmailReplyTheme == null ? null : parseGmailReplyThemeJson(updatedStore.gmailReplyTheme), lowStockThreshold: (updatedStore as { lowStockThreshold?: number | null }).lowStockThreshold ?? null, lowStockShopifyThreshold: (updatedStore as { lowStockShopifyThreshold?: number | null }).lowStockShopifyThreshold ?? null, storefrontOtpSessionMinutes: (updatedStore as { storefrontOtpSessionMinutes?: number }).storefrontOtpSessionMinutes ?? 60, ...(lowStockSettingsTouched ? { lowStockReconcileQueued: (updatedStore as { lowStockThreshold?: number | null }).lowStockThreshold != null || (updatedStore as { lowStockShopifyThreshold?: number | null }) .lowStockShopifyThreshold != null, } : {}), }) } catch (error) { console.error("Error updating store settings:", error) return NextResponse.json( { message: "Failed to update store setting" }, { status: 500 } ) } } /** * POST /api/stores/[storeId] * Inject storefront chat snippet + JS asset using saved customization. */ export async function POST( _request: Request, { params }: { params: Promise<{ storeId: string }> } ) { try { const session = await auth() if (!session?.user?.id) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } const { storeId } = await params if (!storeId) { return NextResponse.json({ message: "Store ID is required" }, { status: 400 }) } const store = await prisma.store.findFirst({ where: { id: storeId, userId: session.user.id, }, select: { id: true, storefrontChatEnabled: true, storefrontChatName: true, storefrontChatTagline: true, storefrontChatBrandColor: true, storefrontChatFontFamily: true, storefrontChatLogoUrl: true, shopifyDomain: true, shopifyAccessToken: true, }, }) if (!store) { return NextResponse.json({ message: "Store not found" }, { status: 404 }) } if (!store.storefrontChatEnabled) { return NextResponse.json({ message: "Enable storefront chat first" }, { status: 400 }) } await syncStorefrontTheme({ storeId: store.id, enabled: true, shopifyDomain: store.shopifyDomain, shopifyAccessToken: store.shopifyAccessToken, chatName: sanitizeShortText(store.storefrontChatName, DEFAULT_CHAT_NAME, 50), tagline: sanitizeShortText(store.storefrontChatTagline, DEFAULT_TAGLINE, 90), brandColor: sanitizeColor(store.storefrontChatBrandColor), fontFamily: sanitizeFont(store.storefrontChatFontFamily), logoUrl: sanitizeLogoUrl(store.storefrontChatLogoUrl), }) return NextResponse.json({ success: true }) } catch (error) { console.error("[STOREFRONT_CHAT_INJECT_ERR]", error) return NextResponse.json({ message: "Failed to inject storefront chat" }, { status: 500 }) } }