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[]
|
flaggedEmails FlaggedEmail[]
|
||||||
gmailInboundAutoReplies GmailInboundAutoReply[]
|
gmailInboundAutoReplies GmailInboundAutoReply[]
|
||||||
templates Template[]
|
templates Template[]
|
||||||
|
chatMessages ChatMessage[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@ -207,6 +208,8 @@ model CachedCustomer {
|
|||||||
totalSpent String?
|
totalSpent String?
|
||||||
phone String?
|
phone String?
|
||||||
tags String?
|
tags String?
|
||||||
|
/** Shopify REST `default_address` snapshot for vendor UI (city, country, etc.). */
|
||||||
|
defaultAddress Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
lastSyncedAt DateTime @default(now())
|
lastSyncedAt DateTime @default(now())
|
||||||
@ -591,17 +594,21 @@ model ChatMessage {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
senderId String
|
senderId String
|
||||||
receiverId 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)
|
kind ChatMessageKind @default(TEXT)
|
||||||
content String @map("encryptedContent") @db.Text
|
content String @map("encryptedContent") @db.Text
|
||||||
attachment Json?
|
attachment Json?
|
||||||
|
|
||||||
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
||||||
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id])
|
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id])
|
||||||
|
store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([senderId, receiverId])
|
@@index([senderId, receiverId])
|
||||||
@@index([receiverId, senderId])
|
@@index([receiverId, senderId])
|
||||||
|
@@index([senderId, receiverId, storeId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum StorefrontChatSenderType {
|
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 { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
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() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
@ -13,30 +25,41 @@ export async function GET() {
|
|||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
include: {
|
include: {
|
||||||
supplierProfile: true,
|
supplierProfile: true,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new NextResponse("User not found", { status: 404 })
|
return new NextResponse("User not found", { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Contact {
|
let contacts: ChatContactRow[] = []
|
||||||
id: string
|
|
||||||
name: string | null
|
|
||||||
email: string | null
|
|
||||||
image: string | null
|
|
||||||
role: string
|
|
||||||
}
|
|
||||||
let contacts: Contact[] = []
|
|
||||||
|
|
||||||
if (user.role === "VENDOR") {
|
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({
|
const connections = await prisma.connection.findMany({
|
||||||
where: {
|
where: {
|
||||||
store: { userId: user.id },
|
store: {
|
||||||
status: "CONNECTED"
|
userId: user.id,
|
||||||
|
...(scopedStoreId ? { id: scopedStoreId } : {}),
|
||||||
|
},
|
||||||
|
status: "CONNECTED",
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
supplier: {
|
supplier: {
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
@ -45,26 +68,29 @@ export async function GET() {
|
|||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
image: true,
|
image: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
type VendorSideConnection = (typeof connections)[number]
|
type C = (typeof connections)[number]
|
||||||
contacts = connections.map((c: VendorSideConnection) => ({
|
contacts = connections.map((c: C) => ({
|
||||||
id: c.supplier.user.id,
|
id: c.supplier.user.id,
|
||||||
|
connectionId: c.id,
|
||||||
|
storeId: c.store.id,
|
||||||
|
storeName: c.store.businessName,
|
||||||
name: c.supplier.companyName || c.supplier.user.name,
|
name: c.supplier.companyName || c.supplier.user.name,
|
||||||
email: c.supplier.user.email,
|
email: c.supplier.user.email,
|
||||||
image: c.supplier.user.image,
|
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) {
|
} else if (user.role === "SUPPLIER" && user.supplierProfile) {
|
||||||
// Fetch connected vendors
|
|
||||||
const connections = await prisma.connection.findMany({
|
const connections = await prisma.connection.findMany({
|
||||||
where: {
|
where: {
|
||||||
supplierId: user.supplierProfile.id,
|
supplierId: user.supplierProfile.id,
|
||||||
status: "CONNECTED"
|
status: "CONNECTED",
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
store: {
|
store: {
|
||||||
@ -75,20 +101,24 @@ export async function GET() {
|
|||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
image: true,
|
image: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
type SupplierSideConnection = (typeof connections)[number]
|
type C = (typeof connections)[number]
|
||||||
contacts = connections.map((c: SupplierSideConnection) => ({
|
contacts = connections.map((c: C) => ({
|
||||||
id: c.store.user.id,
|
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,
|
email: c.store.user.email,
|
||||||
image: c.store.user.image,
|
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 })
|
return NextResponse.json({ contacts })
|
||||||
|
|||||||
@ -2,6 +2,11 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { sendPushNotification } from "@/lib/push"
|
import { sendPushNotification } from "@/lib/push"
|
||||||
|
import {
|
||||||
|
countConnectionsForPair,
|
||||||
|
hasChatConnectionForStore,
|
||||||
|
resolveB2bChatPair,
|
||||||
|
} from "@/lib/chat/chatThreadAccess"
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -10,14 +15,34 @@ export async function POST(req: Request) {
|
|||||||
return new NextResponse("Unauthorized", { status: 401 })
|
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") {
|
if (!receiverId || typeof receiverId !== "string") {
|
||||||
return new NextResponse("Missing fields", { status: 400 })
|
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 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 (normalizedKind === "TEXT") {
|
||||||
if (content != null && typeof content !== "string") {
|
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({
|
const message = await prisma.chatMessage.create({
|
||||||
data: {
|
data: {
|
||||||
senderId: session.user.id,
|
senderId: session.user.id,
|
||||||
receiverId,
|
receiverId,
|
||||||
|
storeId: storeIdRaw,
|
||||||
kind: normalizedKind as any,
|
kind: normalizedKind as any,
|
||||||
content: (typeof content === "string" ? content : "") as any,
|
content: (typeof content === "string" ? content : "") as any,
|
||||||
attachment: (normalizedKind === "TEXT" ? null : (attachment as any)) as any,
|
attachment: (normalizedKind === "TEXT" ? null : (attachment as any)) as any,
|
||||||
} as any
|
} as any,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send push notification to receiver
|
|
||||||
try {
|
try {
|
||||||
await sendPushNotification(receiverId, {
|
await sendPushNotification(receiverId, {
|
||||||
title: "New Message",
|
title: "New Message",
|
||||||
body: `${session.user.name || "A user"} sent you a 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) {
|
} catch (pushError) {
|
||||||
console.error("Failed to send push notification", pushError)
|
console.error("Failed to send push notification", pushError)
|
||||||
@ -66,22 +106,58 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
const contactId = searchParams.get("contactId")
|
const contactId = searchParams.get("contactId")
|
||||||
|
const storeId = searchParams.get("storeId")
|
||||||
|
|
||||||
if (!contactId) {
|
if (!contactId) {
|
||||||
return new NextResponse("Missing contactId", { status: 400 })
|
return new NextResponse("Missing contactId", { status: 400 })
|
||||||
}
|
}
|
||||||
|
if (!storeId) {
|
||||||
|
return new NextResponse("Missing storeId", { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
const messages = await prisma.chatMessage.findMany({
|
const pair = await resolveB2bChatPair(session.user.id, contactId)
|
||||||
where: {
|
if (!pair) {
|
||||||
OR: [
|
return new NextResponse("Invalid chat participant", { status: 403 })
|
||||||
{ senderId: session.user.id, receiverId: contactId },
|
}
|
||||||
{ senderId: contactId, receiverId: session.user.id }
|
|
||||||
]
|
const allowed = await hasChatConnectionForStore(
|
||||||
},
|
pair.vendorUserId,
|
||||||
orderBy: {
|
pair.supplierUserId,
|
||||||
createdAt: "asc"
|
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 })
|
return NextResponse.json({ messages })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { sumVariantInventoryQuantity } from "@/lib/products/shopifyInventorySum"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/products/sync/[syncId]
|
* PUT /api/products/sync/[syncId]
|
||||||
@ -83,6 +84,17 @@ export async function PUT(
|
|||||||
|
|
||||||
if (action === "accept") {
|
if (action === "accept") {
|
||||||
updateData.acceptedAt = new Date()
|
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 {
|
} else {
|
||||||
updateData.rejectedAt = new Date()
|
updateData.rejectedAt = new Date()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/products/sync
|
* 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) {
|
} catch (error) {
|
||||||
console.error("Error fetching product syncs:", error)
|
console.error("Error fetching product syncs:", error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -154,7 +168,41 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 }
|
{ 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
|
// Create sync request
|
||||||
|
|||||||
@ -3,6 +3,37 @@ import { auth } from "@/auth"
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { isCacheFresh, getCachedCustomers, updateCustomerCache } from "@/lib/cache"
|
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() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
@ -41,7 +72,8 @@ export async function GET() {
|
|||||||
total_spent: customer.totalSpent,
|
total_spent: customer.totalSpent,
|
||||||
phone: customer.phone,
|
phone: customer.phone,
|
||||||
tags: customer.tags,
|
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({
|
return NextResponse.json({
|
||||||
customers: transformedCustomers,
|
customers: transformedCustomers,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/supplier/products
|
* GET /api/supplier/products
|
||||||
@ -56,10 +57,7 @@ export async function GET() {
|
|||||||
// Transform synced products to match product format
|
// Transform synced products to match product format
|
||||||
const syncedProductsFormatted = syncedProducts.map((sync: any) => {
|
const syncedProductsFormatted = syncedProducts.map((sync: any) => {
|
||||||
const productData = sync.shopifyProductData || {}
|
const productData = sync.shopifyProductData || {}
|
||||||
const supplierQuantity =
|
const supplierQuantity = supplierQuantityFromSyncedProductData(productData)
|
||||||
typeof productData.supplier_quantity === "number"
|
|
||||||
? productData.supplier_quantity
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `synced-${sync.id}`, // Unique ID for synced products
|
id: `synced-${sync.id}`, // Unique ID for synced products
|
||||||
@ -77,7 +75,7 @@ export async function GET() {
|
|||||||
businessName: sync.store.businessName
|
businessName: sync.store.businessName
|
||||||
},
|
},
|
||||||
isSynced: true, // Flag to indicate this is a synced product from vendor
|
isSynced: true, // Flag to indicate this is a synced product from vendor
|
||||||
availableQuantity: supplierQuantity,
|
availableQuantity: supplierQuantity ?? null,
|
||||||
// Include full Shopify product data for reference
|
// Include full Shopify product data for reference
|
||||||
shopifyData: productData
|
shopifyData: productData
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/vendors/synced-products/[id]
|
* GET /api/vendors/synced-products/[id]
|
||||||
@ -54,10 +55,8 @@ export async function GET(
|
|||||||
|
|
||||||
if (sync) {
|
if (sync) {
|
||||||
const productData = sync.shopifyProductData || {}
|
const productData = sync.shopifyProductData || {}
|
||||||
const supplierQuantity =
|
const supplierQuantity = supplierQuantityFromSyncedProductData(productData)
|
||||||
typeof productData.supplier_quantity === "number"
|
|
||||||
? productData.supplier_quantity
|
|
||||||
: undefined
|
|
||||||
const product = {
|
const product = {
|
||||||
id: sync.id,
|
id: sync.id,
|
||||||
syncId: sync.id,
|
syncId: sync.id,
|
||||||
|
|||||||
90
src/app/api/vendors/synced-products/route.ts
vendored
90
src/app/api/vendors/synced-products/route.ts
vendored
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/vendors/synced-products
|
* GET /api/vendors/synced-products
|
||||||
@ -50,40 +51,33 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Transform syncs into product-like objects for display
|
// Get supplier-provided products for this store (offers list)
|
||||||
const syncedProducts = acceptedSyncs.map((sync: any) => {
|
|
||||||
const productData = sync.shopifyProductData || {}
|
|
||||||
const supplierQuantity =
|
|
||||||
typeof productData.supplier_quantity === "number"
|
|
||||||
? productData.supplier_quantity
|
|
||||||
: undefined
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get supplier-provided products for this store
|
|
||||||
if (!("supplierProductOffer" in prisma)) {
|
if (!("supplierProductOffer" in prisma)) {
|
||||||
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
|
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
|
||||||
// Return only synced products if SupplierProductOffer model is missing
|
const syncedOnly = acceptedSyncs.map((sync: any) => {
|
||||||
return NextResponse.json({ products: syncedProducts })
|
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,
|
||||||
|
source: "synced",
|
||||||
|
offerQuantity: supplierQuantity,
|
||||||
|
shopifyData: productData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return NextResponse.json({ products: syncedOnly })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const supplierOffers = await (prisma as any).supplierProductOffer.findMany({
|
const supplierOffers = await (prisma as any).supplierProductOffer.findMany({
|
||||||
@ -106,6 +100,11 @@ export async function GET() {
|
|||||||
id: true,
|
id: true,
|
||||||
companyName: true
|
companyName: true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
select: {
|
||||||
|
status: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: {
|
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) => ({
|
const supplierProducts = supplierOffers.map((offer: any) => ({
|
||||||
id: offer.id,
|
id: offer.id,
|
||||||
syncId: null,
|
syncId: null,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { updateSingleProduct, updateSingleOrder } from "@/lib/cache"
|
import { updateSingleProduct, updateSingleOrder } from "@/lib/cache"
|
||||||
|
import { autoLinkOfferToShopifyProduct } from "@/lib/products/autoLinkOfferToShopifyProduct"
|
||||||
import crypto from "crypto"
|
import crypto from "crypto"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
@ -93,6 +94,18 @@ export async function POST(req: NextRequest) {
|
|||||||
case "products/create":
|
case "products/create":
|
||||||
logger.info(`[Webhook] Processing product ${topic}: ${body.id}`)
|
logger.info(`[Webhook] Processing product ${topic}: ${body.id}`)
|
||||||
await updateSingleProduct(store.id, body)
|
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
|
// Update sync metadata to invalidate cache
|
||||||
await prisma.syncMetadata.upsert({
|
await prisma.syncMetadata.upsert({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@ -23,11 +23,28 @@ interface ShopifyCustomer {
|
|||||||
last_order_id: number | null
|
last_order_id: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
default_address: {
|
default_address: {
|
||||||
city: string
|
city?: string
|
||||||
country_name: string
|
country_name?: string
|
||||||
|
country?: string
|
||||||
|
province?: string
|
||||||
|
address1?: string
|
||||||
} | null
|
} | 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() {
|
export default function CustomersPage() {
|
||||||
const [customers, setCustomers] = useState<ShopifyCustomer[]>([])
|
const [customers, setCustomers] = useState<ShopifyCustomer[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -140,7 +157,7 @@ export default function CustomersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-400 font-medium md:hidden mt-1">
|
<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" />
|
<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>
|
</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">
|
<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">
|
<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" />
|
<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>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 md:px-6 py-3 md:py-4 hidden lg:table-cell">
|
<td className="px-3 md:px-6 py-3 md:py-4 hidden lg:table-cell">
|
||||||
|
|||||||
@ -56,6 +56,8 @@ interface ProductSync {
|
|||||||
id: string
|
id: string
|
||||||
companyName: string
|
companyName: string
|
||||||
}
|
}
|
||||||
|
/** Set by GET /api/products/sync when status is ACCEPTED (from JSON or offer match). */
|
||||||
|
supplierAvailableQuantity?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Supplier {
|
interface Supplier {
|
||||||
@ -68,6 +70,27 @@ interface Supplier {
|
|||||||
connectionStatus: "NONE" | "PENDING" | "CONNECTED" | "REJECTED"
|
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() {
|
export default function InventoryPage() {
|
||||||
const [products, setProducts] = useState<ShopifyProduct[]>([])
|
const [products, setProducts] = useState<ShopifyProduct[]>([])
|
||||||
const [productSyncs, setProductSyncs] = useState<Map<string, ProductSync>>(new Map())
|
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">
|
<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-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">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 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-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>
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 md:px-6 py-3 md:py-4">
|
<td className="px-3 md:px-6 py-3 md:py-4">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="font-bold text-sm md:text-base text-white">{product.variants.length} Variants</span>
|
<span className="font-bold text-sm md:text-base text-white tabular-nums">
|
||||||
<span className="text-[10px] text-slate-500 font-medium hidden sm:inline">Total Sync Stock</span>
|
{totalShopifyInventory(product.variants)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
{product.variants.length} variant
|
||||||
|
{product.variants.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<td className="px-3 md:px-6 py-3 md:py-4 hidden md:table-cell text-left">
|
||||||
{(() => {
|
{(() => {
|
||||||
const sync = isProductSynced(product.id)
|
const sync = isProductSynced(product.id)
|
||||||
|
|||||||
@ -23,8 +23,11 @@ import { ChatAttachmentModal } from "@/components/chat/ChatAttachmentModal"
|
|||||||
|
|
||||||
interface Contact {
|
interface Contact {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
connectionId: string
|
||||||
email: string
|
storeId: string
|
||||||
|
storeName: string | null
|
||||||
|
name: string | null
|
||||||
|
email: string | null
|
||||||
image: string | null
|
image: string | null
|
||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
@ -33,6 +36,7 @@ interface Message {
|
|||||||
id: string
|
id: string
|
||||||
senderId: string
|
senderId: string
|
||||||
receiverId: string
|
receiverId: string
|
||||||
|
storeId?: string | null
|
||||||
content: string
|
content: string
|
||||||
kind?: "TEXT" | "PRODUCT" | "ORDER" | string | null
|
kind?: "TEXT" | "PRODUCT" | "ORDER" | string | null
|
||||||
attachment?: any
|
attachment?: any
|
||||||
@ -138,13 +142,16 @@ export function ChatView() {
|
|||||||
setLoadingMessages(true)
|
setLoadingMessages(true)
|
||||||
setMessagesError(null)
|
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) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const typedMessages = data.messages.map((m: any) => ({
|
const typedMessages = data.messages.map((m: any) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
senderId: m.senderId,
|
senderId: m.senderId,
|
||||||
receiverId: m.receiverId,
|
receiverId: m.receiverId,
|
||||||
|
storeId: m.storeId ?? null,
|
||||||
content: m.content ?? "",
|
content: m.content ?? "",
|
||||||
kind: m.kind ?? "TEXT",
|
kind: m.kind ?? "TEXT",
|
||||||
attachment: m.attachment ?? null,
|
attachment: m.attachment ?? null,
|
||||||
@ -182,7 +189,7 @@ export function ChatView() {
|
|||||||
fetchMessages(selectedContact, true)
|
fetchMessages(selectedContact, true)
|
||||||
}, POLL_INTERVAL_MS)
|
}, POLL_INTERVAL_MS)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [selectedContact?.id])
|
}, [selectedContact?.connectionId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
@ -201,6 +208,8 @@ export function ChatView() {
|
|||||||
}, [attachOpen])
|
}, [attachOpen])
|
||||||
|
|
||||||
const openAttach = async () => {
|
const openAttach = async () => {
|
||||||
|
if (!selectedContact) return
|
||||||
|
const threadStoreId = selectedContact.storeId
|
||||||
setAttachOpen(true)
|
setAttachOpen(true)
|
||||||
setAttachLoading(true)
|
setAttachLoading(true)
|
||||||
setAttachOrders([])
|
setAttachOrders([])
|
||||||
@ -226,11 +235,17 @@ export function ChatView() {
|
|||||||
])
|
])
|
||||||
if (ordersRes.ok) {
|
if (ordersRes.ok) {
|
||||||
const ordersData = await ordersRes.json()
|
const ordersData = await ordersRes.json()
|
||||||
setAttachOrders(ordersData.orders || [])
|
const allOrders = ordersData.orders || []
|
||||||
|
setAttachOrders(
|
||||||
|
allOrders.filter((o: AttachOrder) => o.storeId === threadStoreId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (productsRes.ok) {
|
if (productsRes.ok) {
|
||||||
const productsData = await productsRes.json()
|
const productsData = await productsRes.json()
|
||||||
setAttachProducts(productsData.products || [])
|
const allProducts = productsData.products || []
|
||||||
|
setAttachProducts(
|
||||||
|
allProducts.filter((p: AttachProduct) => p.storeId === threadStoreId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -249,6 +264,7 @@ export function ChatView() {
|
|||||||
total_price: order.total_price,
|
total_price: order.total_price,
|
||||||
currency: order.currency ?? null,
|
currency: order.currency ?? null,
|
||||||
storeName: order.storeName ?? null,
|
storeName: order.storeName ?? null,
|
||||||
|
storeId: order.storeId,
|
||||||
shopifyOrderId: order.id
|
shopifyOrderId: order.id
|
||||||
})
|
})
|
||||||
const summary = `Order ${order.orderNumber ?? order.name}${order.customerName ? ` - ${order.customerName}` : ""}${order.storeName ? ` · ${order.storeName}` : ""}`
|
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,
|
description: product.description ?? null,
|
||||||
imageUrl: product.imageUrl ?? null,
|
imageUrl: product.imageUrl ?? null,
|
||||||
storeName: product.storeName ?? null,
|
storeName: product.storeName ?? null,
|
||||||
|
storeId: product.storeId,
|
||||||
productId: product.id
|
productId: product.id
|
||||||
})
|
})
|
||||||
const summary = `Product: ${product.title}${product.storeName ? ` · ${product.storeName}` : ""}`
|
const summary = `Product: ${product.title}${product.storeName ? ` · ${product.storeName}` : ""}`
|
||||||
@ -282,6 +299,7 @@ export function ChatView() {
|
|||||||
setSendError(null)
|
setSendError(null)
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
receiverId: selectedContact.id,
|
receiverId: selectedContact.id,
|
||||||
|
storeId: selectedContact.storeId,
|
||||||
content: newMessage
|
content: newMessage
|
||||||
}
|
}
|
||||||
if (hasAttachment) {
|
if (hasAttachment) {
|
||||||
@ -300,6 +318,7 @@ export function ChatView() {
|
|||||||
id: msg.id,
|
id: msg.id,
|
||||||
senderId: msg.senderId,
|
senderId: msg.senderId,
|
||||||
receiverId: msg.receiverId,
|
receiverId: msg.receiverId,
|
||||||
|
storeId: msg.storeId ?? selectedContact.storeId,
|
||||||
content: newMessage,
|
content: newMessage,
|
||||||
kind: msg.kind ?? "TEXT",
|
kind: msg.kind ?? "TEXT",
|
||||||
attachment: msg.attachment ?? null,
|
attachment: msg.attachment ?? null,
|
||||||
@ -401,10 +420,13 @@ export function ChatView() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredContacts = contacts.filter(c =>
|
const q = searchQuery.toLowerCase()
|
||||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const filteredContacts = contacts.filter(c => {
|
||||||
c.email.toLowerCase().includes(searchQuery.toLowerCase())
|
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 (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@ -469,11 +491,11 @@ export function ChatView() {
|
|||||||
<div className="px-2 space-y-1">
|
<div className="px-2 space-y-1">
|
||||||
{filteredContacts.map(contact => (
|
{filteredContacts.map(contact => (
|
||||||
<button
|
<button
|
||||||
key={contact.id}
|
key={contact.connectionId}
|
||||||
onClick={() => setSelectedContact(contact)}
|
onClick={() => setSelectedContact(contact)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-3 p-4 rounded-2xl transition-all group relative",
|
"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 ? "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"
|
: isLight ? "hover:bg-slate-100" : "hover:bg-white/5"
|
||||||
)}
|
)}
|
||||||
@ -481,24 +503,46 @@ export function ChatView() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-12 h-12 rounded-xl flex items-center justify-center font-black transition-all",
|
"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"
|
? "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"
|
: 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>
|
||||||
<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 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>
|
||||||
<div className="flex-1 text-left min-w-0">
|
<div className="flex-1 text-left min-w-0">
|
||||||
<h4 className={cn(
|
<h4 className={cn(
|
||||||
"text-sm font-black truncate tracking-tight",
|
"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>
|
</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(
|
<p className={cn(
|
||||||
"text-[10px] uppercase font-black tracking-widest mt-0.5",
|
"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}
|
{contact.role}
|
||||||
</p>
|
</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",
|
"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"
|
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>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className={cn("font-black text-sm md:text-lg tracking-tight leading-none mb-1", isLight ? "text-slate-900" : "text-white")}>
|
<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.name ?? selectedContact.email ?? "Unknown"}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
{selectedContact.email ? (
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
<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>
|
<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")} />
|
<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")}>
|
<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
|
: legacy?.attachment
|
||||||
: null
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
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 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"
|
: "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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveAttachmentKind("PRODUCT")
|
setActiveAttachmentKind("PRODUCT")
|
||||||
setActiveAttachment(attachmentToRender)
|
setActiveAttachment(displayAttachment)
|
||||||
setAttachmentModalOpen(true)
|
setAttachmentModalOpen(true)
|
||||||
}}
|
}}
|
||||||
className={cn("w-full text-left bg-transparent border-0 p-0 m-0")}
|
className={cn("w-full text-left bg-transparent border-0 p-0 m-0")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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
|
// 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">
|
<div className="flex flex-col items-center justify-center px-1">
|
||||||
<Package className="w-5 h-5 text-indigo-400" />
|
<Package className="w-5 h-5 text-indigo-400" />
|
||||||
<div className="text-[10px] font-black mt-1 leading-none text-indigo-200">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-black truncate">
|
<div className="font-black truncate">
|
||||||
{attachmentToRender.title || msg.content}
|
{displayAttachment.title || msg.content}
|
||||||
</div>
|
</div>
|
||||||
{attachmentToRender.description ? (
|
{displayAttachment.description ? (
|
||||||
<div className={cn("text-xs opacity-90 mt-1 line-clamp-2")}>
|
<div className={cn("text-xs opacity-90 mt-1 line-clamp-2")}>
|
||||||
{attachmentToRender.description}
|
{displayAttachment.description}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{attachmentToRender.storeName ? (
|
{displayAttachment.storeName ? (
|
||||||
<div className={cn("text-[10px] font-black uppercase tracking-widest mt-1 opacity-80")}>
|
<div className={cn("text-[10px] font-black uppercase tracking-widest mt-1 opacity-80")}>
|
||||||
{attachmentToRender.storeName}
|
{displayAttachment.storeName}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
) : kindToRender === "ORDER" && attachmentToRender ? (
|
) : displayKind === "ORDER" && displayAttachment ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveAttachmentKind("ORDER")
|
setActiveAttachmentKind("ORDER")
|
||||||
setActiveAttachment(attachmentToRender)
|
setActiveAttachment(displayAttachment)
|
||||||
setAttachmentModalOpen(true)
|
setAttachmentModalOpen(true)
|
||||||
}}
|
}}
|
||||||
className={cn("w-full text-left bg-transparent border-0 p-0 m-0")}
|
className={cn("w-full text-left bg-transparent border-0 p-0 m-0")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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
|
// 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">
|
<div className="flex flex-col items-center justify-center px-1">
|
||||||
<ShoppingCart className="w-5 h-5 text-indigo-400" />
|
<ShoppingCart className="w-5 h-5 text-indigo-400" />
|
||||||
<div className="text-[10px] font-black mt-1 leading-none text-indigo-200">
|
<div className="text-[10px] font-black mt-1 leading-none text-indigo-200">
|
||||||
{attachmentToRender.orderNumber || "#"}
|
{displayAttachment.orderNumber || "#"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-black truncate">
|
<div className="font-black truncate">
|
||||||
Order {attachmentToRender.orderNumber || msg.content}
|
Order {displayAttachment.orderNumber || msg.content}
|
||||||
</div>
|
</div>
|
||||||
{attachmentToRender.customerName ? (
|
{displayAttachment.customerName ? (
|
||||||
<div className={cn("text-xs opacity-90 mt-1 line-clamp-1")}>
|
<div className={cn("text-xs opacity-90 mt-1 line-clamp-1")}>
|
||||||
{attachmentToRender.customerName}
|
{displayAttachment.customerName}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{attachmentToRender.total_price ? (
|
{displayAttachment.total_price ? (
|
||||||
<div className={cn("text-[10px] font-black uppercase tracking-widest mt-1 opacity-80")}>
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -46,6 +46,23 @@ interface ShopifyCustomerRecord {
|
|||||||
phone?: string
|
phone?: string
|
||||||
tags?: string
|
tags?: string
|
||||||
created_at?: 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
|
// Cache TTL (Time To Live) in milliseconds
|
||||||
@ -319,7 +336,8 @@ export async function updateCustomerCache(storeId: string, customers: ShopifyCus
|
|||||||
totalSpent: customer.total_spent || "0",
|
totalSpent: customer.total_spent || "0",
|
||||||
phone: customer.phone || null,
|
phone: customer.phone || null,
|
||||||
tags: customer.tags || 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({
|
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