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