diff --git a/prisma/migrations/20260512220500_store_custom_ai_modules/migration.sql b/prisma/migrations/20260512220500_store_custom_ai_modules/migration.sql new file mode 100644 index 0000000..6846127 --- /dev/null +++ b/prisma/migrations/20260512220500_store_custom_ai_modules/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Store" ADD COLUMN "customAiModules" JSONB; diff --git a/prisma/migrations/20260512223000_cached_order_note_attributes/migration.sql b/prisma/migrations/20260512223000_cached_order_note_attributes/migration.sql new file mode 100644 index 0000000..9504550 --- /dev/null +++ b/prisma/migrations/20260512223000_cached_order_note_attributes/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "CachedOrder" ADD COLUMN "noteAttributes" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 731356d..8478380 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,6 +144,10 @@ model Store { * Vendor Type-1 answers keyed by shared_field_id (sf_*); one value per key store-wide (§4.2). */ sharedFieldAnswers Json? + /** + * Vendor-created knowledge modules. Each enabled module is embedded into STRUCTURED chunks. + */ + customAiModules Json? /** * Optional Gmail auto-reply HTML theming (colors, font, logo URL). Merged with defaults server-side. @@ -304,6 +308,7 @@ model CachedOrder { lastShopifySyncAt DateTime? lastShopifySyncError String? lineItems Json // Store line items as JSON + noteAttributes Json? customer Json? // Store customer info as JSON createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/api/dashboard/ai-analytics/route.ts b/src/app/api/dashboard/ai-analytics/route.ts index 9e59b07..f45e32d 100644 --- a/src/app/api/dashboard/ai-analytics/route.ts +++ b/src/app/api/dashboard/ai-analytics/route.ts @@ -52,6 +52,7 @@ type CachedOrderRow = { totalPrice: string currency: string | null lineItems: unknown + noteAttributes?: unknown shopifyCreatedAt: Date | null createdAt: Date } @@ -416,6 +417,7 @@ export async function GET(req: Request) { totalPrice: true, currency: true, lineItems: true, + noteAttributes: true, shopifyCreatedAt: true, createdAt: true, }, diff --git a/src/app/api/storefront-chat/messages/route.ts b/src/app/api/storefront-chat/messages/route.ts index e951bd8..d15f5f5 100644 --- a/src/app/api/storefront-chat/messages/route.ts +++ b/src/app/api/storefront-chat/messages/route.ts @@ -221,12 +221,14 @@ function imageUrlFromImages(images: unknown): string | null { async function buildCartResponse(params: { storeId: string + conversationId: string shopifyDomain: string productId: string requestedVariantId?: string | null quantity?: number message: string existingCartLines: StorefrontCartLine[] + attributionSource: "primary" | "upsell" | "direct" }): Promise { const product = await prisma.cachedProduct.findFirst({ where: { storeId: params.storeId, shopifyProductId: params.productId }, @@ -306,7 +308,12 @@ async function buildCartResponse(params: { productId: product.shopifyProductId, title: product.title, }) - const cartUrl = buildCartPermalink(params.shopifyDomain, nextCartLines) + const cartUrl = buildCartPermalink(params.shopifyDomain, nextCartLines, { + juno_ai_conversation_id: params.conversationId, + juno_ai_product_id: product.shopifyProductId, + juno_ai_variant_id: selected.variant.id, + juno_ai_source: params.attributionSource, + }) if (!cartUrl) { return { content: "I could not create a cart link for that product right now.", @@ -332,6 +339,7 @@ async function buildCartResponse(params: { async function resolveDirectAddToCart(params: { storeId: string + conversationId: string shopifyDomain: string message: string existingCartLines: StorefrontCartLine[] @@ -343,12 +351,14 @@ async function resolveDirectAddToCart(params: { const product = productSearchHitToActionProduct(hit) return buildCartResponse({ storeId: params.storeId, + conversationId: params.conversationId, shopifyDomain: params.shopifyDomain, productId: hit.shopifyProductId, requestedVariantId: product?.variantId, quantity: product?.quantity ?? 1, message: params.message, existingCartLines: params.existingCartLines, + attributionSource: "direct", }) } @@ -467,25 +477,30 @@ export async function POST(req: Request) { const cartResponse = action ? await buildCartResponse({ storeId: store.id, + conversationId: conversation.id, shopifyDomain: store.shopifyDomain, productId: action.productId, requestedVariantId: action.variantId, quantity: action.quantity, message: customerContent, existingCartLines, + attributionSource: action.type === "upsell_suggestion" ? "upsell" : "primary", }) : pendingCartProduct && isCartConfirmationMessage(customerContent) ? await buildCartResponse({ storeId: store.id, + conversationId: conversation.id, shopifyDomain: store.shopifyDomain, productId: pendingCartProduct.productId, requestedVariantId: pendingCartProduct.variantId, quantity: pendingCartProduct.quantity, message: customerContent, existingCartLines, + attributionSource: "primary", }) : await resolveDirectAddToCart({ storeId: store.id, + conversationId: conversation.id, shopifyDomain: store.shopifyDomain, message: customerContent, existingCartLines, diff --git a/src/app/api/stores/[storeId]/route.ts b/src/app/api/stores/[storeId]/route.ts index 890982c..63271cc 100644 --- a/src/app/api/stores/[storeId]/route.ts +++ b/src/app/api/stores/[storeId]/route.ts @@ -13,6 +13,11 @@ import { sharedFieldAnswersBodySchema, sharedFieldAnswersPayload, } from "@/lib/kb/sharedFieldAnswers" +import { + customAiModulesBodySchema, + customAiModulesPayload, + parseCustomAiModulesJson, +} from "@/lib/kb/customAiModules" import { replaceStructuredKnowledgeChunksForStore } from "@/lib/kb/structuredTemplateChunks" import { getPublishedThemeId, @@ -571,8 +576,8 @@ async function syncStorefrontTheme(params: { const updated = params.enabled ? upsertThemeLiquidInjection(themeLiquid.data.value, assetFilename) : removeThemeLiquidInjection(themeLiquid.data.value) - if (!updated.updated && (updated as any).error) { - throw new Error((updated as any).error) + 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) @@ -610,6 +615,7 @@ export async function GET( aiModules: unknown modulePolicies?: unknown sharedFieldAnswers?: unknown + customAiModules?: unknown gmailReplyTheme?: unknown lowStockThreshold?: number | null lowStockShopifyThreshold?: number | null @@ -644,6 +650,7 @@ export async function GET( aiModules: store.aiModules, modulePolicies: parseModulePoliciesJson(store.modulePolicies ?? null), sharedFieldAnswers: parseSharedFieldAnswersJson(store.sharedFieldAnswers ?? null), + customAiModules: parseCustomAiModulesJson(store.customAiModules ?? null), gmailReplyTheme: store.gmailReplyTheme === undefined ? undefined @@ -704,6 +711,7 @@ export async function PATCH( lowStockThreshold?: number | null lowStockShopifyThreshold?: number | null storefrontOtpSessionMinutes?: number + customAiModules?: Prisma.InputJsonValue | typeof Prisma.JsonNull } const updatePayload: StorePatchPayload = { @@ -760,6 +768,20 @@ export async function PATCH( 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 @@ -880,6 +902,9 @@ export async function PATCH( aiModules: updatedStore.aiModules, modulePolicies: parseModulePoliciesJson(updatedStore.modulePolicies), sharedFieldAnswers: parseSharedFieldAnswersJson(updatedStore.sharedFieldAnswers), + customAiModules: parseCustomAiModulesJson( + (updatedStore as { customAiModules?: unknown }).customAiModules, + ), gmailReplyTheme: updatedStore.gmailReplyTheme == null ? null diff --git a/src/app/dashboard/juno-engine/modules/page.tsx b/src/app/dashboard/juno-engine/modules/page.tsx index 24f8244..722d948 100644 --- a/src/app/dashboard/juno-engine/modules/page.tsx +++ b/src/app/dashboard/juno-engine/modules/page.tsx @@ -26,24 +26,35 @@ import { } from "@/lib/ai/orchestrator/moduleThreeTypes" import { parseSharedFieldAnswersJson, sharedFieldAnswersPayload } from "@/lib/kb/sharedFieldAnswers" import { KNOWLEDGE_TEMPLATE_ANSWER_MAX } from "@/lib/kb/knowledgeTemplate" +import { + customAiModulesPayload, + parseCustomAiModulesJson, + type CustomAiModule, +} from "@/lib/kb/customAiModules" import { ChevronRight, + Eye, HelpCircle, Info, Layers, Loader2, MapPin, Package, + Pencil, + Plus, Ruler, Save, ShieldAlert, ThumbsUp, + Trash2, UserCircle, X, } from "lucide-react" const FAQ_CARD_ID = "faq" const CATEGORY_CARD_ID = "category_metadetails" +const ADD_CUSTOM_MODULE_ID = "custom_module_new" +const CUSTOM_MODULE_PREFIX = "custom_module:" /** Single grid order — no section groupings on the dashboard. */ const DASHBOARD_CARD_ORDER: Array< @@ -79,7 +90,13 @@ function categoryAutomation(on: boolean): AutomationFlags { return { knowledge: false, verifiedLookup: on, ticket: false } } -type ModalId = typeof FAQ_CARD_ID | typeof CATEGORY_CARD_ID | Exclude | null +type CustomModuleModalId = typeof ADD_CUSTOM_MODULE_ID | `${typeof CUSTOM_MODULE_PREFIX}${string}` +type ModalId = + | typeof FAQ_CARD_ID + | typeof CATEGORY_CARD_ID + | Exclude + | CustomModuleModalId + | null const ALL_MODAL_MODULES = new Set(L1_MODULE_IDS) @@ -107,6 +124,23 @@ function moduleIcon(m: Exclude) { } } +function makeId(prefix: "custom" | "answer") { + return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` +} + +function emptyCustomModule(): CustomAiModule { + const now = new Date().toISOString() + return { + id: makeId("custom"), + title: "", + subtitle: "", + enabled: true, + answers: [{ id: makeId("answer"), subtitle: "", answer: "" }], + createdAt: now, + updatedAt: now, + } +} + export default function JunoEngineModulesPage() { const { theme } = useTheme() const isLight = theme === "light" @@ -120,6 +154,11 @@ export default function JunoEngineModulesPage() { parseAiModulesJson(null), ) const [sharedFields, setSharedFields] = useState>({}) + const [customModules, setCustomModules] = useState([]) + const [draftCustomModule, setDraftCustomModule] = useState(() => + emptyCustomModule(), + ) + const [customModuleViewMode, setCustomModuleViewMode] = useState<"view" | "edit">("view") const [categoryMetaEnabled, setCategoryMetaEnabled] = useState(false) const [modalId, setModalId] = useState(null) const [modalSaveError, setModalSaveError] = useState(null) @@ -142,6 +181,7 @@ export default function JunoEngineModulesPage() { const data = raw as { aiModules?: unknown sharedFieldAnswers?: unknown + customAiModules?: unknown categoryMetadetailsEnabled?: boolean } const parsedMods = parseAiModulesJson(data.aiModules) @@ -150,6 +190,7 @@ export default function JunoEngineModulesPage() { setMods(parsedMods) setCategoryMetaEnabled(catOn) setSharedFields(parseSharedFieldAnswersJson(data.sharedFieldAnswers)) + setCustomModules(parseCustomAiModulesJson(data.customAiModules)) }, []) useEffect(() => { @@ -202,12 +243,29 @@ export default function JunoEngineModulesPage() { if (!modalId) return setModalSaveError(null) setModuleInfoOpen(false) + if (modalId === ADD_CUSTOM_MODULE_ID) { + setDraftCustomModule(emptyCustomModule()) + setCustomModuleViewMode("edit") + } else if (typeof modalId === "string" && modalId.startsWith(CUSTOM_MODULE_PREFIX)) { + const id = modalId.slice(CUSTOM_MODULE_PREFIX.length) + const existing = customModules.find((module) => module.id === id) + if (existing) { + setDraftCustomModule({ + ...existing, + answers: + existing.answers.length > 0 + ? existing.answers.map((answer) => ({ ...answer })) + : [{ id: makeId("answer"), subtitle: "", answer: "" }], + }) + setCustomModuleViewMode("view") + } + } const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setModalId(null) } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) - }, [modalId]) + }, [customModules, modalId]) useEffect(() => { if (modalId) { @@ -220,7 +278,9 @@ export default function JunoEngineModulesPage() { } }, [modalId]) - async function saveChanges(): Promise<{ ok: boolean; error?: string }> { + async function saveChanges( + customModuleOverride?: CustomAiModule[], + ): Promise<{ ok: boolean; error?: string }> { if (!activeStoreId) return { ok: false, error: "No store selected" } setSaving(true) setToast(null) @@ -236,12 +296,14 @@ export default function JunoEngineModulesPage() { aiModules: serializeAiModules(mergedMods), categoryMetadetailsEnabled: categoryMetaEnabled, sharedFieldAnswers: sharedFieldAnswersPayload(sharedFields), + customAiModules: customAiModulesPayload(customModuleOverride ?? customModules), }), }) const j = (await res.json().catch(() => ({}))) as { message?: string aiModules?: unknown sharedFieldAnswers?: unknown + customAiModules?: unknown categoryMetadetailsEnabled?: boolean } if (!res.ok) throw new Error(j.message || "Save failed") @@ -251,6 +313,7 @@ export default function JunoEngineModulesPage() { setMods(nextMods) setCategoryMetaEnabled(catOn) setSharedFields(parseSharedFieldAnswersJson(j.sharedFieldAnswers)) + setCustomModules(parseCustomAiModulesJson(j.customAiModules)) setToast("Saved.") return { ok: true } } catch (e) { @@ -272,6 +335,80 @@ export default function JunoEngineModulesPage() { })) } + const customModalModuleId = + typeof modalId === "string" && modalId.startsWith(CUSTOM_MODULE_PREFIX) + ? modalId.slice(CUSTOM_MODULE_PREFIX.length) + : null + const isCustomModuleModal = modalId === ADD_CUSTOM_MODULE_ID || Boolean(customModalModuleId) + const customModalExisting = customModalModuleId + ? customModules.find((module) => module.id === customModalModuleId) ?? null + : null + + function setDraftAnswer( + answerId: string, + key: "subtitle" | "answer", + value: string, + ) { + setDraftCustomModule((prev) => ({ + ...prev, + answers: prev.answers.map((answer) => + answer.id === answerId ? { ...answer, [key]: value } : answer, + ), + })) + } + + function addDraftAnswer() { + setDraftCustomModule((prev) => ({ + ...prev, + answers: [...prev.answers, { id: makeId("answer"), subtitle: "", answer: "" }], + })) + } + + function removeDraftAnswer(answerId: string) { + setDraftCustomModule((prev) => ({ + ...prev, + answers: + prev.answers.length > 1 + ? prev.answers.filter((answer) => answer.id !== answerId) + : prev.answers, + })) + } + + function nextCustomModulesFromDraft(): { ok: true; modules: CustomAiModule[] } | { ok: false; error: string } { + const normalized = customAiModulesPayload([draftCustomModule])[0] + if (!normalized?.title) { + return { ok: false, error: "Add a module title." } + } + if (normalized.answers.length === 0) { + return { ok: false, error: "Add at least one subtitle and answer." } + } + const exists = customModules.some((module) => module.id === normalized.id) + const modules = exists + ? customModules.map((module) => (module.id === normalized.id ? normalized : module)) + : [...customModules, normalized] + return { ok: true, modules } + } + + async function saveDraftCustomModule() { + const next = nextCustomModulesFromDraft() + if (!next.ok) { + setModalSaveError(next.error) + return + } + setCustomModules(next.modules) + const result = await saveChanges(next.modules) + if (result.ok) setModalId(null) + else if (result.error) setModalSaveError(result.error) + } + + async function deleteCustomModule(moduleId: string) { + const nextModules = customModules.filter((module) => module.id !== moduleId) + setCustomModules(nextModules) + const result = await saveChanges(nextModules) + if (result.ok) setModalId(null) + else if (result.error) setModalSaveError(result.error) + } + const modalModule: Exclude | null = modalId && isModuleModalId(modalId) ? modalId : null @@ -536,6 +673,268 @@ export default function JunoEngineModulesPage() { ) } + function renderCustomModuleCard(module: CustomAiModule) { + const subtitle = + module.subtitle || + module.answers[0]?.subtitle || + "Vendor-created knowledge module" + return ( + + ) + } + + function renderAddModuleCard() { + return ( + + ) + } + + function renderCustomModuleEditor() { + const subtle = isLight ? "text-slate-600" : "text-slate-400" + const rowBorder = isLight ? "border-slate-200 bg-slate-50/50" : "border-white/10 bg-white/[0.03]" + const canView = modalId !== ADD_CUSTOM_MODULE_ID && customModuleViewMode === "view" + + if (canView) { + return ( +
+
+
+
+

+ {draftCustomModule.title || "Untitled module"} +

+

+ {draftCustomModule.subtitle || "No module subtitle added."} +

+
+ + {draftCustomModule.enabled ? "On" : "Off"} + +
+
+ {draftCustomModule.answers.map((answer) => ( +
+

+ {answer.subtitle} +

+

+ {answer.answer} +

+
+ ))} +
+ ) + } + + return ( +
+
+ +
+ + + setDraftCustomModule((prev) => ({ + ...prev, + title: e.target.value.slice(0, 80), + })) + } + maxLength={80} + disabled={saving} + placeholder="Example: VIP delivery rules" + className={cn( + "w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/30", + inputSurface, + )} + /> +
+
+ + + setDraftCustomModule((prev) => ({ + ...prev, + subtitle: e.target.value.slice(0, 160), + })) + } + maxLength={160} + disabled={saving} + placeholder="Short description shown on the module card" + className={cn( + "w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/30", + inputSurface, + )} + /> +
+
+ + {draftCustomModule.answers.map((answer, index) => ( +
+
+

+ Subtitle answer {index + 1} +

+ {draftCustomModule.answers.length > 1 ? ( + + ) : null} +
+
+ + setDraftAnswer(answer.id, "subtitle", e.target.value.slice(0, 160))} + maxLength={160} + disabled={saving} + placeholder="Example: Do you offer same-day delivery?" + className={cn( + "w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/30", + inputSurface, + )} + /> +
+
+ +