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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/backend/src/jobs/dto/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { IsString, IsOptional, IsArray, IsEnum, IsNumber, IsDateString, Min } from 'class-validator';
import { JobStatus, ApplicationStatus } from '../job.entity';

export class CreateJobDto {
@IsString() title: string;
@IsString() description: string;
@IsString() @IsOptional() category?: string;
@IsArray() @IsOptional() requiredSkills?: string[];
@IsNumber() @IsOptional() @Min(0) budgetMin?: number;
@IsNumber() @IsOptional() @Min(0) budgetMax?: number;
@IsDateString() @IsOptional() expiresAt?: string;
}

export class UpdateJobDto {
@IsString() @IsOptional() title?: string;
@IsString() @IsOptional() description?: string;
@IsString() @IsOptional() category?: string;
@IsArray() @IsOptional() requiredSkills?: string[];
@IsEnum(JobStatus) @IsOptional() status?: JobStatus;
@IsDateString() @IsOptional() expiresAt?: string;
}

export class CreateApplicationDto {
@IsString() @IsOptional() coverLetter?: string;
}

export class UpdateApplicationStatusDto {
@IsEnum(ApplicationStatus) status: ApplicationStatus;
@IsString() @IsOptional() reviewNote?: string;
}

export class JobQueryDto {
@IsString() @IsOptional() search?: string;
@IsString() @IsOptional() category?: string;
@IsEnum(JobStatus) @IsOptional() status?: JobStatus;
@IsNumber() @IsOptional() @Min(1) page?: number;
@IsNumber() @IsOptional() @Min(1) limit?: number;
}
45 changes: 45 additions & 0 deletions apps/backend/src/jobs/job.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
Entity, PrimaryGeneratedColumn, Column, CreateDateColumn,
UpdateDateColumn, ManyToOne, JoinColumn, OneToMany, Index,
} from 'typeorm';
import { User } from '../users/user.entity';

export enum JobStatus { OPEN = 'open', CLOSED = 'closed', EXPIRED = 'expired', FILLED = 'filled' }
export enum ApplicationStatus { PENDING = 'pending', REVIEWED = 'reviewed', ACCEPTED = 'accepted', REJECTED = 'rejected', WITHDRAWN = 'withdrawn' }

@Entity('jobs')
@Index(['status', 'expiresAt'])
@Index(['instructorId'])
export class Job {
@PrimaryGeneratedColumn('uuid') id: string;
@Column() title: string;
@Column('text') description: string;
@Column({ default: '' }) category: string;
@Column('simple-array', { nullable: true }) requiredSkills: string[];
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) budgetMin?: number;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) budgetMax?: number;
@Column({ type: 'enum', enum: JobStatus, default: JobStatus.OPEN }) status: JobStatus;
@Column({ nullable: true }) instructorId: string;
@Column({ nullable: true, type: 'timestamptz' }) expiresAt?: Date;
@Column({ default: false }) isDeleted: boolean;
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date;
@ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) @JoinColumn({ name: 'instructorId' }) instructor: User;
@OneToMany(() => JobApplication, a => a.job) applications: JobApplication[];
}

@Entity('job_applications')
@Index(['jobId', 'applicantId'], { unique: true })
@Index(['applicantId', 'status'])
export class JobApplication {
@PrimaryGeneratedColumn('uuid') id: string;
@Column() jobId: string;
@Column() applicantId: string;
@Column('text', { nullable: true }) coverLetter?: string;
@Column({ type: 'enum', enum: ApplicationStatus, default: ApplicationStatus.PENDING }) status: ApplicationStatus;
@Column({ nullable: true, type: 'text' }) reviewNote?: string;
@CreateDateColumn() appliedAt: Date;
@UpdateDateColumn() updatedAt: Date;
@ManyToOne(() => Job, j => j.applications, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'jobId' }) job: Job;
@ManyToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'applicantId' }) applicant: User;
}
64 changes: 64 additions & 0 deletions apps/backend/src/jobs/jobs.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { JobsService } from './jobs.service';
import { CreateJobDto, UpdateJobDto, CreateApplicationDto, UpdateApplicationStatusDto, JobQueryDto } from './dto';

@ApiTags('jobs')
@Controller('jobs')
export class JobsController {
constructor(private readonly service: JobsService) {}

@Get() @ApiOperation({ summary: 'List open jobs' })
findAll(@Query() q: JobQueryDto) { return this.service.findAll(q); }

@Get('recommendations') @UseGuards(JwtAuthGuard) @ApiBearerAuth()
@ApiOperation({ summary: 'Get skill-matched job recommendations' })
getRecommendations(@Request() req: any) {
const skills: string[] = req.user.skills ?? [];
return this.service.getMatchingJobs(skills);
}

@Get(':id') @ApiOperation({ summary: 'Get a job by ID' })
findOne(@Param('id') id: string) { return this.service.findOne(id); }

@Post() @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Post a new job' })
create(@Request() req: any, @Body() dto: CreateJobDto) { return this.service.createJob(req.user.id, dto); }

@Patch(':id') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Update a job' })
update(@Param('id') id: string, @Request() req: any, @Body() dto: UpdateJobDto) {
return this.service.update(id, req.user.id, dto);
}

@Delete(':id') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Delete a job' })
remove(@Param('id') id: string, @Request() req: any) { return this.service.remove(id, req.user.id); }

// ── Applications ──────────────────────────────────────────────────────────

@Post(':id/apply') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Apply to a job' })
apply(@Param('id') id: string, @Request() req: any, @Body() dto: CreateApplicationDto) {
return this.service.apply(id, req.user.id, dto);
}

@Get(':id/applications') @UseGuards(JwtAuthGuard) @ApiBearerAuth()
@ApiOperation({ summary: 'List applications for a job (poster only)' })
getApplications(@Param('id') id: string, @Request() req: any) {
return this.service.getApplicationsForJob(id, req.user.id);
}

@Get('applications/mine') @UseGuards(JwtAuthGuard) @ApiBearerAuth()
@ApiOperation({ summary: 'Get my job applications' })
myApplications(@Request() req: any) { return this.service.getMyApplications(req.user.id); }

@Patch('applications/:appId/status') @UseGuards(JwtAuthGuard) @ApiBearerAuth()
@ApiOperation({ summary: 'Update application status (poster only)' })
updateStatus(@Param('appId') appId: string, @Request() req: any, @Body() dto: UpdateApplicationStatusDto) {
return this.service.updateApplicationStatus(appId, req.user.id, dto);
}

@Patch('applications/:appId/withdraw') @UseGuards(JwtAuthGuard) @ApiBearerAuth()
@ApiOperation({ summary: 'Withdraw an application' })
withdraw(@Param('appId') appId: string, @Request() req: any) {
return this.service.withdraw(appId, req.user.id);
}
}
14 changes: 14 additions & 0 deletions apps/backend/src/jobs/jobs.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { Job, JobApplication } from './job.entity';
import { JobsService } from './jobs.service';
import { JobsController } from './jobs.controller';

@Module({
imports: [TypeOrmModule.forFeature([Job, JobApplication]), ScheduleModule.forRoot()],
providers: [JobsService],
controllers: [JobsController],
exports: [JobsService],
})
export class JobsModule {}
153 changes: 153 additions & 0 deletions apps/backend/src/jobs/jobs.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Jobs service — Issue #648
* Job posting, application workflow, skill matching, auto-expiry, notifications.
*/
import { Injectable, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, ILike, LessThan } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Job, JobApplication, JobStatus, ApplicationStatus } from './job.entity';
import { CreateJobDto, UpdateJobDto, CreateApplicationDto, UpdateApplicationStatusDto, JobQueryDto } from './dto';

@Injectable()
export class JobsService {
constructor(
@InjectRepository(Job) private jobRepo: Repository<Job>,
@InjectRepository(JobApplication) private appRepo: Repository<JobApplication>,
private events: EventEmitter2,
) {}

// ── Jobs ─────────────────────────────────────────────────────────────────

async createJob(userId: string, dto: CreateJobDto): Promise<Job> {
const job = this.jobRepo.create({
...dto,
instructorId: userId,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined,
});
const saved = await this.jobRepo.save(job);
this.events.emit('job.created', { jobId: saved.id, userId });
return saved;
}

async findAll(query: JobQueryDto) {
const { search, category, status = JobStatus.OPEN, page = 1, limit = 20 } = query;
const where: any = { status, isDeleted: false };
if (category) where.category = category;
if (search) where.title = ILike(`%${search}%`);

const [data, total] = await this.jobRepo.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
relations: ['instructor'],
});
return { data, total, page, limit };
}

async findOne(id: string): Promise<Job> {
const job = await this.jobRepo.findOne({ where: { id, isDeleted: false }, relations: ['instructor', 'applications'] });
if (!job) throw new NotFoundException('Job not found');
return job;
}

async update(id: string, userId: string, dto: UpdateJobDto): Promise<Job> {
const job = await this.findOne(id);
if (job.instructorId !== userId) throw new ForbiddenException('Not your job');
Object.assign(job, dto);
if (dto.expiresAt) job.expiresAt = new Date(dto.expiresAt);
return this.jobRepo.save(job);
}

async remove(id: string, userId: string): Promise<void> {
const job = await this.findOne(id);
if (job.instructorId !== userId) throw new ForbiddenException('Not your job');
job.isDeleted = true;
await this.jobRepo.save(job);
}

// ── Applications ──────────────────────────────────────────────────────────

async apply(jobId: string, userId: string, dto: CreateApplicationDto): Promise<JobApplication> {
const job = await this.findOne(jobId);
if (job.status !== JobStatus.OPEN) throw new ConflictException('Job is not open');
if (job.instructorId === userId) throw new ForbiddenException('Cannot apply to own job');

const existing = await this.appRepo.findOne({ where: { jobId, applicantId: userId } });
if (existing) throw new ConflictException('Already applied');

const app = this.appRepo.create({ jobId, applicantId: userId, coverLetter: dto.coverLetter });
const saved = await this.appRepo.save(app);
this.events.emit('job.application.submitted', { jobId, applicantId: userId, applicationId: saved.id });
return saved;
}

async getApplicationsForJob(jobId: string, userId: string) {
const job = await this.findOne(jobId);
if (job.instructorId !== userId) throw new ForbiddenException('Not your job');
return this.appRepo.find({ where: { jobId }, relations: ['applicant'], order: { appliedAt: 'DESC' } });
}

async getMyApplications(userId: string) {
return this.appRepo.find({ where: { applicantId: userId }, relations: ['job'], order: { appliedAt: 'DESC' } });
}

async updateApplicationStatus(appId: string, userId: string, dto: UpdateApplicationStatusDto): Promise<JobApplication> {
const app = await this.appRepo.findOne({ where: { id: appId }, relations: ['job'] });
if (!app) throw new NotFoundException('Application not found');
if (app.job.instructorId !== userId) throw new ForbiddenException('Not your job');

app.status = dto.status;
app.reviewNote = dto.reviewNote;
const saved = await this.appRepo.save(app);

this.events.emit('job.application.status_changed', {
applicationId: appId,
applicantId: app.applicantId,
status: dto.status,
jobTitle: app.job.title,
});

// Mark job as filled when accepting
if (dto.status === ApplicationStatus.ACCEPTED) {
await this.jobRepo.update(app.jobId, { status: JobStatus.FILLED });
}
return saved;
}

async withdraw(appId: string, userId: string): Promise<void> {
const app = await this.appRepo.findOne({ where: { id: appId, applicantId: userId } });
if (!app) throw new NotFoundException('Application not found');
if (app.status === ApplicationStatus.ACCEPTED) throw new ConflictException('Cannot withdraw accepted application');
app.status = ApplicationStatus.WITHDRAWN;
await this.appRepo.save(app);
}

// ── Skill-based recommendations ───────────────────────────────────────────

async getMatchingJobs(userSkills: string[], limit = 10): Promise<Job[]> {
if (!userSkills.length) return this.jobRepo.find({ where: { status: JobStatus.OPEN, isDeleted: false }, take: limit });
const jobs = await this.jobRepo.find({ where: { status: JobStatus.OPEN, isDeleted: false } });
return jobs
.map(j => ({ job: j, score: (j.requiredSkills ?? []).filter(s => userSkills.includes(s)).length }))
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(x => x.job);
}

// ── Auto-expiry (runs every hour) ─────────────────────────────────────────

@Cron(CronExpression.EVERY_HOUR)
async expireOldJobs(): Promise<void> {
const expired = await this.jobRepo.find({
where: { status: JobStatus.OPEN, expiresAt: LessThan(new Date()) },
});
for (const job of expired) {
job.status = JobStatus.EXPIRED;
await this.jobRepo.save(job);
this.events.emit('job.expired', { jobId: job.id, title: job.title });
}
}
}
42 changes: 42 additions & 0 deletions apps/backend/src/media/media.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Controller, Post, Get, Delete, Param, Query, UseGuards, Request, UploadedFile, UseInterceptors, BadRequestException } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiConsumes } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { StorageService } from './storage.service';
import * as multer from 'multer';

@ApiTags('media')
@Controller('media')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class MediaController {
constructor(private readonly storage: StorageService) {}

@Post('upload')
@ApiOperation({ summary: 'Upload a file to object storage' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file', { storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } }))
upload(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
if (!file) throw new BadRequestException('No file uploaded');
return this.storage.upload(file, req.user.id);
}

@Get('mine') @ApiOperation({ summary: 'List my uploaded media' })
mine(@Request() req: any) { return this.storage.findByOwner(req.user.id); }

@Get(':id/url') @ApiOperation({ summary: 'Get a signed download URL' })
getUrl(@Param('id') id: string, @Query('ttl') ttl?: string) {
return this.storage.getSignedUrl(id, ttl ? parseInt(ttl) : undefined).then(url => ({ url }));
}

@Get(':id/url/:suffix') @ApiOperation({ summary: 'Get signed URL for an image derivative' })
getDerivativeUrl(@Param('id') id: string, @Param('suffix') suffix: string) {
return this.storage.getDerivativeSignedUrl(id, suffix).then(url => ({ url }));
}

@Delete(':id') @ApiOperation({ summary: 'Soft-delete a media file' })
delete(@Param('id') id: string, @Request() req: any) { return this.storage.softDelete(id, req.user.id); }

@Delete(':id/purge') @ApiOperation({ summary: 'Hard-delete a media file from storage and DB' })
purge(@Param('id') id: string, @Request() req: any) { return this.storage.hardDelete(id, req.user.id); }
}
22 changes: 22 additions & 0 deletions apps/backend/src/media/media.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';

export enum MediaStatus { PENDING = 'pending', READY = 'ready', FAILED = 'failed', DELETED = 'deleted' }

@Entity('media')
@Index(['ownerId'])
@Index(['status'])
export class Media {
@PrimaryGeneratedColumn('uuid') id: string;
@Column() ownerId: string;
@Column() originalName: string;
@Column() mimeType: string;
@Column('int') sizeBytes: number;
@Column() storageKey: string;
@Column() bucket: string;
@Column({ nullable: true }) publicUrl?: string;
@Column({ type: 'enum', enum: MediaStatus, default: MediaStatus.PENDING }) status: MediaStatus;
@Column('jsonb', { nullable: true }) derivatives?: Record<string, string>;
@Column('jsonb', { nullable: true }) metadata?: Record<string, unknown>;
@CreateDateColumn() uploadedAt: Date;
@Column({ nullable: true, type: 'timestamptz' }) deletedAt?: Date;
}
13 changes: 13 additions & 0 deletions apps/backend/src/media/media.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Media } from './media.entity';
import { StorageService } from './storage.service';
import { MediaController } from './media.controller';

@Module({
imports: [TypeOrmModule.forFeature([Media])],
providers: [StorageService],
controllers: [MediaController],
exports: [StorageService],
})
export class MediaModule {}
Loading
Loading