Product sharing fix

This commit is contained in:
root 2026-02-16 21:21:48 +05:00
parent 8354a90698
commit 1ff77cb459
32 changed files with 3556 additions and 326 deletions

View File

@ -7,11 +7,14 @@ COPY package*.json prisma.config.ts ./
COPY prisma ./prisma
# Install ALL dependencies (including devDependencies needed for build)
RUN npm ci && npx prisma generate
RUN npm ci
# Copy application code (includes .env created in CI from EC2_ENV for NEXT_PUBLIC_* at build time)
COPY . .
# Regenerate Prisma Client with final schema before building Next.js
RUN npx prisma generate
# Build the Next.js application (reads .env and embeds NEXT_PUBLIC_* into the bundle)
RUN npm run build

View File

@ -15,8 +15,8 @@ client.connect()
sleep 1
done
echo "Syncing database schema with Prisma (db push)..."
npx prisma db push --accept-data-loss
echo "Running Prisma migrations (migrate deploy)..."
npx prisma migrate deploy
if [ "$RUN_TEMPLATE_SEED_ON_START" = "true" ]; then
echo "Seeding predefined email templates..."

View File

@ -28,6 +28,10 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "*.ngrok-free.dev",
},
{
protocol: "https",
hostname: "junohub-product-images.s3.us-east-1.amazonaws.com",
},
],
},
// Allow ngrok origin for development (fixes cross-origin warnings)

995
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@
"@auth/core": "^0.41.0",
"@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-bedrock-runtime": "^3.989.0",
"@aws-sdk/client-s3": "^3.989.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"@tailwindcss/postcss": "^4",
@ -21,6 +22,7 @@
"@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.34.3",
"aws-cli": "^0.0.2",
"aws-sdk": "^2.1693.0",
"bcryptjs": "^3.0.3",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",

View File

@ -1,15 +0,0 @@
-- Enable pgvector extension and create index for knowledge chunk embeddings
CREATE EXTENSION IF NOT EXISTS vector;
-- Adjust the embedding column on KnowledgeChunk to use a concrete vector dimension.
-- NOTE: Update 1536 to match the actual embedding size used by your Gemini model.
ALTER TABLE "KnowledgeChunk"
ALTER COLUMN "embedding" TYPE vector(1536);
CREATE INDEX IF NOT EXISTS "KnowledgeChunk_embedding_storeId_idx"
ON "KnowledgeChunk"
USING ivfflat ("embedding" vector_cosine_ops)
WITH (lists = 100)
WHERE "storeId" IS NOT NULL;

View File

@ -1,22 +0,0 @@
-- Migrate KnowledgeChunk embeddings from 1536 dimensions (Gemini) to 1024 dimensions (Bedrock Titan V2)
--
-- NOTE: This migration changes the vector dimension. Existing embeddings will need to be
-- re-embedded using the re-embedding script after this migration runs.
--
-- Strategy: Drop the index, change column type (will truncate/pad existing vectors),
-- then recreate the index with the new dimension.
-- Drop the existing index
DROP INDEX IF EXISTS "KnowledgeChunk_embedding_storeId_idx";
-- Change the embedding column dimension from 1536 to 1024
-- PostgreSQL will automatically truncate vectors longer than 1024 or pad shorter ones
ALTER TABLE "KnowledgeChunk"
ALTER COLUMN "embedding" TYPE vector(1024);
-- Recreate the index with the new dimension
CREATE INDEX IF NOT EXISTS "KnowledgeChunk_embedding_storeId_idx"
ON "KnowledgeChunk"
USING ivfflat ("embedding" vector_cosine_ops)
WITH (lists = 100)
WHERE "storeId" IS NOT NULL;

View File

@ -0,0 +1,657 @@
-- Enable pgvector extension for KnowledgeChunk.embedding
CREATE EXTENSION IF NOT EXISTS vector;
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('VENDOR', 'SUPPLIER', 'ADMIN');
-- CreateEnum
CREATE TYPE "ConnectionStatus" AS ENUM ('PENDING', 'CONNECTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ProductSyncStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "NotificationType" AS ENUM ('INVITATION_ACCEPTED', 'INVITATION_RECEIVED', 'CONNECTION_STATUS_CHANGED', 'ORDER_RECEIVED', 'PRODUCT_UPDATED', 'PRODUCT_SYNC_REQUESTED', 'PRODUCT_SYNC_ACCEPTED', 'PRODUCT_SYNC_REJECTED');
-- CreateEnum
CREATE TYPE "FlaggedEmailPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
-- CreateEnum
CREATE TYPE "KnowledgeDocumentStatus" AS ENUM ('UPLOADED', 'PROCESSING', 'READY', 'FAILED');
-- CreateEnum
CREATE TYPE "KnowledgeChunkSourceType" AS ENUM ('DOCUMENT', 'FAQ');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"password" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'VENDOR',
"activeStoreId" TEXT,
"mfaEnabled" BOOLEAN NOT NULL DEFAULT false,
"mfaSecret" TEXT,
"chatPublicKey" TEXT,
"chatPrivateKeyEncrypted" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Store" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"businessName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"timezone" TEXT NOT NULL,
"country" TEXT NOT NULL,
"shopifyStoreName" TEXT NOT NULL,
"shopifyDomain" TEXT NOT NULL,
"shopifyApiKey" TEXT NOT NULL,
"shopifyApiSecret" TEXT NOT NULL,
"shopifyAccessToken" TEXT NOT NULL,
"onboardingComplete" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Store_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CachedProduct" (
"id" TEXT NOT NULL,
"shopifyProductId" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"vendor" TEXT,
"productType" TEXT,
"status" TEXT,
"variants" JSONB NOT NULL,
"images" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CachedProduct_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CachedOrder" (
"id" TEXT NOT NULL,
"shopifyOrderId" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"orderNumber" TEXT NOT NULL,
"email" TEXT,
"totalPrice" TEXT NOT NULL,
"currency" TEXT,
"financialStatus" TEXT,
"fulfillmentStatus" TEXT,
"lineItems" JSONB NOT NULL,
"customer" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"shopifyCreatedAt" TIMESTAMP(3),
CONSTRAINT "CachedOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CachedCustomer" (
"id" TEXT NOT NULL,
"shopifyCustomerId" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"email" TEXT,
"firstName" TEXT,
"lastName" TEXT,
"ordersCount" INTEGER NOT NULL DEFAULT 0,
"totalSpent" TEXT,
"phone" TEXT,
"tags" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"shopifyCreatedAt" TIMESTAMP(3),
CONSTRAINT "CachedCustomer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SyncMetadata" (
"id" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"resourceType" TEXT NOT NULL,
"lastSyncAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"syncStatus" TEXT NOT NULL DEFAULT 'success',
"recordCount" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT,
CONSTRAINT "SyncMetadata_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SupplierProfile" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupplierProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Connection" (
"id" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"supplierId" TEXT NOT NULL,
"status" "ConnectionStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Connection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Invitation" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"supplierId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"usedAt" TIMESTAMP(3),
CONSTRAINT "Invitation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Product" (
"id" TEXT NOT NULL,
"supplierId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"price" DOUBLE PRECISION NOT NULL,
"sku" TEXT,
"imageUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SupplierProductOffer" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"supplierId" TEXT NOT NULL,
"price" DOUBLE PRECISION NOT NULL,
"quantity" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupplierProductOffer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductSync" (
"id" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"supplierId" TEXT NOT NULL,
"shopifyProductId" TEXT NOT NULL,
"shopifyProductTitle" TEXT NOT NULL,
"shopifyProductData" JSONB,
"status" "ProductSyncStatus" NOT NULL DEFAULT 'PENDING',
"requestedBy" TEXT NOT NULL,
"acceptedAt" TIMESTAMP(3),
"rejectedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductSync_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GmailIntegration" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT NOT NULL,
"refreshToken" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"email" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GmailIntegration_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"read" BOOLEAN NOT NULL DEFAULT false,
"readAt" TIMESTAMP(3),
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmailLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"storeId" TEXT,
"to" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"bodyPreview" TEXT,
"isAutomated" BOOLEAN NOT NULL DEFAULT false,
"trigger" TEXT,
"status" TEXT NOT NULL,
"providerMessageId" TEXT,
"errorMessage" TEXT,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmailLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FlaggedEmail" (
"id" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"from" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"priority" "FlaggedEmailPriority" NOT NULL DEFAULT 'MEDIUM',
"messageId" TEXT,
"flaggedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FlaggedEmail_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KnowledgeDocument" (
"id" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"status" "KnowledgeDocumentStatus" NOT NULL DEFAULT 'UPLOADED',
"errorMessage" TEXT,
"storagePath" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KnowledgeDocument_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FaqItem" (
"id" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"question" TEXT NOT NULL,
"answer" TEXT NOT NULL,
"tags" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FaqItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KnowledgeChunk" (
"id" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"documentId" TEXT,
"faqItemId" TEXT,
"sourceType" "KnowledgeChunkSourceType" NOT NULL,
"content" TEXT NOT NULL,
"metadata" JSONB,
"embedding" vector NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KnowledgeChunk_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Template" (
"id" TEXT NOT NULL,
"storeId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"intent" TEXT NOT NULL,
"mood" TEXT NOT NULL,
"description" TEXT,
"body" TEXT NOT NULL,
"variables" JSONB,
"isHtml" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatMessage" (
"id" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"encryptedContent" TEXT NOT NULL,
"encryptedKeyForSender" TEXT NOT NULL,
"encryptedKeyForReceiver" TEXT NOT NULL,
"iv" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE INDEX "Store_userId_idx" ON "Store"("userId");
-- CreateIndex
CREATE INDEX "CachedProduct_storeId_idx" ON "CachedProduct"("storeId");
-- CreateIndex
CREATE UNIQUE INDEX "CachedProduct_shopifyProductId_storeId_key" ON "CachedProduct"("shopifyProductId", "storeId");
-- CreateIndex
CREATE INDEX "CachedOrder_storeId_idx" ON "CachedOrder"("storeId");
-- CreateIndex
CREATE INDEX "CachedOrder_shopifyCreatedAt_idx" ON "CachedOrder"("shopifyCreatedAt");
-- CreateIndex
CREATE UNIQUE INDEX "CachedOrder_shopifyOrderId_storeId_key" ON "CachedOrder"("shopifyOrderId", "storeId");
-- CreateIndex
CREATE INDEX "CachedCustomer_storeId_idx" ON "CachedCustomer"("storeId");
-- CreateIndex
CREATE UNIQUE INDEX "CachedCustomer_shopifyCustomerId_storeId_key" ON "CachedCustomer"("shopifyCustomerId", "storeId");
-- CreateIndex
CREATE INDEX "SyncMetadata_storeId_idx" ON "SyncMetadata"("storeId");
-- CreateIndex
CREATE UNIQUE INDEX "SyncMetadata_storeId_resourceType_key" ON "SyncMetadata"("storeId", "resourceType");
-- CreateIndex
CREATE UNIQUE INDEX "SupplierProfile_userId_key" ON "SupplierProfile"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Connection_storeId_supplierId_key" ON "Connection"("storeId", "supplierId");
-- CreateIndex
CREATE UNIQUE INDEX "Invitation_token_key" ON "Invitation"("token");
-- CreateIndex
CREATE INDEX "Invitation_token_idx" ON "Invitation"("token");
-- CreateIndex
CREATE INDEX "Invitation_supplierId_idx" ON "Invitation"("supplierId");
-- CreateIndex
CREATE INDEX "Invitation_storeId_idx" ON "Invitation"("storeId");
-- CreateIndex
CREATE INDEX "SupplierProductOffer_storeId_idx" ON "SupplierProductOffer"("storeId");
-- CreateIndex
CREATE INDEX "SupplierProductOffer_supplierId_idx" ON "SupplierProductOffer"("supplierId");
-- CreateIndex
CREATE INDEX "SupplierProductOffer_connectionId_idx" ON "SupplierProductOffer"("connectionId");
-- CreateIndex
CREATE UNIQUE INDEX "SupplierProductOffer_productId_storeId_key" ON "SupplierProductOffer"("productId", "storeId");
-- CreateIndex
CREATE INDEX "ProductSync_storeId_idx" ON "ProductSync"("storeId");
-- CreateIndex
CREATE INDEX "ProductSync_supplierId_idx" ON "ProductSync"("supplierId");
-- CreateIndex
CREATE INDEX "ProductSync_status_idx" ON "ProductSync"("status");
-- CreateIndex
CREATE INDEX "ProductSync_shopifyProductId_idx" ON "ProductSync"("shopifyProductId");
-- CreateIndex
CREATE UNIQUE INDEX "ProductSync_storeId_supplierId_shopifyProductId_key" ON "ProductSync"("storeId", "supplierId", "shopifyProductId");
-- CreateIndex
CREATE UNIQUE INDEX "GmailIntegration_userId_key" ON "GmailIntegration"("userId");
-- CreateIndex
CREATE INDEX "GmailIntegration_userId_idx" ON "GmailIntegration"("userId");
-- CreateIndex
CREATE INDEX "Notification_userId_idx" ON "Notification"("userId");
-- CreateIndex
CREATE INDEX "Notification_userId_read_idx" ON "Notification"("userId", "read");
-- CreateIndex
CREATE INDEX "Notification_createdAt_idx" ON "Notification"("createdAt");
-- CreateIndex
CREATE INDEX "EmailLog_storeId_sentAt_idx" ON "EmailLog"("storeId", "sentAt");
-- CreateIndex
CREATE INDEX "EmailLog_userId_sentAt_idx" ON "EmailLog"("userId", "sentAt");
-- CreateIndex
CREATE INDEX "FlaggedEmail_storeId_idx" ON "FlaggedEmail"("storeId");
-- CreateIndex
CREATE INDEX "FlaggedEmail_storeId_flaggedAt_idx" ON "FlaggedEmail"("storeId", "flaggedAt");
-- CreateIndex
CREATE INDEX "KnowledgeDocument_storeId_idx" ON "KnowledgeDocument"("storeId");
-- CreateIndex
CREATE INDEX "FaqItem_storeId_idx" ON "FaqItem"("storeId");
-- CreateIndex
CREATE INDEX "KnowledgeChunk_storeId_idx" ON "KnowledgeChunk"("storeId");
-- CreateIndex
CREATE INDEX "KnowledgeChunk_documentId_idx" ON "KnowledgeChunk"("documentId");
-- CreateIndex
CREATE INDEX "KnowledgeChunk_faqItemId_idx" ON "KnowledgeChunk"("faqItemId");
-- CreateIndex
CREATE INDEX "Template_storeId_idx" ON "Template"("storeId");
-- CreateIndex
CREATE INDEX "ChatMessage_senderId_receiverId_idx" ON "ChatMessage"("senderId", "receiverId");
-- CreateIndex
CREATE INDEX "ChatMessage_receiverId_senderId_idx" ON "ChatMessage"("receiverId", "senderId");
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Store" ADD CONSTRAINT "Store_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CachedProduct" ADD CONSTRAINT "CachedProduct_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CachedOrder" ADD CONSTRAINT "CachedOrder_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CachedCustomer" ADD CONSTRAINT "CachedCustomer_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SyncMetadata" ADD CONSTRAINT "SyncMetadata_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupplierProfile" ADD CONSTRAINT "SupplierProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Connection" ADD CONSTRAINT "Connection_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Connection" ADD CONSTRAINT "Connection_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "SupplierProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "SupplierProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "SupplierProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupplierProductOffer" ADD CONSTRAINT "SupplierProductOffer_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupplierProductOffer" ADD CONSTRAINT "SupplierProductOffer_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupplierProductOffer" ADD CONSTRAINT "SupplierProductOffer_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "Connection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SupplierProductOffer" ADD CONSTRAINT "SupplierProductOffer_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "SupplierProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductSync" ADD CONSTRAINT "ProductSync_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProductSync" ADD CONSTRAINT "ProductSync_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "SupplierProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GmailIntegration" ADD CONSTRAINT "GmailIntegration_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmailLog" ADD CONSTRAINT "EmailLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmailLog" ADD CONSTRAINT "EmailLog_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FlaggedEmail" ADD CONSTRAINT "FlaggedEmail_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KnowledgeDocument" ADD CONSTRAINT "KnowledgeDocument_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FaqItem" ADD CONSTRAINT "FaqItem_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KnowledgeChunk" ADD CONSTRAINT "KnowledgeChunk_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KnowledgeChunk" ADD CONSTRAINT "KnowledgeChunk_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "KnowledgeDocument"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KnowledgeChunk" ADD CONSTRAINT "KnowledgeChunk_faqItemId_fkey" FOREIGN KEY ("faqItemId") REFERENCES "FaqItem"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -28,6 +28,7 @@ model User {
mfaSecret String? // Encrypted TOTP secret
chatPublicKey String?
chatPrivateKeyEncrypted String? @db.Text
// Relations
sentMessages ChatMessage[] @relation("SentMessages")
@ -110,6 +111,7 @@ model Store {
syncMetadata SyncMetadata[]
invitations Invitation[]
productSyncs ProductSync[]
supplierProductOffers SupplierProductOffer[]
knowledgeDocuments KnowledgeDocument[]
faqItems FaqItem[]
knowledgeChunks KnowledgeChunk[]
@ -217,6 +219,7 @@ model SupplierProfile {
products Product[]
invitations Invitation[]
productSyncs ProductSync[]
supplierProductOffers SupplierProductOffer[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -235,6 +238,7 @@ model Connection {
updatedAt DateTime @updatedAt
@@unique([storeId, supplierId])
supplierProductOffers SupplierProductOffer[]
}
model Invitation {
@ -276,6 +280,30 @@ model Product {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
offers SupplierProductOffer[]
}
model SupplierProductOffer {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
storeId String
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
connectionId String
connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
supplierId String
supplier SupplierProfile @relation(fields: [supplierId], references: [id], onDelete: Cascade)
price Float
quantity Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([productId, storeId])
@@index([storeId])
@@index([supplierId])
@@index([connectionId])
}
enum ProductSyncStatus {

View File

@ -0,0 +1,208 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
/**
* GET /api/supplier/vendors/[connectionId]/offers
* List all products this supplier has offered to a specific vendor connection
*/
export async function GET(
req: Request,
{ params }: { params: Promise<{ connectionId: string }> }
) {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const { connectionId } = await params
const supplierProfile = await prisma.supplierProfile.findUnique({
where: { userId: session.user.id }
})
if (!supplierProfile) {
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
}
const connection = await prisma.connection.findFirst({
where: {
id: connectionId,
supplierId: supplierProfile.id
},
include: {
store: true
}
})
if (!connection) {
return NextResponse.json({ message: "Connection not found" }, { status: 404 })
}
// Access SupplierProductOffer model (camelCase in Prisma Client)
if (!("supplierProductOffer" in prisma)) {
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
return NextResponse.json(
{ message: "Database model not available. Please regenerate Prisma Client." },
{ status: 500 }
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const offers = await (prisma as any).supplierProductOffer.findMany({
where: {
connectionId: connection.id
},
include: {
product: true,
store: {
select: {
id: true,
businessName: true
}
}
},
orderBy: {
createdAt: "desc"
}
})
return NextResponse.json({
offers
})
} catch (error) {
console.error("Error fetching supplier product offers:", error)
return NextResponse.json(
{ message: "Failed to fetch supplier product offers" },
{ status: 500 }
)
}
}
/**
* POST /api/supplier/vendors/[connectionId]/offers
* Create a new product offer from supplier to this vendor
*/
export async function POST(
req: Request,
{ params }: { params: Promise<{ connectionId: string }> }
) {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const { connectionId } = await params
const body = await req.json()
const { productId, price, quantity } = body as {
productId?: string
price?: number
quantity?: number
}
if (!productId || price === undefined || quantity === undefined) {
return NextResponse.json(
{ message: "productId, price and quantity are required" },
{ status: 400 }
)
}
if (quantity <= 0 || price < 0) {
return NextResponse.json(
{ message: "Price must be non-negative and quantity must be greater than zero" },
{ status: 400 }
)
}
const supplierProfile = await prisma.supplierProfile.findUnique({
where: { userId: session.user.id }
})
if (!supplierProfile) {
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
}
const connection = await prisma.connection.findFirst({
where: {
id: connectionId,
supplierId: supplierProfile.id
}
})
if (!connection) {
return NextResponse.json({ message: "Connection not found" }, { status: 404 })
}
if (connection.status !== "CONNECTED") {
return NextResponse.json(
{ message: "You can only provide products to connected vendors" },
{ status: 400 }
)
}
// Ensure the product belongs to this supplier
const product = await prisma.product.findFirst({
where: {
id: productId,
supplierId: supplierProfile.id
}
})
if (!product) {
return NextResponse.json(
{ message: "Product not found or does not belong to this supplier" },
{ status: 404 }
)
}
// Check for existing offer for this product + store
if (!("supplierProductOffer" in prisma)) {
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
return NextResponse.json(
{ message: "Database model not available. Please regenerate Prisma Client." },
{ status: 500 }
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const existingOffer = await (prisma as any).supplierProductOffer.findFirst({
where: {
productId,
storeId: connection.storeId
}
})
if (existingOffer) {
return NextResponse.json(
{ message: "This product has already been provided to this vendor" },
{ status: 400 }
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const offer = await (prisma as any).supplierProductOffer.create({
data: {
productId,
storeId: connection.storeId,
connectionId: connection.id,
supplierId: supplierProfile.id,
price,
quantity
}
})
return NextResponse.json({
message: "Product provided to vendor successfully",
offer
})
} catch (error) {
console.error("Error creating supplier product offer:", error)
return NextResponse.json(
{ message: "Failed to provide product to vendor" },
{ status: 500 }
)
}
}

View File

@ -2,6 +2,69 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
/**
* GET /api/supplier/vendors/[connectionId]
* Get a single vendor connection with store details for the authenticated supplier
*/
export async function GET(
req: Request,
{ params }: { params: Promise<{ connectionId: string }> }
) {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const { connectionId } = await params
const supplierProfile = await prisma.supplierProfile.findUnique({
where: { userId: session.user.id }
})
if (!supplierProfile) {
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
}
const connection = await prisma.connection.findFirst({
where: {
id: connectionId,
supplierId: supplierProfile.id
},
include: {
store: {
select: {
id: true,
businessName: true,
shopifyDomain: true,
shopifyStoreName: true,
email: true,
user: {
select: {
name: true,
email: true
}
}
}
}
}
})
if (!connection) {
return NextResponse.json({ message: "Connection not found" }, { status: 404 })
}
return NextResponse.json({ connection })
} catch (error) {
console.error("Error fetching vendor connection:", error)
return NextResponse.json(
{ message: "Failed to fetch vendor connection" },
{ status: 500 }
)
}
}
/**
* PUT /api/supplier/vendors/[connectionId]
* Update connection status (accept/reject)

View File

@ -0,0 +1,158 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
/**
* PUT /api/supplier/vendors/offers/[offerId]
* Update an existing supplier product offer (price, quantity)
*/
export async function PUT(
req: Request,
{ params }: { params: Promise<{ offerId: string }> }
) {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const { offerId } = await params
const body = await req.json()
const { price, quantity } = body as {
price?: number
quantity?: number
}
if (price === undefined && quantity === undefined) {
return NextResponse.json(
{ message: "At least one of price or quantity must be provided" },
{ status: 400 }
)
}
if (quantity !== undefined && quantity <= 0) {
return NextResponse.json(
{ message: "Quantity must be greater than zero" },
{ status: 400 }
)
}
if (price !== undefined && price < 0) {
return NextResponse.json(
{ message: "Price must be non-negative" },
{ status: 400 }
)
}
const supplierProfile = await prisma.supplierProfile.findUnique({
where: { userId: session.user.id }
})
if (!supplierProfile) {
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
}
if (!("supplierProductOffer" in prisma)) {
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
return NextResponse.json(
{ message: "Database model not available. Please regenerate Prisma Client." },
{ status: 500 }
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const existingOffer = await (prisma as any).supplierProductOffer.findFirst({
where: {
id: offerId,
supplierId: supplierProfile.id
}
})
if (!existingOffer) {
return NextResponse.json({ message: "Offer not found" }, { status: 404 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updatedOffer = await (prisma as any).supplierProductOffer.update({
where: { id: offerId },
data: {
...(price !== undefined ? { price } : {}),
...(quantity !== undefined ? { quantity } : {})
}
})
return NextResponse.json({
message: "Offer updated successfully",
offer: updatedOffer
})
} catch (error) {
console.error("Error updating supplier product offer:", error)
return NextResponse.json(
{ message: "Failed to update supplier product offer" },
{ status: 500 }
)
}
}
/**
* DELETE /api/supplier/vendors/offers/[offerId]
* Withdraw an existing supplier product offer from a vendor
*/
export async function DELETE(
req: Request,
{ params }: { params: Promise<{ offerId: string }> }
) {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const { offerId } = await params
const supplierProfile = await prisma.supplierProfile.findUnique({
where: { userId: session.user.id }
})
if (!supplierProfile) {
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
}
// Verify the offer belongs to this supplier
if (!("supplierProductOffer" in prisma)) {
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
return NextResponse.json(
{ message: "Database model not available. Please regenerate Prisma Client." },
{ status: 500 }
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const existingOffer = await (prisma as any).supplierProductOffer.findFirst({
where: {
id: offerId,
supplierId: supplierProfile.id
}
})
if (!existingOffer) {
return NextResponse.json({ message: "Offer not found" }, { status: 404 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (prisma as any).supplierProductOffer.delete({
where: { id: offerId }
})
return NextResponse.json({
message: "Offer withdrawn successfully"
})
} catch (error) {
console.error("Error deleting supplier product offer:", error)
return NextResponse.json(
{ message: "Failed to withdraw supplier product offer" },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,55 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { uploadSupplierProductImage } from "@/lib/s3"
export async function POST(req: Request) {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const formData = await req.formData()
const file = formData.get("file")
if (!(file instanceof File)) {
return NextResponse.json(
{ message: "Missing file" },
{ status: 400 }
)
}
// Resolve supplier profile so we can scope keys per-supplier
const supplierProfile = await prisma.supplierProfile.findUnique({
where: { userId: session.user.id },
select: { id: true }
})
if (!supplierProfile) {
return NextResponse.json(
{ message: "Supplier profile not found" },
{ status: 404 }
)
}
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const { url } = await uploadSupplierProductImage({
supplierId: supplierProfile.id,
fileBuffer: buffer,
contentType: file.type || undefined,
originalName: file.name
})
return NextResponse.json({ url })
} catch (error) {
console.error("[SUPPLIER_PRODUCT_IMAGE_UPLOAD]", error)
return NextResponse.json(
{ message: "Failed to upload image" },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,53 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { decryptString } from "@/lib/encryption"
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return new NextResponse("Unauthorized", { status: 401 })
}
const user = await prisma.user.findUnique({
where: { id: session.user.id }
}) as { chatPrivateKeyEncrypted?: string | null } | null
if (!user?.chatPrivateKeyEncrypted) {
return NextResponse.json(
{ hasBackup: false },
{
headers: {
"Cache-Control": "no-store"
}
}
)
}
const decrypted = decryptString(user.chatPrivateKeyEncrypted)
if (!decrypted) {
return NextResponse.json(
{ hasBackup: false },
{
headers: {
"Cache-Control": "no-store"
}
}
)
}
return NextResponse.json(
{ hasBackup: true, privateKey: decrypted },
{
headers: {
"Cache-Control": "no-store"
}
}
)
} catch (error) {
console.error("[CHAT_KEY_GET]", error)
return new NextResponse("Internal Error", { status: 500 })
}
}

View File

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { encryptString } from "@/lib/encryption"
export async function POST(req: Request) {
try {
@ -9,15 +10,31 @@ export async function POST(req: Request) {
return new NextResponse("Unauthorized", { status: 401 })
}
const { publicKey } = await req.json()
const { publicKey, privateKey } = await req.json() as {
publicKey?: string
privateKey?: string
}
if (!publicKey) {
return new NextResponse("Missing publicKey", { status: 400 })
}
let chatPrivateKeyEncrypted: string | undefined
if (privateKey) {
try {
chatPrivateKeyEncrypted = encryptString(privateKey)
} catch (err) {
console.error("[CHAT_SETUP] Failed to encrypt private key", err)
return new NextResponse("Failed to encrypt private key", { status: 500 })
}
}
await prisma.user.update({
where: { id: session.user.id },
data: { chatPublicKey: publicKey }
data: {
chatPublicKey: publicKey,
...(chatPrivateKeyEncrypted ? { chatPrivateKeyEncrypted } : {})
}
})
return NextResponse.json({ success: true })

View File

@ -0,0 +1,141 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
/**
* GET /api/vendors/synced-products/[id]
* Get a single product visible on the vendor's New Products page.
* The ID may correspond to either:
* - A ProductSync (vendor-initiated sync), or
* - A SupplierProductOffer (supplier-provided product)
*/
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const { id } = await params
// Get vendor's active store
const vendorStore = await prisma.store.findFirst({
where: {
userId: session.user.id,
isActive: true
}
})
if (!vendorStore) {
return NextResponse.json({ message: "Not found" }, { status: 404 })
}
// First, try to find a ProductSync
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sync = await (prisma as any).productSync.findFirst({
where: {
id,
storeId: vendorStore.id
},
include: {
supplier: {
select: {
id: true,
companyName: true
}
}
}
})
if (sync) {
const productData = sync.shopifyProductData || {}
const product = {
id: sync.id,
syncId: sync.id,
shopifyProductId: sync.shopifyProductId,
title: sync.shopifyProductTitle,
description: productData.body_html || productData.description || null,
price: productData.variants?.[0]?.price || "0",
sku: productData.variants?.[0]?.sku || null,
imageUrl: productData.image?.src || productData.images?.[0]?.src || null,
supplier: {
id: sync.supplier.id,
companyName: sync.supplier.companyName
},
syncedAt: sync.acceptedAt || sync.createdAt,
isSynced: true,
source: "synced",
shopifyData: productData
}
return NextResponse.json({ product })
}
// Otherwise, try SupplierProductOffer
if (!("supplierProductOffer" in prisma)) {
return NextResponse.json({ message: "Product not found" }, { status: 404 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const offer = await (prisma as any).supplierProductOffer.findFirst({
where: {
id,
storeId: vendorStore.id
},
include: {
product: {
select: {
id: true,
title: true,
description: true,
price: true,
sku: true,
imageUrl: true
}
},
supplier: {
select: {
id: true,
companyName: true
}
}
}
})
if (!offer) {
return NextResponse.json({ message: "Product not found" }, { status: 404 })
}
const product = {
id: offer.id,
syncId: null,
shopifyProductId: null,
title: offer.product.title,
description: offer.product.description,
price: offer.price.toFixed(2),
sku: offer.product.sku,
imageUrl: offer.product.imageUrl,
supplier: {
id: offer.supplier.id,
companyName: offer.supplier.companyName
},
syncedAt: offer.createdAt,
isSynced: false,
source: "supplier_offer",
offerQuantity: offer.quantity,
offerId: offer.id
}
return NextResponse.json({ product })
} catch (error) {
console.error("Error fetching synced product:", error)
return NextResponse.json(
{ message: "Failed to fetch product" },
{ status: 500 }
)
}
}

View File

@ -4,8 +4,10 @@ import { prisma } from "@/lib/prisma"
/**
* GET /api/vendors/synced-products
* Get all synced products for the vendor (read-only)
* These are products that vendors have synced with suppliers
* Get all products available to the vendor (read-only)
* Includes:
* - Products that vendors have synced with suppliers (ProductSync ACCEPTED)
* - Products that suppliers have provided directly to this vendor (SupplierProductOffer)
*/
export async function GET() {
try {
@ -27,7 +29,7 @@ export async function GET() {
return NextResponse.json({ products: [] })
}
// Get all ACCEPTED product syncs for this store
// Get all ACCEPTED product syncs for this store (vendor-initiated)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const acceptedSyncs = await (prisma as any).productSync.findMany({
where: {
@ -65,12 +67,70 @@ export async function GET() {
},
syncedAt: sync.acceptedAt || sync.createdAt,
isSynced: true, // Flag to indicate this is a synced product
source: "synced",
// Include full Shopify product data for reference
shopifyData: productData
}
})
// Get supplier-provided products for this store
if (!("supplierProductOffer" in prisma)) {
console.error("Prisma Client missing supplierProductOffer model. Run: npx prisma generate")
// Return only synced products if SupplierProductOffer model is missing
return NextResponse.json({ products: syncedProducts })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supplierOffers = await (prisma as any).supplierProductOffer.findMany({
where: {
storeId: vendorStore.id
},
include: {
product: {
select: {
id: true,
title: true,
description: true,
price: true,
sku: true,
imageUrl: true
}
},
supplier: {
select: {
id: true,
companyName: true
}
}
},
orderBy: {
createdAt: "desc"
}
})
const supplierProducts = supplierOffers.map((offer: any) => ({
id: offer.id,
syncId: null,
shopifyProductId: null,
title: offer.product.title,
description: offer.product.description,
// Store as string for consistency with existing response
price: offer.price.toFixed(2),
sku: offer.product.sku,
imageUrl: offer.product.imageUrl,
supplier: {
id: offer.supplier.id,
companyName: offer.supplier.companyName
},
syncedAt: offer.createdAt,
isSynced: false,
source: "supplier_offer",
offerQuantity: offer.quantity,
offerId: offer.id
}))
const allProducts = [...syncedProducts, ...supplierProducts]
return NextResponse.json({ products: allProducts })
} catch (error) {
console.error("Error fetching synced products:", error)
return NextResponse.json(

View File

@ -55,9 +55,7 @@ export default async function DashboardLayout({
</div>
<div className="flex items-center gap-1.5 md:gap-4 shrink-0">
<ThemeToggle />
<div className="hidden xs:block">
<NotificationBell />
</div>
<div className="hidden sm:flex flex-col text-right">
<span className="text-xs font-bold text-white truncate max-w-[120px]">
{session.user?.name || "Vendor User"}

View File

@ -8,14 +8,16 @@ import {
Image as ImageIcon,
Eye,
Building2,
Calendar
Calendar,
X,
Info
} from "lucide-react"
import Image from "next/image"
interface SyncedProduct {
id: string
syncId: string
shopifyProductId: string
syncId: string | null
shopifyProductId: string | null
title: string
description: string | null
price: string
@ -27,13 +29,19 @@ interface SyncedProduct {
}
syncedAt: string
isSynced: boolean
source?: "synced" | "supplier_offer"
shopifyData?: any
offerQuantity?: number
offerId?: string
}
export default function NewProductsPage() {
const [products, setProducts] = useState<SyncedProduct[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [selectedProduct, setSelectedProduct] = useState<SyncedProduct | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [detailError, setDetailError] = useState("")
useEffect(() => {
fetchSyncedProducts()
@ -54,11 +62,33 @@ export default function NewProductsPage() {
}
}
const openProductDetail = async (product: SyncedProduct) => {
setSelectedProduct(null)
setDetailError("")
setDetailLoading(true)
try {
const res = await fetch(`/api/vendors/synced-products/${product.id}`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.message || "Failed to load product")
}
const data = await res.json()
setSelectedProduct(data.product || product)
} catch (err) {
setDetailError(err instanceof Error ? err.message : "Failed to load product")
setSelectedProduct(product)
} finally {
setDetailLoading(false)
}
}
return (
<div className="h-full flex flex-col space-y-4 md:space-y-6">
<div className="flex-shrink-0">
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">New Products</h1>
<p className="text-sm md:text-base text-slate-400 mt-1">Products synced with your suppliers</p>
<p className="text-sm md:text-base text-slate-400 mt-1">
Products available from your connected suppliers
</p>
</div>
{error && (
@ -88,9 +118,11 @@ export default function NewProductsPage() {
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{products.map((product) => (
<div
<button
key={product.id}
className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 overflow-hidden hover:border-indigo-500/50 transition-colors group"
type="button"
onClick={() => openProductDetail(product)}
className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 overflow-hidden hover:border-indigo-500/50 transition-colors group text-left"
>
<div className="relative w-full h-48 bg-white/10 overflow-hidden">
{product.imageUrl ? (
@ -105,9 +137,9 @@ export default function NewProductsPage() {
<ImageIcon className="w-12 h-12 text-slate-500" />
</div>
)}
<div className="absolute top-2 right-2">
<div className="absolute top-2 right-2 flex gap-2">
<span className="px-2 py-1 bg-emerald-500/20 text-emerald-400 rounded-full text-xs font-bold uppercase">
Synced
{product.source === "supplier_offer" ? "From Supplier" : "Synced"}
</span>
</div>
</div>
@ -124,7 +156,9 @@ export default function NewProductsPage() {
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-2xl font-bold text-white">${product.price}</p>
<p className="text-2xl font-bold text-white">
${parseFloat(product.price).toFixed(2)}
</p>
{product.sku && (
<p className="text-xs text-slate-500 font-mono">SKU: {product.sku}</p>
)}
@ -136,17 +170,146 @@ export default function NewProductsPage() {
<Building2 className="w-4 h-4" />
<span className="truncate">{product.supplier.companyName}</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<div className="flex items-center justify-between gap-2 text-xs text-slate-500">
<div className="flex items-center gap-2">
<Calendar className="w-3 h-3" />
<span>Synced {new Date(product.syncedAt).toLocaleDateString()}</span>
</div>
<span>
{product.source === "supplier_offer"
? `Provided ${new Date(product.syncedAt).toLocaleDateString()}`
: `Synced ${new Date(product.syncedAt).toLocaleDateString()}`}
</span>
</div>
{product.source === "supplier_offer" && product.offerQuantity !== undefined && (
<span className="flex items-center gap-1">
<Info className="w-3 h-3" />
Qty: {product.offerQuantity}
</span>
)}
</div>
</div>
</div>
</button>
))}
</div>
)}
</div>
{selectedProduct && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-slate-900 border border-white/10 rounded-2xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<div className="flex items-center gap-2">
<Package className="w-5 h-5 text-indigo-400" />
<div>
<h2 className="text-lg font-bold text-white">{selectedProduct.title}</h2>
<p className="text-xs text-slate-500">
{selectedProduct.source === "supplier_offer"
? "Provided by supplier"
: "Synced product"}
</p>
</div>
</div>
<button
type="button"
onClick={() => {
setSelectedProduct(null)
setDetailError("")
}}
className="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{detailLoading && (
<div className="flex items-center gap-2 text-slate-400 text-sm mb-2">
<Loader2 className="w-4 h-4 animate-spin" />
Loading latest details...
</div>
)}
{detailError && (
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 flex items-center gap-2 text-sm text-red-300">
<AlertCircle className="w-4 h-4" />
<span>{detailError}</span>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative w-full h-56 bg-white/10 rounded-xl overflow-hidden">
{selectedProduct.imageUrl ? (
<Image
src={selectedProduct.imageUrl}
alt={selectedProduct.title}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-500">
<ImageIcon className="w-10 h-10" />
</div>
)}
</div>
<div className="space-y-3 text-sm text-slate-300">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wide">
Price
</p>
<p className="text-2xl font-bold text-white">
${parseFloat(selectedProduct.price).toFixed(2)}
</p>
{selectedProduct.source === "supplier_offer" &&
selectedProduct.offerQuantity !== undefined && (
<p className="mt-1 text-xs text-slate-500">
Quantity available from supplier:{" "}
{selectedProduct.offerQuantity}
</p>
)}
</div>
{selectedProduct.sku && (
<div>
<p className="text-xs text-slate-500 uppercase tracking-wide">
SKU
</p>
<p className="font-mono text-xs text-slate-200">
{selectedProduct.sku}
</p>
</div>
)}
<div>
<p className="text-xs text-slate-500 uppercase tracking-wide">
Supplier
</p>
<p className="text-sm text-slate-200">
{selectedProduct.supplier.companyName}
</p>
</div>
<div className="text-xs text-slate-500">
{selectedProduct.source === "supplier_offer"
? `Provided on ${new Date(
selectedProduct.syncedAt
).toLocaleString()}`
: `Synced on ${new Date(
selectedProduct.syncedAt
).toLocaleString()}`}
</div>
</div>
</div>
{selectedProduct.description && (
<div className="mt-2 text-sm text-slate-300 space-y-1">
<p className="text-xs text-slate-500 uppercase tracking-wide">
Description
</p>
<p>
{selectedProduct.description.replace(/<[^>]*>/g, "")}
</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -247,7 +247,7 @@ export default function DashboardPage() {
</div>
</div>
<div className="h-[250px] md:h-[350px] w-full">
<div className="h-[250px] md:h-[350px] w-full min-w-0">
{loading ? (
<div className="w-full h-full flex items-center justify-center rounded-2xl bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5">
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />

View File

@ -24,12 +24,32 @@ export default async function SupplierLayout({
// Ensure supplier profile exists, create if not
const { prisma } = await import("@/lib/prisma")
// Safety guard: make sure the corresponding User row exists in our application database.
// This can be missing after a database reset or if auth is backed by a different store.
const existingUser = await prisma.user.findUnique({
where: { id: session.user.id }
})
if (!existingUser) {
await prisma.user.create({
data: {
id: session.user.id,
name: session.user.name,
email: session.user.email,
image: (session.user as { image?: string | null })?.image ?? null,
role: session.user.role,
}
})
}
let supplierProfile = await prisma.supplierProfile.findUnique({
where: { userId: session.user.id }
})
if (!supplierProfile) {
// Create default supplier profile
// Create default supplier profile, but guard against inconsistent data
try {
supplierProfile = await prisma.supplierProfile.create({
data: {
userId: session.user.id,
@ -37,6 +57,19 @@ export default async function SupplierLayout({
description: null
}
})
} catch (error) {
if (error && typeof error === "object" && (error as { code?: string }).code === "P2003") {
console.error(
"[SUPPLIER_LAYOUT] Failed to create supplier profile due to missing user row",
{ userId: session.user.id, error }
)
// Session says user exists but DB row is missing; surface a clear error instead of looping redirects.
throw new Error(
"Your supplier account is missing required data in the database. Please try logging out and back in, or contact support."
)
}
throw error
}
}
return (

View File

@ -49,6 +49,7 @@ export default function SupplierProductsPage() {
sku: "",
imageUrl: ""
})
const [imageFile, setImageFile] = useState<File | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
@ -93,6 +94,7 @@ export default function SupplierProductsPage() {
sku: "",
imageUrl: ""
})
setImageFile(null)
setShowModal(true)
}
@ -105,6 +107,7 @@ export default function SupplierProductsPage() {
sku: product.sku || "",
imageUrl: product.imageUrl || ""
})
setImageFile(null)
setShowModal(true)
}
@ -144,6 +147,23 @@ export default function SupplierProductsPage() {
: "/api/supplier/products"
const method = editingProduct ? "PUT" : "POST"
// If a new image file is selected, upload it first to get a URL
let finalImageUrl = formData.imageUrl || null
if (imageFile) {
const fd = new FormData()
fd.append("file", imageFile)
const uploadRes = await fetch("/api/uploads/supplier-product-image", {
method: "POST",
body: fd
})
if (!uploadRes.ok) {
const data = await uploadRes.json().catch(() => ({}))
throw new Error(data.message || "Failed to upload image")
}
const uploadData = await uploadRes.json()
finalImageUrl = uploadData.url || null
}
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
@ -152,7 +172,7 @@ export default function SupplierProductsPage() {
description: formData.description || null,
price: parseFloat(formData.price),
sku: formData.sku || null,
imageUrl: formData.imageUrl || null
imageUrl: finalImageUrl
})
})
@ -378,7 +398,20 @@ export default function SupplierProductsPage() {
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-2">Image URL</label>
<label className="block text-sm font-medium text-slate-400 mb-2">Product image</label>
<div className="space-y-2">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0] || null
setImageFile(file)
}}
className="block w-full text-sm text-slate-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-500 file:text-white hover:file:bg-indigo-600"
/>
<p className="text-xs text-slate-500">
Optional: you can also paste an external URL instead of uploading.
</p>
<input
type="url"
value={formData.imageUrl}
@ -386,6 +419,10 @@ export default function SupplierProductsPage() {
className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
placeholder="https://example.com/image.jpg"
/>
<p className="text-xs text-slate-500">
If you upload a file and also provide a URL, the uploaded image will be used.
</p>
</div>
</div>
<div className="flex gap-3 pt-4">

View File

@ -0,0 +1,629 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import {
ArrowLeft,
Building2,
Globe,
Mail,
Loader2,
AlertCircle,
Package,
Plus,
Edit,
Trash2
} from "lucide-react"
import Image from "next/image"
interface StoreSummary {
id: string
businessName: string
shopifyDomain: string | null
shopifyStoreName: string | null
email?: string | null
user: {
name: string | null
email: string | null
}
}
interface ConnectionDetail {
id: string
status: "PENDING" | "CONNECTED" | "REJECTED"
createdAt: string
store: StoreSummary
}
interface Product {
id: string
title: string
description: string | null
price: number
sku: string | null
imageUrl: string | null
}
interface Offer {
id: string
price: number
quantity: number
createdAt: string
product: Product
store: {
id: string
businessName: string
}
}
interface SupplierProduct {
id: string
title: string
description: string | null
price: number
sku: string | null
imageUrl: string | null
isSynced?: boolean
}
export default function SupplierVendorDetailPage() {
const params = useParams<{ connectionId: string }>()
const router = useRouter()
const [connection, setConnection] = useState<ConnectionDetail | null>(null)
const [offers, setOffers] = useState<Offer[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [showProvideModal, setShowProvideModal] = useState(false)
const [products, setProducts] = useState<SupplierProduct[]>([])
const [productsLoading, setProductsLoading] = useState(false)
const [selectedProductId, setSelectedProductId] = useState<string>("")
const [priceInput, setPriceInput] = useState<string>("")
const [quantityInput, setQuantityInput] = useState<string>("")
const [submittingOffer, setSubmittingOffer] = useState(false)
const connectionId = params?.connectionId
useEffect(() => {
if (!connectionId) return
fetchData()
}, [connectionId])
useEffect(() => {
if (success) {
const timer = setTimeout(() => setSuccess(""), 5000)
return () => clearTimeout(timer)
}
}, [success])
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(""), 5000)
return () => clearTimeout(timer)
}
}, [error])
const fetchData = async () => {
try {
setLoading(true)
setError("")
const [connRes, offersRes] = await Promise.all([
fetch(`/api/supplier/vendors/${connectionId}`),
fetch(`/api/supplier/vendors/${connectionId}/offers`)
])
if (!connRes.ok) {
const data = await connRes.json().catch(() => ({}))
throw new Error(data.message || "Failed to load vendor")
}
const connData = await connRes.json()
setConnection(connData.connection)
if (offersRes.ok) {
const offersData = await offersRes.json()
setOffers(offersData.offers || [])
} else {
setOffers([])
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load vendor")
} finally {
setLoading(false)
}
}
const openProvideModal = async () => {
setShowProvideModal(true)
setError("")
setSuccess("")
setSelectedProductId("")
setPriceInput("")
setQuantityInput("")
try {
setProductsLoading(true)
const res = await fetch("/api/supplier/products")
if (!res.ok) throw new Error("Failed to load products")
const data = await res.json()
const allProducts: SupplierProduct[] = (data.products || []).filter(
(p: SupplierProduct) => !p.isSynced
)
// Filter out products already offered to this vendor
const offeredProductIds = new Set(offers.map((o) => o.product.id))
const availableProducts = allProducts.filter((p) => !offeredProductIds.has(p.id))
setProducts(availableProducts)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load products")
} finally {
setProductsLoading(false)
}
}
const handleCreateOffer = async (e: React.FormEvent) => {
e.preventDefault()
if (!connectionId || !selectedProductId) return
const price = parseFloat(priceInput)
const quantity = parseInt(quantityInput, 10)
if (Number.isNaN(price) || Number.isNaN(quantity)) {
setError("Please enter valid price and quantity")
return
}
setSubmittingOffer(true)
setError("")
try {
const res = await fetch(`/api/supplier/vendors/${connectionId}/offers`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
productId: selectedProductId,
price,
quantity
})
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.message || "Failed to provide product")
}
setSuccess(data.message || "Product provided to vendor successfully")
setShowProvideModal(false)
setSelectedProductId("")
setPriceInput("")
setQuantityInput("")
fetchData()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to provide product")
} finally {
setSubmittingOffer(false)
}
}
const handleUpdateOffer = async (offerId: string, price: number, quantity: number) => {
setError("")
setSuccess("")
try {
const res = await fetch(`/api/supplier/vendors/offers/${offerId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ price, quantity })
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.message || "Failed to update offer")
}
setSuccess(data.message || "Offer updated successfully")
fetchData()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update offer")
}
}
const handleWithdrawOffer = async (offerId: string) => {
if (!confirm("Are you sure you want to withdraw this product from the vendor?")) return
setError("")
setSuccess("")
try {
const res = await fetch(`/api/supplier/vendors/offers/${offerId}`, {
method: "DELETE"
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data.message || "Failed to withdraw offer")
}
setSuccess(data.message || "Offer withdrawn successfully")
setOffers((prev) => prev.filter((o) => o.id !== offerId))
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to withdraw offer")
}
}
const selectedProduct = products.find((p) => p.id === selectedProductId) || null
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => router.push("/supplier/vendors")}
className="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
Vendor Details
</h1>
<p className="text-sm md:text-base text-slate-400 mt-1">
View store information and provide products to this vendor.
</p>
</div>
</div>
{success && (
<div className="bg-emerald-500/20 border border-emerald-500/30 rounded-xl p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-emerald-400" />
<span className="text-sm text-emerald-400 font-medium">{success}</span>
</div>
)}
{error && (
<div className="bg-red-500/20 border border-red-500/30 rounded-xl p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-400" />
<span className="text-sm text-red-400 font-medium">{error}</span>
</div>
)}
{loading || !connection ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
</div>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="bg-white/5 rounded-xl p-6 border border-white/10 lg:col-span-1">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-slate-400" />
<div>
<h2 className="text-lg font-bold text-white">
{connection.store.businessName}
</h2>
{connection.store.shopifyStoreName && (
<p className="text-sm text-slate-400">
{connection.store.shopifyStoreName}
</p>
)}
</div>
</div>
</div>
<div className="space-y-3 text-sm text-slate-300">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-slate-400" />
<span>{connection.store.user.email}</span>
</div>
{connection.store.shopifyDomain && (
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-slate-400" />
<span className="truncate">{connection.store.shopifyDomain}</span>
</div>
)}
<p className="text-xs text-slate-500">
Connected on{" "}
{new Date(connection.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="lg:col-span-2 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-white flex items-center gap-2">
<Package className="w-5 h-5 text-slate-400" />
Products provided to this vendor
</h2>
<button
type="button"
onClick={openProvideModal}
disabled={connection.status !== "CONNECTED"}
className="px-4 py-2 bg-indigo-500 hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Provide Product
</button>
</div>
{offers.length === 0 ? (
<div className="bg-white/5 border border-white/10 rounded-xl p-6 text-sm text-slate-400 flex flex-col items-center justify-center gap-2">
<Package className="w-8 h-8 opacity-30" />
<p>No products have been provided to this vendor yet.</p>
<p>Click &quot;Provide Product&quot; to send your first product.</p>
</div>
) : (
<div className="space-y-3">
{offers.map((offer) => (
<div
key={offer.id}
className="bg-white/5 border border-white/10 rounded-xl p-4 flex flex-col md:flex-row md:items-center gap-4"
>
<div className="flex items-center gap-4 flex-1">
<div className="relative w-16 h-16 rounded-lg overflow-hidden bg-white/10 flex-shrink-0">
{offer.product.imageUrl ? (
<Image
src={offer.product.imageUrl}
alt={offer.product.title}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-500">
<Package className="w-6 h-6" />
</div>
)}
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-white">
{offer.product.title}
</p>
{offer.product.sku && (
<p className="text-xs text-slate-500">
SKU: {offer.product.sku}
</p>
)}
<p className="text-xs text-slate-500">
Provided on{" "}
{new Date(offer.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-6 md:gap-8">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wide">
Price for this vendor
</p>
<p className="text-lg font-bold text-white">
${offer.price.toFixed(2)}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wide">
Quantity
</p>
<p className="text-lg font-bold text-white">
{offer.quantity}
</p>
</div>
</div>
<div className="flex items-center gap-2 md:flex-col md:items-stretch">
<button
type="button"
onClick={() =>
handleUpdateOffer(
offer.id,
offer.price,
offer.quantity
)
}
className="flex-1 px-3 py-2 bg-white/10 hover:bg-white/15 text-slate-200 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
>
<Edit className="w-4 h-4" />
Edit
</button>
<button
type="button"
onClick={() => handleWithdrawOffer(offer.id)}
className="flex-1 px-3 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
>
<Trash2 className="w-4 h-4" />
Withdraw
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
{showProvideModal && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-slate-900 border border-white/10 rounded-2xl shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<div className="flex items-center gap-2">
<Package className="w-5 h-5 text-indigo-400" />
<h2 className="text-lg font-bold text-white">
Provide product to {connection?.store.businessName}
</h2>
</div>
<button
type="button"
onClick={() => setShowProvideModal(false)}
className="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
>
✕
</button>
</div>
<form onSubmit={handleCreateOffer} className="flex-1 flex flex-col overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 px-6 py-4 overflow-y-auto">
<div className="space-y-3">
<label className="block text-sm font-medium text-slate-300">
Select product from your catalog
</label>
{productsLoading ? (
<div className="flex items-center justify-center py-10 text-slate-400">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading products...
</div>
) : products.length === 0 ? (
<p className="text-sm text-slate-500">
You have no available products to provide. Create a product in
your Products tab first.
</p>
) : (
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
{products.map((product) => (
<button
key={product.id}
type="button"
onClick={() => {
setSelectedProductId(product.id)
setPriceInput(product.price.toString())
}}
className={`w-full flex items-center gap-3 rounded-lg border px-3 py-2 text-left transition-colors ${
selectedProductId === product.id
? "border-indigo-500 bg-indigo-500/10"
: "border-white/10 hover:border-white/20"
}`}
>
<div className="relative w-10 h-10 rounded-md overflow-hidden bg-white/10 flex-shrink-0">
{product.imageUrl ? (
<Image
src={product.imageUrl}
alt={product.title}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-500">
<Package className="w-4 h-4" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white truncate">
{product.title}
</p>
{product.sku && (
<p className="text-xs text-slate-500">
SKU: {product.sku}
</p>
)}
</div>
<div className="text-sm text-slate-300 font-medium">
${product.price.toFixed(2)}
</div>
</button>
))}
</div>
)}
</div>
<div className="space-y-4">
{!selectedProduct && (
<div className="h-full flex flex-col items-center justify-center text-slate-500 text-sm bg-white/5 border border-dashed border-white/10 rounded-xl px-4 py-6">
<p className="font-medium text-slate-300 mb-1">
Select a product to configure pricing
</p>
<p className="text-xs text-slate-500 text-center">
Choose a product from your catalog on the left. Price and total
items will appear here after you make a selection.
</p>
</div>
)}
{selectedProduct && (
<>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">
Price for this vendor
</label>
<div className="flex items-center gap-2">
<span className="text-slate-400">$</span>
<input
type="number"
step="0.01"
min="0"
value={priceInput}
onChange={(e) => setPriceInput(e.target.value)}
className="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
placeholder="0.00"
/>
</div>
<p className="mt-1 text-xs text-slate-500">
Base catalog price: ${selectedProduct.price.toFixed(2)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1">
Total items (quantity)
</label>
<input
type="number"
min="1"
value={quantityInput}
onChange={(e) => setQuantityInput(e.target.value)}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
placeholder="e.g. 100"
/>
</div>
<div className="mt-4 bg-white/5 border border-white/10 rounded-lg p-3 text-xs text-slate-400 space-y-1">
<p className="font-semibold text-slate-200">
Preview: {selectedProduct.title}
</p>
{selectedProduct.description && (
<p className="line-clamp-3">
{selectedProduct.description}
</p>
)}
</div>
</>
)}
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10">
<button
type="button"
onClick={() => setShowProvideModal(false)}
className="px-4 py-2 rounded-lg border border-white/10 text-slate-200 hover:bg-white/5 transition-colors text-sm font-medium"
>
Cancel
</button>
<button
type="submit"
disabled={
submittingOffer ||
!selectedProductId ||
!priceInput ||
!quantityInput
}
className="px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-2"
>
{submittingOffer ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Sending...
</>
) : (
<>
<Plus className="w-4 h-4" />
Send product
</>
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@ -437,9 +437,15 @@ function SupplierVendorsContent() {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredConnections.map((connection) => (
<div
<button
key={connection.id}
className="bg-white/5 rounded-xl p-6 border border-white/10 hover:border-white/20 transition-colors"
type="button"
onClick={() => {
if (connection.status === "CONNECTED") {
router.push(`/supplier/vendors/${connection.id}`)
}
}}
className="bg-white/5 rounded-xl p-6 border border-white/10 hover:border-white/20 transition-colors text-left w-full"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-2">
@ -454,7 +460,11 @@ function SupplierVendorsContent() {
</div>
{connection.status !== "PENDING" && (
<button
onClick={() => handleDelete(connection.id)}
type="button"
onClick={(e) => {
e.stopPropagation()
handleDelete(connection.id)
}}
className="p-1 hover:bg-red-500/20 rounded text-slate-400 hover:text-red-400 transition-colors"
title="Remove"
>
@ -494,14 +504,22 @@ function SupplierVendorsContent() {
{connection.status === "PENDING" && (
<div className="flex gap-2 mt-4 pt-4 border-t border-white/10">
<button
onClick={() => handleStatusChange(connection.id, "CONNECTED")}
type="button"
onClick={(e) => {
e.stopPropagation()
handleStatusChange(connection.id, "CONNECTED")
}}
className="flex-1 px-3 py-2 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-400 rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
Accept
</button>
<button
onClick={() => handleStatusChange(connection.id, "REJECTED")}
type="button"
onClick={(e) => {
e.stopPropagation()
handleStatusChange(connection.id, "REJECTED")
}}
className="flex-1 px-3 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
>
<XCircle className="w-4 h-4" />
@ -509,7 +527,7 @@ function SupplierVendorsContent() {
</button>
</div>
)}
</div>
</button>
))}
</div>
)}

View File

@ -1,6 +1,7 @@
"use client"
import { useState, useEffect } from "react"
import { createPortal } from "react-dom"
import { usePathname } from "next/navigation"
import { motion, AnimatePresence } from "framer-motion"
import { X, Menu } from "lucide-react"
@ -15,7 +16,11 @@ export function MobileSidebar() {
const isLight = theme === "light"
const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Close sidebar when route changes
useEffect(() => {
@ -52,7 +57,8 @@ export function MobileSidebar() {
</button>
{/* Mobile Sidebar Overlay */}
<AnimatePresence>
{mounted && createPortal(
<AnimatePresence mode="wait">
{isOpen && (
<>
<motion.div
@ -61,7 +67,7 @@ export function MobileSidebar() {
exit={{ opacity: 0 }}
key="sidebar-backdrop"
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[9999] md:hidden pointer-events-auto"
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[99999] md:hidden pointer-events-auto"
onClick={() => setIsOpen(false)}
/>
<motion.div
@ -71,7 +77,7 @@ export function MobileSidebar() {
key="sidebar-content"
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className={cn(
"fixed inset-y-0 left-0 w-72 backdrop-blur-xl border-r z-[10000] md:hidden flex flex-col pointer-events-auto",
"fixed inset-y-0 left-0 w-72 backdrop-blur-xl border-r z-[100000] md:hidden flex flex-col pointer-events-auto",
isLight
? "bg-white/95 border-slate-200"
: "bg-slate-900/95 border-white/10"
@ -122,7 +128,9 @@ export function MobileSidebar() {
</motion.div>
</>
)}
</AnimatePresence>
</AnimatePresence>,
document.body
)}
</>
)
}

View File

@ -1,15 +1,23 @@
"use client"
import { useState, useEffect } from "react"
import { createPortal } from "react-dom"
import { usePathname } from "next/navigation"
import { motion, AnimatePresence } from "framer-motion"
import { X, Menu, Zap } from "lucide-react"
import { X, Menu } from "lucide-react"
import { SupplierSidebar } from "./SupplierSidebar"
import Image from "next/image"
export function MobileSupplierSidebar() {
const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Close sidebar when route changes
useEffect(() => {
const timer = setTimeout(() => setIsOpen(false), 0)
@ -40,29 +48,39 @@ export function MobileSupplierSidebar() {
</button>
{/* Mobile Sidebar Overlay */}
<AnimatePresence>
{mounted && createPortal(
<AnimatePresence mode="wait">
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
key="supplier-sidebar-backdrop"
transition={{ duration: 0.2 }}
onClick={() => setIsOpen(false)}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[200] md:hidden"
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[99999] md:hidden"
/>
<motion.div
initial={{ x: -288 }}
animate={{ x: 0 }}
exit={{ x: -288 }}
key="supplier-sidebar-content"
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed left-0 top-0 bottom-0 w-72 bg-slate-900/95 backdrop-blur-xl border-r border-white/10 z-[210] md:hidden overflow-y-auto"
className="fixed left-0 top-0 bottom-0 w-72 bg-slate-900/95 backdrop-blur-xl border-r border-white/10 z-[100000] md:hidden overflow-y-auto"
>
<div className="p-4 flex items-center justify-between border-b border-white/10">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-indigo-500/40 border border-white/20 flex items-center justify-center">
<Zap className="w-5 h-5 text-white fill-white" />
<div className="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-md border border-white/10">
<Image
src="/logo.png"
alt="JUNOHUB Logo"
width={36}
height={36}
className="object-cover"
/>
</div>
<h1 className="text-lg font-bold text-white">JUNOHUB</h1>
<h1 className="text-xl font-bold tracking-tight text-white">JUNOHUB</h1>
</div>
<button
onClick={() => setIsOpen(false)}
@ -72,11 +90,13 @@ export function MobileSupplierSidebar() {
<X className="w-5 h-5" />
</button>
</div>
<SupplierSidebar />
<SupplierSidebar showLogo={false} />
</motion.div>
</>
)}
</AnimatePresence>
</AnimatePresence>,
document.body
)}
</>
)
}

View File

@ -8,8 +8,8 @@ import {
ShoppingCart,
Users,
Mail,
Zap,
} from "lucide-react"
import Image from "next/image"
import { cn } from "@/lib/utils"
const supplierRoutes = [
@ -45,17 +45,29 @@ const supplierRoutes = [
},
]
export function SupplierSidebar() {
interface SupplierSidebarProps {
showLogo?: boolean
}
export function SupplierSidebar({ showLogo = true }: SupplierSidebarProps) {
const pathname = usePathname()
return (
<div className="space-y-4 py-4 flex flex-col h-full text-white">
{showLogo && (
<div className="px-6 py-2 flex items-center mb-6">
<div className="w-8 h-8 rounded-lg bg-indigo-500/40 border border-white/20 flex items-center justify-center mr-3 backdrop-blur-sm">
<Zap className="w-5 h-5 text-white fill-white" />
<div className="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center mr-3 shadow-md border border-white/10">
<Image
src="/logo.png"
alt="JUNOHUB Logo"
width={36}
height={36}
className="object-cover"
/>
</div>
<h1 className="text-xl font-bold tracking-tight">JUNOHUB</h1>
</div>
)}
<div className="flex-1 px-3 space-y-1 overflow-y-auto">
{supplierRoutes.map((route) => (
<Link

View File

@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from "react"
import { usePathname } from "next/navigation"
import { getSession } from "next-auth/react"
import { getSession, useSession } from "next-auth/react"
import { useChatCrypto, PRIVATE_KEY_STORAGE_KEY, PUBLIC_KEY_STORAGE_KEY } from "@/hooks/useChatCrypto"
import { ChatCrypto } from "@/lib/chat-crypto"
import { useTheme } from "@/components/ThemeProvider"
@ -69,7 +69,9 @@ export function ChatView() {
const isSupplier = pathname.startsWith("/supplier")
const { theme } = useTheme()
const isLight = theme === "light"
const { hasKeys, loading: cryptoLoading, setupKeys, privateKey, getStoredPublicKey } = useChatCrypto()
const { data: session } = useSession()
const userId = (session?.user as { id?: string })?.id
const { hasKeys, loading: cryptoLoading, setupKeys, privateKey, getStoredPublicKey } = useChatCrypto(userId)
const [contacts, setContacts] = useState<Contact[]>([])
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
@ -95,6 +97,11 @@ export function ChatView() {
const handleSetup = async () => {
try {
setContactsError(null)
// Ensure we have a user id before trying to set up keys
if (!userId) {
setContactsError("Session not ready. Please refresh the page and try again.")
return
}
await setupKeys()
await getSession()
fetchContacts()
@ -106,7 +113,7 @@ export function ChatView() {
// Auto-setup chat keys when user has none (e.g. supplier who just accepted invitation)
useEffect(() => {
if (cryptoLoading || hasKeys || autoSetupAttemptedRef.current) return
if (cryptoLoading || hasKeys || autoSetupAttemptedRef.current || !userId) return
autoSetupAttemptedRef.current = true
setContactsError(null)
setupKeys()

View File

@ -19,16 +19,40 @@ export function useChatCrypto(userId?: string) {
setLoading(false)
return
}
const stored = localStorage.getItem(privateKeyKey)
const stored = typeof window !== "undefined" ? localStorage.getItem(privateKeyKey) : null
if (stored) {
setPrivateKey(stored)
setHasKeys(true)
setLoading(false)
return
}
// No local key – try to restore from server backup
; (async () => {
try {
const res = await fetch("/api/user/chat-key", { cache: "no-store" })
if (!res.ok) {
setLoading(false)
return
}
const data = await res.json()
if (data.hasBackup && data.privateKey && typeof window !== "undefined") {
localStorage.setItem(privateKeyKey, data.privateKey as string)
localStorage.setItem(publicKeyKey, (data.publicKey as string | undefined) ?? localStorage.getItem(publicKeyKey) ?? "")
setPrivateKey(data.privateKey as string)
setHasKeys(true)
} else {
setPrivateKey(null)
setHasKeys(false)
}
} catch (error) {
console.error("restore chat key failed", error)
} finally {
setLoading(false)
}, [userId, privateKeyKey])
}
})()
}, [userId, privateKeyKey, publicKeyKey])
const setupKeys = useCallback(async () => {
if (!userId) throw new Error("User ID required for chat setup")
@ -44,7 +68,7 @@ export function useChatCrypto(userId?: string) {
const res = await fetch("/api/user/chat-setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ publicKey: keys.publicKey })
body: JSON.stringify({ publicKey: keys.publicKey, privateKey: keys.privateKey })
})
if (!res.ok) throw new Error("Failed to save public key")

View File

@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
import { sendPushNotification } from "@/lib/push"
// Keep this union in sync with the NotificationType enum in prisma/schema.prisma
type NotificationType =
@ -18,6 +19,7 @@ interface CreateNotificationParams {
title: string
message: string
metadata?: Record<string, unknown>
url?: string
}
/**
@ -28,7 +30,8 @@ export async function createNotification({
type,
title,
message,
metadata
metadata,
url
}: CreateNotificationParams) {
try {
// Use dynamic access to handle environments where the Notification model
@ -43,6 +46,18 @@ export async function createNotification({
metadata: metadata ? metadata : undefined
}
})
// Fire-and-forget push notification for this event, if push is enabled
if (url) {
sendPushNotification(userId, {
title,
body: message,
url
}).catch((error) => {
logger.error("Error sending push notification for notification", error)
})
}
return notification
} catch (error) {
logger.error("Error creating notification", error)
@ -68,7 +83,8 @@ export async function notifyInvitationAccepted(
connectionId,
supplierName,
storeName
}
},
url: "/dashboard/suppliers/my"
})
}
@ -90,7 +106,8 @@ export async function notifyInvitationReceived(
invitationId,
vendorName,
storeName
}
},
url: "/supplier/vendors"
})
}
@ -115,7 +132,8 @@ export async function notifyConnectionStatusChanged(
connectionId,
status,
otherPartyName
}
},
url: "/dashboard"
})
}
@ -137,7 +155,8 @@ export async function notifyProductSyncRequested(
syncId,
vendorName,
productTitle
}
},
url: "/supplier/vendors"
})
}
@ -164,6 +183,7 @@ export async function notifyProductSyncStatusChanged(
supplierName,
productTitle,
status
}
},
url: "/dashboard/new-products"
})
}

75
src/lib/s3.ts Normal file
View File

@ -0,0 +1,75 @@
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
import crypto from "crypto"
// Prefer S3-specific env vars, fall back to generic AWS ones
const REGION =
process.env.SUPPLIER_PRODUCT_IMAGES_REGION ||
process.env.AWS_S3_REGION ||
process.env.AWS_REGION ||
process.env.AWS_DEFAULT_REGION ||
"us-east-1"
const BUCKET =
process.env.SUPPLIER_PRODUCT_IMAGES_BUCKET ||
process.env.AWS_S3_BUCKET_NAME ||
""
const ACCESS_KEY_ID = process.env.AWS_S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID
const SECRET_ACCESS_KEY = process.env.AWS_S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY
if (!BUCKET) {
// We intentionally don't throw here to avoid crashing the app in environments
// where S3 hasn't been configured yet. The upload API will check and fail
// gracefully with a 500 + clear message.
// eslint-disable-next-line no-console
console.warn(
"[S3] SUPPLIER_PRODUCT_IMAGES_BUCKET or AWS_S3_BUCKET_NAME is not set. Supplier image uploads will fail."
)
}
export const s3Client = new S3Client({
region: REGION,
credentials:
ACCESS_KEY_ID && SECRET_ACCESS_KEY
? {
accessKeyId: ACCESS_KEY_ID,
secretAccessKey: SECRET_ACCESS_KEY
}
: undefined
})
export async function uploadSupplierProductImage(params: {
supplierId: string
fileBuffer: Buffer
contentType?: string
originalName?: string
}) {
if (!BUCKET) {
throw new Error("S3 bucket is not configured for supplier product images")
}
const extensionFromType =
params.contentType && params.contentType.includes("/")
? params.contentType.split("/")[1]
: undefined
const ext =
(params.originalName && params.originalName.split(".").pop()) ||
extensionFromType ||
"bin"
const key = `supplier-products/${params.supplierId}/${crypto.randomUUID()}.${ext}`
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: params.fileBuffer,
ContentType: params.contentType
})
await s3Client.send(command)
const url = `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`
return { key, url }
}