Storefront OTP session settings, inbox empty-session filter, low stock, inventory load-stock, migrations
Made-with: Cursor
This commit is contained in:
parent
7c817dd68b
commit
ffbeaf493f
@ -32,6 +32,7 @@ services:
|
||||
# Override DATABASE_URL to use internal docker network hostname
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-dropshipping}?schema=public
|
||||
NODE_ENV: production
|
||||
# P3009 one-time: PRISMA_MIGRATE_RESOLVE_ROLLED_BACK or PRISMA_MIGRATE_RESOLVE_APPLIED (see docker-compose.yml comments)
|
||||
volumes:
|
||||
- ./public/uploads/supplier-products:/app/public/uploads/supplier-products
|
||||
depends_on:
|
||||
|
||||
@ -34,6 +34,9 @@ services:
|
||||
# Override DATABASE_URL to use internal docker network hostname
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-dropshipping}?schema=public
|
||||
NODE_ENV: production
|
||||
# P3009 recovery (one-time): set ONE of these on app, restart, verify migrate deploy, then remove.
|
||||
#PRISMA_MIGRATE_RESOLVE_ROLLED_BACK: "20260412180000_supplier_automations"
|
||||
# PRISMA_MIGRATE_RESOLVE_APPLIED: "20260412180000_supplier_automations"
|
||||
volumes:
|
||||
- ./public/uploads/supplier-products:/app/public/uploads/supplier-products
|
||||
- ./public/uploads/gmail-reply-logos:/app/public/uploads/gmail-reply-logos
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
set -e
|
||||
|
||||
echo "Waiting for database to be ready..."
|
||||
# Wait for database to be available (already handled by depends_on in docker-compose, but extra safety)
|
||||
# Use Node.js to check database connectivity since we have pg available
|
||||
until node -e "
|
||||
const { Client } = require('pg');
|
||||
const client = new Client({ connectionString: process.env.DATABASE_URL });
|
||||
@ -15,8 +13,42 @@ client.connect()
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Syncing database schema with Prisma (db push)..."
|
||||
npx prisma db push --accept-data-loss
|
||||
# Optional: recover from P3009 / failed migration (set in compose or .env, then remove after success).
|
||||
# Rolled back = migration will run again from scratch (only if DB objects from that migration are gone or safe to re-run).
|
||||
if [ -n "$PRISMA_MIGRATE_RESOLVE_ROLLED_BACK" ]; then
|
||||
echo "prisma migrate resolve --rolled-back $PRISMA_MIGRATE_RESOLVE_ROLLED_BACK"
|
||||
npx prisma migrate resolve --rolled-back "$PRISMA_MIGRATE_RESOLVE_ROLLED_BACK" ||
|
||||
echo "[entrypoint] resolve --rolled-back skipped (not failed state or already cleared)"
|
||||
fi
|
||||
|
||||
# Optional: mark a migration as already applied when the DB matches but _prisma_migrations recorded a failure.
|
||||
if [ -n "$PRISMA_MIGRATE_RESOLVE_APPLIED" ]; then
|
||||
echo "prisma migrate resolve --applied $PRISMA_MIGRATE_RESOLVE_APPLIED"
|
||||
npx prisma migrate resolve --applied "$PRISMA_MIGRATE_RESOLVE_APPLIED" ||
|
||||
echo "[entrypoint] resolve --applied skipped (already applied or invalid)"
|
||||
fi
|
||||
|
||||
# Clear legacy low-stock rows when the table exists (avoids NOT NULL add failures if db push was used earlier).
|
||||
if [ "${PRISMA_PRE_MIGRATE_CLEANUP:-1}" != "0" ]; then
|
||||
echo "Prisma pre-migrate: clearing LowStockAlertState if table exists..."
|
||||
npx prisma db execute --file scripts/prisma-pre-migrate-cleanup.sql 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# P3009: failed row for this migration blocks deploy forever. Migration SQL is idempotent; safe to clear and re-apply.
|
||||
# No-op when the migration is not in a failed state (resolve exits non-zero → ignored).
|
||||
echo "Prisma: clear failed supplier_automations lock if present (P3009)..."
|
||||
npx prisma migrate resolve --rolled-back "20260412180000_supplier_automations" 2>/dev/null || true
|
||||
|
||||
echo "Applying Prisma migrations (migrate deploy)..."
|
||||
if ! npx prisma migrate deploy; then
|
||||
echo ""
|
||||
echo "migrate deploy failed. If you see P3009 (failed migrations), fix _prisma_migrations then restart."
|
||||
echo "Examples (run on host with DATABASE_URL set, or docker compose exec app sh -lc '...'):"
|
||||
echo " npx prisma migrate resolve --rolled-back 20260412180000_supplier_automations"
|
||||
echo " npx prisma migrate resolve --applied 20260412180000_supplier_automations"
|
||||
echo "Or set env on the app service once: PRISMA_MIGRATE_RESOLVE_ROLLED_BACK or PRISMA_MIGRATE_RESOLVE_APPLIED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Regenerating Prisma Client..."
|
||||
npx prisma generate
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:gmail-auto-reply": "set -e; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml up -d; export $(cat tests/gmail-auto-reply/env.test | grep -v '^#' | xargs); docker compose -f tests/gmail-auto-reply/docker-compose.test.yml exec -T db sh -lc \"psql -U postgres -d dropshipping -c 'CREATE EXTENSION IF NOT EXISTS vector;'\"; npx prisma db push --accept-data-loss; npx tsx tests/gmail-auto-reply/seed.ts; vitest run tests/gmail-auto-reply/run.test.ts; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml down -v",
|
||||
"test:gmail-auto-reply": "set -e; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml up -d; export $(cat tests/gmail-auto-reply/env.test | grep -v '^#' | xargs); docker compose -f tests/gmail-auto-reply/docker-compose.test.yml exec -T db sh -lc \"psql -U postgres -d dropshipping -c 'CREATE EXTENSION IF NOT EXISTS vector;'\"; npx prisma migrate deploy; npx tsx tests/gmail-auto-reply/seed.ts; vitest run tests/gmail-auto-reply/run.test.ts; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml down -v",
|
||||
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
|
||||
"db:reset": "prisma migrate reset",
|
||||
"db:apply-gmail-reply-theme-column": "prisma db execute --file prisma/migrations/20260412160000_store_gmail_reply_theme/migration.sql",
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SupplierAutomationKind" AS ENUM ('LOW_STOCK', 'ASK_SUPPLIER');
|
||||
-- Idempotent: safe when objects already exist (partial / db push / re-apply after resolve).
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Store" ADD COLUMN "lowStockThreshold" INTEGER;
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TYPE "SupplierAutomationKind" AS ENUM ('LOW_STOCK', 'ASK_SUPPLIER');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LowStockAlertState" (
|
||||
ALTER TABLE "Store" ADD COLUMN IF NOT EXISTS "lowStockThreshold" INTEGER;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "LowStockAlertState" (
|
||||
"id" TEXT NOT NULL,
|
||||
"storeId" TEXT NOT NULL,
|
||||
"shopifyProductId" TEXT NOT NULL,
|
||||
@ -17,8 +21,7 @@ CREATE TABLE "LowStockAlertState" (
|
||||
CONSTRAINT "LowStockAlertState_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SupplierAutomationLog" (
|
||||
CREATE TABLE IF NOT EXISTS "SupplierAutomationLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"storeId" TEXT NOT NULL,
|
||||
"kind" "SupplierAutomationKind" NOT NULL,
|
||||
@ -31,20 +34,24 @@ CREATE TABLE "SupplierAutomationLog" (
|
||||
CONSTRAINT "SupplierAutomationLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LowStockAlertState_storeId_shopifyProductId_key" ON "LowStockAlertState"("storeId", "shopifyProductId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "LowStockAlertState_storeId_shopifyProductId_key" ON "LowStockAlertState"("storeId", "shopifyProductId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LowStockAlertState_storeId_idx" ON "LowStockAlertState"("storeId");
|
||||
CREATE INDEX IF NOT EXISTS "LowStockAlertState_storeId_idx" ON "LowStockAlertState"("storeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupplierAutomationLog_storeId_kind_createdAt_idx" ON "SupplierAutomationLog"("storeId", "kind", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "SupplierAutomationLog_storeId_kind_createdAt_idx" ON "SupplierAutomationLog"("storeId", "kind", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupplierAutomationLog_storeId_shopifyProductId_supplierUserId_createdAt_idx" ON "SupplierAutomationLog"("storeId", "shopifyProductId", "supplierUserId", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "SupplierAutomationLog_storeId_shopifyProductId_supplierUserId_createdAt_idx" ON "SupplierAutomationLog"("storeId", "shopifyProductId", "supplierUserId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LowStockAlertState" ADD CONSTRAINT "LowStockAlertState_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "LowStockAlertState" ADD CONSTRAINT "LowStockAlertState_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SupplierAutomationLog" ADD CONSTRAINT "SupplierAutomationLog_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "SupplierAutomationLog" ADD CONSTRAINT "SupplierAutomationLog_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
-- Low-stock dedupe is per supplier (supplier-reported quantity vs threshold).
|
||||
DELETE FROM "LowStockAlertState";
|
||||
|
||||
DROP INDEX IF EXISTS "LowStockAlertState_storeId_shopifyProductId_key";
|
||||
|
||||
ALTER TABLE "LowStockAlertState" RENAME COLUMN "lastSeenTotalQty" TO "lastSeenSupplierQty";
|
||||
|
||||
ALTER TABLE "LowStockAlertState" ADD COLUMN "supplierId" TEXT NOT NULL;
|
||||
|
||||
ALTER TABLE "LowStockAlertState" ADD CONSTRAINT "LowStockAlertState_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "SupplierProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE UNIQUE INDEX "LowStockAlertState_storeId_shopifyProductId_supplierId_key" ON "LowStockAlertState"("storeId", "shopifyProductId", "supplierId");
|
||||
|
||||
CREATE INDEX "LowStockAlertState_supplierId_idx" ON "LowStockAlertState"("supplierId");
|
||||
@ -0,0 +1,24 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'SHOPIFY_LOW_STOCK';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LowStockShopifyVendorAlertState" (
|
||||
"id" TEXT NOT NULL,
|
||||
"storeId" TEXT NOT NULL,
|
||||
"shopifyProductId" TEXT NOT NULL,
|
||||
"lastSeenShopifyTotal" INTEGER,
|
||||
"lastNotifiedBelowAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LowStockShopifyVendorAlertState_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LowStockShopifyVendorAlertState_storeId_shopifyProductId_key" ON "LowStockShopifyVendorAlertState"("storeId", "shopifyProductId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LowStockShopifyVendorAlertState_storeId_idx" ON "LowStockShopifyVendorAlertState"("storeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LowStockShopifyVendorAlertState" ADD CONSTRAINT "LowStockShopifyVendorAlertState_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Store" ADD COLUMN "storefrontOtpSessionMinutes" INTEGER NOT NULL DEFAULT 60;
|
||||
@ -0,0 +1,2 @@
|
||||
-- Optional second threshold for Shopify on-hand vs supplier-reported low-stock automation.
|
||||
ALTER TABLE "Store" ADD COLUMN IF NOT EXISTS "lowStockShopifyThreshold" INTEGER;
|
||||
@ -109,6 +109,8 @@ model Store {
|
||||
|
||||
// Customer-facing storefront widget (floating chat) enablement.
|
||||
storefrontChatEnabled Boolean @default(false)
|
||||
/** After OTP verify, widget session length (minutes). Clamped server-side (e.g. 5–10080). Default 60. */
|
||||
storefrontOtpSessionMinutes Int @default(60)
|
||||
storefrontChatName String?
|
||||
storefrontChatTagline String?
|
||||
storefrontChatBrandColor String?
|
||||
@ -133,10 +135,15 @@ model Store {
|
||||
gmailReplyTheme Json?
|
||||
|
||||
/**
|
||||
* When set (non-null), low-stock automation is on: sum of variant inventory_quantity below this
|
||||
* triggers a B2B chat message to the ProductSync-linked supplier(s).
|
||||
* When set, supplier-reported stock (ProductSync snapshot) below this value can trigger
|
||||
* B2B low-stock chat to linked suppliers. Null = off for this signal.
|
||||
*/
|
||||
lowStockThreshold Int?
|
||||
lowStockThreshold Int?
|
||||
/**
|
||||
* When set, total Shopify variant inventory (sum of inventory_quantity) below this value
|
||||
* triggers an in-app notification to the vendor (restock / Load stock). Null = off.
|
||||
*/
|
||||
lowStockShopifyThreshold Int?
|
||||
|
||||
// Relations
|
||||
connections Connection[]
|
||||
@ -157,6 +164,7 @@ model Store {
|
||||
templates Template[]
|
||||
chatMessages ChatMessage[]
|
||||
lowStockAlertStates LowStockAlertState[]
|
||||
lowStockShopifyVendorAlertStates LowStockShopifyVendorAlertState[]
|
||||
supplierAutomationLogs SupplierAutomationLog[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@ -165,19 +173,39 @@ model Store {
|
||||
@@index([userId]) // Add index for performance
|
||||
}
|
||||
|
||||
/** Dedupes low-stock B2B notifications per store + Shopify product while stock stays below threshold. */
|
||||
/** Dedupes low-stock B2B notifications per store + product + supplier while supplier-reported qty stays below threshold. */
|
||||
model LowStockAlertState {
|
||||
id String @id @default(cuid())
|
||||
storeId String
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
shopifyProductId String
|
||||
lastSeenTotalQty Int?
|
||||
/** Set when we sent a low-stock alert; cleared when total qty is back >= threshold. */
|
||||
supplierId String
|
||||
supplier SupplierProfile @relation(fields: [supplierId], references: [id], onDelete: Cascade)
|
||||
lastSeenSupplierQty Int?
|
||||
/** Set when we sent a low-stock alert; cleared when supplier qty is back >= threshold. */
|
||||
lastNotifiedBelowAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([storeId, shopifyProductId, supplierId])
|
||||
@@index([storeId])
|
||||
@@index([supplierId])
|
||||
}
|
||||
|
||||
/** Dedupes Shopify low-stock vendor notifications per store + product while total stays below threshold. */
|
||||
model LowStockShopifyVendorAlertState {
|
||||
id String @id @default(cuid())
|
||||
storeId String
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
shopifyProductId String
|
||||
lastSeenShopifyTotal Int?
|
||||
/** Set when we created a vendor notification; cleared when Shopify total is back >= threshold. */
|
||||
lastNotifiedBelowAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([storeId, shopifyProductId])
|
||||
@@index([storeId])
|
||||
}
|
||||
@ -313,6 +341,7 @@ model SupplierProfile {
|
||||
invitations Invitation[]
|
||||
productSyncs ProductSync[]
|
||||
supplierProductOffers SupplierProductOffer[]
|
||||
lowStockAlertStates LowStockAlertState[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@ -460,6 +489,7 @@ enum NotificationType {
|
||||
PRODUCT_SYNC_REQUESTED
|
||||
PRODUCT_SYNC_ACCEPTED
|
||||
PRODUCT_SYNC_REJECTED
|
||||
SHOPIFY_LOW_STOCK
|
||||
}
|
||||
|
||||
model Notification {
|
||||
|
||||
7
scripts/prisma-pre-migrate-cleanup.sql
Normal file
7
scripts/prisma-pre-migrate-cleanup.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- Safe before migrate deploy: legacy LowStockAlertState rows block NOT NULL supplierId if tooling used db push.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public."LowStockAlertState"') IS NOT NULL THEN
|
||||
EXECUTE 'DELETE FROM "LowStockAlertState"';
|
||||
END IF;
|
||||
END $$;
|
||||
158
src/app/api/shopify/inventory/load-stock/route.ts
Normal file
158
src/app/api/shopify/inventory/load-stock/route.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { Prisma } from "@prisma/client"
|
||||
import { auth } from "@/auth"
|
||||
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||
import { loadStockRequestSchema } from "@/lib/api-schemas/business"
|
||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { mergeShopifyProductDataAfterLoadStock } from "@/lib/products/applyLoadStockSupplierQuantity"
|
||||
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||
import {
|
||||
adjustAvailableInventory,
|
||||
getFirstVariantInventoryItemId,
|
||||
resolveLocationIdForInventoryAdjust,
|
||||
} from "@/lib/shopify/inventoryAdjust"
|
||||
|
||||
class LoadStockHttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = "LoadStockHttpError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/shopify/inventory/load-stock
|
||||
* Vendor: increase Shopify sellable inventory for the first variant and decrement supplier stock on ProductSync.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || !isVendorPortalRole(session.user.role)) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const raw = await req.json().catch(() => ({}))
|
||||
const parsed = loadStockRequestSchema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return validationErrorResponse(parsed.error)
|
||||
}
|
||||
const { productSyncId, quantity } = parsed.data
|
||||
|
||||
const vendorStore = await prisma.store.findFirst({
|
||||
where: { userId: session.user.id, isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
shopifyDomain: true,
|
||||
shopifyAccessToken: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!vendorStore?.shopifyAccessToken || !vendorStore.shopifyDomain) {
|
||||
return NextResponse.json({ message: "Shopify store not connected." }, { status: 400 })
|
||||
}
|
||||
|
||||
const { shopifyAccessToken: token, shopifyDomain } = vendorStore
|
||||
|
||||
let shopifyAdjusted = false
|
||||
let newSupplierQty: number | undefined
|
||||
let compensate:
|
||||
| {
|
||||
inventoryItemId: number
|
||||
locationId: number
|
||||
}
|
||||
| undefined
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw(Prisma.sql`SELECT pg_advisory_xact_lock(abs(hashtext(${productSyncId}))::bigint)`)
|
||||
|
||||
const sync = await tx.productSync.findUnique({
|
||||
where: { id: productSyncId },
|
||||
})
|
||||
|
||||
if (!sync) {
|
||||
throw new LoadStockHttpError(404, "Product sync not found.")
|
||||
}
|
||||
if (sync.storeId !== vendorStore.id) {
|
||||
throw new LoadStockHttpError(403, "You cannot load stock for this product.")
|
||||
}
|
||||
if (sync.status !== "ACCEPTED") {
|
||||
throw new LoadStockHttpError(400, "Product sync must be accepted before loading stock.")
|
||||
}
|
||||
|
||||
const available = supplierQuantityFromSyncedProductData(sync.shopifyProductData)
|
||||
if (available === undefined) {
|
||||
throw new LoadStockHttpError(
|
||||
400,
|
||||
"Supplier stock is unknown for this product. Ask the supplier to set quantity or refresh the sync."
|
||||
)
|
||||
}
|
||||
if (quantity > available) {
|
||||
throw new LoadStockHttpError(
|
||||
400,
|
||||
`Amount cannot exceed supplier stock (${available}).`
|
||||
)
|
||||
}
|
||||
|
||||
const item = await getFirstVariantInventoryItemId(shopifyDomain, token, sync.shopifyProductId)
|
||||
if ("error" in item) {
|
||||
throw new LoadStockHttpError(400, item.error)
|
||||
}
|
||||
|
||||
const loc = await resolveLocationIdForInventoryAdjust(shopifyDomain, token, item.inventoryItemId)
|
||||
if ("error" in loc) {
|
||||
throw new LoadStockHttpError(loc.status ?? 502, loc.error)
|
||||
}
|
||||
|
||||
const adjust = await adjustAvailableInventory(shopifyDomain, token, {
|
||||
inventoryItemId: item.inventoryItemId,
|
||||
locationId: loc.locationId,
|
||||
availableAdjustment: quantity,
|
||||
})
|
||||
if ("error" in adjust) {
|
||||
throw new LoadStockHttpError(adjust.status, adjust.error)
|
||||
}
|
||||
|
||||
shopifyAdjusted = true
|
||||
compensate = {
|
||||
inventoryItemId: item.inventoryItemId,
|
||||
locationId: loc.locationId,
|
||||
}
|
||||
|
||||
const nextData = mergeShopifyProductDataAfterLoadStock(sync.shopifyProductData, quantity)
|
||||
newSupplierQty = supplierQuantityFromSyncedProductData(nextData as unknown)
|
||||
|
||||
await tx.productSync.update({
|
||||
where: { id: sync.id },
|
||||
data: { shopifyProductData: nextData },
|
||||
})
|
||||
})
|
||||
} catch (inner: unknown) {
|
||||
if (shopifyAdjusted && compensate) {
|
||||
await adjustAvailableInventory(shopifyDomain, token, {
|
||||
inventoryItemId: compensate.inventoryItemId,
|
||||
locationId: compensate.locationId,
|
||||
availableAdjustment: -quantity,
|
||||
}).catch((e) => {
|
||||
console.error("[load-stock] Failed to compensate Shopify after DB error:", e)
|
||||
})
|
||||
}
|
||||
if (inner instanceof LoadStockHttpError) {
|
||||
return NextResponse.json({ message: inner.message }, { status: inner.status })
|
||||
}
|
||||
throw inner
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Stock loaded successfully.",
|
||||
quantity,
|
||||
newSupplierQty: newSupplierQty ?? null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[load-stock]", error)
|
||||
return NextResponse.json({ message: "Failed to load stock." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { storefrontOtpVerifySchema } from "@/lib/api-schemas/public"
|
||||
import { validationErrorResponse } from "@/lib/api-schemas/validate"
|
||||
import {
|
||||
hashStorefrontOtp,
|
||||
STOREFRONT_SESSION_TTL_MS,
|
||||
storefrontSessionTtlMsFromMinutes,
|
||||
} from "@/lib/storefront-chat/otpCrypto"
|
||||
|
||||
function normalizeShopDomain(input: string) {
|
||||
@ -50,7 +50,7 @@ export async function POST(req: Request) {
|
||||
|
||||
const stores = await prisma.store.findMany({
|
||||
where: { shopifyDomain: { in: variants } },
|
||||
select: { id: true, storefrontChatEnabled: true },
|
||||
select: { id: true, storefrontChatEnabled: true, storefrontOtpSessionMinutes: true },
|
||||
})
|
||||
const store =
|
||||
stores.find((s) => Boolean(s.storefrontChatEnabled)) || stores[0]
|
||||
@ -94,7 +94,8 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ message: "Invalid code." }, { status: 400 })
|
||||
}
|
||||
|
||||
const sessionExpiresAt = new Date(Date.now() + STOREFRONT_SESSION_TTL_MS)
|
||||
const sessionMs = storefrontSessionTtlMsFromMinutes(store.storefrontOtpSessionMinutes)
|
||||
const sessionExpiresAt = new Date(Date.now() + sessionMs)
|
||||
|
||||
await prisma.storefrontChatConversation.update({
|
||||
where: {
|
||||
|
||||
@ -2,7 +2,11 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth.vendor"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { Prisma } from "@prisma/client"
|
||||
import { storeSelectPatchStorefrontAi } from "@/lib/prisma/storeSelects"
|
||||
import {
|
||||
storeSelectPatchStorefrontAi,
|
||||
storeSelectVendorSettingsGetFull,
|
||||
storeSelectVendorSettingsGetMinimal,
|
||||
} from "@/lib/prisma/storeSelects"
|
||||
import { modulePoliciesBodySchema, parseModulePoliciesJson } from "@/lib/kb/modulePolicies"
|
||||
import {
|
||||
parseSharedFieldAnswersJson,
|
||||
@ -22,6 +26,53 @@ import {
|
||||
parseGmailReplyThemeJson,
|
||||
resolvedThemeFromBody,
|
||||
} from "@/lib/gmail/emailReplyTheme"
|
||||
import { reconcileLowStockForStore } from "@/lib/automation/lowStock"
|
||||
import {
|
||||
MAX_STOREFRONT_OTP_SESSION_MINUTES,
|
||||
MIN_STOREFRONT_OTP_SESSION_MINUTES,
|
||||
} from "@/lib/storefront-chat/otpCrypto"
|
||||
|
||||
function parseStorefrontOtpSessionMinutes(
|
||||
value: unknown,
|
||||
): { ok: true; value: number } | { ok: false; message: string } {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "storefrontOtpSessionMinutes must be a whole number (total minutes).",
|
||||
}
|
||||
}
|
||||
if (
|
||||
value < MIN_STOREFRONT_OTP_SESSION_MINUTES ||
|
||||
value > MAX_STOREFRONT_OTP_SESSION_MINUTES
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Session length must be between ${MIN_STOREFRONT_OTP_SESSION_MINUTES} and ${MAX_STOREFRONT_OTP_SESSION_MINUTES} minutes.`,
|
||||
}
|
||||
}
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
function parseOptionalLowStockThreshold(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
): { ok: true; value: number | null } | { ok: false; message: string } {
|
||||
if (value === null) {
|
||||
return { ok: true, value: null }
|
||||
}
|
||||
if (
|
||||
typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 0 &&
|
||||
value <= 1_000_000
|
||||
) {
|
||||
return { ok: true, value }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: `${fieldName} must be null or an integer 0–1000000`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/stores/[storeId]
|
||||
@ -561,39 +612,25 @@ export async function GET(
|
||||
sharedFieldAnswers?: unknown
|
||||
gmailReplyTheme?: unknown
|
||||
lowStockThreshold?: number | null
|
||||
lowStockShopifyThreshold?: number | null
|
||||
storefrontOtpSessionMinutes?: number
|
||||
}
|
||||
|
||||
let store: StoreGetRow | null = null
|
||||
try {
|
||||
store = await prisma.store.findFirst({
|
||||
store = (await prisma.store.findFirst({
|
||||
where: { id: storeId, userId: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
businessName: true,
|
||||
categoryMetadetailsEnabled: true,
|
||||
aiModules: true,
|
||||
modulePolicies: true,
|
||||
sharedFieldAnswers: true,
|
||||
gmailReplyTheme: true,
|
||||
lowStockThreshold: true,
|
||||
},
|
||||
})
|
||||
select: storeSelectVendorSettingsGetFull as unknown as Prisma.StoreSelect,
|
||||
})) as StoreGetRow | null
|
||||
} catch (firstErr) {
|
||||
console.warn(
|
||||
"[STORE_GET] full select failed (run migrations if modulePolicies/sharedFieldAnswers are missing); retrying minimal",
|
||||
firstErr,
|
||||
)
|
||||
store = await prisma.store.findFirst({
|
||||
store = (await prisma.store.findFirst({
|
||||
where: { id: storeId, userId: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
businessName: true,
|
||||
categoryMetadetailsEnabled: true,
|
||||
aiModules: true,
|
||||
gmailReplyTheme: true,
|
||||
lowStockThreshold: true,
|
||||
},
|
||||
})
|
||||
select: storeSelectVendorSettingsGetMinimal as unknown as Prisma.StoreSelect,
|
||||
})) as StoreGetRow | null
|
||||
}
|
||||
|
||||
if (!store) {
|
||||
@ -614,6 +651,8 @@ export async function GET(
|
||||
? null
|
||||
: parseGmailReplyThemeJson(store.gmailReplyTheme),
|
||||
lowStockThreshold: store.lowStockThreshold ?? null,
|
||||
lowStockShopifyThreshold: store.lowStockShopifyThreshold ?? null,
|
||||
storefrontOtpSessionMinutes: store.storefrontOtpSessionMinutes ?? 60,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error reading store settings:", error)
|
||||
@ -661,7 +700,13 @@ export async function PATCH(
|
||||
const nextFont = sanitizeFont(data.storefrontChatFontFamily ?? store.storefrontChatFontFamily)
|
||||
const nextLogoUrl = sanitizeLogoUrl(data.storefrontChatLogoUrl ?? store.storefrontChatLogoUrl)
|
||||
|
||||
const updatePayload: Prisma.StoreUpdateInput = {
|
||||
type StorePatchPayload = Prisma.StoreUpdateInput & {
|
||||
lowStockThreshold?: number | null
|
||||
lowStockShopifyThreshold?: number | null
|
||||
storefrontOtpSessionMinutes?: number
|
||||
}
|
||||
|
||||
const updatePayload: StorePatchPayload = {
|
||||
storefrontChatEnabled: nextEnabled,
|
||||
storefrontChatName: nextChatName,
|
||||
storefrontChatTagline: nextTagline,
|
||||
@ -734,30 +779,66 @@ export async function PATCH(
|
||||
}
|
||||
}
|
||||
|
||||
if (data.lowStockThreshold !== undefined) {
|
||||
if (data.lowStockThreshold === null) {
|
||||
updatePayload.lowStockThreshold = null
|
||||
} else if (
|
||||
typeof data.lowStockThreshold === "number" &&
|
||||
Number.isInteger(data.lowStockThreshold) &&
|
||||
data.lowStockThreshold >= 0 &&
|
||||
data.lowStockThreshold <= 1_000_000
|
||||
) {
|
||||
updatePayload.lowStockThreshold = data.lowStockThreshold
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ message: "lowStockThreshold must be null or an integer 0–1000000" },
|
||||
{ status: 400 },
|
||||
)
|
||||
const lowStockThresholdTouched = data.lowStockThreshold !== undefined
|
||||
const lowStockShopifyThresholdTouched = data.lowStockShopifyThreshold !== undefined
|
||||
|
||||
if (lowStockThresholdTouched) {
|
||||
const parsed = parseOptionalLowStockThreshold(data.lowStockThreshold, "lowStockThreshold")
|
||||
if (!parsed.ok) {
|
||||
return NextResponse.json({ message: parsed.message }, { status: 400 })
|
||||
}
|
||||
updatePayload.lowStockThreshold = parsed.value
|
||||
}
|
||||
|
||||
if (lowStockShopifyThresholdTouched) {
|
||||
const parsed = parseOptionalLowStockThreshold(
|
||||
data.lowStockShopifyThreshold,
|
||||
"lowStockShopifyThreshold",
|
||||
)
|
||||
if (!parsed.ok) {
|
||||
return NextResponse.json({ message: parsed.message }, { status: 400 })
|
||||
}
|
||||
updatePayload.lowStockShopifyThreshold = parsed.value
|
||||
}
|
||||
|
||||
if (data.storefrontOtpSessionMinutes !== undefined) {
|
||||
const parsed = parseStorefrontOtpSessionMinutes(data.storefrontOtpSessionMinutes)
|
||||
if (!parsed.ok) {
|
||||
return NextResponse.json({ message: parsed.message }, { status: 400 })
|
||||
}
|
||||
updatePayload.storefrontOtpSessionMinutes = parsed.value
|
||||
}
|
||||
|
||||
const lowStockSettingsTouched =
|
||||
lowStockThresholdTouched || lowStockShopifyThresholdTouched
|
||||
|
||||
const updatedStore = await prisma.store.update({
|
||||
where: { id: storeId },
|
||||
data: updatePayload,
|
||||
select: storeSelectPatchStorefrontAi,
|
||||
data: updatePayload as Prisma.StoreUpdateInput,
|
||||
select: storeSelectPatchStorefrontAi as unknown as Prisma.StoreSelect,
|
||||
})
|
||||
|
||||
if (lowStockSettingsTouched) {
|
||||
await prisma.$executeRaw(
|
||||
Prisma.sql`DELETE FROM "LowStockAlertState" WHERE "storeId" = ${storeId}`,
|
||||
)
|
||||
await prisma.$executeRaw(
|
||||
Prisma.sql`DELETE FROM "LowStockShopifyVendorAlertState" WHERE "storeId" = ${storeId}`,
|
||||
)
|
||||
const patchLowStock = updatedStore as {
|
||||
lowStockThreshold?: number | null
|
||||
lowStockShopifyThreshold?: number | null
|
||||
}
|
||||
if (
|
||||
patchLowStock.lowStockThreshold != null ||
|
||||
patchLowStock.lowStockShopifyThreshold != null
|
||||
) {
|
||||
void reconcileLowStockForStore(storeId).catch((err) => {
|
||||
console.error("[LOW_STOCK_RECONCILE_AFTER_THRESHOLD_SAVE]", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit behavior change:
|
||||
// - Enable: save only (no auto inject).
|
||||
// - Disable: remove snippet immediately to hard-stop storefront widget.
|
||||
@ -803,7 +884,23 @@ export async function PATCH(
|
||||
updatedStore.gmailReplyTheme == null
|
||||
? null
|
||||
: parseGmailReplyThemeJson(updatedStore.gmailReplyTheme),
|
||||
lowStockThreshold: updatedStore.lowStockThreshold ?? null,
|
||||
lowStockThreshold:
|
||||
(updatedStore as { lowStockThreshold?: number | null }).lowStockThreshold ?? null,
|
||||
lowStockShopifyThreshold:
|
||||
(updatedStore as { lowStockShopifyThreshold?: number | null }).lowStockShopifyThreshold ??
|
||||
null,
|
||||
storefrontOtpSessionMinutes:
|
||||
(updatedStore as { storefrontOtpSessionMinutes?: number }).storefrontOtpSessionMinutes ??
|
||||
60,
|
||||
...(lowStockSettingsTouched
|
||||
? {
|
||||
lowStockReconcileQueued:
|
||||
(updatedStore as { lowStockThreshold?: number | null }).lowStockThreshold !=
|
||||
null ||
|
||||
(updatedStore as { lowStockShopifyThreshold?: number | null })
|
||||
.lowStockShopifyThreshold != null,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error updating store settings:", error)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { updateSingleProduct, updateSingleOrder } from "@/lib/cache"
|
||||
import { sumVariantInventoryQuantity } from "@/lib/products/shopifyInventorySum"
|
||||
import { evaluateLowStockAfterProductWebhook } from "@/lib/automation/lowStock"
|
||||
import { autoLinkOfferToShopifyProduct } from "@/lib/products/autoLinkOfferToShopifyProduct"
|
||||
import crypto from "crypto"
|
||||
@ -96,28 +95,10 @@ export async function POST(req: NextRequest) {
|
||||
case "products/create":
|
||||
logger.info(`[Webhook] Processing product ${topic}: ${body.id}`)
|
||||
{
|
||||
const productNumericId =
|
||||
body?.id !== undefined && body?.id !== null ? String(body.id) : null
|
||||
let prevTotalQty: number | null = null
|
||||
if (productNumericId) {
|
||||
const prevRow = await prisma.cachedProduct.findUnique({
|
||||
where: {
|
||||
shopifyProductId_storeId: {
|
||||
shopifyProductId: productNumericId,
|
||||
storeId: store.id,
|
||||
},
|
||||
},
|
||||
select: { variants: true },
|
||||
})
|
||||
if (prevRow) {
|
||||
prevTotalQty = sumVariantInventoryQuantity(prevRow.variants)
|
||||
}
|
||||
}
|
||||
await updateSingleProduct(store.id, body)
|
||||
evaluateLowStockAfterProductWebhook({
|
||||
storeId: store.id,
|
||||
shopifyProductBody: body,
|
||||
prevTotalQty,
|
||||
}).catch((lowStockErr) => {
|
||||
logger.error("[Webhook] lowStock evaluation failed", lowStockErr)
|
||||
})
|
||||
|
||||
@ -21,7 +21,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
BellRing
|
||||
PackagePlus,
|
||||
} from "lucide-react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
@ -109,18 +109,41 @@ export default function InventoryPage() {
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [menuRect, setMenuRect] = useState<{ top: number; left: number } | null>(null)
|
||||
const [showLoadStockModal, setShowLoadStockModal] = useState(false)
|
||||
const [loadStockProduct, setLoadStockProduct] = useState<ShopifyProduct | null>(null)
|
||||
const [loadStockSync, setLoadStockSync] = useState<ProductSync | null>(null)
|
||||
const [loadStockQtyInput, setLoadStockQtyInput] = useState("1")
|
||||
const [loadStockSubmitting, setLoadStockSubmitting] = useState(false)
|
||||
const [loadStockError, setLoadStockError] = useState("")
|
||||
const menuRefs = useRef<{ [key: number]: HTMLButtonElement | null }>({})
|
||||
const { theme } = useTheme()
|
||||
const isLight = theme === "light"
|
||||
|
||||
const [activeStoreId, setActiveStoreId] = useState<string | null>(null)
|
||||
const [lowStockDraft, setLowStockDraft] = useState("")
|
||||
const [lowStockSaving, setLowStockSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const refetchInventoryAndSyncs = async () => {
|
||||
try {
|
||||
const invRes = await fetch("/api/shopify/inventory")
|
||||
if (invRes.ok) {
|
||||
const invData = await invRes.json()
|
||||
setProducts(invData.products || [])
|
||||
}
|
||||
const syncRes = await fetch("/api/products/sync")
|
||||
if (syncRes.ok) {
|
||||
const syncData = await syncRes.json()
|
||||
const syncsMap = new Map<string, ProductSync>()
|
||||
syncData.syncs?.forEach((sync: ProductSync) => {
|
||||
syncsMap.set(sync.shopifyProductId, sync)
|
||||
})
|
||||
setProductSyncs(syncsMap)
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStoreInfo = async () => {
|
||||
try {
|
||||
@ -128,23 +151,6 @@ export default function InventoryPage() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive)
|
||||
if (activeStore?.id) {
|
||||
setActiveStoreId(activeStore.id)
|
||||
try {
|
||||
const settingsRes = await fetch(`/api/stores/${activeStore.id}`, {
|
||||
cache: "no-store",
|
||||
})
|
||||
if (settingsRes.ok) {
|
||||
const settingsData = (await settingsRes.json()) as {
|
||||
lowStockThreshold?: number | null
|
||||
}
|
||||
const t = settingsData.lowStockThreshold
|
||||
setLowStockDraft(typeof t === "number" ? String(t) : "")
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (activeStore?.shopifyDomain) {
|
||||
const cleanDomain = activeStore.shopifyDomain.replace(/^https?:\/\//, "").replace(/\/$/, "")
|
||||
setStoreDomain(cleanDomain)
|
||||
@ -311,6 +317,53 @@ export default function InventoryPage() {
|
||||
return productSyncs.get(productId.toString()) || null
|
||||
}
|
||||
|
||||
const handleOpenLoadStock = (product: ShopifyProduct, sync: ProductSync) => {
|
||||
setLoadStockProduct(product)
|
||||
setLoadStockSync(sync)
|
||||
setLoadStockQtyInput("1")
|
||||
setLoadStockError("")
|
||||
setShowLoadStockModal(true)
|
||||
setOpenMenuId(null)
|
||||
setMenuRect(null)
|
||||
}
|
||||
|
||||
const handleSubmitLoadStock = async () => {
|
||||
if (!loadStockProduct || !loadStockSync) return
|
||||
const qty = parseInt(loadStockQtyInput, 10)
|
||||
if (!Number.isFinite(qty) || qty < 1) {
|
||||
setLoadStockError("Enter a positive whole number.")
|
||||
return
|
||||
}
|
||||
const cap = loadStockSync.supplierAvailableQuantity
|
||||
if (cap != null && qty > cap) {
|
||||
setLoadStockError(`Amount cannot exceed supplier stock (${cap}).`)
|
||||
return
|
||||
}
|
||||
setLoadStockSubmitting(true)
|
||||
setLoadStockError("")
|
||||
try {
|
||||
const res = await fetch("/api/shopify/inventory/load-stock", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ productSyncId: loadStockSync.id, quantity: qty }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
setLoadStockError(typeof data.message === "string" ? data.message : "Could not load stock.")
|
||||
return
|
||||
}
|
||||
toast.success(typeof data.message === "string" ? data.message : "Stock loaded.")
|
||||
setShowLoadStockModal(false)
|
||||
setLoadStockProduct(null)
|
||||
setLoadStockSync(null)
|
||||
await refetchInventoryAndSyncs()
|
||||
} catch {
|
||||
setLoadStockError("Could not load stock.")
|
||||
} finally {
|
||||
setLoadStockSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSyncProduct = (product: ShopifyProduct) => {
|
||||
if (connectedSuppliers.length === 0) {
|
||||
toast.error("You need to connect with at least one supplier before syncing products. Please go to Suppliers > My Suppliers to connect.")
|
||||
@ -365,46 +418,6 @@ export default function InventoryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveLowStockSettings = async () => {
|
||||
if (!activeStoreId) {
|
||||
toast.error("No active store")
|
||||
return
|
||||
}
|
||||
const trimmed = lowStockDraft.trim()
|
||||
let payload: { lowStockThreshold: number | null }
|
||||
if (trimmed === "") {
|
||||
payload = { lowStockThreshold: null }
|
||||
} else {
|
||||
const n = Number.parseInt(trimmed, 10)
|
||||
if (!Number.isFinite(n) || n < 0 || n > 1_000_000) {
|
||||
toast.error("Enter a whole number from 0 to 1000000, or leave empty to turn off.")
|
||||
return
|
||||
}
|
||||
payload = { lowStockThreshold: n }
|
||||
}
|
||||
setLowStockSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/stores/${activeStoreId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new Error((data as { message?: string }).message || "Save failed")
|
||||
}
|
||||
toast.success(
|
||||
payload.lowStockThreshold === null
|
||||
? "Low-stock supplier alerts turned off."
|
||||
: `Alerts on when total variant stock drops below ${payload.lowStockThreshold}.`,
|
||||
)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Could not save settings")
|
||||
} finally {
|
||||
setLowStockSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const csvHeaders = ["ID", "Title", "Vendor", "Status", "Variants", "Created At"]
|
||||
const csvRows = filteredProducts.map(p => [
|
||||
@ -461,63 +474,6 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl md:rounded-2xl border p-4 md:p-5 flex flex-col sm:flex-row sm:items-center gap-4 flex-shrink-0 ${
|
||||
isLight
|
||||
? "bg-indigo-50/80 border-indigo-200"
|
||||
: "bg-indigo-500/10 border-indigo-500/20"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
isLight ? "bg-indigo-100 text-indigo-700" : "bg-indigo-500/20 text-indigo-300"
|
||||
}`}
|
||||
>
|
||||
<BellRing className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
className={`text-sm font-black uppercase tracking-wider ${
|
||||
isLight ? "text-indigo-900" : "text-indigo-200"
|
||||
}`}
|
||||
>
|
||||
Supplier low-stock alerts
|
||||
</h2>
|
||||
<p
|
||||
className={`text-xs mt-1 leading-relaxed ${
|
||||
isLight ? "text-indigo-800/80" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
When enabled, synced products (ACCEPTED) message the linked supplier in JUNO chat if
|
||||
total inventory drops below your threshold. Leave empty to turn off.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:ml-auto shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="Off"
|
||||
value={lowStockDraft}
|
||||
onChange={(e) => setLowStockDraft(e.target.value.replace(/[^\d]/g, ""))}
|
||||
className={`h-10 w-full sm:w-28 px-3 rounded-xl border text-sm font-bold ${
|
||||
isLight
|
||||
? "bg-white border-indigo-200 text-slate-900 placeholder:text-slate-400"
|
||||
: "bg-white/5 border-white/10 text-white placeholder:text-slate-500"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lowStockSaving || !activeStoreId}
|
||||
onClick={() => saveLowStockSettings()}
|
||||
className="h-10 px-4 rounded-xl font-bold text-sm bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{lowStockSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 flex flex-col flex-1 min-h-0 relative overflow-hidden">
|
||||
<div className="p-4 md:p-6 border-b border-white/10 flex flex-col sm:flex-row gap-3 md:gap-4 justify-between bg-white/5 flex-shrink-0">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
@ -738,6 +694,89 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
|
||||
{/* Supplier Selection Modal */}
|
||||
{showLoadStockModal && loadStockProduct && loadStockSync && (
|
||||
<div className={`fixed inset-0 backdrop-blur-sm z-50 flex items-center justify-center p-4 ${isLight ? "bg-black/40" : "bg-black/60"}`}>
|
||||
<div className={`rounded-2xl shadow-2xl max-w-md w-full overflow-hidden flex flex-col ${isLight ? "bg-white border border-slate-200" : "bg-slate-900 border border-white/10"}`}>
|
||||
<div className={`p-6 flex items-center justify-between ${isLight ? "border-b border-slate-200" : "border-b border-white/10"}`}>
|
||||
<div>
|
||||
<h2 className={`text-xl font-bold ${isLight ? "text-slate-900" : "text-white"}`}>Load stock</h2>
|
||||
<p className={`text-sm mt-1 ${isLight ? "text-slate-600" : "text-slate-400"}`}>
|
||||
Add inventory to Shopify from supplier stock for "{loadStockProduct.title}"
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!loadStockSubmitting) {
|
||||
setShowLoadStockModal(false)
|
||||
setLoadStockProduct(null)
|
||||
setLoadStockSync(null)
|
||||
}
|
||||
}}
|
||||
className={`p-2 rounded-lg transition-colors ${isLight ? "text-slate-500 hover:text-slate-900 hover:bg-slate-100" : "text-slate-400 hover:text-white hover:bg-white/10"}`}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<p className={`text-xs ${isLight ? "text-slate-500" : "text-slate-500"}`}>
|
||||
Sellable quantity is added to the <span className="font-semibold">first variant</span> at your primary Shopify location. Supplier stock cannot exceed what is shown below.
|
||||
</p>
|
||||
<div className={`grid grid-cols-2 gap-3 text-sm ${isLight ? "text-slate-700" : "text-slate-300"}`}>
|
||||
<div>
|
||||
<span className={`block text-[10px] uppercase font-bold ${isLight ? "text-slate-400" : "text-slate-500"}`}>Supplier stock</span>
|
||||
<span className={`font-mono font-bold tabular-nums text-base ${isLight ? "text-slate-900" : "text-white"}`}>
|
||||
{loadStockSync.supplierAvailableQuantity ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`block text-[10px] uppercase font-bold ${isLight ? "text-slate-400" : "text-slate-500"}`}>Shopify (total)</span>
|
||||
<span className={`font-mono font-bold tabular-nums text-base ${isLight ? "text-slate-900" : "text-white"}`}>
|
||||
{totalShopifyInventory(loadStockProduct.variants)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="load-stock-qty" className={`block text-xs font-bold uppercase tracking-widest mb-2 ${isLight ? "text-slate-500" : "text-slate-400"}`}>
|
||||
Quantity to load
|
||||
</label>
|
||||
<input
|
||||
id="load-stock-qty"
|
||||
type="number"
|
||||
min={1}
|
||||
max={loadStockSync.supplierAvailableQuantity ?? undefined}
|
||||
value={loadStockQtyInput}
|
||||
onChange={(e) => setLoadStockQtyInput(e.target.value)}
|
||||
disabled={loadStockSubmitting}
|
||||
className={`w-full h-11 rounded-xl px-3 font-mono tabular-nums border outline-none focus:ring-2 ${isLight
|
||||
? "bg-slate-50 border-slate-200 text-slate-900 focus:ring-indigo-500/30"
|
||||
: "bg-white/5 border-white/10 text-white focus:ring-indigo-500/30"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{loadStockError ? (
|
||||
<p className="text-sm text-red-400">{loadStockError}</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadStockSubmitting}
|
||||
onClick={() => void handleSubmitLoadStock()}
|
||||
className="w-full h-11 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold text-sm transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loadStockSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Loading…
|
||||
</>
|
||||
) : (
|
||||
"Load"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSupplierModal && selectedProduct && (
|
||||
<div className={`fixed inset-0 backdrop-blur-sm z-50 flex items-center justify-center p-4 ${isLight ? "bg-black/40" : "bg-black/60"}`}>
|
||||
<div className={`rounded-2xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col ${isLight ? "bg-white border border-slate-200" : "bg-slate-900 border border-white/10"}`}>
|
||||
@ -819,6 +858,27 @@ export default function InventoryPage() {
|
||||
handleViewProduct={handleViewProduct}
|
||||
handleCopyProductId={handleCopyProductId}
|
||||
handleRefreshProduct={handleRefreshProduct}
|
||||
loadStockEnabled={(() => {
|
||||
const p = products.find((x) => x.id === openMenuId)
|
||||
if (!p) return false
|
||||
const s = getProductSyncStatus(p.id)
|
||||
const cap = s?.supplierAvailableQuantity
|
||||
return (
|
||||
!!s &&
|
||||
s.status === "ACCEPTED" &&
|
||||
cap != null &&
|
||||
cap > 0
|
||||
)
|
||||
})()}
|
||||
onLoadStock={() => {
|
||||
const p = products.find((x) => x.id === openMenuId)
|
||||
if (!p) return
|
||||
const s = getProductSyncStatus(p.id)
|
||||
if (!s || s.status !== "ACCEPTED") return
|
||||
const cap = s.supplierAvailableQuantity
|
||||
if (cap == null || cap <= 0) return
|
||||
handleOpenLoadStock(p, s)
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpenMenuId(null)
|
||||
setMenuRect(null)
|
||||
@ -842,6 +902,8 @@ function ActionsMenuContent({
|
||||
handleViewProduct,
|
||||
handleCopyProductId,
|
||||
handleRefreshProduct,
|
||||
loadStockEnabled,
|
||||
onLoadStock,
|
||||
onClose
|
||||
}: {
|
||||
openMenuId: number;
|
||||
@ -852,6 +914,8 @@ function ActionsMenuContent({
|
||||
handleViewProduct: (product: ShopifyProduct) => void;
|
||||
handleCopyProductId: (productId: number) => void;
|
||||
handleRefreshProduct: () => void;
|
||||
loadStockEnabled: boolean;
|
||||
onLoadStock: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const product = products.find(p => p.id === openMenuId);
|
||||
@ -906,6 +970,22 @@ function ActionsMenuContent({
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh Product
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!loadStockEnabled}
|
||||
title={!loadStockEnabled ? "Requires accepted sync with supplier stock" : undefined}
|
||||
onClick={() => {
|
||||
if (!loadStockEnabled) return
|
||||
onLoadStock()
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 text-left text-sm font-medium flex items-center gap-2 transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${isLight
|
||||
? "text-slate-700 hover:bg-slate-50 hover:text-indigo-600"
|
||||
: "text-slate-300 hover:bg-white/5 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<PackagePlus className="w-4 h-4" />
|
||||
Load stock
|
||||
</button>
|
||||
|
||||
{storeDomain && product.handle && (
|
||||
<>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { User, Shield } from "lucide-react"
|
||||
import { User, Shield, BellRing, MessageCircle } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -17,7 +17,19 @@ const settingsTabs = [
|
||||
label: "Security",
|
||||
icon: Shield,
|
||||
href: "/dashboard/settings/security"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "low-stock",
|
||||
label: "Low stock",
|
||||
icon: BellRing,
|
||||
href: "/dashboard/settings/low-stock"
|
||||
},
|
||||
{
|
||||
id: "storefront",
|
||||
label: "Storefront",
|
||||
icon: MessageCircle,
|
||||
href: "/dashboard/settings/storefront"
|
||||
},
|
||||
]
|
||||
|
||||
export default function SettingsLayout({
|
||||
@ -31,7 +43,9 @@ export default function SettingsLayout({
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Settings</h1>
|
||||
<p className="text-sm md:text-base text-slate-400 mt-1">Manage your account and security preferences.</p>
|
||||
<p className="text-sm md:text-base text-slate-400 mt-1">
|
||||
Manage your account, security, low-stock alerts, and storefront chat session settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 overflow-hidden">
|
||||
|
||||
198
src/app/dashboard/settings/low-stock/page.tsx
Normal file
198
src/app/dashboard/settings/low-stock/page.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import toast from "react-hot-toast"
|
||||
import { BellRing, CheckCircle2, Loader2, Package, Warehouse } from "lucide-react"
|
||||
|
||||
function parseThresholdDraft(trimmed: string): { ok: true; value: number | null } | { ok: false } {
|
||||
if (trimmed === "") return { ok: true, value: null }
|
||||
const n = Number.parseInt(trimmed, 10)
|
||||
if (!Number.isFinite(n) || n < 0 || n > 1_000_000) return { ok: false }
|
||||
return { ok: true, value: n }
|
||||
}
|
||||
|
||||
export default function LowStockSettingsPage() {
|
||||
const [activeStoreId, setActiveStoreId] = useState<string | null>(null)
|
||||
const [shopifyDraft, setShopifyDraft] = useState("")
|
||||
const [supplierDraft, setSupplierDraft] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savedFlash, setSavedFlash] = useState(false)
|
||||
const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const loadStore = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/stores")
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const active = data.stores?.find((s: { isActive: boolean }) => s.isActive) as
|
||||
| { id?: string }
|
||||
| undefined
|
||||
if (!active?.id) {
|
||||
setActiveStoreId(null)
|
||||
return
|
||||
}
|
||||
setActiveStoreId(active.id)
|
||||
const settingsRes = await fetch(`/api/stores/${active.id}`, { cache: "no-store" })
|
||||
if (!settingsRes.ok) return
|
||||
const s = (await settingsRes.json()) as {
|
||||
lowStockThreshold?: number | null
|
||||
lowStockShopifyThreshold?: number | null
|
||||
}
|
||||
setShopifyDraft(
|
||||
typeof s.lowStockShopifyThreshold === "number" ? String(s.lowStockShopifyThreshold) : "",
|
||||
)
|
||||
setSupplierDraft(typeof s.lowStockThreshold === "number" ? String(s.lowStockThreshold) : "")
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadStore()
|
||||
}, [loadStore])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flashTimerRef.current) clearTimeout(flashTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const save = async () => {
|
||||
if (!activeStoreId) {
|
||||
toast.error("No active store")
|
||||
return
|
||||
}
|
||||
const shopifyTrim = shopifyDraft.trim()
|
||||
const supplierTrim = supplierDraft.trim()
|
||||
const shopifyParsed = parseThresholdDraft(shopifyTrim)
|
||||
const supplierParsed = parseThresholdDraft(supplierTrim)
|
||||
if (!shopifyParsed.ok || !supplierParsed.ok) {
|
||||
toast.error("Enter whole numbers from 0 to 1000000, or leave empty to turn off.")
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
lowStockShopifyThreshold: shopifyParsed.value,
|
||||
lowStockThreshold: supplierParsed.value,
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/stores/${activeStoreId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
lowStockReconcileQueued?: boolean
|
||||
}
|
||||
if (!res.ok) throw new Error(data.message || "Save failed")
|
||||
if (flashTimerRef.current) {
|
||||
clearTimeout(flashTimerRef.current)
|
||||
flashTimerRef.current = null
|
||||
}
|
||||
setSavedFlash(true)
|
||||
flashTimerRef.current = setTimeout(() => {
|
||||
setSavedFlash(false)
|
||||
flashTimerRef.current = null
|
||||
}, 2800)
|
||||
const anyOn = payload.lowStockThreshold !== null || payload.lowStockShopifyThreshold !== null
|
||||
if (!anyOn) {
|
||||
toast.success("Low-stock alert thresholds turned off.")
|
||||
} else if (data.lowStockReconcileQueued) {
|
||||
toast.success(
|
||||
"Saved. Scanning products for low stock (supplier chat and Shopify notifications where applicable).",
|
||||
)
|
||||
} else {
|
||||
toast.success("Thresholds saved.")
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Could not save")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Low-stock alerts</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Optional thresholds for your active store. Empty means off. When Shopify total stock falls below
|
||||
your Shopify threshold, you get an in-app notification. When supplier-reported stock falls below the
|
||||
supplier threshold, linked suppliers get a JUNO chat product card (existing behavior).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-slate-400 mb-2">
|
||||
<Package className="w-4 h-4" />
|
||||
Shopify stock (on-hand)
|
||||
</label>
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
Sum of variant <code className="text-slate-400">inventory_quantity</code> for the product (from
|
||||
webhooks / cache). If it drops below this number, you receive a notification in the bell menu—restock
|
||||
or use Load stock in Inventory.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="Off"
|
||||
value={shopifyDraft}
|
||||
onChange={(e) => setShopifyDraft(e.target.value.replace(/[^\d]/g, ""))}
|
||||
className="w-full max-w-xs px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-slate-400 mb-2">
|
||||
<Warehouse className="w-4 h-4" />
|
||||
Supplier-reported stock
|
||||
</label>
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
Matches the Supplier stock column on Inventory (ACCEPTED sync snapshot). When set, linked suppliers
|
||||
can get a product card in JUNO chat if reported quantity is below this value.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="Off"
|
||||
value={supplierDraft}
|
||||
onChange={(e) => setSupplierDraft(e.target.value.replace(/[^\d]/g, ""))}
|
||||
className="w-full max-w-xs px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !activeStoreId}
|
||||
onClick={() => void save()}
|
||||
className={`h-10 px-5 rounded-xl font-bold text-sm text-white disabled:opacity-50 transition-colors flex items-center justify-center gap-2 min-w-[6rem] ${
|
||||
savedFlash && !saving ? "bg-emerald-600 hover:bg-emerald-500" : "bg-indigo-600 hover:bg-indigo-500"
|
||||
}`}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin shrink-0" aria-hidden />
|
||||
Saving…
|
||||
</>
|
||||
) : savedFlash ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" aria-hidden />
|
||||
Saved
|
||||
</>
|
||||
) : (
|
||||
"Save thresholds"
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-start gap-2 text-xs text-slate-500 max-w-lg">
|
||||
<BellRing className="w-4 h-4 shrink-0 mt-0.5 text-slate-400" aria-hidden />
|
||||
<span>
|
||||
Values are stored on your store and persist across sessions. "Saved" is brief
|
||||
confirmation only—you do not need to save again for future alerts.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
src/app/dashboard/settings/storefront/page.tsx
Normal file
210
src/app/dashboard/settings/storefront/page.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import toast from "react-hot-toast"
|
||||
import { CheckCircle2, Loader2, MessageCircle, Timer } from "lucide-react"
|
||||
import {
|
||||
MAX_STOREFRONT_OTP_SESSION_MINUTES,
|
||||
MIN_STOREFRONT_OTP_SESSION_MINUTES,
|
||||
} from "@/lib/storefront-chat/otpCrypto"
|
||||
|
||||
const MAX_HOURS = Math.floor(MAX_STOREFRONT_OTP_SESSION_MINUTES / 60)
|
||||
|
||||
function splitTotalMinutes(total: number): { hours: number; minutes: number } {
|
||||
const h = Math.floor(total / 60)
|
||||
const m = total % 60
|
||||
return { hours: h, minutes: m }
|
||||
}
|
||||
|
||||
export default function StorefrontSettingsPage() {
|
||||
const [activeStoreId, setActiveStoreId] = useState<string | null>(null)
|
||||
const [hoursDraft, setHoursDraft] = useState("1")
|
||||
const [minutesDraft, setMinutesDraft] = useState("0")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savedFlash, setSavedFlash] = useState(false)
|
||||
const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const loadStore = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/stores")
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const active = data.stores?.find((s: { isActive: boolean }) => s.isActive) as
|
||||
| { id?: string }
|
||||
| undefined
|
||||
if (!active?.id) {
|
||||
setActiveStoreId(null)
|
||||
return
|
||||
}
|
||||
setActiveStoreId(active.id)
|
||||
const settingsRes = await fetch(`/api/stores/${active.id}`, { cache: "no-store" })
|
||||
if (!settingsRes.ok) return
|
||||
const s = (await settingsRes.json()) as {
|
||||
storefrontOtpSessionMinutes?: number
|
||||
}
|
||||
const total =
|
||||
typeof s.storefrontOtpSessionMinutes === "number"
|
||||
? s.storefrontOtpSessionMinutes
|
||||
: 60
|
||||
const { hours, minutes } = splitTotalMinutes(total)
|
||||
setHoursDraft(String(hours))
|
||||
setMinutesDraft(String(minutes))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadStore()
|
||||
}, [loadStore])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flashTimerRef.current) clearTimeout(flashTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const save = async () => {
|
||||
if (!activeStoreId) {
|
||||
toast.error("No active store")
|
||||
return
|
||||
}
|
||||
const hTrim = hoursDraft.trim()
|
||||
const mTrim = minutesDraft.trim()
|
||||
const h = hTrim === "" ? 0 : Number.parseInt(hTrim, 10)
|
||||
const m = mTrim === "" ? 0 : Number.parseInt(mTrim, 10)
|
||||
if (!Number.isFinite(h) || h < 0 || h > MAX_HOURS || !Number.isFinite(m) || m < 0 || m > 59) {
|
||||
toast.error(`Hours: 0–${MAX_HOURS}, minutes: 0–59.`)
|
||||
return
|
||||
}
|
||||
if (h === MAX_HOURS && m > 0) {
|
||||
toast.error(`Maximum session is ${MAX_HOURS} hours (10080 minutes).`)
|
||||
return
|
||||
}
|
||||
const totalMinutes = h * 60 + m
|
||||
if (totalMinutes < MIN_STOREFRONT_OTP_SESSION_MINUTES) {
|
||||
toast.error(`Session must be at least ${MIN_STOREFRONT_OTP_SESSION_MINUTES} minutes total.`)
|
||||
return
|
||||
}
|
||||
if (totalMinutes > MAX_STOREFRONT_OTP_SESSION_MINUTES) {
|
||||
toast.error(`Session cannot exceed ${MAX_STOREFRONT_OTP_SESSION_MINUTES} minutes (one week).`)
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/stores/${activeStoreId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ storefrontOtpSessionMinutes: totalMinutes }),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as { message?: string }
|
||||
if (!res.ok) throw new Error(data.message || "Save failed")
|
||||
if (flashTimerRef.current) {
|
||||
clearTimeout(flashTimerRef.current)
|
||||
flashTimerRef.current = null
|
||||
}
|
||||
setSavedFlash(true)
|
||||
flashTimerRef.current = setTimeout(() => {
|
||||
setSavedFlash(false)
|
||||
flashTimerRef.current = null
|
||||
}, 2800)
|
||||
toast.success("Storefront session length saved.")
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Could not save")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Storefront chat (OTP session)</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
After a customer verifies their email with a one-time code, the widget stays signed in for
|
||||
this length. The code itself still expires in 10 minutes; this only affects the session after
|
||||
verification.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<label className="flex items-center gap-2 text-sm font-bold text-slate-400 mb-3">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
Verified session length
|
||||
</label>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="otp-session-hours" className="text-xs text-slate-500 block">
|
||||
Hours
|
||||
</label>
|
||||
<input
|
||||
id="otp-session-hours"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={hoursDraft}
|
||||
onChange={(e) => setHoursDraft(e.target.value.replace(/[^\d]/g, ""))}
|
||||
className="w-24 px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||
aria-describedby="otp-session-hint"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="otp-session-minutes" className="text-xs text-slate-500 block">
|
||||
Minutes
|
||||
</label>
|
||||
<input
|
||||
id="otp-session-minutes"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={minutesDraft}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/[^\d]/g, "").slice(0, 2)
|
||||
if (raw === "") {
|
||||
setMinutesDraft("")
|
||||
return
|
||||
}
|
||||
const n = Number.parseInt(raw, 10)
|
||||
setMinutesDraft(String(Math.min(59, n)))
|
||||
}}
|
||||
className="w-24 px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||
aria-describedby="otp-session-hint"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p id="otp-session-hint" className="text-xs text-slate-500 mt-3 flex items-start gap-2">
|
||||
<Timer className="w-4 h-4 shrink-0 mt-0.5 text-slate-400" aria-hidden />
|
||||
<span>
|
||||
Total must be between <strong className="text-slate-300">{MIN_STOREFRONT_OTP_SESSION_MINUTES}</strong> and{" "}
|
||||
<strong className="text-slate-300">{MAX_STOREFRONT_OTP_SESSION_MINUTES}</strong> minutes (up to{" "}
|
||||
{MAX_HOURS} hours). Default was 1 hour.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !activeStoreId}
|
||||
onClick={() => void save()}
|
||||
className={`h-10 px-5 rounded-xl font-bold text-sm text-white disabled:opacity-50 transition-colors flex items-center justify-center gap-2 min-w-[6rem] ${
|
||||
savedFlash && !saving ? "bg-emerald-600 hover:bg-emerald-500" : "bg-indigo-600 hover:bg-indigo-500"
|
||||
}`}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin shrink-0" aria-hidden />
|
||||
Saving…
|
||||
</>
|
||||
) : savedFlash ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" aria-hidden />
|
||||
Saved
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -186,7 +186,7 @@ export default function OnboardingClient() {
|
||||
<AlertCircle className="w-6 h-6 text-indigo-400 shrink-0" />
|
||||
<div className="text-sm text-slate-300 leading-relaxed">
|
||||
<p className="font-bold mb-1 text-white">How to get your credentials?</p>
|
||||
Go to your Shopify Admin > Settings > Apps and sales channels > Develop apps. Create an app and configure Admin API scopes. For the storefront chat widget auto-injection, enable <span className="font-bold text-white">read_themes</span> and <span className="font-bold text-white">write_themes</span> too.
|
||||
Go to your Shopify Admin > Settings > Apps and sales channels > Develop apps. Create an app and configure Admin API scopes. To <span className="font-bold text-white">load stock</span> from Inventory, enable <span className="font-bold text-white">read_inventory</span>, <span className="font-bold text-white">write_inventory</span>, and <span className="font-bold text-white">read_locations</span>. For the storefront chat widget auto-injection, enable <span className="font-bold text-white">read_themes</span> and <span className="font-bold text-white">write_themes</span> too.
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { Send } from "lucide-react"
|
||||
|
||||
type WidgetMessage = {
|
||||
@ -225,13 +226,18 @@ export default function StorefrontChatWidgetPage() {
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.message || "Invalid code")
|
||||
}
|
||||
setSessionUntil(
|
||||
typeof data.sessionExpiresAt === "string" ? data.sessionExpiresAt : null,
|
||||
)
|
||||
const expiresRaw =
|
||||
typeof data.sessionExpiresAt === "string" ? data.sessionExpiresAt : null
|
||||
setSessionUntil(expiresRaw)
|
||||
setOtpCode("")
|
||||
setOtpPhase("verified")
|
||||
const expiresAt = expiresRaw ? new Date(expiresRaw) : null
|
||||
const distanceOk =
|
||||
expiresAt && !Number.isNaN(expiresAt.getTime()) && expiresAt.getTime() > Date.now()
|
||||
setOtpMessage(
|
||||
"You’re verified. You can ask about your orders for about one hour. We’ll use this session until it expires.",
|
||||
distanceOk
|
||||
? `You’re verified. This session stays active ${formatDistanceToNow(expiresAt!, { addSuffix: true })}. You can ask about your orders until then.`
|
||||
: "You’re verified. You can ask about your orders until this session expires.",
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
setOtpMessage(e instanceof Error ? e.message : "Verification failed")
|
||||
|
||||
@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Loader2, AlertCircle, Package, Truck, Save, CheckCircle2, User, Mail, Phone, MapPin, X } from "lucide-react"
|
||||
import toast from "react-hot-toast"
|
||||
import { useTheme } from "@/components/ThemeProvider"
|
||||
import {
|
||||
SUPPLIER_HOLD_REASON_OPTIONS,
|
||||
type SupplierHoldReasonCode,
|
||||
@ -63,6 +64,8 @@ export default function SupplierOrderDetailsPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const orderId = params?.orderId as string
|
||||
const { theme } = useTheme()
|
||||
const isLight = theme === "light"
|
||||
|
||||
const [order, setOrder] = useState<SupplierOrder | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -474,26 +477,39 @@ export default function SupplierOrderDetailsPage() {
|
||||
|
||||
{holdModalOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70"
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center p-4 ${isLight ? "bg-slate-900/40 backdrop-blur-sm" : "bg-black/70"}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="hold-modal-title"
|
||||
>
|
||||
<div className="w-full max-w-md rounded-2xl border border-white/15 bg-slate-900 p-5 shadow-xl space-y-4">
|
||||
<div
|
||||
className={`w-full max-w-md rounded-2xl p-5 shadow-xl space-y-4 ${
|
||||
isLight
|
||||
? "bg-white border border-slate-200"
|
||||
: "border border-white/15 bg-slate-900"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 id="hold-modal-title" className="text-lg font-bold text-white">
|
||||
<h2
|
||||
id="hold-modal-title"
|
||||
className={`text-lg font-bold ${isLight ? "text-slate-900" : "text-white"}`}
|
||||
>
|
||||
On hold reason
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelHoldModal}
|
||||
className="p-1 rounded-lg text-slate-400 hover:bg-white/10 hover:text-white"
|
||||
className={`p-1 rounded-lg ${
|
||||
isLight
|
||||
? "text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
: "text-slate-400 hover:bg-white/10 hover:text-white"
|
||||
}`}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">
|
||||
<p className={`text-sm ${isLight ? "text-slate-600" : "text-slate-400"}`}>
|
||||
Select why this order is on hold. Required before you can save.
|
||||
</p>
|
||||
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
|
||||
@ -502,8 +518,12 @@ export default function SupplierOrderDetailsPage() {
|
||||
key={opt.value}
|
||||
className={`flex items-start gap-3 rounded-xl border p-3 cursor-pointer ${
|
||||
modalReason === opt.value
|
||||
? "border-indigo-500/60 bg-indigo-500/15"
|
||||
: "border-white/10 bg-white/5"
|
||||
? isLight
|
||||
? "border-indigo-500 bg-indigo-50"
|
||||
: "border-indigo-500/60 bg-indigo-500/15"
|
||||
: isLight
|
||||
? "border-slate-200 bg-slate-50"
|
||||
: "border-white/10 bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
@ -513,20 +533,32 @@ export default function SupplierOrderDetailsPage() {
|
||||
checked={modalReason === opt.value}
|
||||
onChange={() => setModalReason(opt.value)}
|
||||
/>
|
||||
<span className="text-sm text-white font-medium">{opt.label}</span>
|
||||
<span
|
||||
className={`text-sm font-medium ${isLight ? "text-slate-900" : "text-white"}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{modalReason === "other" ? (
|
||||
<div>
|
||||
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Note</label>
|
||||
<label
|
||||
className={`block text-xs uppercase font-bold mb-2 ${isLight ? "text-slate-500" : "text-slate-400"}`}
|
||||
>
|
||||
Note
|
||||
</label>
|
||||
<textarea
|
||||
value={modalNote}
|
||||
onChange={(e) => setModalNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder="Describe the hold…"
|
||||
className="w-full rounded-xl bg-white/10 border border-white/10 text-white px-3 py-2 text-sm"
|
||||
className={
|
||||
isLight
|
||||
? "w-full rounded-xl bg-white border border-slate-200 text-slate-900 placeholder:text-slate-400 px-3 py-2 text-sm"
|
||||
: "w-full rounded-xl bg-white/10 border border-white/10 text-white px-3 py-2 text-sm"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@ -534,7 +566,11 @@ export default function SupplierOrderDetailsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelHoldModal}
|
||||
className="h-10 px-4 rounded-xl bg-white/10 text-white font-semibold"
|
||||
className={
|
||||
isLight
|
||||
? "h-10 px-4 rounded-xl bg-slate-100 text-slate-800 font-semibold hover:bg-slate-200"
|
||||
: "h-10 px-4 rounded-xl bg-white/10 text-white font-semibold"
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { Bell, Loader2, CheckCircle2, Clock, XCircle, Mail, Package, ShoppingCart, X } from "lucide-react"
|
||||
import { Bell, Loader2, CheckCircle2, Clock, XCircle, Mail, Package, ShoppingCart, X, AlertTriangle } from "lucide-react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { useTheme } from "@/components/ThemeProvider"
|
||||
@ -117,6 +117,8 @@ export default function NotificationBell() {
|
||||
return <ShoppingCart className="w-4 h-4 text-blue-400" />
|
||||
case "PRODUCT_UPDATED":
|
||||
return <Package className="w-4 h-4 text-purple-400" />
|
||||
case "SHOPIFY_LOW_STOCK":
|
||||
return <AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
default:
|
||||
return <Bell className="w-4 h-4 text-slate-400" />
|
||||
}
|
||||
|
||||
@ -64,6 +64,23 @@ type AttachProduct = {
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
/** Matches the line inserted when picking a product from + attach (see insertProduct). */
|
||||
function defaultProductAttachSummary(title: string, storeName: string | null | undefined): string {
|
||||
const t = title.trim()
|
||||
const s = (storeName ?? "").trim()
|
||||
return s ? `Product: ${t} · ${s}` : `Product: ${t}`
|
||||
}
|
||||
|
||||
/** Hide duplicate body when the message is only the auto summary; show automation / user-typed text. */
|
||||
function shouldHideProductMessageBody(
|
||||
content: string,
|
||||
attachment: { title?: string; storeName?: string | null } | null,
|
||||
): boolean {
|
||||
const c = content.trim()
|
||||
if (!c || !attachment?.title) return true
|
||||
return c === defaultProductAttachSummary(attachment.title, attachment.storeName ?? null)
|
||||
}
|
||||
|
||||
export function ChatView() {
|
||||
const pathname = usePathname()
|
||||
const isSupplier = pathname.startsWith("/supplier")
|
||||
@ -692,46 +709,63 @@ export function ChatView() {
|
||||
: "bg-white/5 border border-white/10 text-slate-200 rounded-tl-none backdrop-blur-sm shadow-xl"
|
||||
)}>
|
||||
{displayKind === "PRODUCT" && displayAttachment ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveAttachmentKind("PRODUCT")
|
||||
setActiveAttachment(displayAttachment)
|
||||
setAttachmentModalOpen(true)
|
||||
}}
|
||||
className={cn("w-full text-left bg-transparent border-0 p-0 m-0")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-xl overflow-hidden bg-black/10 border border-white/10 flex-shrink-0 flex items-center justify-center">
|
||||
{displayAttachment.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={displayAttachment.imageUrl} alt={displayAttachment.title || "Product"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center px-1">
|
||||
<Package className="w-5 h-5 text-indigo-400" />
|
||||
<div className="text-[10px] font-black mt-1 leading-none text-indigo-200">
|
||||
{displayAttachment.title ? displayAttachment.title : ""}
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveAttachmentKind("PRODUCT")
|
||||
setActiveAttachment(displayAttachment)
|
||||
setAttachmentModalOpen(true)
|
||||
}}
|
||||
className={cn("w-full text-left bg-transparent border-0 p-0 m-0")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-xl overflow-hidden bg-black/10 border border-white/10 flex-shrink-0 flex items-center justify-center">
|
||||
{displayAttachment.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={displayAttachment.imageUrl} alt={displayAttachment.title || "Product"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center px-1">
|
||||
<Package className="w-5 h-5 text-indigo-400" />
|
||||
<div className="text-[10px] font-black mt-1 leading-none text-indigo-200">
|
||||
{displayAttachment.title ? displayAttachment.title : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-black truncate">
|
||||
{displayAttachment.title || msg.content}
|
||||
)}
|
||||
</div>
|
||||
{displayAttachment.description ? (
|
||||
<div className={cn("text-xs opacity-90 mt-1 line-clamp-2")}>
|
||||
{displayAttachment.description}
|
||||
<div className="min-w-0">
|
||||
<div className="font-black truncate">
|
||||
{displayAttachment.title || msg.content}
|
||||
</div>
|
||||
) : null}
|
||||
{displayAttachment.storeName ? (
|
||||
<div className={cn("text-[10px] font-black uppercase tracking-widest mt-1 opacity-80")}>
|
||||
{displayAttachment.storeName}
|
||||
</div>
|
||||
) : null}
|
||||
{displayAttachment.description ? (
|
||||
<div className={cn("text-xs opacity-90 mt-1 line-clamp-2")}>
|
||||
{displayAttachment.description}
|
||||
</div>
|
||||
) : null}
|
||||
{displayAttachment.storeName ? (
|
||||
<div className={cn("text-[10px] font-black uppercase tracking-widest mt-1 opacity-80")}>
|
||||
{displayAttachment.storeName}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
{msg.content.trim() &&
|
||||
!shouldHideProductMessageBody(msg.content, displayAttachment) ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-3 pt-3 border-t text-sm font-medium whitespace-pre-wrap break-words",
|
||||
isMe
|
||||
? "border-white/25 text-white/95"
|
||||
: isLight
|
||||
? "border-slate-200 text-slate-800"
|
||||
: "border-white/10 text-slate-200",
|
||||
)}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : displayKind === "ORDER" && displayAttachment ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -2,7 +2,10 @@ import { prisma } from "@/lib/prisma"
|
||||
import { storeSelectDashboardStorefrontInbox } from "@/lib/prisma/storeSelects"
|
||||
import { auth } from "@/auth.vendor"
|
||||
import { redirect } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import StorefrontChatInboxClient, {
|
||||
type SerializedConversationCard,
|
||||
type SerializedStorefrontMessage,
|
||||
} from "@/components/storefront/StorefrontChatInboxClient"
|
||||
|
||||
export default async function StorefrontChatInbox() {
|
||||
const session = await auth()
|
||||
@ -31,27 +34,54 @@ export default async function StorefrontChatInbox() {
|
||||
|
||||
const conversationIds = conversations.map((c) => c.id)
|
||||
|
||||
const messages = await prisma.storefrontChatMessage.findMany({
|
||||
where: conversationIds.length ? { conversationId: { in: conversationIds } } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
senderType: true,
|
||||
conversation: {
|
||||
select: {
|
||||
visitorId: true,
|
||||
customerEmail: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
/** Most recent messages store-wide (then grouped per conversation); avoids loading unbounded history. */
|
||||
const messages = conversationIds.length
|
||||
? await prisma.storefrontChatMessage.findMany({
|
||||
where: { conversationId: { in: conversationIds } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 800,
|
||||
select: {
|
||||
id: true,
|
||||
conversationId: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
senderType: true,
|
||||
},
|
||||
})
|
||||
: []
|
||||
|
||||
const byConversationId = new Map<string, typeof messages>()
|
||||
for (const m of messages) {
|
||||
const list = byConversationId.get(m.conversationId) ?? []
|
||||
list.push(m)
|
||||
byConversationId.set(m.conversationId, list)
|
||||
}
|
||||
for (const list of byConversationId.values()) {
|
||||
list.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
}
|
||||
|
||||
const serializedCards: SerializedConversationCard[] = conversations.map((c) => {
|
||||
const thread = byConversationId.get(c.id) ?? []
|
||||
const serializedMessages: SerializedStorefrontMessage[] = thread.map((m) => ({
|
||||
id: m.id,
|
||||
conversationId: m.conversationId,
|
||||
content: m.content,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
senderType: m.senderType,
|
||||
}))
|
||||
return {
|
||||
id: c.id,
|
||||
visitorId: c.visitorId,
|
||||
customerEmail: c.customerEmail,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
messages: serializedMessages,
|
||||
}
|
||||
})
|
||||
|
||||
const sorted = [...messages].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
const aiCount = sorted.filter((m) => m.senderType === "AI").length
|
||||
const customerCount = sorted.filter((m) => m.senderType === "CUSTOMER").length
|
||||
const cardsWithMessages = serializedCards.filter((c) => c.messages.length > 0)
|
||||
|
||||
const aiCount = messages.filter((m) => m.senderType === "AI").length
|
||||
const customerCount = messages.filter((m) => m.senderType === "CUSTOMER").length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-1">
|
||||
@ -63,74 +93,33 @@ export default async function StorefrontChatInbox() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-sm font-black text-white">{conversations.length} visitors</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{sorted.length} messages</p>
|
||||
<p className="text-sm font-black text-white">
|
||||
{cardsWithMessages.length} conversation{cardsWithMessages.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{messages.length} messages</p>
|
||||
<p className="text-[11px] text-slate-500 mt-1">
|
||||
{customerCount} customer · {aiCount} AI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-400">
|
||||
L1 AI modules and catalog metafields:{" "}
|
||||
<Link
|
||||
href="/dashboard/juno-engine/modules"
|
||||
className="font-semibold text-indigo-400 hover:underline"
|
||||
>
|
||||
JUNO Engine → Modules
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
{conversations.length === 0 ? (
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 text-slate-300">
|
||||
<p className="font-black">No storefront chat messages yet.</p>
|
||||
<p className="font-black">No storefront chat conversations yet.</p>
|
||||
<p className="text-sm text-slate-400 mt-2">
|
||||
When customers send a message from the widget, it will show here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden">
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
||||
<p className="text-sm font-black text-white">Recent activity</p>
|
||||
<p className="text-xs text-slate-400">Read-only</p>
|
||||
</div>
|
||||
<div className="max-h-[70vh] overflow-y-auto p-4 space-y-3">
|
||||
{sorted.map((m) => (
|
||||
<div key={m.id} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={`text-[10px] font-black uppercase tracking-widest rounded-full px-2 py-1 border ${
|
||||
m.senderType === "AI"
|
||||
? "text-indigo-300 border-indigo-400/30 bg-indigo-500/10"
|
||||
: "text-emerald-300 border-emerald-400/30 bg-emerald-500/10"
|
||||
}`}
|
||||
>
|
||||
{m.senderType === "CUSTOMER" ? "CUSTOMER" : "AI"}
|
||||
</span>
|
||||
<p className="text-xs font-black uppercase tracking-widest text-slate-400 truncate">
|
||||
{m.conversation?.visitorId || "visitor"}
|
||||
</p>
|
||||
</div>
|
||||
{m.conversation?.customerEmail ? (
|
||||
<p className="text-[11px] text-slate-500 truncate">
|
||||
{m.conversation.customerEmail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 shrink-0">
|
||||
{new Date(m.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm text-white/90 bg-black/20 border border-white/10 rounded-2xl px-4 py-3">
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : cardsWithMessages.length === 0 ? (
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 text-slate-300">
|
||||
<p className="font-black">No messages yet.</p>
|
||||
<p className="text-sm text-slate-400 mt-2">
|
||||
Sessions without any messages are hidden. Once a customer sends a message from the
|
||||
widget, the conversation will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<StorefrontChatInboxClient cards={cardsWithMessages} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
344
src/components/storefront/StorefrontChatInboxClient.tsx
Normal file
344
src/components/storefront/StorefrontChatInboxClient.tsx
Normal file
@ -0,0 +1,344 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { MessageCircle, MoreHorizontal, X } from "lucide-react"
|
||||
import { useTheme } from "@/components/ThemeProvider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type SerializedStorefrontMessage = {
|
||||
id: string
|
||||
conversationId: string
|
||||
content: string
|
||||
createdAt: string
|
||||
senderType: "CUSTOMER" | "AI"
|
||||
}
|
||||
|
||||
export type SerializedConversationCard = {
|
||||
id: string
|
||||
visitorId: string
|
||||
customerEmail: string | null
|
||||
updatedAt: string
|
||||
messages: SerializedStorefrontMessage[]
|
||||
}
|
||||
|
||||
function shortVisitorLabel(visitorId: string): string {
|
||||
const v = visitorId.trim()
|
||||
if (v.length <= 14) return v
|
||||
return `${v.slice(0, 8)}…${v.slice(-4)}`
|
||||
}
|
||||
|
||||
export default function StorefrontChatInboxClient({ cards }: { cards: SerializedConversationCard[] }) {
|
||||
const { theme } = useTheme()
|
||||
const isLight = theme === "light"
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
const open = openId ? cards.find((c) => c.id === openId) : null
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpenId(null)
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (openId) {
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = "hidden"
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
}
|
||||
}
|
||||
}, [openId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-black",
|
||||
isLight ? "text-slate-900" : "text-white",
|
||||
)}
|
||||
>
|
||||
Conversations
|
||||
</p>
|
||||
<p className={cn("text-xs", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||
Read-only · click a card to open
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 md:gap-6">
|
||||
{cards.map((card) => {
|
||||
const thread = card.messages
|
||||
const last = thread[thread.length - 1]
|
||||
const snippet =
|
||||
last?.content?.trim().slice(0, 160) ||
|
||||
(thread.length === 0 ? "No messages in this session." : "")
|
||||
const nCustomer = thread.filter((m) => m.senderType === "CUSTOMER").length
|
||||
const nAi = thread.filter((m) => m.senderType === "AI").length
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setOpenId(card.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
setOpenId(card.id)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-xl border p-5 transition-all cursor-pointer group relative outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40",
|
||||
isLight
|
||||
? "bg-white border-slate-200 shadow-sm hover:bg-slate-50 hover:border-indigo-300"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-indigo-500/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-lg border flex items-center justify-center shrink-0 transition-all",
|
||||
isLight
|
||||
? "bg-indigo-50 border-indigo-200 text-indigo-600 group-hover:bg-indigo-100"
|
||||
: "bg-indigo-500/20 border-indigo-400/20 text-indigo-400 group-hover:bg-indigo-500/30",
|
||||
)}
|
||||
>
|
||||
<MessageCircle className="w-5 h-5" aria-hidden />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3
|
||||
className={cn(
|
||||
"font-black text-base font-mono tracking-tight truncate",
|
||||
isLight ? "text-slate-900" : "text-white",
|
||||
)}
|
||||
title={card.visitorId}
|
||||
>
|
||||
{shortVisitorLabel(card.visitorId)}
|
||||
</h3>
|
||||
{card.customerEmail ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] mt-0.5 truncate",
|
||||
isLight ? "text-slate-600" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{card.customerEmail}
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] mt-0.5",
|
||||
isLight ? "text-slate-500" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
No email yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"p-2 rounded-lg transition-colors shrink-0 pointer-events-none",
|
||||
isLight
|
||||
? "text-slate-400 group-hover:text-indigo-600"
|
||||
: "text-slate-400 group-hover:text-indigo-400",
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" aria-hidden />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs line-clamp-3",
|
||||
isLight ? "text-slate-600" : "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{snippet}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 text-xs flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border",
|
||||
isLight
|
||||
? "bg-emerald-50 text-emerald-800 border-emerald-200"
|
||||
: "bg-emerald-500/15 text-emerald-300 border-emerald-400/25",
|
||||
)}
|
||||
>
|
||||
{nCustomer} customer
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border",
|
||||
isLight
|
||||
? "bg-indigo-50 text-indigo-800 border-indigo-200"
|
||||
: "bg-indigo-500/15 text-indigo-300 border-indigo-400/25",
|
||||
)}
|
||||
>
|
||||
{nAi} AI
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border",
|
||||
isLight
|
||||
? "bg-slate-100 text-slate-700 border-slate-200"
|
||||
: "bg-white/5 text-slate-300 border-white/10",
|
||||
)}
|
||||
>
|
||||
{thread.length} messages
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-right text-[11px] shrink-0",
|
||||
isLight ? "text-slate-500" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
<p>Updated {new Date(card.updatedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-[200]" role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute inset-0 backdrop-blur-sm border-0 cursor-default w-full h-full",
|
||||
isLight ? "bg-slate-900/25" : "bg-black/60",
|
||||
)}
|
||||
aria-label="Close conversation"
|
||||
onClick={() => setOpenId(null)}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="storefront-chat-dialog-title"
|
||||
className={cn(
|
||||
"pointer-events-auto w-full max-w-2xl max-h-[85vh] flex flex-col rounded-2xl border shadow-2xl overflow-hidden",
|
||||
isLight
|
||||
? "bg-white border-slate-200 text-slate-900"
|
||||
: "bg-slate-900 border-white/10 text-white",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start justify-between gap-4 p-5 border-b shrink-0",
|
||||
isLight ? "border-slate-200" : "border-white/10",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2
|
||||
id="storefront-chat-dialog-title"
|
||||
className={cn(
|
||||
"text-lg font-black font-mono tracking-tight truncate",
|
||||
isLight ? "text-slate-900" : "text-white",
|
||||
)}
|
||||
title={open.visitorId}
|
||||
>
|
||||
{shortVisitorLabel(open.visitorId)}
|
||||
</h2>
|
||||
{open.customerEmail ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm mt-1 truncate",
|
||||
isLight ? "text-slate-600" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{open.customerEmail}
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm mt-1",
|
||||
isLight ? "text-slate-500" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
No email on file
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenId(null)}
|
||||
className={cn(
|
||||
"p-2 rounded-xl transition-colors shrink-0",
|
||||
isLight
|
||||
? "text-slate-500 hover:text-slate-900 hover:bg-slate-100"
|
||||
: "text-slate-400 hover:text-white hover:bg-white/10",
|
||||
)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-3">
|
||||
{open.messages.length === 0 ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm text-center py-8",
|
||||
isLight ? "text-slate-500" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
No messages in this thread.
|
||||
</p>
|
||||
) : (
|
||||
open.messages.map((m) => (
|
||||
<div key={m.id} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-black uppercase tracking-widest rounded-full px-2 py-0.5 border shrink-0",
|
||||
m.senderType === "AI"
|
||||
? isLight
|
||||
? "text-indigo-800 border-indigo-200 bg-indigo-50"
|
||||
: "text-indigo-300 border-indigo-400/30 bg-indigo-500/10"
|
||||
: isLight
|
||||
? "text-emerald-800 border-emerald-200 bg-emerald-50"
|
||||
: "text-emerald-300 border-emerald-400/30 bg-emerald-500/10",
|
||||
)}
|
||||
>
|
||||
{m.senderType === "CUSTOMER" ? "Customer" : "AI"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] tabular-nums",
|
||||
isLight ? "text-slate-500" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{new Date(m.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-pre-wrap text-sm rounded-xl px-4 py-3 border",
|
||||
isLight
|
||||
? "text-slate-800 bg-slate-50 border-slate-200"
|
||||
: "text-white/90 bg-black/30 border-white/10",
|
||||
)}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -80,8 +80,15 @@ export const productSyncDecisionSchema = z.object({
|
||||
action: z.enum(["accept", "reject"]),
|
||||
})
|
||||
|
||||
/** POST /api/shopify/inventory/load-stock */
|
||||
export const loadStockRequestSchema = z.object({
|
||||
productSyncId: z.string().trim().min(1).max(128),
|
||||
quantity: z.number().int().positive().max(1e9),
|
||||
})
|
||||
|
||||
export type SupplierProductCreateInput = z.infer<typeof supplierProductCreateSchema>
|
||||
export type SupplierProductUpdateInput = z.infer<typeof supplierProductUpdateSchema>
|
||||
export type ProductSyncRequestInput = z.infer<typeof productSyncRequestSchema>
|
||||
export type SupplierProfileUpsertInput = z.infer<typeof supplierProfileUpsertSchema>
|
||||
export type ProductSyncDecisionInput = z.infer<typeof productSyncDecisionSchema>
|
||||
export type LoadStockRequestInput = z.infer<typeof loadStockRequestSchema>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
productSyncRequestSchema,
|
||||
supplierProfileUpsertSchema,
|
||||
productSyncDecisionSchema,
|
||||
loadStockRequestSchema,
|
||||
} from "./business"
|
||||
import { notificationsMarkReadSchema } from "./vendorMutations"
|
||||
import { webPushSubscribeSchema, webPushUnsubscribeSchema } from "./notificationsPush"
|
||||
@ -120,6 +121,16 @@ describe("productSyncDecisionSchema", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadStockRequestSchema", () => {
|
||||
it("accepts valid payload", () => {
|
||||
const r = loadStockRequestSchema.safeParse({ productSyncId: "clxyz123", quantity: 5 })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
it("rejects non-positive quantity", () => {
|
||||
expect(loadStockRequestSchema.safeParse({ productSyncId: "a", quantity: 0 }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("notificationsMarkReadSchema", () => {
|
||||
it("requires markAll or ids", () => {
|
||||
expect(notificationsMarkReadSchema.safeParse({ markAllAsRead: true }).success).toBe(true)
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { sumVariantInventoryQuantity } from "@/lib/products/shopifyInventorySum"
|
||||
import {
|
||||
sumVariantInventoryQuantity,
|
||||
supplierQuantityFromSyncedProductData,
|
||||
} from "@/lib/products/shopifyInventorySum"
|
||||
import { postB2bChatMessage } from "@/lib/chat/postB2bChatMessage"
|
||||
import { notifyVendorShopifyLowStock } from "@/lib/notifications"
|
||||
import {
|
||||
isLowStockRateLimited,
|
||||
logSupplierAutomation,
|
||||
@ -14,6 +18,7 @@ type ShopifyProductWebhookBody = {
|
||||
id?: unknown
|
||||
title?: unknown
|
||||
variants?: unknown
|
||||
images?: unknown
|
||||
}
|
||||
|
||||
function shopifyProductIdString(body: ShopifyProductWebhookBody): string | null {
|
||||
@ -22,39 +27,149 @@ function shopifyProductIdString(body: ShopifyProductWebhookBody): string | null
|
||||
return String(id)
|
||||
}
|
||||
|
||||
function buildLowStockMessage(params: {
|
||||
storeName: string
|
||||
productTitle: string
|
||||
function extractImageUrlFromImagesField(images: unknown): string | null {
|
||||
if (!Array.isArray(images) || images.length === 0) return null
|
||||
const first = images[0] as { src?: string; url?: string }
|
||||
return first?.src || first?.url || null
|
||||
}
|
||||
|
||||
function buildLowStockTextContent(supplierQty: number): string {
|
||||
return `Supplier-reported quantity: ${supplierQty}. Please restock or share an ETA when you can.`
|
||||
}
|
||||
|
||||
async function runShopifyLowStockVendorNotification(params: {
|
||||
storeId: string
|
||||
vendorUserId: string
|
||||
shopifyProductId: string
|
||||
currentQty: number
|
||||
title: string
|
||||
shopifyTotal: number
|
||||
threshold: number
|
||||
shopifyDomain: string
|
||||
}): string {
|
||||
const domain = params.shopifyDomain
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "")
|
||||
const adminUrl = `https://${domain}/admin/products/${params.shopifyProductId}`
|
||||
return [
|
||||
`[Low stock — automated]`,
|
||||
`Store: ${params.storeName}`,
|
||||
`Product: ${params.productTitle}`,
|
||||
`Shopify product ID: ${params.shopifyProductId}`,
|
||||
`Total inventory (sum of variants): ${params.currentQty}`,
|
||||
`Store threshold: ${params.threshold}`,
|
||||
`Please confirm restock or ETA when you can.`,
|
||||
`Admin: ${adminUrl}`,
|
||||
].join("\n")
|
||||
}): Promise<void> {
|
||||
const { storeId, vendorUserId, shopifyProductId, title, shopifyTotal, threshold: shopifyTh } = params
|
||||
|
||||
const state = await prisma.lowStockShopifyVendorAlertState.findUnique({
|
||||
where: {
|
||||
storeId_shopifyProductId: { storeId, shopifyProductId },
|
||||
},
|
||||
})
|
||||
|
||||
const upsertAboveOrClear = async () => {
|
||||
await prisma.lowStockShopifyVendorAlertState.upsert({
|
||||
where: { storeId_shopifyProductId: { storeId, shopifyProductId } },
|
||||
create: {
|
||||
storeId,
|
||||
shopifyProductId,
|
||||
lastSeenShopifyTotal: shopifyTotal,
|
||||
lastNotifiedBelowAt: null,
|
||||
},
|
||||
update: {
|
||||
lastSeenShopifyTotal: shopifyTotal,
|
||||
lastNotifiedBelowAt: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (shopifyTotal >= shopifyTh) {
|
||||
await upsertAboveOrClear()
|
||||
return
|
||||
}
|
||||
|
||||
const prevQty = state?.lastSeenShopifyTotal ?? null
|
||||
if (
|
||||
!shouldSendLowStockSupplierMessage({
|
||||
newQty: shopifyTotal,
|
||||
threshold: shopifyTh,
|
||||
prevQty,
|
||||
lastNotifiedBelowAt: state?.lastNotifiedBelowAt ?? null,
|
||||
})
|
||||
) {
|
||||
await prisma.lowStockShopifyVendorAlertState.upsert({
|
||||
where: { storeId_shopifyProductId: { storeId, shopifyProductId } },
|
||||
create: {
|
||||
storeId,
|
||||
shopifyProductId,
|
||||
lastSeenShopifyTotal: shopifyTotal,
|
||||
lastNotifiedBelowAt: null,
|
||||
},
|
||||
update: { lastSeenShopifyTotal: shopifyTotal },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await notifyVendorShopifyLowStock(vendorUserId, {
|
||||
storeId,
|
||||
shopifyProductId,
|
||||
productTitle: title,
|
||||
shopifyTotal,
|
||||
threshold: shopifyTh,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.warn("[lowStock] notifyVendorShopifyLowStock failed", e)
|
||||
await prisma.lowStockShopifyVendorAlertState.upsert({
|
||||
where: { storeId_shopifyProductId: { storeId, shopifyProductId } },
|
||||
create: {
|
||||
storeId,
|
||||
shopifyProductId,
|
||||
lastSeenShopifyTotal: shopifyTotal,
|
||||
lastNotifiedBelowAt: null,
|
||||
},
|
||||
update: { lastSeenShopifyTotal: shopifyTotal },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
await prisma.lowStockShopifyVendorAlertState.upsert({
|
||||
where: { storeId_shopifyProductId: { storeId, shopifyProductId } },
|
||||
create: {
|
||||
storeId,
|
||||
shopifyProductId,
|
||||
lastSeenShopifyTotal: shopifyTotal,
|
||||
lastNotifiedBelowAt: now,
|
||||
},
|
||||
update: {
|
||||
lastSeenShopifyTotal: shopifyTotal,
|
||||
lastNotifiedBelowAt: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function buildProductChatAttachment(params: {
|
||||
storeId: string
|
||||
shopifyProductId: string
|
||||
title: string
|
||||
description: string | null
|
||||
imageUrl: string | null
|
||||
}): {
|
||||
title: string
|
||||
description: string | null
|
||||
imageUrl: string | null
|
||||
storeName: null
|
||||
storeId: string
|
||||
productId: number
|
||||
} {
|
||||
const productId = Number.parseInt(params.shopifyProductId, 10)
|
||||
return {
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
imageUrl: params.imageUrl,
|
||||
storeName: null,
|
||||
storeId: params.storeId,
|
||||
productId: Number.isFinite(productId) ? productId : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After Shopify product webhook updates cache: evaluate low-stock and notify linked supplier(s).
|
||||
* @param prevTotalQty - total from CachedProduct variants before this update; null if no prior row
|
||||
* After Shopify product webhook updates cache (or during reconcile):
|
||||
* - If `lowStockShopifyThreshold` is set: compare total Shopify variant inventory to threshold; notify the vendor
|
||||
* in-app when appropriate (deduped per product).
|
||||
* - If `lowStockThreshold` is set: for each ACCEPTED ProductSync, compare supplier-reported quantity; notify that
|
||||
* supplier via a PRODUCT chat card (unchanged).
|
||||
*/
|
||||
export async function evaluateLowStockAfterProductWebhook(params: {
|
||||
storeId: string
|
||||
shopifyProductBody: ShopifyProductWebhookBody
|
||||
prevTotalQty: number | null
|
||||
}): Promise<void> {
|
||||
const shopifyProductId = shopifyProductIdString(params.shopifyProductBody)
|
||||
if (!shopifyProductId) return
|
||||
@ -63,20 +178,19 @@ export async function evaluateLowStockAfterProductWebhook(params: {
|
||||
where: { id: params.storeId },
|
||||
select: {
|
||||
lowStockThreshold: true,
|
||||
lowStockShopifyThreshold: true,
|
||||
userId: true,
|
||||
businessName: true,
|
||||
shopifyDomain: true,
|
||||
},
|
||||
})
|
||||
|
||||
const threshold = store?.lowStockThreshold
|
||||
if (store === null || threshold === null || threshold === undefined) {
|
||||
const supplierThreshold = store?.lowStockThreshold
|
||||
const shopifyThreshold = store?.lowStockShopifyThreshold
|
||||
if (store === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const variants = params.shopifyProductBody.variants
|
||||
const newQty = sumVariantInventoryQuantity(variants)
|
||||
if (newQty === null) {
|
||||
const hasSupplierRule = supplierThreshold !== null && supplierThreshold !== undefined
|
||||
const hasShopifyRule = shopifyThreshold !== null && shopifyThreshold !== undefined
|
||||
if (!hasSupplierRule && !hasShopifyRule) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -85,58 +199,57 @@ export async function evaluateLowStockAfterProductWebhook(params: {
|
||||
? params.shopifyProductBody.title
|
||||
: "Product"
|
||||
|
||||
const state = await prisma.lowStockAlertState.findUnique({
|
||||
where: {
|
||||
storeId_shopifyProductId: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
},
|
||||
},
|
||||
})
|
||||
let imageUrl = extractImageUrlFromImagesField(params.shopifyProductBody.images)
|
||||
|
||||
if (newQty >= threshold) {
|
||||
await prisma.lowStockAlertState.upsert({
|
||||
let cachedProductRow: { images: unknown; variants: unknown } | null = null
|
||||
const loadCachedProductRow = async (): Promise<{ images: unknown; variants: unknown } | null> => {
|
||||
if (cachedProductRow !== null) return cachedProductRow
|
||||
cachedProductRow = await prisma.cachedProduct.findUnique({
|
||||
where: {
|
||||
storeId_shopifyProductId: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId_storeId: {
|
||||
shopifyProductId,
|
||||
storeId: params.storeId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
select: { images: true, variants: true },
|
||||
})
|
||||
return cachedProductRow
|
||||
}
|
||||
|
||||
if (imageUrl === null) {
|
||||
const row = await loadCachedProductRow()
|
||||
imageUrl = extractImageUrlFromImagesField(row?.images)
|
||||
}
|
||||
|
||||
if (hasShopifyRule) {
|
||||
const shopifyTh = shopifyThreshold as number
|
||||
let shopifyTotal = sumVariantInventoryQuantity(params.shopifyProductBody.variants)
|
||||
if (shopifyTotal === null) {
|
||||
const row = await loadCachedProductRow()
|
||||
shopifyTotal = sumVariantInventoryQuantity(row?.variants)
|
||||
}
|
||||
if (shopifyTotal === null) {
|
||||
logger.info("[lowStock] skip Shopify vendor alert (no variant inventory totals)", {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
lastSeenTotalQty: newQty,
|
||||
lastNotifiedBelowAt: null,
|
||||
},
|
||||
update: {
|
||||
lastSeenTotalQty: newQty,
|
||||
lastNotifiedBelowAt: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
} else {
|
||||
await runShopifyLowStockVendorNotification({
|
||||
storeId: params.storeId,
|
||||
vendorUserId: store.userId,
|
||||
shopifyProductId,
|
||||
title,
|
||||
shopifyTotal,
|
||||
threshold: shopifyTh,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasSupplierRule) {
|
||||
return
|
||||
}
|
||||
|
||||
// Below threshold
|
||||
const prevQty = params.prevTotalQty
|
||||
if (
|
||||
!shouldSendLowStockSupplierMessage({
|
||||
newQty,
|
||||
threshold,
|
||||
prevQty,
|
||||
lastNotifiedBelowAt: state?.lastNotifiedBelowAt ?? null,
|
||||
})
|
||||
) {
|
||||
await prisma.lowStockAlertState.update({
|
||||
where: {
|
||||
storeId_shopifyProductId: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
},
|
||||
},
|
||||
data: { lastSeenTotalQty: newQty },
|
||||
})
|
||||
return
|
||||
}
|
||||
const threshold = supplierThreshold as number
|
||||
|
||||
const syncs = await prisma.productSync.findMany({
|
||||
where: {
|
||||
@ -152,33 +265,89 @@ export async function evaluateLowStockAfterProductWebhook(params: {
|
||||
})
|
||||
|
||||
if (!syncs.length) {
|
||||
logger.info("[lowStock] no ACCEPTED ProductSync for product", {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
})
|
||||
await prisma.lowStockAlertState.upsert({
|
||||
where: {
|
||||
storeId_shopifyProductId: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
lastSeenTotalQty: newQty,
|
||||
lastNotifiedBelowAt: null,
|
||||
},
|
||||
update: { lastSeenTotalQty: newQty },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const vendorUserId = store.userId
|
||||
const now = new Date()
|
||||
let anySent = false
|
||||
|
||||
for (const sync of syncs) {
|
||||
const supplierQty = supplierQuantityFromSyncedProductData(sync.shopifyProductData)
|
||||
if (supplierQty === undefined) {
|
||||
logger.info("[lowStock] skip sync (no supplier quantity in snapshot)", {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
productSyncId: sync.id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const state = await prisma.lowStockAlertState.findUnique({
|
||||
where: {
|
||||
storeId_shopifyProductId_supplierId: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
supplierId: sync.supplierId,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const upsertAboveOrClear = async () => {
|
||||
await prisma.lowStockAlertState.upsert({
|
||||
where: {
|
||||
storeId_shopifyProductId_supplierId: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
supplierId: sync.supplierId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
supplierId: sync.supplierId,
|
||||
lastSeenSupplierQty: supplierQty,
|
||||
lastNotifiedBelowAt: null,
|
||||
},
|
||||
update: {
|
||||
lastSeenSupplierQty: supplierQty,
|
||||
lastNotifiedBelowAt: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (supplierQty >= threshold) {
|
||||
await upsertAboveOrClear()
|
||||
continue
|
||||
}
|
||||
|
||||
const prevQty = state?.lastSeenSupplierQty ?? null
|
||||
if (
|
||||
!shouldSendLowStockSupplierMessage({
|
||||
newQty: supplierQty,
|
||||
threshold,
|
||||
prevQty,
|
||||
lastNotifiedBelowAt: state?.lastNotifiedBelowAt ?? null,
|
||||
})
|
||||
) {
|
||||
await prisma.lowStockAlertState.upsert({
|
||||
where: {
|
||||
storeId_shopifyProductId_supplierId: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
supplierId: sync.supplierId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
supplierId: sync.supplierId,
|
||||
lastSeenSupplierQty: supplierQty,
|
||||
lastNotifiedBelowAt: null,
|
||||
},
|
||||
update: { lastSeenSupplierQty: supplierQty },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const supplierUserId = sync.supplier.userId
|
||||
const limited = await isLowStockRateLimited(
|
||||
params.storeId,
|
||||
@ -195,24 +364,26 @@ export async function evaluateLowStockAfterProductWebhook(params: {
|
||||
continue
|
||||
}
|
||||
|
||||
const content = buildLowStockMessage({
|
||||
storeName: store.businessName,
|
||||
productTitle: title,
|
||||
const content = buildLowStockTextContent(supplierQty)
|
||||
const attachment = buildProductChatAttachment({
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
currentQty: newQty,
|
||||
threshold,
|
||||
shopifyDomain: store.shopifyDomain,
|
||||
title: sync.shopifyProductTitle || title,
|
||||
description: null,
|
||||
imageUrl,
|
||||
})
|
||||
|
||||
const posted = await postB2bChatMessage({
|
||||
senderId: vendorUserId,
|
||||
receiverId: supplierUserId,
|
||||
storeId: params.storeId,
|
||||
kind: "TEXT",
|
||||
kind: "PRODUCT",
|
||||
content,
|
||||
attachment,
|
||||
notify: true,
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
if (!posted.ok) {
|
||||
logger.warn("[lowStock] chat post failed", {
|
||||
storeId: params.storeId,
|
||||
@ -220,10 +391,26 @@ export async function evaluateLowStockAfterProductWebhook(params: {
|
||||
supplierUserId,
|
||||
reason: posted.reason,
|
||||
})
|
||||
await prisma.lowStockAlertState.upsert({
|
||||
where: {
|
||||
storeId_shopifyProductId_supplierId: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
supplierId: sync.supplierId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
supplierId: sync.supplierId,
|
||||
lastSeenSupplierQty: supplierQty,
|
||||
lastNotifiedBelowAt: null,
|
||||
},
|
||||
update: { lastSeenSupplierQty: supplierQty },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
anySent = true
|
||||
await logSupplierAutomation({
|
||||
storeId: params.storeId,
|
||||
kind: "LOW_STOCK",
|
||||
@ -231,26 +418,92 @@ export async function evaluateLowStockAfterProductWebhook(params: {
|
||||
supplierUserId,
|
||||
messagePreview: content.slice(0, 500),
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.lowStockAlertState.upsert({
|
||||
where: {
|
||||
storeId_shopifyProductId: {
|
||||
await prisma.lowStockAlertState.upsert({
|
||||
where: {
|
||||
storeId_shopifyProductId_supplierId: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
supplierId: sync.supplierId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
supplierId: sync.supplierId,
|
||||
lastSeenSupplierQty: supplierQty,
|
||||
lastNotifiedBelowAt: now,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId,
|
||||
lastSeenTotalQty: newQty,
|
||||
lastNotifiedBelowAt: anySent ? now : null,
|
||||
},
|
||||
update: {
|
||||
lastSeenTotalQty: newQty,
|
||||
...(anySent ? { lastNotifiedBelowAt: now } : {}),
|
||||
update: {
|
||||
lastSeenSupplierQty: supplierQty,
|
||||
lastNotifiedBelowAt: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile cached ACCEPTED-synced products for one store (missed webhooks / after threshold save).
|
||||
* Bounded work per invocation.
|
||||
*/
|
||||
export async function reconcileLowStockForStore(
|
||||
storeId: string,
|
||||
maxProductsPerStore = 120,
|
||||
): Promise<void> {
|
||||
const store = await prisma.store.findUnique({
|
||||
where: { id: storeId },
|
||||
select: {
|
||||
id: true,
|
||||
lowStockThreshold: true,
|
||||
lowStockShopifyThreshold: true,
|
||||
},
|
||||
})
|
||||
if (
|
||||
!store ||
|
||||
(store.lowStockThreshold === null && store.lowStockShopifyThreshold === null)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const syncRows = await prisma.productSync.findMany({
|
||||
where: {
|
||||
storeId: store.id,
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
select: { shopifyProductId: true },
|
||||
take: maxProductsPerStore * 3,
|
||||
})
|
||||
const seen = new Set<string>()
|
||||
const syncedIds: { shopifyProductId: string }[] = []
|
||||
for (const r of syncRows) {
|
||||
if (seen.has(r.shopifyProductId)) continue
|
||||
seen.add(r.shopifyProductId)
|
||||
syncedIds.push({ shopifyProductId: r.shopifyProductId })
|
||||
if (syncedIds.length >= maxProductsPerStore) break
|
||||
}
|
||||
|
||||
for (const row of syncedIds) {
|
||||
const cached = await prisma.cachedProduct.findUnique({
|
||||
where: {
|
||||
shopifyProductId_storeId: {
|
||||
shopifyProductId: row.shopifyProductId,
|
||||
storeId: store.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!cached) continue
|
||||
|
||||
const body: ShopifyProductWebhookBody = {
|
||||
id: row.shopifyProductId,
|
||||
title: cached.title,
|
||||
variants: cached.variants as unknown,
|
||||
images: cached.images as unknown,
|
||||
}
|
||||
await evaluateLowStockAfterProductWebhook({
|
||||
storeId: store.id,
|
||||
shopifyProductBody: body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -261,70 +514,18 @@ export async function reconcileLowStockForActiveStores(maxStores = 10, maxProduc
|
||||
const stores = await prisma.store.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
lowStockThreshold: { not: null },
|
||||
OR: [
|
||||
{ lowStockThreshold: { not: null } },
|
||||
{ lowStockShopifyThreshold: { not: null } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
lowStockThreshold: true,
|
||||
userId: true,
|
||||
businessName: true,
|
||||
shopifyDomain: true,
|
||||
},
|
||||
take: maxStores,
|
||||
})
|
||||
|
||||
for (const store of stores) {
|
||||
const threshold = store.lowStockThreshold
|
||||
if (threshold === null) continue
|
||||
|
||||
const syncRows = await prisma.productSync.findMany({
|
||||
where: {
|
||||
storeId: store.id,
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
select: { shopifyProductId: true },
|
||||
take: maxProductsPerStore * 3,
|
||||
})
|
||||
const seen = new Set<string>()
|
||||
const syncedIds: { shopifyProductId: string }[] = []
|
||||
for (const r of syncRows) {
|
||||
if (seen.has(r.shopifyProductId)) continue
|
||||
seen.add(r.shopifyProductId)
|
||||
syncedIds.push({ shopifyProductId: r.shopifyProductId })
|
||||
if (syncedIds.length >= maxProductsPerStore) break
|
||||
}
|
||||
|
||||
for (const row of syncedIds) {
|
||||
const cached = await prisma.cachedProduct.findUnique({
|
||||
where: {
|
||||
shopifyProductId_storeId: {
|
||||
shopifyProductId: row.shopifyProductId,
|
||||
storeId: store.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!cached) continue
|
||||
|
||||
const alertState = await prisma.lowStockAlertState.findUnique({
|
||||
where: {
|
||||
storeId_shopifyProductId: {
|
||||
storeId: store.id,
|
||||
shopifyProductId: row.shopifyProductId,
|
||||
},
|
||||
},
|
||||
})
|
||||
const prevForReconcile = alertState?.lastSeenTotalQty ?? null
|
||||
|
||||
const body: ShopifyProductWebhookBody = {
|
||||
id: row.shopifyProductId,
|
||||
title: cached.title,
|
||||
variants: cached.variants as unknown,
|
||||
}
|
||||
await evaluateLowStockAfterProductWebhook({
|
||||
storeId: store.id,
|
||||
shopifyProductBody: body,
|
||||
prevTotalQty: prevForReconcile,
|
||||
})
|
||||
}
|
||||
await reconcileLowStockForStore(store.id, maxProductsPerStore)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Whether to send a low-stock supplier chat for this inventory observation.
|
||||
* Used by webhook/reconcile paths and unit-tested in isolation.
|
||||
* `newQty` / `prevQty` are supplier-reported quantities (ProductSync snapshot).
|
||||
*/
|
||||
export function shouldSendLowStockSupplierMessage(params: {
|
||||
newQty: number
|
||||
|
||||
@ -12,6 +12,7 @@ type NotificationType =
|
||||
| "PRODUCT_SYNC_REQUESTED"
|
||||
| "PRODUCT_SYNC_ACCEPTED"
|
||||
| "PRODUCT_SYNC_REJECTED"
|
||||
| "SHOPIFY_LOW_STOCK"
|
||||
|
||||
interface CreateNotificationParams {
|
||||
userId: string
|
||||
@ -187,3 +188,29 @@ export async function notifyProductSyncStatusChanged(
|
||||
url: "/dashboard/new-products"
|
||||
})
|
||||
}
|
||||
|
||||
/** Vendor: Shopify sellable stock (sum of variant quantities) fell below the configured threshold. */
|
||||
export async function notifyVendorShopifyLowStock(
|
||||
vendorUserId: string,
|
||||
params: {
|
||||
storeId: string
|
||||
shopifyProductId: string
|
||||
productTitle: string
|
||||
shopifyTotal: number
|
||||
threshold: number
|
||||
},
|
||||
) {
|
||||
return createNotification({
|
||||
userId: vendorUserId,
|
||||
type: "SHOPIFY_LOW_STOCK",
|
||||
title: "Shopify inventory low",
|
||||
message: `"${params.productTitle}" is low on Shopify (${params.shopifyTotal} available, your threshold is ${params.threshold}). Please restock or use Load stock in Inventory.`,
|
||||
metadata: {
|
||||
storeId: params.storeId,
|
||||
shopifyProductId: params.shopifyProductId,
|
||||
shopifyTotal: params.shopifyTotal,
|
||||
threshold: params.threshold,
|
||||
},
|
||||
url: "/dashboard/inventory",
|
||||
})
|
||||
}
|
||||
|
||||
@ -33,6 +33,32 @@ export const storeSelectGmailL1Context = {
|
||||
gmailReplyTheme: true,
|
||||
} as const
|
||||
|
||||
/** GET /api/stores/[storeId] — full vendor settings row (cast at call site if IDE Prisma types lag schema). */
|
||||
export const storeSelectVendorSettingsGetFull = {
|
||||
id: true,
|
||||
businessName: true,
|
||||
categoryMetadetailsEnabled: true,
|
||||
aiModules: true,
|
||||
modulePolicies: true,
|
||||
sharedFieldAnswers: true,
|
||||
gmailReplyTheme: true,
|
||||
lowStockThreshold: true,
|
||||
lowStockShopifyThreshold: true,
|
||||
storefrontOtpSessionMinutes: true,
|
||||
} as const
|
||||
|
||||
/** Fallback when modulePolicies/sharedFieldAnswers columns are missing on old DBs. */
|
||||
export const storeSelectVendorSettingsGetMinimal = {
|
||||
id: true,
|
||||
businessName: true,
|
||||
categoryMetadetailsEnabled: true,
|
||||
aiModules: true,
|
||||
gmailReplyTheme: true,
|
||||
lowStockThreshold: true,
|
||||
lowStockShopifyThreshold: true,
|
||||
storefrontOtpSessionMinutes: true,
|
||||
} as const
|
||||
|
||||
export const storeSelectPatchStorefrontAi = {
|
||||
storefrontChatEnabled: true,
|
||||
storefrontChatName: true,
|
||||
@ -46,6 +72,8 @@ export const storeSelectPatchStorefrontAi = {
|
||||
sharedFieldAnswers: true,
|
||||
gmailReplyTheme: true,
|
||||
lowStockThreshold: true,
|
||||
lowStockShopifyThreshold: true,
|
||||
storefrontOtpSessionMinutes: true,
|
||||
} as const
|
||||
|
||||
/** Session / OTP fields for L1 storefront replies */
|
||||
|
||||
29
src/lib/products/applyLoadStockSupplierQuantity.test.ts
Normal file
29
src/lib/products/applyLoadStockSupplierQuantity.test.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { mergeShopifyProductDataAfterLoadStock } from "./applyLoadStockSupplierQuantity"
|
||||
import { supplierQuantityFromSyncedProductData } from "./shopifyInventorySum"
|
||||
|
||||
describe("mergeShopifyProductDataAfterLoadStock", () => {
|
||||
it("decrements explicit supplier_quantity", () => {
|
||||
const merged = mergeShopifyProductDataAfterLoadStock({ supplier_quantity: 10, title: "T" }, 3)
|
||||
expect(supplierQuantityFromSyncedProductData(merged)).toBe(7)
|
||||
})
|
||||
|
||||
it("uses variant sum when supplier_quantity absent", () => {
|
||||
const merged = mergeShopifyProductDataAfterLoadStock(
|
||||
{
|
||||
variants: [{ inventory_quantity: 4 }, { inventory_quantity: 2 }],
|
||||
},
|
||||
3
|
||||
)
|
||||
expect(supplierQuantityFromSyncedProductData(merged)).toBe(3)
|
||||
})
|
||||
|
||||
it("does not go below zero", () => {
|
||||
const merged = mergeShopifyProductDataAfterLoadStock({ supplier_quantity: 2 }, 5)
|
||||
expect(supplierQuantityFromSyncedProductData(merged)).toBe(0)
|
||||
})
|
||||
|
||||
it("throws when supplier quantity unknown", () => {
|
||||
expect(() => mergeShopifyProductDataAfterLoadStock({}, 1)).toThrow("supplier quantity unknown")
|
||||
})
|
||||
})
|
||||
22
src/lib/products/applyLoadStockSupplierQuantity.ts
Normal file
22
src/lib/products/applyLoadStockSupplierQuantity.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { Prisma } from "@prisma/client"
|
||||
import { supplierQuantityFromSyncedProductData } from "@/lib/products/shopifyInventorySum"
|
||||
|
||||
/**
|
||||
* After a successful Shopify inventory increase, persist reduced supplier-available stock
|
||||
* on ProductSync.shopifyProductData by setting explicit `supplier_quantity`.
|
||||
*/
|
||||
export function mergeShopifyProductDataAfterLoadStock(
|
||||
shopifyProductData: unknown,
|
||||
decrementBy: number
|
||||
): Prisma.InputJsonValue {
|
||||
const current = supplierQuantityFromSyncedProductData(shopifyProductData)
|
||||
if (current === undefined) {
|
||||
throw new Error("supplier quantity unknown")
|
||||
}
|
||||
const next = Math.max(0, current - decrementBy)
|
||||
const base =
|
||||
shopifyProductData && typeof shopifyProductData === "object" && !Array.isArray(shopifyProductData)
|
||||
? { ...(shopifyProductData as Record<string, unknown>) }
|
||||
: {}
|
||||
return { ...base, supplier_quantity: next } as Prisma.InputJsonValue
|
||||
}
|
||||
211
src/lib/shopify/inventoryAdjust.ts
Normal file
211
src/lib/shopify/inventoryAdjust.ts
Normal file
@ -0,0 +1,211 @@
|
||||
const SHOPIFY_API_VERSION = "2024-01"
|
||||
|
||||
function cleanShopDomain(shopifyDomain: string): string {
|
||||
return shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
|
||||
}
|
||||
|
||||
async function shopifyAdminJson<T>(
|
||||
cleanDomain: string,
|
||||
accessToken: string,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<{ ok: boolean; status: number; data: T }> {
|
||||
const url = `https://${cleanDomain}/admin/api/${SHOPIFY_API_VERSION}${path}`
|
||||
const method = init?.method ?? "GET"
|
||||
const write = method !== "GET" && method !== "HEAD"
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"X-Shopify-Access-Token": accessToken,
|
||||
...(write ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
})
|
||||
const data = (await response.json().catch(() => ({}))) as T
|
||||
return { ok: response.ok, status: response.status, data }
|
||||
}
|
||||
|
||||
export function formatShopifyAdminError(data: unknown): string {
|
||||
if (data == null) return "Shopify request failed"
|
||||
if (typeof data === "string" && data.trim()) return data
|
||||
if (typeof data !== "object") return "Shopify request failed"
|
||||
const d = data as Record<string, unknown>
|
||||
if (typeof d.message === "string" && d.message.trim()) return d.message
|
||||
const errs = d.errors
|
||||
if (typeof errs === "string" && errs.trim()) return errs
|
||||
if (errs !== undefined) {
|
||||
try {
|
||||
return JSON.stringify(errs)
|
||||
} catch {
|
||||
return "Shopify validation error"
|
||||
}
|
||||
}
|
||||
return "Shopify request failed"
|
||||
}
|
||||
|
||||
/** First active location id (requires read_locations — often missing on minimal custom apps). */
|
||||
export async function getActiveLocationId(
|
||||
shopifyDomain: string,
|
||||
accessToken: string
|
||||
): Promise<{ locationId: number } | { error: string }> {
|
||||
const cleanDomain = cleanShopDomain(shopifyDomain)
|
||||
const { ok, status, data } = await shopifyAdminJson<{ locations?: { id: number; active: boolean }[] }>(
|
||||
cleanDomain,
|
||||
accessToken,
|
||||
"/locations.json"
|
||||
)
|
||||
if (!ok) {
|
||||
return { error: `Could not load locations (${status}): ${formatShopifyAdminError(data)}` }
|
||||
}
|
||||
const locations = data.locations || []
|
||||
const active = locations.find((l) => l.active) ?? locations[0]
|
||||
if (!active) {
|
||||
return { error: "No Shopify locations found for this store." }
|
||||
}
|
||||
return { locationId: active.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Locations where this inventory item already has levels (read_inventory — no read_locations).
|
||||
*/
|
||||
async function getInventoryLevelsForItem(
|
||||
cleanDomain: string,
|
||||
accessToken: string,
|
||||
inventoryItemId: number
|
||||
): Promise<
|
||||
| { ok: true; levels: { location_id: number }[] }
|
||||
| { ok: false; status: number; data: unknown }
|
||||
> {
|
||||
const path = `/inventory_levels.json?inventory_item_ids=${encodeURIComponent(String(inventoryItemId))}`
|
||||
const { ok, status, data } = await shopifyAdminJson<{ inventory_levels?: { location_id: number }[] }>(
|
||||
cleanDomain,
|
||||
accessToken,
|
||||
path
|
||||
)
|
||||
if (!ok) {
|
||||
return { ok: false, status, data }
|
||||
}
|
||||
const levels = Array.isArray(data.inventory_levels) ? data.inventory_levels : []
|
||||
return { ok: true, levels }
|
||||
}
|
||||
|
||||
/** Connect tracked inventory to a location so adjust can run (write_inventory). */
|
||||
async function connectInventoryAtLocation(
|
||||
cleanDomain: string,
|
||||
accessToken: string,
|
||||
inventoryItemId: number,
|
||||
locationId: number
|
||||
): Promise<{ ok: true } | { error: string; status: number }> {
|
||||
const { ok, status, data } = await shopifyAdminJson<unknown>(cleanDomain, accessToken, "/inventory_levels/connect.json", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
inventory_item_id: inventoryItemId,
|
||||
location_id: locationId,
|
||||
}),
|
||||
})
|
||||
if (ok) return { ok: true }
|
||||
const msg = formatShopifyAdminError(data)
|
||||
if (status === 422 && /already (?:been )?connected|already exists/i.test(msg)) {
|
||||
return { ok: true }
|
||||
}
|
||||
return { error: msg, status: status >= 400 && status < 600 ? status : 502 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves location_id for inventory_levels/adjust without requiring read_locations when possible:
|
||||
* 1) Existing inventory level for this item (read_inventory)
|
||||
* 2) Else list locations + connect item (read_locations + write_inventory)
|
||||
*/
|
||||
export async function resolveLocationIdForInventoryAdjust(
|
||||
shopifyDomain: string,
|
||||
accessToken: string,
|
||||
inventoryItemId: number
|
||||
): Promise<{ locationId: number } | { error: string; status?: number }> {
|
||||
const cleanDomain = cleanShopDomain(shopifyDomain)
|
||||
|
||||
const fromLevels = await getInventoryLevelsForItem(cleanDomain, accessToken, inventoryItemId)
|
||||
if (!fromLevels.ok) {
|
||||
const detail = formatShopifyAdminError(fromLevels.data)
|
||||
return {
|
||||
error: `Could not read inventory levels (${fromLevels.status}): ${detail}`,
|
||||
status: fromLevels.status >= 400 && fromLevels.status < 600 ? fromLevels.status : 502,
|
||||
}
|
||||
}
|
||||
|
||||
if (fromLevels.levels.length > 0) {
|
||||
return { locationId: fromLevels.levels[0].location_id }
|
||||
}
|
||||
|
||||
const loc = await getActiveLocationId(shopifyDomain, accessToken)
|
||||
if ("error" in loc) {
|
||||
return {
|
||||
error:
|
||||
`This product has no inventory level at a location yet. Your token cannot list locations (often missing read_locations scope). ` +
|
||||
`Either add the **read_locations** scope to your Shopify custom app and save a new Admin API token in JunoHub, or open Shopify Admin and assign this product to a fulfillment location so it has stock. ` +
|
||||
`Original error: ${loc.error}`,
|
||||
status: 403,
|
||||
}
|
||||
}
|
||||
|
||||
const connected = await connectInventoryAtLocation(cleanDomain, accessToken, inventoryItemId, loc.locationId)
|
||||
if ("error" in connected) {
|
||||
return {
|
||||
error: `Could not connect inventory to a location: ${connected.error}`,
|
||||
status: connected.status,
|
||||
}
|
||||
}
|
||||
|
||||
return { locationId: loc.locationId }
|
||||
}
|
||||
|
||||
/**
|
||||
* First variant's inventory_item_id (v1: load stock applies to first variant only).
|
||||
*/
|
||||
export async function getFirstVariantInventoryItemId(
|
||||
shopifyDomain: string,
|
||||
accessToken: string,
|
||||
shopifyProductId: string
|
||||
): Promise<{ inventoryItemId: number } | { error: string }> {
|
||||
const cleanDomain = cleanShopDomain(shopifyDomain)
|
||||
const { ok, status, data } = await shopifyAdminJson<{ product?: { variants?: { inventory_item_id?: number }[] } }>(
|
||||
cleanDomain,
|
||||
accessToken,
|
||||
`/products/${encodeURIComponent(shopifyProductId)}.json`
|
||||
)
|
||||
if (!ok) {
|
||||
return { error: `Could not load product (${status}): ${formatShopifyAdminError(data)}` }
|
||||
}
|
||||
const variants = data.product?.variants
|
||||
const first = variants?.[0]
|
||||
const inventoryItemId = first?.inventory_item_id
|
||||
if (inventoryItemId === undefined || inventoryItemId === null || Number.isNaN(Number(inventoryItemId))) {
|
||||
return { error: "Product has no variant with inventory tracking (inventory_item_id missing)." }
|
||||
}
|
||||
return { inventoryItemId: Number(inventoryItemId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative adjustment to available quantity at a location (positive = increase sellable stock).
|
||||
*/
|
||||
export async function adjustAvailableInventory(
|
||||
shopifyDomain: string,
|
||||
accessToken: string,
|
||||
params: { inventoryItemId: number; locationId: number; availableAdjustment: number }
|
||||
): Promise<{ ok: true } | { error: string; status: number }> {
|
||||
const cleanDomain = cleanShopDomain(shopifyDomain)
|
||||
const body = {
|
||||
inventory_item_id: params.inventoryItemId,
|
||||
location_id: params.locationId,
|
||||
available_adjustment: params.availableAdjustment,
|
||||
}
|
||||
const { ok, status, data } = await shopifyAdminJson<unknown>(cleanDomain, accessToken, "/inventory_levels/adjust.json", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!ok) {
|
||||
return {
|
||||
error: formatShopifyAdminError(data),
|
||||
status: status >= 400 && status < 600 ? status : 502,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
@ -3,8 +3,34 @@ import crypto from "node:crypto"
|
||||
/** OTP validity (send to email). */
|
||||
export const STOREFRONT_OTP_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
/** Verified widget session length (spec: ~1h). */
|
||||
export const STOREFRONT_SESSION_TTL_MS = 60 * 60 * 1000
|
||||
/** Default verified session if store value missing (60 minutes). */
|
||||
export const DEFAULT_STOREFRONT_OTP_SESSION_MINUTES = 60
|
||||
|
||||
export const MIN_STOREFRONT_OTP_SESSION_MINUTES = 5
|
||||
|
||||
/** One week — upper bound for vendor-configured session length. */
|
||||
export const MAX_STOREFRONT_OTP_SESSION_MINUTES = 10080
|
||||
|
||||
/** @deprecated Prefer storefrontSessionTtlMsFromMinutes(storeMinutes) */
|
||||
export const STOREFRONT_SESSION_TTL_MS = DEFAULT_STOREFRONT_OTP_SESSION_MINUTES * 60 * 1000
|
||||
|
||||
/**
|
||||
* Converts store setting (minutes) to TTL for sessionExpiresAt after OTP verify.
|
||||
*/
|
||||
export function storefrontSessionTtlMsFromMinutes(
|
||||
minutes: number | null | undefined,
|
||||
): number {
|
||||
const raw =
|
||||
minutes == null || !Number.isFinite(minutes)
|
||||
? DEFAULT_STOREFRONT_OTP_SESSION_MINUTES
|
||||
: Number(minutes)
|
||||
const m = Math.round(raw)
|
||||
const clamped = Math.min(
|
||||
Math.max(m, MIN_STOREFRONT_OTP_SESSION_MINUTES),
|
||||
MAX_STOREFRONT_OTP_SESSION_MINUTES,
|
||||
)
|
||||
return clamped * 60 * 1000
|
||||
}
|
||||
|
||||
export function hashStorefrontOtp(
|
||||
code: string,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user