Skip to main content

Overview

The Vaniyk Empire API supports three types of digital content: PDFs, videos, and audio files. Content management involves uploading files to Cloudinary, storing metadata in MongoDB, and controlling access based on purchase status.

Content Types

The system supports three distinct content types, each with specific handling:

PDF

Documents and ebooks stored as Cloudinary image resource type

Video

Video content with duration tracking, stored as video resource type

Audio

Audio files with duration tracking, stored as video resource type

Content Model Schema

Every content item in the database follows this structure:
const contentSchema = new mongoose.Schema({
  // Basic Information
  title: {
    type: String,
    required: true
  },
  description: {
    type: String,
    required: true
  },
  type: {
    type: String,
    enum: ['pdf', 'video', 'audio'],
    required: true
  },
  category: {
    type: String,
    required: true
  },
  price: {
    type: Number,
    required: true,
    min: 0
  },
  
  // File Storage
  fileUrl: {
    type: String,
    required: true
  },
  filePublicId: {
    type: String,
    required: true
  },
  thumbnailUrl: {
    type: String
  },
  thumbnailPublicId: {
    type: String
  },
  
  // Metadata
  duration: {
    type: Number  // For video/audio - in seconds
  },
  fileSize: {
    type: Number  // In bytes
  },
  status: {
    type: String,
    enum: ['draft', 'published'],
    default: 'draft'
  },
  tags: [{
    type: String
  }],
  
  // Tracking
  createdBy: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  createdAt: {
    type: Date,
    default: Date.now
  },
  updatedAt: {
    type: Date,
    default: Date.now
  }
});

// Text search index
contentSchema.index({ title: 'text', description: 'text', tags: 'text' });
The text search index on title, description, and tags enables full-text search functionality across content items.

File Upload Flow

Content uploads use Multer with Cloudinary storage to handle file uploads directly to the cloud.

Upload Configuration

The upload system is configured in /workspace/source/src/config/cloudinary.js:12:
const contentStorage = new CloudinaryStorage({
  cloudinary: cloudinary,
  params: async (req, file) => {
    let folder = 'content';
    let resourceType = 'auto';

    // Organize files by type
    if (file.fieldname === 'thumbnail') {
      folder = 'content/thumbnails';
      resourceType = 'image';
    } else if (file.mimetype.startsWith('video/')) {
      folder = 'content/videos';
      resourceType = 'video';
    } else if (file.mimetype.startsWith('audio/')) {
      folder = 'content/audio';
      resourceType = 'video';  // Audio stored as video type
    } else if (file.mimetype === 'application/pdf') {
      folder = 'content/pdfs';
      resourceType = 'image';  // PDFs stored as image type
    }

    return {
      folder: folder,
      resource_type: resourceType,
    };
  }
});

const uploadContent = multer({ 
  storage: contentStorage,
  limits: {
    fileSize: 500 * 1024 * 1024 // 500MB limit
  }
});
The 500MB file size limit is enforced at the application level. Ensure your Cloudinary account supports files of this size.

Cloudinary Folder Structure

Files are automatically organized in Cloudinary:
content/
├── pdfs/           # PDF documents
├── videos/         # Video files
├── audio/          # Audio files
└── thumbnails/     # Content thumbnails

Upload Endpoint

Admins can create content via the POST endpoint with multipart form data:
router.post('/', 
  authenticate,              // Verify user is logged in
  requireAdmin,              // Verify user is admin
  uploadContent.fields([     // Handle file uploads
    { name: 'file', maxCount: 1 },
    { name: 'thumbnail', maxCount: 1 }
  ]),
  contentController.createContent
);

Create Content Logic

The controller handles the complete upload and database creation flow:
exports.createContent = async (req, res) => {
  try {
    const { 
      title, 
      description, 
      type, 
      category, 
      price, 
      status,
      tags 
    } = req.body;

    // Validate file upload
    if (!req.files || !req.files.file) {
      return res.status(400).json({ error: 'Content file is required' });
    }

    const file = req.files.file[0];
    const thumbnail = req.files.thumbnail ? req.files.thumbnail[0] : null;

    // Create database record
    const content = await Content.create({
      title,
      description,
      type,
      category,
      price,
      fileUrl: file.path,              // Cloudinary URL
      filePublicId: file.filename,      // Cloudinary public ID
      thumbnailUrl: thumbnail?.path || null,
      thumbnailPublicId: thumbnail?.filename || null,
      duration: file.duration || null,  // Auto-detected for video/audio
      fileSize: file.bytes,             // File size in bytes
      status: status || 'draft',
      tags: tags ? JSON.parse(tags) : [],
      createdBy: req.mongoUser._id
    });

    await content.populate('createdBy', 'name email');

    res.status(201).json({ 
      message: 'Content created successfully',
      content 
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
};
Cloudinary automatically extracts metadata like duration for video/audio files and bytes for file size. These values are stored in the database for quick access.

Content Lifecycle

Content Status

Content can be in one of two states:
Draft content:
  • Not visible in public listings
  • Cannot be purchased
  • Visible only to admin users
  • Used for content preparation
{ status: 'draft' }

Access Control

The API implements a three-tier access control system:

1. Public Access (Unauthenticated)

Anyone can browse published content metadata:
GET /api/content

Response:
{
  "content": [
    {
      "_id": "...",
      "title": "Advanced Node.js Patterns",
      "description": "...",
      "type": "pdf",
      "category": "programming",
      "price": 29.99,
      "thumbnailUrl": "https://res.cloudinary.com/...",
      "duration": null,
      "fileSize": 5242880,
      "status": "published",
      "tags": ["nodejs", "javascript"],
      "createdBy": { "name": "Admin User" },
      // Note: fileUrl and filePublicId are excluded
    }
  ],
  "totalPages": 5,
  "currentPage": 1,
  "totalContent": 47
}
Public endpoints explicitly exclude fileUrl and filePublicId fields to prevent unauthorized access to content files.

2. Authenticated User Access

Authenticated users who have purchased content can access the file:
GET /api/content/:contentId/access
Authorization: Bearer <token>

// Access control logic
const purchase = await Purchase.findOne({
  user: userId,
  content: contentId,
  status: 'completed'
});

if (!purchase) {
  return res.status(403).json({ 
    error: 'You need to purchase this content to access it' 
  });
}

// Return full content including fileUrl
const content = await Content.findOne({ 
  _id: contentId, 
  status: 'published' 
});

res.json({ content });

3. Admin Access

Admins have full access to all content, including drafts:
GET /api/content/admin/all
Authorization: Bearer <token>

// No purchase check required
// Includes both draft and published content
// Returns all fields including fileUrl

Access Flow Diagram

Updating Content

Admins can update content, including replacing files:
exports.updateContent = async (req, res) => {
  const { contentId } = req.params;
  const updates = req.body;

  const content = await Content.findById(contentId);
  
  if (!content) {
    return res.status(404).json({ error: 'Content not found' });
  }

  // Handle file replacement
  if (req.files?.file) {
    // Delete old file from Cloudinary
    if (content.filePublicId) {
      const resourceType = content.type === 'pdf' ? 'image' : 'video';
      await cloudinary.uploader.destroy(content.filePublicId, {
        resource_type: resourceType
      });
    }

    // Update with new file
    const file = req.files.file[0];
    content.fileUrl = file.path;
    content.filePublicId = file.filename;
    content.duration = file.duration || content.duration;
    content.fileSize = file.bytes;
  }

  // Update metadata
  Object.keys(updates).forEach(key => {
    if (key === 'tags' && typeof updates[key] === 'string') {
      content[key] = JSON.parse(updates[key]);
    } else if (key !== 'file' && key !== 'thumbnail') {
      content[key] = updates[key];
    }
  });

  content.updatedAt = new Date();
  await content.save();
};
When updating content files, the old file is automatically deleted from Cloudinary to prevent orphaned files and excessive storage usage.

Deleting Content

Deleting content removes both database records and Cloudinary files:
exports.deleteContent = async (req, res) => {
  const { contentId } = req.params;
  const content = await Content.findById(contentId);
  
  if (!content) {
    return res.status(404).json({ error: 'Content not found' });
  }

  // Delete main file from Cloudinary
  if (content.filePublicId) {
    const resourceType = content.type === 'pdf' ? 'image' : 'video';
    await cloudinary.uploader.destroy(content.filePublicId, {
      resource_type: resourceType
    });
  }

  // Delete thumbnail from Cloudinary
  if (content.thumbnailPublicId) {
    await cloudinary.uploader.destroy(content.thumbnailPublicId);
  }

  // Delete database record
  await Content.findByIdAndDelete(contentId);

  res.json({ message: 'Content and files deleted successfully' });
};

Search and Filtering

The list endpoint supports powerful filtering:
GET /api/content?
  page=1&
  limit=10&
  category=programming&
  type=pdf&
  minPrice=10&
  maxPrice=50&
  search=nodejs

// Controller logic
const query = { status: 'published' };

if (category) query.category = category;
if (type) query.type = type;

if (minPrice || maxPrice) {
  query.price = {};
  if (minPrice) query.price.$gte = Number(minPrice);
  if (maxPrice) query.price.$lte = Number(maxPrice);
}

if (search) {
  query.$text = { $search: search };
}

const content = await Content.find(query)
  .select('-fileUrl -filePublicId')
  .populate('createdBy', 'name')
  .limit(limit * 1)
  .skip((page - 1) * limit)
  .sort({ createdAt: -1 });
The text search uses MongoDB’s text index on title, description, and tags, providing fast and relevant search results.

User Purchase History

Users can retrieve their purchased content:
GET /api/content/user/purchases?page=1&limit=10
Authorization: Bearer <token>

exports.getUserPurchases = async (req, res) => {
  const userId = req.mongoUser._id;
  const { page = 1, limit = 10 } = req.query;

  const purchases = await Purchase.find({
    user: userId,
    status: 'completed'
  })
  .populate({
    path: 'content',
    select: 'title description type category price thumbnailUrl'
  })
  .limit(limit * 1)
  .skip((page - 1) * limit)
  .sort({ purchasedAt: -1 });

  const count = await Purchase.countDocuments({
    user: userId,
    status: 'completed'
  });

  res.json({
    purchases,
    totalPages: Math.ceil(count / limit),
    currentPage: Number(page),
    totalPurchases: count
  });
};

Best Practices

Use Drafts

Create content as drafts first, review, then publish to avoid exposing incomplete content

Add Thumbnails

Always upload thumbnails for better user experience in content listings

Tag Appropriately

Use descriptive tags to improve searchability and content discovery

Set Correct Types

Ensure content type matches file type for proper Cloudinary handling

Next Steps

Payment Flow

Learn how payments work for content purchases

Content API Reference

Explore the complete Content API documentation

Build docs developers (and LLMs) love