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 0000000..11b561b Binary files /dev/null and b/public/uploads/supplier-products/cmnubvaap000h51o3vg6okl1y/79121e46-27b6-4f57-bd14-db2344f94111.jpeg differ diff --git a/public/uploads/supplier-products/cmnubvaap000h51o3vg6okl1y/df776f98-15f2-4476-94a9-2ea05465f90e.jpeg b/public/uploads/supplier-products/cmnubvaap000h51o3vg6okl1y/df776f98-15f2-4476-94a9-2ea05465f90e.jpeg new file mode 100644 index 0000000..af4f2d9 Binary files /dev/null and b/public/uploads/supplier-products/cmnubvaap000h51o3vg6okl1y/df776f98-15f2-4476-94a9-2ea05465f90e.jpeg differ 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 ? (