Service logic

Implementing the actual logic for managing the products' images.

Before entering the ProductsService, we should add the FilesModule to the imports of the ProductsModule. This way, the StorageService will be available for injection in its context.

Now, let's actually implement these methods, starting with uploadImages(). Let's begin by simply searching for the entity, but not doing anything with it. This is done just to check its existence.

await this.findOne(id);

We'll use certain path fragments many times, making it useful to have them as constants. So, let's create them in file.constants.

export const FilePath = {
  Products: {
    BASE: 'products',
    IMAGES: 'images',
  },
} as const satisfies Record<string, Record<string, string>>;

After that, back in the service, let's create the path where the images will be saved.

const { BASE, IMAGES } = FilePath.Products;
const path = join(BASE, id.toString(), IMAGES);

Then, as we can save many images, let's ensure that the amount of images that arrived, combined with the amount already present in the directory, don't exceed the limit. However, we should first verify that the folder actually exists, lest an error will be thrown.

if (await pathExists(join(BASE_PATH, path))) {
  const incomingFilecount = files.length;
  const dirFilecount = await this.storageService.getDirFilecount(path);
  const totalFilecount = incomingFilecount + dirFilecount;
  
  this.storageService.validateFilecount(totalFilecount, MaxFileCount.PRODUCT_IMAGES);
}

We checked everything and have the path in hand. Let's then create the directory where the files will be stored, in case it does not exist yet.

await this.storageService.createDir(path);

Finally, let's save the images in parallel using Promise.all().

await Promise.all(
  files.map((file) => this.storageService.saveFile(path, file)),
);

Here, map() is used instead of forEach() due to the latter's unstable behavior with promises.

Now, the downloadImage() method. The step of searching for the entity is identical. However, the path also includes the name of the file to be downloaded.

const path = join(BASE, id.toString(), IMAGES, filename);

Then, it is checked if the file actually exists.

await this.storageService.validatePath(path);

Finally, the file is returned.

return this.storageService.getFile(path);

The last method is deleteImage(), which is practically identical to downloadImage(). The only difference is the last line, where the file is deleted.

await this.storageService.delete(path);

One final improvement: let's make that, when removing a product, its file-related folder is too. First, create an auxiliary method that performs this exclusion.

private async deleteBaseDir(id: number) {
  const { BASE } = FilePath.Products;

  const path = join(BASE, id.toString());
  await this.storageService.delete(path);
}

Then, invoke it in the remove() method. However, so that both can happen in atomicity, we should wrap this in a transaction.

remove(id: number) {
  return this.dataSource.transaction(async (manager) => {
    const productsRepository = manager.getRepository(Product);

    const product = await productsRepository.findOneByOrFail({ id });
    await productsRepository.remove(product);

    await this.deleteBaseDir(id);

    return product;
  });
}

Ensuring that both database and file system operations are in sync can be challenging, but as the nature of our operations is very simple (database operation followed by file system operation), a transaction is enough.

We have finished implementing the file-related methods in the service.

Commit - Implementing file related methods in service

Last updated