diff --git a/src/app.module.ts b/src/app.module.ts index 223800ac..65451155 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -31,6 +31,7 @@ import { ReadReplicaModule } from './database/read-replica'; import { CachingModule } from './caching/caching.module'; import { CoursesModule } from './courses/courses.module'; import { AuthModule } from './auth/auth.module'; +import { CohortsModule } from './cohorts/cohorts.module'; const featureFlags = loadFeatureFlags(); @@ -65,6 +66,7 @@ const featureFlags = loadFeatureFlags(); // ✅ courses module with enrollment and prerequisite enforcement CoursesModule, + CohortsModule, ], controllers: [AppController], providers: [ diff --git a/src/cohorts/cohorts.controller.ts b/src/cohorts/cohorts.controller.ts new file mode 100644 index 00000000..4f69823d --- /dev/null +++ b/src/cohorts/cohorts.controller.ts @@ -0,0 +1,129 @@ +import { + Controller, + Post, + Get, + Body, + Param, + Req, + UseGuards, + Delete, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CohortsService } from './cohorts.service'; +import { CreateCohortDto } from './dto/create-cohort.dto'; +import { AddCohortMemberDto } from './dto/add-cohort-member.dto'; +import { CreateCohortThreadDto } from './dto/create-cohort-thread.dto'; +import { CreateCohortCommentDto } from './dto/create-cohort-comment.dto'; +import { CreateCohortAssignmentDto } from './dto/create-cohort-assignment.dto'; + +@ApiTags('Cohorts') +@Controller('cohorts') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class CohortsController { + constructor(private readonly cohortsService: CohortsService) {} + + @Post() + @ApiOperation({ summary: 'Create a new cohort for collaborative learning' }) + @ApiResponse({ status: 201, description: 'Cohort created successfully' }) + createCohort(@Body() dto: CreateCohortDto, @Req() req: any) { + return this.cohortsService.createCohort(dto, req.user.id); + } + + @Get() + @ApiOperation({ summary: 'List cohorts the authenticated user belongs to' }) + getCohorts(@Req() req: any) { + return this.cohortsService.getCohorts(req.user.id); + } + + @Get(':id') + @ApiOperation({ summary: 'Get cohort details' }) + getCohort(@Param('id') id: string, @Req() req: any) { + return this.cohortsService.getCohort(id, req.user.id); + } + + @Post(':id/members') + @ApiOperation({ summary: 'Add a member to a cohort' }) + addCohortMember( + @Param('id') id: string, + @Body() dto: AddCohortMemberDto, + @Req() req: any, + ) { + return this.cohortsService.addMember(id, req.user.id, dto); + } + + @Delete(':id/members/:memberId') + @ApiOperation({ summary: 'Remove a member from a cohort' }) + removeCohortMember( + @Param('id') id: string, + @Param('memberId') memberId: string, + @Req() req: any, + ) { + return this.cohortsService.removeMember(id, req.user.id, memberId); + } + + @Get(':id/members') + @ApiOperation({ summary: 'List cohort members' }) + listMembers(@Param('id') id: string, @Req() req: any) { + return this.cohortsService.listMembers(id, req.user.id); + } + + @Post(':id/threads') + @ApiOperation({ summary: 'Create a discussion thread inside a cohort' }) + createThread( + @Param('id') id: string, + @Body() dto: CreateCohortThreadDto, + @Req() req: any, + ) { + return this.cohortsService.createThread(id, req.user.id, dto); + } + + @Get(':id/threads') + @ApiOperation({ summary: 'List discussion threads inside a cohort' }) + getThreads(@Param('id') id: string, @Req() req: any) { + return this.cohortsService.listThreads(id, req.user.id); + } + + @Get(':id/threads/:threadId') + @ApiOperation({ summary: 'Get a cohort discussion thread with comments' }) + getThread(@Param('threadId') threadId: string, @Req() req: any) { + return this.cohortsService.getThread(threadId, req.user.id); + } + + @Post('threads/:threadId/comments') + @ApiOperation({ summary: 'Add a comment to a cohort discussion thread' }) + addComment( + @Param('threadId') threadId: string, + @Body() dto: CreateCohortCommentDto, + @Req() req: any, + ) { + return this.cohortsService.addComment(threadId, req.user.id, dto); + } + + @Post(':id/assignments') + @ApiOperation({ summary: 'Create an assignment for a cohort' }) + createAssignment( + @Param('id') id: string, + @Body() dto: CreateCohortAssignmentDto, + @Req() req: any, + ) { + return this.cohortsService.createAssignment(id, req.user.id, dto); + } + + @Get(':id/assignments') + @ApiOperation({ summary: 'List assignments for a cohort' }) + getAssignments(@Param('id') id: string, @Req() req: any) { + return this.cohortsService.listAssignments(id, req.user.id); + } + + @Get(':id/assignments/:assignmentId') + @ApiOperation({ summary: 'Get assignment details for a cohort' }) + getAssignment( + @Param('id') id: string, + @Param('assignmentId') assignmentId: string, + @Req() req: any, + ) { + return this.cohortsService.getAssignment(id, assignmentId, req.user.id); + } +} diff --git a/src/cohorts/cohorts.module.ts b/src/cohorts/cohorts.module.ts new file mode 100644 index 00000000..b5a6902a --- /dev/null +++ b/src/cohorts/cohorts.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CohortsController } from './cohorts.controller'; +import { CohortsService } from './cohorts.service'; +import { Cohort } from './entities/cohort.entity'; +import { CohortMember } from './entities/cohort-member.entity'; +import { CohortThread } from './entities/cohort-thread.entity'; +import { CohortComment } from './entities/cohort-comment.entity'; +import { CohortAssignment } from './entities/cohort-assignment.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Cohort, + CohortMember, + CohortThread, + CohortComment, + CohortAssignment, + ]), + ], + controllers: [CohortsController], + providers: [CohortsService], + exports: [CohortsService], +}) +export class CohortsModule {} diff --git a/src/cohorts/cohorts.service.ts b/src/cohorts/cohorts.service.ts new file mode 100644 index 00000000..e7341982 --- /dev/null +++ b/src/cohorts/cohorts.service.ts @@ -0,0 +1,242 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Cohort } from './entities/cohort.entity'; +import { CohortMember } from './entities/cohort-member.entity'; +import { CohortThread } from './entities/cohort-thread.entity'; +import { CohortComment } from './entities/cohort-comment.entity'; +import { CohortAssignment, CohortAssignmentStatus } from './entities/cohort-assignment.entity'; +import { CreateCohortDto } from './dto/create-cohort.dto'; +import { AddCohortMemberDto } from './dto/add-cohort-member.dto'; +import { CreateCohortThreadDto } from './dto/create-cohort-thread.dto'; +import { CreateCohortCommentDto } from './dto/create-cohort-comment.dto'; +import { CreateCohortAssignmentDto } from './dto/create-cohort-assignment.dto'; + +@Injectable() +export class CohortsService { + constructor( + @InjectRepository(Cohort) + private readonly cohortRepo: Repository, + @InjectRepository(CohortMember) + private readonly memberRepo: Repository, + @InjectRepository(CohortThread) + private readonly threadRepo: Repository, + @InjectRepository(CohortComment) + private readonly commentRepo: Repository, + @InjectRepository(CohortAssignment) + private readonly assignmentRepo: Repository, + ) {} + + async createCohort(dto: CreateCohortDto, ownerId: string): Promise { + const cohort = this.cohortRepo.create({ + name: dto.name, + description: dto.description, + ownerId, + }); + + const saved = await this.cohortRepo.save(cohort); + const ownerMembership = this.memberRepo.create({ + cohortId: saved.id, + userId: ownerId, + role: 'owner', + }); + + await this.memberRepo.save(ownerMembership); + return saved; + } + + async getCohorts(userId: string): Promise { + return this.cohortRepo + .createQueryBuilder('cohort') + .innerJoin('cohort.members', 'member', 'member.userId = :userId', { userId }) + .orderBy('cohort.createdAt', 'DESC') + .getMany(); + } + + async getCohort(cohortId: string, userId: string): Promise { + const cohort = await this.getAccessibleCohort(cohortId, userId); + return cohort; + } + + async addMember( + cohortId: string, + actorId: string, + dto: AddCohortMemberDto, + ): Promise { + const cohort = await this.requireOwner(cohortId, actorId); + + const existing = await this.memberRepo.findOne({ + where: { cohortId, userId: dto.userId }, + }); + + if (existing) { + if (dto.role && existing.role !== dto.role) { + existing.role = dto.role; + return this.memberRepo.save(existing); + } + return existing; + } + + const membership = this.memberRepo.create({ + cohortId: cohort.id, + userId: dto.userId, + role: dto.role ?? 'member', + }); + return this.memberRepo.save(membership); + } + + async removeMember(cohortId: string, actorId: string, userId: string): Promise { + const cohort = await this.requireOwner(cohortId, actorId); + if (cohort.ownerId === userId) { + throw new BadRequestException('Cohort owner cannot be removed'); + } + + const membership = await this.memberRepo.findOne({ + where: { cohortId, userId }, + }); + if (!membership) { + throw new NotFoundException('Cohort member not found'); + } + + await this.memberRepo.remove(membership); + } + + async listMembers(cohortId: string, userId: string): Promise { + await this.requireMembership(cohortId, userId); + return this.memberRepo.find({ where: { cohortId }, order: { createdAt: 'ASC' } }); + } + + async createThread( + cohortId: string, + authorId: string, + dto: CreateCohortThreadDto, + ): Promise { + await this.requireMembership(cohortId, authorId); + + const thread = this.threadRepo.create({ + cohortId, + authorId, + title: dto.title, + content: dto.content, + }); + return this.threadRepo.save(thread); + } + + async listThreads(cohortId: string, userId: string): Promise { + await this.requireMembership(cohortId, userId); + return this.threadRepo.find({ + where: { cohortId }, + order: { createdAt: 'DESC' }, + }); + } + + async getThread(threadId: string, userId: string): Promise { + const thread = await this.threadRepo.findOne({ + where: { id: threadId }, + relations: ['comments'], + order: { comments: { createdAt: 'ASC' } }, + }); + + if (!thread) { + throw new NotFoundException('Thread not found'); + } + + await this.requireMembership(thread.cohortId, userId); + return thread; + } + + async addComment( + threadId: string, + authorId: string, + dto: CreateCohortCommentDto, + ): Promise { + const thread = await this.threadRepo.findOne({ where: { id: threadId } }); + if (!thread) { + throw new NotFoundException('Thread not found'); + } + + await this.requireMembership(thread.cohortId, authorId); + + const comment = this.commentRepo.create({ + threadId, + authorId, + content: dto.content, + parentId: dto.parentId, + }); + return this.commentRepo.save(comment); + } + + async createAssignment( + cohortId: string, + actorId: string, + dto: CreateCohortAssignmentDto, + ): Promise { + await this.requireOwner(cohortId, actorId); + + const assignment = this.assignmentRepo.create({ + cohortId, + title: dto.title, + description: dto.description, + dueDate: dto.dueDate, + status: CohortAssignmentStatus.OPEN, + }); + + return this.assignmentRepo.save(assignment); + } + + async listAssignments(cohortId: string, userId: string): Promise { + await this.requireMembership(cohortId, userId); + return this.assignmentRepo.find({ where: { cohortId }, order: { createdAt: 'DESC' } }); + } + + async getAssignment(cohortId: string, assignmentId: string, userId: string): Promise { + await this.requireMembership(cohortId, userId); + const assignment = await this.assignmentRepo.findOne({ where: { id: assignmentId, cohortId } }); + if (!assignment) { + throw new NotFoundException('Assignment not found'); + } + return assignment; + } + + private async requireOwner(cohortId: string, userId: string): Promise { + const cohort = await this.cohortRepo.findOne({ where: { id: cohortId } }); + if (!cohort) { + throw new NotFoundException('Cohort not found'); + } + + if (cohort.ownerId !== userId) { + throw new ForbiddenException('Only cohort owner can manage this resource'); + } + + return cohort; + } + + private async requireMembership(cohortId: string, userId: string): Promise { + const membership = await this.memberRepo.findOne({ + where: { cohortId, userId }, + }); + if (!membership) { + throw new ForbiddenException('Access denied'); + } + return membership; + } + + private async getAccessibleCohort(cohortId: string, userId: string): Promise { + const cohort = await this.cohortRepo + .createQueryBuilder('cohort') + .innerJoin('cohort.members', 'member', 'member.userId = :userId', { userId }) + .where('cohort.id = :cohortId', { cohortId }) + .getOne(); + + if (!cohort) { + throw new NotFoundException('Cohort not found or access denied'); + } + + return cohort; + } +} diff --git a/src/cohorts/dto/add-cohort-member.dto.ts b/src/cohorts/dto/add-cohort-member.dto.ts new file mode 100644 index 00000000..70157d67 --- /dev/null +++ b/src/cohorts/dto/add-cohort-member.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsUUID, IsOptional, IsString } from 'class-validator'; + +export class AddCohortMemberDto { + @ApiProperty({ example: '50f87278-3e39-4f90-8ce3-fae39d733458' }) + @IsUUID() + userId: string; + + @ApiPropertyOptional({ example: 'member' }) + @IsString() + @IsOptional() + role?: string; +} diff --git a/src/cohorts/dto/create-cohort-assignment.dto.ts b/src/cohorts/dto/create-cohort-assignment.dto.ts new file mode 100644 index 00000000..4a5a538f --- /dev/null +++ b/src/cohorts/dto/create-cohort-assignment.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsDateString } from 'class-validator'; + +export class CreateCohortAssignmentDto { + @ApiProperty({ example: 'Final project proposal' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiPropertyOptional({ example: 'Submit a 2-page proposal outlining your final project idea.' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ example: '2026-07-15T23:59:59.000Z' }) + @IsDateString() + @IsOptional() + dueDate?: string; +} diff --git a/src/cohorts/dto/create-cohort-comment.dto.ts b/src/cohorts/dto/create-cohort-comment.dto.ts new file mode 100644 index 00000000..55bce16f --- /dev/null +++ b/src/cohorts/dto/create-cohort-comment.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsUUID, IsOptional } from 'class-validator'; + +export class CreateCohortCommentDto { + @ApiProperty({ example: 'I found the event loop explanation very helpful.' }) + @IsString() + @IsNotEmpty() + content: string; + + @ApiPropertyOptional({ example: 'ebc12796-7ed5-4e30-a4c9-052faf919469' }) + @IsUUID() + @IsOptional() + parentId?: string; +} diff --git a/src/cohorts/dto/create-cohort-thread.dto.ts b/src/cohorts/dto/create-cohort-thread.dto.ts new file mode 100644 index 00000000..7c2479fd --- /dev/null +++ b/src/cohorts/dto/create-cohort-thread.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class CreateCohortThreadDto { + @ApiProperty({ example: 'Week 1 Discussion' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ example: 'What concepts did you find most challenging in the first module?' }) + @IsString() + @IsNotEmpty() + content: string; +} diff --git a/src/cohorts/dto/create-cohort.dto.ts b/src/cohorts/dto/create-cohort.dto.ts new file mode 100644 index 00000000..019fc674 --- /dev/null +++ b/src/cohorts/dto/create-cohort.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; + +export class CreateCohortDto { + @ApiProperty({ example: 'JavaScript Learners' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ example: 'A collaborative cohort for beginner JavaScript students.' }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/src/cohorts/entities/cohort-assignment.entity.ts b/src/cohorts/entities/cohort-assignment.entity.ts new file mode 100644 index 00000000..72ac68c1 --- /dev/null +++ b/src/cohorts/entities/cohort-assignment.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + Index, +} from 'typeorm'; +import { Cohort } from './cohort.entity'; + +export enum CohortAssignmentStatus { + OPEN = 'open', + CLOSED = 'closed', +} + +@Entity('cohort_assignments') +export class CohortAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + cohortId: string; + + @ManyToOne(() => Cohort, (cohort) => cohort.assignments, { onDelete: 'CASCADE' }) + cohort: Cohort; + + @Column() + title: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'timestamp', nullable: true }) + dueDate?: Date; + + @Column({ type: 'enum', enum: CohortAssignmentStatus, default: CohortAssignmentStatus.OPEN }) + status: CohortAssignmentStatus; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/cohorts/entities/cohort-comment.entity.ts b/src/cohorts/entities/cohort-comment.entity.ts new file mode 100644 index 00000000..28bf33e4 --- /dev/null +++ b/src/cohorts/entities/cohort-comment.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + Index, +} from 'typeorm'; +import { CohortThread } from './cohort-thread.entity'; + +@Entity('cohort_comments') +export class CohortComment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + threadId: string; + + @ManyToOne(() => CohortThread, (thread) => thread.comments, { onDelete: 'CASCADE' }) + thread: CohortThread; + + @Column() + authorId: string; + + @Column({ type: 'text' }) + content: string; + + @Column({ nullable: true }) + parentId?: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/cohorts/entities/cohort-member.entity.ts b/src/cohorts/entities/cohort-member.entity.ts new file mode 100644 index 00000000..a93ebc17 --- /dev/null +++ b/src/cohorts/entities/cohort-member.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + Index, +} from 'typeorm'; +import { Cohort } from './cohort.entity'; + +@Entity('cohort_members') +export class CohortMember { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + cohortId: string; + + @ManyToOne(() => Cohort, (cohort) => cohort.members, { onDelete: 'CASCADE' }) + cohort: Cohort; + + @Column() + @Index() + userId: string; + + @Column({ default: 'member' }) + role: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/cohorts/entities/cohort-thread.entity.ts b/src/cohorts/entities/cohort-thread.entity.ts new file mode 100644 index 00000000..adef06dd --- /dev/null +++ b/src/cohorts/entities/cohort-thread.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + Index, +} from 'typeorm'; +import { Cohort } from './cohort.entity'; +import { CohortComment } from './cohort-comment.entity'; + +@Entity('cohort_threads') +export class CohortThread { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + cohortId: string; + + @ManyToOne(() => Cohort, (cohort) => cohort.threads, { onDelete: 'CASCADE' }) + cohort: Cohort; + + @Column() + authorId: string; + + @Column() + title: string; + + @Column({ type: 'text' }) + content: string; + + @OneToMany(() => CohortComment, (comment) => comment.thread) + comments: CohortComment[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/cohorts/entities/cohort.entity.ts b/src/cohorts/entities/cohort.entity.ts new file mode 100644 index 00000000..7e1b4d7a --- /dev/null +++ b/src/cohorts/entities/cohort.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { CohortMember } from './cohort-member.entity'; +import { CohortThread } from './cohort-thread.entity'; +import { CohortAssignment } from './cohort-assignment.entity'; + +@Entity('cohorts') +export class Cohort { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + name: string; + + @Column({ nullable: true, type: 'text' }) + description?: string; + + @Column() + ownerId: string; + + @OneToMany(() => CohortMember, (member) => member.cohort) + members: CohortMember[]; + + @OneToMany(() => CohortThread, (thread) => thread.cohort) + threads: CohortThread[]; + + @OneToMany(() => CohortAssignment, (assignment) => assignment.cohort) + assignments: CohortAssignment[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; +}