From ffbeaf493f3fb6853786b5c2769053de2729ff14 Mon Sep 17 00:00:00 2001 From: talatops Date: Sun, 12 Apr 2026 20:22:12 +0500 Subject: [PATCH] Storefront OTP session settings, inbox empty-session filter, low stock, inventory load-stock, migrations Made-with: Cursor --- docker-compose.ec2.yml | 1 + docker-compose.yml | 3 + docker-entrypoint.sh | 40 +- package.json | 2 +- .../migration.sql | 47 +- .../migration.sql | 14 + .../migration.sql | 24 + .../migration.sql | 2 + .../migration.sql | 2 + prisma/schema.prisma | 42 +- scripts/prisma-pre-migrate-cleanup.sql | 7 + .../api/shopify/inventory/load-stock/route.ts | 158 +++++ .../api/storefront-chat/otp/verify/route.ts | 7 +- src/app/api/stores/[storeId]/route.ts | 181 ++++-- src/app/api/webhooks/shopify/route.ts | 19 - src/app/dashboard/inventory/page.tsx | 318 ++++++---- src/app/dashboard/settings/layout.tsx | 20 +- src/app/dashboard/settings/low-stock/page.tsx | 198 +++++++ .../dashboard/settings/storefront/page.tsx | 210 +++++++ src/app/onboarding/onboarding-client.tsx | 2 +- src/app/storefront-chat/widget/page.tsx | 14 +- src/app/supplier/orders/[orderId]/page.tsx | 58 +- src/components/NotificationBell.tsx | 4 +- src/components/chat/ChatView.tsx | 106 ++-- .../storefront/StorefrontChatInbox.tsx | 141 +++-- .../storefront/StorefrontChatInboxClient.tsx | 344 +++++++++++ src/lib/api-schemas/business.ts | 7 + src/lib/api-schemas/schemas.test.ts | 11 + src/lib/automation/lowStock.ts | 551 ++++++++++++------ src/lib/automation/lowStockNotifyGate.ts | 2 +- src/lib/notifications.ts | 27 + src/lib/prisma/storeSelects.ts | 28 + .../applyLoadStockSupplierQuantity.test.ts | 29 + .../applyLoadStockSupplierQuantity.ts | 22 + src/lib/shopify/inventoryAdjust.ts | 211 +++++++ src/lib/storefront-chat/otpCrypto.ts | 30 +- 36 files changed, 2358 insertions(+), 524 deletions(-) create mode 100644 prisma/migrations/20260412190000_low_stock_per_supplier/migration.sql create mode 100644 prisma/migrations/20260412193100_shopify_low_stock_vendor_notification/migration.sql create mode 100644 prisma/migrations/20260412194500_storefront_otp_session_minutes/migration.sql create mode 100644 prisma/migrations/20260412200000_store_low_stock_shopify_threshold/migration.sql create mode 100644 scripts/prisma-pre-migrate-cleanup.sql create mode 100644 src/app/api/shopify/inventory/load-stock/route.ts create mode 100644 src/app/dashboard/settings/low-stock/page.tsx create mode 100644 src/app/dashboard/settings/storefront/page.tsx create mode 100644 src/components/storefront/StorefrontChatInboxClient.tsx create mode 100644 src/lib/products/applyLoadStockSupplierQuantity.test.ts create mode 100644 src/lib/products/applyLoadStockSupplierQuantity.ts create mode 100644 src/lib/shopify/inventoryAdjust.ts diff --git a/docker-compose.ec2.yml b/docker-compose.ec2.yml index cf2cd0d..5abb90b 100644 --- a/docker-compose.ec2.yml +++ b/docker-compose.ec2.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index dc0ea92..592d84b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0bdff91..f9e2e7f 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -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 diff --git a/package.json b/package.json index 3e6e592..f9bb93e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260412180000_supplier_automations/migration.sql b/prisma/migrations/20260412180000_supplier_automations/migration.sql index af2972d..a2e11d4 100644 --- a/prisma/migrations/20260412180000_supplier_automations/migration.sql +++ b/prisma/migrations/20260412180000_supplier_automations/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 $$; diff --git a/prisma/migrations/20260412190000_low_stock_per_supplier/migration.sql b/prisma/migrations/20260412190000_low_stock_per_supplier/migration.sql new file mode 100644 index 0000000..36029ab --- /dev/null +++ b/prisma/migrations/20260412190000_low_stock_per_supplier/migration.sql @@ -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"); diff --git a/prisma/migrations/20260412193100_shopify_low_stock_vendor_notification/migration.sql b/prisma/migrations/20260412193100_shopify_low_stock_vendor_notification/migration.sql new file mode 100644 index 0000000..16d0999 --- /dev/null +++ b/prisma/migrations/20260412193100_shopify_low_stock_vendor_notification/migration.sql @@ -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; diff --git a/prisma/migrations/20260412194500_storefront_otp_session_minutes/migration.sql b/prisma/migrations/20260412194500_storefront_otp_session_minutes/migration.sql new file mode 100644 index 0000000..aa48321 --- /dev/null +++ b/prisma/migrations/20260412194500_storefront_otp_session_minutes/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Store" ADD COLUMN "storefrontOtpSessionMinutes" INTEGER NOT NULL DEFAULT 60; diff --git a/prisma/migrations/20260412200000_store_low_stock_shopify_threshold/migration.sql b/prisma/migrations/20260412200000_store_low_stock_shopify_threshold/migration.sql new file mode 100644 index 0000000..13c9b61 --- /dev/null +++ b/prisma/migrations/20260412200000_store_low_stock_shopify_threshold/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cb8fb22..0ed6571 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/scripts/prisma-pre-migrate-cleanup.sql b/scripts/prisma-pre-migrate-cleanup.sql new file mode 100644 index 0000000..44f3433 --- /dev/null +++ b/scripts/prisma-pre-migrate-cleanup.sql @@ -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 $$; diff --git a/src/app/api/shopify/inventory/load-stock/route.ts b/src/app/api/shopify/inventory/load-stock/route.ts new file mode 100644 index 0000000..4c2f41a --- /dev/null +++ b/src/app/api/shopify/inventory/load-stock/route.ts @@ -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 }) + } +} diff --git a/src/app/api/storefront-chat/otp/verify/route.ts b/src/app/api/storefront-chat/otp/verify/route.ts index 9ed41c7..adf14e8 100644 --- a/src/app/api/storefront-chat/otp/verify/route.ts +++ b/src/app/api/storefront-chat/otp/verify/route.ts @@ -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: { diff --git a/src/app/api/stores/[storeId]/route.ts b/src/app/api/stores/[storeId]/route.ts index ad58d52..890982c 100644 --- a/src/app/api/stores/[storeId]/route.ts +++ b/src/app/api/stores/[storeId]/route.ts @@ -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) diff --git a/src/app/api/webhooks/shopify/route.ts b/src/app/api/webhooks/shopify/route.ts index 288fdfd..ed3de9d 100644 --- a/src/app/api/webhooks/shopify/route.ts +++ b/src/app/api/webhooks/shopify/route.ts @@ -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) }) diff --git a/src/app/dashboard/inventory/page.tsx b/src/app/dashboard/inventory/page.tsx index 2338879..e38c341 100644 --- a/src/app/dashboard/inventory/page.tsx +++ b/src/app/dashboard/inventory/page.tsx @@ -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(null) + const [loadStockSync, setLoadStockSync] = useState(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(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() + 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() { -
-
-
- -
-
-

- Supplier low-stock alerts -

-

- When enabled, synced products (ACCEPTED) message the linked supplier in JUNO chat if - total inventory drops below your threshold. Leave empty to turn off. -

-
-
-
- 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" - }`} - /> - -
-
-
@@ -738,6 +694,89 @@ export default function InventoryPage() {
{/* Supplier Selection Modal */} + {showLoadStockModal && loadStockProduct && loadStockSync && ( +
+
+
+
+

Load stock

+

+ Add inventory to Shopify from supplier stock for "{loadStockProduct.title}" +

+
+ +
+
+

+ Sellable quantity is added to the first variant at your primary Shopify location. Supplier stock cannot exceed what is shown below. +

+
+
+ Supplier stock + + {loadStockSync.supplierAvailableQuantity ?? "—"} + +
+
+ Shopify (total) + + {totalShopifyInventory(loadStockProduct.variants)} + +
+
+
+ + 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" + }`} + /> +
+ {loadStockError ? ( +

{loadStockError}

+ ) : null} + +
+
+
+ )} + {showSupplierModal && selectedProduct && (
@@ -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({ Refresh Product + {storeDomain && product.handle && ( <> diff --git a/src/app/dashboard/settings/layout.tsx b/src/app/dashboard/settings/layout.tsx index 3073c39..b862440 100644 --- a/src/app/dashboard/settings/layout.tsx +++ b/src/app/dashboard/settings/layout.tsx @@ -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({

Settings

-

Manage your account and security preferences.

+

+ Manage your account, security, low-stock alerts, and storefront chat session settings. +

diff --git a/src/app/dashboard/settings/low-stock/page.tsx b/src/app/dashboard/settings/low-stock/page.tsx new file mode 100644 index 0000000..be2a70b --- /dev/null +++ b/src/app/dashboard/settings/low-stock/page.tsx @@ -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(null) + const [shopifyDraft, setShopifyDraft] = useState("") + const [supplierDraft, setSupplierDraft] = useState("") + const [saving, setSaving] = useState(false) + const [savedFlash, setSavedFlash] = useState(false) + const flashTimerRef = useRef | 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 ( +
+
+

Low-stock alerts

+

+ 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). +

+
+ +
+ +

+ Sum of variant inventory_quantity 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. +

+ 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" + /> +
+ +
+ +

+ 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. +

+ 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" + /> +
+ +
+ +
+ + + 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. + +
+
+
+ ) +} diff --git a/src/app/dashboard/settings/storefront/page.tsx b/src/app/dashboard/settings/storefront/page.tsx new file mode 100644 index 0000000..cb4b632 --- /dev/null +++ b/src/app/dashboard/settings/storefront/page.tsx @@ -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(null) + const [hoursDraft, setHoursDraft] = useState("1") + const [minutesDraft, setMinutesDraft] = useState("0") + const [saving, setSaving] = useState(false) + const [savedFlash, setSavedFlash] = useState(false) + const flashTimerRef = useRef | 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 ( +
+
+

Storefront chat (OTP session)

+

+ 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. +

+
+ +
+ +
+
+ + 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" + /> +
+
+ + { + 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" + /> +
+
+

+ + + Total must be between {MIN_STOREFRONT_OTP_SESSION_MINUTES} and{" "} + {MAX_STOREFRONT_OTP_SESSION_MINUTES} minutes (up to{" "} + {MAX_HOURS} hours). Default was 1 hour. + +

+
+ +
+ +
+
+ ) +} diff --git a/src/app/onboarding/onboarding-client.tsx b/src/app/onboarding/onboarding-client.tsx index 9c9d1f3..3bc5f92 100644 --- a/src/app/onboarding/onboarding-client.tsx +++ b/src/app/onboarding/onboarding-client.tsx @@ -186,7 +186,7 @@ export default function OnboardingClient() {

How to get your credentials?

- 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 read_themes and write_themes too. + Go to your Shopify Admin > Settings > Apps and sales channels > Develop apps. Create an app and configure Admin API scopes. To load stock from Inventory, enable read_inventory, write_inventory, and read_locations. For the storefront chat widget auto-injection, enable read_themes and write_themes too.
diff --git a/src/app/storefront-chat/widget/page.tsx b/src/app/storefront-chat/widget/page.tsx index 1f42561..80c6662 100644 --- a/src/app/storefront-chat/widget/page.tsx +++ b/src/app/storefront-chat/widget/page.tsx @@ -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") diff --git a/src/app/supplier/orders/[orderId]/page.tsx b/src/app/supplier/orders/[orderId]/page.tsx index e86048b..ebfc82e 100644 --- a/src/app/supplier/orders/[orderId]/page.tsx +++ b/src/app/supplier/orders/[orderId]/page.tsx @@ -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(null) const [loading, setLoading] = useState(true) @@ -474,26 +477,39 @@ export default function SupplierOrderDetailsPage() { {holdModalOpen ? (
-
+
-

+

On hold reason

-

+

Select why this order is on hold. Required before you can save.

@@ -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" }`} > setModalReason(opt.value)} /> - {opt.label} + + {opt.label} + ))}
{modalReason === "other" ? (
- +