From bbeb43adb1373d7dde038a8c19715befd75dd67e Mon Sep 17 00:00:00 2001 From: talatops Date: Sat, 11 Apr 2026 19:45:38 +0500 Subject: [PATCH] fix: Separate JUNO CHAT, Stock fixed on both ends --- .../migration.sql | 8 + .../migration.sql | 2 + prisma/schema.prisma | 7 + .../79121e46-27b6-4f57-bd14-db2344f94111.jpeg | Bin 0 -> 4377 bytes .../df776f98-15f2-4476-94a9-2ea05465f90e.jpeg | Bin 0 -> 3204 bytes src/app/api/chat/contacts/route.ts | 92 ++++-- src/app/api/chat/messages/route.ts | 108 ++++++- src/app/api/products/sync/[syncId]/route.ts | 12 + src/app/api/products/sync/route.ts | 52 ++- src/app/api/shopify/customers/route.ts | 34 +- src/app/api/supplier/products/route.ts | 8 +- .../api/vendors/synced-products/[id]/route.ts | 7 +- src/app/api/vendors/synced-products/route.ts | 90 ++++-- src/app/api/webhooks/shopify/route.ts | 13 + src/app/dashboard/customers/page.tsx | 25 +- src/app/dashboard/inventory/page.tsx | 57 +++- src/components/chat/ChatView.tsx | 162 +++++++--- src/lib/cache.ts | 20 +- src/lib/chat/chatThreadAccess.ts | 69 ++++ .../autoLinkOfferToShopifyProduct.test.ts | 103 ++++++ .../products/autoLinkOfferToShopifyProduct.ts | 305 ++++++++++++++++++ src/lib/products/shopifyInventorySum.test.ts | 40 +++ src/lib/products/shopifyInventorySum.ts | 38 +++ 23 files changed, 1109 insertions(+), 143 deletions(-) create mode 100644 prisma/migrations/20260405183000_chat_message_store_context/migration.sql create mode 100644 prisma/migrations/20260411120000_cached_customer_default_address/migration.sql create mode 100644 public/uploads/supplier-products/cmnubvaap000h51o3vg6okl1y/79121e46-27b6-4f57-bd14-db2344f94111.jpeg create mode 100644 public/uploads/supplier-products/cmnubvaap000h51o3vg6okl1y/df776f98-15f2-4476-94a9-2ea05465f90e.jpeg create mode 100644 src/lib/chat/chatThreadAccess.ts create mode 100644 src/lib/products/autoLinkOfferToShopifyProduct.test.ts create mode 100644 src/lib/products/autoLinkOfferToShopifyProduct.ts create mode 100644 src/lib/products/shopifyInventorySum.test.ts create mode 100644 src/lib/products/shopifyInventorySum.ts diff --git a/prisma/migrations/20260405183000_chat_message_store_context/migration.sql b/prisma/migrations/20260405183000_chat_message_store_context/migration.sql new file mode 100644 index 0000000..5faf463 --- /dev/null +++ b/prisma/migrations/20260405183000_chat_message_store_context/migration.sql @@ -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"); diff --git a/prisma/migrations/20260411120000_cached_customer_default_address/migration.sql b/prisma/migrations/20260411120000_cached_customer_default_address/migration.sql new file mode 100644 index 0000000..3ab93a8 --- /dev/null +++ b/prisma/migrations/20260411120000_cached_customer_default_address/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CachedCustomer" ADD COLUMN "defaultAddress" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 92255a4..3d97165 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -137,6 +137,7 @@ model Store { flaggedEmails FlaggedEmail[] gmailInboundAutoReplies GmailInboundAutoReply[] templates Template[] + chatMessages ChatMessage[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -207,6 +208,8 @@ model CachedCustomer { totalSpent String? phone String? tags String? + /** Shopify REST `default_address` snapshot for vendor UI (city, country, etc.). */ + defaultAddress Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt lastSyncedAt DateTime @default(now()) @@ -591,17 +594,21 @@ model ChatMessage { id String @id @default(cuid()) senderId String receiverId String + /** Vendor store this thread belongs to (B2B JUNO CHAT). Null = legacy messages before per-store threading. */ + storeId String? kind ChatMessageKind @default(TEXT) content String @map("encryptedContent") @db.Text attachment Json? sender User @relation("SentMessages", fields: [senderId], references: [id]) receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id]) + store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) @@index([senderId, receiverId]) @@index([receiverId, senderId]) + @@index([senderId, receiverId, storeId]) } enum StorefrontChatSenderType { diff --git a/public/uploads/supplier-products/cmnubvaap000h51o3vg6okl1y/79121e46-27b6-4f57-bd14-db2344f94111.jpeg b/public/uploads/supplier-products/cmnubvaap000h51o3vg6okl1y/79121e46-27b6-4f57-bd14-db2344f94111.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..11b561bfda29c8517b8947391e23b4bd5023b734 GIT binary patch literal 4377 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 literal 0 HcmV?d00001 diff --git a/src/app/api/chat/contacts/route.ts b/src/app/api/chat/contacts/route.ts index d34ac02..b849d9c 100644 --- a/src/app/api/chat/contacts/route.ts +++ b/src/app/api/chat/contacts/route.ts @@ -2,6 +2,18 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" +/** One sidebar row per Connection (per vendor store ↔ supplier). */ +export type ChatContactRow = { + id: string + connectionId: string + storeId: string + storeName: string | null + name: string | null + email: string | null + image: string | null + role: "VENDOR" | "SUPPLIER" +} + export async function GET() { try { const session = await auth() @@ -13,30 +25,41 @@ export async function GET() { where: { id: session.user.id }, include: { supplierProfile: true, - } + }, }) if (!user) { return new NextResponse("User not found", { status: 404 }) } - interface Contact { - id: string - name: string | null - email: string | null - image: string | null - role: string - } - let contacts: Contact[] = [] + let contacts: ChatContactRow[] = [] if (user.role === "VENDOR") { - // Fetch connected suppliers + // Only show chats for the globally selected store (matches dashboard store switcher). + let scopedStoreId = user.activeStoreId + if (!scopedStoreId) { + const active = await prisma.store.findFirst({ + where: { userId: user.id, isActive: true }, + select: { id: true }, + }) + scopedStoreId = active?.id ?? null + } + const connections = await prisma.connection.findMany({ where: { - store: { userId: user.id }, - status: "CONNECTED" + store: { + userId: user.id, + ...(scopedStoreId ? { id: scopedStoreId } : {}), + }, + status: "CONNECTED", }, include: { + store: { + select: { + id: true, + businessName: true, + }, + }, supplier: { include: { user: { @@ -45,26 +68,29 @@ export async function GET() { name: true, email: true, image: true, - } - } - } - } - } + }, + }, + }, + }, + }, }) - type VendorSideConnection = (typeof connections)[number] - contacts = connections.map((c: VendorSideConnection) => ({ + type C = (typeof connections)[number] + contacts = connections.map((c: C) => ({ id: c.supplier.user.id, + connectionId: c.id, + storeId: c.store.id, + storeName: c.store.businessName, name: c.supplier.companyName || c.supplier.user.name, email: c.supplier.user.email, image: c.supplier.user.image, - role: "SUPPLIER" + role: "SUPPLIER" as const, })) + contacts.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "") || (a.storeName ?? "").localeCompare(b.storeName ?? "")) } else if (user.role === "SUPPLIER" && user.supplierProfile) { - // Fetch connected vendors const connections = await prisma.connection.findMany({ where: { supplierId: user.supplierProfile.id, - status: "CONNECTED" + status: "CONNECTED", }, include: { store: { @@ -75,20 +101,24 @@ export async function GET() { name: true, email: true, image: true, - } - } - } - } - } + }, + }, + }, + }, + }, }) - type SupplierSideConnection = (typeof connections)[number] - contacts = connections.map((c: SupplierSideConnection) => ({ + type C = (typeof connections)[number] + contacts = connections.map((c: C) => ({ id: c.store.user.id, - name: c.store.businessName || c.store.user.name, + connectionId: c.id, + storeId: c.store.id, + storeName: c.store.businessName, + name: c.store.user.name || c.store.businessName, email: c.store.user.email, image: c.store.user.image, - role: "VENDOR" + role: "VENDOR" as const, })) + contacts.sort((a, b) => (a.storeName ?? "").localeCompare(b.storeName ?? "") || (a.name ?? "").localeCompare(b.name ?? "")) } return NextResponse.json({ contacts }) diff --git a/src/app/api/chat/messages/route.ts b/src/app/api/chat/messages/route.ts index c7fdc8b..6211a7a 100644 --- a/src/app/api/chat/messages/route.ts +++ b/src/app/api/chat/messages/route.ts @@ -2,6 +2,11 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { sendPushNotification } from "@/lib/push" +import { + countConnectionsForPair, + hasChatConnectionForStore, + resolveB2bChatPair, +} from "@/lib/chat/chatThreadAccess" export async function POST(req: Request) { try { @@ -10,14 +15,34 @@ export async function POST(req: Request) { return new NextResponse("Unauthorized", { status: 401 }) } - const { receiverId, content, kind, attachment } = await req.json() + const body = await req.json() + const { receiverId, content, kind, attachment, storeId: storeIdRaw } = body if (!receiverId || typeof receiverId !== "string") { return new NextResponse("Missing fields", { status: 400 }) } + if (!storeIdRaw || typeof storeIdRaw !== "string") { + return new NextResponse("Missing storeId", { status: 400 }) + } + + const pair = await resolveB2bChatPair(session.user.id, receiverId) + if (!pair) { + return new NextResponse("Invalid chat participant", { status: 403 }) + } + + const allowed = await hasChatConnectionForStore( + pair.vendorUserId, + pair.supplierUserId, + storeIdRaw + ) + if (!allowed) { + return new NextResponse("Store not connected for this chat", { status: 403 }) + } + const CHAT_KINDS = ["TEXT", "PRODUCT", "ORDER"] as const - const normalizedKind = typeof kind === "string" && (CHAT_KINDS as readonly string[]).includes(kind) ? kind : "TEXT" + const normalizedKind = + typeof kind === "string" && (CHAT_KINDS as readonly string[]).includes(kind) ? kind : "TEXT" if (normalizedKind === "TEXT") { if (content != null && typeof content !== "string") { @@ -29,22 +54,37 @@ export async function POST(req: Request) { } } + const sessionUser = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { role: true }, + }) + + if ( + sessionUser?.role === "VENDOR" && + (normalizedKind === "PRODUCT" || normalizedKind === "ORDER") + ) { + const att = attachment as { storeId?: string } + if (!att.storeId || att.storeId !== storeIdRaw) { + return new NextResponse("Attachment store does not match chat store", { status: 400 }) + } + } + const message = await prisma.chatMessage.create({ data: { senderId: session.user.id, receiverId, + storeId: storeIdRaw, kind: normalizedKind as any, content: (typeof content === "string" ? content : "") as any, attachment: (normalizedKind === "TEXT" ? null : (attachment as any)) as any, - } as any + } as any, }) - // Send push notification to receiver try { await sendPushNotification(receiverId, { title: "New Message", body: `${session.user.name || "A user"} sent you a message.`, - url: "/dashboard/chat" // Correct to your chat dashboard path + url: "/dashboard/chat", }) } catch (pushError) { console.error("Failed to send push notification", pushError) @@ -66,22 +106,58 @@ export async function GET(req: Request) { const { searchParams } = new URL(req.url) const contactId = searchParams.get("contactId") + const storeId = searchParams.get("storeId") if (!contactId) { return new NextResponse("Missing contactId", { status: 400 }) } + if (!storeId) { + return new NextResponse("Missing storeId", { status: 400 }) + } - const messages = await prisma.chatMessage.findMany({ - where: { - OR: [ - { senderId: session.user.id, receiverId: contactId }, - { senderId: contactId, receiverId: session.user.id } - ] - }, - orderBy: { - createdAt: "asc" - } - }) + 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 }) } catch (error) { diff --git a/src/app/api/products/sync/[syncId]/route.ts b/src/app/api/products/sync/[syncId]/route.ts index 3d63047..b355da3 100644 --- a/src/app/api/products/sync/[syncId]/route.ts +++ b/src/app/api/products/sync/[syncId]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" +import { sumVariantInventoryQuantity } from "@/lib/products/shopifyInventorySum" /** * PUT /api/products/sync/[syncId] @@ -83,6 +84,17 @@ export async function PUT( if (action === "accept") { updateData.acceptedAt = new Date() + const base = { + ...(productSync.shopifyProductData && + typeof productSync.shopifyProductData === "object" + ? productSync.shopifyProductData + : {}), + } as Record + const initialSq = sumVariantInventoryQuantity(base.variants) + if (initialSq !== null) { + base.supplier_quantity = initialSq + } + updateData.shopifyProductData = base } else { updateData.rejectedAt = new Date() } diff --git a/src/app/api/products/sync/route.ts b/src/app/api/products/sync/route.ts index 37977d6..bbfba0e 100644 --- a/src/app/api/products/sync/route.ts +++ b/src/app/api/products/sync/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" +import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum" /** * GET /api/products/sync @@ -46,7 +47,20 @@ export async function GET() { } }) - return NextResponse.json({ syncs }) + const enriched = syncs.map((sync: any) => { + const productData = sync.shopifyProductData || {} + let supplierAvailable: number | null = null + if (sync.status === "ACCEPTED") { + const sq = supplierQuantityFromSyncedProductData(productData) + if (sq !== undefined) supplierAvailable = sq + } + return { + ...sync, + supplierAvailableQuantity: supplierAvailable, + } + }) + + return NextResponse.json({ syncs: enriched }) } catch (error) { console.error("Error fetching product syncs:", error) return NextResponse.json( @@ -154,7 +168,41 @@ export async function POST(req: Request) { { status: 400 } ) } - // If rejected, allow creating a new request + // REJECTED: reuse row (unique on store+supplier+shopifyProductId) instead of create + if (existingSync.status === "REJECTED") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const productSync = await (prisma as any).productSync.update({ + where: { id: existingSync.id }, + data: { + shopifyProductTitle, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + shopifyProductData: shopifyProductData + ? (shopifyProductData as any) + : undefined, + status: "PENDING", + requestedBy: session.user.id, + rejectedAt: null, + acceptedAt: null, + }, + }) + + try { + const { notifyProductSyncRequested } = await import("@/lib/notifications") + await notifyProductSyncRequested( + supplier.user.id, + vendorStore.businessName, + shopifyProductTitle, + productSync.id + ) + } catch (notifError) { + console.error("Error creating notification:", notifError) + } + + return NextResponse.json({ + message: "Product sync request sent successfully", + sync: productSync, + }) + } } // Create sync request diff --git a/src/app/api/shopify/customers/route.ts b/src/app/api/shopify/customers/route.ts index 49784ad..6027872 100644 --- a/src/app/api/shopify/customers/route.ts +++ b/src/app/api/shopify/customers/route.ts @@ -3,6 +3,37 @@ import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { isCacheFresh, getCachedCustomers, updateCustomerCache } from "@/lib/cache" +function defaultAddressFromCacheJson(raw: unknown): { + city: string + country_name: string + country: string + province: string + address1: string +} | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null + const d = raw as Record + const out = { + city: typeof d.city === "string" ? d.city : "", + country_name: + typeof d.country_name === "string" + ? d.country_name + : typeof d.country === "string" + ? d.country + : "", + country: typeof d.country === "string" ? d.country : "", + province: typeof d.province === "string" ? d.province : "", + address1: typeof d.address1 === "string" ? d.address1 : "", + } + const has = + out.city.trim() || + out.country_name.trim() || + out.country.trim() || + out.province.trim() || + out.address1.trim() + if (!has) return null + return out +} + export async function GET() { try { const session = await auth() @@ -41,7 +72,8 @@ export async function GET() { total_spent: customer.totalSpent, phone: customer.phone, tags: customer.tags, - created_at: customer.shopifyCreatedAt?.toISOString() || customer.createdAt.toISOString() + created_at: customer.shopifyCreatedAt?.toISOString() || customer.createdAt.toISOString(), + default_address: defaultAddressFromCacheJson(customer.defaultAddress), })) return NextResponse.json({ customers: transformedCustomers, diff --git a/src/app/api/supplier/products/route.ts b/src/app/api/supplier/products/route.ts index 1e703be..c4c4898 100644 --- a/src/app/api/supplier/products/route.ts +++ b/src/app/api/supplier/products/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" +import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum" /** * GET /api/supplier/products @@ -56,10 +57,7 @@ export async function GET() { // Transform synced products to match product format const syncedProductsFormatted = syncedProducts.map((sync: any) => { const productData = sync.shopifyProductData || {} - const supplierQuantity = - typeof productData.supplier_quantity === "number" - ? productData.supplier_quantity - : undefined + const supplierQuantity = supplierQuantityFromSyncedProductData(productData) return { id: `synced-${sync.id}`, // Unique ID for synced products @@ -77,7 +75,7 @@ export async function GET() { businessName: sync.store.businessName }, isSynced: true, // Flag to indicate this is a synced product from vendor - availableQuantity: supplierQuantity, + availableQuantity: supplierQuantity ?? null, // Include full Shopify product data for reference shopifyData: productData } diff --git a/src/app/api/vendors/synced-products/[id]/route.ts b/src/app/api/vendors/synced-products/[id]/route.ts index 9f195ed..30b1444 100644 --- a/src/app/api/vendors/synced-products/[id]/route.ts +++ b/src/app/api/vendors/synced-products/[id]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" +import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum" /** * GET /api/vendors/synced-products/[id] @@ -54,10 +55,8 @@ export async function GET( if (sync) { const productData = sync.shopifyProductData || {} - const supplierQuantity = - typeof productData.supplier_quantity === "number" - ? productData.supplier_quantity - : undefined + const supplierQuantity = supplierQuantityFromSyncedProductData(productData) + const product = { id: sync.id, syncId: sync.id, diff --git a/src/app/api/vendors/synced-products/route.ts b/src/app/api/vendors/synced-products/route.ts index 4bf1c28..9f00e98 100644 --- a/src/app/api/vendors/synced-products/route.ts +++ b/src/app/api/vendors/synced-products/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { isVendorPortalRole } from "@/lib/auth-roles" import { prisma } from "@/lib/prisma" +import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum" /** * GET /api/vendors/synced-products @@ -50,40 +51,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 = - 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 + // Get supplier-provided products for this store (offers list) 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 }) + const syncedOnly = 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, + source: "synced", + offerQuantity: supplierQuantity, + shopifyData: productData + } + }) + return NextResponse.json({ products: syncedOnly }) } // eslint-disable-next-line @typescript-eslint/no-explicit-any const supplierOffers = await (prisma as any).supplierProductOffer.findMany({ @@ -106,6 +100,11 @@ export async function GET() { id: true, companyName: true } + }, + connection: { + select: { + status: true + } } }, orderBy: { @@ -113,6 +112,33 @@ export async function GET() { } }) + // Transform syncs into product-like objects for display + const syncedProducts = acceptedSyncs.map((sync: any) => { + const productData = sync.shopifyProductData || {} + const supplierQuantity = supplierQuantityFromSyncedProductData(productData) + + return { + id: sync.id, + syncId: sync.id, + shopifyProductId: sync.shopifyProductId, + title: sync.shopifyProductTitle, + description: productData.body_html || productData.description || null, + price: productData.variants?.[0]?.price || "0", + sku: productData.variants?.[0]?.sku || null, + imageUrl: productData.image?.src || productData.images?.[0]?.src || null, + supplier: { + id: sync.supplier.id, + companyName: sync.supplier.companyName + }, + syncedAt: sync.acceptedAt || sync.createdAt, + isSynced: true, // Flag to indicate this is a synced product + source: "synced", + offerQuantity: supplierQuantity, + // Include full Shopify product data for reference + shopifyData: productData + } + }) + const supplierProducts = supplierOffers.map((offer: any) => ({ id: offer.id, syncId: null, diff --git a/src/app/api/webhooks/shopify/route.ts b/src/app/api/webhooks/shopify/route.ts index df503e2..8f4f3c3 100644 --- a/src/app/api/webhooks/shopify/route.ts +++ b/src/app/api/webhooks/shopify/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server" import { prisma } from "@/lib/prisma" import { updateSingleProduct, updateSingleOrder } from "@/lib/cache" +import { autoLinkOfferToShopifyProduct } from "@/lib/products/autoLinkOfferToShopifyProduct" import crypto from "crypto" import { logger } from "@/lib/logger" @@ -93,6 +94,18 @@ export async function POST(req: NextRequest) { case "products/create": logger.info(`[Webhook] Processing product ${topic}: ${body.id}`) await updateSingleProduct(store.id, body) + try { + const linkResult = await autoLinkOfferToShopifyProduct(store.id, body) + if (linkResult.linked) { + logger.info(`[Webhook] Auto-linked product to supplier offer`, { + storeId: store.id, + productSyncId: linkResult.productSyncId, + supplierId: linkResult.supplierId, + }) + } + } catch (autoLinkErr) { + logger.error("[Webhook] autoLinkOfferToShopifyProduct error (ignored)", autoLinkErr) + } // Update sync metadata to invalidate cache await prisma.syncMetadata.upsert({ where: { diff --git a/src/app/dashboard/customers/page.tsx b/src/app/dashboard/customers/page.tsx index b1ceb92..3ad6b8c 100644 --- a/src/app/dashboard/customers/page.tsx +++ b/src/app/dashboard/customers/page.tsx @@ -23,11 +23,28 @@ interface ShopifyCustomer { last_order_id: number | null created_at: string default_address: { - city: string - country_name: string + city?: string + country_name?: string + country?: string + province?: string + address1?: string } | null } +function formatCustomerLocation(default_address: ShopifyCustomer["default_address"]): string { + if (!default_address) return "Not provided" + const city = (default_address.city || "").trim() + const country = (default_address.country_name || default_address.country || "").trim() + if (city && country) return `${city}, ${country}` + if (city) return city + if (country) return country + const prov = (default_address.province || "").trim() + if (prov) return prov + const line = (default_address.address1 || "").trim() + if (line) return line + return "Not provided" +} + export default function CustomersPage() { const [customers, setCustomers] = useState([]) const [loading, setLoading] = useState(true) @@ -140,7 +157,7 @@ export default function CustomersPage() {
- {customer.default_address ? `${customer.default_address.city}, ${customer.default_address.country_name}` : "Not provided"} + {formatCustomerLocation(customer.default_address)}
@@ -156,7 +173,7 @@ export default function CustomersPage() {
- {customer.default_address ? `${customer.default_address.city}, ${customer.default_address.country_name}` : "Not provided"} + {formatCustomerLocation(customer.default_address)}
diff --git a/src/app/dashboard/inventory/page.tsx b/src/app/dashboard/inventory/page.tsx index be9f6e0..6526a6d 100644 --- a/src/app/dashboard/inventory/page.tsx +++ b/src/app/dashboard/inventory/page.tsx @@ -56,6 +56,8 @@ interface ProductSync { id: string companyName: string } + /** Set by GET /api/products/sync when status is ACCEPTED (from JSON or offer match). */ + supplierAvailableQuantity?: number | null } interface Supplier { @@ -68,6 +70,27 @@ interface Supplier { connectionStatus: "NONE" | "PENDING" | "CONNECTED" | "REJECTED" } +/** Sum variant inventory_quantity from Shopify REST/cache payload. */ +function totalShopifyInventory(variants: unknown): string { + if (!Array.isArray(variants) || variants.length === 0) return "—" + let total = 0 + let hasQty = false + for (const v of variants) { + if (v && typeof v === "object" && v !== null && "inventory_quantity" in v) { + const raw = (v as { inventory_quantity?: unknown }).inventory_quantity + if (raw !== null && raw !== undefined && raw !== "") { + const n = Number(raw) + if (!Number.isNaN(n)) { + total += n + hasQty = true + } + } + } + } + if (!hasQty) return "—" + return String(total) +} + export default function InventoryPage() { const [products, setProducts] = useState([]) const [productSyncs, setProductSyncs] = useState>(new Map()) @@ -471,7 +494,12 @@ export default function InventoryPage() { Product Status - Inventory + + Shopify stock + + + Supplier stock + Supplier Sync Actions @@ -504,11 +532,32 @@ export default function InventoryPage() { -
- {product.variants.length} Variants - Total Sync Stock +
+ + {totalShopifyInventory(product.variants)} + + + {product.variants.length} variant + {product.variants.length !== 1 ? "s" : ""} +
+ + {(() => { + const sync = getProductSyncStatus(product.id) + if (!sync || sync.status !== "ACCEPTED") { + return ( + — + ) + } + const q = sync.supplierAvailableQuantity + return ( + + {q != null ? String(q) : "—"} + + ) + })()} + {(() => { const sync = isProductSynced(product.id) diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index a9e897a..8a0e8db 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -23,8 +23,11 @@ import { ChatAttachmentModal } from "@/components/chat/ChatAttachmentModal" interface Contact { id: string - name: string - email: string + connectionId: string + storeId: string + storeName: string | null + name: string | null + email: string | null image: string | null role: string } @@ -33,6 +36,7 @@ interface Message { id: string senderId: string receiverId: string + storeId?: string | null content: string kind?: "TEXT" | "PRODUCT" | "ORDER" | string | null attachment?: any @@ -138,13 +142,16 @@ export function ChatView() { setLoadingMessages(true) setMessagesError(null) } - const res = await fetch(`/api/chat/messages?contactId=${contact.id}`) + const res = await fetch( + `/api/chat/messages?contactId=${encodeURIComponent(contact.id)}&storeId=${encodeURIComponent(contact.storeId)}` + ) if (res.ok) { const data = await res.json() const typedMessages = data.messages.map((m: any) => ({ id: m.id, senderId: m.senderId, receiverId: m.receiverId, + storeId: m.storeId ?? null, content: m.content ?? "", kind: m.kind ?? "TEXT", attachment: m.attachment ?? null, @@ -182,7 +189,7 @@ export function ChatView() { fetchMessages(selectedContact, true) }, POLL_INTERVAL_MS) return () => clearInterval(interval) - }, [selectedContact?.id]) + }, [selectedContact?.connectionId]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) @@ -201,6 +208,8 @@ export function ChatView() { }, [attachOpen]) const openAttach = async () => { + if (!selectedContact) return + const threadStoreId = selectedContact.storeId setAttachOpen(true) setAttachLoading(true) setAttachOrders([]) @@ -226,11 +235,17 @@ export function ChatView() { ]) if (ordersRes.ok) { const ordersData = await ordersRes.json() - setAttachOrders(ordersData.orders || []) + const allOrders = ordersData.orders || [] + setAttachOrders( + allOrders.filter((o: AttachOrder) => o.storeId === threadStoreId) + ) } if (productsRes.ok) { const productsData = await productsRes.json() - setAttachProducts(productsData.products || []) + const allProducts = productsData.products || [] + setAttachProducts( + allProducts.filter((p: AttachProduct) => p.storeId === threadStoreId) + ) } } } catch { @@ -249,6 +264,7 @@ export function ChatView() { total_price: order.total_price, currency: order.currency ?? null, storeName: order.storeName ?? null, + storeId: order.storeId, shopifyOrderId: order.id }) const summary = `Order ${order.orderNumber ?? order.name}${order.customerName ? ` - ${order.customerName}` : ""}${order.storeName ? ` · ${order.storeName}` : ""}` @@ -263,6 +279,7 @@ export function ChatView() { description: product.description ?? null, imageUrl: product.imageUrl ?? null, storeName: product.storeName ?? null, + storeId: product.storeId, productId: product.id }) const summary = `Product: ${product.title}${product.storeName ? ` · ${product.storeName}` : ""}` @@ -282,6 +299,7 @@ export function ChatView() { setSendError(null) const payload: any = { receiverId: selectedContact.id, + storeId: selectedContact.storeId, content: newMessage } if (hasAttachment) { @@ -300,6 +318,7 @@ export function ChatView() { id: msg.id, senderId: msg.senderId, receiverId: msg.receiverId, + storeId: msg.storeId ?? selectedContact.storeId, content: newMessage, kind: msg.kind ?? "TEXT", attachment: msg.attachment ?? null, @@ -401,10 +420,13 @@ export function ChatView() { return null } - const filteredContacts = contacts.filter(c => - c.name.toLowerCase().includes(searchQuery.toLowerCase()) || - c.email.toLowerCase().includes(searchQuery.toLowerCase()) - ) + const q = searchQuery.toLowerCase() + const filteredContacts = contacts.filter(c => { + const name = (c.name ?? "").toLowerCase() + const email = (c.email ?? "").toLowerCase() + const storeName = (c.storeName ?? "").toLowerCase() + return name.includes(q) || email.includes(q) || storeName.includes(q) + }) return (
{filteredContacts.map(contact => ( - ) : kindToRender === "ORDER" && attachmentToRender ? ( + ) : displayKind === "ORDER" && displayAttachment ? (