From 06de516fd8ef0f27426039cc215a83dfbc1fca3e Mon Sep 17 00:00:00 2001 From: ayshadogo Date: Sun, 31 May 2026 11:28:48 +0100 Subject: [PATCH] Implement bulk course operations for instructors --- src/courses/courses.controller.ts | 71 ++++ src/courses/courses.module.ts | 3 +- src/courses/courses.service.bulk.spec.ts | 312 ++++++++++++++++++ src/courses/courses.service.ts | 234 ++++++++++++- src/courses/dto/bulk-operations.dto.ts | 90 +++++ src/courses/entities/bulk-operation.entity.ts | 107 ++++++ src/courses/entities/course.entity.ts | 5 + ...748600000000-add-course-bulk-operations.ts | 98 ++++++ 8 files changed, 918 insertions(+), 2 deletions(-) create mode 100644 src/courses/courses.service.bulk.spec.ts create mode 100644 src/courses/dto/bulk-operations.dto.ts create mode 100644 src/courses/entities/bulk-operation.entity.ts create mode 100644 src/migrations/1748600000000-add-course-bulk-operations.ts diff --git a/src/courses/courses.controller.ts b/src/courses/courses.controller.ts index 86412bbb..4bb35e24 100644 --- a/src/courses/courses.controller.ts +++ b/src/courses/courses.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + Query, UseGuards, Request, HttpCode, @@ -17,6 +18,11 @@ import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; import { SubmitForReviewDto } from './dto/submit-for-review.dto'; import { ReviewCourseDto } from './dto/review-course.dto'; +import { + BulkCategoryUpdateDto, + BulkPriceUpdateDto, + BulkPublishDto, +} from './dto/bulk-operations.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @ApiTags('courses') @@ -92,4 +98,69 @@ export class CoursesController { async getPendingQueue(@Request() req) { return this.coursesService.getPendingQueue(req.user); } + + // ─── BULK OPERATIONS ────────────────────────────────────────────────────── + + @Post('bulk/publish') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Bulk publish or unpublish courses', + description: + 'Apply a publish/unpublish action to many courses owned by the caller in one request. ' + + 'Courses that are not found or not owned by the caller are skipped and reported in the snapshots.', + }) + @ApiResponse({ status: 200, description: 'Bulk publish operation recorded' }) + async bulkPublish(@Body() dto: BulkPublishDto, @Request() req) { + return this.coursesService.bulkPublish(dto, req.user); + } + + @Post('bulk/price') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Bulk update price across courses', + description: 'Apply a single new price value to many courses owned by the caller in one request.', + }) + @ApiResponse({ status: 200, description: 'Bulk price update recorded' }) + async bulkUpdatePrice(@Body() dto: BulkPriceUpdateDto, @Request() req) { + return this.coursesService.bulkUpdatePrice(dto, req.user); + } + + @Post('bulk/category') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Bulk update category across courses', + description: + 'Apply a single new category value to many courses owned by the caller. ' + + 'Send `category: null` (or omit) to clear the category.', + }) + @ApiResponse({ status: 200, description: 'Bulk category update recorded' }) + async bulkUpdateCategory(@Body() dto: BulkCategoryUpdateDto, @Request() req) { + return this.coursesService.bulkUpdateCategory(dto, req.user); + } + + @Get('bulk/operations') + @ApiOperation({ summary: 'List recent bulk operations triggered by the current user' }) + @ApiResponse({ status: 200, description: 'Returns recent bulk operations' }) + async listBulkOperations(@Request() req, @Query('limit') limit?: string) { + const parsed = limit ? Number(limit) : undefined; + return this.coursesService.listBulkOperations( + req.user, + Number.isFinite(parsed) ? (parsed as number) : 50, + ); + } + + @Post('bulk/operations/:id/undo') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Undo a previously executed bulk operation', + description: + 'Restores every successfully-modified course to its pre-operation state using the stored snapshot. ' + + 'Only the initiator or an admin/moderator may undo. An already-undone operation cannot be undone again.', + }) + @ApiResponse({ status: 200, description: 'Bulk operation undone' }) + @ApiResponse({ status: 400, description: 'Operation already undone' }) + @ApiResponse({ status: 404, description: 'Bulk operation not found' }) + async undoBulkOperation(@Param('id') id: string, @Request() req) { + return this.coursesService.undoBulkOperation(id, req.user); + } } diff --git a/src/courses/courses.module.ts b/src/courses/courses.module.ts index a0d6128f..3760edf4 100644 --- a/src/courses/courses.module.ts +++ b/src/courses/courses.module.ts @@ -8,11 +8,12 @@ import { Course } from './entities/course.entity'; import { Enrollment } from './entities/enrollment.entity'; import { CourseReview } from './entities/course-review.entity'; import { CourseModule } from './entities/course-module.entity'; +import { BulkOperation } from './entities/bulk-operation.entity'; import { CachingModule } from '../caching/caching.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Course, Enrollment, CourseReview, CourseModule]), + TypeOrmModule.forFeature([Course, Enrollment, CourseReview, CourseModule, BulkOperation]), CachingModule, ], providers: [CoursesService, EnrollmentsService], diff --git a/src/courses/courses.service.bulk.spec.ts b/src/courses/courses.service.bulk.spec.ts new file mode 100644 index 00000000..e34f361f --- /dev/null +++ b/src/courses/courses.service.bulk.spec.ts @@ -0,0 +1,312 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { CoursesService } from './courses.service'; +import { Course, CourseStatus } from './entities/course.entity'; +import { CourseReview } from './entities/course-review.entity'; +import { + BulkOperation, + BulkOperationStatus, + BulkOperationType, +} from './entities/bulk-operation.entity'; + +/** + * Helper that builds a Course-shaped object for tests. We don't extend the real + * entity because TypeORM decorators carry runtime metadata we don't need here. + */ +const makeCourse = (overrides: Partial = {}): Course => + ({ + id: overrides.id ?? 'course-1', + title: 'Test course', + description: 'desc', + price: 10, + status: CourseStatus.DRAFT, + instructorId: overrides.instructorId ?? 'instructor-1', + category: undefined, + ...overrides, + } as unknown as Course); + +const owner = { id: 'instructor-1', roles: [] as any[] } as any; +const otherUser = { id: 'instructor-2', roles: [] as any[] } as any; +const admin = { id: 'admin-1', roles: ['admin'] as any[] } as any; + +describe('CoursesService - bulk operations', () => { + let service: CoursesService; + + const courseRepo = { + find: jest.fn(), + save: jest.fn((entity: any) => Promise.resolve(entity)), + }; + const reviewRepo = {}; + const bulkOpRepo = { + create: jest.fn((data: any) => data), + save: jest.fn((entity: any) => Promise.resolve({ id: 'op-1', ...entity })), + find: jest.fn(), + findOne: jest.fn(), + }; + const eventEmitter = { emit: jest.fn() }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CoursesService, + { provide: getRepositoryToken(Course), useValue: courseRepo }, + { provide: getRepositoryToken(CourseReview), useValue: reviewRepo }, + { provide: getRepositoryToken(BulkOperation), useValue: bulkOpRepo }, + { provide: EventEmitter2, useValue: eventEmitter }, + ], + }).compile(); + + service = module.get(CoursesService); + }); + + // ─── bulkPublish ────────────────────────────────────────────────────────── + + describe('bulkPublish', () => { + it('publishes courses owned by the caller and snapshots previous status', async () => { + const c1 = makeCourse({ id: 'c1', status: CourseStatus.DRAFT }); + const c2 = makeCourse({ id: 'c2', status: CourseStatus.DRAFT }); + courseRepo.find.mockResolvedValueOnce([c1, c2]); + + const op = await service.bulkPublish( + { courseIds: ['c1', 'c2'], publish: true }, + owner, + ); + + expect(c1.status).toBe(CourseStatus.PUBLISHED); + expect(c2.status).toBe(CourseStatus.PUBLISHED); + expect(courseRepo.save).toHaveBeenCalledWith([c1, c2]); + expect(op.type).toBe(BulkOperationType.PUBLISH); + expect(op.status).toBe(BulkOperationStatus.COMPLETED); + expect(op.successCount).toBe(2); + expect(op.failureCount).toBe(0); + expect(op.snapshots[0]).toMatchObject({ + courseId: 'c1', + applied: true, + previous: { status: CourseStatus.DRAFT }, + }); + expect(eventEmitter.emit).toHaveBeenCalledTimes(2); + }); + + it('unpublish moves courses to ARCHIVED', async () => { + const c1 = makeCourse({ id: 'c1', status: CourseStatus.PUBLISHED }); + courseRepo.find.mockResolvedValueOnce([c1]); + + await service.bulkPublish({ courseIds: ['c1'], publish: false }, owner); + + expect(c1.status).toBe(CourseStatus.ARCHIVED); + }); + + it('skips courses owned by another instructor and reports them as FORBIDDEN', async () => { + const mine = makeCourse({ id: 'c1' }); + const theirs = makeCourse({ id: 'c2', instructorId: 'someone-else' }); + courseRepo.find.mockResolvedValueOnce([mine, theirs]); + + const op = await service.bulkPublish( + { courseIds: ['c1', 'c2'], publish: true }, + owner, + ); + + expect(op.status).toBe(BulkOperationStatus.PARTIAL); + expect(op.successCount).toBe(1); + expect(op.failureCount).toBe(1); + const failed = op.snapshots.find(s => s.courseId === 'c2'); + expect(failed?.applied).toBe(false); + expect(failed?.error).toBe('FORBIDDEN'); + }); + + it('marks missing courses as NOT_FOUND', async () => { + courseRepo.find.mockResolvedValueOnce([]); + + const op = await service.bulkPublish( + { courseIds: ['missing-1'], publish: true }, + owner, + ); + + expect(op.status).toBe(BulkOperationStatus.FAILED); + expect(op.snapshots[0].error).toBe('NOT_FOUND'); + expect(courseRepo.save).not.toHaveBeenCalled(); + }); + + it('admin can bulk publish courses owned by other instructors', async () => { + const someoneElses = makeCourse({ id: 'c1', instructorId: 'instructor-x' }); + courseRepo.find.mockResolvedValueOnce([someoneElses]); + + const op = await service.bulkPublish( + { courseIds: ['c1'], publish: true }, + admin, + ); + + expect(op.status).toBe(BulkOperationStatus.COMPLETED); + expect(someoneElses.status).toBe(CourseStatus.PUBLISHED); + }); + }); + + // ─── bulkUpdatePrice ────────────────────────────────────────────────────── + + describe('bulkUpdatePrice', () => { + it('updates price and snapshots the previous numeric value', async () => { + const c1 = makeCourse({ id: 'c1', price: 10 }); + const c2 = makeCourse({ id: 'c2', price: 20 }); + courseRepo.find.mockResolvedValueOnce([c1, c2]); + + const op = await service.bulkUpdatePrice( + { courseIds: ['c1', 'c2'], price: 99.99 }, + owner, + ); + + expect(c1.price).toBe(99.99); + expect(c2.price).toBe(99.99); + expect(op.type).toBe(BulkOperationType.PRICE_UPDATE); + expect(op.snapshots.map(s => s.previous.price)).toEqual([10, 20]); + }); + }); + + // ─── bulkUpdateCategory ─────────────────────────────────────────────────── + + describe('bulkUpdateCategory', () => { + it('sets category and snapshots previous value', async () => { + const c1 = makeCourse({ id: 'c1', category: 'old' }); + courseRepo.find.mockResolvedValueOnce([c1]); + + const op = await service.bulkUpdateCategory( + { courseIds: ['c1'], category: 'web-development' }, + owner, + ); + + expect(c1.category).toBe('web-development'); + expect(op.snapshots[0].previous.category).toBe('old'); + }); + + it('clears category when null is provided', async () => { + const c1 = makeCourse({ id: 'c1', category: 'old' }); + courseRepo.find.mockResolvedValueOnce([c1]); + + await service.bulkUpdateCategory( + { courseIds: ['c1'], category: null }, + owner, + ); + + expect(c1.category).toBeUndefined(); + }); + }); + + // ─── undoBulkOperation ──────────────────────────────────────────────────── + + describe('undoBulkOperation', () => { + it('throws NotFoundException when the operation does not exist', async () => { + bulkOpRepo.findOne.mockResolvedValueOnce(null); + await expect(service.undoBulkOperation('missing', owner)).rejects.toThrow( + NotFoundException, + ); + }); + + it('rejects callers that did not initiate the op and are not privileged', async () => { + bulkOpRepo.findOne.mockResolvedValueOnce({ + id: 'op-1', + initiatedById: owner.id, + status: BulkOperationStatus.COMPLETED, + snapshots: [], + }); + await expect(service.undoBulkOperation('op-1', otherUser)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('rejects already-undone operations', async () => { + bulkOpRepo.findOne.mockResolvedValueOnce({ + id: 'op-1', + initiatedById: owner.id, + status: BulkOperationStatus.UNDONE, + snapshots: [], + }); + await expect(service.undoBulkOperation('op-1', owner)).rejects.toThrow( + BadRequestException, + ); + }); + + it('restores previous price/status/category for applied snapshots', async () => { + const c1 = makeCourse({ + id: 'c1', + status: CourseStatus.PUBLISHED, + price: 99, + category: 'new', + }); + bulkOpRepo.findOne.mockResolvedValueOnce({ + id: 'op-1', + initiatedById: owner.id, + status: BulkOperationStatus.COMPLETED, + snapshots: [ + { + courseId: 'c1', + applied: true, + previous: { status: CourseStatus.DRAFT, price: 10, category: 'old' }, + }, + { courseId: 'c2', applied: false, previous: {}, error: 'NOT_FOUND' }, + ], + }); + courseRepo.find.mockResolvedValueOnce([c1]); + + const result = await service.undoBulkOperation('op-1', owner); + + expect(c1.status).toBe(CourseStatus.DRAFT); + expect(c1.price).toBe(10); + expect(c1.category).toBe('old'); + expect(courseRepo.save).toHaveBeenCalledWith([c1]); + expect(result.status).toBe(BulkOperationStatus.UNDONE); + expect(result.undoneAt).toBeInstanceOf(Date); + }); + + it('clears category back to null if previous value was null', async () => { + const c1 = makeCourse({ id: 'c1', category: 'web-dev' }); + bulkOpRepo.findOne.mockResolvedValueOnce({ + id: 'op-1', + initiatedById: owner.id, + status: BulkOperationStatus.COMPLETED, + snapshots: [ + { courseId: 'c1', applied: true, previous: { category: null } }, + ], + }); + courseRepo.find.mockResolvedValueOnce([c1]); + + await service.undoBulkOperation('op-1', owner); + + expect(c1.category).toBeUndefined(); + }); + + it('marks the op UNDONE without saving courses when nothing was applied', async () => { + bulkOpRepo.findOne.mockResolvedValueOnce({ + id: 'op-1', + initiatedById: owner.id, + status: BulkOperationStatus.FAILED, + snapshots: [{ courseId: 'c1', applied: false, previous: {}, error: 'NOT_FOUND' }], + }); + + const result = await service.undoBulkOperation('op-1', owner); + + expect(courseRepo.find).not.toHaveBeenCalled(); + expect(courseRepo.save).not.toHaveBeenCalled(); + expect(result.status).toBe(BulkOperationStatus.UNDONE); + }); + + it('admin can undo an operation initiated by someone else', async () => { + const c1 = makeCourse({ id: 'c1', status: CourseStatus.PUBLISHED }); + bulkOpRepo.findOne.mockResolvedValueOnce({ + id: 'op-1', + initiatedById: 'instructor-x', + status: BulkOperationStatus.COMPLETED, + snapshots: [ + { courseId: 'c1', applied: true, previous: { status: CourseStatus.DRAFT } }, + ], + }); + courseRepo.find.mockResolvedValueOnce([c1]); + + const result = await service.undoBulkOperation('op-1', admin); + + expect(c1.status).toBe(CourseStatus.DRAFT); + expect(result.status).toBe(BulkOperationStatus.UNDONE); + }); + }); +}); diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index 0f5f0e59..d49336df 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -6,15 +6,26 @@ import { } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { CACHE_EVENTS } from '../caching/caching.constants'; import { Course, CourseStatus } from './entities/course.entity'; import { CourseReview, ReviewDecision } from './entities/course-review.entity'; +import { + BulkCourseSnapshot, + BulkOperation, + BulkOperationStatus, + BulkOperationType, +} from './entities/bulk-operation.entity'; import { User } from '../users/entities/user.entity'; import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; import { SubmitForReviewDto } from './dto/submit-for-review.dto'; import { ReviewCourseDto } from './dto/review-course.dto'; +import { + BulkCategoryUpdateDto, + BulkPriceUpdateDto, + BulkPublishDto, +} from './dto/bulk-operations.dto'; /** * Maps a ReviewDecision to the resulting CourseStatus after the decision. @@ -35,6 +46,8 @@ export class CoursesService { private readonly courseRepo: Repository, @InjectRepository(CourseReview) private readonly reviewRepo: Repository, + @InjectRepository(BulkOperation) + private readonly bulkOpRepo: Repository, private readonly eventEmitter: EventEmitter2, ) {} @@ -235,4 +248,223 @@ export class CoursesService { throw new ForbiddenException('Insufficient permissions.'); } } + + // ─── BULK OPERATIONS ───────────────────────────────────────────────────────── + + /** + * Bulk publish or unpublish a list of courses owned by the caller. + * + * Publish moves a course to PUBLISHED (only legal from DRAFT or + * CHANGES_REQUESTED-via-PENDING_REVIEW workflows is enforced via the + * normal review flow, but instructors with `admin`/`moderator` roles + * may force-publish here). Unpublish moves a course to ARCHIVED. + * + * Each course is processed independently: failures on one course do + * not abort the whole batch. A `BulkOperation` record with per-course + * snapshots is persisted so the action can be undone later. + */ + async bulkPublish(dto: BulkPublishDto, user: User): Promise { + const targetStatus = dto.publish ? CourseStatus.PUBLISHED : CourseStatus.ARCHIVED; + const opType = dto.publish ? BulkOperationType.PUBLISH : BulkOperationType.UNPUBLISH; + + return this.runBulkOperation({ + type: opType, + payload: { publish: dto.publish, targetStatus }, + courseIds: dto.courseIds, + user, + apply: course => { + const previous: BulkCourseSnapshot['previous'] = { status: course.status }; + course.status = targetStatus; + return previous; + }, + }); + } + + /** + * Bulk update the `price` field for a list of courses owned by the caller. + */ + async bulkUpdatePrice(dto: BulkPriceUpdateDto, user: User): Promise { + return this.runBulkOperation({ + type: BulkOperationType.PRICE_UPDATE, + payload: { price: dto.price }, + courseIds: dto.courseIds, + user, + apply: course => { + const previous: BulkCourseSnapshot['previous'] = { price: Number(course.price) }; + course.price = dto.price; + return previous; + }, + }); + } + + /** + * Bulk update the `category` field for a list of courses owned by the caller. + * Pass `category` as null/undefined in the DTO to clear it. + */ + async bulkUpdateCategory(dto: BulkCategoryUpdateDto, user: User): Promise { + const nextCategory = dto.category ?? null; + return this.runBulkOperation({ + type: BulkOperationType.CATEGORY_UPDATE, + payload: { category: nextCategory }, + courseIds: dto.courseIds, + user, + apply: course => { + const previous: BulkCourseSnapshot['previous'] = { + category: course.category ?? null, + }; + course.category = nextCategory ?? undefined; + return previous; + }, + }); + } + + /** + * Returns recent bulk operations triggered by the given user (newest first). + */ + async listBulkOperations(user: User, limit = 50): Promise { + return this.bulkOpRepo.find({ + where: { initiatedById: user.id }, + order: { createdAt: 'DESC' }, + take: Math.min(Math.max(limit, 1), 200), + }); + } + + /** + * Reverts a previously executed bulk operation by restoring each + * affected course's snapshotted field values. Only the user that + * initiated the operation, or an admin/moderator, may undo it. An + * already-undone operation cannot be undone again. + */ + async undoBulkOperation(operationId: string, user: User): Promise { + const op = await this.bulkOpRepo.findOne({ where: { id: operationId } }); + if (!op) { + throw new NotFoundException(`Bulk operation ${operationId} not found`); + } + + const isInitiator = op.initiatedById === user.id; + const isPrivileged = user.roles.some(role => ['admin', 'moderator'].includes(role)); + if (!isInitiator && !isPrivileged) { + throw new ForbiddenException('Only the initiator or an admin/moderator may undo this operation.'); + } + + if (op.status === BulkOperationStatus.UNDONE) { + throw new BadRequestException('This bulk operation has already been undone.'); + } + + const appliedSnapshots = (op.snapshots ?? []).filter(s => s.applied); + if (appliedSnapshots.length === 0) { + op.status = BulkOperationStatus.UNDONE; + op.undoneAt = new Date(); + return this.bulkOpRepo.save(op); + } + + const courseIds = appliedSnapshots.map(s => s.courseId); + const courses = await this.courseRepo.find({ where: { id: In(courseIds) } }); + const courseById = new Map(courses.map(c => [c.id, c])); + + const restored: Course[] = []; + for (const snap of appliedSnapshots) { + const course = courseById.get(snap.courseId); + if (!course) continue; // course removed since the op; skip silently + + if (snap.previous.status !== undefined) { + course.status = snap.previous.status as CourseStatus; + } + if (snap.previous.price !== undefined) { + course.price = snap.previous.price; + } + if (Object.prototype.hasOwnProperty.call(snap.previous, 'category')) { + course.category = snap.previous.category ?? undefined; + } + restored.push(course); + } + + if (restored.length > 0) { + await this.courseRepo.save(restored); + restored.forEach(c => + this.eventEmitter.emit(CACHE_EVENTS.COURSE_UPDATED, { id: c.id }), + ); + } + + op.status = BulkOperationStatus.UNDONE; + op.undoneAt = new Date(); + return this.bulkOpRepo.save(op); + } + + /** + * Shared engine for all bulk operations. Loads the requested courses, + * authorises ownership, applies the per-course mutation, persists + * results, and records a `BulkOperation` row with snapshots for undo. + */ + private async runBulkOperation(args: { + type: BulkOperationType; + payload: Record; + courseIds: string[]; + user: User; + apply: (course: Course) => BulkCourseSnapshot['previous']; + }): Promise { + const { type, payload, courseIds, user, apply } = args; + const isPrivileged = user.roles.some(role => ['admin', 'moderator'].includes(role)); + + const courses = await this.courseRepo.find({ where: { id: In(courseIds) } }); + const found = new Map(courses.map(c => [c.id, c])); + + const snapshots: BulkCourseSnapshot[] = []; + const toSave: Course[] = []; + + for (const id of courseIds) { + const course = found.get(id); + if (!course) { + snapshots.push({ courseId: id, previous: {}, applied: false, error: 'NOT_FOUND' }); + continue; + } + if (!isPrivileged && course.instructorId !== user.id) { + snapshots.push({ courseId: id, previous: {}, applied: false, error: 'FORBIDDEN' }); + continue; + } + + try { + const previous = apply(course); + snapshots.push({ courseId: id, previous, applied: true }); + toSave.push(course); + } catch (err) { + snapshots.push({ + courseId: id, + previous: {}, + applied: false, + error: err instanceof Error ? err.message : 'APPLY_FAILED', + }); + } + } + + if (toSave.length > 0) { + await this.courseRepo.save(toSave); + toSave.forEach(c => + this.eventEmitter.emit(CACHE_EVENTS.COURSE_UPDATED, { id: c.id }), + ); + } + + const successCount = snapshots.filter(s => s.applied).length; + const failureCount = snapshots.length - successCount; + let status: BulkOperationStatus; + if (successCount === 0) { + status = BulkOperationStatus.FAILED; + } else if (failureCount === 0) { + status = BulkOperationStatus.COMPLETED; + } else { + status = BulkOperationStatus.PARTIAL; + } + + const op = this.bulkOpRepo.create({ + type, + status, + payload, + snapshots, + totalCount: courseIds.length, + successCount, + failureCount, + initiatedById: user.id, + }); + return this.bulkOpRepo.save(op); + } } diff --git a/src/courses/dto/bulk-operations.dto.ts b/src/courses/dto/bulk-operations.dto.ts new file mode 100644 index 00000000..4f2dd73f --- /dev/null +++ b/src/courses/dto/bulk-operations.dto.ts @@ -0,0 +1,90 @@ +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsBoolean, + IsNumber, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Maximum number of courses allowed in a single bulk request. + * Keeps single-request memory/transaction size bounded. + */ +export const BULK_OPERATION_MAX_COURSES = 200; + +/** + * Common shape for any bulk request: a non-empty, de-duplicated + * array of course UUIDs that the caller wants to operate on. + */ +export class BulkCourseIdsDto { + @ApiProperty({ + type: [String], + description: 'List of course IDs to operate on (max 200, must be unique).', + example: ['8a4cd9b2-9f9d-4f4f-8c2c-1f0c5e6e1a11'], + }) + @IsArray({ message: 'courseIds must be an array' }) + @ArrayMinSize(1, { message: 'At least one course ID is required' }) + @ArrayMaxSize(BULK_OPERATION_MAX_COURSES, { + message: `Cannot operate on more than ${BULK_OPERATION_MAX_COURSES} courses at once`, + }) + @ArrayUnique({ message: 'courseIds must not contain duplicates' }) + @IsUUID('4', { each: true, message: 'Every course ID must be a valid UUID v4' }) + courseIds: string[]; +} + +/** + * Bulk publish or unpublish a list of courses. + * `publish=true` moves courses into PUBLISHED, `publish=false` moves + * them to ARCHIVED (instructor-driven unpublish). + */ +export class BulkPublishDto extends BulkCourseIdsDto { + @ApiProperty({ + description: + 'When true, courses are published. When false, courses are unpublished (archived).', + }) + @IsBoolean({ message: 'publish must be a boolean' }) + publish: boolean; +} + +/** + * Bulk update the price of a list of courses. + */ +export class BulkPriceUpdateDto extends BulkCourseIdsDto { + @ApiProperty({ + description: 'New price to apply to every selected course.', + minimum: 0, + example: 49.99, + }) + @IsNumber( + { allowNaN: false, allowInfinity: false, maxDecimalPlaces: 2 }, + { message: 'price must be a number with up to 2 decimal places' }, + ) + @Min(0, { message: 'price cannot be negative' }) + price: number; +} + +/** + * Bulk update the category of a list of courses. + * Pass `category: null` to clear the category for all selected courses. + */ +export class BulkCategoryUpdateDto extends BulkCourseIdsDto { + @ApiPropertyOptional({ + description: + 'New category to apply to every selected course. Pass null/omit to clear the category.', + nullable: true, + example: 'web-development', + }) + @IsOptional() + @IsString({ message: 'category must be a string' }) + @MinLength(1, { message: 'category cannot be empty' }) + @MaxLength(100, { message: 'category cannot exceed 100 characters' }) + category?: string | null; +} diff --git a/src/courses/entities/bulk-operation.entity.ts b/src/courses/entities/bulk-operation.entity.ts new file mode 100644 index 00000000..5523dede --- /dev/null +++ b/src/courses/entities/bulk-operation.entity.ts @@ -0,0 +1,107 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +/** Type of bulk operation an instructor can perform on their courses. */ +export enum BulkOperationType { + PUBLISH = 'publish', + UNPUBLISH = 'unpublish', + PRICE_UPDATE = 'price_update', + CATEGORY_UPDATE = 'category_update', +} + +/** Lifecycle of a bulk operation record. */ +export enum BulkOperationStatus { + COMPLETED = 'completed', + PARTIAL = 'partial', + FAILED = 'failed', + UNDONE = 'undone', +} + +/** + * One snapshot per affected course captured before a bulk operation + * is applied. Used to deterministically undo the operation later. + */ +export interface BulkCourseSnapshot { + /** Course ID that was modified. */ + courseId: string; + /** Field-level previous values (only fields the op touched). */ + previous: { + status?: string; + price?: number; + category?: string | null; + }; + /** Whether this course was applied successfully in the bulk run. */ + applied: boolean; + /** Optional error message if this course failed. */ + error?: string; +} + +/** + * Records a bulk operation performed by an instructor so it can be + * audited and undone. The `snapshots` JSON column stores the + * pre-operation state of every affected course. + */ +@Entity('course_bulk_operations') +export class BulkOperation { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** The instructor (or admin) who triggered the operation. */ + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'initiated_by_id' }) + initiatedBy?: User; + + @Column({ name: 'initiated_by_id', type: 'uuid', nullable: true }) + @Index() + initiatedById?: string; + + @Column({ type: 'enum', enum: BulkOperationType }) + @Index() + type: BulkOperationType; + + @Column({ + type: 'enum', + enum: BulkOperationStatus, + default: BulkOperationStatus.COMPLETED, + }) + status: BulkOperationStatus; + + /** The payload that was applied (e.g. `{ price: 49.99 }`). */ + @Column({ type: 'jsonb' }) + payload: Record; + + /** Per-course snapshot used for undo. */ + @Column({ type: 'jsonb', default: () => "'[]'" }) + snapshots: BulkCourseSnapshot[]; + + /** Total number of courses requested in the bulk action. */ + @Column({ type: 'int', default: 0 }) + totalCount: number; + + /** Number of courses that were applied successfully. */ + @Column({ type: 'int', default: 0 }) + successCount: number; + + /** Number of courses that failed during the bulk run. */ + @Column({ type: 'int', default: 0 }) + failureCount: number; + + /** Timestamp at which an undo was successfully performed. */ + @Column({ type: 'timestamptz', nullable: true }) + undoneAt?: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/courses/entities/course.entity.ts b/src/courses/entities/course.entity.ts index 2374b62d..dea032bf 100644 --- a/src/courses/entities/course.entity.ts +++ b/src/courses/entities/course.entity.ts @@ -58,6 +58,11 @@ export class Course { @Column({ nullable: true }) thumbnailUrl: string; + /** Optional category/tag used for catalog grouping and bulk operations. */ + @Column({ nullable: true }) + @Index() + category?: string; + @ManyToOne(() => User, (user) => user.courses) instructor: User; diff --git a/src/migrations/1748600000000-add-course-bulk-operations.ts b/src/migrations/1748600000000-add-course-bulk-operations.ts new file mode 100644 index 00000000..bfcb9de8 --- /dev/null +++ b/src/migrations/1748600000000-add-course-bulk-operations.ts @@ -0,0 +1,98 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +/** + * Adds support for instructor-driven bulk course operations: + * - Adds `category` column to `course` for bulk category updates. + * - Creates `course_bulk_operations` to record each bulk action with + * a per-course snapshot used to power undo. + */ +export class AddCourseBulkOperations1748600000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 1. category column on course + await queryRunner.query( + `ALTER TABLE "course" ADD COLUMN IF NOT EXISTS "category" varchar`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_course_category" ON "course" ("category")`, + ); + + // 2. enums for the new table + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE "course_bulk_operations_type_enum" AS ENUM ( + 'publish', 'unpublish', 'price_update', 'category_update' + ); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE "course_bulk_operations_status_enum" AS ENUM ( + 'completed', 'partial', 'failed', 'undone' + ); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `); + + // 3. course_bulk_operations table + await queryRunner.createTable( + new Table({ + name: 'course_bulk_operations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'initiated_by_id', type: 'uuid', isNullable: true }, + { name: 'type', type: 'course_bulk_operations_type_enum', isNullable: false }, + { + name: 'status', + type: 'course_bulk_operations_status_enum', + isNullable: false, + default: `'completed'`, + }, + { name: 'payload', type: 'jsonb', isNullable: false }, + { name: 'snapshots', type: 'jsonb', isNullable: false, default: `'[]'::jsonb` }, + { name: 'totalCount', type: 'int', isNullable: false, default: 0 }, + { name: 'successCount', type: 'int', isNullable: false, default: 0 }, + { name: 'failureCount', type: 'int', isNullable: false, default: 0 }, + { name: 'undoneAt', type: 'timestamptz', isNullable: true }, + { name: 'createdAt', type: 'timestamptz', default: 'now()' }, + { name: 'updatedAt', type: 'timestamptz', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndices('course_bulk_operations', [ + new TableIndex({ + name: 'IDX_course_bulk_ops_initiator', + columnNames: ['initiated_by_id'], + }), + new TableIndex({ + name: 'IDX_course_bulk_ops_type', + columnNames: ['type'], + }), + ]); + + await queryRunner.createForeignKey( + 'course_bulk_operations', + new TableForeignKey({ + name: 'FK_course_bulk_ops_initiator', + columnNames: ['initiated_by_id'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('course_bulk_operations', true); + await queryRunner.query(`DROP TYPE IF EXISTS "course_bulk_operations_type_enum"`); + await queryRunner.query(`DROP TYPE IF EXISTS "course_bulk_operations_status_enum"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_course_category"`); + await queryRunner.query(`ALTER TABLE "course" DROP COLUMN IF EXISTS "category"`); + } +}