diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index 948ae33..b2c063e 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -24,7 +24,7 @@ import { RolesGuard } from '../auth/guards/roles.guard'; }), ], controllers: [AdminController], - providers: [AdminService, JwtAuthGuard, RolesGuard], + providers: [AdminService, RolesGuard], exports: [AdminService], }) export class AdminModule {} diff --git a/src/app.module.ts b/src/app.module.ts index 1b8ba66..dff8864 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,12 +4,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; import { AuthModule } from './auth/auth.module'; import { User } from './auth/entities/user.entity'; -import { Message } from './messaging/entities/message.entity'; +import { EmailToken } from './auth/entities/email-token.entity'; +import { PasswordReset } from './auth/entities/password-reset.entity'; +import { Message } from './messaging/entities/messaging.entity'; import { FeedModule } from './feed/feed.module'; import { PostModule } from './post/post.module'; import * as dotenv from 'dotenv'; import { SavedPost } from './feed/entities/savedpost.entity'; -import { Post } from './post/entities/post.entity'; +import { Post } from './feed/entities/post.entity'; import { MessagingModule } from './messaging/messaging.module'; import { Team } from './auth/entities/team.entity'; import { TeamMember } from './auth/entities/team-member.entity'; @@ -21,8 +23,12 @@ import { ApplicationsModule } from './applications/applications.module'; import { AdminModule } from './admin/admin.module'; import { ReportsModule } from './reports/reports.module'; import { Comment } from './feed/entities/comment.entity'; +import { Like } from './feed/entities/like.entity'; import { Job } from './jobs/entities/job.entity'; +import { SavedJob } from './jobs/entities/saved-job.entity'; +import { Recommendation } from './jobs/entities/recommendation.entity'; import { Portfolio } from './auth/entities/portfolio.entity'; +import { SkillVerification } from './auth/entities/skills-verification.entity'; import { Report } from './reports/entities/report.entity'; import { BackupModule } from './backup/backup.module'; import { Backup } from './backup/entities/backup.entity'; @@ -55,13 +61,19 @@ dotenv.config(); database: configService.get('DB_NAME'), entities: [ User, + EmailToken, + PasswordReset, SavedPost, Post, Application, Message, Comment, + Like, Job, + SavedJob, + Recommendation, Portfolio, + SkillVerification, Report, Team, TeamMember, @@ -95,10 +107,10 @@ dotenv.config(); AvailabilityModule, ], providers: [ - { - provide: APP_GUARD, - useClass: RateLimitGuard, - }, + // { + // provide: APP_GUARD, + // useClass: RateLimitGuard, + // }, ], }) export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index d4d1e8c..aa9c2a3 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -15,14 +15,25 @@ import { UnauthorizedException, Put, } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiUnauthorizedResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; import type { AuthService } from './auth.service'; import type { RegisterDto } from './dto/register-user.dto'; +import { LoginDto } from './dto/login-user.dto'; +import { RefreshTokenDto } from './dto/refresh-token.dto'; import { User } from './entities/user.entity'; import type { FeedService } from '../feed/feed.service'; import type { JobsService } from '../jobs/jobs.service'; import type { GetUsersDto } from './dto/get-users.dto'; import type { SuspendUserDto } from './dto/suspend-user.dto'; import type { TeamService } from './services/team.service'; +import { LogInProvider } from './providers/loginProvider'; +import { Public } from './decorators/public.decorator'; // ... keep other imports from main ... @ApiTags('auth') @@ -53,7 +64,7 @@ export class AuthController { }, }) @ApiUnauthorizedResponse({ description: 'Invalid credentials' }) - async login(@Body() loginDto: LogInDto) { + async login(@Body() loginDto: LoginDto) { return this.authService.login(loginDto); } @@ -75,7 +86,9 @@ export class AuthController { @ApiUnauthorizedResponse({ description: 'Invalid refresh token' }) async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) { try { - const tokens = await this.authService.refreshTokens(refreshTokenDto.refreshToken); + const tokens = await this.authService.refreshTokens( + refreshTokenDto.refreshToken, + ); return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, @@ -93,4 +106,4 @@ export class AuthController { } // ... keep all other endpoints from main branch ... -} \ No newline at end of file +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 794a104..83ced9f 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -53,14 +53,16 @@ import { SkillVerification } from './entities/skills-verification.entity'; useFactory: async (configService: ConfigService) => { const secret = configService.get('JWT_SECRET'); const refreshSecret = configService.get('JWT_REFRESH_SECRET'); - + if (!secret) { throw new Error('JWT_SECRET environment variable is required'); } if (!refreshSecret) { - throw new Error('JWT_REFRESH_SECRET environment variable is required'); + throw new Error( + 'JWT_REFRESH_SECRET environment variable is required', + ); } - + return { secret, signOptions: { expiresIn: '15m' }, @@ -82,6 +84,7 @@ import { SkillVerification } from './entities/skills-verification.entity'; }, JwtStrategy, JwtRefreshStrategy, + JwtAuthGuard, { provide: APP_GUARD, useClass: JwtAuthGuard, @@ -102,4 +105,4 @@ import { SkillVerification } from './entities/skills-verification.entity'; GenerateTokensProvider, ], }) -export class AuthModule {} \ No newline at end of file +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 12fff4a..6b1c227 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -20,15 +20,18 @@ import { addHours, addDays, addMinutes } from 'date-fns'; import * as crypto from 'crypto'; import { MailService } from '../mail/mail.service'; import { ConfigService } from '@nestjs/config'; -import type { LogInDto } from './dto/loginDto'; +import type { LoginDto } from './dto/login-user.dto'; import { LogInProvider } from './providers/loginProvider'; import { JwtPayload } from './interfaces/jwt-payload.interface'; import { TeamService } from './services/team.service'; -import { SkillVerification, VerificationStatus } from './entities/skills-verification.entity'; -import type { - CreateSkillVerificationDto, - SkillAssessmentDto, - UpdateSkillVerificationDto +import { + SkillVerification, + VerificationStatus, +} from './entities/skills-verification.entity'; +import type { + CreateSkillVerificationDto, + SkillAssessmentDto, + UpdateSkillVerificationDto, } from './dto/skills.dto'; @Injectable() @@ -66,8 +69,8 @@ export class AuthService { // ... [keep all other methods exactly as they are] ... - async login(loginDto: LogInDto): Promise<{ - accessToken: string; + async login(loginDto: LoginDto): Promise<{ + accessToken: string; refreshToken: string; user: Omit; }> { @@ -87,15 +90,136 @@ export class AuthService { // Generate tokens using the refresh token flow const tokens = await this.getTokens(user.id, user.email); await this.updateRefreshToken(user.id, tokens.refreshToken); - + // Remove password from the user object const { password: _, ...userWithoutPassword } = user; - + return { ...tokens, - user: userWithoutPassword + user: userWithoutPassword, + }; + } + + async getTokens( + userId: string, + email: string, + ): Promise<{ + accessToken: string; + refreshToken: string; + }> { + const jwtPayload: JwtPayload = { + sub: userId, + email: email, + }; + + const [accessToken, refreshToken] = await Promise.all([ + this.jwtService.signAsync(jwtPayload, { + secret: this.configService.get('JWT_SECRET'), + expiresIn: '15m', + }), + this.jwtService.signAsync(jwtPayload, { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: '7d', + }), + ]); + + return { + accessToken, + refreshToken, + }; + } + + async updateRefreshToken( + userId: string, + refreshToken: string, + ): Promise { + const hashedRefreshToken = await bcrypt.hash(refreshToken, 10); + await this.userRepository.update(userId, { + refreshToken: hashedRefreshToken, + }); + } + + async refreshTokens(refreshToken: string): Promise<{ + accessToken: string; + refreshToken: string; + }> { + const payload = this.jwtService.verify(refreshToken, { + secret: this.configService.get('JWT_REFRESH_SECRET'), + }); + + const user = await this.userRepository.findOne({ + where: { id: payload.sub }, + }); + + if (!user || !user.refreshToken) { + throw new ForbiddenException('Access Denied'); + } + + const refreshTokenMatches = await bcrypt.compare( + refreshToken, + user.refreshToken, + ); + + if (!refreshTokenMatches) { + throw new ForbiddenException('Access Denied'); + } + + const tokens = await this.getTokens(user.id, user.email); + await this.updateRefreshToken(user.id, tokens.refreshToken); + + return tokens; + } + + async sendPasswordResetEmail(email: string): Promise<{ message: string }> { + const user = await this.userRepository.findOne({ where: { email } }); + + if (!user) { + // Don't reveal if email exists + return { + message: + 'If an account with that email exists, a password reset email has been sent.', + }; + } + + // Generate reset token + const resetToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = addHours(new Date(), 1); // 1 hour expiration + + // Save reset token + const passwordReset = this.passwordResetRepository.create({ + user, + token: resetToken, + expiresAt, + }); + + await this.passwordResetRepository.save(passwordReset); + + // Send email with reset link + const resetUrl = `${this.configService.get('FRONTEND_URL')}/auth/reset-password?token=${resetToken}`; + + await this.mailService.sendEmail({ + to: email, + subject: 'Password Reset Request', + template: 'password-reset', + context: { + resetUrl, + expiresIn: '1 hour', + }, + }); + + return { + message: + 'If an account with that email exists, a password reset email has been sent.', }; } + async getOneByEmail(email: string): Promise { + return this.userRepository.findOne({ where: { email } }); + } + + async validateUserById(userId: string): Promise { + return this.userRepository.findOne({ where: { id: userId } }); + } + // ... [keep all other methods exactly as they are] ... -} \ No newline at end of file +} diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts index 70a0c6c..570d677 100644 --- a/src/auth/guards/jwt-auth.guard.ts +++ b/src/auth/guards/jwt-auth.guard.ts @@ -1,4 +1,8 @@ -import { ExecutionContext, Injectable } from '@nestjs/common'; +import { + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; @@ -28,4 +32,4 @@ export class JwtAuthGuard extends AuthGuard('jwt') { } return user; } -} \ No newline at end of file +} diff --git a/src/backup/backup.service.ts b/src/backup/backup.service.ts index 927d5d5..400b087 100644 --- a/src/backup/backup.service.ts +++ b/src/backup/backup.service.ts @@ -4,7 +4,7 @@ import { NotFoundException, BadRequestException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectRepository, InjectDataSource } from '@nestjs/typeorm'; import { Repository, DataSource, LessThan } from 'typeorm'; import { ConfigService } from '@nestjs/config'; import { v4 as uuidv4 } from 'uuid'; @@ -47,6 +47,7 @@ export class BackupService { constructor( @InjectRepository(Backup) private readonly backupRepository: Repository, + @InjectDataSource() private readonly dataSource: DataSource, private readonly configService: ConfigService, ) { diff --git a/src/data-source.ts b/src/data-source.ts index ad0b3d9..04a265a 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -6,8 +6,10 @@ import { User } from './auth/entities/user.entity'; import { EmailToken } from './auth/entities/email-token.entity'; import { PasswordReset } from './auth/entities/password-reset.entity'; import { Portfolio } from './auth/entities/portfolio.entity'; +import { SkillVerification } from './auth/entities/skills-verification.entity'; import { Post } from './feed/entities/post.entity'; import { Comment } from './feed/entities/comment.entity'; +import { Like } from './feed/entities/like.entity'; import { SavedPost } from './feed/entities/savedpost.entity'; import { Job } from './jobs/entities/job.entity'; import { SavedJob } from './jobs/entities/saved-job.entity'; @@ -33,8 +35,10 @@ export const AppDataSource = new DataSource({ EmailToken, PasswordReset, Portfolio, + SkillVerification, Post, Comment, + Like, SavedPost, Job, SavedJob, diff --git a/src/feed/entities/comment.entity.ts b/src/feed/entities/comment.entity.ts index b35ded9..ac24d05 100644 --- a/src/feed/entities/comment.entity.ts +++ b/src/feed/entities/comment.entity.ts @@ -1,5 +1,5 @@ import { User } from 'src/auth/entities/user.entity'; -import { Post } from 'src/post/entities/post.entity'; +import { Post } from './post.entity'; import { Entity, PrimaryGeneratedColumn, diff --git a/src/feed/entities/like.entity.ts b/src/feed/entities/like.entity.ts index 08dadca..394327e 100644 --- a/src/feed/entities/like.entity.ts +++ b/src/feed/entities/like.entity.ts @@ -6,7 +6,7 @@ import { CreateDateColumn, Unique, } from 'typeorm'; -import { Post } from '../../post/entities/post.entity'; +import { Post } from './post.entity'; import { User } from '../../auth/entities/user.entity'; @Entity() diff --git a/src/feed/entities/post.entity.ts b/src/feed/entities/post.entity.ts index b4aa88e..cc4d5d0 100644 --- a/src/feed/entities/post.entity.ts +++ b/src/feed/entities/post.entity.ts @@ -3,9 +3,13 @@ import { PrimaryGeneratedColumn, Column, ManyToOne, + OneToMany, CreateDateColumn, } from 'typeorm'; import { User } from 'src/auth/entities/user.entity'; +import { Like } from './like.entity'; +import { Comment } from './comment.entity'; +import { SavedPost } from './savedpost.entity'; @Entity() export class Post { @@ -18,12 +22,18 @@ export class Post { @Column({ nullable: true }) image?: string; - @ManyToOne(() => User, (user) => user.posts, { - eager: true, - onDelete: 'CASCADE', - }) + @ManyToOne(() => User, (user) => user.posts) user: User; + @OneToMany(() => Like, (like) => like.post) + likes: Like[]; + + @OneToMany(() => Comment, (comment) => comment.post) + comments: Comment[]; + + @OneToMany(() => SavedPost, (savedPost) => savedPost.post) + savedBy: SavedPost[]; + @CreateDateColumn() createdAt: Date; } diff --git a/src/feed/entities/savedpost.entity.ts b/src/feed/entities/savedpost.entity.ts index 0b02e6e..6baf352 100644 --- a/src/feed/entities/savedpost.entity.ts +++ b/src/feed/entities/savedpost.entity.ts @@ -5,7 +5,7 @@ import { JoinColumn, CreateDateColumn, } from 'typeorm'; -import { Post } from 'src/post/entities/post.entity'; +import { Post } from './post.entity'; import { User } from 'src/auth/entities/user.entity'; @Entity() diff --git a/src/feed/enums/job-status.enum.ts b/src/feed/enums/job-status.enum.ts index 944dd3d..afbb3ab 100644 --- a/src/feed/enums/job-status.enum.ts +++ b/src/feed/enums/job-status.enum.ts @@ -1,6 +1,9 @@ export enum JobStatus { OPEN = 'open', + ACTIVE = 'active', CLOSED = 'closed', + EXPIRED = 'expired', + ARCHIVED = 'archived', APPROVED = 'approved', REJECTED = 'rejected', INPROGRESS = 'in_progress', diff --git a/src/feed/feed.service.ts b/src/feed/feed.service.ts index c3c497a..6ef747d 100644 --- a/src/feed/feed.service.ts +++ b/src/feed/feed.service.ts @@ -134,11 +134,13 @@ export class FeedService { const updatedJob = await this.jobRepo.save(job); - await this.notificationsService.sendJobStatusNotification( - job.freelancer.id, - job.title, - status as 'approved' | 'rejected', - ); + if (job.freelancer) { + await this.notificationsService.sendJobStatusNotification( + job.freelancer, + job.title, + status as 'approved' | 'rejected', + ); + } return updatedJob; } @@ -242,4 +244,4 @@ export class FeedService { remove(id: number) { return `This action removes a #${id} feed`; } -} \ No newline at end of file +} diff --git a/src/job-posting/entities/job.entity.ts b/src/job-posting/entities/job.entity.ts index 1771d89..0e0a3b7 100644 --- a/src/job-posting/entities/job.entity.ts +++ b/src/job-posting/entities/job.entity.ts @@ -195,6 +195,8 @@ export enum JobStatus { INPROGRESS = 'in_progress', REJECTED = 'rejected', OPEN = 'open', // Added from your original entity + EXPIRED = 'expired', + ARCHIVED = 'archived', } export enum CompletionStatus { diff --git a/src/job-posting/job.module.ts b/src/job-posting/job.module.ts index 23d72cf..9a2b71b 100644 --- a/src/job-posting/job.module.ts +++ b/src/job-posting/job.module.ts @@ -10,4 +10,4 @@ import { Job } from './entities/job.entity'; providers: [JobService], exports: [JobService], }) -export class JobModule {} +export class JobPostingModule {} diff --git a/src/jobs/adapters/job.adapter.ts b/src/jobs/adapters/job.adapter.ts index f19db19..f884b3c 100644 --- a/src/jobs/adapters/job.adapter.ts +++ b/src/jobs/adapters/job.adapter.ts @@ -8,7 +8,37 @@ export class JobAdapter { * Convert a single Job entity to the format expected by JobResponseDto */ static toJobPostingEntity(job: Job): JobResponseDto { - return new JobResponseDto(job); + // Create a compatible object that matches the job-posting Job interface + const compatibleJob = { + id: job.id, + title: job.title, + description: job.description, + company: job.company || '', + location: job.location || '', + jobType: job.jobType || 'full_time', + status: job.status, + experienceLevel: job.experienceLevel || 'mid', + salaryMin: job.salaryMin, + salaryMax: job.salaryMax, + salaryCurrency: job.salaryCurrency, + requirements: job.requirements || [], + responsibilities: job.responsibilities || [], + benefits: job.benefits || [], + skills: job.skills || [], + contactEmail: job.contactEmail, + contactPhone: job.contactPhone, + applicationDeadline: job.applicationDeadline, + isRemote: job.isRemote || false, + isUrgent: job.isUrgent || false, + isFeatured: job.isFeatured || false, + viewCount: job.viewCount || 0, + applicationCount: job.applicationCount || 0, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + freelancer: job.freelancer || null, + }; + + return new JobResponseDto(compatibleJob as any); } /** diff --git a/src/jobs/blockchain/blockchain.service.ts b/src/jobs/blockchain/blockchain.service.ts index 6f2da32..3969f58 100644 --- a/src/jobs/blockchain/blockchain.service.ts +++ b/src/jobs/blockchain/blockchain.service.ts @@ -184,4 +184,25 @@ export class BlockchainService { // return Number(balance) / (10 ** tokenMeta.decimals); return 1000; // Mock: 1000 units (e.g., USDC, STRK) } + + // Escrow methods for the jobs service + async lockFunds( + walletAddress: string, + currency: string, + amount: number, + ): Promise { + this.logger.log(`Locking ${amount} ${currency} from ${walletAddress}`); + // TODO: Implement actual blockchain fund locking + // For now, this is a mock implementation + } + + async releaseFunds( + walletAddress: string, + currency: string, + amount: number, + ): Promise { + this.logger.log(`Releasing ${amount} ${currency} to ${walletAddress}`); + // TODO: Implement actual blockchain fund release + // For now, this is a mock implementation + } } diff --git a/src/jobs/dto/initiate-payment.dto.ts b/src/jobs/dto/initiate-payment.dto.ts index ddd1b02..cfa3c38 100644 --- a/src/jobs/dto/initiate-payment.dto.ts +++ b/src/jobs/dto/initiate-payment.dto.ts @@ -16,3 +16,5 @@ export class InitiatePaymentDto { @IsEnum(['ETH', 'USDC', 'STRK']) currency: 'ETH' | 'USDC' | 'STRK'; } + +export class CreateEscrowDto extends InitiatePaymentDto {} diff --git a/src/jobs/dto/pagination.dto.ts b/src/jobs/dto/pagination.dto.ts new file mode 100644 index 0000000..f73ab09 --- /dev/null +++ b/src/jobs/dto/pagination.dto.ts @@ -0,0 +1,103 @@ +import { + IsOptional, + IsNumber, + IsEnum, + Min, + Max, + IsString, + IsBoolean, +} from 'class-validator'; +import { Type, Transform } from 'class-transformer'; +import { JobType, ExperienceLevel } from '../entities/job.entity'; + +export enum JobSortOrder { + ASC = 'asc', + DESC = 'desc', +} + +export enum JobSortField { + CREATED_AT = 'createdAt', + TITLE = 'title', + BUDGET = 'budget', + DEADLINE = 'deadline', + STATUS = 'status', + VIEW_COUNT = 'viewCount', + APPLICATION_COUNT = 'applicationCount', + SALARY_MIN = 'salaryMin', + SALARY_MAX = 'salaryMax', +} + +export class PaginationDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 10; + + @IsOptional() + @IsEnum(JobSortField) + sortBy?: JobSortField = JobSortField.CREATED_AT; + + @IsOptional() + @IsEnum(JobSortOrder) + sortOrder?: JobSortOrder = JobSortOrder.DESC; + + // Optional filters for job listings + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + location?: string; + + @IsOptional() + @IsString() + company?: string; + + @IsOptional() + @IsEnum(JobType) + jobType?: JobType; + + @IsOptional() + @IsEnum(ExperienceLevel) + experienceLevel?: ExperienceLevel; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + salaryMin?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + salaryMax?: number; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isRemote?: boolean; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isUrgent?: boolean; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isFeatured?: boolean; + + @IsOptional() + @IsString() + skills?: string; +} diff --git a/src/jobs/dto/release-payment.dto.ts b/src/jobs/dto/release-payment.dto.ts new file mode 100644 index 0000000..d42e01c --- /dev/null +++ b/src/jobs/dto/release-payment.dto.ts @@ -0,0 +1,9 @@ +import { IsUUID } from 'class-validator'; + +export class ReleaseEscrowDto { + @IsUUID() + escrowId: string; + + @IsUUID() + releasedBy: string; +} diff --git a/src/jobs/entities/escrow.entity.ts b/src/jobs/entities/escrow.entity.ts index 6ba08bc..306dd42 100644 --- a/src/jobs/entities/escrow.entity.ts +++ b/src/jobs/entities/escrow.entity.ts @@ -1,4 +1,13 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Job } from './job.entity'; export enum EscrowStatus { LOCKED = 'locked', @@ -15,6 +24,10 @@ export class Escrow { @Column() jobId: string; + @ManyToOne(() => Job, { eager: true }) + @JoinColumn({ name: 'jobId' }) + job: Job; + @Column() recruiterId: string; @@ -30,6 +43,18 @@ export class Escrow { @Column({ type: 'enum', enum: EscrowStatus, default: EscrowStatus.LOCKED }) status: EscrowStatus; + @Column({ type: 'timestamp', nullable: true }) + lockedAt?: Date; + + @Column({ type: 'timestamp', nullable: true }) + releasedAt?: Date; + + @Column({ type: 'timestamp', nullable: true }) + disputedAt?: Date; + + @Column({ type: 'text', nullable: true }) + disputeReason?: string; + @CreateDateColumn() createdAt: Date; diff --git a/src/jobs/entities/job.entity.ts b/src/jobs/entities/job.entity.ts index b82fbb8..a326c2b 100644 --- a/src/jobs/entities/job.entity.ts +++ b/src/jobs/entities/job.entity.ts @@ -308,8 +308,12 @@ export class Job { @Column() recruiterId: string; + @ManyToOne(() => User, { nullable: true, eager: false }) + @JoinColumn({ name: 'freelancerId' }) + freelancer?: User; + @Column({ nullable: true }) - freelancer: any; + freelancerId?: string; // Team functionality @ManyToOne(() => Team, { nullable: true, eager: false }) @@ -348,5 +352,15 @@ export class Job { @Column({ type: 'varchar', length: 20, nullable: true }) currency?: string; + + @Column({ nullable: true }) + escrowId?: string; + + @Column({ type: 'timestamp', nullable: true }) + expiredAt?: Date; + + @Column({ type: 'timestamp', nullable: true }) + archivedAt?: Date; + employer: any; } diff --git a/src/jobs/jobs.controller.ts b/src/jobs/jobs.controller.ts index 5be3284..7171e23 100644 --- a/src/jobs/jobs.controller.ts +++ b/src/jobs/jobs.controller.ts @@ -30,6 +30,8 @@ import { GetUser } from 'src/auth/decorators/get-user.decorator'; import { User } from 'src/auth/entities/user.entity'; import { BlockchainService } from './blockchain/blockchain.service'; import { PaymentRequestDto } from './dto/payment-request.dto'; +import { InitiatePaymentDto } from './dto/initiate-payment.dto'; +import { ExtendJobDto } from './dto/extend-job.dto'; import { ApiTags, ApiOperation, @@ -46,6 +48,7 @@ import { } from './dto/job-template.dto'; import { JobTemplate } from './entities/job-template.entity'; import { AuthGuardGuard } from '../auth/guards/auth.guard'; +import { PaginationDto } from './dto/pagination.dto'; // For Express Request typing import { Request } from 'express'; @@ -85,8 +88,54 @@ export class JobsController { } @Get() - findAll() { - return this.jobsService.findAllJobs(); + @ApiOperation({ + summary: 'Get paginated list of job postings', + description: + 'Retrieve a paginated list of active job postings with optional filtering. Newest jobs are displayed first by default. This is a public endpoint accessible without authentication.', + }) + @ApiResponse({ + status: 200, + description: + 'Paginated list of job postings with filtering and sorting support', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + title: { type: 'string' }, + description: { type: 'string' }, + company: { type: 'string' }, + location: { type: 'string' }, + jobType: { type: 'string' }, + experienceLevel: { type: 'string' }, + salaryMin: { type: 'number' }, + salaryMax: { type: 'number' }, + skills: { type: 'array', items: { type: 'string' } }, + isRemote: { type: 'boolean' }, + isUrgent: { type: 'boolean' }, + isFeatured: { type: 'boolean' }, + viewCount: { type: 'number' }, + applicationCount: { type: 'number' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + total: { + type: 'number', + description: 'Total number of jobs matching the criteria', + }, + page: { type: 'number', description: 'Current page number' }, + limit: { type: 'number', description: 'Number of items per page' }, + totalPages: { type: 'number', description: 'Total number of pages' }, + }, + }, + }) + findAll(@Query() paginationDto: PaginationDto) { + return this.jobsService.findAll(paginationDto); } @Get('search') @@ -385,12 +434,8 @@ export class JobsController { ); } - - @Post(':id/extend') - async extendJob( - @Param('id') id: string, - @Body() extendJobDto: ExtendJobDto, - ) { + @Post(':id/extend') + async extendJob(@Param('id') id: string, @Body() extendJobDto: ExtendJobDto) { return this.jobsService.extendJob(id, extendJobDto); } @@ -426,11 +471,3 @@ export class JobsController { return { message: 'Inactive jobs archived' }; } } -function advancedSearch(arg0: any, query: any, SearchJobsDto: typeof SearchJobsDto) { - throw new Error('Function not implemented.'); -} - -function findAll() { - throw new Error('Function not implemented.'); -} - diff --git a/src/jobs/jobs.module.ts b/src/jobs/jobs.module.ts index 1397fc4..73e9a0b 100644 --- a/src/jobs/jobs.module.ts +++ b/src/jobs/jobs.module.ts @@ -9,18 +9,13 @@ import { AntiSpamModule } from '../anti-spam/anti-spam.module'; import { JobsController } from './jobs.controller'; import { FeedModule } from 'src/feed/feed.module'; import { Job } from './entities/job.entity'; +import { Escrow } from './entities/escrow.entity'; import { SavedJob } from './entities/saved-job.entity'; import { Recommendation } from './entities/recommendation.entity'; import { ExcludeSoftDeleteInterceptor } from 'src/common/interceptors/exclude-soft-delete.interceptor'; import { BlockchainService } from './blockchain/blockchain.service'; import { JobTemplate } from './entities/job-template.entity'; -import { ScheduleModule } from '@nestjs/schedule'; import { JobCleanupTask } from './tasks/job-cleanup.task'; - -@Module({ - imports: [ScheduleModule.forRoot()], - providers: [JobCleanupTask], -}) import { CurrencyConversionService } from './services/currency-conversion.service'; import { Transaction } from './entities/transaction.entity'; @@ -36,12 +31,13 @@ import { Transaction } from './entities/transaction.entity'; Recommendation, User, Transaction, + Escrow, ]), AntiSpamModule, ], providers: [ JobsService, - JobCleanupTask, + // JobCleanupTask, RecommendationService, { provide: APP_INTERCEPTOR, diff --git a/src/jobs/jobs.service.ts b/src/jobs/jobs.service.ts index 2171ebd..8f2231a 100644 --- a/src/jobs/jobs.service.ts +++ b/src/jobs/jobs.service.ts @@ -3,11 +3,19 @@ import { NotFoundException, ForbiddenException, BadRequestException, - Inject, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource, Brackets } from 'typeorm'; -import { CompletionStatus, Job, JobStatus } from './entities/job.entity'; +import { InjectRepository, InjectDataSource } from '@nestjs/typeorm'; +import { + Repository, + DataSource, + Brackets, + LessThan, + MoreThanOrEqual, + LessThanOrEqual, + Between, +} from 'typeorm'; +import { CompletionStatus, Job, ExperienceLevel } from './entities/job.entity'; +import { Escrow, EscrowStatus } from './entities/escrow.entity'; import { Application } from 'src/applications/entities/application.entity'; import { SavedJob } from './entities/saved-job.entity'; import { CreateJobDto } from './dto/create-job.dto'; @@ -27,19 +35,21 @@ import { CreateTemplateDto, UpdateTemplateDto, } from './dto/job-template.dto'; -import { Job, CompletionStatus, ExperienceLevel } from './entities/job.entity'; import { BlockchainService } from './blockchain/blockchain.service'; import { CurrencyConversionService } from './services/currency-conversion.service'; import { User } from '../auth/entities/user.entity'; import { JobStatus } from 'src/feed/enums/job-status.enum'; import { CreateEscrowDto } from './dto/initiate-payment.dto'; import { ReleaseEscrowDto } from './dto/release-payment.dto'; +import { ExtendJobDto } from './dto/extend-job.dto'; +import { JobAdapter } from './adapters/job.adapter'; +import { PaginationDto } from './dto/pagination.dto'; @Injectable() export class JobsService { logger: any; mailerService: any; - + constructor( @InjectRepository(Job) private readonly jobRepository: Repository, @@ -58,29 +68,139 @@ export class JobsService { @InjectRepository(JobTemplate) private readonly templateRepository: Repository, - @Inject(DataSource) + @InjectRepository(Escrow) + private readonly escrowRepository: Repository, + + @InjectDataSource() private readonly dataSource: DataSource, private readonly blockchainService: BlockchainService, private readonly currencyConversionService: CurrencyConversionService, - - @InjectRepository(Escrow) - private readonly escrowRepository: Repository, - private readonly blockchainService: BlockchainService, ) {} - async findAll(): Promise { - return this.jobRepository.find(); + async findAll( + paginationDto?: PaginationDto, + ): Promise { + const { + page = 1, + limit = 10, + sortBy = 'createdAt', + sortOrder = 'desc', + search, + location, + company, + jobType, + experienceLevel, + salaryMin, + salaryMax, + isRemote, + isUrgent, + isFeatured, + skills, + } = paginationDto || {}; + + const skip = (page - 1) * limit; + + const query = this.jobRepository.createQueryBuilder('job'); + + // Only show open/active jobs for public access + query.where('job.status IN (:...activeStatuses)', { + activeStatuses: [JobStatus.OPEN, JobStatus.ACTIVE], + }); + + // Only show jobs that are accepting applications + query.andWhere('job.isAcceptingApplications = :isAccepting', { + isAccepting: true, + }); + + // Apply filters + if (search) { + query.andWhere( + '(job.title ILIKE :search OR job.description ILIKE :search OR job.company ILIKE :search)', + { search: `%${search}%` }, + ); + } + + if (location) { + query.andWhere('job.location ILIKE :location', { + location: `%${location}%`, + }); + } + + if (company) { + query.andWhere('job.company ILIKE :company', { + company: `%${company}%`, + }); + } + + if (jobType) { + query.andWhere('job.jobType = :jobType', { jobType }); + } + + if (experienceLevel) { + query.andWhere('job.experienceLevel = :experienceLevel', { + experienceLevel, + }); + } + + if (salaryMin !== undefined) { + query.andWhere('job.salaryMax >= :salaryMin', { salaryMin }); + } + + if (salaryMax !== undefined) { + query.andWhere('job.salaryMin <= :salaryMax', { salaryMax }); + } + + if (isRemote !== undefined) { + query.andWhere('job.isRemote = :isRemote', { isRemote }); + } + + if (isUrgent !== undefined) { + query.andWhere('job.isUrgent = :isUrgent', { isUrgent }); + } + + if (isFeatured !== undefined) { + query.andWhere('job.isFeatured = :isFeatured', { isFeatured }); + } + + if (skills) { + // Split skills by comma and search for any of them + const skillsArray = skills.split(',').map((skill) => skill.trim()); + query.andWhere('job.skills && :skills', { skills: skillsArray }); + } + + // Apply sorting - default to newest first (createdAt DESC) + const sortDirection = sortOrder.toUpperCase() as 'ASC' | 'DESC'; + query.orderBy(`job.${sortBy}`, sortDirection); + + // Apply pagination + query.skip(skip).take(limit); + + const [jobs, total] = await query.getManyAndCount(); + + // Convert jobs using adapter to ensure proper response format + const convertedJobs = jobs.map((job) => JobAdapter.toJobPostingEntity(job)); + + return new PaginatedJobResponseDto( + convertedJobs as any, + total, + page, + limit, + ); } async findOne(id: string): Promise { - const job = await this.jobRepository.findOne({ where: { id } }); + const job = await this.jobRepository.findOne({ + where: { id: parseInt(id, 10) }, + }); if (!job) throw new NotFoundException('Job not found'); return job; } async initiateEscrowPayment(dto: CreateEscrowDto): Promise { - const job = await this.jobRepository.findOne({ where: { id: dto.jobId } }); + const job = await this.jobRepository.findOne({ + where: { id: parseInt(dto.jobId, 10) }, + }); if (!job) throw new NotFoundException('Job not found'); if (job.escrowId) throw new BadRequestException('Escrow already initiated for this job'); @@ -110,7 +230,7 @@ export class JobsService { freelancerId: dto.freelancerId, amount: dto.amount, currency: dto.currency, - status: 'LOCKED', + status: EscrowStatus.LOCKED, lockedAt: new Date(), }); @@ -126,7 +246,7 @@ export class JobsService { relations: ['job'], }); - if (!escrow || escrow.status !== 'LOCKED') + if (!escrow || escrow.status !== EscrowStatus.LOCKED) throw new BadRequestException('Escrow not in a valid state for release'); const freelancer = await this.userRepository.findOne({ @@ -142,7 +262,7 @@ export class JobsService { escrow.amount, ); - escrow.status = 'RELEASED'; + escrow.status = EscrowStatus.RELEASED; escrow.releasedAt = new Date(); escrow.job.paymentReleased = true; await this.jobRepository.save(escrow.job); @@ -150,16 +270,32 @@ export class JobsService { } async handleDispute(escrowId: string, reason: string): Promise { - const escrow = await this.escrowRepository.findOne({ where: { id: escrowId } }); + const escrow = await this.escrowRepository.findOne({ + where: { id: escrowId }, + }); if (!escrow) throw new NotFoundException('Escrow not found'); - escrow.status = 'DISPUTED'; + escrow.status = EscrowStatus.DISPUTED; escrow.disputeReason = reason; escrow.disputedAt = new Date(); return this.escrowRepository.save(escrow); } + // Alias methods for controller compatibility + async initiateEscrow(dto: CreateEscrowDto): Promise { + return this.initiateEscrowPayment(dto); + } + + async releaseEscrow(escrowId: string): Promise { + const dto: ReleaseEscrowDto = { escrowId, releasedBy: 'system' }; + return this.releaseEscrowPayment(dto); + } + + async markEscrowAsDisputed(escrowId: string): Promise { + return this.handleDispute(escrowId, 'Disputed by user'); + } + async createJob(createJobDto: CreateJobDto): Promise { if ( createJobDto.currency && @@ -292,16 +428,6 @@ export class JobsService { return application; } - async createApplication(dto: CreateApplicationDto): Promise { - const jobId = typeof dto.jobId === 'string' ? parseInt(dto.jobId, 10) : dto.jobId; - const job = await this.jobRepository.findOne({ where: { id: jobId } }); - if (!job) throw new NotFoundException(`Job with ID ${dto.jobId} not found`); - if (!job.isAcceptingApplications) - throw new ForbiddenException('This job is not accepting applications.'); - const app = this.applicationRepository.create(dto); - return this.applicationRepository.save(app); - } - async updateApplication( id: number, dto: UpdateApplicationDto, @@ -464,16 +590,22 @@ export class JobsService { .take(safeLimit) .getManyAndCount(); - return new PaginatedJobResponseDto(jobs, total, safePage, safeLimit); + const adaptedJobs = jobs.map((job) => JobAdapter.toJobPostingEntity(job)); + return new PaginatedJobResponseDto( + adaptedJobs as any, + total, + safePage, + safeLimit, + ); } async getSingleJobAsDto(id: number): Promise { const job = await this.findJobById(id); await this.incrementViewCount(id); - const convertedJob = JobAdapter?.toJobPostingEntity - ? JobAdapter.toJobPostingEntity(job) - : job; - return new JobResponseDto(convertedJob); + // const convertedJob = JobAdapter?.toJobPostingEntity + // ? JobAdapter.toJobPostingEntity(job) + // : job; + return JobAdapter.toJobPostingEntity(job); } // Template methods remain the same @@ -821,16 +953,16 @@ export class JobsService { } query.skip(skip).take(limit); const [jobs, total] = await query.getManyAndCount(); - return new PaginatedJobResponseDto(jobs, total, page, limit); + const adaptedJobs = jobs.map((job) => JobAdapter.toJobPostingEntity(job)); + return new PaginatedJobResponseDto(adaptedJobs as any, total, page, limit); } - - async findExpiredJobs(): Promise { + async findExpiredJobs(): Promise { const now = new Date(); return this.jobRepository.find({ where: { - deadline: { $lt: now }, - status: 'active', + deadline: LessThan(now), + status: JobStatus.ACTIVE, }, relations: ['employer'], }); @@ -840,11 +972,11 @@ export class JobsService { const futureDate = new Date(); futureDate.setDate(futureDate.getDate() + days); const now = new Date(); - + return this.jobRepository.find({ where: { - deadline: { $gte: now, $lte: futureDate }, - status: 'active', + deadline: Between(now, futureDate), + status: JobStatus.ACTIVE, }, relations: ['employer'], }); @@ -852,7 +984,7 @@ export class JobsService { async expireJob(jobId: string): Promise { const job = await this.jobRepository.findOne({ - where: { id: jobId }, + where: { id: parseInt(jobId) }, relations: ['employer'], }); @@ -860,28 +992,28 @@ export class JobsService { throw new Error('Job not found'); } - job.status = 'expired'; + job.status = JobStatus.EXPIRED; job.expiredAt = new Date(); return this.jobRepository.save(job); } async archiveJob(jobId: string): Promise { const job = await this.jobRepository.findOne({ - where: { id: jobId }, + where: { id: parseInt(jobId) }, }); if (!job) { throw new Error('Job not found'); } - job.status = 'archived'; + job.status = JobStatus.ARCHIVED; job.archivedAt = new Date(); return this.jobRepository.save(job); } async extendJob(jobId: string, extendJobDto: ExtendJobDto): Promise { const job = await this.jobRepository.findOne({ - where: { id: jobId }, + where: { id: parseInt(jobId) }, relations: ['employer'], }); @@ -889,7 +1021,7 @@ export class JobsService { throw new Error('Job not found'); } - if (job.status !== 'active' && job.status !== 'expired') { + if (job.status !== JobStatus.ACTIVE && job.status !== JobStatus.EXPIRED) { throw new Error('Job cannot be extended'); } @@ -899,15 +1031,15 @@ export class JobsService { } job.deadline = newDeadline; - job.status = 'active'; + job.status = JobStatus.ACTIVE; job.updatedAt = new Date(); - + return this.jobRepository.save(job); } async renewJob(jobId: string): Promise { const job = await this.jobRepository.findOne({ - where: { id: jobId }, + where: { id: parseInt(jobId) }, relations: ['employer'], }); @@ -919,15 +1051,19 @@ export class JobsService { newDeadline.setMonth(newDeadline.getMonth() + 1); job.deadline = newDeadline; - job.status = 'active'; + job.status = JobStatus.ACTIVE; job.updatedAt = new Date(); - + return this.jobRepository.save(job); } async sendExpiryNotification(job: Job): Promise { + if (!job.deadline) { + return; // Skip jobs without deadline + } + const daysUntilExpiry = Math.ceil( - (job.deadline.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24) + (job.deadline.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24), ); await this.mailerService.sendMail({ @@ -945,16 +1081,16 @@ export class JobsService { async processJobExpiry(): Promise { const expiredJobs = await this.findExpiredJobs(); - + for (const job of expiredJobs) { - await this.expireJob(job.id); + await this.expireJob(job.id.toString()); this.logger.log(`Job ${job.id} expired`); } } async sendExpiryNotifications(): Promise { const jobsExpiringSoon = await this.findJobsExpiringIn(3); - + for (const job of jobsExpiringSoon) { await this.sendExpiryNotification(job); this.logger.log(`Expiry notification sent for job ${job.id}`); @@ -967,18 +1103,18 @@ export class JobsService { return this.jobRepository.find({ where: { - status: 'active', - lastActivity: { $lt: cutoffDate }, + status: JobStatus.ACTIVE, + updatedAt: LessThan(cutoffDate), }, }); } async archiveInactiveJobs(): Promise { const inactiveJobs = await this.findInactiveJobs(); - + for (const job of inactiveJobs) { await this.archiveJob(job.id.toString()); this.logger.log(`Job ${job.id} archived due to inactivity`); } } -} \ No newline at end of file +} diff --git a/src/jobs/recommendation.service.ts b/src/jobs/recommendation.service.ts index 0a35da4..dd91479 100644 --- a/src/jobs/recommendation.service.ts +++ b/src/jobs/recommendation.service.ts @@ -3,7 +3,7 @@ import { NotFoundException, UnauthorizedException, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectRepository, InjectDataSource } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { Recommendation } from './entities/recommendation.entity'; import { Job } from './entities/job.entity'; @@ -35,6 +35,7 @@ export class RecommendationService { private readonly applicationRepository: Repository, @InjectRepository(SavedJob) private readonly savedJobRepository: Repository, + @InjectDataSource() private readonly dataSource: DataSource, ) {} diff --git a/src/jobs/tasks/job-cleanup.task.ts b/src/jobs/tasks/job-cleanup.task.ts index cc951cf..2676b01 100644 --- a/src/jobs/tasks/job-cleanup.task.ts +++ b/src/jobs/tasks/job-cleanup.task.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { JobsService } from '../jobs.service'; @@ -6,7 +6,10 @@ import { JobsService } from '../jobs.service'; export class JobCleanupTask { private readonly logger = new Logger(JobCleanupTask.name); - constructor(private readonly jobsService: JobsService) {} + constructor( + @Inject(forwardRef(() => JobsService)) + private readonly jobsService: JobsService, + ) {} @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) async handleExpiredJobs() { diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts index 4daa258..ddbfcb1 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -15,59 +15,52 @@ export interface EmailOptions { @Injectable() export class MailService { - private transporter: nodemailer.Transporter; + private transporter: nodemailer.Transporter | null; constructor(private readonly configService: ConfigService) { this.initializeTransporter(); } private initializeTransporter() { - const host = this.configService.get('SMTP_HOST'); - const port = this.configService.get('SMTP_PORT'); - const secure = this.configService.get('SMTP_SECURE', false); - const user = this.configService.get('SMTP_USER'); - const pass = this.configService.get('SMTP_PASSWORD'); - const from = this.configService.get( - 'SMTP_FROM', - 'noreply@starkhive.com', - ); - - this.transporter = nodemailer.createTransport({ - host, - port, - secure, // true for 465, false for other ports - auth: { - user, - pass, - }, - }); + try { + const host = this.configService.get('SMTP_HOST'); + const port = this.configService.get('SMTP_PORT'); + const secure = this.configService.get('SMTP_SECURE', false); + const user = this.configService.get('SMTP_USER'); + const pass = this.configService.get('SMTP_PASSWORD'); - // Verify connection configuration - this.transporter.verify((error) => { - if (error) { - console.error('Error with mailer configuration:', error); - } else { - console.log('Mailer is ready to send emails'); - } - }); - } + this.transporter = nodemailer.createTransport({ + host, + port, + secure, // true for 465, false for other ports + auth: { + user, + pass, + }, + }); - private renderTemplate( - templateName: string, - context: Record, - ): string { - const templatePath = path.join( - __dirname, - '../notifications/templates', - templateName, - ); - const templateSource = fs.readFileSync(templatePath, 'utf8'); - const template = Handlebars.compile(templateSource); - return template(context); + // Verify connection configuration + this.transporter.verify((error) => { + if (error) { + console.error('Error with mailer configuration:', error); + } else { + console.log('Mailer is ready to send emails'); + } + }); + } catch (error) { + console.error('Failed to initialize mail transporter:', error); + // Set a null transporter to prevent crashes + this.transporter = null; + } } async sendEmail(options: EmailOptions): Promise { try { + if (!this.transporter) { + console.warn('Mail transporter not initialized, skipping email send'); + return false; + } + const html = this.renderTemplate(options.template, options.context); const mailOptions: nodemailer.SendMailOptions = { from: `"StarkHive" <${this.configService.get('SMTP_FROM', 'noreply@starkhive.com')}>`, @@ -82,47 +75,100 @@ export class MailService { return true; } catch (error) { console.error('Error sending email:', error); - throw new Error('Failed to send email'); + return false; + } + } + + private renderTemplate( + templateName: string, + context: Record, + ): string { + try { + const templatePath = path.join( + __dirname, + '..', + 'templates', + `${templateName}.hbs`, + ); + const templateSource = fs.readFileSync(templatePath, 'utf8'); + const template = Handlebars.compile(templateSource); + return template(context); + } catch (error) { + console.error(`Error rendering template ${templateName}:`, error); + return `

Error rendering email template

`; } } - // Helper method to send verification email async sendVerificationEmail( email: string, + name: string, verificationUrl: string, ): Promise { - const subject = 'Verify your email address'; - const html = ` -
-

Welcome to StarkHive!

-

Thank you for registering. Please verify your email address by clicking the button below:

- -

Or copy and paste this link into your browser:

-

${verificationUrl}

-

If you didn't create an account, you can safely ignore this email.

-
-

- This email was sent to ${email} because you registered an account on StarkHive. -

-
- `; + return this.sendEmail({ + to: email, + subject: 'Verify your email address', + template: 'verification', + context: { + name, + verificationUrl, + }, + }); + } + + async sendPasswordResetEmail( + email: string, + name: string, + resetUrl: string, + ): Promise { + return this.sendEmail({ + to: email, + subject: 'Reset your password', + template: 'password-reset', + context: { + name, + resetUrl, + }, + }); + } + + async sendWelcomeEmail(email: string, name: string): Promise { + return this.sendEmail({ + to: email, + subject: 'Welcome to StarkHive!', + template: 'welcome', + context: { + name, + }, + }); + } + async sendJobApplicationEmail( + email: string, + applicantName: string, + jobTitle: string, + ): Promise { + return this.sendEmail({ + to: email, + subject: `New application for ${jobTitle}`, + template: 'job-application', + context: { + applicantName, + jobTitle, + }, + }); + } + + async sendCustomEmail( + email: string, + subject: string, + html: string, + ): Promise { return this.sendEmail({ to: email, subject, template: '', // Not using template for verification context: {}, - html, + text: html.replace(/<[^>]*>/g, ''), // Strip HTML for text version } as any); } } diff --git a/src/main.ts b/src/main.ts index c27b0d0..d0eff46 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,7 @@ import { } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import helmet from 'helmet'; -import * as compression from 'compression'; +import compression from 'compression'; import { ConfigService } from '@nestjs/config'; import { useContainer } from 'class-validator'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts index 639d59d..fb837b5 100644 --- a/src/notifications/notifications.module.ts +++ b/src/notifications/notifications.module.ts @@ -8,12 +8,14 @@ import { Preferences } from './entities/preferences.entity'; import { AuthModule } from '../auth/auth.module'; import { ApplicationsModule } from 'src/applications/applications.module'; import { SmsService } from './services/sms.service'; +import { MailModule } from '../mail/mail.module'; @Module({ imports: [ forwardRef(() => ApplicationsModule), TypeOrmModule.forFeature([Notification, NotificationDelivery, Preferences]), forwardRef(() => AuthModule), + MailModule, ], controllers: [NotificationsController], providers: [NotificationsService, SmsService], diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 49dd42e..cc71dd5 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -204,21 +204,35 @@ export class NotificationsService { } } -async markAsUnread(notificationId: string, userId: string) { - const notification = await this.notificationRepository.findOne({ - where: { id: notificationId, userId }, - }); + async markAsUnread(notificationId: string, userId: string) { + const notification = await this.notificationRepository.findOne({ + where: { id: notificationId, userId }, + }); + + if (!notification) { + throw new NotFoundException('Notification not found'); + } - if (!notification) { - throw new NotFoundException('Notification not found'); + notification.isRead = false; + notification.updatedAt = new Date(); + + return await this.notificationRepository.save(notification); } - notification.isRead = false; - notification.updatedAt = new Date(); + async markAsRead(notificationId: string, userId: string) { + const notification = await this.notificationRepository.findOne({ + where: { id: notificationId, userId }, + }); - return await this.notificationRepository.save(notification); -} + if (!notification) { + throw new NotFoundException('Notification not found'); + } + notification.isRead = true; + notification.updatedAt = new Date(); + + return await this.notificationRepository.save(notification); + } async markAllAsRead(userId: string) { await this.notificationRepository.update(