fix: Separate JUNO CHAT, Stock fixed on both ends

This commit is contained in:
talatops 2026-04-11 19:45:38 +05:00
parent e49a0047aa
commit bbeb43adb1
23 changed files with 1109 additions and 143 deletions

View File

@ -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");

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "CachedCustomer" ADD COLUMN "defaultAddress" JSONB;

View File

@ -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

View File

@ -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 })

View File

@ -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,21 +106,57 @@ 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) {
const messages = await prisma.chatMessage.findMany({ return new NextResponse("Missing storeId", { status: 400 })
where: {
OR: [
{ senderId: session.user.id, receiverId: contactId },
{ senderId: contactId, receiverId: session.user.id }
]
},
orderBy: {
createdAt: "asc"
} }
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 }) return NextResponse.json({ messages })

View File

@ -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()
} }

View File

@ -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

View File

@ -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,

View File

@ -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
} }

View File

@ -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,

View File

@ -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,13 +51,12 @@ 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) => { 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 productData = sync.shopifyProductData || {}
const supplierQuantity = const supplierQuantity = supplierQuantityFromSyncedProductData(productData)
typeof productData.supplier_quantity === "number"
? productData.supplier_quantity
: undefined
return { return {
id: sync.id, id: sync.id,
syncId: sync.id, syncId: sync.id,
@ -71,19 +71,13 @@ export async function GET() {
companyName: sync.supplier.companyName companyName: sync.supplier.companyName
}, },
syncedAt: sync.acceptedAt || sync.createdAt, syncedAt: sync.acceptedAt || sync.createdAt,
isSynced: true, // Flag to indicate this is a synced product isSynced: true,
source: "synced", source: "synced",
offerQuantity: supplierQuantity, offerQuantity: supplierQuantity,
// Include full Shopify product data for reference
shopifyData: productData shopifyData: productData
} }
}) })
return NextResponse.json({ products: syncedOnly })
// 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 })
} }
// 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,

View File

@ -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: {

View File

@ -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">

View File

@ -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)

View File

@ -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>

View File

@ -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({

View 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
}

View 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()
})
})

View 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" }
}
}

View 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)
})
})

View 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
}