Skip to content

File Uploads

File upload system with local storage support and future extensibility for cloud storage (S3, Azure, etc.).

Overview

  • Local file storage implementation
  • Upload tracking in database
  • Multiple upload types (avatars, service banners, documents, chat files)
  • Tag-based organization
  • Automatic file serving
  • Future cloud storage support

Storage Interface

export abstract class AbstractStorage {
  abstract upload(
    file: File,
    objectType: UploadObjectType,
    objectId: string,
    tag: UploadTag,
    order: number
  ): Promise<{ id: string; path: string }>;

  abstract delete(uploadId: string): Promise<void>;
  abstract getUrl(path: string): string;
}

Upload Types

export enum UploadObjectType {
  USER = 'user',
  SERVICE = 'service',
  ORDER = 'order',
  IDENTITY = 'identity',
  CHAT = 'chat'
}

export enum UploadTag {
  AVATAR = 'avatar',
  SERVICE_BANNER = 'service_banner',
  SERVICE_GALLERY = 'service_gallery',
  ID_DOCUMENT = 'id_document',
  BUSINESS_DOCUMENT = 'business_document',
  ORDER_DELIVERY = 'order_delivery',
  CHAT_ATTACHMENT = 'chat_attachment'
}

Local Storage Implementation

export class LocalStorage extends AbstractStorage {
  private basePath: string = './storage/uploads';

  async upload(
    file: File,
    objectType: UploadObjectType,
    objectId: string,
    tag: UploadTag,
    order: number = 0
  ): Promise<{ id: string; path: string }> {
    // Generate unique filename
    const ext = path.extname(file.filename);
    const filename = `${generateId()}${ext}`;

    // Organize by type and object
    const relativePath = `${objectType}/${objectId}/${tag}/${filename}`;
    const fullPath = path.join(this.basePath, relativePath);

    // Create directory if not exists
    await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });

    // Save file
    await fs.promises.writeFile(fullPath, file.buffer);

    // Create upload record
    const upload = await this.uploadRepository.create({
      id: generateId(),
      objectType,
      objectId,
      tag,
      path: relativePath,
      filename: file.filename,
      mimetype: file.mimetype,
      size: file.size,
      order
    });

    return { id: upload.id, path: relativePath };
  }

  async delete(uploadId: string): Promise<void> {
    const upload = await this.uploadRepository.get(uploadId);
    const fullPath = path.join(this.basePath, upload.path);

    await fs.promises.unlink(fullPath);
    await this.uploadRepository.delete(uploadId);
  }

  getUrl(path: string): string {
    return `${process.env.API_URL}/uploads/${path}`;
  }
}

Storage Service

Convenience wrapper:

export class StorageService {
  constructor(
    private storage: AbstractStorage,
    private uploadRepository: UploadRepository
  ) {}

  async upload(
    file: File,
    objectType: UploadObjectType,
    objectId: string,
    tag: UploadTag,
    order: number = 0
  ) {
    return this.storage.upload(file, objectType, objectId, tag, order);
  }

  async attach(
    uploadId: string,
    objectType: UploadObjectType,
    objectId: string,
    tag: UploadTag,
    order: number = 0
  ) {
    // Move uploaded file to proper location
    const upload = await this.uploadRepository.get(uploadId);
    await this.uploadRepository.update(uploadId, {
      objectType,
      objectId,
      tag,
      order
    });
    return upload;
  }

  async attachOrReplace(
    uploadId: string,
    objectType: UploadObjectType,
    objectId: string,
    tag: UploadTag
  ) {
    // Delete existing upload with same tag
    const existing = await this.uploadRepository.findOne({
      objectType,
      objectId,
      tag
    });
    if (existing) {
      await this.storage.delete(existing.id);
    }

    // Attach new upload
    return this.attach(uploadId, objectType, objectId, tag, 0);
  }

  async getUrl(uploadId: string): Promise<string> {
    const upload = await this.uploadRepository.get(uploadId);
    return this.storage.getUrl(upload.path);
  }
}

Upload Flow

1. Upload File

POST /uploads
Authorization: Bearer <access_token>
Content-Type: multipart/form-data

file: [binary data]

Response:

{
  "id": "upload-123",
  "filename": "image.jpg",
  "size": 245678,
  "mimetype": "image/jpeg"
}

2. Attach to Object

When creating/updating an entity:

POST /users/me
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "fullName": "John Doe",
  "avatar": "upload-123"
}

The service will call:

await storageService.attachOrReplace(
  data.avatar,
  UploadObjectType.USER,
  user.id,
  UploadTag.AVATAR
);

3. Serve Files

GET /uploads/user/user-123/avatar/abc123.jpg

Static file serving configured in NestJS:

app.useStaticAssets(join(__dirname, '..', 'storage/uploads'), {
  prefix: '/uploads'
});

File Validation

export class FileUploadValidator {
  static validate(file: File, options: {
    maxSize?: number;
    allowedMimeTypes?: string[];
  }) {
    // Check size
    if (options.maxSize && file.size > options.maxSize) {
      throw new ValidationError({
        file: [`File too large. Maximum ${options.maxSize} bytes`]
      });
    }

    // Check mimetype
    if (options.allowedMimeTypes &&
        !options.allowedMimeTypes.includes(file.mimetype)) {
      throw new ValidationError({
        file: [`Invalid file type. Allowed: ${options.allowedMimeTypes.join(', ')}`]
      });
    }
  }
}

Upload Constraints

  • Avatars: Max 5MB, images only (jpg, png)
  • Service banners: Max 10MB, images only
  • ID documents: Max 10MB, images/PDFs
  • Chat attachments: Max 10MB, all types

Best Practices

✅ Do

  • Validate file size and type
  • Generate unique filenames
  • Organize files by type and object
  • Track uploads in database
  • Serve files through web server
  • Implement file cleanup for orphaned uploads

❌ Don't

  • Don't trust user-provided filenames
  • Don't skip virus scanning (in production)
  • Don't expose full file paths
  • Don't allow unlimited upload sizes
  • Don't store sensitive files without encryption

Cloud Storage Migration

To migrate to S3/Azure/Google Cloud:

export class S3Storage extends AbstractStorage {
  private s3Client: S3Client;

  async upload(...) {
    // Upload to S3
    await this.s3Client.putObject({
      Bucket: process.env.S3_BUCKET,
      Key: relativePath,
      Body: file.buffer,
      ContentType: file.mimetype
    });

    // Save upload record
    return { id: upload.id, path: relativePath };
  }

  getUrl(path: string): string {
    // Return S3 URL or CloudFront URL
    return `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${path}`;
  }
}

Update dependency injection:

providers: [
  {
    provide: AbstractStorage,
    useClass: process.env.STORAGE_TYPE === 's3' ? S3Storage : LocalStorage
  }
]

Summary

The file upload system provides: - Local storage implementation - Upload tracking in database - Type-based organization - Static file serving - Easy cloud migration path - File validation built-in