fix: Separate JUNO CHAT, Stock fixed on both ends
This commit is contained in:
parent
e49a0047aa
commit
bbeb43adb1
@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ChatMessage" ADD COLUMN "storeId" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ChatMessage_senderId_receiverId_storeId_idx" ON "ChatMessage"("senderId", "receiverId", "storeId");
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CachedCustomer" ADD COLUMN "defaultAddress" JSONB;
|
||||
@ -137,6 +137,7 @@ model Store {
|
||||
flaggedEmails FlaggedEmail[]
|
||||
gmailInboundAutoReplies GmailInboundAutoReply[]
|
||||
templates Template[]
|
||||
chatMessages ChatMessage[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@ -207,6 +208,8 @@ model CachedCustomer {
|
||||
totalSpent String?
|
||||
phone String?
|
||||
tags String?
|
||||
/** Shopify REST `default_address` snapshot for vendor UI (city, country, etc.). */
|
||||
defaultAddress Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastSyncedAt DateTime @default(now())
|
||||
@ -591,17 +594,21 @@ model ChatMessage {
|
||||
id String @id @default(cuid())
|
||||
senderId String
|
||||
receiverId String
|
||||
/** Vendor store this thread belongs to (B2B JUNO CHAT). Null = legacy messages before per-store threading. */
|
||||
storeId String?
|
||||
kind ChatMessageKind @default(TEXT)
|
||||
content String @map("encryptedContent") @db.Text
|
||||
attachment Json?
|
||||
|
||||
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
||||
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id])
|
||||
store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([senderId, receiverId])
|
||||
@@index([receiverId, senderId])
|
||||
@@index([senderId, receiverId, storeId])
|
||||
}
|
||||
|
||||
enum StorefrontChatSenderType {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@ -2,6 +2,18 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
/** One sidebar row per Connection (per vendor store ↔ supplier). */
|
||||
export type ChatContactRow = {
|
||||
id: string
|
||||
connectionId: string
|
||||
storeId: string
|
||||
storeName: string | null
|
||||
name: string | null
|
||||
email: string | null
|
||||
image: string | null
|
||||
role: "VENDOR" | "SUPPLIER"
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
@ -13,30 +25,41 @@ export async function GET() {
|
||||
where: { id: session.user.id },
|
||||
include: {
|
||||
supplierProfile: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return new NextResponse("User not found", { status: 404 })
|
||||
}
|
||||
|
||||
interface Contact {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string | null
|
||||
image: string | null
|
||||
role: string
|
||||
}
|
||||
let contacts: Contact[] = []
|
||||
let contacts: ChatContactRow[] = []
|
||||
|
||||
if (user.role === "VENDOR") {
|
||||
// Fetch connected suppliers
|
||||
// Only show chats for the globally selected store (matches dashboard store switcher).
|
||||
let scopedStoreId = user.activeStoreId
|
||||
if (!scopedStoreId) {
|
||||
const active = await prisma.store.findFirst({
|
||||
where: { userId: user.id, isActive: true },
|
||||
select: { id: true },
|
||||
})
|
||||
scopedStoreId = active?.id ?? null
|
||||
}
|
||||
|
||||
const connections = await prisma.connection.findMany({
|
||||
where: {
|
||||
store: { userId: user.id },
|
||||
status: "CONNECTED"
|
||||
store: {
|
||||
userId: user.id,
|
||||
...(scopedStoreId ? { id: scopedStoreId } : {}),
|
||||
},
|
||||
status: "CONNECTED",
|
||||
},
|
||||
include: {
|
||||
store: {
|
||||
select: {
|
||||
id: true,
|
||||
businessName: true,
|
||||
},
|
||||
},
|
||||
supplier: {
|
||||
include: {
|
||||
user: {
|
||||
@ -45,26 +68,29 @@ export async function GET() {
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
type VendorSideConnection = (typeof connections)[number]
|
||||
contacts = connections.map((c: VendorSideConnection) => ({
|
||||
type C = (typeof connections)[number]
|
||||
contacts = connections.map((c: C) => ({
|
||||
id: c.supplier.user.id,
|
||||
connectionId: c.id,
|
||||
storeId: c.store.id,
|
||||
storeName: c.store.businessName,
|
||||
name: c.supplier.companyName || c.supplier.user.name,
|
||||
email: c.supplier.user.email,
|
||||
image: c.supplier.user.image,
|
||||
role: "SUPPLIER"
|
||||
role: "SUPPLIER" as const,
|
||||
}))
|
||||
contacts.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "") || (a.storeName ?? "").localeCompare(b.storeName ?? ""))
|
||||
} else if (user.role === "SUPPLIER" && user.supplierProfile) {
|
||||
// Fetch connected vendors
|
||||
const connections = await prisma.connection.findMany({
|
||||
where: {
|
||||
supplierId: user.supplierProfile.id,
|
||||
status: "CONNECTED"
|
||||
status: "CONNECTED",
|
||||
},
|
||||
include: {
|
||||
store: {
|
||||
@ -75,20 +101,24 @@ export async function GET() {
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
type SupplierSideConnection = (typeof connections)[number]
|
||||
contacts = connections.map((c: SupplierSideConnection) => ({
|
||||
type C = (typeof connections)[number]
|
||||
contacts = connections.map((c: C) => ({
|
||||
id: c.store.user.id,
|
||||
name: c.store.businessName || c.store.user.name,
|
||||
connectionId: c.id,
|
||||
storeId: c.store.id,
|
||||
storeName: c.store.businessName,
|
||||
name: c.store.user.name || c.store.businessName,
|
||||
email: c.store.user.email,
|
||||
image: c.store.user.image,
|
||||
role: "VENDOR"
|
||||
role: "VENDOR" as const,
|
||||
}))
|
||||
contacts.sort((a, b) => (a.storeName ?? "").localeCompare(b.storeName ?? "") || (a.name ?? "").localeCompare(b.name ?? ""))
|
||||
}
|
||||
|
||||
return NextResponse.json({ contacts })
|
||||
|
||||
@ -2,6 +2,11 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { sendPushNotification } from "@/lib/push"
|
||||
import {
|
||||
countConnectionsForPair,
|
||||
hasChatConnectionForStore,
|
||||
resolveB2bChatPair,
|
||||
} from "@/lib/chat/chatThreadAccess"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@ -10,14 +15,34 @@ export async function POST(req: Request) {
|
||||
return new NextResponse("Unauthorized", { status: 401 })
|
||||
}
|
||||
|
||||
const { receiverId, content, kind, attachment } = await req.json()
|
||||
const body = await req.json()
|
||||
const { receiverId, content, kind, attachment, storeId: storeIdRaw } = body
|
||||
|
||||
if (!receiverId || typeof receiverId !== "string") {
|
||||
return new NextResponse("Missing fields", { status: 400 })
|
||||
}
|
||||
|
||||
if (!storeIdRaw || typeof storeIdRaw !== "string") {
|
||||
return new NextResponse("Missing storeId", { status: 400 })
|
||||
}
|
||||
|
||||
const pair = await resolveB2bChatPair(session.user.id, receiverId)
|
||||
if (!pair) {
|
||||
return new NextResponse("Invalid chat participant", { status: 403 })
|
||||
}
|
||||
|
||||
const allowed = await hasChatConnectionForStore(
|
||||
pair.vendorUserId,
|
||||
pair.supplierUserId,
|
||||
storeIdRaw
|
||||
)
|
||||
if (!allowed) {
|
||||
return new NextResponse("Store not connected for this chat", { status: 403 })
|
||||
}
|
||||
|
||||
const CHAT_KINDS = ["TEXT", "PRODUCT", "ORDER"] as const
|
||||
const normalizedKind = typeof kind === "string" && (CHAT_KINDS as readonly string[]).includes(kind) ? kind : "TEXT"
|
||||
const normalizedKind =
|
||||
typeof kind === "string" && (CHAT_KINDS as readonly string[]).includes(kind) ? kind : "TEXT"
|
||||
|
||||
if (normalizedKind === "TEXT") {
|
||||
if (content != null && typeof content !== "string") {
|
||||
@ -29,22 +54,37 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionUser = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true },
|
||||
})
|
||||
|
||||
if (
|
||||
sessionUser?.role === "VENDOR" &&
|
||||
(normalizedKind === "PRODUCT" || normalizedKind === "ORDER")
|
||||
) {
|
||||
const att = attachment as { storeId?: string }
|
||||
if (!att.storeId || att.storeId !== storeIdRaw) {
|
||||
return new NextResponse("Attachment store does not match chat store", { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const message = await prisma.chatMessage.create({
|
||||
data: {
|
||||
senderId: session.user.id,
|
||||
receiverId,
|
||||
storeId: storeIdRaw,
|
||||
kind: normalizedKind as any,
|
||||
content: (typeof content === "string" ? content : "") as any,
|
||||
attachment: (normalizedKind === "TEXT" ? null : (attachment as any)) as any,
|
||||
} as any
|
||||
} as any,
|
||||
})
|
||||
|
||||
// Send push notification to receiver
|
||||
try {
|
||||
await sendPushNotification(receiverId, {
|
||||
title: "New Message",
|
||||
body: `${session.user.name || "A user"} sent you a message.`,
|
||||
url: "/dashboard/chat" // Correct to your chat dashboard path
|
||||
url: "/dashboard/chat",
|
||||
})
|
||||
} catch (pushError) {
|
||||
console.error("Failed to send push notification", pushError)
|
||||
@ -66,21 +106,57 @@ export async function GET(req: Request) {
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const contactId = searchParams.get("contactId")
|
||||
const storeId = searchParams.get("storeId")
|
||||
|
||||
if (!contactId) {
|
||||
return new NextResponse("Missing contactId", { status: 400 })
|
||||
}
|
||||
|
||||
const messages = await prisma.chatMessage.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ senderId: session.user.id, receiverId: contactId },
|
||||
{ senderId: contactId, receiverId: session.user.id }
|
||||
]
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc"
|
||||
if (!storeId) {
|
||||
return new NextResponse("Missing storeId", { status: 400 })
|
||||
}
|
||||
|
||||
const pair = await resolveB2bChatPair(session.user.id, contactId)
|
||||
if (!pair) {
|
||||
return new NextResponse("Invalid chat participant", { status: 403 })
|
||||
}
|
||||
|
||||
const allowed = await hasChatConnectionForStore(
|
||||
pair.vendorUserId,
|
||||
pair.supplierUserId,
|
||||
storeId
|
||||
)
|
||||
if (!allowed) {
|
||||
return new NextResponse("Store not connected for this chat", { status: 403 })
|
||||
}
|
||||
|
||||
const pairConnectionCount = await countConnectionsForPair(
|
||||
pair.vendorUserId,
|
||||
pair.supplierUserId
|
||||
)
|
||||
|
||||
const userPairOr = [
|
||||
{ senderId: session.user.id, receiverId: contactId },
|
||||
{ senderId: contactId, receiverId: session.user.id },
|
||||
]
|
||||
|
||||
const messages =
|
||||
pairConnectionCount <= 1
|
||||
? await prisma.chatMessage.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ OR: userPairOr },
|
||||
{
|
||||
OR: [{ storeId }, { storeId: null }],
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
})
|
||||
: await prisma.chatMessage.findMany({
|
||||
where: {
|
||||
AND: [{ OR: userPairOr }, { storeId }],
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
})
|
||||
|
||||
return NextResponse.json({ messages })
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { sumVariantInventoryQuantity } from "@/lib/products/shopifyInventorySum"
|
||||
|
||||
/**
|
||||
* PUT /api/products/sync/[syncId]
|
||||
@ -83,6 +84,17 @@ export async function PUT(
|
||||
|
||||
if (action === "accept") {
|
||||
updateData.acceptedAt = new Date()
|
||||
const base = {
|
||||
...(productSync.shopifyProductData &&
|
||||
typeof productSync.shopifyProductData === "object"
|
||||
? productSync.shopifyProductData
|
||||
: {}),
|
||||
} as Record<string, unknown>
|
||||
const initialSq = sumVariantInventoryQuantity(base.variants)
|
||||
if (initialSq !== null) {
|
||||
base.supplier_quantity = initialSq
|
||||
}
|
||||
updateData.shopifyProductData = base
|
||||
} else {
|
||||
updateData.rejectedAt = new Date()
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||
|
||||
/**
|
||||
* GET /api/products/sync
|
||||
@ -46,7 +47,20 @@ export async function GET() {
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ syncs })
|
||||
const enriched = syncs.map((sync: any) => {
|
||||
const productData = sync.shopifyProductData || {}
|
||||
let supplierAvailable: number | null = null
|
||||
if (sync.status === "ACCEPTED") {
|
||||
const sq = supplierQuantityFromSyncedProductData(productData)
|
||||
if (sq !== undefined) supplierAvailable = sq
|
||||
}
|
||||
return {
|
||||
...sync,
|
||||
supplierAvailableQuantity: supplierAvailable,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ syncs: enriched })
|
||||
} catch (error) {
|
||||
console.error("Error fetching product syncs:", error)
|
||||
return NextResponse.json(
|
||||
@ -154,7 +168,41 @@ export async function POST(req: Request) {
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
// If rejected, allow creating a new request
|
||||
// REJECTED: reuse row (unique on store+supplier+shopifyProductId) instead of create
|
||||
if (existingSync.status === "REJECTED") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const productSync = await (prisma as any).productSync.update({
|
||||
where: { id: existingSync.id },
|
||||
data: {
|
||||
shopifyProductTitle,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
shopifyProductData: shopifyProductData
|
||||
? (shopifyProductData as any)
|
||||
: undefined,
|
||||
status: "PENDING",
|
||||
requestedBy: session.user.id,
|
||||
rejectedAt: null,
|
||||
acceptedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const { notifyProductSyncRequested } = await import("@/lib/notifications")
|
||||
await notifyProductSyncRequested(
|
||||
supplier.user.id,
|
||||
vendorStore.businessName,
|
||||
shopifyProductTitle,
|
||||
productSync.id
|
||||
)
|
||||
} catch (notifError) {
|
||||
console.error("Error creating notification:", notifError)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Product sync request sent successfully",
|
||||
sync: productSync,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create sync request
|
||||
|
||||
@ -3,6 +3,37 @@ import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { isCacheFresh, getCachedCustomers, updateCustomerCache } from "@/lib/cache"
|
||||
|
||||
function defaultAddressFromCacheJson(raw: unknown): {
|
||||
city: string
|
||||
country_name: string
|
||||
country: string
|
||||
province: string
|
||||
address1: string
|
||||
} | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null
|
||||
const d = raw as Record<string, unknown>
|
||||
const out = {
|
||||
city: typeof d.city === "string" ? d.city : "",
|
||||
country_name:
|
||||
typeof d.country_name === "string"
|
||||
? d.country_name
|
||||
: typeof d.country === "string"
|
||||
? d.country
|
||||
: "",
|
||||
country: typeof d.country === "string" ? d.country : "",
|
||||
province: typeof d.province === "string" ? d.province : "",
|
||||
address1: typeof d.address1 === "string" ? d.address1 : "",
|
||||
}
|
||||
const has =
|
||||
out.city.trim() ||
|
||||
out.country_name.trim() ||
|
||||
out.country.trim() ||
|
||||
out.province.trim() ||
|
||||
out.address1.trim()
|
||||
if (!has) return null
|
||||
return out
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
@ -41,7 +72,8 @@ export async function GET() {
|
||||
total_spent: customer.totalSpent,
|
||||
phone: customer.phone,
|
||||
tags: customer.tags,
|
||||
created_at: customer.shopifyCreatedAt?.toISOString() || customer.createdAt.toISOString()
|
||||
created_at: customer.shopifyCreatedAt?.toISOString() || customer.createdAt.toISOString(),
|
||||
default_address: defaultAddressFromCacheJson(customer.defaultAddress),
|
||||
}))
|
||||
return NextResponse.json({
|
||||
customers: transformedCustomers,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||
|
||||
/**
|
||||
* GET /api/supplier/products
|
||||
@ -56,10 +57,7 @@ export async function GET() {
|
||||
// Transform synced products to match product format
|
||||
const syncedProductsFormatted = syncedProducts.map((sync: any) => {
|
||||
const productData = sync.shopifyProductData || {}
|
||||
const supplierQuantity =
|
||||
typeof productData.supplier_quantity === "number"
|
||||
? productData.supplier_quantity
|
||||
: undefined
|
||||
const supplierQuantity = supplierQuantityFromSyncedProductData(productData)
|
||||
|
||||
return {
|
||||
id: `synced-${sync.id}`, // Unique ID for synced products
|
||||
@ -77,7 +75,7 @@ export async function GET() {
|
||||
businessName: sync.store.businessName
|
||||
},
|
||||
isSynced: true, // Flag to indicate this is a synced product from vendor
|
||||
availableQuantity: supplierQuantity,
|
||||
availableQuantity: supplierQuantity ?? null,
|
||||
// Include full Shopify product data for reference
|
||||
shopifyData: productData
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||
|
||||
/**
|
||||
* GET /api/vendors/synced-products/[id]
|
||||
@ -54,10 +55,8 @@ export async function GET(
|
||||
|
||||
if (sync) {
|
||||
const productData = sync.shopifyProductData || {}
|
||||
const supplierQuantity =
|
||||
typeof productData.supplier_quantity === "number"
|
||||
? productData.supplier_quantity
|
||||
: undefined
|
||||
const supplierQuantity = supplierQuantityFromSyncedProductData(productData)
|
||||
|
||||
const product = {
|
||||
id: sync.id,
|
||||
syncId: sync.id,
|
||||
|
||||
54
src/app/api/vendors/synced-products/route.ts
vendored
54
src/app/api/vendors/synced-products/route.ts
vendored
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||
|
||||
/**
|
||||
* GET /api/vendors/synced-products
|
||||
@ -50,13 +51,12 @@ export async function GET() {
|
||||
}
|
||||
})
|
||||
|
||||
// Transform syncs into product-like objects for display
|
||||
const syncedProducts = acceptedSyncs.map((sync: any) => {
|
||||
// Get supplier-provided products for this store (offers list)
|
||||
if (!("supplierProductOffer" in prisma)) {
|
||||
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
|
||||
const syncedOnly = acceptedSyncs.map((sync: any) => {
|
||||
const productData = sync.shopifyProductData || {}
|
||||
const supplierQuantity =
|
||||
typeof productData.supplier_quantity === "number"
|
||||
? productData.supplier_quantity
|
||||
: undefined
|
||||
const supplierQuantity = supplierQuantityFromSyncedProductData(productData)
|
||||
return {
|
||||
id: sync.id,
|
||||
syncId: sync.id,
|
||||
@ -71,19 +71,13 @@ export async function GET() {
|
||||
companyName: sync.supplier.companyName
|
||||
},
|
||||
syncedAt: sync.acceptedAt || sync.createdAt,
|
||||
isSynced: true, // Flag to indicate this is a synced product
|
||||
isSynced: true,
|
||||
source: "synced",
|
||||
offerQuantity: supplierQuantity,
|
||||
// Include full Shopify product data for reference
|
||||
shopifyData: productData
|
||||
}
|
||||
})
|
||||
|
||||
// Get supplier-provided products for this store
|
||||
if (!("supplierProductOffer" in prisma)) {
|
||||
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
|
||||
// Return only synced products if SupplierProductOffer model is missing
|
||||
return NextResponse.json({ products: syncedProducts })
|
||||
return NextResponse.json({ products: syncedOnly })
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supplierOffers = await (prisma as any).supplierProductOffer.findMany({
|
||||
@ -106,6 +100,11 @@ export async function GET() {
|
||||
id: true,
|
||||
companyName: true
|
||||
}
|
||||
},
|
||||
connection: {
|
||||
select: {
|
||||
status: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
@ -113,6 +112,33 @@ export async function GET() {
|
||||
}
|
||||
})
|
||||
|
||||
// Transform syncs into product-like objects for display
|
||||
const syncedProducts = acceptedSyncs.map((sync: any) => {
|
||||
const productData = sync.shopifyProductData || {}
|
||||
const supplierQuantity = supplierQuantityFromSyncedProductData(productData)
|
||||
|
||||
return {
|
||||
id: sync.id,
|
||||
syncId: sync.id,
|
||||
shopifyProductId: sync.shopifyProductId,
|
||||
title: sync.shopifyProductTitle,
|
||||
description: productData.body_html || productData.description || null,
|
||||
price: productData.variants?.[0]?.price || "0",
|
||||
sku: productData.variants?.[0]?.sku || null,
|
||||
imageUrl: productData.image?.src || productData.images?.[0]?.src || null,
|
||||
supplier: {
|
||||
id: sync.supplier.id,
|
||||
companyName: sync.supplier.companyName
|
||||
},
|
||||
syncedAt: sync.acceptedAt || sync.createdAt,
|
||||
isSynced: true, // Flag to indicate this is a synced product
|
||||
source: "synced",
|
||||
offerQuantity: supplierQuantity,
|
||||
// Include full Shopify product data for reference
|
||||
shopifyData: productData
|
||||
}
|
||||
})
|
||||
|
||||
const supplierProducts = supplierOffers.map((offer: any) => ({
|
||||
id: offer.id,
|
||||
syncId: null,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { updateSingleProduct, updateSingleOrder } from "@/lib/cache"
|
||||
import { autoLinkOfferToShopifyProduct } from "@/lib/products/autoLinkOfferToShopifyProduct"
|
||||
import crypto from "crypto"
|
||||
import { logger } from "@/lib/logger"
|
||||
|
||||
@ -93,6 +94,18 @@ export async function POST(req: NextRequest) {
|
||||
case "products/create":
|
||||
logger.info(`[Webhook] Processing product ${topic}: ${body.id}`)
|
||||
await updateSingleProduct(store.id, body)
|
||||
try {
|
||||
const linkResult = await autoLinkOfferToShopifyProduct(store.id, body)
|
||||
if (linkResult.linked) {
|
||||
logger.info(`[Webhook] Auto-linked product to supplier offer`, {
|
||||
storeId: store.id,
|
||||
productSyncId: linkResult.productSyncId,
|
||||
supplierId: linkResult.supplierId,
|
||||
})
|
||||
}
|
||||
} catch (autoLinkErr) {
|
||||
logger.error("[Webhook] autoLinkOfferToShopifyProduct error (ignored)", autoLinkErr)
|
||||
}
|
||||
// Update sync metadata to invalidate cache
|
||||
await prisma.syncMetadata.upsert({
|
||||
where: {
|
||||
|
||||
@ -23,11 +23,28 @@ interface ShopifyCustomer {
|
||||
last_order_id: number | null
|
||||
created_at: string
|
||||
default_address: {
|
||||
city: string
|
||||
country_name: string
|
||||
city?: string
|
||||
country_name?: string
|
||||
country?: string
|
||||
province?: string
|
||||
address1?: string
|
||||
} | null
|
||||
}
|
||||
|
||||
function formatCustomerLocation(default_address: ShopifyCustomer["default_address"]): string {
|
||||
if (!default_address) return "Not provided"
|
||||
const city = (default_address.city || "").trim()
|
||||
const country = (default_address.country_name || default_address.country || "").trim()
|
||||
if (city && country) return `${city}, ${country}`
|
||||
if (city) return city
|
||||
if (country) return country
|
||||
const prov = (default_address.province || "").trim()
|
||||
if (prov) return prov
|
||||
const line = (default_address.address1 || "").trim()
|
||||
if (line) return line
|
||||
return "Not provided"
|
||||
}
|
||||
|
||||
export default function CustomersPage() {
|
||||
const [customers, setCustomers] = useState<ShopifyCustomer[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -140,7 +157,7 @@ export default function CustomersPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400 font-medium md:hidden mt-1">
|
||||
<MapPin className="w-3 h-3 text-slate-500 shrink-0" />
|
||||
<span className="truncate">{customer.default_address ? `${customer.default_address.city}, ${customer.default_address.country_name}` : "Not provided"}</span>
|
||||
<span className="truncate">{formatCustomerLocation(customer.default_address)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -156,7 +173,7 @@ export default function CustomersPage() {
|
||||
<td className="px-3 md:px-6 py-3 md:py-4 hidden md:table-cell">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400 font-medium">
|
||||
<MapPin className="w-3 h-3 text-slate-500 shrink-0" />
|
||||
<span className="truncate">{customer.default_address ? `${customer.default_address.city}, ${customer.default_address.country_name}` : "Not provided"}</span>
|
||||
<span className="truncate">{formatCustomerLocation(customer.default_address)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 md:px-6 py-3 md:py-4 hidden lg:table-cell">
|
||||
|
||||
@ -56,6 +56,8 @@ interface ProductSync {
|
||||
id: string
|
||||
companyName: string
|
||||
}
|
||||
/** Set by GET /api/products/sync when status is ACCEPTED (from JSON or offer match). */
|
||||
supplierAvailableQuantity?: number | null
|
||||
}
|
||||
|
||||
interface Supplier {
|
||||
@ -68,6 +70,27 @@ interface Supplier {
|
||||
connectionStatus: "NONE" | "PENDING" | "CONNECTED" | "REJECTED"
|
||||
}
|
||||
|
||||
/** Sum variant inventory_quantity from Shopify REST/cache payload. */
|
||||
function totalShopifyInventory(variants: unknown): string {
|
||||
if (!Array.isArray(variants) || variants.length === 0) return "—"
|
||||
let total = 0
|
||||
let hasQty = false
|
||||
for (const v of variants) {
|
||||
if (v && typeof v === "object" && v !== null && "inventory_quantity" in v) {
|
||||
const raw = (v as { inventory_quantity?: unknown }).inventory_quantity
|
||||
if (raw !== null && raw !== undefined && raw !== "") {
|
||||
const n = Number(raw)
|
||||
if (!Number.isNaN(n)) {
|
||||
total += n
|
||||
hasQty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasQty) return "—"
|
||||
return String(total)
|
||||
}
|
||||
|
||||
export default function InventoryPage() {
|
||||
const [products, setProducts] = useState<ShopifyProduct[]>([])
|
||||
const [productSyncs, setProductSyncs] = useState<Map<string, ProductSync>>(new Map())
|
||||
@ -471,7 +494,12 @@ export default function InventoryPage() {
|
||||
<tr className="bg-white/5 border-b border-white/10">
|
||||
<th className="px-4 md:px-8 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest min-w-[200px] md:min-w-[300px]">Product</th>
|
||||
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">Status</th>
|
||||
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">Inventory</th>
|
||||
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap text-left">
|
||||
Shopify stock
|
||||
</th>
|
||||
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap text-left">
|
||||
Supplier stock
|
||||
</th>
|
||||
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden md:table-cell text-left">Supplier</th>
|
||||
<th className="px-3 md:px-6 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap hidden lg:table-cell text-left">Sync</th>
|
||||
<th className="px-4 md:px-8 py-3 md:py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest text-right">Actions</th>
|
||||
@ -504,11 +532,32 @@ export default function InventoryPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 md:px-6 py-3 md:py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-sm md:text-base text-white">{product.variants.length} Variants</span>
|
||||
<span className="text-[10px] text-slate-500 font-medium hidden sm:inline">Total Sync Stock</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-bold text-sm md:text-base text-white tabular-nums">
|
||||
{totalShopifyInventory(product.variants)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{product.variants.length} variant
|
||||
{product.variants.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 md:px-6 py-3 md:py-4">
|
||||
{(() => {
|
||||
const sync = getProductSyncStatus(product.id)
|
||||
if (!sync || sync.status !== "ACCEPTED") {
|
||||
return (
|
||||
<span className="text-sm text-slate-500">—</span>
|
||||
)
|
||||
}
|
||||
const q = sync.supplierAvailableQuantity
|
||||
return (
|
||||
<span className="font-bold text-sm md:text-base text-white tabular-nums">
|
||||
{q != null ? String(q) : "—"}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-3 md:px-6 py-3 md:py-4 hidden md:table-cell text-left">
|
||||
{(() => {
|
||||
const sync = isProductSynced(product.id)
|
||||
|
||||
@ -23,8 +23,11 @@ import { ChatAttachmentModal } from "@/components/chat/ChatAttachmentModal"
|
||||
|
||||
interface Contact {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
connectionId: string
|
||||
storeId: string
|
||||
storeName: string | null
|
||||
name: string | null
|
||||
email: string | null
|
||||
image: string | null
|
||||
role: string
|
||||
}
|
||||
@ -33,6 +36,7 @@ interface Message {
|
||||
id: string
|
||||
senderId: string
|
||||
receiverId: string
|
||||
storeId?: string | null
|
||||
content: string
|
||||
kind?: "TEXT" | "PRODUCT" | "ORDER" | string | null
|
||||
attachment?: any
|
||||
@ -138,13 +142,16 @@ export function ChatView() {
|
||||
setLoadingMessages(true)
|
||||
setMessagesError(null)
|
||||
}
|
||||
const res = await fetch(`/api/chat/messages?contactId=${contact.id}`)
|
||||
const res = await fetch(
|
||||
`/api/chat/messages?contactId=${encodeURIComponent(contact.id)}&storeId=${encodeURIComponent(contact.storeId)}`
|
||||
)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const typedMessages = data.messages.map((m: any) => ({
|
||||
id: m.id,
|
||||
senderId: m.senderId,
|
||||
receiverId: m.receiverId,
|
||||
storeId: m.storeId ?? null,
|
||||
content: m.content ?? "",
|
||||
kind: m.kind ?? "TEXT",
|
||||
attachment: m.attachment ?? null,
|
||||
@ -182,7 +189,7 @@ export function ChatView() {
|
||||
fetchMessages(selectedContact, true)
|
||||
}, POLL_INTERVAL_MS)
|
||||
return () => clearInterval(interval)
|
||||
}, [selectedContact?.id])
|
||||
}, [selectedContact?.connectionId])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
@ -201,6 +208,8 @@ export function ChatView() {
|
||||
}, [attachOpen])
|
||||
|
||||
const openAttach = async () => {
|
||||
if (!selectedContact) return
|
||||
const threadStoreId = selectedContact.storeId
|
||||
setAttachOpen(true)
|
||||
setAttachLoading(true)
|
||||
setAttachOrders([])
|
||||
@ -226,11 +235,17 @@ export function ChatView() {
|
||||
])
|
||||
if (ordersRes.ok) {
|
||||
const ordersData = await ordersRes.json()
|
||||
setAttachOrders(ordersData.orders || [])
|
||||
const allOrders = ordersData.orders || []
|
||||
setAttachOrders(
|
||||
allOrders.filter((o: AttachOrder) => o.storeId === threadStoreId)
|
||||
)
|
||||
}
|
||||
if (productsRes.ok) {
|
||||
const productsData = await productsRes.json()
|
||||
setAttachProducts(productsData.products || [])
|
||||
const allProducts = productsData.products || []
|
||||
setAttachProducts(
|
||||
allProducts.filter((p: AttachProduct) => p.storeId === threadStoreId)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@ -249,6 +264,7 @@ export function ChatView() {
|
||||
total_price: order.total_price,
|
||||
currency: order.currency ?? null,
|
||||
storeName: order.storeName ?? null,
|
||||
storeId: order.storeId,
|
||||
shopifyOrderId: order.id
|
||||
})
|
||||
const summary = `Order ${order.orderNumber ?? order.name}${order.customerName ? ` - ${order.customerName}` : ""}${order.storeName ? ` · ${order.storeName}` : ""}`
|
||||
@ -263,6 +279,7 @@ export function ChatView() {
|
||||
description: product.description ?? null,
|
||||
imageUrl: product.imageUrl ?? null,
|
||||
storeName: product.storeName ?? null,
|
||||
storeId: product.storeId,
|
||||
productId: product.id
|
||||
})
|
||||
const summary = `Product: ${product.title}${product.storeName ? ` · ${product.storeName}` : ""}`
|
||||
@ -282,6 +299,7 @@ export function ChatView() {
|
||||
setSendError(null)
|
||||
const payload: any = {
|
||||
receiverId: selectedContact.id,
|
||||
storeId: selectedContact.storeId,
|
||||
content: newMessage
|
||||
}
|
||||
if (hasAttachment) {
|
||||
@ -300,6 +318,7 @@ export function ChatView() {
|
||||
id: msg.id,
|
||||
senderId: msg.senderId,
|
||||
receiverId: msg.receiverId,
|
||||
storeId: msg.storeId ?? selectedContact.storeId,
|
||||
content: newMessage,
|
||||
kind: msg.kind ?? "TEXT",
|
||||
attachment: msg.attachment ?? null,
|
||||
@ -401,10 +420,13 @@ export function ChatView() {
|
||||
return null
|
||||
}
|
||||
|
||||
const filteredContacts = contacts.filter(c =>
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
const q = searchQuery.toLowerCase()
|
||||
const filteredContacts = contacts.filter(c => {
|
||||
const name = (c.name ?? "").toLowerCase()
|
||||
const email = (c.email ?? "").toLowerCase()
|
||||
const storeName = (c.storeName ?? "").toLowerCase()
|
||||
return name.includes(q) || email.includes(q) || storeName.includes(q)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
@ -469,11 +491,11 @@ export function ChatView() {
|
||||
<div className="px-2 space-y-1">
|
||||
{filteredContacts.map(contact => (
|
||||
<button
|
||||
key={contact.id}
|
||||
key={contact.connectionId}
|
||||
onClick={() => setSelectedContact(contact)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-4 rounded-2xl transition-all group relative",
|
||||
selectedContact?.id === contact.id
|
||||
selectedContact?.connectionId === contact.connectionId
|
||||
? isLight ? "bg-indigo-600 shadow-lg text-white" : "bg-indigo-600 shadow-lg shadow-indigo-600/20"
|
||||
: isLight ? "hover:bg-slate-100" : "hover:bg-white/5"
|
||||
)}
|
||||
@ -481,24 +503,46 @@ export function ChatView() {
|
||||
<div className="relative">
|
||||
<div className={cn(
|
||||
"w-12 h-12 rounded-xl flex items-center justify-center font-black transition-all",
|
||||
selectedContact?.id === contact.id
|
||||
selectedContact?.connectionId === contact.connectionId
|
||||
? "bg-white text-indigo-600"
|
||||
: isLight ? "bg-slate-100 text-slate-600 group-hover:bg-indigo-100 group-hover:text-indigo-600" : "bg-white/10 text-slate-300 group-hover:bg-indigo-500/20 group-hover:text-indigo-400"
|
||||
)}>
|
||||
{contact.name?.[0].toUpperCase() || "?"}
|
||||
{(contact.name?.[0] ?? contact.email?.[0] ?? "?").toUpperCase()}
|
||||
</div>
|
||||
<div className={cn("absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 bg-green-500", isLight ? "border-white" : "border-slate-900")} />
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<h4 className={cn(
|
||||
"text-sm font-black truncate tracking-tight",
|
||||
selectedContact?.id === contact.id ? "text-white" : isLight ? "text-slate-900" : "text-slate-200"
|
||||
selectedContact?.connectionId === contact.connectionId ? "text-white" : isLight ? "text-slate-900" : "text-slate-200"
|
||||
)}>
|
||||
{contact.name}
|
||||
{contact.name ?? contact.email ?? "Unknown"}
|
||||
</h4>
|
||||
{contact.email ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] font-medium truncate mt-0.5",
|
||||
selectedContact?.connectionId === contact.connectionId ? "text-indigo-100/90" : isLight ? "text-slate-600" : "text-slate-400"
|
||||
)}
|
||||
title={contact.email}
|
||||
>
|
||||
{contact.email}
|
||||
</p>
|
||||
) : null}
|
||||
{contact.storeName ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[10px] font-semibold truncate mt-0.5 normal-case tracking-normal",
|
||||
selectedContact?.connectionId === contact.connectionId ? "text-indigo-100/85" : isLight ? "text-slate-600" : "text-slate-400"
|
||||
)}
|
||||
title={contact.storeName}
|
||||
>
|
||||
{contact.storeName}
|
||||
</p>
|
||||
) : null}
|
||||
<p className={cn(
|
||||
"text-[10px] uppercase font-black tracking-widest mt-0.5",
|
||||
selectedContact?.id === contact.id ? "text-indigo-100/70" : isLight ? "text-slate-500" : "text-slate-500"
|
||||
selectedContact?.connectionId === contact.connectionId ? "text-indigo-100/70" : isLight ? "text-slate-500" : "text-slate-500"
|
||||
)}>
|
||||
{contact.role}
|
||||
</p>
|
||||
@ -552,14 +596,24 @@ export function ChatView() {
|
||||
"w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center font-black",
|
||||
isLight ? "bg-indigo-100 border border-indigo-200 text-indigo-600" : "bg-indigo-500/20 border border-indigo-500/30 text-indigo-400"
|
||||
)}>
|
||||
{selectedContact.name[0].toUpperCase()}
|
||||
{(selectedContact.name?.[0] ?? selectedContact.email?.[0] ?? "?").toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={cn("font-black text-sm md:text-lg tracking-tight leading-none mb-1", isLight ? "text-slate-900" : "text-white")}>
|
||||
{selectedContact.name}
|
||||
<div className="min-w-0">
|
||||
<h3 className={cn("font-black text-sm md:text-lg tracking-tight leading-none mb-1 truncate", isLight ? "text-slate-900" : "text-white")}>
|
||||
{selectedContact.name ?? selectedContact.email ?? "Unknown"}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
{selectedContact.email ? (
|
||||
<p className={cn("text-xs font-medium truncate mb-1", isLight ? "text-slate-600" : "text-slate-400")} title={selectedContact.email}>
|
||||
{selectedContact.email}
|
||||
</p>
|
||||
) : null}
|
||||
{selectedContact.storeName ? (
|
||||
<p className={cn("text-[10px] font-semibold truncate mb-1", isLight ? "text-slate-500" : "text-slate-500")} title={selectedContact.storeName}>
|
||||
{selectedContact.storeName}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
|
||||
<span className={cn("text-[10px] font-black uppercase tracking-widest", isLight ? "text-slate-500" : "text-slate-500")}>Active Now</span>
|
||||
<div className={cn("h-3 w-[1px] mx-1", isLight ? "bg-slate-200" : "bg-white/10")} />
|
||||
<span className={cn("text-[10px] font-black uppercase tracking-widest flex items-center gap-1", isLight ? "text-indigo-600" : "text-indigo-400")}>
|
||||
@ -608,6 +662,28 @@ export function ChatView() {
|
||||
: legacy?.attachment
|
||||
: null
|
||||
|
||||
const threadStoreId = selectedContact.storeId
|
||||
let wrongStoreForRichCard = false
|
||||
if (
|
||||
(kindToRender === "PRODUCT" || kindToRender === "ORDER") &&
|
||||
threadStoreId
|
||||
) {
|
||||
if (msg.storeId != null && msg.storeId !== "") {
|
||||
wrongStoreForRichCard = msg.storeId !== threadStoreId
|
||||
} else if (
|
||||
attachmentToRender &&
|
||||
typeof (attachmentToRender as { storeId?: string }).storeId ===
|
||||
"string" &&
|
||||
(attachmentToRender as { storeId: string }).storeId !== ""
|
||||
) {
|
||||
wrongStoreForRichCard =
|
||||
(attachmentToRender as { storeId: string }).storeId !==
|
||||
threadStoreId
|
||||
}
|
||||
}
|
||||
const displayKind = wrongStoreForRichCard ? "TEXT" : kindToRender
|
||||
const displayAttachment = wrongStoreForRichCard ? null : attachmentToRender
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
@ -624,83 +700,83 @@ export function ChatView() {
|
||||
? "bg-white border border-slate-200 text-slate-800 rounded-tl-none shadow-md"
|
||||
: "bg-white/5 border border-white/10 text-slate-200 rounded-tl-none backdrop-blur-sm shadow-xl"
|
||||
)}>
|
||||
{kindToRender === "PRODUCT" && attachmentToRender ? (
|
||||
{displayKind === "PRODUCT" && displayAttachment ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveAttachmentKind("PRODUCT")
|
||||
setActiveAttachment(attachmentToRender)
|
||||
setActiveAttachment(displayAttachment)
|
||||
setAttachmentModalOpen(true)
|
||||
}}
|
||||
className={cn("w-full text-left bg-transparent border-0 p-0 m-0")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-xl overflow-hidden bg-black/10 border border-white/10 flex-shrink-0 flex items-center justify-center">
|
||||
{attachmentToRender.imageUrl ? (
|
||||
{displayAttachment.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={attachmentToRender.imageUrl} alt={attachmentToRender.title || "Product"} className="w-full h-full object-cover" />
|
||||
<img src={displayAttachment.imageUrl} alt={displayAttachment.title || "Product"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center px-1">
|
||||
<Package className="w-5 h-5 text-indigo-400" />
|
||||
<div className="text-[10px] font-black mt-1 leading-none text-indigo-200">
|
||||
{attachmentToRender.title ? attachmentToRender.title : ""}
|
||||
{displayAttachment.title ? displayAttachment.title : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-black truncate">
|
||||
{attachmentToRender.title || msg.content}
|
||||
{displayAttachment.title || msg.content}
|
||||
</div>
|
||||
{attachmentToRender.description ? (
|
||||
{displayAttachment.description ? (
|
||||
<div className={cn("text-xs opacity-90 mt-1 line-clamp-2")}>
|
||||
{attachmentToRender.description}
|
||||
{displayAttachment.description}
|
||||
</div>
|
||||
) : null}
|
||||
{attachmentToRender.storeName ? (
|
||||
{displayAttachment.storeName ? (
|
||||
<div className={cn("text-[10px] font-black uppercase tracking-widest mt-1 opacity-80")}>
|
||||
{attachmentToRender.storeName}
|
||||
{displayAttachment.storeName}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
) : kindToRender === "ORDER" && attachmentToRender ? (
|
||||
) : displayKind === "ORDER" && displayAttachment ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveAttachmentKind("ORDER")
|
||||
setActiveAttachment(attachmentToRender)
|
||||
setActiveAttachment(displayAttachment)
|
||||
setAttachmentModalOpen(true)
|
||||
}}
|
||||
className={cn("w-full text-left bg-transparent border-0 p-0 m-0")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-xl overflow-hidden bg-black/10 border border-white/10 flex-shrink-0 flex items-center justify-center">
|
||||
{attachmentToRender.primaryImageUrl ? (
|
||||
{displayAttachment.primaryImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={attachmentToRender.primaryImageUrl} alt="Order item" className="w-full h-full object-cover" />
|
||||
<img src={displayAttachment.primaryImageUrl} alt="Order item" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center px-1">
|
||||
<ShoppingCart className="w-5 h-5 text-indigo-400" />
|
||||
<div className="text-[10px] font-black mt-1 leading-none text-indigo-200">
|
||||
{attachmentToRender.orderNumber || "#"}
|
||||
{displayAttachment.orderNumber || "#"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-black truncate">
|
||||
Order {attachmentToRender.orderNumber || msg.content}
|
||||
Order {displayAttachment.orderNumber || msg.content}
|
||||
</div>
|
||||
{attachmentToRender.customerName ? (
|
||||
{displayAttachment.customerName ? (
|
||||
<div className={cn("text-xs opacity-90 mt-1 line-clamp-1")}>
|
||||
{attachmentToRender.customerName}
|
||||
{displayAttachment.customerName}
|
||||
</div>
|
||||
) : null}
|
||||
{attachmentToRender.total_price ? (
|
||||
{displayAttachment.total_price ? (
|
||||
<div className={cn("text-[10px] font-black uppercase tracking-widest mt-1 opacity-80")}>
|
||||
{attachmentToRender.currency ? `${attachmentToRender.currency} ` : ""}{attachmentToRender.total_price}
|
||||
{displayAttachment.currency ? `${displayAttachment.currency} ` : ""}{displayAttachment.total_price}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -46,6 +46,23 @@ interface ShopifyCustomerRecord {
|
||||
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
|
||||
@ -319,7 +336,8 @@ export async function updateCustomerCache(storeId: string, customers: ShopifyCus
|
||||
totalSpent: customer.total_spent || "0",
|
||||
phone: customer.phone || null,
|
||||
tags: customer.tags || null,
|
||||
shopifyCreatedAt: customer.created_at ? new Date(customer.created_at) : null
|
||||
shopifyCreatedAt: customer.created_at ? new Date(customer.created_at) : null,
|
||||
defaultAddress: defaultAddressForCustomerCache(customer.default_address),
|
||||
}))
|
||||
|
||||
await prisma.cachedCustomer.createMany({
|
||||
|
||||
69
src/lib/chat/chatThreadAccess.ts
Normal file
69
src/lib/chat/chatThreadAccess.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export type ChatPair = { vendorUserId: string; supplierUserId: string }
|
||||
|
||||
/** Map session user + contact to vendor/supplier user ids for B2B chat. */
|
||||
export async function resolveB2bChatPair(
|
||||
sessionUserId: string,
|
||||
contactUserId: string
|
||||
): Promise<ChatPair | null> {
|
||||
const [sessionUser, contactUser] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: sessionUserId },
|
||||
select: { id: true, role: true },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: contactUserId },
|
||||
select: { id: true, role: true },
|
||||
}),
|
||||
])
|
||||
if (!sessionUser || !contactUser) return null
|
||||
if (sessionUser.role === "VENDOR" && contactUser.role === "SUPPLIER") {
|
||||
return { vendorUserId: sessionUserId, supplierUserId: contactUserId }
|
||||
}
|
||||
if (sessionUser.role === "SUPPLIER" && contactUser.role === "VENDOR") {
|
||||
return { vendorUserId: contactUserId, supplierUserId: sessionUserId }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function countConnectionsForPair(
|
||||
vendorUserId: string,
|
||||
supplierUserId: string
|
||||
): Promise<number> {
|
||||
const sp = await prisma.supplierProfile.findUnique({
|
||||
where: { userId: supplierUserId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!sp) return 0
|
||||
return prisma.connection.count({
|
||||
where: {
|
||||
status: "CONNECTED",
|
||||
supplierId: sp.id,
|
||||
store: { userId: vendorUserId },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Returns true if this store is linked between the vendor and supplier users. */
|
||||
export async function hasChatConnectionForStore(
|
||||
vendorUserId: string,
|
||||
supplierUserId: string,
|
||||
storeId: string
|
||||
): Promise<boolean> {
|
||||
const sp = await prisma.supplierProfile.findUnique({
|
||||
where: { userId: supplierUserId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!sp) return false
|
||||
const conn = await prisma.connection.findFirst({
|
||||
where: {
|
||||
status: "CONNECTED",
|
||||
storeId,
|
||||
supplierId: sp.id,
|
||||
store: { userId: vendorUserId },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
return !!conn
|
||||
}
|
||||
103
src/lib/products/autoLinkOfferToShopifyProduct.test.ts
Normal file
103
src/lib/products/autoLinkOfferToShopifyProduct.test.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
normalizeProductTitle,
|
||||
pickMatchingSupplierOffer,
|
||||
resolveSupplierQuantityFromOffers,
|
||||
type SupplierOfferForMatching,
|
||||
} from "./autoLinkOfferToShopifyProduct"
|
||||
|
||||
describe("normalizeProductTitle", () => {
|
||||
it("trims and lowercases and collapses whitespace", () => {
|
||||
expect(normalizeProductTitle(" Hello WORLD ")).toBe("hello world")
|
||||
})
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(normalizeProductTitle(" ")).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickMatchingSupplierOffer", () => {
|
||||
const a: SupplierOfferForMatching = {
|
||||
supplierId: "s1",
|
||||
offerQuantity: 10,
|
||||
product: { title: "Blue Widget", sku: "BW-1" },
|
||||
}
|
||||
const b: SupplierOfferForMatching = {
|
||||
supplierId: "s2",
|
||||
offerQuantity: 20,
|
||||
product: { title: "Blue Widget", sku: "BW-2" },
|
||||
}
|
||||
const c: SupplierOfferForMatching = {
|
||||
supplierId: "s3",
|
||||
offerQuantity: 3,
|
||||
product: { title: "Red Hat", sku: "RH-99" },
|
||||
}
|
||||
|
||||
it("returns single title match", () => {
|
||||
expect(
|
||||
pickMatchingSupplierOffer([a, c], "blue widget", new Set()),
|
||||
).toEqual(a)
|
||||
})
|
||||
|
||||
it("disambiguates multiple title matches by SKU", () => {
|
||||
expect(
|
||||
pickMatchingSupplierOffer([a, b], "blue widget", new Set(["BW-2"])),
|
||||
).toEqual(b)
|
||||
})
|
||||
|
||||
it("returns null when multiple title matches and SKU not unique", () => {
|
||||
expect(
|
||||
pickMatchingSupplierOffer([a, b], "blue widget", new Set()),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("uses SKU-only when no title match", () => {
|
||||
expect(
|
||||
pickMatchingSupplierOffer([a, b, c], "unknown title", new Set(["RH-99"])),
|
||||
).toEqual(c)
|
||||
})
|
||||
|
||||
it("returns null for ambiguous SKU-only", () => {
|
||||
expect(
|
||||
pickMatchingSupplierOffer([a, b], "x", new Set(["BW-1", "BW-2"])),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for empty offers", () => {
|
||||
expect(pickMatchingSupplierOffer([], "t", new Set(["x"]))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveSupplierQuantityFromOffers", () => {
|
||||
it("returns quantity from single title match", () => {
|
||||
expect(
|
||||
resolveSupplierQuantityFromOffers(
|
||||
"HandBags",
|
||||
{ variants: [{ sku: "HB-1" }] },
|
||||
[
|
||||
{
|
||||
supplierId: "s1",
|
||||
quantity: 5,
|
||||
product: { title: "HandBags", sku: "HB-1" },
|
||||
},
|
||||
],
|
||||
),
|
||||
).toBe(5)
|
||||
})
|
||||
|
||||
it("returns undefined when no match", () => {
|
||||
expect(
|
||||
resolveSupplierQuantityFromOffers(
|
||||
"Other",
|
||||
{},
|
||||
[
|
||||
{
|
||||
supplierId: "s1",
|
||||
quantity: 5,
|
||||
product: { title: "HandBags", sku: "HB-1" },
|
||||
},
|
||||
],
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
305
src/lib/products/autoLinkOfferToShopifyProduct.ts
Normal file
305
src/lib/products/autoLinkOfferToShopifyProduct.ts
Normal file
@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Auto-links a Shopify product to a supplier when exactly one SupplierProductOffer
|
||||
* matches (title first, SKU to disambiguate or SKU-only fallback).
|
||||
*
|
||||
* Skips entirely if any ProductSync already exists for this store + shopifyProductId
|
||||
* (any status, any supplier) so manual / pending / rejected flows are not overridden.
|
||||
* Vendors with a rejected sync must use manual sync after the supplier rejects.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import type { Prisma } from "@prisma/client"
|
||||
import { sumVariantInventoryQuantity } from "@/lib/products/shopifyInventorySum"
|
||||
|
||||
export type SupplierOfferForMatching = {
|
||||
supplierId: string
|
||||
/** Offer quantity (items available from supplier for this store). */
|
||||
offerQuantity: number
|
||||
product: { title: string; sku: string | null }
|
||||
}
|
||||
|
||||
export type SupplierOfferQuantityRow = {
|
||||
supplierId: string
|
||||
quantity: number
|
||||
product: { title: string; sku: string | null }
|
||||
}
|
||||
|
||||
export function normalizeProductTitle(title: string): string {
|
||||
return title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
}
|
||||
|
||||
export function extractVariantSkusFromShopifyBody(body: unknown): Set<string> {
|
||||
const out = new Set<string>()
|
||||
if (!body || typeof body !== "object") return out
|
||||
const variants = (body as { variants?: unknown }).variants
|
||||
if (!Array.isArray(variants)) return out
|
||||
for (const v of variants) {
|
||||
if (v && typeof v === "object" && "sku" in v) {
|
||||
const sku = String((v as { sku?: unknown }).sku ?? "").trim()
|
||||
if (sku) out.add(sku)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function shopifyTitleFromBody(body: unknown): string {
|
||||
if (!body || typeof body !== "object") return ""
|
||||
const t = (body as { title?: unknown }).title
|
||||
return typeof t === "string" ? t : ""
|
||||
}
|
||||
|
||||
function shopifyProductIdFromBody(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") return null
|
||||
const id = (body as { id?: unknown }).id
|
||||
if (id === undefined || id === null) return null
|
||||
return String(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick at most one offer using: normalized title match; if multiple, narrow by variant SKU;
|
||||
* if no title match, unique SKU match across variants.
|
||||
*/
|
||||
export function pickMatchingSupplierOffer(
|
||||
offers: SupplierOfferForMatching[],
|
||||
shopifyTitle: string,
|
||||
variantSkus: Set<string>,
|
||||
): SupplierOfferForMatching | null {
|
||||
if (!offers.length) return null
|
||||
|
||||
const normShop = normalizeProductTitle(shopifyTitle)
|
||||
if (!normShop) {
|
||||
// No title — only SKU-only path
|
||||
return pickUniqueSkuMatch(offers, variantSkus)
|
||||
}
|
||||
|
||||
const titleMatches = offers.filter(
|
||||
(o) => normalizeProductTitle(o.product.title) === normShop,
|
||||
)
|
||||
|
||||
if (titleMatches.length === 1) {
|
||||
return titleMatches[0]!
|
||||
}
|
||||
|
||||
if (titleMatches.length > 1) {
|
||||
const narrowed = filterOffersBySkuInSet(titleMatches, variantSkus)
|
||||
return narrowed.length === 1 ? narrowed[0]! : null
|
||||
}
|
||||
|
||||
return pickUniqueSkuMatch(offers, variantSkus)
|
||||
}
|
||||
|
||||
function filterOffersBySkuInSet(
|
||||
offers: SupplierOfferForMatching[],
|
||||
variantSkus: Set<string>,
|
||||
): SupplierOfferForMatching[] {
|
||||
return offers.filter((o) => {
|
||||
const s = (o.product.sku ?? "").trim()
|
||||
return Boolean(s) && variantSkus.has(s)
|
||||
})
|
||||
}
|
||||
|
||||
function pickUniqueSkuMatch(
|
||||
offers: SupplierOfferForMatching[],
|
||||
variantSkus: Set<string>,
|
||||
): SupplierOfferForMatching | null {
|
||||
if (variantSkus.size === 0) return null
|
||||
const matches = filterOffersBySkuInSet(offers, variantSkus)
|
||||
return matches.length === 1 ? matches[0]! : null
|
||||
}
|
||||
|
||||
/**
|
||||
* When ProductSync JSON lacks supplier_quantity, infer it from the matching
|
||||
* SupplierProductOffer (same title/SKU rules as auto-link).
|
||||
*/
|
||||
export function resolveSupplierQuantityFromOffers(
|
||||
shopifyTitle: string,
|
||||
shopifyProductData: unknown,
|
||||
offers: SupplierOfferQuantityRow[],
|
||||
): number | undefined {
|
||||
if (!offers.length) return undefined
|
||||
const variantSkus = extractVariantSkusFromShopifyBody(shopifyProductData)
|
||||
const mapped: SupplierOfferForMatching[] = offers.map((o) => ({
|
||||
supplierId: o.supplierId,
|
||||
offerQuantity: o.quantity,
|
||||
product: o.product,
|
||||
}))
|
||||
const winner = pickMatchingSupplierOffer(mapped, shopifyTitle, variantSkus)
|
||||
return winner?.offerQuantity
|
||||
}
|
||||
|
||||
function mergeShopifyBodyWithSupplierQuantity(
|
||||
body: unknown,
|
||||
supplierQuantity: number,
|
||||
): Prisma.InputJsonValue {
|
||||
const base =
|
||||
body && typeof body === "object" && !Array.isArray(body)
|
||||
? { ...(body as Record<string, unknown>) }
|
||||
: {}
|
||||
return { ...base, supplier_quantity: supplierQuantity } as Prisma.InputJsonValue
|
||||
}
|
||||
|
||||
function autoLinkEnabledFromEnv(): boolean {
|
||||
const v = process.env.AUTO_LINK_SUPPLIER_OFFERS?.trim().toLowerCase()
|
||||
if (v === "0" || v === "false" || v === "off" || v === "no") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export type AutoLinkOfferResult =
|
||||
| { linked: true; productSyncId: string; supplierId: string }
|
||||
| {
|
||||
linked: false
|
||||
reason:
|
||||
| "disabled"
|
||||
| "invalid_payload"
|
||||
| "existing_sync"
|
||||
| "no_store_user"
|
||||
| "no_offers"
|
||||
| "no_match"
|
||||
| "create_failed"
|
||||
}
|
||||
|
||||
/**
|
||||
* After Shopify products/create or products/update webhook body is verified and cache updated.
|
||||
*/
|
||||
export async function autoLinkOfferToShopifyProduct(
|
||||
storeId: string,
|
||||
shopifyProductBody: unknown,
|
||||
): Promise<AutoLinkOfferResult> {
|
||||
if (!autoLinkEnabledFromEnv()) {
|
||||
return { linked: false, reason: "disabled" }
|
||||
}
|
||||
|
||||
const shopifyProductId = shopifyProductIdFromBody(shopifyProductBody)
|
||||
if (!shopifyProductId) {
|
||||
return { linked: false, reason: "invalid_payload" }
|
||||
}
|
||||
|
||||
const existingCount = await prisma.productSync.count({
|
||||
where: {
|
||||
storeId,
|
||||
shopifyProductId,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingCount > 0) {
|
||||
return { linked: false, reason: "existing_sync" }
|
||||
}
|
||||
|
||||
const store = await prisma.store.findUnique({
|
||||
where: { id: storeId },
|
||||
select: { userId: true },
|
||||
})
|
||||
|
||||
if (!store?.userId) {
|
||||
return { linked: false, reason: "no_store_user" }
|
||||
}
|
||||
|
||||
if (!("supplierProductOffer" in prisma)) {
|
||||
logger.warn("[autoLinkOffer] Prisma client missing supplierProductOffer model")
|
||||
return { linked: false, reason: "no_offers" }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawOffers = await (prisma as any).supplierProductOffer.findMany({
|
||||
where: { storeId },
|
||||
include: {
|
||||
product: {
|
||||
select: {
|
||||
title: true,
|
||||
sku: true,
|
||||
},
|
||||
},
|
||||
connection: {
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as Array<{
|
||||
supplierId: string
|
||||
quantity: number
|
||||
product: { title: string; sku: string | null }
|
||||
connection: { status: string }
|
||||
}>
|
||||
|
||||
const offers: SupplierOfferForMatching[] = rawOffers
|
||||
.filter((o) => o.connection.status === "CONNECTED")
|
||||
.map((o) => ({
|
||||
supplierId: o.supplierId,
|
||||
offerQuantity: o.quantity,
|
||||
product: o.product,
|
||||
}))
|
||||
|
||||
if (!offers.length) {
|
||||
return { linked: false, reason: "no_offers" }
|
||||
}
|
||||
|
||||
const shopifyTitle = shopifyTitleFromBody(shopifyProductBody)
|
||||
const variantSkus = extractVariantSkusFromShopifyBody(shopifyProductBody)
|
||||
const winner = pickMatchingSupplierOffer(offers, shopifyTitle, variantSkus)
|
||||
|
||||
if (!winner) {
|
||||
logger.info("[autoLinkOffer] No unambiguous supplier offer match", {
|
||||
storeId,
|
||||
shopifyProductId,
|
||||
titleSample: shopifyTitle.slice(0, 80),
|
||||
variantSkuCount: variantSkus.size,
|
||||
connectedOfferCount: offers.length,
|
||||
})
|
||||
return { linked: false, reason: "no_match" }
|
||||
}
|
||||
|
||||
const variants =
|
||||
shopifyProductBody && typeof shopifyProductBody === "object"
|
||||
? (shopifyProductBody as { variants?: unknown }).variants
|
||||
: undefined
|
||||
const shopifyStockSum = sumVariantInventoryQuantity(variants)
|
||||
const initialSupplierQty =
|
||||
shopifyStockSum !== null ? shopifyStockSum : winner.offerQuantity
|
||||
|
||||
const shopifyProductData = mergeShopifyBodyWithSupplierQuantity(
|
||||
shopifyProductBody,
|
||||
initialSupplierQty,
|
||||
)
|
||||
|
||||
try {
|
||||
const row = await prisma.productSync.create({
|
||||
data: {
|
||||
storeId,
|
||||
supplierId: winner.supplierId,
|
||||
shopifyProductId,
|
||||
shopifyProductTitle: shopifyTitle || `Product ${shopifyProductId}`,
|
||||
shopifyProductData,
|
||||
status: "ACCEPTED",
|
||||
requestedBy: store.userId,
|
||||
acceptedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
logger.info("[autoLinkOffer] Created ACCEPTED ProductSync from webhook", {
|
||||
storeId,
|
||||
shopifyProductId,
|
||||
supplierId: winner.supplierId,
|
||||
productSyncId: row.id,
|
||||
})
|
||||
|
||||
return {
|
||||
linked: true,
|
||||
productSyncId: row.id,
|
||||
supplierId: winner.supplierId,
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("[autoLinkOffer] Failed to create ProductSync", {
|
||||
storeId,
|
||||
shopifyProductId,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
return { linked: false, reason: "create_failed" }
|
||||
}
|
||||
}
|
||||
40
src/lib/products/shopifyInventorySum.test.ts
Normal file
40
src/lib/products/shopifyInventorySum.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
sumVariantInventoryQuantity,
|
||||
supplierQuantityFromSyncedProductData,
|
||||
} from "./shopifyInventorySum"
|
||||
|
||||
describe("sumVariantInventoryQuantity", () => {
|
||||
it("sums numeric inventory_quantity across variants", () => {
|
||||
expect(
|
||||
sumVariantInventoryQuantity([
|
||||
{ inventory_quantity: 10 },
|
||||
{ inventory_quantity: 7 },
|
||||
]),
|
||||
).toBe(17)
|
||||
})
|
||||
|
||||
it("returns null when no usable quantities", () => {
|
||||
expect(sumVariantInventoryQuantity([])).toBeNull()
|
||||
expect(sumVariantInventoryQuantity([{ sku: "x" }])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("supplierQuantityFromSyncedProductData", () => {
|
||||
it("prefers supplier_quantity when set", () => {
|
||||
expect(
|
||||
supplierQuantityFromSyncedProductData({
|
||||
supplier_quantity: 3,
|
||||
variants: [{ inventory_quantity: 99 }],
|
||||
}),
|
||||
).toBe(3)
|
||||
})
|
||||
|
||||
it("falls back to variant sum", () => {
|
||||
expect(
|
||||
supplierQuantityFromSyncedProductData({
|
||||
variants: [{ inventory_quantity: 4 }, { inventory_quantity: 5 }],
|
||||
}),
|
||||
).toBe(9)
|
||||
})
|
||||
})
|
||||
38
src/lib/products/shopifyInventorySum.ts
Normal file
38
src/lib/products/shopifyInventorySum.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Sum Shopify REST-style variant `inventory_quantity` values (vendor inventory snapshot).
|
||||
* Used to seed supplier-facing stock on first product sync so it matches Shopify stock.
|
||||
*/
|
||||
export function sumVariantInventoryQuantity(variants: unknown): number | null {
|
||||
if (!Array.isArray(variants) || variants.length === 0) return null
|
||||
let total = 0
|
||||
let hasQty = false
|
||||
for (const v of variants) {
|
||||
if (v && typeof v === "object" && v !== null && "inventory_quantity" in v) {
|
||||
const raw = (v as { inventory_quantity?: unknown }).inventory_quantity
|
||||
if (raw !== null && raw !== undefined && raw !== "") {
|
||||
const n = Number(raw)
|
||||
if (!Number.isNaN(n)) {
|
||||
total += n
|
||||
hasQty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasQty ? total : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplier stock shown for an ACCEPTED ProductSync: persisted `supplier_quantity` (supplier edits),
|
||||
* else sum of variant inventory from the last-synced Shopify snapshot (initial parity with Shopify stock).
|
||||
*/
|
||||
export function supplierQuantityFromSyncedProductData(productData: unknown): number | undefined {
|
||||
const pd =
|
||||
productData && typeof productData === "object" && !Array.isArray(productData)
|
||||
? (productData as Record<string, unknown>)
|
||||
: {}
|
||||
if (typeof pd.supplier_quantity === "number" && !Number.isNaN(pd.supplier_quantity)) {
|
||||
return pd.supplier_quantity
|
||||
}
|
||||
const sum = sumVariantInventoryQuantity(pd.variants)
|
||||
return sum !== null ? sum : undefined
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user