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
- Overview
- How Vendor Registration Works
- Vendor Statuses & Workflow
- Architecture & Workflow
- API Endpoints - Vendor Management
- API Endpoints - Product Management
- Database Models
- Implementation Guide
- ImageKit Integration
- Security Best Practices
- Testing & Deployment
- Troubleshooting
- FAQ
- 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)
- 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
- Track Application Status
GET /api/vendor/status
- Check if pending, approved, or rejected
- See rejection reason if rejected
- Resubmit if Rejected
PUT /api/vendor/resubmit
- Upload corrected documents
- Update any incorrect information
- Status returns to PENDING
- Start Selling (after approval)
- Your role is automatically upgraded to VENDOR
- Access vendor dashboard
- Add products, manage inventory
For Admins
- Review Applications
GET /api/admin/vendors?status=PENDING
- View all pending applications
- Review documents and business details
- 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
- Manage Vendors
PUT /api/admin/vendors/:id/suspend
- Suspend problematic vendors
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
CUSTOMERuntil approved - Admin receives notification
- User can track application status using
/api/vendor/statusendpoint
Step 2: Admin Reviews Application
- Admin with
MANAGE_VENDORSpermission 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
CUSTOMERtoVENDOR -
verifiedAttimestamp recorded - Vendor can now start adding products
- Email/SMS notification sent
- Vendor status changes to
-
If Rejected:
- Vendor status changes to
REJECTED - User role remains
CUSTOMER - Rejection reason provided to user
- User notified to fix issues and resubmit
- Vendor status changes to
Step 4: Resubmission (if rejected)
- User checks rejection reason via
/api/vendor/statusendpoint - User can resubmit via
/api/vendor/resubmitendpoint - 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
REJECTEDtoPENDING - 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
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) │
└─────────────────┘
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 │
└─────────────┘ └──────────────┘
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
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)
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)
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"
}
}
What Happens:
- User submits complete vendor application with all documents
- All documents uploaded to ImageKit in one request
- Vendor profile created with status
PENDING - User role remains
CUSTOMER - Admin notification sent for review
- 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."
}
}
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"
}
}
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
}
}
Response (200 OK) - No Application:
{
"ok": true,
"hasVendorApplication": false,
"message": "You haven't submitted a vendor application yet."
}
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"
}
}
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"
}
Response (200 OK):
{
"ok": true,
"message": "Vendor profile updated successfully",
"vendor": {
"id": "cm3xyz789ghi012",
"businessName": "HealthCare Pharmacy",
"businessPhone": "9876543211",
"description": "Updated description"
}
}
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>
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"
}
}
What Happens:
- User updates any rejected/incorrect information
- New documents uploaded to ImageKit (old ones cleaned up)
- Vendor status changes from
REJECTEDtoPENDING - User role remains
CUSTOMER - Admin notified for re-review
- 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
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"
}
}
]
}
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"
}
}
8. Admin - Approve Vendor
Endpoint: PUT /api/admin/vendors/:vendorId/approve
Authentication: Bearer token (Admin with MANAGE_VENDORS permission)
Request:
{
"commission": 10.0
}
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"
}
}
What Happens:
- Vendor status changes from
PENDINGtoAPPROVED - User role upgraded from
CUSTOMERtoVENDOR -
verifiedAttimestamp recorded - Activity log entry created
- User can now access vendor dashboard and manage products
- 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."
}
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."
}
}
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"
}
Response (200 OK):
{
"ok": true,
"message": "Vendor suspended successfully",
"vendor": {
"id": "cm3xyz789ghi012",
"status": "SUSPENDED",
"suspensionReason": "Multiple customer complaints about product quality"
}
}
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"
}
]
}
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"
}
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"
}
}
13. Vendor - Get Own Products
Endpoint: GET /api/vendor/products
Authentication: Bearer token (VENDOR role required)
Query Parameters:
?status=ACTIVE&limit=50&offset=0
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"
}
]
}
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"
}
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"
}
}
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"
}
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>
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"
}
}
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
}
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"
}
}
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"
}
]
}
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"
}
]
}
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"
}
}
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."
}
Response (200 OK):
{
"ok": true,
"message": "Product rejected",
"product": {
"id": "prod123",
"status": "INACTIVE",
"rejectionReason": "Product images are unclear. Please provide high-quality images."
}
}
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
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"
}
]
}
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])
}
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])
}
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])
}
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
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;
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),
});
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),
});
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;
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;
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;
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;
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;
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;
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;
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 {};
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;
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
},
};
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
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])
}
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
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"
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
};
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
);
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)
};
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)
};
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");
}
};
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)));
}
}
};
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);
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;
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);
};
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)
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
- Testing: Write unit and integration tests
- Notifications: Implement email/SMS notifications
- Analytics: Add vendor performance tracking
- Payments: Integrate payout system for vendors
- Reviews: Add vendor rating and review system
- Multi-Store: Extend for multi-store support
- Advanced Search: Add Elasticsearch for product search
- 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)