Merge pull request #5 from talatops/talat/updates

Storefront OTP session settings, inbox empty-session filter, low stoc…
This commit is contained in:
talatops 2026-04-12 20:22:41 +05:00 committed by GitHub
commit f082124a54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 2358 additions and 524 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Store" ADD COLUMN "storefrontOtpSessionMinutes" INTEGER NOT NULL DEFAULT 60;

View File

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

View File

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

View 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 $$;

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

View File

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

View File

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

View File

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

View File

@ -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 &quot;{loadStockProduct.title}&quot;
</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 && (
<>

View File

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

View 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. &quot;Saved&quot; is brief
confirmation only—you do not need to save again for future alerts.
</span>
</div>
</div>
</div>
)
}

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

View File

@ -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 &gt; Settings &gt; Apps and sales channels &gt; 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 &gt; Settings &gt; Apps and sales channels &gt; 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">

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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