diff --git a/src/database/entities/jobs.entity.ts b/src/database/entities/jobs.entity.ts index 06828d7b..0aac1cc4 100644 --- a/src/database/entities/jobs.entity.ts +++ b/src/database/entities/jobs.entity.ts @@ -12,6 +12,7 @@ import { JobsAffirmativeTypeEnum } from '../../modules/jobs/enums/job-affirmativ import { JobsTypeContractEnum } from '../../modules/jobs/enums/job-contract-type.enum'; import { JobsModalityEnum } from '../../modules/jobs/enums/job-modality.enum'; import { JobsTypeEnum } from '../../modules/jobs/enums/job-type.enum'; +import { JobStatus } from '../../modules/jobs/enums/job-status.enum'; import { ApplicationEntity } from './applications.entity'; import { CommentsEntity } from './comments.entity'; import { CompaniesEntity } from './companies.entity'; @@ -27,10 +28,10 @@ export class JobsEntity { @Column() title: string; - @Column() + @Column({ nullable: true }) description: string; - @Column() + @Column({ nullable: true }) prerequisites: string; @Column({ nullable: true }) @@ -44,7 +45,7 @@ export class JobsEntity { JobsTypeEnum.TRAINEE, JobsTypeEnum.INTERNSHIP, ], - default: JobsTypeEnum.JUNIOR, + nullable: true, }) type: string; @@ -55,7 +56,6 @@ export class JobsEntity { JobsTypeContractEnum.PJ, JobsTypeContractEnum.OTHER, ], - default: JobsTypeContractEnum.CLT, nullable: true, }) typeContract: string; @@ -83,18 +83,16 @@ export class JobsEntity { @Column({ nullable: true }) city: string; - @Column({ - default: true, - }) + @Column({ default: true, nullable: true }) openEndedContract: boolean; @Column({ nullable: true }) contractType: string; @Column({ nullable: true }) - contractText?: string; + contractText: string; - @Column({ default: true }) + @Column({ default: true, nullable: true }) affirmative: boolean; @Column({ @@ -121,6 +119,19 @@ export class JobsEntity { @Column({ nullable: false, default: StatusEnum.ACTIVE }) status: StatusEnum; + @Column({ + type: 'enum', + enum: JobStatus, + default: JobStatus.DRAFT, + }) + jobStatus: JobStatus; + + @Column({ nullable: true }) + publishedAt: Date; + + @Column({ nullable: true }) + canceledAt: Date; + @OneToMany(() => CommentsEntity, (comment) => comment.job, { cascade: true, }) diff --git a/src/database/migrations/1765483583757-AddJobStatusFields.ts b/src/database/migrations/1765483583757-AddJobStatusFields.ts new file mode 100644 index 00000000..81b72902 --- /dev/null +++ b/src/database/migrations/1765483583757-AddJobStatusFields.ts @@ -0,0 +1,80 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddJobStatusFields1765483583757 implements MigrationInterface { + name = 'AddJobStatusFields1765483583757' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tb_alerts" DROP CONSTRAINT "FK_a60dd357707a39803ce4cfbd90b"`); + await queryRunner.query(`CREATE TABLE "tb_saved_jobs" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "savedAt" TIMESTAMP NOT NULL DEFAULT now(), "expiresAt" TIMESTAMP NOT NULL, "userId" uuid NOT NULL, "jobId" uuid, CONSTRAINT "PK_b69821433d55c7266698911ead6" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_3845b06bfda63ccc1da359b378" ON "tb_saved_jobs" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_9bbd9a1f3bb4942f0471816b11" ON "tb_saved_jobs" ("jobId") `); + await queryRunner.query(`CREATE TYPE "public"."tb_jobs_jobstatus_enum" AS ENUM('DRAFT', 'PUBLISHED', 'ARCHIVED', 'CANCELED')`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ADD "jobStatus" "public"."tb_jobs_jobstatus_enum" NOT NULL DEFAULT 'DRAFT'`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ADD "publishedAt" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ADD "canceledAt" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "description" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "prerequisites" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "type" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "type" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "typeContract" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "openEndedContract" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "affirmative" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "tb_candidacies" DROP COLUMN "date_candidacy"`); + await queryRunner.query(`ALTER TABLE "tb_candidacies" ADD "date_candidacy" date NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "tb_certifications" ADD CONSTRAINT "FK_75f15015611ef7f444ae3fade23" FOREIGN KEY ("personal_data_id") REFERENCES "tb_personal_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_courses" ADD CONSTRAINT "FK_beb3c749f9d5000af64c4442989" FOREIGN KEY ("personal_data_id") REFERENCES "tb_personal_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_languages" ADD CONSTRAINT "FK_ecd946bdf16c31509a966a5002f" FOREIGN KEY ("personal_data_id") REFERENCES "tb_personal_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_work_experiences" ADD CONSTRAINT "FK_e668e3b05cc240131e27a41e5b5" FOREIGN KEY ("personal_data_id") REFERENCES "tb_personal_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_curriculum" ADD CONSTRAINT "FK_2c53580e4d1f616f6ffee74ba51" FOREIGN KEY ("user_id") REFERENCES "tb_users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_comments" ADD CONSTRAINT "FK_b7d6e83dbfa5e98148529803894" FOREIGN KEY ("user_id") REFERENCES "tb_users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_comments" ADD CONSTRAINT "FK_d4e57c6e48ba93300dc02734659" FOREIGN KEY ("job_id") REFERENCES "tb_jobs"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ADD CONSTRAINT "FK_a64a855331c54d698baddb03b6f" FOREIGN KEY ("company_id") REFERENCES "tb_companies"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_applications" ADD CONSTRAINT "FK_1408b5e5220c7d0fe25573cb3b9" FOREIGN KEY ("job_id") REFERENCES "tb_jobs"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_applications" ADD CONSTRAINT "FK_08c82d5bde7b75b17f54460adda" FOREIGN KEY ("user_id") REFERENCES "tb_users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_applications" ADD CONSTRAINT "FK_f3502a850c1b2b75a5dbe2c04ee" FOREIGN KEY ("curriculum_id") REFERENCES "tb_curriculum"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_users" ADD CONSTRAINT "FK_0afe3b230cbd95a08c72f9df3f0" FOREIGN KEY ("personalDataId") REFERENCES "tb_personal_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_saved_jobs" ADD CONSTRAINT "FK_3845b06bfda63ccc1da359b378a" FOREIGN KEY ("userId") REFERENCES "tb_users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_saved_jobs" ADD CONSTRAINT "FK_9bbd9a1f3bb4942f0471816b111" FOREIGN KEY ("jobId") REFERENCES "tb_jobs"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_reports" ADD CONSTRAINT "FK_c95aa2c975cb6369772d5f15c7d" FOREIGN KEY ("job_id") REFERENCES "tb_jobs"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_reports" ADD CONSTRAINT "FK_86d265112a095a1daae2e34d669" FOREIGN KEY ("user_id") REFERENCES "tb_users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tb_alerts" ADD CONSTRAINT "FK_a60dd357707a39803ce4cfbd90b" FOREIGN KEY ("user_id") REFERENCES "tb_users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tb_alerts" DROP CONSTRAINT "FK_a60dd357707a39803ce4cfbd90b"`); + await queryRunner.query(`ALTER TABLE "tb_reports" DROP CONSTRAINT "FK_86d265112a095a1daae2e34d669"`); + await queryRunner.query(`ALTER TABLE "tb_reports" DROP CONSTRAINT "FK_c95aa2c975cb6369772d5f15c7d"`); + await queryRunner.query(`ALTER TABLE "tb_saved_jobs" DROP CONSTRAINT "FK_9bbd9a1f3bb4942f0471816b111"`); + await queryRunner.query(`ALTER TABLE "tb_saved_jobs" DROP CONSTRAINT "FK_3845b06bfda63ccc1da359b378a"`); + await queryRunner.query(`ALTER TABLE "tb_users" DROP CONSTRAINT "FK_0afe3b230cbd95a08c72f9df3f0"`); + await queryRunner.query(`ALTER TABLE "tb_applications" DROP CONSTRAINT "FK_f3502a850c1b2b75a5dbe2c04ee"`); + await queryRunner.query(`ALTER TABLE "tb_applications" DROP CONSTRAINT "FK_08c82d5bde7b75b17f54460adda"`); + await queryRunner.query(`ALTER TABLE "tb_applications" DROP CONSTRAINT "FK_1408b5e5220c7d0fe25573cb3b9"`); + await queryRunner.query(`ALTER TABLE "tb_jobs" DROP CONSTRAINT "FK_a64a855331c54d698baddb03b6f"`); + await queryRunner.query(`ALTER TABLE "tb_comments" DROP CONSTRAINT "FK_d4e57c6e48ba93300dc02734659"`); + await queryRunner.query(`ALTER TABLE "tb_comments" DROP CONSTRAINT "FK_b7d6e83dbfa5e98148529803894"`); + await queryRunner.query(`ALTER TABLE "tb_curriculum" DROP CONSTRAINT "FK_2c53580e4d1f616f6ffee74ba51"`); + await queryRunner.query(`ALTER TABLE "tb_work_experiences" DROP CONSTRAINT "FK_e668e3b05cc240131e27a41e5b5"`); + await queryRunner.query(`ALTER TABLE "tb_languages" DROP CONSTRAINT "FK_ecd946bdf16c31509a966a5002f"`); + await queryRunner.query(`ALTER TABLE "tb_courses" DROP CONSTRAINT "FK_beb3c749f9d5000af64c4442989"`); + await queryRunner.query(`ALTER TABLE "tb_certifications" DROP CONSTRAINT "FK_75f15015611ef7f444ae3fade23"`); + await queryRunner.query(`ALTER TABLE "tb_candidacies" DROP COLUMN "date_candidacy"`); + await queryRunner.query(`ALTER TABLE "tb_candidacies" ADD "date_candidacy" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "affirmative" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "openEndedContract" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "typeContract" SET DEFAULT 'CLT'`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "type" SET DEFAULT 'JUNIOR'`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "type" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "prerequisites" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "tb_jobs" ALTER COLUMN "description" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "tb_jobs" DROP COLUMN "canceledAt"`); + await queryRunner.query(`ALTER TABLE "tb_jobs" DROP COLUMN "publishedAt"`); + await queryRunner.query(`ALTER TABLE "tb_jobs" DROP COLUMN "jobStatus"`); + await queryRunner.query(`DROP TYPE "public"."tb_jobs_jobstatus_enum"`); + await queryRunner.query(`DROP INDEX "public"."IDX_9bbd9a1f3bb4942f0471816b11"`); + await queryRunner.query(`DROP INDEX "public"."IDX_3845b06bfda63ccc1da359b378"`); + await queryRunner.query(`DROP TABLE "tb_saved_jobs"`); + await queryRunner.query(`ALTER TABLE "tb_alerts" ADD CONSTRAINT "FK_a60dd357707a39803ce4cfbd90b" FOREIGN KEY ("user_id") REFERENCES "tb_users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + +} diff --git a/src/modules/jobs/dtos/complete-job.dto.ts b/src/modules/jobs/dtos/complete-job.dto.ts new file mode 100644 index 00000000..98ea6a36 --- /dev/null +++ b/src/modules/jobs/dtos/complete-job.dto.ts @@ -0,0 +1,85 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; +import { JobsTypeContractEnum } from '../enums/job-contract-type.enum'; + +export class CompleteJobDto { + @IsNotEmpty() + @IsString() + @MaxLength(5000) + @ApiProperty({ + required: true, + description: 'Descrição da vaga', + example: 'Buscamos um desenvolvedor backend...', + }) + description: string; + + @IsNotEmpty() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ + required: true, + description: 'Requisitos da vaga', + example: ['Node.js', 'TypeScript', 'NestJS'], + }) + requirements: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ + required: false, + description: 'Benefícios', + example: ['Vale alimentação', 'Plano de saúde'], + }) + benefits?: string[]; + + @IsNotEmpty() + @IsEnum(JobsTypeContractEnum) + @ApiProperty({ + required: true, + description: 'Tipo de contrato', + example: JobsTypeContractEnum.CLT, + enum: [ + JobsTypeContractEnum.CLT, + JobsTypeContractEnum.PJ, + JobsTypeContractEnum.OTHER, + ], + }) + contractType: JobsTypeContractEnum; + + @IsNotEmpty() + @IsString() + @ApiProperty({ + required: true, + description: 'Jornada de trabalho', + example: '40 horas semanais', + }) + journey: string; + + @IsNotEmpty() + @IsArray() + @IsString({ each: true }) + @ApiProperty({ + required: true, + description: 'Processo seletivo', + example: ['Entrevista com RH', 'Teste técnico', 'Entrevista técnica'], + }) + selectionProcess: string[]; + + @IsOptional() + @IsString() + @MaxLength(2000) + @ApiProperty({ + required: false, + description: 'Informações adicionais', + example: 'Início imediato', + }) + additionalInfo?: string; +} diff --git a/src/modules/jobs/dtos/create-job-draft.dto.ts b/src/modules/jobs/dtos/create-job-draft.dto.ts new file mode 100644 index 00000000..5d1f526a --- /dev/null +++ b/src/modules/jobs/dtos/create-job-draft.dto.ts @@ -0,0 +1,92 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, + ValidateIf, +} from 'class-validator'; +import { JobsModalityEnum } from '../enums/job-modality.enum'; + +export class CreateJobDraftDto { + @IsNotEmpty() + @IsString() + @MaxLength(100) + @ApiProperty({ + required: true, + description: 'Título da vaga', + example: 'Desenvolvedor Backend Junior', + }) + title: string; + + @IsNotEmpty() + @IsString() + @MaxLength(100) + @ApiProperty({ + required: true, + description: 'Área de interesse', + example: 'Tecnologia', + }) + interestArea: string; + + @IsNotEmpty() + @IsEnum(JobsModalityEnum) + @ApiProperty({ + required: true, + description: 'Modalidade do trabalho', + example: JobsModalityEnum.REMOTE, + enum: [ + JobsModalityEnum.HYBRID, + JobsModalityEnum.ON_SITE, + JobsModalityEnum.REMOTE, + ], + }) + modality: JobsModalityEnum; + + @ValidateIf((o) => o.modality !== JobsModalityEnum.REMOTE) + @IsString() + @ApiProperty({ + required: false, + description: 'Cidade', + example: 'São Paulo', + }) + city?: string; + + @IsNotEmpty() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(999999) + @ApiProperty({ + required: true, + description: 'Salário mínimo', + example: 3000, + }) + salaryMin: number; + + @IsNotEmpty() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(999999) + @ApiProperty({ + required: true, + description: 'Salário máximo', + example: 5000, + }) + salaryMax: number; + + @IsOptional() + @IsString() + @ApiProperty({ + required: false, + description: 'Opção de trabalho remoto', + example: 'Híbrido 2x por semana', + }) + remoteWorkOption?: string; + + @IsOptional() + company_id?: string; +} diff --git a/src/modules/jobs/enums/job-status.enum.ts b/src/modules/jobs/enums/job-status.enum.ts new file mode 100644 index 00000000..659d8f06 --- /dev/null +++ b/src/modules/jobs/enums/job-status.enum.ts @@ -0,0 +1,6 @@ +export enum JobStatus { + DRAFT = 'DRAFT', + PUBLISHED = 'PUBLISHED', + ARCHIVED = 'ARCHIVED', + CANCELED = 'CANCELED', +} diff --git a/src/modules/jobs/guards/job-owner.guard.ts b/src/modules/jobs/guards/job-owner.guard.ts new file mode 100644 index 00000000..6d54b8ae --- /dev/null +++ b/src/modules/jobs/guards/job-owner.guard.ts @@ -0,0 +1,37 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { JobRepository } from '../repository/job.repository'; + +@Injectable() +export class JobOwnerGuard implements CanActivate { + constructor(private jobRepository: JobRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user; + const jobId = request.params.id; + + if (!user || !user.id) { + throw new ForbiddenException('User not authenticated'); + } + + const job = await this.jobRepository.findOneById(jobId); + + if (!job) { + throw new NotFoundException('Job not found'); + } + + if (job.company_id !== user.id) { + throw new ForbiddenException( + 'You do not have permission to modify this job', + ); + } + + return true; + } +} diff --git a/src/modules/jobs/jobs.controller.ts b/src/modules/jobs/jobs.controller.ts index 2b49cee6..00d7f9f4 100644 --- a/src/modules/jobs/jobs.controller.ts +++ b/src/modules/jobs/jobs.controller.ts @@ -1,8 +1,8 @@ import { Body, Controller, + Delete, Get, - NotFoundException, Param, Patch, Post, @@ -19,7 +19,6 @@ import { GetOneJobSwagger } from 'src/shared/Swagger/decorators/jobs/get-one-job import { SearchJobSwagger } from 'src/shared/Swagger/decorators/jobs/search-job.swagger'; import { UpdateJobSwagger } from 'src/shared/Swagger/decorators/jobs/update-job.swagger'; import { CompaniesEntity } from '../../database/entities/companies.entity'; -import { JobsEntity } from '../../database/entities/jobs.entity'; import { PageOptionsDto } from '../../shared/pagination'; import { LoggedCompany } from '../auth/decorator/logged-company.decorator'; import { CreateJobDto } from './dtos/create-job.dto'; @@ -37,6 +36,17 @@ import { GetAllJobsSwagger } from 'src/shared/Swagger/decorators/jobs/get-all-jo import { GetAllJobsFromLoggedCompanyService } from './services/get-all-jobs-from-logged-company.service'; import { Response } from 'express'; import { DeleteJobService } from './services/delete-job.service'; +import { CreateJobDraftDto } from './dtos/create-job-draft.dto'; +import { CreateJobDraftService } from './services/create-job-draft.service'; +import { PublishJobService } from './services/publish-job.service'; +import { CompleteJobDto } from './dtos/complete-job.dto'; +import { JobOwnerGuard } from './guards/job-owner.guard'; +import { CancelJobService } from './services/cancel-job.service'; +import { DeleteJobDraftService } from './services/delete-job-draft.service'; +import { DeleteJobDraftSwagger } from 'src/shared/Swagger/decorators/jobs/delete-job-draft.swagger'; +import { CreateJobDraftSwagger } from 'src/shared/Swagger/decorators/jobs/create-job-draft.swagger'; +import { PublishJobSwagger } from 'src/shared/Swagger/decorators/jobs/publish-job.swagger'; +import { CancelJobSwagger } from 'src/shared/Swagger/decorators/jobs/cancel-job.swagger'; @ApiTags('Job') @Controller('job') @@ -48,9 +58,51 @@ export class JobsController { private updateJobService: UpdateJobService, private deleteJobService: DeleteJobService, private searchJobsService: SearchJobsService, - private getAllJobsFromLoggedCompany: GetAllJobsFromLoggedCompanyService + private getAllJobsFromLoggedCompany: GetAllJobsFromLoggedCompanyService, + private createJobDraftService: CreateJobDraftService, + private publishJobService: PublishJobService, + private cancelJobService: CancelJobService, + private deleteJobDraftService: DeleteJobDraftService, ) {} + @Post('draft') + @CreateJobDraftSwagger() + @ApiBearerAuth() + @UseGuards(AuthGuard()) + async createJobDraft( + @Body() data: CreateJobDraftDto, + @LoggedCompany() company: CompaniesEntity, + ) { + return this.createJobDraftService.execute(data, company); + } + + @Patch(':id/publish') + @PublishJobSwagger() + @ApiBearerAuth() + @UseGuards(AuthGuard(), JobOwnerGuard) + async publishJob( + @Param('id') id: string, + @Body() completeData: CompleteJobDto, + ) { + return this.publishJobService.execute(id, completeData); + } + + @Patch(':id/cancel') + @CancelJobSwagger() + @ApiBearerAuth() + @UseGuards(AuthGuard(), JobOwnerGuard) + async cancelJob(@Param('id') id: string) { + return this.cancelJobService.execute(id); + } + + @Delete(':id') + @DeleteJobDraftSwagger() + @ApiBearerAuth() + @UseGuards(AuthGuard(), JobOwnerGuard) + async deleteJobDraft(@Param('id') id: string) { + return this.deleteJobDraftService.execute(id); + } + @Post() @CreateNewJobSwagger() @ApiBearerAuth() @@ -73,16 +125,19 @@ export class JobsController { ) { return this.getAllJobsService.execute(pageOptionsDto, params); } + @GetAllJobsOfLoggedCompanySwagger() @ApiBearerAuth() @UseGuards(AuthGuard()) @Get('loggedCompanyJobs') async getAllLoggedCompanyJobs( @LoggedCompany() company: CompaniesEntity, - @Res() res: Response + @Res() res: Response, ) { - const { status, data } = await this.getAllJobsFromLoggedCompany.execute(company.id); - return res.status(status).json(data) + const { status, data } = await this.getAllJobsFromLoggedCompany.execute( + company.id, + ); + return res.status(status).json(data); } @Get(':id') @@ -101,11 +156,7 @@ export class JobsController { @ApiBearerAuth() @UseGuards(AuthGuard()) @ArchiveJobSwagger() - async archivedJob( - @Param() - jobId: string, - @Body('content') content: string, - ) { + async archivedJob(@Param() jobId: string, @Body('content') content: string) { return this.deleteJobService.execute(jobId, content); } diff --git a/src/modules/jobs/jobs.module.ts b/src/modules/jobs/jobs.module.ts index ff365da4..85fba139 100644 --- a/src/modules/jobs/jobs.module.ts +++ b/src/modules/jobs/jobs.module.ts @@ -17,6 +17,11 @@ import { GetAllJobsFromLoggedCompanyService } from './services/get-all-jobs-from import { JobsEntity } from 'src/database/entities/jobs.entity'; import { CompaniesEntity } from 'src/database/entities/companies.entity'; import { DeleteJobService } from './services/delete-job.service'; +import { CreateJobDraftService } from './services/create-job-draft.service'; +import { PublishJobService } from './services/publish-job.service'; +import { CancelJobService } from './services/cancel-job.service'; +import { DeleteJobDraftService } from './services/delete-job-draft.service'; +import { JobOwnerGuard } from './guards/job-owner.guard'; @Module({ imports: [ @@ -35,7 +40,12 @@ import { DeleteJobService } from './services/delete-job.service'; DeleteJobService, SearchJobsService, JobRepository, - CompanyRepository + CompanyRepository, + CreateJobDraftService, + PublishJobService, + CancelJobService, + DeleteJobDraftService, + JobOwnerGuard, ], }) export class JobsModule {} diff --git a/src/modules/jobs/repository/job.repository.ts b/src/modules/jobs/repository/job.repository.ts index b028f7c2..e683ca10 100644 --- a/src/modules/jobs/repository/job.repository.ts +++ b/src/modules/jobs/repository/job.repository.ts @@ -11,23 +11,73 @@ import { GetAllJobsDto } from '../dtos/get-all-jobs.dto'; import { UpdateJobDto } from '../dtos/update-job.dto'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { CreateJobDraftDto } from '../dtos/create-job-draft.dto'; +import { JobStatus } from '../enums/job-status.enum'; +import { CompleteJobDto } from '../dtos/complete-job.dto'; @Injectable() export class JobRepository { - constructor(@InjectRepository(JobsEntity) private jobsRepository: Repository) {} + constructor( + @InjectRepository(JobsEntity) + private jobsRepository: Repository, + ) {} async createNewJob(data: CreateJobDto): Promise { await this.jobsRepository.save(data).catch(handleError); return; } - async getAllJobsByCompanyId( - companyId: string - ): Promise { + async createJobDraft(data: CreateJobDraftDto): Promise { + const draft = this.jobsRepository.create({ + ...data, + jobStatus: JobStatus.DRAFT, + prerequisites: data.interestArea, + }); + return this.jobsRepository.save(draft).catch(handleError); + } + + async publishJob( + jobId: string, + completeData: CompleteJobDto, + ): Promise { + await this.jobsRepository + .update(jobId, { + description: completeData.description, + prerequisites: completeData.requirements.join(', '), + benefits: completeData.benefits?.join(', '), + typeContract: completeData.contractType, + contractType: completeData.journey, + contractText: completeData.selectionProcess.join(' -> '), + content: completeData.additionalInfo, + jobStatus: JobStatus.PUBLISHED, + publishedAt: new Date(), + }) + .catch(handleError); - const jobs = await this.jobsRepository.find({where: {company_id: companyId}}) + return this.jobsRepository.findOneBy({ id: jobId }).catch(handleError); + } + + async cancelJob(jobId: string): Promise { + await this.jobsRepository + .update(jobId, { + jobStatus: JobStatus.CANCELED, + canceledAt: new Date(), + }) + .catch(handleError); - return jobs + return this.jobsRepository.findOneBy({ id: jobId }).catch(handleError); + } + + async deleteJobDraft(jobId: string): Promise { + await this.jobsRepository.delete(jobId).catch(handleError); + } + + async getAllJobsByCompanyId(companyId: string): Promise { + const jobs = await this.jobsRepository.find({ + where: { company_id: companyId }, + }); + + return jobs; } async getAllJobs( @@ -42,6 +92,7 @@ export class JobRepository { .andWhere(params.modality ? 'jobs.modality = :modality' : {}, { modality: params.modality, }) + .andWhere('jobs.jobStatus = :status', { status: JobStatus.PUBLISHED }) .orderBy(`jobs.${pageOptionsDto.orderByColumn}`, pageOptionsDto.order) .skip((pageOptionsDto.page - 1) * pageOptionsDto.take) .take(pageOptionsDto.take); @@ -65,7 +116,8 @@ export class JobRepository { } async findOneById(id: string): Promise { - const queryBuilder = this.jobsRepository.createQueryBuilder('jobs') + const queryBuilder = this.jobsRepository + .createQueryBuilder('jobs') .leftJoinAndSelect('jobs.comments', 'comments') .leftJoinAndSelect('comments.user', 'user') .leftJoinAndSelect('jobs.company', 'company') @@ -92,12 +144,14 @@ export class JobRepository { } async updateJob(id: string, data: UpdateJobDto) { - const job = await this.jobsRepository.findOneBy({id}).catch(handleError); + const job = await this.jobsRepository.findOneBy({ id }).catch(handleError); - return this.jobsRepository.save({ - ...job, - ...data, - }).catch(handleError); + return this.jobsRepository + .save({ + ...job, + ...data, + }) + .catch(handleError); } async searchJobs( @@ -111,7 +165,7 @@ export class JobRepository { .leftJoin('job.company', 'company') .select(['job', 'company.id', 'company.companyName', 'company.profile']) .andWhere(`job.title ILIKE '%${searchQuery}%'`) - .andWhere(`job.status = 'ACTIVE'`) + .andWhere(`job.jobStatus = :status`, { status: JobStatus.PUBLISHED }) .orderBy(`job.${pageOptionsDto.orderByColumn}`, pageOptionsDto.order) .skip((pageOptionsDto.page - 1) * pageOptionsDto.take) .take(pageOptionsDto.take); diff --git a/src/modules/jobs/services/cancel-job.service.ts b/src/modules/jobs/services/cancel-job.service.ts new file mode 100644 index 00000000..5e48754b --- /dev/null +++ b/src/modules/jobs/services/cancel-job.service.ts @@ -0,0 +1,29 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { JobStatus } from '../enums/job-status.enum'; +import { JobRepository } from '../repository/job.repository'; + +@Injectable() +export class CancelJobService { + constructor(private jobRepository: JobRepository) {} + + async execute(jobId: string) { + const job = await this.jobRepository.findOneById(jobId); + + if (!job) { + throw new BadRequestException('Job not found'); + } + + if (job.jobStatus !== JobStatus.PUBLISHED) { + throw new BadRequestException('Only published jobs can be canceled'); + } + + const canceledJob = await this.jobRepository.cancelJob(jobId); + + return { + id: canceledJob.id, + status: canceledJob.jobStatus, + canceledAt: canceledJob.canceledAt, + message: 'Job canceled successfully', + }; + } +} diff --git a/src/modules/jobs/services/create-job-draft.service.ts b/src/modules/jobs/services/create-job-draft.service.ts new file mode 100644 index 00000000..733c52d4 --- /dev/null +++ b/src/modules/jobs/services/create-job-draft.service.ts @@ -0,0 +1,29 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { CompaniesEntity } from '../../../database/entities/companies.entity'; +import { CreateJobDraftDto } from '../dtos/create-job-draft.dto'; +import { JobRepository } from '../repository/job.repository'; + +@Injectable() +export class CreateJobDraftService { + constructor(private jobRepository: JobRepository) {} + + async execute(data: CreateJobDraftDto, company: CompaniesEntity) { + const { salaryMin, salaryMax } = data; + + if (salaryMin > salaryMax) { + throw new BadRequestException( + 'Salary minimum cannot be greater than salary maximum', + ); + } + + data.company_id = company.id; + + const draft = await this.jobRepository.createJobDraft(data); + + return { + id: draft.id, + status: draft.jobStatus, + message: 'Draft created successfully', + }; + } +} diff --git a/src/modules/jobs/services/delete-job-draft.service.ts b/src/modules/jobs/services/delete-job-draft.service.ts new file mode 100644 index 00000000..c9318cbd --- /dev/null +++ b/src/modules/jobs/services/delete-job-draft.service.ts @@ -0,0 +1,26 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { JobStatus } from '../enums/job-status.enum'; +import { JobRepository } from '../repository/job.repository'; + +@Injectable() +export class DeleteJobDraftService { + constructor(private jobRepository: JobRepository) {} + + async execute(jobId: string) { + const job = await this.jobRepository.findOneById(jobId); + + if (!job) { + throw new BadRequestException('Job not found'); + } + + if (job.jobStatus !== JobStatus.DRAFT) { + throw new BadRequestException('Only draft jobs can be deleted'); + } + + await this.jobRepository.deleteJobDraft(jobId); + + return { + message: 'Draft deleted successfully', + }; + } +} diff --git a/src/modules/jobs/services/publish-job.service.ts b/src/modules/jobs/services/publish-job.service.ts new file mode 100644 index 00000000..20479f44 --- /dev/null +++ b/src/modules/jobs/services/publish-job.service.ts @@ -0,0 +1,33 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { JobStatus } from '../enums/job-status.enum'; +import { CompleteJobDto } from '../dtos/complete-job.dto'; +import { JobRepository } from '../repository/job.repository'; + +@Injectable() +export class PublishJobService { + constructor(private jobRepository: JobRepository) {} + + async execute(jobId: string, completeData: CompleteJobDto) { + const job = await this.jobRepository.findOneById(jobId); + + if (!job) { + throw new BadRequestException('Job not found'); + } + + if (job.jobStatus !== JobStatus.DRAFT) { + throw new BadRequestException('Only draft jobs can be published'); + } + + const publishedJob = await this.jobRepository.publishJob( + jobId, + completeData, + ); + + return { + id: publishedJob.id, + status: publishedJob.jobStatus, + publishedAt: publishedJob.publishedAt, + message: 'Job published successfully', + }; + } +} diff --git a/src/modules/mails/mail.module.ts b/src/modules/mails/mail.module.ts index a25829de..87fe25d7 100644 --- a/src/modules/mails/mail.module.ts +++ b/src/modules/mails/mail.module.ts @@ -12,13 +12,13 @@ import { MailService } from './mail.service'; useFactory: async (config: ConfigService) => ({ transport: { host: config.get('MAIL_HOST'), - port: config.get('MAIL_PORT'), + port: config.get('MAIL_PORT'), // secure: true, // secure: false, - auth: { - user: config.get('MAIL_USER'), - pass: config.get('MAIL_PASSWORD'), - }, + // auth: { + // user: config.get('MAIL_USER'), + // pass: config.get('MAIL_PASSWORD'), + // }, // tls: { // rejectUnauthorized: false, // }, diff --git a/src/shared/Swagger/decorators/jobs/cancel-job.swagger.ts b/src/shared/Swagger/decorators/jobs/cancel-job.swagger.ts new file mode 100644 index 00000000..ab032b19 --- /dev/null +++ b/src/shared/Swagger/decorators/jobs/cancel-job.swagger.ts @@ -0,0 +1,30 @@ +import { applyDecorators, HttpStatus } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { BadRequestSwagger } from '../../bad-request.swagger'; +import { UnauthorizedSwagger } from '../../unauthorized.swagger'; + +export function CancelJobSwagger() { + return applyDecorators( + ApiResponse({ + status: HttpStatus.OK, + description: 'Vaga cancelada com sucesso', + }), + ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Modelo de erro', + type: BadRequestSwagger, + }), + ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Não autorizado', + type: UnauthorizedSwagger, + }), + ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Sem permissão para modificar esta vaga', + }), + ApiOperation({ + summary: 'Cancelar vaga publicada', + }), + ); +} diff --git a/src/shared/Swagger/decorators/jobs/create-job-draft.swagger.ts b/src/shared/Swagger/decorators/jobs/create-job-draft.swagger.ts new file mode 100644 index 00000000..7d54d748 --- /dev/null +++ b/src/shared/Swagger/decorators/jobs/create-job-draft.swagger.ts @@ -0,0 +1,26 @@ +import { applyDecorators, HttpStatus } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { BadRequestSwagger } from '../../bad-request.swagger'; +import { UnauthorizedSwagger } from '../../unauthorized.swagger'; + +export function CreateJobDraftSwagger() { + return applyDecorators( + ApiResponse({ + status: HttpStatus.CREATED, + description: 'Rascunho criado com sucesso', + }), + ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Modelo de erro', + type: BadRequestSwagger, + }), + ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Não autorizado', + type: UnauthorizedSwagger, + }), + ApiOperation({ + summary: 'Criar rascunho de vaga', + }), + ); +} diff --git a/src/shared/Swagger/decorators/jobs/delete-job-draft.swagger.ts b/src/shared/Swagger/decorators/jobs/delete-job-draft.swagger.ts new file mode 100644 index 00000000..b4715199 --- /dev/null +++ b/src/shared/Swagger/decorators/jobs/delete-job-draft.swagger.ts @@ -0,0 +1,30 @@ +import { applyDecorators, HttpStatus } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { BadRequestSwagger } from '../../bad-request.swagger'; +import { UnauthorizedSwagger } from '../../unauthorized.swagger'; + +export function DeleteJobDraftSwagger() { + return applyDecorators( + ApiResponse({ + status: HttpStatus.OK, + description: 'Rascunho excluído com sucesso', + }), + ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Modelo de erro', + type: BadRequestSwagger, + }), + ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Não autorizado', + type: UnauthorizedSwagger, + }), + ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Sem permissão para modificar esta vaga', + }), + ApiOperation({ + summary: 'Excluir rascunho de vaga', + }), + ); +} diff --git a/src/shared/Swagger/decorators/jobs/publish-job.swagger.ts b/src/shared/Swagger/decorators/jobs/publish-job.swagger.ts new file mode 100644 index 00000000..fbf562fa --- /dev/null +++ b/src/shared/Swagger/decorators/jobs/publish-job.swagger.ts @@ -0,0 +1,30 @@ +import { applyDecorators, HttpStatus } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { BadRequestSwagger } from '../../bad-request.swagger'; +import { UnauthorizedSwagger } from '../../unauthorized.swagger'; + +export function PublishJobSwagger() { + return applyDecorators( + ApiResponse({ + status: HttpStatus.OK, + description: 'Vaga publicada com sucesso', + }), + ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Modelo de erro', + type: BadRequestSwagger, + }), + ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Não autorizado', + type: UnauthorizedSwagger, + }), + ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Sem permissão para modificar esta vaga', + }), + ApiOperation({ + summary: 'Publicar vaga a partir do rascunho', + }), + ); +} diff --git a/test/modules/jobs/services/create-job-draft.service.spec.ts b/test/modules/jobs/services/create-job-draft.service.spec.ts new file mode 100644 index 00000000..07d18fae --- /dev/null +++ b/test/modules/jobs/services/create-job-draft.service.spec.ts @@ -0,0 +1,106 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { CreateJobDraftService } from '../../../../src/modules/jobs/services/create-job-draft.service'; +import { JobRepository } from '../../../../src/modules/jobs/repository/job.repository'; +import { JobsModalityEnum } from '../../../../src/modules/jobs/enums/job-modality.enum'; +import { JobStatus } from '../../../../src/modules/jobs/enums/job-status.enum'; + +class JobRepositoryMock { + createJobDraft = jest.fn(); +} + +describe('CreateJobDraftService', () => { + let service: CreateJobDraftService; + let jobRepository: JobRepositoryMock; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CreateJobDraftService, + { + provide: JobRepository, + useClass: JobRepositoryMock, + }, + ], + }).compile(); + + service = module.get(CreateJobDraftService); + jobRepository = module.get(JobRepository); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('execute', () => { + const mockCompany = { + id: 'company-id', + companyName: 'Test Company', + } as any; + + const mockDraftData = { + title: 'Backend Developer', + interestArea: 'Technology', + modality: JobsModalityEnum.REMOTE, + salaryMin: 3000, + salaryMax: 5000, + }; + + it('should throw error when salaryMin > salaryMax', async () => { + const invalidData = { + ...mockDraftData, + salaryMin: 6000, + salaryMax: 5000, + }; + + await expect(service.execute(invalidData, mockCompany)).rejects.toThrow( + BadRequestException, + ); + await expect(service.execute(invalidData, mockCompany)).rejects.toThrow( + 'Salary minimum cannot be greater than salary maximum', + ); + }); + + it('should create draft with correct status', async () => { + const mockDraft = { + id: 'draft-id', + ...mockDraftData, + company_id: mockCompany.id, + jobStatus: JobStatus.DRAFT, + }; + + jobRepository.createJobDraft = jest.fn().mockResolvedValue(mockDraft); + + const result = await service.execute(mockDraftData, mockCompany); + + expect(result).toEqual({ + id: 'draft-id', + status: JobStatus.DRAFT, + message: 'Draft created successfully', + }); + expect(jobRepository.createJobDraft).toHaveBeenCalledWith({ + ...mockDraftData, + company_id: mockCompany.id, + }); + }); + + it('should link draft to logged company', async () => { + const mockDraft = { + id: 'draft-id', + ...mockDraftData, + company_id: mockCompany.id, + jobStatus: JobStatus.DRAFT, + }; + + jobRepository.createJobDraft = jest.fn().mockResolvedValue(mockDraft); + + await service.execute(mockDraftData, mockCompany); + + expect(jobRepository.createJobDraft).toHaveBeenCalledWith( + expect.objectContaining({ + company_id: mockCompany.id, + }), + ); + }); + }); +}); diff --git a/test/modules/jobs/services/job-owner.guard.spec.ts b/test/modules/jobs/services/job-owner.guard.spec.ts new file mode 100644 index 00000000..dd86a740 --- /dev/null +++ b/test/modules/jobs/services/job-owner.guard.spec.ts @@ -0,0 +1,102 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { ExecutionContext } from '@nestjs/common'; +import { JobOwnerGuard } from '../../../../src/modules/jobs/guards/job-owner.guard'; +import { JobRepository } from '../../../../src/modules/jobs/repository/job.repository'; + +class JobRepositoryMock { + findOneById = jest.fn(); +} + +describe('JobOwnerGuard', () => { + let guard: JobOwnerGuard; + let jobRepository: JobRepositoryMock; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JobOwnerGuard, + { + provide: JobRepository, + useClass: JobRepositoryMock, + }, + ], + }).compile(); + + guard = module.get(JobOwnerGuard); + jobRepository = module.get(JobRepository); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + describe('canActivate', () => { + const mockExecutionContext = (user: any, jobId: string) => { + return { + switchToHttp: () => ({ + getRequest: () => ({ + user, + params: { id: jobId }, + }), + }), + } as ExecutionContext; + }; + + it('should throw ForbiddenException when user not authenticated', async () => { + const context = mockExecutionContext(null, 'job-id'); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException, + ); + await expect(guard.canActivate(context)).rejects.toThrow( + 'User not authenticated', + ); + }); + + it('should throw NotFoundException when job not found', async () => { + const user = { id: 'company-id' }; + const context = mockExecutionContext(user, 'job-id'); + + jobRepository.findOneById = jest.fn().mockResolvedValue(null); + + await expect(guard.canActivate(context)).rejects.toThrow( + NotFoundException, + ); + await expect(guard.canActivate(context)).rejects.toThrow('Job not found'); + }); + + it('should throw ForbiddenException when user is not owner', async () => { + const user = { id: 'company-id' }; + const context = mockExecutionContext(user, 'job-id'); + const job = { + id: 'job-id', + company_id: 'other-company-id', + }; + + jobRepository.findOneById = jest.fn().mockResolvedValue(job); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException, + ); + await expect(guard.canActivate(context)).rejects.toThrow( + 'You do not have permission to modify this job', + ); + }); + + it('should allow access when user is owner', async () => { + const user = { id: 'company-id' }; + const context = mockExecutionContext(user, 'job-id'); + const job = { + id: 'job-id', + company_id: 'company-id', + }; + + jobRepository.findOneById = jest.fn().mockResolvedValue(job); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + }); + }); +}); diff --git a/test/modules/jobs/services/publish-job.service.spec.ts b/test/modules/jobs/services/publish-job.service.spec.ts new file mode 100644 index 00000000..aff8aae4 --- /dev/null +++ b/test/modules/jobs/services/publish-job.service.spec.ts @@ -0,0 +1,102 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { PublishJobService } from '../../../../src/modules/jobs/services/publish-job.service'; +import { JobRepository } from '../../../../src/modules/jobs/repository/job.repository'; +import { JobStatus } from '../../../../src/modules/jobs/enums/job-status.enum'; +import { JobsTypeContractEnum } from '../../../../src/modules/jobs/enums/job-contract-type.enum'; + +class JobRepositoryMock { + findOneById = jest.fn(); + publishJob = jest.fn(); +} + +describe('PublishJobService', () => { + let service: PublishJobService; + let jobRepository: JobRepositoryMock; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PublishJobService, + { + provide: JobRepository, + useClass: JobRepositoryMock, + }, + ], + }).compile(); + + service = module.get(PublishJobService); + jobRepository = module.get(JobRepository); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('execute', () => { + const mockCompleteData = { + description: 'Job description', + requirements: ['Node.js', 'TypeScript'], + benefits: ['Health insurance'], + contractType: JobsTypeContractEnum.CLT, + journey: '40 hours/week', + selectionProcess: ['Interview', 'Technical test'], + }; + + it('should throw error when job not found', async () => { + jobRepository.findOneById = jest.fn().mockResolvedValue(null); + + await expect(service.execute('job-id', mockCompleteData)).rejects.toThrow( + BadRequestException, + ); + await expect(service.execute('job-id', mockCompleteData)).rejects.toThrow( + 'Job not found', + ); + }); + + it('should throw error when job is not draft', async () => { + const publishedJob = { + id: 'job-id', + jobStatus: JobStatus.PUBLISHED, + }; + + jobRepository.findOneById = jest.fn().mockResolvedValue(publishedJob); + + await expect(service.execute('job-id', mockCompleteData)).rejects.toThrow( + BadRequestException, + ); + await expect(service.execute('job-id', mockCompleteData)).rejects.toThrow( + 'Only draft jobs can be published', + ); + }); + + it('should publish draft job successfully', async () => { + const draftJob = { + id: 'job-id', + jobStatus: JobStatus.DRAFT, + }; + + const publishedJob = { + id: 'job-id', + jobStatus: JobStatus.PUBLISHED, + publishedAt: new Date(), + }; + + jobRepository.findOneById = jest.fn().mockResolvedValue(draftJob); + jobRepository.publishJob = jest.fn().mockResolvedValue(publishedJob); + + const result = await service.execute('job-id', mockCompleteData); + + expect(result).toEqual({ + id: 'job-id', + status: JobStatus.PUBLISHED, + publishedAt: expect.any(Date), + message: 'Job published successfully', + }); + expect(jobRepository.publishJob).toHaveBeenCalledWith( + 'job-id', + mockCompleteData, + ); + }); + }); +});