Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5,258 changes: 3,449 additions & 1,809 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"docker:down": "docker compose down"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1029.0",
"@google/generative-ai": "^0.24.1",
"@keyv/redis": "^5.1.6",
"@nestjs/cache-manager": "^3.1.0",
Expand All @@ -56,6 +57,7 @@
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.0",
"@nestjs/websockets": "^11.1.15",
"@types/multer": "^2.1.0",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
Expand Down
5 changes: 5 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MulterModule } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './modules/users/users.module';
Expand All @@ -17,6 +19,7 @@ import { RedisModule } from './modules/redis/redis.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { SchedulerModule } from './modules/scheduler/scheduler.module';
import { S3Module } from './modules/s3/s3.module';

@Module({
imports: [
Expand All @@ -27,7 +30,9 @@ import { SchedulerModule } from './modules/scheduler/scheduler.module';
}),
TypeOrmModule.forRootAsync(databaseConfig),
ThrottlerModule.forRootAsync(throttlerConfig),
MulterModule.register({ storage: memoryStorage() }),
EventEmitterModule.forRoot(),
S3Module,
RedisModule,
LoggerModule,
AuthModule,
Expand Down
5 changes: 5 additions & 0 deletions src/common/constants/swagger-docs/companies.swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export const CompaniesSwagger: Record<string, Partial<OperationObject>> = {
summary: 'Update company',
description: 'Updates company name, description, or avatar. Requires company OWNER or company ADMIN role.',
},
UPDATE_AVATAR: {
summary: 'Update company avatar',
description:
'Uploads a new company avatar image (JPEG, PNG, WebP, max 5MB). Requires company OWNER or ADMIN role. Replaces the old one on S3 automatically.',
},
REMOVE: {
summary: 'Delete company',
description: 'Permanently deletes the company and all its data. Requires company OWNER role.',
Expand Down
4 changes: 4 additions & 0 deletions src/common/constants/swagger-docs/users.swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export const UsersSwagger: Record<string, Partial<OperationObject>> = {
summary: 'Update my profile',
description: "Updates the current user's name or password.",
},
UPDATE_AVATAR: {
summary: 'Update my avatar',
description: 'Uploads a new avatar image (JPEG, PNG, WebP, max 5MB). Replaces the old one on S3 automatically.',
},
REMOVE_ME: {
summary: 'Delete my account',
description: "Permanently deletes the authenticated user's account.",
Expand Down
27 changes: 27 additions & 0 deletions src/common/pipes/file-upload.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';

const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB

@Injectable()
export class FileUploadPipe implements PipeTransform {
transform(file: Express.Multer.File | undefined): Express.Multer.File {
if (!file) {
throw new BadRequestException('File is required');
}

if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
throw new BadRequestException(
`Invalid file type: ${file.mimetype}. Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}`,
);
}

if (file.size > MAX_FILE_SIZE_BYTES) {
throw new BadRequestException(
`File is too large (${(file.size / 1024 / 1024).toFixed(2)}MB). Maximum allowed size is 5MB`,
);
}

return file;
}
}
14 changes: 14 additions & 0 deletions src/config/s3.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ConfigService } from '@nestjs/config';

export const getS3Config = (configService: ConfigService) => {
const region = configService.getOrThrow<string>('AWS_REGION');
const bucketName = configService.getOrThrow<string>('AWS_S3_BUCKET');

return {
region,
accessKeyId: configService.getOrThrow<string>('AWS_ACCESS_KEY_ID'),
secretAccessKey: configService.getOrThrow<string>('AWS_SECRET_ACCESS_KEY'),
bucketName,
publicUrl: `https://${bucketName}.s3.${region}.amazonaws.com`,
};
};
9 changes: 9 additions & 0 deletions src/modules/companies/__tests__/companies.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// companies.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { CompaniesController } from '../controllers/companies.controller';
import { CompaniesService } from '../services/companies.service';
import { CompanyMembersService } from '../services/company-members.service';
import { CompanyRepository } from '../repositories/company.repository';
import { DataSource } from 'typeorm';
import { S3Service } from '@/modules/s3/s3.service';

describe('CompaniesController', () => {
let controller: CompaniesController;
Expand Down Expand Up @@ -37,6 +39,13 @@ describe('CompaniesController', () => {
transaction: jest.fn(),
},
},
{
provide: S3Service,
useValue: {
uploadFile: jest.fn(),
deleteFile: jest.fn(),
},
},
],
}).compile();

Expand Down
9 changes: 9 additions & 0 deletions src/modules/companies/__tests__/companies.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// companies.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { CompanyRepository } from '../repositories/company.repository';
import { Company } from '../entities/company.entity';
import { CompanyMembersService } from '../services/company-members.service';
import { CompaniesService } from '../services/companies.service';
import { S3Service } from '@/modules/s3/s3.service';

// === Factory functions ===

Expand Down Expand Up @@ -56,6 +58,13 @@ describe('CompaniesService', () => {
transaction: jest.fn(),
},
},
{
provide: S3Service,
useValue: {
uploadFile: jest.fn(),
deleteFile: jest.fn(),
},
},
],
}).compile();

Expand Down
18 changes: 18 additions & 0 deletions src/modules/companies/controllers/companies.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import {
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiEndpoint } from '@/common/decorators/api-endpoint.decorator';
import { ResponseMessage } from '@/common/decorators/response-message.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
Expand All @@ -29,6 +32,7 @@ import { toCompanyMemberResponseDto } from '../mappers/company/company-member.ma
import { CompanyMembersListResponseDto } from '../dto/res/company/company-member.res.dto';
import { CreateCompanyDto } from '../dto/req/company/create-company.req.dto';
import { UpdateCompanyDto } from '../dto/req/company/update-company.req.dto';
import { FileUploadPipe } from '@/common/pipes/file-upload.pipe';
import type { JwtPayload } from '@/common/interfaces/jwt-payload.interface';

@Controller(CompaniesRoutes.ROOT)
Expand Down Expand Up @@ -99,6 +103,20 @@ export class CompaniesController {
};
}

@Patch(CompaniesRoutes.AVATAR)
@UseGuards(CompanyRoleGuard)
@RequireCompanyRoles(CompanyRole.OWNER, CompanyRole.ADMIN)
@UseInterceptors(FileInterceptor('avatar'))
@ApiEndpoint(CompanyResponseDto, HttpStatus.OK, CompaniesSwagger.UPDATE_AVATAR)
@ResponseMessage('Company avatar updated successfully')
async updateAvatar(
@Param('slug') slug: string,
@UploadedFile(new FileUploadPipe()) file: Express.Multer.File,
): Promise<{ company: CompanyResponseDto }> {
const company = await this.companiesService.updateAvatar(slug, file);
return { company: toCompanyResponseDto(company) };
}

@Patch(CompaniesRoutes.BY_SLUG)
@UseGuards(CompanyRoleGuard)
@RequireCompanyRoles(CompanyRole.OWNER, CompanyRole.ADMIN)
Expand Down
1 change: 1 addition & 0 deletions src/modules/companies/routes/companies.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const CompaniesRoutes = {
ROOT: 'companies',
BY_SLUG: ':slug',
AVATAR: ':slug/avatar',

INVITATIONS: {
ME: 'invitations/me',
Expand Down
24 changes: 24 additions & 0 deletions src/modules/companies/services/companies.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import { UpdateCompanyDto } from '../dto/req/company/update-company.req.dto';
import { CompanyRepository } from '../repositories/company.repository';
import { CompanyMembersService } from './company-members.service';
import { GuardAssertions } from '@/common/helpers/assert.helper';
import { S3Service } from '@/modules/s3/s3.service';

@Injectable()
export class CompaniesService {
constructor(
private readonly companyRepository: CompanyRepository,
private readonly companyMembersService: CompanyMembersService,
private readonly dataSource: DataSource,
private readonly s3Service: S3Service,
) {}

async create(dto: CreateCompanyDto, userId: string): Promise<Company> {
Expand Down Expand Up @@ -71,9 +73,31 @@ export class CompaniesService {
return this.companyRepository.save(company);
}

async updateAvatar(slug: string, file: Express.Multer.File): Promise<Company> {
const company = await this.companyRepository.findBySlugRaw(slug);
GuardAssertions.exists(company, 'Company not found');

const oldUrl = company.avatarUrl;

company.avatarUrl = await this.s3Service.uploadFile(file, 'companies');

const updated = await this.companyRepository.save(company);

if (oldUrl) {
await this.s3Service.deleteFile(oldUrl);
}

return updated;
}

async removeBySlug(slug: string): Promise<void> {
const company = await this.companyRepository.findBySlugRaw(slug);
GuardAssertions.exists(company, 'Company not found');

if (company.avatarUrl) {
await this.s3Service.deleteFile(company.avatarUrl);
}

await this.companyRepository.remove(company);
}
}
9 changes: 9 additions & 0 deletions src/modules/s3/s3.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { S3Service } from './s3.service';

@Global()
@Module({
providers: [S3Service],
exports: [S3Service],
})
export class S3Module {}
98 changes: 98 additions & 0 deletions src/modules/s3/s3.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { getS3Config } from '@/config/s3.config';
import { AppLogger } from '@/common/logger/app-logger';

export type S3Folder = 'users' | 'companies';

@Injectable()
export class S3Service {
private readonly client: S3Client;
private readonly bucket: string;
private readonly publicUrl: string;

constructor(
configService: ConfigService,
private readonly logger: AppLogger,
) {
this.logger.setLoggerContext(S3Service.name);

const config = getS3Config(configService);

this.bucket = config.bucketName;
this.publicUrl = config.publicUrl;

this.client = new S3Client({
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
}

async uploadFile(file: Express.Multer.File, folder: S3Folder): Promise<string> {
const key = this.generateUniqueKey(file, folder);

try {
await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
ContentLength: file.size,
}),
);

const url = this.getPublicUrl(key);
this.logger.log({ url, folder, size: file.size }, 'File uploaded to S3');
return url;
} catch (error) {
this.logger.logError('Failed to upload file to S3', error);
throw new InternalServerErrorException('Failed to upload file');
}
}

async deleteFile(url: string): Promise<void> {
try {
const key = this.extractKeyFromUrl(url);

await this.client.send(
new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
}),
);

this.logger.log({ key }, 'File deleted from S3');
} catch (error) {
this.logger.logError('Failed to delete file from S3', error);
}
}

private getPublicUrl(key: string): string {
return `${this.publicUrl}/${key}`;
}

private extractKeyFromUrl(url: string): string {
return url.replace(`${this.publicUrl}/`, '');
}

private generateUniqueKey(file: Express.Multer.File, folder: S3Folder): string {
const ext = this.resolveExtension(file);
return `${folder}/${randomUUID()}${ext}`;
}

private resolveExtension(file: Express.Multer.File): string {
const mimeToExt: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
};

return mimeToExt[file.mimetype] ?? '.jpg';
}
}
1 change: 1 addition & 0 deletions src/modules/users/__tests__/users.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('UsersController', () => {
provide: UsersService,
useValue: {
create: jest.fn(),
updateAvatar: jest.fn(),
},
},
],
Expand Down
Loading
Loading