DEV Community

Safal Bhandari
Safal Bhandari

Posted on

Dump

Vendor Management System - Complete Guide

Production-ready vendor management system with registration, approval workflow, document management with ImageKit integration, and comprehensive product management.


Table of Contents

  1. Overview
  2. How Vendor Registration Works
  3. Vendor Statuses & Workflow
  4. Architecture & Workflow
  5. API Endpoints - Vendor Management
  6. API Endpoints - Product Management
  7. Database Models
  8. Implementation Guide
  9. ImageKit Integration
  10. Security Best Practices
  11. Testing & Deployment
  12. Troubleshooting
  13. FAQ
  14. Quick Reference Guide

Overview

This guide covers the production-ready vendor management system where any authenticated user (CUSTOMER role) can apply to become a vendor by submitting a registration application with business documents. Admins with MANAGE_VENDORS permission review and approve/reject applications. Upon approval, the user's role is upgraded from CUSTOMER to VENDOR, enabling them to manage products.

Key Features

One-Step Registration - Complete vendor application with all documents in one request
Document Upload via ImageKit - All documents uploaded securely to ImageKit CDN
Admin Approval Workflow - Admins review and approve/reject applications
Real-Time Status Tracking - Users can check application status anytime
Easy Resubmission - Update documents and info if rejected
Automatic Role Upgrade - User role upgraded to VENDOR on approval
Product Management - Full CRUD operations for products
Inventory Management - Track stock levels and batches
Activity Logging - Complete audit trail
Production Ready - Enterprise-grade implementation

Vendor Journey

Step User Role Action Vendor Status
1 CUSTOMER User applies to become vendor PENDING
2 CUSTOMER Admin reviews application & documents PENDING
3a VENDOR Admin approves → Role upgraded to VENDOR APPROVED
3b CUSTOMER Admin rejects → Role stays CUSTOMER REJECTED
4 CUSTOMER If rejected, user fixes & resubmits PENDING
5 VENDOR Approved vendors can manage products APPROVED
6 VENDOR Admin can suspend vendor account SUSPENDED

Quick Start Guide

For Users (Becoming a Vendor)

  1. Register as Vendor (one-time, all documents at once)
   POST /api/vendor/register
   - Upload all required documents in one request
   - Status automatically set to PENDING
Enter fullscreen mode Exit fullscreen mode
  1. Track Application Status
   GET /api/vendor/status
   - Check if pending, approved, or rejected
   - See rejection reason if rejected
Enter fullscreen mode Exit fullscreen mode
  1. Resubmit if Rejected
   PUT /api/vendor/resubmit
   - Upload corrected documents
   - Update any incorrect information
   - Status returns to PENDING
Enter fullscreen mode Exit fullscreen mode
  1. Start Selling (after approval)
   - Your role is automatically upgraded to VENDOR
   - Access vendor dashboard
   - Add products, manage inventory
Enter fullscreen mode Exit fullscreen mode

For Admins

  1. Review Applications
   GET /api/admin/vendors?status=PENDING
   - View all pending applications
   - Review documents and business details
Enter fullscreen mode Exit fullscreen mode
  1. Approve or Reject
   PUT /api/admin/vendors/:id/approve
   - Approves vendor, upgrades user role to VENDOR

   PUT /api/admin/vendors/:id/reject
   - Rejects with reason, user can resubmit
Enter fullscreen mode Exit fullscreen mode
  1. Manage Vendors
   PUT /api/admin/vendors/:id/suspend
   - Suspend problematic vendors
Enter fullscreen mode Exit fullscreen mode

How Vendor Registration Works

Registration Flow

Step 1: User Submits Vendor Application

  • Any authenticated user (CUSTOMER role) can apply to become a vendor
  • User submits complete vendor registration in one request with:
    • All business details (name, email, phone, address, bank info)
    • License numbers and tax IDs
    • All required documents (business registration, pharmacy license, tax document)
    • Optional logo image
  • All documents uploaded to ImageKit in the same request
  • Vendor profile created with status PENDING
  • User role remains CUSTOMER until approved
  • Admin receives notification
  • User can track application status using /api/vendor/status endpoint

Step 2: Admin Reviews Application

  • Admin with MANAGE_VENDORS permission reviews application
  • Checks all documents and business details
  • Verifies license numbers and tax documents
  • Can approve or reject with reason

Step 3: Approval/Rejection

  • If Approved:
    • Vendor status changes to APPROVED
    • User role upgraded from CUSTOMER to VENDOR
    • verifiedAt timestamp recorded
    • Vendor can now start adding products
    • Email/SMS notification sent
  • If Rejected:
    • Vendor status changes to REJECTED
    • User role remains CUSTOMER
    • Rejection reason provided to user
    • User notified to fix issues and resubmit

Step 4: Resubmission (if rejected)

  • User checks rejection reason via /api/vendor/status endpoint
  • User can resubmit via /api/vendor/resubmit endpoint
  • Upload corrected/new documents in one request
  • Update any incorrect business information
  • New documents replace old ones in ImageKit (old ones cleaned up)
  • Vendor status returns from REJECTED to PENDING
  • User role still CUSTOMER
  • Admin receives notification for re-review

Smart Document Handling

  • Documents stored in ImageKit CDN
  • Automatic image optimization
  • Secure URL generation
  • Version tracking for resubmissions
  • Automatic cleanup of old documents

Vendor Statuses & Workflow

Status Definitions

PENDING     → Initial status after registration or resubmission
APPROVED    → Vendor verified and can manage products
REJECTED    → Registration rejected, can resubmit
SUSPENDED   → Temporarily disabled by admin
Enter fullscreen mode Exit fullscreen mode

Status Transitions

┌─────────────────────────────────────────────────────────────┐
│          USER (CUSTOMER) APPLIES TO BECOME VENDOR            │
│               Submits Vendor Application                     │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
                ┌──────────────────────┐
                │  Vendor Status:      │
                │  [PENDING]           │
                │  User Role: CUSTOMER │
                └──────────────────────┘
                           │
            ┌──────────────┴──────────────┐
            │                             │
            ▼                             ▼
    ┌──────────────┐            ┌──────────────┐
    │  APPROVED    │            │  REJECTED    │
    │              │            │              │
    │ Vendor:      │            │ Vendor:      │
    │  APPROVED    │            │  REJECTED    │
    │              │            │              │
    │ User Role:   │            │ User Role:   │
    │  VENDOR ✓    │            │  CUSTOMER    │
    └──────────────┘            └──────────────┘
            │                             │
            │                             │
            ▼                             ▼
    Can Manage              Can Fix Issues &
    Products Now            Resubmit Application
            │                             │
            │                             ▼
            │                    ┌──────────────────┐
            │                    │  Vendor Status:  │
            │                    │  [PENDING]       │
            │                    │  User: CUSTOMER  │
            │                    └──────────────────┘
            │                             │
            └────────────┬────────────────┘
                         │
                         ▼
                ┌─────────────────┐
                │  [SUSPENDED]    │
                │  (by admin)     │
                │                 │
                │ User Role:      │
                │  VENDOR         │
                │  (deactivated)  │
                └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

Architecture & Workflow

Vendor Registration Workflow (Detailed)

┌──────────────────────────────────────────────────────────────┐
│ 1. User (CUSTOMER) Submits Complete Vendor Application       │
│    POST /api/vendor/register (multipart/form-data)           │
│    • User must be authenticated (CUSTOMER role)              │
│    • Business information                                    │
│    • License numbers                                         │
│    • Bank details                                            │
│    • Business address                                        │
│    • Upload ALL documents in ONE request:                    │
│      - Business registration (PDF/Image)                     │
│      - Pharmacy license (PDF/Image)                          │
│      - Tax document (PDF/Image)                              │
│      - Logo (Image - optional)                               │
│    • All files uploaded to ImageKit                          │
│    • Document URLs stored in database                        │
└────────────────┬─────────────────────────────────────────────┘
                 │
                 ▼
┌──────────────────────────────────────────────────────────────┐
│ 2. Vendor Profile Created - Status: PENDING                  │
│    • Vendor profile linked to user account                   │
│    • User role STILL CUSTOMER (not changed yet)              │
│    • Activity log entry created                              │
│    • Admin notification sent                                 │
│    • User can track status via GET /api/vendor/status        │
└────────────────┬─────────────────────────────────────────────┘
                 │
                 ▼
┌──────────────────────────────────────────────────────────────┐
│ 3. Admin Reviews (MANAGE_VENDORS permission)                 │
│    GET /api/admin/vendors/pending                            │
│    • View all pending vendor applications                    │
│    • Download and verify documents                           │
│    • Verify license numbers and tax IDs                      │
└────────────────┬─────────────────────────────────────────────┘
                 │
        ┌────────┴────────┐
        │                 │
        ▼                 ▼
    APPROVE           REJECT
        │                 │
        │                 │
        ▼                 ▼
┌─────────────┐   ┌──────────────┐
│ APPROVED    │   │ REJECTED     │
│             │   │              │
│ Vendor:     │   │ Vendor:      │
│ • Status:   │   │ • Status:    │
│   APPROVED  │   │   REJECTED   │
│ • verifiedAt│   │ • Rejection  │
│   recorded  │   │   reason     │
│             │   │   stored     │
│ User:       │   │              │
│ • Role:     │   │ User:        │
│   VENDOR ✓  │   │ • Role:      │
│ • Can now   │   │   CUSTOMER   │
│   add       │   │   (no change)│
│   products  │   │ • Must fix   │
│             │   │   & resubmit │
│ • Vendor    │   │              │
│   notified  │   │ • Vendor     │
│             │   │   notified   │
└─────────────┘   └──────────────┘
Enter fullscreen mode Exit fullscreen mode

Product Management Workflow

┌──────────────────────────────────────────────────────────────┐
│ Vendor Creates Product                                       │
│ POST /api/vendor/products                                    │
│ • Product details                                            │
│ • Images (via ImageKit)                                      │
│ • Pricing information                                        │
│ • Stock details                                              │
│ • Status: DRAFT or PENDING_APPROVAL                          │
└────────────────┬─────────────────────────────────────────────┘
                 │
                 ▼
┌──────────────────────────────────────────────────────────────┐
│ Admin Reviews Product (MANAGE_PRODUCTS permission)           │
│ GET /api/admin/products/pending                              │
│ • Verify product information                                 │
│ • Check compliance                                           │
│ • Approve or reject                                          │
└────────────────┬─────────────────────────────────────────────┘
                 │
        ┌────────┴────────┐
        │                 │
        ▼                 ▼
    APPROVED          REJECTED
        │                 │
        ▼                 ▼
   Status: ACTIVE    Product updated
   Listed on site    Vendor notified
Enter fullscreen mode Exit fullscreen mode

System Architecture

📁 Backend Structure
├── src/
│   ├── modules/
│   │   └── vendor/
│   │       ├── vendor.controller.ts         (Request handling)
│   │       ├── vendor.service.ts            (Business logic)
│   │       ├── vendor.validation.ts         (Input validation)
│   │       ├── vendor.route.ts              (Vendor API routes)
│   │       ├── product.controller.ts        (Product handling)
│   │       ├── product.service.ts           (Product logic)
│   │       ├── product.validation.ts        (Product validation)
│   │       └── product.route.ts             (Product API routes)
│   │
│   ├── services/
│   │   └── imagekit.service.ts              (ImageKit integration)
│   │
│   ├── middleware/
│   │   ├── requireAuth.ts                   (Authentication)
│   │   ├── requireAdmin.ts                  (Admin check)
│   │   ├── requirePermission.ts             (Permission check)
│   │   └── requireVendor.ts                 (Vendor check)
│   │
│   └── utils/
│       └── asyncHandler.ts                  (Error handling)
│
└── docs/
    └── VENDOR_MANAGEMENT.md                 (This file)
Enter fullscreen mode Exit fullscreen mode

API Endpoints - Vendor Management

Vendor Endpoints Overview

Endpoint Method Auth Purpose
/api/vendor/register POST CUSTOMER Submit vendor application with documents
/api/vendor/status GET Any User Check vendor application status
/api/vendor/resubmit PUT CUSTOMER Resubmit after rejection with updated docs
/api/vendor/profile GET VENDOR Get own vendor profile
/api/vendor/profile PUT VENDOR Update vendor profile

Admin Endpoints Overview

Endpoint Method Permission Purpose
/api/admin/vendors GET MANAGE_VENDORS List all vendors (with filters)
/api/admin/vendors/:id GET MANAGE_VENDORS Get single vendor details
/api/admin/vendors/:id/approve PUT MANAGE_VENDORS Approve vendor (upgrades role to VENDOR)
/api/admin/vendors/:id/reject PUT MANAGE_VENDORS Reject vendor with reason
/api/admin/vendors/:id/suspend PUT MANAGE_VENDORS Suspend vendor account
/api/admin/vendors/:id/activity GET MANAGE_VENDORS Get vendor activity logs
/api/admin/vendors/:id/products GET MANAGE_PRODUCTS Get all products by vendor ID

1. Vendor Registration (User Applies to Become Vendor)

Endpoint: POST /api/vendor/register

Authentication: Bearer token (CUSTOMER role user required - any authenticated user)

Content-Type: multipart/form-data

Request:

Form fields:

businessName: "HealthCare Pharmacy"
businessEmail: "contact@healthcare.com"
businessPhone: "9876543210"
licenseNumber: "PL-2024-1234"
taxId: "GSTIN12345ABC"
description: "Premium healthcare provider"
addressLine1: "123 Main Street"
addressLine2: "Near City Hospital"
city: "Mumbai"
state: "Maharashtra"
postalCode: "400001"
country: "India"
bankName: "HDFC Bank"
accountNumber: "1234567890"
accountHolderName: "HealthCare Pharmacy"
ifscCode: "HDFC0001234"

Files (all required):
businessRegistration: <PDF/Image file>
pharmacyLicense: <PDF/Image file>
taxDocument: <PDF/Image file>
logo: <Image file> (optional)
Enter fullscreen mode Exit fullscreen mode

Response (201 Created):

{
  "ok": true,
  "message": "Vendor registration submitted successfully. Your application is under review.",
  "vendor": {
    "id": "cm3xyz789ghi012",
    "businessName": "HealthCare Pharmacy",
    "businessEmail": "contact@healthcare.com",
    "status": "PENDING",
    "documents": {
      "businessRegistration": "https://ik.imagekit.io/yourapp/vendors/cm3xyz789/reg.pdf",
      "pharmacyLicense": "https://ik.imagekit.io/yourapp/vendors/cm3xyz789/license.pdf",
      "taxDocument": "https://ik.imagekit.io/yourapp/vendors/cm3xyz789/tax.pdf",
      "logo": "https://ik.imagekit.io/yourapp/vendors/cm3xyz789/logo.png"
    },
    "createdAt": "2025-11-02T10:30:00Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

What Happens:

  1. User submits complete vendor application with all documents
  2. All documents uploaded to ImageKit in one request
  3. Vendor profile created with status PENDING
  4. User role remains CUSTOMER
  5. Admin notification sent for review
  6. User can track status using status endpoint

2. Check Vendor Application Status

Endpoint: GET /api/vendor/status

Authentication: Bearer token (Any authenticated user)

Response (200 OK) - Pending:

{
  "ok": true,
  "hasVendorApplication": true,
  "vendor": {
    "id": "cm3xyz789ghi012",
    "businessName": "HealthCare Pharmacy",
    "businessEmail": "contact@healthcare.com",
    "status": "PENDING",
    "submittedAt": "2025-11-02T10:30:00Z",
    "message": "Your vendor application is under review. We'll notify you once it's processed."
  }
}
Enter fullscreen mode Exit fullscreen mode

Response (200 OK) - Approved:

{
  "ok": true,
  "hasVendorApplication": true,
  "vendor": {
    "id": "cm3xyz789ghi012",
    "businessName": "HealthCare Pharmacy",
    "status": "APPROVED",
    "verifiedAt": "2025-11-02T12:00:00Z",
    "message": "Congratulations! Your vendor application has been approved. You can now start adding products.",
    "userRole": "VENDOR"
  }
}
Enter fullscreen mode Exit fullscreen mode

Response (200 OK) - Rejected:

{
  "ok": true,
  "hasVendorApplication": true,
  "vendor": {
    "id": "cm3xyz789ghi012",
    "businessName": "HealthCare Pharmacy",
    "status": "REJECTED",
    "rejectedAt": "2025-11-02T12:00:00Z",
    "rejectionReason": "Invalid license number. Please provide a valid pharmacy license.",
    "message": "Your vendor application was rejected. Please review the reason and resubmit with corrected documents.",
    "canResubmit": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Response (200 OK) - No Application:

{
  "ok": true,
  "hasVendorApplication": false,
  "message": "You haven't submitted a vendor application yet."
}
Enter fullscreen mode Exit fullscreen mode

3. Get Vendor Profile (Own Profile)

Endpoint: GET /api/vendor/profile

Authentication: Bearer token (VENDOR role required)

Response (200 OK):

{
  "ok": true,
  "vendor": {
    "id": "cm3xyz789ghi012",
    "businessName": "HealthCare Pharmacy",
    "businessEmail": "contact@healthcare.com",
    "businessPhone": "9876543210",
    "status": "PENDING",
    "licenseNumber": "PL-2024-1234",
    "taxId": "GSTIN12345ABC",
    "logo": "https://ik.imagekit.io/yourapp/vendors/logo.png",
    "description": "Premium healthcare provider",
    "rating": 0,
    "totalSales": 0,
    "totalOrders": 0,
    "documents": {
      "businessRegistration": "https://ik.imagekit.io/yourapp/vendors/reg.pdf",
      "pharmacyLicense": "https://ik.imagekit.io/yourapp/vendors/license.pdf",
      "taxDocument": "https://ik.imagekit.io/yourapp/vendors/tax.pdf"
    },
    "createdAt": "2025-11-02T10:30:00Z",
    "updatedAt": "2025-11-02T10:30:00Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Update Vendor Profile

Endpoint: PUT /api/vendor/profile

Authentication: Bearer token (VENDOR role required)

Request:

{
  "businessPhone": "9876543211",
  "description": "Updated description",
  "bankName": "ICICI Bank",
  "accountNumber": "0987654321",
  "ifscCode": "ICIC0001234"
}
Enter fullscreen mode Exit fullscreen mode

Response (200 OK):

{
  "ok": true,
  "message": "Vendor profile updated successfully",
  "vendor": {
    "id": "cm3xyz789ghi012",
    "businessName": "HealthCare Pharmacy",
    "businessPhone": "9876543211",
    "description": "Updated description"
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Resubmit Vendor Application (After Rejection)

Endpoint: PUT /api/vendor/resubmit

Authentication: Bearer token (CUSTOMER role user with REJECTED vendor application)

Content-Type: multipart/form-data

Request:

Form fields (all optional - only send fields you want to update):

businessName: "HealthCare Pharmacy Updated"
businessEmail: "updated@healthcare.com"
businessPhone: "9876543211"
licenseNumber: "PL-2024-5678"
taxId: "GSTIN67890XYZ"
description: "Updated description"
addressLine1: "Updated address"
city: "Mumbai"
state: "Maharashtra"
postalCode: "400001"
country: "India"
bankName: "ICICI Bank"
accountNumber: "0987654321"
accountHolderName: "HealthCare Pharmacy"
ifscCode: "ICIC0001234"

Files (optional - only upload documents you want to replace):
businessRegistration: <PDF/Image file>
pharmacyLicense: <PDF/Image file>
taxDocument: <PDF/Image file>
logo: <Image file>
Enter fullscreen mode Exit fullscreen mode

Response (200 OK):

{
  "ok": true,
  "message": "Vendor application resubmitted successfully. Your application is under review again.",
  "vendor": {
    "id": "cm3xyz789ghi012",
    "businessName": "HealthCare Pharmacy Updated",
    "businessEmail": "updated@healthcare.com",
    "status": "PENDING",
    "documents": {
      "businessRegistration": "https://ik.imagekit.io/yourapp/vendors/cm3xyz789/reg-v2.pdf",
      "pharmacyLicense": "https://ik.imagekit.io/yourapp/vendors/cm3xyz789/license-v2.pdf",
      "taxDocument": "https://ik.imagekit.io/yourapp/vendors/cm3xyz789/tax-v2.pdf"
    },
    "updatedAt": "2025-11-02T11:00:00Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

What Happens:

  1. User updates any rejected/incorrect information
  2. New documents uploaded to ImageKit (old ones cleaned up)
  3. Vendor status changes from REJECTED to PENDING
  4. User role remains CUSTOMER
  5. Admin notified for re-review
  6. Previous rejection reason cleared

6. Admin - Get All Vendors

Endpoint: GET /api/admin/vendors

Authentication: Bearer token (Admin with MANAGE_VENDORS permission)

Query Parameters:

?status=PENDING&limit=50&offset=0
Enter fullscreen mode Exit fullscreen mode

Response (200 OK):

{
  "ok": true,
  "total": 25,
  "limit": 50,
  "offset": 0,
  "vendors": [
    {
      "id": "cm3xyz789ghi012",
      "businessName": "HealthCare Pharmacy",
      "businessEmail": "contact@healthcare.com",
      "status": "PENDING",
      "licenseNumber": "PL-2024-1234",
      "createdAt": "2025-11-02T10:30:00Z",
      "user": {
        "id": "user123",
        "phone": "9876543210",
        "firstName": "John",
        "lastName": "Doe"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

7. Admin - Get Single Vendor Details

Endpoint: GET /api/admin/vendors/:vendorId

Authentication: Bearer token (Admin with MANAGE_VENDORS permission)

Response (200 OK):

{
  "ok": true,
  "vendor": {
    "id": "cm3xyz789ghi012",
    "businessName": "HealthCare Pharmacy",
    "businessEmail": "contact@healthcare.com",
    "businessPhone": "9876543210",
    "status": "PENDING",
    "licenseNumber": "PL-2024-1234",
    "taxId": "GSTIN12345ABC",
    "documents": {
      "businessRegistration": "https://ik.imagekit.io/yourapp/vendors/reg.pdf",
      "pharmacyLicense": "https://ik.imagekit.io/yourapp/vendors/license.pdf",
      "taxDocument": "https://ik.imagekit.io/yourapp/vendors/tax.pdf"
    },
    "address": {
      "addressLine1": "123 Main Street",
      "city": "Mumbai",
      "state": "Maharashtra",
      "postalCode": "400001",
      "country": "India"
    },
    "bankDetails": {
      "bankName": "HDFC Bank",
      "accountNumber": "****7890",
      "accountHolderName": "HealthCare Pharmacy",
      "ifscCode": "HDFC0001234"
    },
    "user": {
      "id": "user123",
      "phone": "9876543210",
      "email": "john@example.com",
      "firstName": "John",
      "lastName": "Doe"
    },
    "createdAt": "2025-11-02T10:30:00Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

8. Admin - Approve Vendor

Endpoint: PUT /api/admin/vendors/:vendorId/approve

Authentication: Bearer token (Admin with MANAGE_VENDORS permission)

Request:

{
  "commission": 10.0
}
Enter fullscreen mode Exit fullscreen mode

Response (200 OK):

{
  "ok": true,
  "message": "Vendor approved successfully. User role upgraded to VENDOR.",
  "vendor": {
    "id": "cm3xyz789ghi012",
    "businessName": "HealthCare Pharmacy",
    "status": "APPROVED",
    "verifiedAt": "2025-11-02T12:00:00Z",
    "commission": 10.0
  },
  "user": {
    "id": "user123",
    "role": "VENDOR",
    "previousRole": "CUSTOMER"
  }
}
Enter fullscreen mode Exit fullscreen mode

What Happens:

  1. Vendor status changes from PENDING to APPROVED
  2. User role upgraded from CUSTOMER to VENDOR
  3. verifiedAt timestamp recorded
  4. Activity log entry created
  5. User can now access vendor dashboard and manage products
  6. Email/SMS notification sent to user

9. Admin - Reject Vendor

Endpoint: PUT /api/admin/vendors/:vendorId/reject

Authentication: Bearer token (Admin with MANAGE_VENDORS permission)

Request:

{
  "reason": "Invalid license number. Please provide a valid pharmacy license."
}
Enter fullscreen mode Exit fullscreen mode

Response (200 OK):

{
  "ok": true,
  "message": "Vendor registration rejected",
  "vendor": {
    "id": "cm3xyz789ghi012",
    "businessName": "HealthCare Pharmacy",
    "status": "REJECTED",
    "rejectionReason": "Invalid license number. Please provide a valid pharmacy license."
  }
}
Enter fullscreen mode Exit fullscreen mode

10. Admin - Suspend Vendor

Endpoint: PUT /api/admin/vendors/:vendorId/suspend

Authentication: Bearer token (Admin with MANAGE_VENDORS permission)

Request:

{
  "reason": "Multiple customer complaints about product quality"
}
Enter fullscreen mode Exit fullscreen mode

Response (200 OK):

{
  "ok": true,
  "message": "Vendor suspended successfully",
  "vendor": {
    "id": "cm3xyz789ghi012",
    "status": "SUSPENDED",
    "suspensionReason": "Multiple customer complaints about product quality"
  }
}
Enter fullscreen mode Exit fullscreen mode

11. Admin - Get Vendor Activity Logs

Endpoint: GET /api/admin/vendors/:vendorId/activity

Authentication: Bearer token (Admin with MANAGE_VENDORS permission)

Response (200 OK):

{
  "ok": true,
  "total": 5,
  "logs": [
    {
      "id": "log123",
      "action": "APPROVE_VENDOR",
      "description": "Vendor approved: HealthCare Pharmacy",
      "adminId": "admin456",
      "adminName": "Super Admin",
      "createdAt": "2025-11-02T12:00:00Z"
    },
    {
      "id": "log124",
      "action": "VENDOR_REGISTRATION",
      "description": "Vendor registered: HealthCare Pharmacy",
      "createdAt": "2025-11-02T10:30:00Z"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

API Endpoints - Product Management

12. Vendor - Create Product

Endpoint: POST /api/vendor/products

Authentication: Bearer token (VENDOR role, APPROVED status required)

Request:

{
  "categoryId": "cat123",
  "name": "Paracetamol 500mg",
  "slug": "paracetamol-500mg",
  "description": "Effective pain relief and fever reducer",
  "shortDescription": "Pain relief tablet",
  "sku": "MED-PARA-500",
  "barcode": "8901234567890",
  "productType": "OTC",
  "requiresPrescription": false,
  "basePrice": 50.0,
  "comparePrice": 60.0,
  "costPrice": 35.0,
  "manufacturer": "PharmaCorp Ltd",
  "composition": "Paracetamol 500mg",
  "packSize": "10 tablets",
  "dosageForm": "Tablet",
  "strength": "500mg",
  "images": [
    "https://ik.imagekit.io/yourapp/products/para1.jpg",
    "https://ik.imagekit.io/yourapp/products/para2.jpg"
  ],
  "thumbnail": "https://ik.imagekit.io/yourapp/products/para-thumb.jpg",
  "weight": 0.05,
  "storageConditions": "Store in a cool, dry place",
  "status": "PENDING_APPROVAL"
}
Enter fullscreen mode Exit fullscreen mode

Response (201 Created):

{
  "ok": true,
  "message": "Product created successfully",
  "product": {
    "id": "prod123",
    "name": "Paracetamol 500mg",
    "sku": "MED-PARA-500",
    "status": "PENDING_APPROVAL",
    "basePrice": 50.0,
    "createdAt": "2025-11-02T14:00:00Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

13. Vendor - Get Own Products

Endpoint: GET /api/vendor/products

Authentication: Bearer token (VENDOR role required)

Query Parameters:

?status=ACTIVE&limit=50&offset=0
Enter fullscreen mode Exit fullscreen mode

Response (200 OK):

{
  "ok": true,
  "total": 15,
  "products": [
    {
      "id": "prod123",
      "name": "Paracetamol 500mg",
      "sku": "MED-PARA-500",
      "status": "ACTIVE",
      "basePrice": 50.0,
      "thumbnail": "https://ik.imagekit.io/yourapp/products/para-thumb.jpg",
      "category": {
        "id": "cat123",
        "name": "Pain Relief"
      },
      "createdAt": "2025-11-02T14:00:00Z"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

14. Vendor - Update Product

Endpoint: PUT /api/vendor/products/:productId

Authentication: Bearer token (VENDOR role required)

Request:

{
  "basePrice": 55.0,
  "comparePrice": 65.0,
  "description": "Updated description with more details",
  "status": "ACTIVE"
}
Enter fullscreen mode Exit fullscreen mode

Response (200 OK):

{
  "ok": true,
  "message": "Product updated successfully",
  "product": {
    "id": "prod123",
    "name": "Paracetamol 500mg",
    "basePrice": 55.0,
    "status": "ACTIVE",
    "updatedAt": "2025-11-02T15:00:00Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

15. Vendor - Delete Product

Endpoint: DELETE /api/vendor/products/:productId

Authentication: Bearer token (VENDOR role required)

Response (200 OK):

{
  "ok": true,
  "message": "Product deleted successfully",
  "productId": "prod123"
}
Enter fullscreen mode Exit fullscreen mode

16. Vendor - Upload Product Image

Endpoint: POST /api/vendor/products/upload-image

Authentication: Bearer token (VENDOR role required)

Content-Type: multipart/form-data

Request:

file: <binary image data>
Enter fullscreen mode Exit fullscreen mode

Response (200 OK):

{
  "ok": true,
  "message": "Image uploaded successfully",
  "image": {
    "url": "https://ik.imagekit.io/yourapp/products/img123.jpg",
    "fileId": "ik_file_abc456",
    "thumbnail": "https://ik.imagekit.io/yourapp/products/tr:w-300/img123.jpg"
  }
}
Enter fullscreen mode Exit fullscreen mode

17. Vendor - Manage Inventory

Endpoint: POST /api/vendor/products/:productId/inventory

Authentication: Bearer token (VENDOR role required)

Request:

{
  "quantity": 100,
  "batchNumber": "BATCH2024-001",
  "expiryDate": "2026-12-31T00:00:00Z",
  "manufacturingDate": "2024-01-15T00:00:00Z",
  "reorderLevel": 20
}
Enter fullscreen mode Exit fullscreen mode

Response (201 Created):

{
  "ok": true,
  "message": "Inventory added successfully",
  "inventory": {
    "id": "inv123",
    "productId": "prod123",
    "quantity": 100,
    "availableQty": 100,
    "reservedQty": 0,
    "batchNumber": "BATCH2024-001",
    "expiryDate": "2026-12-31T00:00:00Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

18. Vendor - Get Inventory

Endpoint: GET /api/vendor/products/:productId/inventory

Authentication: Bearer token (VENDOR role required)

Response (200 OK):

{
  "ok": true,
  "inventories": [
    {
      "id": "inv123",
      "quantity": 100,
      "availableQty": 85,
      "reservedQty": 15,
      "batchNumber": "BATCH2024-001",
      "expiryDate": "2026-12-31T00:00:00Z",
      "createdAt": "2025-11-02T14:30:00Z"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

19. Admin - Get All Products (Pending Approval)

Endpoint: GET /api/admin/products/pending

Authentication: Bearer token (Admin with MANAGE_PRODUCTS permission)

Response (200 OK):

{
  "ok": true,
  "total": 8,
  "products": [
    {
      "id": "prod123",
      "name": "Paracetamol 500mg",
      "sku": "MED-PARA-500",
      "status": "PENDING_APPROVAL",
      "basePrice": 50.0,
      "vendor": {
        "id": "vendor123",
        "businessName": "HealthCare Pharmacy"
      },
      "createdAt": "2025-11-02T14:00:00Z"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

20. Admin - Approve Product

Endpoint: PUT /api/admin/products/:productId/approve

Authentication: Bearer token (Admin with MANAGE_PRODUCTS permission)

Response (200 OK):

{
  "ok": true,
  "message": "Product approved successfully",
  "product": {
    "id": "prod123",
    "name": "Paracetamol 500mg",
    "status": "ACTIVE",
    "approvedAt": "2025-11-02T16:00:00Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

21. Admin - Reject Product

Endpoint: PUT /api/admin/products/:productId/reject

Authentication: Bearer token (Admin with MANAGE_PRODUCTS permission)

Request:

{
  "reason": "Product images are unclear. Please provide high-quality images."
}
Enter fullscreen mode Exit fullscreen mode

Response (200 OK):

{
  "ok": true,
  "message": "Product rejected",
  "product": {
    "id": "prod123",
    "status": "INACTIVE",
    "rejectionReason": "Product images are unclear. Please provide high-quality images."
  }
}
Enter fullscreen mode Exit fullscreen mode

22. Admin - Get Products by Vendor ID

Endpoint: GET /api/admin/vendors/:vendorId/products

Authentication: Bearer token (Admin with MANAGE_PRODUCTS permission)

Query Parameters:

?status=ACTIVE&limit=50&offset=0
Enter fullscreen mode Exit fullscreen mode

Response (200 OK):

{
  "ok": true,
  "vendorId": "vendor123",
  "vendor": {
    "id": "vendor123",
    "businessName": "HealthCare Pharmacy",
    "status": "APPROVED"
  },
  "total": 25,
  "limit": 50,
  "offset": 0,
  "products": [
    {
      "id": "prod123",
      "name": "Paracetamol 500mg",
      "sku": "MED-PARA-500",
      "status": "ACTIVE",
      "basePrice": 50.0,
      "comparePrice": 60.0,
      "thumbnail": "https://ik.imagekit.io/yourapp/products/para-thumb.jpg",
      "category": {
        "id": "cat123",
        "name": "Pain Relief"
      },
      "inventories": [
        {
          "quantity": 100,
          "availableQty": 85
        }
      ],
      "createdAt": "2025-11-02T14:00:00Z",
      "approvedAt": "2025-11-02T16:00:00Z"
    },
    {
      "id": "prod124",
      "name": "Vitamin C 1000mg",
      "sku": "SUP-VITC-1000",
      "status": "PENDING_APPROVAL",
      "basePrice": 120.0,
      "thumbnail": "https://ik.imagekit.io/yourapp/products/vitc-thumb.jpg",
      "category": {
        "id": "cat124",
        "name": "Supplements"
      },
      "createdAt": "2025-11-02T15:00:00Z"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

What This Endpoint Does:

  • Admin can view all products from a specific vendor
  • Useful for vendor performance review
  • Can filter by product status (ACTIVE, PENDING_APPROVAL, INACTIVE, etc.)
  • Includes vendor details in response
  • Shows inventory summary for each product
  • Supports pagination for vendors with many products

Database Models

Vendor Model (Existing)

enum VendorStatus {
  PENDING
  APPROVED
  REJECTED
  SUSPENDED
}

model Vendor {
  id                    String        @id @default(cuid())
  userId                String        @unique
  businessName          String
  businessEmail         String        @unique
  businessPhone         String
  licenseNumber         String        @unique
  taxId                 String        @unique
  logo                  String?
  description           String?
  status                VendorStatus  @default(PENDING)
  commission            Float         @default(10.0)
  rating                Float         @default(0)
  totalSales            Float         @default(0)
  totalOrders           Int           @default(0)

  // Business Address
  addressLine1          String
  addressLine2          String?
  city                  String
  state                 String
  postalCode            String
  country               String

  // Bank Details
  bankName              String?
  accountNumber         String?
  accountHolderName     String?
  ifscCode              String?

  // Verification Documents (ImageKit URLs)
  businessRegistration  String?
  pharmacyLicense       String?
  taxDocument           String?

  verifiedAt            DateTime?
  createdAt             DateTime      @default(now())
  updatedAt             DateTime      @updatedAt

  user                  User          @relation(fields: [userId], references: [id], onDelete: Cascade)
  products              Product[]
  inventories           Inventory[]
  payouts               Payout[]

  @@index([status])
  @@index([businessEmail])
}
Enter fullscreen mode Exit fullscreen mode

Product Model (Existing)

enum ProductStatus {
  DRAFT
  PENDING_APPROVAL
  ACTIVE
  INACTIVE
  OUT_OF_STOCK
  DISCONTINUED
}

enum ProductType {
  PRESCRIPTION
  OTC
  SUPPLEMENT
  MEDICAL_DEVICE
  PERSONAL_CARE
}

model Product {
  id                    String          @id @default(cuid())
  vendorId              String
  categoryId            String
  name                  String
  slug                  String          @unique
  description           String
  shortDescription      String?

  sku                   String          @unique
  barcode               String?         @unique
  productType           ProductType
  requiresPrescription  Boolean         @default(false)

  basePrice             Float
  comparePrice          Float?
  costPrice             Float

  trackInventory        Boolean         @default(true)

  manufacturer          String?
  composition           String?
  packSize              String?
  dosageForm            String?
  strength              String?

  images                String[]        // ImageKit URLs
  thumbnail             String?         // ImageKit URL

  weight                Float?
  dimensions            String?

  storageConditions     String?
  sideEffects           String?
  contraindications     String?
  warnings              String?

  status                ProductStatus   @default(DRAFT)
  isActive              Boolean         @default(true)

  approvedAt            DateTime?
  createdAt             DateTime        @default(now())
  updatedAt             DateTime        @updatedAt

  vendor                Vendor          @relation(fields: [vendorId], references: [id], onDelete: Cascade)
  category              Category        @relation(fields: [categoryId], references: [id])
  inventories           Inventory[]

  @@index([vendorId])
  @@index([status])
}
Enter fullscreen mode Exit fullscreen mode

Inventory Model (Existing)

model Inventory {
  id                String    @id @default(cuid())
  productId         String
  vendorId          String
  quantity          Int       @default(0)
  reservedQty       Int       @default(0)
  availableQty      Int       @default(0)
  batchNumber       String?
  expiryDate        DateTime?
  manufacturingDate DateTime?
  reorderLevel      Int       @default(10)
  maxStockLevel     Int?
  createdAt         DateTime  @default(now())
  updatedAt         DateTime  @updatedAt

  product           Product   @relation(fields: [productId], references: [id], onDelete: Cascade)
  vendor            Vendor    @relation(fields: [vendorId], references: [id], onDelete: Cascade)

  @@unique([productId, batchNumber])
  @@index([productId])
  @@index([vendorId])
  @@index([expiryDate])
}
Enter fullscreen mode Exit fullscreen mode

Implementation Guide

This section provides step-by-step code implementation for the complete vendor management system.

Step 1: Install Required Dependencies

First, install ImageKit SDK and other necessary packages:

pnpm add imagekit multer @types/multer zod
Enter fullscreen mode Exit fullscreen mode

Step 2: Setup ImageKit Service

Create src/services/imagekit.service.ts:

import ImageKit from "imagekit";

const imagekit = new ImageKit({
  publicKey: process.env.IMAGEKIT_PUBLIC_KEY!,
  privateKey: process.env.IMAGEKIT_PRIVATE_KEY!,
  urlEndpoint: process.env.IMAGEKIT_URL_ENDPOINT!,
});

class ImageKitService {
  /**
   * Upload file to ImageKit
   */
  static async uploadFile(
    file: Express.Multer.File,
    folder: string,
    fileName?: string
  ) {
    try {
      const result = await imagekit.upload({
        file: file.buffer,
        fileName: fileName || file.originalname,
        folder: folder,
        useUniqueFileName: true,
      });

      return {
        fileId: result.fileId,
        url: result.url,
        thumbnailUrl: result.thumbnailUrl,
        name: result.name,
      };
    } catch (error) {
      console.error("ImageKit upload error:", error);
      throw new Error("Failed to upload file to ImageKit");
    }
  }

  /**
   * Delete file from ImageKit
   */
  static async deleteFile(fileId: string) {
    try {
      await imagekit.deleteFile(fileId);
      return true;
    } catch (error) {
      console.error("ImageKit delete error:", error);
      return false;
    }
  }

  /**
   * Get authentication parameters for client-side upload
   */
  static getAuthenticationParameters() {
    return imagekit.getAuthenticationParameters();
  }
}

export default ImageKitService;
Enter fullscreen mode Exit fullscreen mode

Step 3: Create Vendor Validation Schemas

Create src/modules/vendor/vendor.validation.ts:

import { z } from "zod";

export const registerVendorSchema = z.object({
  businessName: z.string().min(3).max(200),
  businessEmail: z.string().email(),
  businessPhone: z.string().min(10).max(15),
  licenseNumber: z.string().min(5).max(50),
  taxId: z.string().min(5).max(50),
  description: z.string().min(10).max(1000).optional(),
  addressLine1: z.string().min(5).max(200),
  addressLine2: z.string().max(200).optional(),
  city: z.string().min(2).max(100),
  state: z.string().min(2).max(100),
  postalCode: z.string().min(4).max(10),
  country: z.string().min(2).max(100),
  bankName: z.string().min(2).max(100).optional(),
  accountNumber: z.string().min(8).max(20).optional(),
  accountHolderName: z.string().min(2).max(200).optional(),
  ifscCode: z.string().min(6).max(15).optional(),
});

export const updateVendorSchema = z.object({
  businessPhone: z.string().min(10).max(15).optional(),
  description: z.string().min(10).max(1000).optional(),
  logo: z.string().url().optional(),
  addressLine1: z.string().min(5).max(200).optional(),
  addressLine2: z.string().max(200).optional(),
  city: z.string().min(2).max(100).optional(),
  state: z.string().min(2).max(100).optional(),
  postalCode: z.string().min(4).max(10).optional(),
  bankName: z.string().min(2).max(100).optional(),
  accountNumber: z.string().min(8).max(20).optional(),
  accountHolderName: z.string().min(2).max(200).optional(),
  ifscCode: z.string().min(6).max(15).optional(),
});

export const resubmitVendorSchema = z.object({
  licenseNumber: z.string().min(5).max(50).optional(),
  taxId: z.string().min(5).max(50).optional(),
  businessRegistration: z.string().url().optional(),
  pharmacyLicense: z.string().url().optional(),
  taxDocument: z.string().url().optional(),
});

export const approveVendorSchema = z.object({
  commission: z.number().min(0).max(100).default(10.0),
});

export const rejectVendorSchema = z.object({
  reason: z.string().min(10).max(500),
});

export const suspendVendorSchema = z.object({
  reason: z.string().min(10).max(500),
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Create Product Validation Schemas

Create src/modules/vendor/product.validation.ts:

import { z } from "zod";

export const createProductSchema = z.object({
  categoryId: z.string().cuid(),
  name: z.string().min(3).max(200),
  slug: z.string().min(3).max(200),
  description: z.string().min(20).max(5000),
  shortDescription: z.string().max(500).optional(),
  sku: z.string().min(3).max(50),
  barcode: z.string().max(50).optional(),
  productType: z.enum([
    "PRESCRIPTION",
    "OTC",
    "SUPPLEMENT",
    "MEDICAL_DEVICE",
    "PERSONAL_CARE",
  ]),
  requiresPrescription: z.boolean().default(false),
  basePrice: z.number().min(0),
  comparePrice: z.number().min(0).optional(),
  costPrice: z.number().min(0),
  manufacturer: z.string().max(200).optional(),
  composition: z.string().max(500).optional(),
  packSize: z.string().max(100).optional(),
  dosageForm: z.string().max(100).optional(),
  strength: z.string().max(100).optional(),
  images: z.array(z.string().url()).min(1).max(10),
  thumbnail: z.string().url().optional(),
  weight: z.number().min(0).optional(),
  dimensions: z.string().max(100).optional(),
  storageConditions: z.string().max(500).optional(),
  sideEffects: z.string().max(1000).optional(),
  contraindications: z.string().max(1000).optional(),
  warnings: z.string().max(1000).optional(),
  status: z
    .enum(["DRAFT", "PENDING_APPROVAL", "ACTIVE"])
    .default("PENDING_APPROVAL"),
  metaTitle: z.string().max(200).optional(),
  metaDescription: z.string().max(500).optional(),
  metaKeywords: z.string().max(500).optional(),
});

export const updateProductSchema = createProductSchema.partial();

export const createInventorySchema = z.object({
  quantity: z.number().int().min(0),
  batchNumber: z.string().max(50).optional(),
  expiryDate: z.string().datetime().optional(),
  manufacturingDate: z.string().datetime().optional(),
  reorderLevel: z.number().int().min(0).default(10),
  maxStockLevel: z.number().int().min(0).optional(),
});

export const rejectProductSchema = z.object({
  reason: z.string().min(10).max(500),
});
Enter fullscreen mode Exit fullscreen mode

Step 5: Create Vendor Service Layer

Create src/services/vendor.service.ts:

import prisma from "../db/prismaClient";
import { VendorStatus, UserRole } from "@prisma/client";
import ImageKitService from "./imagekit.service";

interface RegisterVendorData {
  userId: string;
  businessName: string;
  businessEmail: string;
  businessPhone: string;
  licenseNumber: string;
  taxId: string;
  description?: string;
  addressLine1: string;
  addressLine2?: string;
  city: string;
  state: string;
  postalCode: string;
  country: string;
  bankName?: string;
  accountNumber?: string;
  accountHolderName?: string;
  ifscCode?: string;
  documents: {
    businessRegistration: string;
    pharmacyLicense: string;
    taxDocument: string;
    logo?: string;
  };
}

class VendorService {
  /**
   * Register new vendor application
   */
  static async registerVendor(data: RegisterVendorData) {
    // Check if user already has a vendor application
    const existingVendor = await prisma.vendor.findUnique({
      where: { userId: data.userId },
    });

    if (existingVendor) {
      if (existingVendor.status === VendorStatus.PENDING) {
        throw new Error(
          "You already have a pending vendor application. Please wait for admin review."
        );
      }
      if (existingVendor.status === VendorStatus.APPROVED) {
        throw new Error("You are already an approved vendor.");
      }
    }

    // Check for duplicate business email or license
    const duplicateCheck = await prisma.vendor.findFirst({
      where: {
        OR: [
          { businessEmail: data.businessEmail },
          { licenseNumber: data.licenseNumber },
          { taxId: data.taxId },
        ],
        NOT: { userId: data.userId },
      },
    });

    if (duplicateCheck) {
      throw new Error(
        "A vendor with this business email, license number, or tax ID already exists."
      );
    }

    // Create vendor profile
    const vendor = await prisma.vendor.create({
      data: {
        userId: data.userId,
        businessName: data.businessName,
        businessEmail: data.businessEmail,
        businessPhone: data.businessPhone,
        licenseNumber: data.licenseNumber,
        taxId: data.taxId,
        description: data.description,
        addressLine1: data.addressLine1,
        addressLine2: data.addressLine2,
        city: data.city,
        state: data.state,
        postalCode: data.postalCode,
        country: data.country,
        bankName: data.bankName,
        accountNumber: data.accountNumber,
        accountHolderName: data.accountHolderName,
        ifscCode: data.ifscCode,
        businessRegistration: data.documents.businessRegistration,
        pharmacyLicense: data.documents.pharmacyLicense,
        taxDocument: data.documents.taxDocument,
        logo: data.documents.logo,
        status: VendorStatus.PENDING,
      },
      include: {
        user: {
          select: {
            id: true,
            phone: true,
            email: true,
            firstName: true,
            lastName: true,
          },
        },
      },
    });

    // TODO: Send notification to admin
    // await NotificationService.notifyAdminNewVendor(vendor);

    return vendor;
  }

  /**
   * Get vendor application status
   */
  static async getVendorStatus(userId: string) {
    const vendor = await prisma.vendor.findUnique({
      where: { userId },
      select: {
        id: true,
        businessName: true,
        businessEmail: true,
        status: true,
        createdAt: true,
        verifiedAt: true,
        rejectionReason: true,
      },
    });

    return vendor;
  }

  /**
   * Resubmit vendor application after rejection
   */
  static async resubmitVendor(
    userId: string,
    data: Partial<RegisterVendorData>
  ) {
    const vendor = await prisma.vendor.findUnique({
      where: { userId },
    });

    if (!vendor) {
      throw new Error("No vendor application found.");
    }

    if (vendor.status !== VendorStatus.REJECTED) {
      throw new Error("Only rejected applications can be resubmitted.");
    }

    // Update vendor with new data
    const updatedVendor = await prisma.vendor.update({
      where: { userId },
      data: {
        ...data,
        status: VendorStatus.PENDING,
        rejectionReason: null,
      },
      include: {
        user: {
          select: {
            id: true,
            phone: true,
            firstName: true,
            lastName: true,
          },
        },
      },
    });

    // TODO: Notify admin of resubmission
    // await NotificationService.notifyAdminVendorResubmission(updatedVendor);

    return updatedVendor;
  }

  /**
   * Get vendor profile (own profile)
   */
  static async getVendorProfile(userId: string) {
    const vendor = await prisma.vendor.findUnique({
      where: { userId },
      include: {
        user: {
          select: {
            id: true,
            phone: true,
            email: true,
            firstName: true,
            lastName: true,
          },
        },
      },
    });

    if (!vendor) {
      throw new Error("Vendor profile not found.");
    }

    return vendor;
  }

  /**
   * Update vendor profile
   */
  static async updateVendorProfile(
    userId: string,
    data: Partial<RegisterVendorData>
  ) {
    const vendor = await prisma.vendor.update({
      where: { userId },
      data,
    });

    return vendor;
  }

  /**
   * Admin: Get all vendors with filters
   */
  static async getAllVendors(filters: {
    status?: VendorStatus;
    search?: string;
    limit?: number;
    offset?: number;
  }) {
    const { status, search, limit = 50, offset = 0 } = filters;

    const where: any = {};

    if (status) {
      where.status = status;
    }

    if (search) {
      where.OR = [
        { businessName: { contains: search, mode: "insensitive" } },
        { businessEmail: { contains: search, mode: "insensitive" } },
        { licenseNumber: { contains: search, mode: "insensitive" } },
      ];
    }

    const [vendors, total] = await Promise.all([
      prisma.vendor.findMany({
        where,
        include: {
          user: {
            select: {
              id: true,
              phone: true,
              firstName: true,
              lastName: true,
            },
          },
        },
        take: limit,
        skip: offset,
        orderBy: { createdAt: "desc" },
      }),
      prisma.vendor.count({ where }),
    ]);

    return { vendors, total };
  }

  /**
   * Admin: Get single vendor details
   */
  static async getVendorById(vendorId: string) {
    const vendor = await prisma.vendor.findUnique({
      where: { id: vendorId },
      include: {
        user: {
          select: {
            id: true,
            phone: true,
            email: true,
            firstName: true,
            lastName: true,
            role: true,
          },
        },
        products: {
          take: 10,
          select: {
            id: true,
            name: true,
            status: true,
            basePrice: true,
          },
        },
      },
    });

    if (!vendor) {
      throw new Error("Vendor not found.");
    }

    return vendor;
  }

  /**
   * Admin: Approve vendor (upgrades user role to VENDOR)
   */
  static async approveVendor(
    vendorId: string,
    adminId: string,
    commission?: number
  ) {
    const vendor = await prisma.vendor.findUnique({
      where: { id: vendorId },
      include: { user: true },
    });

    if (!vendor) {
      throw new Error("Vendor not found.");
    }

    if (vendor.status === VendorStatus.APPROVED) {
      throw new Error("Vendor is already approved.");
    }

    // Update vendor and user role in a transaction
    const result = await prisma.$transaction(async (tx) => {
      // Update vendor status
      const updatedVendor = await tx.vendor.update({
        where: { id: vendorId },
        data: {
          status: VendorStatus.APPROVED,
          verifiedAt: new Date(),
          commission: commission || 10.0,
          rejectionReason: null,
        },
      });

      // Upgrade user role to VENDOR
      const updatedUser = await tx.user.update({
        where: { id: vendor.userId },
        data: {
          role: UserRole.VENDOR,
        },
      });

      // Create activity log
      await tx.adminActivityLog.create({
        data: {
          adminId,
          action: "APPROVE_VENDOR",
          description: `Vendor approved: ${vendor.businessName}`,
          metadata: { vendorId, previousStatus: vendor.status },
        },
      });

      return { vendor: updatedVendor, user: updatedUser };
    });

    // TODO: Send approval notification
    // await NotificationService.notifyVendorApproval(vendor.userId);

    return result;
  }

  /**
   * Admin: Reject vendor
   */
  static async rejectVendor(vendorId: string, adminId: string, reason: string) {
    const vendor = await prisma.vendor.findUnique({
      where: { id: vendorId },
    });

    if (!vendor) {
      throw new Error("Vendor not found.");
    }

    const result = await prisma.$transaction(async (tx) => {
      // Update vendor status
      const updatedVendor = await tx.vendor.update({
        where: { id: vendorId },
        data: {
          status: VendorStatus.REJECTED,
          rejectionReason: reason,
        },
      });

      // Create activity log
      await tx.adminActivityLog.create({
        data: {
          adminId,
          action: "REJECT_VENDOR",
          description: `Vendor rejected: ${vendor.businessName}`,
          metadata: { vendorId, reason },
        },
      });

      return updatedVendor;
    });

    // TODO: Send rejection notification
    // await NotificationService.notifyVendorRejection(vendor.userId, reason);

    return result;
  }

  /**
   * Admin: Suspend vendor
   */
  static async suspendVendor(
    vendorId: string,
    adminId: string,
    reason: string
  ) {
    const vendor = await prisma.vendor.findUnique({
      where: { id: vendorId },
    });

    if (!vendor) {
      throw new Error("Vendor not found.");
    }

    const result = await prisma.$transaction(async (tx) => {
      // Update vendor status
      const updatedVendor = await tx.vendor.update({
        where: { id: vendorId },
        data: {
          status: VendorStatus.SUSPENDED,
        },
      });

      // Create activity log
      await tx.adminActivityLog.create({
        data: {
          adminId,
          action: "SUSPEND_VENDOR",
          description: `Vendor suspended: ${vendor.businessName}`,
          metadata: { vendorId, reason },
        },
      });

      return updatedVendor;
    });

    return result;
  }

  /**
   * Admin: Get vendor activity logs
   */
  static async getVendorActivityLogs(vendorId: string) {
    const logs = await prisma.adminActivityLog.findMany({
      where: {
        OR: [
          { action: { contains: "VENDOR" } },
          {
            metadata: {
              path: ["vendorId"],
              equals: vendorId,
            },
          },
        ],
      },
      include: {
        admin: {
          select: {
            id: true,
            user: {
              select: {
                firstName: true,
                lastName: true,
              },
            },
          },
        },
      },
      orderBy: { createdAt: "desc" },
      take: 50,
    });

    return logs;
  }
}

export default VendorService;
Enter fullscreen mode Exit fullscreen mode

Step 6: Create Product Service Layer

Create src/services/product.service.ts:

import prisma from "../db/prismaClient";
import { ProductStatus, VendorStatus } from "@prisma/client";

interface CreateProductData {
  vendorId: string;
  categoryId: string;
  name: string;
  slug: string;
  description: string;
  shortDescription?: string;
  sku: string;
  barcode?: string;
  productType: string;
  requiresPrescription: boolean;
  basePrice: number;
  comparePrice?: number;
  costPrice: number;
  manufacturer?: string;
  composition?: string;
  packSize?: string;
  dosageForm?: string;
  strength?: string;
  images: string[];
  thumbnail?: string;
  weight?: number;
  dimensions?: string;
  storageConditions?: string;
  sideEffects?: string;
  contraindications?: string;
  warnings?: string;
  status?: ProductStatus;
}

class ProductService {
  /**
   * Create new product
   */
  static async createProduct(data: CreateProductData) {
    // Verify vendor is approved
    const vendor = await prisma.vendor.findUnique({
      where: { id: data.vendorId },
    });

    if (!vendor) {
      throw new Error("Vendor not found.");
    }

    if (vendor.status !== VendorStatus.APPROVED) {
      throw new Error("Only approved vendors can add products.");
    }

    // Check for duplicate SKU
    const existingSku = await prisma.product.findUnique({
      where: { sku: data.sku },
    });

    if (existingSku) {
      throw new Error("A product with this SKU already exists.");
    }

    // Create product
    const product = await prisma.product.create({
      data: {
        ...data,
        status: data.status || ProductStatus.PENDING_APPROVAL,
      },
      include: {
        category: true,
        vendor: {
          select: {
            id: true,
            businessName: true,
          },
        },
      },
    });

    return product;
  }

  /**
   * Get vendor's own products
   */
  static async getVendorProducts(
    vendorId: string,
    filters: {
      status?: ProductStatus;
      search?: string;
      limit?: number;
      offset?: number;
    }
  ) {
    const { status, search, limit = 50, offset = 0 } = filters;

    const where: any = { vendorId };

    if (status) {
      where.status = status;
    }

    if (search) {
      where.OR = [
        { name: { contains: search, mode: "insensitive" } },
        { sku: { contains: search, mode: "insensitive" } },
      ];
    }

    const [products, total] = await Promise.all([
      prisma.product.findMany({
        where,
        include: {
          category: true,
          inventories: {
            select: {
              quantity: true,
              availableQty: true,
            },
          },
        },
        take: limit,
        skip: offset,
        orderBy: { createdAt: "desc" },
      }),
      prisma.product.count({ where }),
    ]);

    return { products, total };
  }

  /**
   * Get single product by ID
   */
  static async getProductById(productId: string, vendorId?: string) {
    const where: any = { id: productId };

    if (vendorId) {
      where.vendorId = vendorId;
    }

    const product = await prisma.product.findUnique({
      where,
      include: {
        category: true,
        vendor: {
          select: {
            id: true,
            businessName: true,
            rating: true,
          },
        },
        inventories: true,
      },
    });

    if (!product) {
      throw new Error("Product not found.");
    }

    return product;
  }

  /**
   * Update product
   */
  static async updateProduct(
    productId: string,
    vendorId: string,
    data: Partial<CreateProductData>
  ) {
    // Verify product belongs to vendor
    const product = await prisma.product.findFirst({
      where: { id: productId, vendorId },
    });

    if (!product) {
      throw new Error("Product not found or you don't have permission.");
    }

    const updatedProduct = await prisma.product.update({
      where: { id: productId },
      data,
    });

    return updatedProduct;
  }

  /**
   * Delete product
   */
  static async deleteProduct(productId: string, vendorId: string) {
    const product = await prisma.product.findFirst({
      where: { id: productId, vendorId },
    });

    if (!product) {
      throw new Error("Product not found or you don't have permission.");
    }

    await prisma.product.delete({
      where: { id: productId },
    });

    return { success: true };
  }

  /**
   * Add inventory for product
   */
  static async addInventory(
    productId: string,
    vendorId: string,
    data: {
      quantity: number;
      batchNumber?: string;
      expiryDate?: Date;
      manufacturingDate?: Date;
      reorderLevel?: number;
      maxStockLevel?: number;
    }
  ) {
    const product = await prisma.product.findFirst({
      where: { id: productId, vendorId },
    });

    if (!product) {
      throw new Error("Product not found or you don't have permission.");
    }

    const inventory = await prisma.inventory.create({
      data: {
        productId,
        vendorId,
        quantity: data.quantity,
        availableQty: data.quantity,
        reservedQty: 0,
        batchNumber: data.batchNumber,
        expiryDate: data.expiryDate,
        manufacturingDate: data.manufacturingDate,
        reorderLevel: data.reorderLevel || 10,
        maxStockLevel: data.maxStockLevel,
      },
    });

    return inventory;
  }

  /**
   * Get product inventory
   */
  static async getProductInventory(productId: string, vendorId: string) {
    const inventories = await prisma.inventory.findMany({
      where: { productId, vendorId },
      orderBy: { createdAt: "desc" },
    });

    return inventories;
  }

  /**
   * Admin: Get pending products
   */
  static async getPendingProducts(filters: {
    limit?: number;
    offset?: number;
  }) {
    const { limit = 50, offset = 0 } = filters;

    const [products, total] = await Promise.all([
      prisma.product.findMany({
        where: { status: ProductStatus.PENDING_APPROVAL },
        include: {
          vendor: {
            select: {
              id: true,
              businessName: true,
            },
          },
          category: true,
        },
        take: limit,
        skip: offset,
        orderBy: { createdAt: "desc" },
      }),
      prisma.product.count({
        where: { status: ProductStatus.PENDING_APPROVAL },
      }),
    ]);

    return { products, total };
  }

  /**
   * Admin: Get products by vendor ID
   */
  static async getProductsByVendorId(
    vendorId: string,
    filters: {
      status?: ProductStatus;
      search?: string;
      limit?: number;
      offset?: number;
    }
  ) {
    // Verify vendor exists
    const vendor = await prisma.vendor.findUnique({
      where: { id: vendorId },
      select: {
        id: true,
        businessName: true,
        status: true,
      },
    });

    if (!vendor) {
      throw new Error("Vendor not found.");
    }

    const { status, search, limit = 50, offset = 0 } = filters;

    const where: any = { vendorId };

    if (status) {
      where.status = status;
    }

    if (search) {
      where.OR = [
        { name: { contains: search, mode: "insensitive" } },
        { sku: { contains: search, mode: "insensitive" } },
      ];
    }

    const [products, total] = await Promise.all([
      prisma.product.findMany({
        where,
        include: {
          category: true,
          inventories: {
            select: {
              quantity: true,
              availableQty: true,
            },
          },
        },
        take: limit,
        skip: offset,
        orderBy: { createdAt: "desc" },
      }),
      prisma.product.count({ where }),
    ]);

    return { vendor, products, total };
  }

  /**
   * Admin: Approve product
   */
  static async approveProduct(productId: string, adminId: string) {
    const product = await prisma.product.findUnique({
      where: { id: productId },
      include: { vendor: true },
    });

    if (!product) {
      throw new Error("Product not found.");
    }

    const result = await prisma.$transaction(async (tx) => {
      const updatedProduct = await tx.product.update({
        where: { id: productId },
        data: {
          status: ProductStatus.ACTIVE,
          approvedAt: new Date(),
        },
      });

      await tx.adminActivityLog.create({
        data: {
          adminId,
          action: "APPROVE_PRODUCT",
          description: `Product approved: ${product.name}`,
          metadata: { productId, vendorId: product.vendorId },
        },
      });

      return updatedProduct;
    });

    return result;
  }

  /**
   * Admin: Reject product
   */
  static async rejectProduct(
    productId: string,
    adminId: string,
    reason: string
  ) {
    const product = await prisma.product.findUnique({
      where: { id: productId },
    });

    if (!product) {
      throw new Error("Product not found.");
    }

    const result = await prisma.$transaction(async (tx) => {
      const updatedProduct = await tx.product.update({
        where: { id: productId },
        data: {
          status: ProductStatus.INACTIVE,
        },
      });

      await tx.adminActivityLog.create({
        data: {
          adminId,
          action: "REJECT_PRODUCT",
          description: `Product rejected: ${product.name}`,
          metadata: { productId, vendorId: product.vendorId, reason },
        },
      });

      return updatedProduct;
    });

    return result;
  }
}

export default ProductService;
Enter fullscreen mode Exit fullscreen mode

Step 7: Create Vendor Controller

Create src/modules/vendor/vendor.controller.ts:

import { Request, Response } from "express";
import multer from "multer";
import VendorService from "../../services/vendor.service";
import ImageKitService from "../../services/imagekit.service";
import {
  registerVendorSchema,
  updateVendorSchema,
  approveVendorSchema,
  rejectVendorSchema,
  suspendVendorSchema,
} from "./vendor.validation";
import asyncHandler from "../../utils/asyncHandler";
import { VendorStatus } from "@prisma/client";

// Configure multer for file uploads
const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 10 * 1024 * 1024, // 10MB limit
  },
  fileFilter: (req, file, cb) => {
    const allowedMimes = [
      "image/jpeg",
      "image/png",
      "image/jpg",
      "application/pdf",
    ];

    if (allowedMimes.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("Invalid file type. Only JPEG, PNG, and PDF are allowed."));
    }
  },
});

export const uploadDocuments = upload.fields([
  { name: "businessRegistration", maxCount: 1 },
  { name: "pharmacyLicense", maxCount: 1 },
  { name: "taxDocument", maxCount: 1 },
  { name: "logo", maxCount: 1 },
]);

class VendorController {
  /**
   * POST /api/vendor/register
   * Register new vendor with documents
   */
  static register = asyncHandler(async (req: Request, res: Response) => {
    const userId = req.user!.id;
    const files = req.files as { [fieldname: string]: Express.Multer.File[] };

    // Validate required documents
    if (
      !files.businessRegistration ||
      !files.pharmacyLicense ||
      !files.taxDocument
    ) {
      return res.status(400).json({
        ok: false,
        error:
          "All documents (businessRegistration, pharmacyLicense, taxDocument) are required.",
      });
    }

    // Validate form data
    const validatedData = registerVendorSchema.parse(req.body);

    // Upload documents to ImageKit
    const [businessReg, pharmacyLic, taxDoc, logo] = await Promise.all([
      ImageKitService.uploadFile(
        files.businessRegistration[0],
        `vendors/${userId}/documents`,
        "business-registration"
      ),
      ImageKitService.uploadFile(
        files.pharmacyLicense[0],
        `vendors/${userId}/documents`,
        "pharmacy-license"
      ),
      ImageKitService.uploadFile(
        files.taxDocument[0],
        `vendors/${userId}/documents`,
        "tax-document"
      ),
      files.logo
        ? ImageKitService.uploadFile(files.logo[0], `vendors/${userId}`, "logo")
        : Promise.resolve(null),
    ]);

    // Create vendor profile
    const vendor = await VendorService.registerVendor({
      userId,
      ...validatedData,
      documents: {
        businessRegistration: businessReg.url,
        pharmacyLicense: pharmacyLic.url,
        taxDocument: taxDoc.url,
        logo: logo?.url,
      },
    });

    res.status(201).json({
      ok: true,
      message:
        "Vendor registration submitted successfully. Your application is under review.",
      vendor: {
        id: vendor.id,
        businessName: vendor.businessName,
        businessEmail: vendor.businessEmail,
        status: vendor.status,
        documents: {
          businessRegistration: businessReg.url,
          pharmacyLicense: pharmacyLic.url,
          taxDocument: taxDoc.url,
          logo: logo?.url,
        },
        createdAt: vendor.createdAt,
      },
    });
  });

  /**
   * GET /api/vendor/status
   * Check vendor application status
   */
  static getStatus = asyncHandler(async (req: Request, res: Response) => {
    const userId = req.user!.id;

    const vendor = await VendorService.getVendorStatus(userId);

    if (!vendor) {
      return res.status(200).json({
        ok: true,
        hasVendorApplication: false,
        message: "You haven't submitted a vendor application yet.",
      });
    }

    let message = "";
    if (vendor.status === VendorStatus.PENDING) {
      message =
        "Your vendor application is under review. We'll notify you once it's processed.";
    } else if (vendor.status === VendorStatus.APPROVED) {
      message =
        "Congratulations! Your vendor application has been approved. You can now start adding products.";
    } else if (vendor.status === VendorStatus.REJECTED) {
      message =
        "Your vendor application was rejected. Please review the reason and resubmit with corrected documents.";
    } else if (vendor.status === VendorStatus.SUSPENDED) {
      message = "Your vendor account has been suspended.";
    }

    res.status(200).json({
      ok: true,
      hasVendorApplication: true,
      vendor: {
        ...vendor,
        message,
        canResubmit: vendor.status === VendorStatus.REJECTED,
        userRole: req.user!.role,
      },
    });
  });

  /**
   * PUT /api/vendor/resubmit
   * Resubmit vendor application after rejection
   */
  static resubmit = asyncHandler(async (req: Request, res: Response) => {
    const userId = req.user!.id;
    const files = req.files as { [fieldname: string]: Express.Multer.File[] };

    // Upload new documents if provided
    const documents: any = {};

    if (files.businessRegistration) {
      const result = await ImageKitService.uploadFile(
        files.businessRegistration[0],
        `vendors/${userId}/documents`,
        "business-registration-v2"
      );
      documents.businessRegistration = result.url;
    }

    if (files.pharmacyLicense) {
      const result = await ImageKitService.uploadFile(
        files.pharmacyLicense[0],
        `vendors/${userId}/documents`,
        "pharmacy-license-v2"
      );
      documents.pharmacyLicense = result.url;
    }

    if (files.taxDocument) {
      const result = await ImageKitService.uploadFile(
        files.taxDocument[0],
        `vendors/${userId}/documents`,
        "tax-document-v2"
      );
      documents.taxDocument = result.url;
    }

    if (files.logo) {
      const result = await ImageKitService.uploadFile(
        files.logo[0],
        `vendors/${userId}`,
        "logo-v2"
      );
      documents.logo = result.url;
    }

    // Merge body data with uploaded documents
    const updateData = { ...req.body, ...documents };

    const vendor = await VendorService.resubmitVendor(userId, updateData);

    res.status(200).json({
      ok: true,
      message:
        "Vendor application resubmitted successfully. Your application is under review again.",
      vendor: {
        id: vendor.id,
        businessName: vendor.businessName,
        businessEmail: vendor.businessEmail,
        status: vendor.status,
        updatedAt: vendor.updatedAt,
      },
    });
  });

  /**
   * GET /api/vendor/profile
   * Get own vendor profile
   */
  static getProfile = asyncHandler(async (req: Request, res: Response) => {
    const userId = req.user!.id;

    const vendor = await VendorService.getVendorProfile(userId);

    res.status(200).json({
      ok: true,
      vendor,
    });
  });

  /**
   * PUT /api/vendor/profile
   * Update own vendor profile
   */
  static updateProfile = asyncHandler(async (req: Request, res: Response) => {
    const userId = req.user!.id;
    const validatedData = updateVendorSchema.parse(req.body);

    const vendor = await VendorService.updateVendorProfile(
      userId,
      validatedData
    );

    res.status(200).json({
      ok: true,
      message: "Vendor profile updated successfully",
      vendor,
    });
  });

  /**
   * GET /api/admin/vendors
   * Admin: Get all vendors with filters
   */
  static getAllVendors = asyncHandler(async (req: Request, res: Response) => {
    const { status, search, limit, offset } = req.query;

    const { vendors, total } = await VendorService.getAllVendors({
      status: status as VendorStatus,
      search: search as string,
      limit: limit ? parseInt(limit as string) : undefined,
      offset: offset ? parseInt(offset as string) : undefined,
    });

    res.status(200).json({
      ok: true,
      total,
      limit: limit || 50,
      offset: offset || 0,
      vendors,
    });
  });

  /**
   * GET /api/admin/vendors/:id
   * Admin: Get single vendor details
   */
  static getVendorById = asyncHandler(async (req: Request, res: Response) => {
    const { id } = req.params;

    const vendor = await VendorService.getVendorById(id);

    res.status(200).json({
      ok: true,
      vendor,
    });
  });

  /**
   * PUT /api/admin/vendors/:id/approve
   * Admin: Approve vendor
   */
  static approveVendor = asyncHandler(async (req: Request, res: Response) => {
    const { id } = req.params;
    const adminId = req.user!.id;
    const { commission } = approveVendorSchema.parse(req.body);

    const result = await VendorService.approveVendor(id, adminId, commission);

    res.status(200).json({
      ok: true,
      message: "Vendor approved successfully. User role upgraded to VENDOR.",
      vendor: result.vendor,
      user: {
        id: result.user.id,
        role: result.user.role,
        previousRole: "CUSTOMER",
      },
    });
  });

  /**
   * PUT /api/admin/vendors/:id/reject
   * Admin: Reject vendor
   */
  static rejectVendor = asyncHandler(async (req: Request, res: Response) => {
    const { id } = req.params;
    const adminId = req.user!.id;
    const { reason } = rejectVendorSchema.parse(req.body);

    const vendor = await VendorService.rejectVendor(id, adminId, reason);

    res.status(200).json({
      ok: true,
      message: "Vendor registration rejected",
      vendor: {
        id: vendor.id,
        businessName: vendor.businessName,
        status: vendor.status,
        rejectionReason: vendor.rejectionReason,
      },
    });
  });

  /**
   * PUT /api/admin/vendors/:id/suspend
   * Admin: Suspend vendor
   */
  static suspendVendor = asyncHandler(async (req: Request, res: Response) => {
    const { id } = req.params;
    const adminId = req.user!.id;
    const { reason } = suspendVendorSchema.parse(req.body);

    const vendor = await VendorService.suspendVendor(id, adminId, reason);

    res.status(200).json({
      ok: true,
      message: "Vendor suspended successfully",
      vendor: {
        id: vendor.id,
        status: vendor.status,
      },
    });
  });

  /**
   * GET /api/admin/vendors/:id/activity
   * Admin: Get vendor activity logs
   */
  static getActivityLogs = asyncHandler(async (req: Request, res: Response) => {
    const { id } = req.params;

    const logs = await VendorService.getVendorActivityLogs(id);

    res.status(200).json({
      ok: true,
      total: logs.length,
      logs,
    });
  });
}

export default VendorController;
Enter fullscreen mode Exit fullscreen mode

Step 8: Create Product Controller

Create src/modules/vendor/product.controller.ts:

import { Request, Response } from "express";
import multer from "multer";
import ProductService from "../../services/product.service";
import ImageKitService from "../../services/imagekit.service";
import {
  createProductSchema,
  updateProductSchema,
  createInventorySchema,
  rejectProductSchema,
} from "./product.validation";
import asyncHandler from "../../utils/asyncHandler";

// Configure multer for product image uploads
const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB limit
  },
  fileFilter: (req, file, cb) => {
    const allowedMimes = ["image/jpeg", "image/png", "image/jpg", "image/webp"];

    if (allowedMimes.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("Invalid file type. Only JPEG, PNG, and WEBP are allowed."));
    }
  },
});

export const uploadProductImage = upload.single("image");

class ProductController {
  /**
   * POST /api/vendor/products
   * Create new product
   */
  static createProduct = asyncHandler(async (req: Request, res: Response) => {
    const vendorId = req.vendor!.id;
    const validatedData = createProductSchema.parse(req.body);

    const product = await ProductService.createProduct({
      vendorId,
      ...validatedData,
    });

    res.status(201).json({
      ok: true,
      message: "Product created successfully",
      product,
    });
  });

  /**
   * GET /api/vendor/products
   * Get own products
   */
  static getProducts = asyncHandler(async (req: Request, res: Response) => {
    const vendorId = req.vendor!.id;
    const { status, search, limit, offset } = req.query;

    const { products, total } = await ProductService.getVendorProducts(
      vendorId,
      {
        status: status as any,
        search: search as string,
        limit: limit ? parseInt(limit as string) : undefined,
        offset: offset ? parseInt(offset as string) : undefined,
      }
    );

    res.status(200).json({
      ok: true,
      total,
      products,
    });
  });

  /**
   * GET /api/vendor/products/:id
   * Get single product
   */
  static getProductById = asyncHandler(async (req: Request, res: Response) => {
    const vendorId = req.vendor!.id;
    const { id } = req.params;

    const product = await ProductService.getProductById(id, vendorId);

    res.status(200).json({
      ok: true,
      product,
    });
  });

  /**
   * PUT /api/vendor/products/:id
   * Update product
   */
  static updateProduct = asyncHandler(async (req: Request, res: Response) => {
    const vendorId = req.vendor!.id;
    const { id } = req.params;
    const validatedData = updateProductSchema.parse(req.body);

    const product = await ProductService.updateProduct(
      id,
      vendorId,
      validatedData
    );

    res.status(200).json({
      ok: true,
      message: "Product updated successfully",
      product,
    });
  });

  /**
   * DELETE /api/vendor/products/:id
   * Delete product
   */
  static deleteProduct = asyncHandler(async (req: Request, res: Response) => {
    const vendorId = req.vendor!.id;
    const { id } = req.params;

    await ProductService.deleteProduct(id, vendorId);

    res.status(200).json({
      ok: true,
      message: "Product deleted successfully",
      productId: id,
    });
  });

  /**
   * POST /api/vendor/products/upload-image
   * Upload product image
   */
  static uploadImage = asyncHandler(async (req: Request, res: Response) => {
    const vendorId = req.vendor!.id;
    const file = req.file;

    if (!file) {
      return res.status(400).json({
        ok: false,
        error: "No image file provided",
      });
    }

    const result = await ImageKitService.uploadFile(
      file,
      `products/${vendorId}`,
      `product-${Date.now()}`
    );

    res.status(200).json({
      ok: true,
      message: "Image uploaded successfully",
      image: {
        url: result.url,
        fileId: result.fileId,
        thumbnail: result.thumbnailUrl,
      },
    });
  });

  /**
   * POST /api/vendor/products/:id/inventory
   * Add inventory for product
   */
  static addInventory = asyncHandler(async (req: Request, res: Response) => {
    const vendorId = req.vendor!.id;
    const { id } = req.params;
    const validatedData = createInventorySchema.parse(req.body);

    const inventory = await ProductService.addInventory(id, vendorId, {
      ...validatedData,
      expiryDate: validatedData.expiryDate
        ? new Date(validatedData.expiryDate)
        : undefined,
      manufacturingDate: validatedData.manufacturingDate
        ? new Date(validatedData.manufacturingDate)
        : undefined,
    });

    res.status(201).json({
      ok: true,
      message: "Inventory added successfully",
      inventory,
    });
  });

  /**
   * GET /api/vendor/products/:id/inventory
   * Get product inventory
   */
  static getInventory = asyncHandler(async (req: Request, res: Response) => {
    const vendorId = req.vendor!.id;
    const { id } = req.params;

    const inventories = await ProductService.getProductInventory(id, vendorId);

    res.status(200).json({
      ok: true,
      inventories,
    });
  });

  /**
   * GET /api/admin/products/pending
   * Admin: Get pending products
   */
  static getPendingProducts = asyncHandler(
    async (req: Request, res: Response) => {
      const { limit, offset } = req.query;

      const { products, total } = await ProductService.getPendingProducts({
        limit: limit ? parseInt(limit as string) : undefined,
        offset: offset ? parseInt(offset as string) : undefined,
      });

      res.status(200).json({
        ok: true,
        total,
        products,
      });
    }
  );

  /**
   * GET /api/admin/vendors/:vendorId/products
   * Admin: Get all products by vendor ID
   */
  static getProductsByVendorId = asyncHandler(
    async (req: Request, res: Response) => {
      const { vendorId } = req.params;
      const { status, search, limit, offset } = req.query;

      const { vendor, products, total } =
        await ProductService.getProductsByVendorId(vendorId, {
          status: status as ProductStatus,
          search: search as string,
          limit: limit ? parseInt(limit as string) : undefined,
          offset: offset ? parseInt(offset as string) : undefined,
        });

      res.status(200).json({
        ok: true,
        vendorId,
        vendor,
        total,
        limit: limit || 50,
        offset: offset || 0,
        products,
      });
    }
  );

  /**
   * PUT /api/admin/products/:id/approve
   * Admin: Approve product
   */
  static approveProduct = asyncHandler(async (req: Request, res: Response) => {
    const { id } = req.params;
    const adminId = req.user!.id;

    const product = await ProductService.approveProduct(id, adminId);

    res.status(200).json({
      ok: true,
      message: "Product approved successfully",
      product,
    });
  });

  /**
   * PUT /api/admin/products/:id/reject
   * Admin: Reject product
   */
  static rejectProduct = asyncHandler(async (req: Request, res: Response) => {
    const { id } = req.params;
    const adminId = req.user!.id;
    const { reason } = rejectProductSchema.parse(req.body);

    const product = await ProductService.rejectProduct(id, adminId, reason);

    res.status(200).json({
      ok: true,
      message: "Product rejected",
      product,
    });
  });
}

export default ProductController;
Enter fullscreen mode Exit fullscreen mode

Step 9: Create Vendor Routes

Create src/modules/vendor/vendor.route.ts:

import { Router } from "express";
import VendorController, { uploadDocuments } from "./vendor.controller";
import requireAuth from "../../middleware/requireAuth";
import requireAdmin from "../../middleware/requireAdmin";
import requirePermission from "../../middleware/requirePermission";
import requireVendor from "../../middleware/requireVendor";
import { AdminPermission } from "@prisma/client";

const router = Router();

// ==========================================
// VENDOR ROUTES (For regular users/vendors)
// ==========================================

/**
 * POST /api/vendor/register
 * Register as vendor (CUSTOMER role required)
 * User submits complete application with all documents
 */
router.post(
  "/register",
  requireAuth,
  uploadDocuments,
  VendorController.register
);

/**
 * GET /api/vendor/status
 * Check vendor application status
 * Any authenticated user can check their status
 */
router.get("/status", requireAuth, VendorController.getStatus);

/**
 * PUT /api/vendor/resubmit
 * Resubmit vendor application after rejection
 * CUSTOMER role with REJECTED vendor application required
 */
router.put(
  "/resubmit",
  requireAuth,
  uploadDocuments,
  VendorController.resubmit
);

/**
 * GET /api/vendor/profile
 * Get own vendor profile
 * VENDOR role required
 */
router.get("/profile", requireAuth, requireVendor, VendorController.getProfile);

/**
 * PUT /api/vendor/profile
 * Update own vendor profile
 * VENDOR role required
 */
router.put(
  "/profile",
  requireAuth,
  requireVendor,
  VendorController.updateProfile
);

// ==========================================
// ADMIN ROUTES (For vendor management)
// ==========================================

/**
 * GET /api/admin/vendors
 * Get all vendors with filters
 * MANAGE_VENDORS permission required
 */
router.get(
  "/admin/vendors",
  requireAuth,
  requireAdmin,
  requirePermission(AdminPermission.MANAGE_VENDORS),
  VendorController.getAllVendors
);

/**
 * GET /api/admin/vendors/:id
 * Get single vendor details
 * MANAGE_VENDORS permission required
 */
router.get(
  "/admin/vendors/:id",
  requireAuth,
  requireAdmin,
  requirePermission(AdminPermission.MANAGE_VENDORS),
  VendorController.getVendorById
);

/**
 * PUT /api/admin/vendors/:id/approve
 * Approve vendor (upgrades user role to VENDOR)
 * MANAGE_VENDORS permission required
 */
router.put(
  "/admin/vendors/:id/approve",
  requireAuth,
  requireAdmin,
  requirePermission(AdminPermission.MANAGE_VENDORS),
  VendorController.approveVendor
);

/**
 * PUT /api/admin/vendors/:id/reject
 * Reject vendor with reason
 * MANAGE_VENDORS permission required
 */
router.put(
  "/admin/vendors/:id/reject",
  requireAuth,
  requireAdmin,
  requirePermission(AdminPermission.MANAGE_VENDORS),
  VendorController.rejectVendor
);

/**
 * PUT /api/admin/vendors/:id/suspend
 * Suspend vendor account
 * MANAGE_VENDORS permission required
 */
router.put(
  "/admin/vendors/:id/suspend",
  requireAuth,
  requireAdmin,
  requirePermission(AdminPermission.MANAGE_VENDORS),
  VendorController.suspendVendor
);

/**
 * GET /api/admin/vendors/:id/activity
 * Get vendor activity logs
 * MANAGE_VENDORS permission required
 */
router.get(
  "/admin/vendors/:id/activity",
  requireAuth,
  requireAdmin,
  requirePermission(AdminPermission.MANAGE_VENDORS),
  VendorController.getActivityLogs
);

export default router;
Enter fullscreen mode Exit fullscreen mode

Step 10: Create Product Routes

Create src/modules/vendor/product.route.ts:

import { Router } from "express";
import ProductController, { uploadProductImage } from "./product.controller";
import requireAuth from "../../middleware/requireAuth";
import requireAdmin from "../../middleware/requireAdmin";
import requirePermission from "../../middleware/requirePermission";
import requireVendor from "../../middleware/requireVendor";
import { AdminPermission } from "@prisma/client";

const router = Router();

// ==========================================
// VENDOR ROUTES (For product management)
// ==========================================

/**
 * POST /api/vendor/products
 * Create new product
 * VENDOR role required
 */
router.post("/", requireAuth, requireVendor, ProductController.createProduct);

/**
 * GET /api/vendor/products
 * Get own products
 * VENDOR role required
 */
router.get("/", requireAuth, requireVendor, ProductController.getProducts);

/**
 * GET /api/vendor/products/:id
 * Get single product details
 * VENDOR role required
 */
router.get(
  "/:id",
  requireAuth,
  requireVendor,
  ProductController.getProductById
);

/**
 * PUT /api/vendor/products/:id
 * Update product
 * VENDOR role required
 */
router.put("/:id", requireAuth, requireVendor, ProductController.updateProduct);

/**
 * DELETE /api/vendor/products/:id
 * Delete product
 * VENDOR role required
 */
router.delete(
  "/:id",
  requireAuth,
  requireVendor,
  ProductController.deleteProduct
);

/**
 * POST /api/vendor/products/upload-image
 * Upload product image
 * VENDOR role required
 */
router.post(
  "/upload-image",
  requireAuth,
  requireVendor,
  uploadProductImage,
  ProductController.uploadImage
);

/**
 * POST /api/vendor/products/:id/inventory
 * Add inventory for product
 * VENDOR role required
 */
router.post(
  "/:id/inventory",
  requireAuth,
  requireVendor,
  ProductController.addInventory
);

/**
 * GET /api/vendor/products/:id/inventory
 * Get product inventory
 * VENDOR role required
 */
router.get(
  "/:id/inventory",
  requireAuth,
  requireVendor,
  ProductController.getInventory
);

// ==========================================
// ADMIN ROUTES (For product approval)
// ==========================================

/**
 * GET /api/admin/products/pending
 * Get all pending products
 * MANAGE_PRODUCTS permission required
 */
router.get(
  "/admin/pending",
  requireAuth,
  requireAdmin,
  requirePermission(AdminPermission.MANAGE_PRODUCTS),
  ProductController.getPendingProducts
);

/**
 * PUT /api/admin/products/:id/approve
 * Approve product
 * MANAGE_PRODUCTS permission required
 */
router.put(
  "/admin/:id/approve",
  requireAuth,
  requireAdmin,
  requirePermission(AdminPermission.MANAGE_PRODUCTS),
  ProductController.approveProduct
);

/**
 * PUT /api/admin/products/:id/reject
 * Reject product with reason
 * MANAGE_PRODUCTS permission required
 */
router.put(
  "/admin/:id/reject",
  requireAuth,
  requireAdmin,
  requirePermission(AdminPermission.MANAGE_PRODUCTS),
  ProductController.rejectProduct
);

/**
 * GET /api/admin/vendors/:vendorId/products
 * Get all products by vendor ID
 * MANAGE_PRODUCTS permission required
 */
router.get(
  "/admin/vendors/:vendorId/products",
  requireAuth,
  requireAdmin,
  requirePermission(AdminPermission.MANAGE_PRODUCTS),
  ProductController.getProductsByVendorId
);

export default router;
Enter fullscreen mode Exit fullscreen mode

Step 11: Create requireVendor Middleware

Create src/middleware/requireVendor.ts:

import { Request, Response, NextFunction } from "express";
import prisma from "../db/prismaClient";
import { UserRole, VendorStatus } from "@prisma/client";

/**
 * Middleware to ensure user is an APPROVED vendor
 * Attaches vendor profile to req.vendor
 */
const requireVendor = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const user = req.user;

    if (!user) {
      return res.status(401).json({
        ok: false,
        error: "Authentication required",
      });
    }

    // Check if user has VENDOR role
    if (user.role !== UserRole.VENDOR) {
      return res.status(403).json({
        ok: false,
        error: "Vendor access required. You are not a vendor.",
      });
    }

    // Get vendor profile
    const vendor = await prisma.vendor.findUnique({
      where: { userId: user.id },
    });

    if (!vendor) {
      return res.status(403).json({
        ok: false,
        error: "Vendor profile not found.",
      });
    }

    // Check if vendor is approved
    if (vendor.status !== VendorStatus.APPROVED) {
      return res.status(403).json({
        ok: false,
        error: `Your vendor account is ${vendor.status.toLowerCase()}. Only approved vendors can access this resource.`,
      });
    }

    // Attach vendor to request
    req.vendor = vendor;

    next();
  } catch (error) {
    console.error("requireVendor middleware error:", error);
    return res.status(500).json({
      ok: false,
      error: "Internal server error",
    });
  }
};

export default requireVendor;
Enter fullscreen mode Exit fullscreen mode

Step 12: Update Express Types

Update src/types/express.d.ts to include vendor type:

import { User, Vendor } from "@prisma/client";

declare global {
  namespace Express {
    interface Request {
      user?: User;
      vendor?: Vendor;
    }
  }
}

export {};
Enter fullscreen mode Exit fullscreen mode

Step 13: Register Routes in Main App

Update src/index.ts or your main routes file:

import express from "express";
import vendorRoutes from "./modules/vendor/vendor.route";
import productRoutes from "./modules/vendor/product.route";

const app = express();

// ... other middleware ...

// Register vendor management routes
app.use("/api/vendor", vendorRoutes);
app.use("/api/vendor/products", productRoutes);

// ... rest of your app ...

export default app;
Enter fullscreen mode Exit fullscreen mode

Step 14: Configure Multer for File Uploads

Create src/config/multer.config.ts:

import multer from "multer";
import path from "path";

// File filter for images
export const imageFileFilter = (
  req: any,
  file: Express.Multer.File,
  cb: multer.FileFilterCallback
) => {
  const allowedMimes = ["image/jpeg", "image/png", "image/jpg", "image/webp"];

  if (allowedMimes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new Error("Invalid file type. Only images are allowed."));
  }
};

// File filter for documents
export const documentFileFilter = (
  req: any,
  file: Express.Multer.File,
  cb: multer.FileFilterCallback
) => {
  const allowedMimes = [
    "image/jpeg",
    "image/png",
    "image/jpg",
    "application/pdf",
  ];

  if (allowedMimes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new Error("Invalid file type. Only images and PDFs are allowed."));
  }
};

// Multer configuration for memory storage
export const multerConfig: multer.Options = {
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 10 * 1024 * 1024, // 10MB
  },
};
Enter fullscreen mode Exit fullscreen mode

Step 15: Environment Variables

Add these to your .env file:

# ImageKit Configuration
IMAGEKIT_PUBLIC_KEY=your_public_key_here
IMAGEKIT_PRIVATE_KEY=your_private_key_here
IMAGEKIT_URL_ENDPOINT=https://ik.imagekit.io/your_id

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname

# JWT
JWT_SECRET=your_jwt_secret_here
JWT_EXPIRES_IN=7d

# Server
PORT=5000
NODE_ENV=development
Enter fullscreen mode Exit fullscreen mode

Step 16: Update Prisma Schema (If Needed)

Ensure your prisma/schema.prisma includes:

enum AdminPermission {
  MANAGE_ADMINS
  MANAGE_USERS
  MANAGE_VENDORS
  MANAGE_PRODUCTS
  MANAGE_ORDERS
  VIEW_REPORTS
  MANAGE_CATEGORIES
  MANAGE_SETTINGS
}

model AdminActivityLog {
  id          String   @id @default(cuid())
  adminId     String
  action      String
  description String
  metadata    Json?
  createdAt   DateTime @default(now())

  admin AdminProfile @relation(fields: [adminId], references: [id], onDelete: Cascade)

  @@index([adminId])
  @@index([createdAt])
}
Enter fullscreen mode Exit fullscreen mode

Step 17: Run Database Migrations

After updating your schema, run migrations:

# Generate Prisma Client
pnpm prisma generate

# Run migrations
pnpm prisma migrate dev --name add_vendor_management

# (Optional) Seed database
pnpm prisma db seed
Enter fullscreen mode Exit fullscreen mode

Testing the Implementation

Test Vendor Registration

# 1. Register as vendor (with documents)
curl -X POST http://localhost:5000/api/vendor/register \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "businessName=HealthCare Pharmacy" \
  -F "businessEmail=contact@healthcare.com" \
  -F "businessPhone=9876543210" \
  -F "licenseNumber=PL-2024-1234" \
  -F "taxId=GSTIN12345ABC" \
  -F "addressLine1=123 Main Street" \
  -F "city=Mumbai" \
  -F "state=Maharashtra" \
  -F "postalCode=400001" \
  -F "country=India" \
  -F "businessRegistration=@/path/to/registration.pdf" \
  -F "pharmacyLicense=@/path/to/license.pdf" \
  -F "taxDocument=@/path/to/tax.pdf" \
  -F "logo=@/path/to/logo.png"

# 2. Check status
curl -X GET http://localhost:5000/api/vendor/status \
  -H "Authorization: Bearer YOUR_TOKEN"

# 3. Admin approves vendor
curl -X PUT http://localhost:5000/api/admin/vendors/VENDOR_ID/approve \
  -H "Authorization: Bearer ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"commission": 10.0}'

# 4. Vendor creates product
curl -X POST http://localhost:5000/api/vendor/products \
  -H "Authorization: Bearer VENDOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "categoryId": "cat123",
    "name": "Paracetamol 500mg",
    "slug": "paracetamol-500mg",
    "description": "Effective pain relief",
    "sku": "MED-PARA-500",
    "productType": "OTC",
    "requiresPrescription": false,
    "basePrice": 50.0,
    "costPrice": 35.0,
    "images": ["https://example.com/image1.jpg"],
    "status": "PENDING_APPROVAL"
  }'

# 5. Admin gets all products by vendor ID
curl -X GET "http://localhost:5000/api/admin/vendors/VENDOR_ID/products?status=ACTIVE&limit=50&offset=0" \
  -H "Authorization: Bearer ADMIN_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

1. File Upload Security

// Validate file size
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

// Validate file type
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "application/pdf"];

// Sanitize filename
const sanitizeFilename = (filename: string) => {
  return filename.replace(/[^a-zA-Z0-9.-]/g, "_");
};

// Scan for malware (optional - use ClamAV or similar)
const scanFile = async (file: Buffer) => {
  // Implement virus scanning
};
Enter fullscreen mode Exit fullscreen mode

2. Rate Limiting

import rateLimit from "express-rate-limit";

const vendorRegistrationLimiter = rateLimit({
  windowMs: 24 * 60 * 60 * 1000, // 24 hours
  max: 3, // Max 3 registration attempts per day
  message: "Too many registration attempts. Please try again tomorrow.",
});

router.post(
  "/register",
  vendorRegistrationLimiter,
  requireAuth,
  uploadDocuments,
  VendorController.register
);
Enter fullscreen mode Exit fullscreen mode

3. Input Validation

// Always validate and sanitize input
const validateBusinessEmail = (email: string) => {
  // Check if email domain exists
  // Check if email is not disposable
  // Check if email format is valid
};

const validateLicenseNumber = (license: string) => {
  // Validate format based on country/region
  // Check against government database (if API available)
};
Enter fullscreen mode Exit fullscreen mode

4. Document Verification

// Verify document authenticity
const verifyDocument = async (documentUrl: string, type: string) => {
  // Use OCR to extract text
  // Verify against known patterns
  // Check expiry dates
  // Validate signatures (if possible)
};
Enter fullscreen mode Exit fullscreen mode

5. Access Control

// Ensure vendors can only access their own data
const checkVendorOwnership = async (vendorId: string, userId: string) => {
  const vendor = await prisma.vendor.findUnique({
    where: { id: vendorId },
  });

  if (vendor?.userId !== userId) {
    throw new Error("Unauthorized access");
  }
};
Enter fullscreen mode Exit fullscreen mode

Troubleshooting

Common Issues

1. ImageKit Upload Fails

Error: "Failed to upload file to ImageKit"

Solutions:

  • Verify ImageKit credentials in .env
  • Check file size (must be under 10MB)
  • Ensure file format is supported
  • Check ImageKit quota/limits
// Add retry logic
const uploadWithRetry = async (file: any, retries = 3) => {
  for (let i = 0; i < retries; i++) {
    try {
      return await ImageKitService.uploadFile(file, "vendors", "doc");
    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

2. Vendor Status Not Updating

Error: User role not upgraded after approval

Solutions:

  • Check transaction is completing successfully
  • Verify AdminPermission.MANAGE_VENDORS is granted
  • Check database constraints
  • Review activity logs
// Add detailed logging
console.log("Approving vendor:", vendorId);
console.log("Previous status:", vendor.status);
console.log("User role before:", user.role);
// ... after update
console.log("New status:", updatedVendor.status);
console.log("User role after:", updatedUser.role);
Enter fullscreen mode Exit fullscreen mode

3. File Upload Middleware Errors

Error: "Unexpected field" or multer errors

Solutions:

  • Ensure field names match exactly
  • Check maxCount in multer configuration
  • Verify Content-Type is multipart/form-data
// Debug multer
uploadDocuments,
  (req, res, next) => {
    console.log("Files received:", req.files);
    console.log("Body received:", req.body);
    next();
  },
  VendorController.register;
Enter fullscreen mode Exit fullscreen mode

4. Permission Denied Errors

Error: "You don't have permission to perform this action"

Solutions:

  • Verify admin has correct permissions
  • Check AdminProfile.permissions array
  • Ensure requirePermission middleware is working
// Debug permissions
const debugPermissions = async (adminId: string) => {
  const profile = await prisma.adminProfile.findUnique({
    where: { userId: adminId },
  });
  console.log("Admin permissions:", profile?.permissions);
};
Enter fullscreen mode Exit fullscreen mode

FAQ

General Questions

Q: Can a user apply to become a vendor multiple times?
A: No. Once submitted, users must wait for approval/rejection. If rejected, they can resubmit through the resubmit endpoint.

Q: What happens if a vendor is suspended?
A: Suspended vendors cannot add/edit products, but existing data remains intact. Admin can re-approve later.

Q: Can vendors change their business email?
A: No, business email is unique and cannot be changed after registration. Contact admin for special cases.

Q: How long do documents stay in ImageKit?
A: Documents are stored permanently unless manually deleted. Old documents are cleaned up during resubmission.

Technical Questions

Q: How are concurrent vendor approvals handled?
A: Database transactions ensure atomic updates. Race conditions are prevented by transaction isolation.

Q: What if ImageKit is down during registration?
A: Registration will fail. Implement retry logic or queue-based uploads for production.

Q: Can I use AWS S3 instead of ImageKit?
A: Yes, replace ImageKitService with S3Service. Update upload methods accordingly.

Q: How to handle large file uploads?
A: Increase multer limits, use streaming uploads, or implement chunked uploads for files >10MB.

Business Questions

Q: What documents are required for vendor registration?
A: Business Registration, Pharmacy License, and Tax Document (all required). Logo is optional.

Q: Can vendors manage multiple stores?
A: Current implementation supports one vendor profile per user. Extend schema for multi-store support.

Q: How is vendor commission calculated?
A: Commission (default 10%) is set during approval. Calculate on each order: commission_amount = order_total * (commission / 100).


Quick Reference

API Endpoints Summary

Category Endpoint Method Role Permission
Vendor /api/vendor/register POST USER -
Vendor /api/vendor/status GET USER -
Vendor /api/vendor/resubmit PUT USER -
Vendor /api/vendor/profile GET VENDOR -
Vendor /api/vendor/profile PUT VENDOR -
Admin /api/admin/vendors GET ADMIN MANAGE_VENDORS
Admin /api/admin/vendors/:id GET ADMIN MANAGE_VENDORS
Admin /api/admin/vendors/:id/approve PUT ADMIN MANAGE_VENDORS
Admin /api/admin/vendors/:id/reject PUT ADMIN MANAGE_VENDORS
Admin /api/admin/vendors/:id/suspend PUT ADMIN MANAGE_VENDORS
Admin /api/admin/vendors/:id/activity GET ADMIN MANAGE_VENDORS
Product /api/vendor/products POST VENDOR -
Product /api/vendor/products GET VENDOR -
Product /api/vendor/products/:id GET VENDOR -
Product /api/vendor/products/:id PUT VENDOR -
Product /api/vendor/products/:id DELETE VENDOR -
Product /api/vendor/products/upload-image POST VENDOR -
Product /api/vendor/products/:id/inventory POST VENDOR -
Product /api/vendor/products/:id/inventory GET VENDOR -
Admin /api/admin/products/pending GET ADMIN MANAGE_PRODUCTS
Admin /api/admin/products/:id/approve PUT ADMIN MANAGE_PRODUCTS
Admin /api/admin/products/:id/reject PUT ADMIN MANAGE_PRODUCTS
Admin /api/admin/vendors/:vendorId/products GET ADMIN MANAGE_PRODUCTS

Status Codes

Code Meaning When Used
200 OK Successful GET, PUT
201 Created Successful POST (resource created)
400 Bad Request Invalid input, validation failed
401 Unauthorized Missing/invalid authentication
403 Forbidden No permission for action
404 Not Found Resource doesn't exist
500 Internal Server Error Server-side error

Vendor Status Flow

PENDING → APPROVED → (can manage products)
        ↓
        REJECTED → resubmit → PENDING
        ↓
        (stays CUSTOMER role)

APPROVED → SUSPENDED → (by admin)
Enter fullscreen mode Exit fullscreen mode

Conclusion

This complete vendor management system provides:

Production-Ready Code - All controllers, services, routes, and middleware implemented
Secure Document Upload - ImageKit integration with validation
Role-Based Access - Proper authentication and authorization
Admin Approval Workflow - Complete approval/rejection flow
Product Management - Full CRUD operations for products
Inventory Tracking - Batch and expiry date management
Activity Logging - Complete audit trail
Error Handling - Comprehensive error handling with async wrapper
Type Safety - Full TypeScript support with Prisma
Scalable Architecture - Clean separation of concerns

Next Steps

  1. Testing: Write unit and integration tests
  2. Notifications: Implement email/SMS notifications
  3. Analytics: Add vendor performance tracking
  4. Payments: Integrate payout system for vendors
  5. Reviews: Add vendor rating and review system
  6. Multi-Store: Extend for multi-store support
  7. Advanced Search: Add Elasticsearch for product search
  8. Caching: Implement Redis for frequently accessed data

For questions or support, refer to the troubleshooting section or contact the development team.


Document Version: 1.0
Last Updated: November 2, 2025
Maintainer: Backend Development Team

Top comments (0)