Product sharing fix
This commit is contained in:
parent
8354a90698
commit
1ff77cb459
@ -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
|
||||
|
||||
|
||||
@ -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..."
|
||||
|
||||
@ -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
995
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
657
prisma/migrations/20260216152711_init_schema/migration.sql
Normal file
657
prisma/migrations/20260216152711_init_schema/migration.sql
Normal 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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@ -27,7 +27,8 @@ model User {
|
||||
mfaEnabled Boolean @default(false)
|
||||
mfaSecret String? // Encrypted TOTP secret
|
||||
|
||||
chatPublicKey String?
|
||||
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 {
|
||||
|
||||
208
src/app/api/supplier/vendors/[connectionId]/offers/route.ts
vendored
Normal file
208
src/app/api/supplier/vendors/[connectionId]/offers/route.ts
vendored
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
158
src/app/api/supplier/vendors/offers/[offerId]/route.ts
vendored
Normal file
158
src/app/api/supplier/vendors/offers/[offerId]/route.ts
vendored
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
55
src/app/api/uploads/supplier-product-image/route.ts
Normal file
55
src/app/api/uploads/supplier-product-image/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
53
src/app/api/user/chat-key/route.ts
Normal file
53
src/app/api/user/chat-key/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
|
||||
141
src/app/api/vendors/synced-products/[id]/route.ts
vendored
Normal file
141
src/app/api/vendors/synced-products/[id]/route.ts
vendored
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
68
src/app/api/vendors/synced-products/route.ts
vendored
68
src/app/api/vendors/synced-products/route.ts
vendored
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ products: syncedProducts })
|
||||
// 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(
|
||||
|
||||
@ -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>
|
||||
<NotificationBell />
|
||||
<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"}
|
||||
|
||||
@ -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">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>Synced {new Date(product.syncedAt).toLocaleDateString()}</span>
|
||||
<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>
|
||||
{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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -24,19 +24,52 @@ 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
|
||||
supplierProfile = await prisma.supplierProfile.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
companyName: session.user.name || "My Company",
|
||||
description: null
|
||||
// Create default supplier profile, but guard against inconsistent data
|
||||
try {
|
||||
supplierProfile = await prisma.supplierProfile.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
companyName: session.user.name || "My Company",
|
||||
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 (
|
||||
|
||||
@ -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,14 +398,31 @@ export default function SupplierProductsPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-400 mb-2">Image URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.imageUrl}
|
||||
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
<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}
|
||||
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
|
||||
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">
|
||||
|
||||
629
src/app/supplier/vendors/[connectionId]/page.tsx
vendored
Normal file
629
src/app/supplier/vendors/[connectionId]/page.tsx
vendored
Normal 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 "Provide Product" 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>
|
||||
)
|
||||
}
|
||||
|
||||
30
src/app/supplier/vendors/page.tsx
vendored
30
src/app/supplier/vendors/page.tsx
vendored
@ -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>
|
||||
)}
|
||||
|
||||
@ -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,77 +57,80 @@ export function MobileSidebar() {
|
||||
</button>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: -288 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -288 }}
|
||||
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",
|
||||
isLight
|
||||
? "bg-white/95 border-slate-200"
|
||||
: "bg-slate-900/95 border-white/10"
|
||||
)}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-between p-4 border-b",
|
||||
isLight ? "border-slate-100" : "border-white/10"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-md border",
|
||||
isLight
|
||||
? "border-slate-200"
|
||||
: "border-white/10"
|
||||
)}>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="JUNOHUB Logo"
|
||||
width={36}
|
||||
height={36}
|
||||
className="object-cover"
|
||||
/>
|
||||
{mounted && createPortal(
|
||||
<AnimatePresence mode="wait">
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
key="sidebar-backdrop"
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[99999] md:hidden pointer-events-auto"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: -288 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -288 }}
|
||||
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-[100000] md:hidden flex flex-col pointer-events-auto",
|
||||
isLight
|
||||
? "bg-white/95 border-slate-200"
|
||||
: "bg-slate-900/95 border-white/10"
|
||||
)}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-between p-4 border-b",
|
||||
isLight ? "border-slate-100" : "border-white/10"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-md border",
|
||||
isLight
|
||||
? "border-slate-200"
|
||||
: "border-white/10"
|
||||
)}>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="JUNOHUB Logo"
|
||||
width={36}
|
||||
height={36}
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<h1 className={cn(
|
||||
"text-xl font-bold tracking-tight",
|
||||
isLight ? "text-slate-900" : "text-white"
|
||||
)}>JUNOHUB</h1>
|
||||
</div>
|
||||
<h1 className={cn(
|
||||
"text-xl font-bold tracking-tight",
|
||||
isLight ? "text-slate-900" : "text-white"
|
||||
)}>JUNOHUB</h1>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={cn(
|
||||
"p-2 rounded-lg transition-all border shadow-sm",
|
||||
isLight
|
||||
? "bg-slate-50 border-slate-200 text-slate-600 hover:bg-slate-100"
|
||||
: "bg-white/10 border-white/10 text-white hover:bg-white/15"
|
||||
)}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={cn(
|
||||
"p-2 rounded-lg transition-all border shadow-sm",
|
||||
isLight
|
||||
? "bg-slate-50 border-slate-200 text-slate-600 hover:bg-slate-100"
|
||||
: "bg-white/10 border-white/10 text-white hover:bg-white/15"
|
||||
)}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Sidebar Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Sidebar showLogo={false} className="!bg-transparent !border-none" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Sidebar Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Sidebar showLogo={false} className="!bg-transparent !border-none" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,43 +48,55 @@ export function MobileSupplierSidebar() {
|
||||
</button>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[200] md:hidden"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: -288 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -288 }}
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
{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/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-[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-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-xl font-bold tracking-tight text-white">JUNOHUB</h1>
|
||||
</div>
|
||||
<h1 className="text-lg font-bold text-white">JUNOHUB</h1>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<SupplierSidebar />
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<SupplierSidebar showLogo={false} />
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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">
|
||||
<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" />
|
||||
{showLogo && (
|
||||
<div className="px-6 py-2 flex items-center mb-6">
|
||||
<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>
|
||||
<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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
} else {
|
||||
setPrivateKey(null)
|
||||
setHasKeys(false)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(false)
|
||||
}, [userId, privateKeyKey])
|
||||
|
||||
// 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, 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")
|
||||
|
||||
@ -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
75
src/lib/s3.ts
Normal 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 }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user