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
| Risk | Mitigation |
|---|---|
| Orphan files on failed confirm | S3 lifecycle policy on temp prefix |
| Authorization bypass | Validate ownership before every mutation |
| Array append race condition | Use Prisma's atomic push operation |