AWS S3 Storage System/Phase 4 of 61-2 hours

Multi-Purpose File Storage

Phase 4: Multi-Purpose Upload API

Overview

Extend storage service to support all entity types: courts, clubs, posts, tournaments, messages. Each entity type has specific requirements (multi-image support, owner validation, etc).

Requirements

Functional:

  • β€’Court images: Multiple images per court, owner-only upload
  • β€’Club media: Avatar + cover, admin-only upload
  • β€’Post images: Single image per post, author-only
  • β€’Tournament banners: Admin-only
  • β€’Message attachments: Sender-only, conversation-scoped

Non-functional:

  • β€’Consistent presign β†’ upload β†’ confirm pattern
  • β€’Authorization checks on confirm step
  • β€’Cascade delete when entity deleted

Architecture

Generic Upload Flow:

POST /api/v1/{entity}/{entityId}/upload/presign
  β†’ StorageService.generateUploadUrl(category, entityId)
POST /api/v1/{entity}/{entityId}/upload/confirm
  β†’ Validate ownership β†’ Update entity β†’ Delete old file

Implementation Steps

1. Create Generic Upload DTOs

src/modules/storage/dto/entity-upload.dto.ts:

import { IsString, IsNumber, Max, Matches, IsOptional } from 'class-validator';

export class EntityUploadPresignDto {
  @IsString()
  fileName: string;

  @IsString()
  @Matches(/^image\/(jpeg|png|webp|gif)$/)
  contentType: string;

  @IsNumber()
  @Max(10 * 1024 * 1024)
  contentLength: number;

  @IsString()
  @IsOptional()
  slot?: string; // 'avatar', 'cover', 'banner', or index for arrays
}

export class EntityUploadConfirmDto {
  @IsString()
  fileKey: string;

  @IsString()
  @IsOptional()
  slot?: string;
}

2. Court Images Upload

Add to src/modules/courts/courts.service.ts:

async getImagePresignUrl(courtId: string, userId: string, dto: EntityUploadPresignDto) {
  await this.validateCourtOwner(courtId, userId);
  
  return this.storageService.generateUploadUrl(
    {
      category: UploadCategory.COURT,
      fileName: dto.fileName,
      contentType: dto.contentType,
      contentLength: dto.contentLength,
      entityId: courtId,
    },
    userId,
  );
}

async confirmImageUpload(courtId: string, userId: string, dto: EntityUploadConfirmDto) {
  await this.validateCourtOwner(courtId, userId);
  
  const court = await this.prisma.court.findUniqueOrThrow({
    where: { id: courtId },
    select: { images: true, imageUrl: true },
  });

  const cdnUrl = this.storageService.getCdnUrl();
  const newImageUrl = `${cdnUrl}/${dto.fileKey}`;

  if (dto.slot === 'primary') {
    // Delete old primary image
    if (court.imageUrl) {
      await this.deleteImageByUrl(court.imageUrl);
    }
    await this.prisma.court.update({
      where: { id: courtId },
      data: { imageUrl: newImageUrl },
    });
  } else {
    // Add to images array
    await this.prisma.court.update({
      where: { id: courtId },
      data: { images: { push: newImageUrl } },
    });
  }

  return { imageUrl: newImageUrl };
}

async deleteImage(courtId: string, userId: string, imageUrl: string) {
  await this.validateCourtOwner(courtId, userId);
  
  const court = await this.prisma.court.findUniqueOrThrow({
    where: { id: courtId },
    select: { images: true, imageUrl: true },
  });

  // Delete from S3
  await this.deleteImageByUrl(imageUrl);

  // Update database
  if (court.imageUrl === imageUrl) {
    await this.prisma.court.update({
      where: { id: courtId },
      data: { imageUrl: null },
    });
  } else {
    await this.prisma.court.update({
      where: { id: courtId },
      data: { images: court.images.filter((img) => img !== imageUrl) },
    });
  }
}

private async deleteImageByUrl(url: string) {
  const key = this.extractKeyFromUrl(url);
  if (key) {
    await this.storageService.deleteFile(key).catch(() => {});
  }
}

private extractKeyFromUrl(url: string): string | null {
  try {
    return new URL(url).pathname.slice(1);
  } catch {
    return null;
  }
}

private async validateCourtOwner(courtId: string, userId: string) {
  const court = await this.prisma.court.findUnique({
    where: { id: courtId },
    select: { ownerId: true },
  });
  if (!court || court.ownerId !== userId) {
    throw new ForbiddenException('Not court owner');
  }
}

3. Court Upload Controller Endpoints

Add to src/modules/courts/courts.controller.ts:

@Post(':id/images/presign')
@ApiOperation({ summary: 'Get presigned URL for court image upload' })
async getImagePresignUrl(
  @Param('id') courtId: string,
  @GetUser('sub') userId: string,
  @Body() dto: EntityUploadPresignDto,
) {
  return this.courtsService.getImagePresignUrl(courtId, userId, dto);
}

@Post(':id/images/confirm')
@ApiOperation({ summary: 'Confirm court image upload' })
async confirmImageUpload(
  @Param('id') courtId: string,
  @GetUser('sub') userId: string,
  @Body() dto: EntityUploadConfirmDto,
) {
  return this.courtsService.confirmImageUpload(courtId, userId, dto);
}

@Delete(':id/images')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete court image' })
async deleteImage(
  @Param('id') courtId: string,
  @GetUser('sub') userId: string,
  @Query('url') imageUrl: string,
) {
  return this.courtsService.deleteImage(courtId, userId, imageUrl);
}

4. Club Media Upload

Add similar methods to src/modules/clubs/clubs.service.ts:

async getMediaPresignUrl(clubId: string, userId: string, dto: EntityUploadPresignDto) {
  await this.validateClubAdmin(clubId, userId);
  
  return this.storageService.generateUploadUrl(
    {
      category: UploadCategory.CLUB,
      fileName: dto.fileName,
      contentType: dto.contentType,
      contentLength: dto.contentLength,
      entityId: clubId,
    },
    userId,
  );
}

async confirmMediaUpload(clubId: string, userId: string, dto: EntityUploadConfirmDto) {
  await this.validateClubAdmin(clubId, userId);
  
  const club = await this.prisma.club.findUniqueOrThrow({
    where: { id: clubId },
    select: { avatarUrl: true, coverUrl: true },
  });

  const cdnUrl = this.storageService.getCdnUrl();
  const newUrl = `${cdnUrl}/${dto.fileKey}`;

  const field = dto.slot === 'cover' ? 'coverUrl' : 'avatarUrl';
  const oldUrl = dto.slot === 'cover' ? club.coverUrl : club.avatarUrl;

  // Delete old image
  if (oldUrl) {
    await this.deleteImageByUrl(oldUrl);
  }

  await this.prisma.club.update({
    where: { id: clubId },
    data: { [field]: newUrl },
  });

  return { [field]: newUrl };
}

5. Post Image Upload

Add to posts module (create if needed):

async getImagePresignUrl(userId: string, dto: EntityUploadPresignDto) {
  // Generate temp key, will be moved to post on confirm
  return this.storageService.generateUploadUrl(
    {
      category: UploadCategory.POST,
      fileName: dto.fileName,
      contentType: dto.contentType,
      contentLength: dto.contentLength,
      entityId: userId, // Temp: use userId until post created
    },
    userId,
  );
}

async createPostWithImage(userId: string, content: string, fileKey?: string) {
  const imageUrl = fileKey ? `${this.storageService.getCdnUrl()}/${fileKey}` : null;
  
  return this.prisma.post.create({
    data: {
      userId,
      content,
      imageUrl,
    },
  });
}

6. Cascade Delete on Entity Deletion

Add cleanup hooks to services. Example for courts:

async deleteCourt(courtId: string, userId: string) {
  const court = await this.prisma.court.findUniqueOrThrow({
    where: { id: courtId },
    select: { ownerId: true, imageUrl: true, images: true },
  });

  if (court.ownerId !== userId) {
    throw new ForbiddenException('Not court owner');
  }

  // Delete all images
  const allImages = [court.imageUrl, ...court.images].filter(Boolean);
  await this.storageService.deleteFiles(
    allImages.map((url) => this.extractKeyFromUrl(url)).filter(Boolean),
  );

  await this.prisma.court.delete({ where: { id: courtId } });
}

Related Code Files

  • β€’Create: src/modules/storage/dto/entity-upload.dto.ts
  • β€’Modify: src/modules/courts/courts.service.ts
  • β€’Modify: src/modules/courts/courts.controller.ts
  • β€’Modify: src/modules/courts/courts.module.ts
  • β€’Modify: src/modules/clubs/clubs.service.ts (if exists)
  • β€’Modify: src/modules/clubs/clubs.controller.ts (if exists)

Success Criteria

  • β€’ Court image upload/confirm/delete working
  • β€’ Club avatar/cover upload working
  • β€’ Authorization validated on all operations
  • β€’ Cascade delete removes S3 files
  • β€’ Swagger docs updated for all endpoints

Risk Assessment

RiskMitigation
Orphan files on failed confirmS3 lifecycle policy on temp prefix
Authorization bypassValidate ownership before every mutation
Array append race conditionUse Prisma's atomic push operation