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,
+ )}
+ />
+
+
+
+
+
+ ))}
+
+
+
+ )
+ }
+
const infoSpecMod: Exclude | null =
modalId === FAQ_CARD_ID
? "FAQ"
@@ -589,6 +988,10 @@ export default function JunoEngineModulesPage() {
? L1_MODULE_CARD_TITLE.FAQ
: modalId === CATEGORY_CARD_ID
? L1_MODULE_CARD_TITLE.CATEGORY_METADETAILS
+ : isCustomModuleModal
+ ? modalId === ADD_CUSTOM_MODULE_ID
+ ? "Add Module"
+ : draftCustomModule.title || "Custom module"
: modalModule
? L1_MODULE_CARD_TITLE[modalModule]
: "Module"}
@@ -603,12 +1006,53 @@ export default function JunoEngineModulesPage() {
? L1_MODULE_CUSTOMER_LABEL.FAQ
: modalId === CATEGORY_CARD_ID
? L1_MODULE_CUSTOMER_LABEL.CATEGORY_METADETAILS
+ : isCustomModuleModal
+ ? "Custom knowledge module embedded into storefront AI answers."
: modalModule
? L1_MODULE_CUSTOMER_LABEL[modalModule]
: ""}
+ {isCustomModuleModal && modalId !== ADD_CUSTOM_MODULE_ID ? (
+
+
+
+
+ ) : null}
{hasModuleInfoPanel && infoSpecMod ? (
<>
+ ) : isCustomModuleModal ? (
+ renderCustomModuleEditor()
) : null}
@@ -689,6 +1135,22 @@ export default function JunoEngineModulesPage() {
isLight ? "border-slate-100 bg-slate-50/80" : "border-white/10 bg-slate-950/50",
)}
>
+ {customModalExisting ? (
+
+ ) : null}
@@ -893,6 +1359,12 @@ export default function JunoEngineModulesPage() {
)
})}
+ {customModules.map((module) => (
+
+ {renderCustomModuleCard(module)}
+
+ ))}
+ {renderAddModuleCard()}
{modal}
diff --git a/src/lib/analytics/revenueAttribution.test.ts b/src/lib/analytics/revenueAttribution.test.ts
index 43f3f59..c7e0ff4 100644
--- a/src/lib/analytics/revenueAttribution.test.ts
+++ b/src/lib/analytics/revenueAttribution.test.ts
@@ -105,4 +105,62 @@ describe("revenueAttribution", () => {
expect(result.directUpsellRevenue).toBe(0)
expect(result.directRows).toHaveLength(0)
})
+
+ it("matches anonymous storefront orders through JUNO cart note attributes", () => {
+ const result = computeAiRevenueAttribution({
+ events: [
+ {
+ id: "event-1",
+ conversationId: "conv-anon",
+ eventType: "upsell_clicked",
+ productId: "p-upsell",
+ variantId: "v-upsell",
+ quantity: 1,
+ createdAt: new Date("2026-05-01T10:00:00Z"),
+ },
+ {
+ id: "event-2",
+ conversationId: "conv-anon",
+ eventType: "customer_question",
+ productId: null,
+ variantId: null,
+ quantity: null,
+ createdAt: new Date("2026-05-01T09:59:00Z"),
+ },
+ ],
+ conversations: [
+ {
+ id: "conv-anon",
+ customerEmail: null,
+ boundShopifyOrderId: null,
+ createdAt: new Date("2026-05-01T09:58:00Z"),
+ updatedAt: new Date("2026-05-01T10:02:00Z"),
+ },
+ ],
+ orders: [
+ {
+ shopifyOrderId: "order-1",
+ orderNumber: "#1001",
+ email: "checkout@example.com",
+ totalPrice: "25.00",
+ currency: "GBP",
+ shopifyCreatedAt: new Date("2026-05-01T10:10:00Z"),
+ createdAt: new Date("2026-05-01T10:10:00Z"),
+ noteAttributes: [
+ { name: "juno_ai_conversation_id", value: "conv-anon" },
+ { name: "juno_ai_source", value: "upsell" },
+ ],
+ lineItems: [
+ { product_id: "p-upsell", variant_id: "v-upsell", title: "Socks", quantity: 1, price: "5.00" },
+ { product_id: "p-base", variant_id: "v-base", title: "Shoes", quantity: 1, price: "20.00" },
+ ],
+ },
+ ],
+ })
+
+ expect(result.directUpsellRevenue).toBe(5)
+ expect(result.aiAssistedRevenue).toBe(25)
+ expect(result.directRows).toHaveLength(1)
+ expect(result.assistedRows).toHaveLength(1)
+ })
})
diff --git a/src/lib/analytics/revenueAttribution.ts b/src/lib/analytics/revenueAttribution.ts
index f5a828a..0097e7a 100644
--- a/src/lib/analytics/revenueAttribution.ts
+++ b/src/lib/analytics/revenueAttribution.ts
@@ -23,6 +23,7 @@ type OrderLike = {
totalPrice: string
currency: string | null
lineItems: unknown
+ noteAttributes?: unknown
shopifyCreatedAt: Date | null
createdAt: Date
}
@@ -93,7 +94,24 @@ function orderDate(order: OrderLike): Date {
return order.shopifyCreatedAt ?? order.createdAt
}
+function noteAttributeValue(raw: unknown, key: string): string | null {
+ if (!Array.isArray(raw)) return null
+ const expected = key.trim().toLowerCase()
+ for (const row of raw) {
+ if (!row || typeof row !== "object") continue
+ const item = row as { name?: unknown; key?: unknown; value?: unknown }
+ const name = asString(item.name ?? item.key)?.toLowerCase()
+ if (name !== expected) continue
+ return asString(item.value)
+ }
+ return null
+}
+
function orderMatchesConversation(order: OrderLike, conversation: ConversationLike) {
+ const attributedConversationId = noteAttributeValue(order.noteAttributes, "juno_ai_conversation_id")
+ if (attributedConversationId && attributedConversationId === conversation.id) {
+ return true
+ }
if (conversation.boundShopifyOrderId && conversation.boundShopifyOrderId === order.shopifyOrderId) {
return true
}
diff --git a/src/lib/cache.ts b/src/lib/cache.ts
index 9ed5d21..ea72746 100644
--- a/src/lib/cache.ts
+++ b/src/lib/cache.ts
@@ -23,6 +23,7 @@ interface ShopifyOrderRecord {
fulfillment_status?: string
customer?: unknown
line_items?: unknown[]
+ note_attributes?: unknown[]
created_at?: string
}
@@ -136,7 +137,7 @@ export async function updateProductCache(storeId: string, products: ShopifyProdu
select: { shopifyDomain: true, shopifyAccessToken: true },
})
- let metafieldsByProductId = new Map()
+ const metafieldsByProductId = new Map()
if (store?.shopifyDomain && store?.shopifyAccessToken && products.length > 0) {
try {
const ids = products.map((p) => p.id)
@@ -279,9 +280,10 @@ export async function updateOrderCache(storeId: string, orders: ShopifyOrderReco
financialStatus: order.financial_status || null,
fulfillmentStatus: order.fulfillment_status || null,
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
+ noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null
- },
+ } as Prisma.CachedOrderUncheckedCreateInput,
update: {
orderNumber: order.order_number?.toString() || order.name || "",
email: extractOrderEmail(order),
@@ -295,10 +297,11 @@ export async function updateOrderCache(storeId: string, orders: ShopifyOrderReco
? existingById.get(order.id.toString())?.fulfillmentStatus || null
: order.fulfillment_status || null,
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
+ noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
lastSyncedAt: new Date()
- }
+ } as Prisma.CachedOrderUncheckedUpdateInput
})
)
@@ -531,10 +534,11 @@ export async function updateSingleOrder(storeId: string, order: ShopifyOrderReco
fulfillmentStatus:
shouldPreserve || hasSupplierHold ? preservedStatus : order.fulfillment_status || null,
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
+ noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
lastSyncedAt: new Date()
- },
+ } as Prisma.CachedOrderUncheckedCreateInput,
update: {
orderNumber: order.order_number?.toString() || order.name || "",
email: extractOrderEmail(order),
@@ -544,9 +548,10 @@ export async function updateSingleOrder(storeId: string, order: ShopifyOrderReco
fulfillmentStatus:
shouldPreserve || hasSupplierHold ? preservedStatus : order.fulfillment_status || null,
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
+ noteAttributes: (order.note_attributes || []) as Prisma.InputJsonValue,
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
lastSyncedAt: new Date()
- }
+ } as Prisma.CachedOrderUncheckedUpdateInput
})
}
diff --git a/src/lib/kb/customAiModules.ts b/src/lib/kb/customAiModules.ts
new file mode 100644
index 0000000..a41afed
--- /dev/null
+++ b/src/lib/kb/customAiModules.ts
@@ -0,0 +1,111 @@
+import { z } from "zod"
+import { KNOWLEDGE_TEMPLATE_ANSWER_MAX } from "@/lib/kb/knowledgeTemplate"
+
+const CUSTOM_MODULE_ID = /^custom_[a-z0-9_-]{6,80}$/
+const CUSTOM_ANSWER_ID = /^answer_[a-z0-9_-]{4,80}$/
+const TITLE_MAX = 80
+const SUBTITLE_MAX = 160
+const MAX_CUSTOM_MODULES = 30
+const MAX_ANSWERS_PER_MODULE = 12
+
+export type CustomAiModuleAnswer = {
+ id: string
+ subtitle: string
+ answer: string
+}
+
+export type CustomAiModule = {
+ id: string
+ title: string
+ subtitle: string
+ enabled: boolean
+ answers: CustomAiModuleAnswer[]
+ createdAt?: string
+ updatedAt?: string
+}
+
+export const customAiModulesBodySchema = z
+ .array(
+ z.object({
+ id: z.string().regex(CUSTOM_MODULE_ID),
+ title: z.string().max(TITLE_MAX),
+ subtitle: z.string().max(SUBTITLE_MAX).optional().default(""),
+ enabled: z.boolean().optional().default(true),
+ answers: z
+ .array(
+ z.object({
+ id: z.string().regex(CUSTOM_ANSWER_ID),
+ subtitle: z.string().max(SUBTITLE_MAX),
+ answer: z.string().max(KNOWLEDGE_TEMPLATE_ANSWER_MAX),
+ }),
+ )
+ .max(MAX_ANSWERS_PER_MODULE)
+ .optional()
+ .default([]),
+ createdAt: z.string().optional(),
+ updatedAt: z.string().optional(),
+ }),
+ )
+ .max(MAX_CUSTOM_MODULES)
+
+function cleanText(value: unknown, max: number) {
+ return typeof value === "string" ? value.trim().slice(0, max) : ""
+}
+
+export function parseCustomAiModulesJson(raw: unknown): CustomAiModule[] {
+ if (!Array.isArray(raw)) return []
+
+ const out: CustomAiModule[] = []
+ const seen = new Set()
+ for (const item of raw) {
+ if (!item || typeof item !== "object") continue
+ const row = item as Record
+ const id = cleanText(row.id, 96)
+ if (!CUSTOM_MODULE_ID.test(id) || seen.has(id)) continue
+ seen.add(id)
+
+ const title = cleanText(row.title, TITLE_MAX)
+ if (!title) continue
+
+ const answers: CustomAiModuleAnswer[] = []
+ const seenAnswers = new Set()
+ const rawAnswers = Array.isArray(row.answers) ? row.answers : []
+ for (const answerRow of rawAnswers) {
+ if (!answerRow || typeof answerRow !== "object") continue
+ const answer = answerRow as Record
+ const answerId = cleanText(answer.id, 96)
+ if (!CUSTOM_ANSWER_ID.test(answerId) || seenAnswers.has(answerId)) continue
+ seenAnswers.add(answerId)
+
+ const subtitle = cleanText(answer.subtitle, SUBTITLE_MAX)
+ const body = cleanText(answer.answer, KNOWLEDGE_TEMPLATE_ANSWER_MAX)
+ if (!subtitle || !body) continue
+ answers.push({ id: answerId, subtitle, answer: body })
+ if (answers.length >= MAX_ANSWERS_PER_MODULE) break
+ }
+
+ out.push({
+ id,
+ title,
+ subtitle: cleanText(row.subtitle, SUBTITLE_MAX),
+ enabled: row.enabled !== false,
+ answers,
+ createdAt: cleanText(row.createdAt, 40) || undefined,
+ updatedAt: cleanText(row.updatedAt, 40) || undefined,
+ })
+ if (out.length >= MAX_CUSTOM_MODULES) break
+ }
+
+ return out
+}
+
+export function customAiModulesPayload(modules: CustomAiModule[]): CustomAiModule[] {
+ const now = new Date().toISOString()
+ return parseCustomAiModulesJson(
+ modules.map((module) => ({
+ ...module,
+ updatedAt: now,
+ createdAt: module.createdAt || now,
+ })),
+ )
+}
diff --git a/src/lib/kb/structuredTemplateChunks.ts b/src/lib/kb/structuredTemplateChunks.ts
index ad99b4f..5a8a2f5 100644
--- a/src/lib/kb/structuredTemplateChunks.ts
+++ b/src/lib/kb/structuredTemplateChunks.ts
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
+import { Prisma } from "@prisma/client"
import { embedBatch } from "@/lib/kb/openaiEmbeddings"
import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta"
import {
@@ -9,10 +10,19 @@ import {
} from "@/lib/kb/knowledgeTemplate"
import { parseModulePoliciesJson } from "@/lib/kb/modulePolicies"
import { parseSharedFieldAnswersJson } from "@/lib/kb/sharedFieldAnswers"
+import { parseCustomAiModulesJson } from "@/lib/kb/customAiModules"
import { L1_MODULE_CUSTOMER_LABEL } from "@/lib/ai/orchestrator/vendorModuleGroups"
import { SHARED_FIELD_SHORT_LABEL } from "@/lib/ai/orchestrator/moduleThreeTypes"
import type { L1Module } from "@/lib/ai/orchestrator/types"
+type StoreStructuredKnowledgeRow = {
+ knowledgeBaseMode: "UNSET" | "STRUCTURED" | "DOCUMENTS"
+ knowledgeTemplate: unknown
+ modulePolicies: unknown
+ sharedFieldAnswers: unknown
+ customAiModules?: unknown
+}
+
/**
* Rebuild STRUCTURED knowledge chunks from the store's v3 template + modulePolicies + sharedFieldAnswers.
* Clears STRUCTURED rows when the store is not in STRUCTURED mode.
@@ -23,73 +33,106 @@ export async function replaceStructuredKnowledgeChunksForStore(storeId: string)
storeId,
)
- const store = await prisma.store.findFirst({
+ const store = (await prisma.store.findFirst({
where: { id: storeId },
select: {
knowledgeBaseMode: true,
knowledgeTemplate: true,
modulePolicies: true,
sharedFieldAnswers: true,
- },
- })
+ customAiModules: true,
+ } as unknown as Prisma.StoreSelect,
+ })) as StoreStructuredKnowledgeRow | null
- if (!store || store.knowledgeBaseMode !== "STRUCTURED") {
+ if (!store) {
return
}
- const stored: KnowledgeTemplateStored =
- parseKnowledgeTemplateJson(store.knowledgeTemplate) ?? {
- version: 3,
- genericAnswers: {},
- updatedAt: new Date().toISOString(),
- }
+ const customModules = parseCustomAiModulesJson(store.customAiModules)
+ const hasEnabledCustomKnowledge = customModules.some(
+ (module) => module.enabled && module.answers.length > 0,
+ )
- const modulePolicies = parseModulePoliciesJson(store.modulePolicies)
- const sharedFieldAnswers = parseSharedFieldAnswersJson(store.sharedFieldAnswers)
+ if (store.knowledgeBaseMode !== "STRUCTURED" && !hasEnabledCustomKnowledge) {
+ return
+ }
const rows: {
content: string
metadata: ReturnType
}[] = []
- for (const field of STORE_VOICE_FIELDS) {
- const text = stored.genericAnswers[field.key]
- if (text?.trim()) {
+ if (store.knowledgeBaseMode === "STRUCTURED") {
+ const stored: KnowledgeTemplateStored =
+ parseKnowledgeTemplateJson(store.knowledgeTemplate) ?? {
+ version: 3,
+ genericAnswers: {},
+ updatedAt: new Date().toISOString(),
+ }
+
+ const modulePolicies = parseModulePoliciesJson(store.modulePolicies)
+ const sharedFieldAnswers = parseSharedFieldAnswersJson(store.sharedFieldAnswers)
+
+ for (const field of STORE_VOICE_FIELDS) {
+ const text = stored.genericAnswers[field.key]
+ if (text?.trim()) {
+ rows.push({
+ content: `Q: ${field.label}\nA: ${text.trim()}`,
+ metadata: kbEmbeddingMetadata({
+ sourceKey: field.key,
+ moduleCode: "ALL",
+ fieldId: field.key,
+ }),
+ })
+ }
+ }
+
+ for (const [mod, text] of Object.entries(modulePolicies) as [L1Module, string][]) {
+ if (!text?.trim()) continue
+ const title = L1_MODULE_CUSTOMER_LABEL[mod] ?? mod
rows.push({
- content: `Q: ${field.label}\nA: ${text.trim()}`,
+ content: `Q: ${title} (store policy)\nA: ${text.trim()}`,
metadata: kbEmbeddingMetadata({
- sourceKey: field.key,
+ sourceKey: `policy_${mod}`,
+ moduleCode: mod,
+ fieldId: `policy_${mod}`,
+ }),
+ })
+ }
+
+ for (const [fieldId, text] of Object.entries(sharedFieldAnswers)) {
+ if (!text?.trim()) continue
+ const lab = SHARED_FIELD_SHORT_LABEL[fieldId] || fieldId
+ rows.push({
+ content: `Q: ${lab} (${fieldId})\nA: ${text.trim()}`,
+ metadata: kbEmbeddingMetadata({
+ sourceKey: fieldId,
moduleCode: "ALL",
- fieldId: field.key,
+ fieldId,
}),
})
}
}
- for (const [mod, text] of Object.entries(modulePolicies) as [L1Module, string][]) {
- if (!text?.trim()) continue
- const title = L1_MODULE_CUSTOMER_LABEL[mod] ?? mod
- rows.push({
- content: `Q: ${title} (store policy)\nA: ${text.trim()}`,
- metadata: kbEmbeddingMetadata({
- sourceKey: `policy_${mod}`,
- moduleCode: mod,
- fieldId: `policy_${mod}`,
- }),
- })
- }
-
- for (const [fieldId, text] of Object.entries(sharedFieldAnswers)) {
- if (!text?.trim()) continue
- const lab = SHARED_FIELD_SHORT_LABEL[fieldId] || fieldId
- rows.push({
- content: `Q: ${lab} (${fieldId})\nA: ${text.trim()}`,
- metadata: kbEmbeddingMetadata({
- sourceKey: fieldId,
- moduleCode: "ALL",
- fieldId,
- }),
- })
+ for (const customModule of customModules) {
+ if (!customModule.enabled) continue
+ for (const answer of customModule.answers) {
+ rows.push({
+ content: [
+ `Custom module: ${customModule.title}`,
+ customModule.subtitle ? `Module subtitle: ${customModule.subtitle}` : "",
+ `Q: ${answer.subtitle}`,
+ `A: ${answer.answer}`,
+ ]
+ .filter(Boolean)
+ .join("\n"),
+ metadata: kbEmbeddingMetadata({
+ sourceKey: `custom_module_${customModule.id}_${answer.id}`,
+ moduleCode: "CUSTOM",
+ fieldId: answer.id,
+ }),
+ })
+ }
}
if (!rows.length) {
diff --git a/src/lib/prisma/storeSelects.ts b/src/lib/prisma/storeSelects.ts
index b29869b..86e760d 100644
--- a/src/lib/prisma/storeSelects.ts
+++ b/src/lib/prisma/storeSelects.ts
@@ -42,6 +42,7 @@ export const storeSelectVendorSettingsGetFull = {
aiModules: true,
modulePolicies: true,
sharedFieldAnswers: true,
+ customAiModules: true,
gmailReplyTheme: true,
lowStockThreshold: true,
lowStockShopifyThreshold: true,
@@ -71,6 +72,7 @@ export const storeSelectPatchStorefrontAi = {
aiModules: true,
modulePolicies: true,
sharedFieldAnswers: true,
+ customAiModules: true,
gmailReplyTheme: true,
lowStockThreshold: true,
lowStockShopifyThreshold: true,
diff --git a/src/lib/storefront-chat/cartLinks.ts b/src/lib/storefront-chat/cartLinks.ts
index 2902c6c..4f4a7f1 100644
--- a/src/lib/storefront-chat/cartLinks.ts
+++ b/src/lib/storefront-chat/cartLinks.ts
@@ -146,6 +146,7 @@ export function selectVariantForCart(params: {
export function buildCartPermalink(
shopifyDomain: string,
lines: Array<{ variantId: string; quantity?: number }>,
+ attributes?: Record,
) {
const cleanDomain = normalizeShopDomain(shopifyDomain)
const encodedLines = lines
@@ -158,7 +159,13 @@ export function buildCartPermalink(
.filter((line): line is string => Boolean(line))
if (!cleanDomain || encodedLines.length === 0) return null
- return `https://${cleanDomain}/cart/${encodedLines.join(",")}`
+ const params = new URLSearchParams()
+ for (const [key, value] of Object.entries(attributes ?? {})) {
+ if (value === null || value === undefined || value === "") continue
+ params.set(`attributes[${key}]`, String(value).slice(0, 255))
+ }
+ const query = params.toString()
+ return `https://${cleanDomain}/cart/${encodedLines.join(",")}${query ? `?${query}` : ""}`
}
export function formatVariantChoices(variants: CartableVariant[]) {