JUNOHUB-test/src/lib/cache.ts
2026-05-12 22:35:05 +05:00

558 lines
20 KiB
TypeScript

import type { Prisma } from "@prisma/client"
import { prisma } from "./prisma"
import { fetchProductMetafieldsMap } from "@/lib/shopify/productMetafieldsGraphql"
/** Minimal Shopify API shapes for cache updates */
interface ShopifyProductRecord {
id: number
title?: string
vendor?: string
product_type?: string
status?: string
variants?: unknown[]
images?: unknown[]
}
interface ShopifyOrderRecord {
id: number
order_number?: number
name?: string
email?: string
total_price?: string
currency?: string
financial_status?: string
fulfillment_status?: string
customer?: unknown
line_items?: unknown[]
note_attributes?: unknown[]
created_at?: string
}
/** Shopify often leaves `order.email` empty but sets `customer.email`. */
function extractOrderEmail(order: ShopifyOrderRecord): string | null {
const direct = order.email?.trim()
if (direct) return direct
const c = order.customer
if (c && typeof c === "object" && c !== null && "email" in c) {
const fromCustomer = String((c as { email?: string }).email ?? "").trim()
if (fromCustomer) return fromCustomer
}
return null
}
interface ShopifyCustomerRecord {
id: number
email?: string
first_name?: string
last_name?: string
orders_count?: number
total_spent?: string
phone?: string
tags?: string
created_at?: string
/** Shopify REST customer `default_address` */
default_address?: unknown
}
function defaultAddressForCustomerCache(addr: unknown): Prisma.InputJsonValue | undefined {
if (!addr || typeof addr !== "object" || Array.isArray(addr)) return undefined
const a = addr as Record<string, unknown>
const payload = {
city: typeof a.city === "string" ? a.city : null,
country: typeof a.country === "string" ? a.country : null,
country_name: typeof a.country_name === "string" ? a.country_name : null,
province: typeof a.province === "string" ? a.province : null,
address1: typeof a.address1 === "string" ? a.address1 : null,
}
const hasAny = Object.values(payload).some((v) => v != null && String(v).trim() !== "")
if (!hasAny) return undefined
return payload as Prisma.InputJsonValue
}
// Cache TTL (Time To Live) in milliseconds
const CACHE_TTL = {
products: 60 * 60 * 1000, // 1 hour
orders: 5 * 60 * 1000, // 5 minutes
customers: 30 * 60 * 1000 // 30 minutes
}
type ResourceType = "products" | "orders" | "customers"
/**
* Check if cache is fresh for a given resource type
*/
export async function isCacheFresh(storeId: string, resourceType: ResourceType): Promise<boolean> {
const metadata = await prisma.syncMetadata.findUnique({
where: {
storeId_resourceType: {
storeId,
resourceType
}
}
})
if (!metadata) return false
const ttl = CACHE_TTL[resourceType]
const age = Date.now() - metadata.lastSyncAt.getTime()
return age < ttl && metadata.syncStatus === "success"
}
/**
* Get cached products for a store
*/
export async function getCachedProducts(storeId: string) {
return await prisma.cachedProduct.findMany({
where: { storeId },
orderBy: { updatedAt: "desc" }
})
}
/**
* Get cached orders for a store
*/
export async function getCachedOrders(storeId: string) {
return await prisma.cachedOrder.findMany({
where: { storeId },
orderBy: { shopifyCreatedAt: "desc" }
})
}
/**
* Get cached customers for a store
*/
export async function getCachedCustomers(storeId: string) {
return await prisma.cachedCustomer.findMany({
where: { storeId },
orderBy: { ordersCount: "desc" }
})
}
/**
* Update product cache
*/
export async function updateProductCache(storeId: string, products: ShopifyProductRecord[]) {
try {
const store = await prisma.store.findUnique({
where: { id: storeId },
select: { shopifyDomain: true, shopifyAccessToken: true },
})
const metafieldsByProductId = new Map<string, Prisma.InputJsonValue>()
if (store?.shopifyDomain && store?.shopifyAccessToken && products.length > 0) {
try {
const ids = products.map((p) => p.id)
const map = await fetchProductMetafieldsMap(
store.shopifyDomain,
store.shopifyAccessToken,
ids,
)
for (const [pid, rows] of map) {
metafieldsByProductId.set(pid, rows as Prisma.InputJsonValue)
}
} catch (e) {
console.error("[updateProductCache] Metafields GraphQL fetch failed:", e)
}
}
// Delete existing cached products
await prisma.cachedProduct.deleteMany({
where: { storeId }
})
// Insert new products
const cachedProducts = products.map((product) => ({
shopifyProductId: product.id.toString(),
storeId,
title: product.title ?? "",
vendor: product.vendor || null,
productType: product.product_type || null,
status: product.status || null,
variants: (product.variants || []) as import("@prisma/client").Prisma.InputJsonValue,
images: (product.images || []) as import("@prisma/client").Prisma.InputJsonValue,
metafields: (metafieldsByProductId.get(product.id.toString()) ??
[]) as Prisma.InputJsonValue,
}))
await prisma.cachedProduct.createMany({
data: cachedProducts
})
// Update sync metadata
await prisma.syncMetadata.upsert({
where: {
storeId_resourceType: {
storeId,
resourceType: "products"
}
},
create: {
storeId,
resourceType: "products",
lastSyncAt: new Date(),
syncStatus: "success",
recordCount: products.length
},
update: {
lastSyncAt: new Date(),
syncStatus: "success",
recordCount: products.length,
errorMessage: null
}
})
return true
} catch (error) {
console.error("Error updating product cache:", error)
// Update sync metadata with error
await prisma.syncMetadata.upsert({
where: {
storeId_resourceType: {
storeId,
resourceType: "products"
}
},
create: {
storeId,
resourceType: "products",
syncStatus: "failed",
errorMessage: error instanceof Error ? error.message : "Unknown error"
},
update: {
syncStatus: "failed",
errorMessage: error instanceof Error ? error.message : "Unknown error"
}
})
return false
}
}
/**
* Update order cache
*/
export async function updateOrderCache(storeId: string, orders: ShopifyOrderRecord[]) {
try {
const orderIds = orders.map((order) => order.id.toString())
const pendingRows = await prisma.$queryRaw<Array<{ shopifyOrderId: string }>>`
SELECT "shopifyOrderId"
FROM "CachedOrder"
WHERE "storeId" = ${storeId}
AND "pendingVendorSync" = true
AND "shopifyOrderId" = ANY(${orderIds}::text[])
`
const pendingIds = new Set(pendingRows.map((r) => r.shopifyOrderId))
const existingRows = await prisma.cachedOrder.findMany({
where: { storeId, shopifyOrderId: { in: orderIds } },
select: {
shopifyOrderId: true,
fulfillmentStatus: true,
holdReasonCode: true,
},
})
const existingById = new Map(
existingRows.map((r) => [
r.shopifyOrderId,
{
fulfillmentStatus: r.fulfillmentStatus,
holdReasonCode: r.holdReasonCode,
},
]),
)
// Use upsert for each order to handle duplicates gracefully
// This ensures webhooks can update orders concurrently without conflicts
const upsertPromises = orders.map((order) =>
prisma.cachedOrder.upsert({
where: {
shopifyOrderId_storeId: {
shopifyOrderId: order.id.toString(),
storeId
}
},
create: {
shopifyOrderId: order.id.toString(),
storeId,
orderNumber: order.order_number?.toString() || order.name || "",
email: extractOrderEmail(order),
totalPrice: order.total_price || "0",
currency: order.currency || null,
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),
totalPrice: order.total_price || "0",
currency: order.currency || null,
financialStatus: order.financial_status || null,
fulfillmentStatus:
pendingIds.has(order.id.toString()) ||
(existingById.get(order.id.toString())?.fulfillmentStatus === "ON_HOLD" &&
!!existingById.get(order.id.toString())?.holdReasonCode)
? 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
})
)
// Execute all upserts in parallel (Prisma handles batching internally)
await Promise.all(upsertPromises)
// Update sync metadata
await prisma.syncMetadata.upsert({
where: {
storeId_resourceType: {
storeId,
resourceType: "orders"
}
},
create: {
storeId,
resourceType: "orders",
lastSyncAt: new Date(),
syncStatus: "success",
recordCount: orders.length
},
update: {
lastSyncAt: new Date(),
syncStatus: "success",
recordCount: orders.length,
errorMessage: null
}
})
return true
} catch (error) {
console.error("Error updating order cache:", error)
await prisma.syncMetadata.upsert({
where: {
storeId_resourceType: {
storeId,
resourceType: "orders"
}
},
create: {
storeId,
resourceType: "orders",
syncStatus: "failed",
errorMessage: error instanceof Error ? error.message : "Unknown error"
},
update: {
syncStatus: "failed",
errorMessage: error instanceof Error ? error.message : "Unknown error"
}
})
return false
}
}
/**
* Update customer cache
*/
export async function updateCustomerCache(storeId: string, customers: ShopifyCustomerRecord[]) {
try {
// Delete existing cached customers
await prisma.cachedCustomer.deleteMany({
where: { storeId }
})
// Insert new customers
const cachedCustomers = customers.map((customer) => ({
shopifyCustomerId: customer.id.toString(),
storeId,
email: customer.email || null,
firstName: customer.first_name || null,
lastName: customer.last_name || null,
ordersCount: customer.orders_count || 0,
totalSpent: customer.total_spent || "0",
phone: customer.phone || null,
tags: customer.tags || null,
shopifyCreatedAt: customer.created_at ? new Date(customer.created_at) : null,
defaultAddress: defaultAddressForCustomerCache(customer.default_address),
}))
await prisma.cachedCustomer.createMany({
data: cachedCustomers
})
// Update sync metadata
await prisma.syncMetadata.upsert({
where: {
storeId_resourceType: {
storeId,
resourceType: "customers"
}
},
create: {
storeId,
resourceType: "customers",
lastSyncAt: new Date(),
syncStatus: "success",
recordCount: customers.length
},
update: {
lastSyncAt: new Date(),
syncStatus: "success",
recordCount: customers.length,
errorMessage: null
}
})
return true
} catch (error) {
console.error("Error updating customer cache:", error)
await prisma.syncMetadata.upsert({
where: {
storeId_resourceType: {
storeId,
resourceType: "customers"
}
},
create: {
storeId,
resourceType: "customers",
syncStatus: "failed",
errorMessage: error instanceof Error ? error.message : "Unknown error"
},
update: {
syncStatus: "failed",
errorMessage: error instanceof Error ? error.message : "Unknown error"
}
})
return false
}
}
/**
* Update a single product in cache (for webhook updates)
*/
export async function updateSingleProduct(storeId: string, product: ShopifyProductRecord) {
const store = await prisma.store.findUnique({
where: { id: storeId },
select: { shopifyDomain: true, shopifyAccessToken: true },
})
let metafieldsPatch: Prisma.InputJsonValue | undefined
if (store?.shopifyDomain && store?.shopifyAccessToken) {
try {
const map = await fetchProductMetafieldsMap(
store.shopifyDomain,
store.shopifyAccessToken,
[product.id],
)
const rows = map.get(product.id.toString())
if (rows !== undefined) {
metafieldsPatch = rows as Prisma.InputJsonValue
}
} catch (e) {
console.error("[updateSingleProduct] Metafields fetch failed:", e)
}
}
await prisma.cachedProduct.upsert({
where: {
shopifyProductId_storeId: {
shopifyProductId: product.id.toString(),
storeId
}
},
create: {
shopifyProductId: product.id.toString(),
storeId,
title: product.title ?? "",
vendor: product.vendor || null,
productType: product.product_type || null,
status: product.status || null,
variants: (product.variants || []) as Prisma.InputJsonValue,
images: (product.images || []) as Prisma.InputJsonValue,
metafields: (metafieldsPatch ?? []) as Prisma.InputJsonValue,
lastSyncedAt: new Date()
},
update: {
title: product.title ?? "",
vendor: product.vendor || null,
productType: product.product_type || null,
status: product.status || null,
variants: (product.variants || []) as Prisma.InputJsonValue,
images: (product.images || []) as Prisma.InputJsonValue,
...(metafieldsPatch !== undefined ? { metafields: metafieldsPatch } : {}),
lastSyncedAt: new Date()
}
})
}
/**
* Update a single order in cache (for webhook updates)
*/
export async function updateSingleOrder(storeId: string, order: ShopifyOrderRecord) {
const pendingRows = await prisma.$queryRaw<
Array<{
pendingVendorSync: boolean
fulfillmentStatus: string | null
holdReasonCode: string | null
}>
>`
SELECT "pendingVendorSync", "fulfillmentStatus", "holdReasonCode"
FROM "CachedOrder"
WHERE "storeId" = ${storeId} AND "shopifyOrderId" = ${order.id.toString()}
LIMIT 1
`
const shouldPreserve = pendingRows[0]?.pendingVendorSync === true
const hasSupplierHold =
pendingRows[0]?.fulfillmentStatus === "ON_HOLD" && !!pendingRows[0]?.holdReasonCode
const preservedStatus = pendingRows[0]?.fulfillmentStatus ?? null
await prisma.cachedOrder.upsert({
where: {
shopifyOrderId_storeId: {
shopifyOrderId: order.id.toString(),
storeId
}
},
create: {
shopifyOrderId: order.id.toString(),
storeId,
orderNumber: order.order_number?.toString() || order.name || "",
email: extractOrderEmail(order),
totalPrice: order.total_price || "0",
currency: order.currency || null,
financialStatus: order.financial_status || null,
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),
totalPrice: order.total_price || "0",
currency: order.currency || null,
financialStatus: order.financial_status || null,
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
})
}