diff --git a/BackendAcademy/src/courses/course-revision.entity.ts b/BackendAcademy/src/courses/course-revision.entity.ts new file mode 100644 index 000000000..ba74d118a --- /dev/null +++ b/BackendAcademy/src/courses/course-revision.entity.ts @@ -0,0 +1,100 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, +} from 'typeorm'; +import { CourseLevel } from './interfaces/course-level.enum'; +import { CourseEntity } from './course.entity'; + +/** + * Append-only, immutable snapshot of a course at a specific version. + * + * `CourseRevisionEntity` is the heart of the course versioning system. Every + * meaningful change to a course (create / update / restore) appends one row + * here. Rows are never updated after creation, so historical revisions + * remain a true audit trail. Of course (`CourseEntity`) → many revisions + * relationship lives on this table. + */ +@Entity({ name: 'course_revisions' }) +@Index(['courseId', 'version'], { unique: true }) +export class CourseRevisionEntity { + @PrimaryColumn('uuid') + id: string; + + @Index('idx_course_revisions_course_id') + @Column({ name: 'course_id', type: 'uuid' }) + courseId: string; + + @Column({ type: 'int' }) + version: number; + + /** + * Immutable JSON snapshot of the editable course fields at this version. + * Stored as `jsonb` so the structure can be queried/audited without an + * additional relation table. + */ + @Column({ type: 'jsonb' }) + snapshot: { + title: string; + description: string; + level: CourseLevel; + order: number; + learningPathId: string; + duration: number; + prerequisites: string[]; + skills: string[]; + xpReward: number; + isActive: boolean; + }; + + /** Optional human-readable summary of what changed */ + @Column({ name: 'change_note', type: 'text', nullable: true }) + changeNote?: string; + + /** Optional author/editor identifier who created this revision */ + @Column({ name: 'revision_author', type: 'varchar', length: 120, nullable: true }) + revisionAuthor?: string; + + /** Reason this revision was created (e.g. 'update', 'restore') */ + @Column({ type: 'varchar', length: 32 }) + reason: CourseRevisionReason; + + /** + * Version that was active immediately before this revision, for traceability. + * Null on the initial 'create' revision. + */ + @Column({ name: 'previous_version', type: 'int', nullable: true }) + previousVersion?: number; + + /** + * Optional pointer to the revision this one was derived from (e.g. a + * `restore` revision that pulled content from a prior version). + */ + @Column({ + name: 'reference_revision_id', + type: 'uuid', + nullable: true, + }) + referenceRevisionId?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + /** + * Optional forward relation to the parent course. Modelled as a plain + * indexed column without an FK constraint so the version history can + * outlive a deleted course — the audit trail must remain queryable even + * after the parent `CourseEntity` row is removed. + */ + course?: CourseEntity; + + constructor(partial: Partial = {}) { + Object.assign(this, partial); + this.createdAt = this.createdAt || new Date(); + this.reason = this.reason || 'update'; + } +} + +export type CourseRevisionReason = 'create' | 'update' | 'restore'; diff --git a/BackendAcademy/src/courses/course.controller.ts b/BackendAcademy/src/courses/course.controller.ts index 1e76335dc..884652bf0 100644 --- a/BackendAcademy/src/courses/course.controller.ts +++ b/BackendAcademy/src/courses/course.controller.ts @@ -1,7 +1,18 @@ -import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, +} from '@nestjs/common'; import { CourseService } from './course.service'; +import { CourseRevisionEntity } from './course-revision.entity'; +import { CourseEntity } from './course.entity'; import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; +import { RestoreRevisionDto } from './dto/restore-revision.dto'; import { CompleteCourseDto } from './dto/complete-course.dto'; @Controller('courses') @@ -29,7 +40,10 @@ export class CourseController { } @Put(':id') - async update(@Param('id') id: string, @Body() dto: UpdateCourseDto) { + async update( + @Param('id') id: string, + @Body() dto: UpdateCourseDto, + ): Promise { return this.courseService.update(id, dto); } @@ -38,6 +52,55 @@ export class CourseController { return this.courseService.remove(id); } + // --------------------------------------------------------------------------- + // Revision history endpoints + // + // Route ordering note: the explicit `/latest` and `/count` paths are + // declared before the parametric `/revisions/:version` so Express / Nest + // matches them first. Moving them below would break the lookup behavior. + // --------------------------------------------------------------------------- + + @Get(':id/revisions') + async listRevisions( + @Param('id') id: string, + ): Promise { + return this.courseService.getRevisions(id); + } + + @Get(':id/revisions/latest') + async getLatestRevision( + @Param('id') id: string, + ): Promise { + return this.courseService.getLatestRevision(id); + } + + @Get(':id/revisions/count') + async getRevisionCount( + @Param('id') id: string, + ): Promise<{ count: number }> { + const count = await this.courseService.getRevisionCount(id); + return { count }; + } + + @Get(':id/revisions/:version') + async getRevision( + @Param('id') id: string, + @Param('version') version: string, + ): Promise { + return this.courseService.getRevisionByVersion(id, Number(version)); + } + + @Post(':id/revisions/:version/restore') + async restoreRevision( + @Param('id') id: string, + @Param('version') version: string, + @Body() dto: RestoreRevisionDto, + ): Promise { + return this.courseService.restoreRevision( + id, + Number(version), + dto.revisionAuthor, + ); @Post(':id/complete') async complete(@Param('id') id: string, @Body() dto: CompleteCourseDto) { return this.courseService.completeCourse(id, dto.userId); diff --git a/BackendAcademy/src/courses/course.entity.ts b/BackendAcademy/src/courses/course.entity.ts index dbc78c6b7..b0af96716 100644 --- a/BackendAcademy/src/courses/course.entity.ts +++ b/BackendAcademy/src/courses/course.entity.ts @@ -1,26 +1,101 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + OneToMany, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; import { CourseLevel } from './interfaces/course-level.enum'; +import { CourseRevisionEntity } from './course-revision.entity'; +/** + * Postgres-backed Course entity. + * + * `CourseEntity` is the canonical, mutable representation of a course that is + * currently being served to learners. Every update to the course should also + * append an immutable entry to the `course_revisions` table (see + * `CourseRevisionEntity`) so the full version history is preserved. + */ +@Entity({ name: 'courses' }) export class CourseEntity { + @PrimaryColumn('uuid') id: string; + + @Column({ type: 'varchar', length: 200 }) title: string; + + @Column({ type: 'text' }) description: string; + + @Column({ type: 'enum', enum: CourseLevel }) level: CourseLevel; + + @Column({ type: 'int' }) order: number; + + @Index('idx_courses_learning_path_id') + @Column({ name: 'learning_path_id', type: 'uuid' }) learningPathId: string; + + @Column({ type: 'int' }) duration: number; + + @Column({ type: 'text', array: true, default: () => "'{}'" }) prerequisites: string[]; + + @Column({ type: 'text', array: true, default: () => "'{}'" }) skills: string[]; + + @Column({ name: 'xp_reward', type: 'int', default: 0 }) xpReward: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) isActive: boolean; + + /** + * Monotonically increasing version that mirrors the latest revision's + * version. Kept on the row itself so callers can compare current state + * to a known revision without an extra join. + */ + @Column({ type: 'int', default: 1 }) + version: number; + + /** + * Pointer to the most recent `CourseRevisionEntity.id` for this course. + * Modelled as a plain nullable UUID column to avoid circular foreign-key + * constraints during inserts. + */ + @Column({ + name: 'latest_revision_id', + type: 'uuid', + nullable: true, + }) + latestRevisionId?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) updatedAt: Date; - constructor(partial: Partial) { + /** + * Reverse relation: the full version history for this course. Not a + * database column — TypeORM populates it from the `course_revisions` table + * when explicitly queried. + */ + @OneToMany(() => CourseRevisionEntity, (revision) => revision.course) + revisions?: CourseRevisionEntity[]; + + constructor(partial: Partial = {}) { Object.assign(this, partial); this.createdAt = this.createdAt || new Date(); this.updatedAt = this.updatedAt || new Date(); this.isActive = this.isActive ?? true; this.prerequisites = this.prerequisites || []; this.skills = this.skills || []; + // Object.assign above already copies version; default to 1 when absent. + this.version ??= 1; } } diff --git a/BackendAcademy/src/courses/course.module.ts b/BackendAcademy/src/courses/course.module.ts index 59c97d691..a01ef2d50 100644 --- a/BackendAcademy/src/courses/course.module.ts +++ b/BackendAcademy/src/courses/course.module.ts @@ -1,6 +1,12 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { CourseController } from './course.controller'; import { CourseService } from './course.service'; +import { CourseEntity } from './course.entity'; +import { CourseRevisionEntity } from './course-revision.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([CourseEntity, CourseRevisionEntity])], import { RewardsModule } from '../rewards/rewards.module'; @Module({ diff --git a/BackendAcademy/src/courses/course.service.spec.ts b/BackendAcademy/src/courses/course.service.spec.ts new file mode 100644 index 000000000..effbe15fb --- /dev/null +++ b/BackendAcademy/src/courses/course.service.spec.ts @@ -0,0 +1,427 @@ +import { NotFoundException } from '@nestjs/common'; +import { CourseService } from './course.service'; +import { CourseEntity } from './course.entity'; +import { CourseRevisionEntity } from './course-revision.entity'; +import { CourseLevel } from './interfaces/course-level.enum'; + +/** + * Minimal in-memory mock that imitates the subset of the + * `Repository` surface that `CourseService` relies on. Tests construct + * a fresh mock per `beforeEach` so the service runs in isolation. + * + * `create()` invokes the entity constructor (mirroring real TypeORM + * behaviour) so defaults declared in the constructor — e.g. + * `isActive ??= true` — still apply to rows stored by the mock. + */ +class InMemoryRepository { + protected readonly rows: Map = new Map(); + + constructor( + protected readonly EntityCtor?: new (partial?: Partial) => T, + ) {} + + create(partial: Partial = {}): T { + if (this.EntityCtor) { + return new this.EntityCtor(partial); + } + return { ...(partial as T) }; + } + + async save(entity: T): Promise { + if (!entity.id) { + (entity as T & { id: string }).id = crypto.randomUUID(); + } + const now = new Date(); + if ('createdAt' in entity && !(entity as { createdAt?: Date }).createdAt) { + (entity as { createdAt: Date }).createdAt = now; + } + if ('updatedAt' in entity) { + (entity as { updatedAt: Date }).updatedAt = now; + } + this.rows.set((entity as T & { id: string }).id, entity); + return entity; + } + + async find(options: { where?: Partial; order?: { version?: 'ASC' | 'DESC' } } = {}): Promise { + const matches = Object.values(this.matchRows(options.where ?? {})); + if (options.order?.version) { + matches.sort((a, b) => { + const av = (a as unknown as { version: number }).version; + const bv = (b as unknown as { version: number }).version; + return options.order!.version === 'ASC' ? av - bv : bv - av; + }); + } + return matches; + } + + async findOne(options: { where: Partial; order?: { version?: 'ASC' | 'DESC' } }): Promise { + const [first] = Object.values(this.matchRows(options.where)); + if (first && options.order?.version) { + const all = Object.values(this.matchRows(options.where)); + return all.sort((a, b) => { + const av = (a as unknown as { version: number }).version; + const bv = (b as unknown as { version: number }).version; + return options.order!.version === 'ASC' ? av - bv : bv - av; + })[0]; + } + return first ?? null; + } + + async remove(entity: T): Promise { + this.rows.delete((entity as T & { id: string }).id); + return entity; + } + + async count(options: { where?: Partial } = {}): Promise { + return Object.values(this.matchRows(options.where ?? {})).length; + } + + private matchRows(where: Partial): Record { + const matches: Record = {}; + for (const [id, row] of this.rows.entries()) { + const ok = Object.entries(where as Record).every( + ([key, expected]) => { + const actual = (row as Record)[key]; + if (Array.isArray(expected)) { + return Array.isArray(actual) && + expected.length === actual.length && + expected.every((v, i) => v === (actual as unknown[])[i]); + } + return actual === expected; + }, + ); + if (ok) matches[id] = row; + } + return matches; + } +} + +class InMemoryCourseRepo extends InMemoryRepository { + constructor() { + super(CourseEntity); + } +} +class InMemoryRevisionRepo extends InMemoryRepository { + constructor() { + super(CourseRevisionEntity); + } +} + +describe('CourseService', () => { + let service: CourseService; + let courseRepo: InMemoryCourseRepo; + let revisionRepo: InMemoryRevisionRepo; + + beforeEach(() => { + courseRepo = new InMemoryCourseRepo(); + revisionRepo = new InMemoryRevisionRepo(); + service = new CourseService( + courseRepo as unknown as import('typeorm').Repository, + revisionRepo as unknown as import('typeorm').Repository, + ); + }); + + // --------------------------------------------------------------------------- + // Baseline CRUD behavior (unchanged from pre-versioning baseline) + // --------------------------------------------------------------------------- + + it('creates a course at version 1 with an initial revision', async () => { + const course = await service.create({ + title: 'Rust 101', + description: 'Intro to Rust', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-rust', + duration: 60, + xpReward: 50, + }); + + expect(course.id).toBeDefined(); + expect(course.version).toBe(1); + expect(course.latestRevisionId).toBeDefined(); + + const revisions = await service.getRevisions(course.id); + expect(revisions).toHaveLength(1); + expect(revisions[0].version).toBe(1); + expect(revisions[0].reason).toBe('create'); + expect(revisions[0].snapshot.title).toBe('Rust 101'); + }); + + it('returns only active courses from findAll()', async () => { + const active = await service.create({ + title: 'Active', + description: 'Active course', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + }); + const inactive = await service.create({ + title: 'Inactive', + description: 'Draft course', + level: CourseLevel.BEGINNER, + order: 2, + learningPathId: 'path-1', + duration: 30, + }); + await service.update(inactive.id, { isActive: false }); + + const all = await service.findAll(); + expect(all.map((c) => c.id)).toEqual([active.id]); + }); + + // --------------------------------------------------------------------------- + // Versioning on update + // --------------------------------------------------------------------------- + + it('increments version and appends a revision on each update', async () => { + const course = await service.create({ + title: 'Title v1', + description: 'Desc v1', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + }); + expect(course.version).toBe(1); + + const updated = await service.update(course.id, { + title: 'Title v2', + changeNote: 'Tightened wording', + revisionAuthor: 'editor-1', + }); + expect(updated).not.toBeNull(); + expect(updated!.version).toBe(2); + expect(updated!.title).toBe('Title v2'); + expect(updated!.latestRevisionId).toBeDefined(); + + const updated2 = await service.update(course.id, { + description: 'Desc v3', + changeNote: 'Expanded goals', + revisionAuthor: 'editor-2', + }); + expect(updated2!.version).toBe(3); + + const revisions = await service.getRevisions(course.id); + expect(revisions.map((r) => r.version)).toEqual([1, 2, 3]); + expect(revisions[1].changeNote).toBe('Tightened wording'); + expect(revisions[1].revisionAuthor).toBe('editor-1'); + expect(revisions[1].reason).toBe('update'); + expect(revisions[1].previousVersion).toBe(1); + expect(revisions[2].snapshot.title).toBe('Title v2'); // carries forward previous edits + expect(revisions[2].snapshot.description).toBe('Desc v3'); + }); + + it('updates latestRevisionId to point at the most recent revision', async () => { + const course = await service.create({ + title: 'Token Test', + description: 'Test', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + }); + const firstLatestId = course.latestRevisionId; + + const updated = await service.update(course.id, { title: 'Token Test 2' }); + expect(updated!.latestRevisionId).not.toEqual(firstLatestId); + + const latest = await service.getLatestRevision(course.id); + expect(latest).not.toBeNull(); + expect(latest!.id).toBe(updated!.latestRevisionId); + expect(latest!.snapshot.title).toBe('Token Test 2'); + }); + + it('preserves a deep copy of the snapshot so later updates do not mutate history', async () => { + const course = await service.create({ + title: 'Snapshot Test', + description: 'Desc', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + prerequisites: ['rust-basics'], + }); + await service.update(course.id, { + prerequisites: ['rust-basics', 'ownership'], + skills: ['borrowing'], + }); + + const revisions = await service.getRevisions(course.id); + expect(revisions[0].snapshot.prerequisites).toEqual(['rust-basics']); + expect(revisions[1].snapshot.prerequisites).toEqual([ + 'rust-basics', + 'ownership', + ]); + }); + + // --------------------------------------------------------------------------- + // Revision lookup + // --------------------------------------------------------------------------- + + it('returns the correct revision by version', async () => { + const course = await service.create({ + title: 'Lookup', + description: 'Desc', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + }); + await service.update(course.id, { title: 'Lookup v2' }); + + const v1 = await service.getRevisionByVersion(course.id, 1); + const v2 = await service.getRevisionByVersion(course.id, 2); + + expect(v1).not.toBeNull(); + expect(v1!.snapshot.title).toBe('Lookup'); + expect(v2).not.toBeNull(); + expect(v2!.snapshot.title).toBe('Lookup v2'); + }); + + it('returns null for a missing revision version', async () => { + const course = await service.create({ + title: 'MissingVersion', + description: 'Desc', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + }); + const result = await service.getRevisionByVersion(course.id, 99); + expect(result).toBeNull(); + }); + + it('returns an empty array when listing revisions for an unknown course', async () => { + const revisions = await service.getRevisions('non-existent'); + expect(revisions).toEqual([]); + expect(await service.getRevisionByVersion('non-existent', 1)).toBeNull(); + expect(await service.getRevisionCount('non-existent')).toBe(0); + expect(await service.getLatestRevision('non-existent')).toBeNull(); + }); + + it('throws NotFoundException for non-positive revision versions', async () => { + const course = await service.create({ + title: 'BadVersion', + description: 'Desc', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + }); + await expect(service.getRevisionByVersion(course.id, 0)).rejects.toThrow( + NotFoundException, + ); + await expect(service.getRevisionByVersion(course.id, -1)).rejects.toThrow( + NotFoundException, + ); + }); + + it('counts the number of revisions correctly', async () => { + const course = await service.create({ + title: 'Counter', + description: 'Desc', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + }); + expect(await service.getRevisionCount(course.id)).toBe(1); + await service.update(course.id, { title: 'Counter v2' }); + expect(await service.getRevisionCount(course.id)).toBe(2); + }); + + // --------------------------------------------------------------------------- + // Restore behavior + // --------------------------------------------------------------------------- + + it('restores a course to a previous version and records a new revision', async () => { + const course = await service.create({ + title: 'Original', + description: 'Original desc', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + prerequisites: [], + }); + await service.update(course.id, { + title: 'Second', + description: 'Second desc', + prerequisites: ['pre-1'], + }); + await service.update(course.id, { + title: 'Third', + description: 'Third desc', + prerequisites: ['pre-1', 'pre-2'], + }); + + const restored = await service.restoreRevision( + course.id, + 1, + 'editor-restore', + ); + expect(restored).not.toBeNull(); + expect(restored!.title).toBe('Original'); + expect(restored!.description).toBe('Original desc'); + expect(restored!.prerequisites).toEqual([]); + // Restoring bumps the version forward (append-only history) + expect(restored!.version).toBe(4); + + const revisions = await service.getRevisions(course.id); + expect(revisions.map((r) => r.version)).toEqual([1, 2, 3, 4]); + expect(revisions[3].reason).toBe('restore'); + expect(revisions[3].changeNote).toBe('Restored from version 1'); + expect(revisions[3].revisionAuthor).toBe('editor-restore'); + expect(revisions[3].previousVersion).toBe(3); + expect(revisions[3].referenceRevisionId).toBe(revisions[0].id); + }); + + it('throws NotFoundException when restoring a course that does not exist', async () => { + await expect(service.restoreRevision('ghost-course', 1)).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws NotFoundException when restoring from a non-existent revision', async () => { + const course = await service.create({ + title: 'RestoreFail', + description: 'Desc', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + }); + await expect(service.restoreRevision(course.id, 99)).rejects.toThrow( + NotFoundException, + ); + }); + + // --------------------------------------------------------------------------- + // Removal preserves revision history + // --------------------------------------------------------------------------- + + it('removes the course but keeps its revision history queryable for audit', async () => { + const course = await service.create({ + title: 'ToDelete', + description: 'Desc', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + }); + + expect(await service.remove(course.id)).toBe(true); + + // The course itself is gone + expect(await service.findById(course.id)).toBeNull(); + // But the revision snapshot is preserved (the repo's `remove` only + // touches the course row). This matches the production behaviour where + // revisions survive a course deletion for audit purposes. + const revisions = await service.getRevisions(course.id); + expect(revisions).toHaveLength(1); + expect(revisions[0].snapshot.title).toBe('ToDelete'); + expect(await service.getRevisionByVersion(course.id, 1)).not.toBeNull(); + expect(await service.getRevisionCount(course.id)).toBe(1); + }); +}); diff --git a/BackendAcademy/src/courses/course.service.ts b/BackendAcademy/src/courses/course.service.ts index 683cfd25f..9b96bc630 100644 --- a/BackendAcademy/src/courses/course.service.ts +++ b/BackendAcademy/src/courses/course.service.ts @@ -1,47 +1,251 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { CourseEntity } from './course.entity'; +import { + CourseRevisionEntity, + CourseRevisionReason, +} from './course-revision.entity'; import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; import { RewardsService } from '../rewards/rewards.service'; +/** + * Business logic for courses. + * + * Persistence is delegated to injected TypeORM repositories + * (`Repository` and `Repository`). + * Each meaningful course change appends an immutable revision to the + * `course_revisions` table so the full version history is preserved as + * an append-only audit trail. + */ @Injectable() export class CourseService { - private readonly courses: Map = new Map(); + /** + * Baseline version assigned to brand-new courses. Kept as a private + * constant so the initial version can never drift away from `1`. + */ + private static readonly INITIAL_VERSION = 1; + + constructor( + @InjectRepository(CourseEntity) + private readonly courseRepo: Repository, + @InjectRepository(CourseRevisionEntity) + private readonly revisionRepo: Repository, + ) {} constructor(private readonly rewardsService: RewardsService) {} async create(dto: CreateCourseDto): Promise { - const course = new CourseEntity({ + const course = this.courseRepo.create({ id: crypto.randomUUID(), + version: CourseService.INITIAL_VERSION, ...dto, }); - this.courses.set(course.id, course); - return course; + const saved = await this.courseRepo.save(course); + await this.appendRevision(saved, 'create', { + changeNote: 'Initial version', + }); + return saved; } async findAll(): Promise { - return Array.from(this.courses.values()).filter(c => c.isActive); + return this.courseRepo.find({ where: { isActive: true } }); } async findByLevel(level: string): Promise { - return Array.from(this.courses.values()).filter( - c => c.isActive && c.level === level, - ); + return this.courseRepo.find({ + where: { isActive: true, level: level as CourseEntity['level'] }, + }); } async findById(id: string): Promise { - return this.courses.get(id) || null; + return this.courseRepo.findOne({ where: { id } }); } async update(id: string, dto: UpdateCourseDto): Promise { - const course = this.courses.get(id); + const course = await this.courseRepo.findOne({ where: { id } }); if (!course) return null; - Object.assign(course, dto, { updatedAt: new Date() }); - return course; + + const previousVersion = course.version; + course.version = previousVersion + 1; + course.updatedAt = new Date(); + Object.assign(course, dto); + const saved = await this.courseRepo.save(course); + + await this.appendRevision(saved, 'update', { + changeNote: dto.changeNote, + revisionAuthor: dto.revisionAuthor, + previousVersion, + }); + return saved; } async remove(id: string): Promise { - return this.courses.delete(id); + const course = await this.courseRepo.findOne({ where: { id } }); + if (!course) return false; + await this.courseRepo.remove(course); + // Revisions are intentionally retained so admins can audit what content + // was previously published even after the parent course row is gone. + return true; + } + + // --------------------------------------------------------------------------- + // Revision history API + // --------------------------------------------------------------------------- + + /** + * Returns the full revision history for a course, ordered by version ascending. + * Revisions remain queryable even after the parent course has been removed + * so the audit trail can still be inspected. + */ + async getRevisions(courseId: string): Promise { + return this.revisionRepo.find({ + where: { courseId }, + order: { version: 'ASC' }, + }); + } + + /** + * Returns the latest revision for a course, or null when no revisions exist. + */ + async getLatestRevision( + courseId: string, + ): Promise { + return this.revisionRepo.findOne({ + where: { courseId }, + order: { version: 'DESC' }, + }); + } + + /** + * Returns a specific revision by its numeric version for a given course. + * Returns null when the revision cannot be found. + */ + async getRevisionByVersion( + courseId: string, + version: number, + ): Promise { + if (!Number.isFinite(version) || version < 1) { + throw new NotFoundException({ + error: 'INVALID_VERSION', + message: `Version must be a positive integer`, + }); + } + return this.revisionRepo.findOne({ where: { courseId, version } }); + } + + /** + * Restores the content of a course to a previous revision. The restore + * operation itself is recorded as a new revision so the audit trail + * remains append-only and the current version always points at the + * latest revision. + */ + async restoreRevision( + courseId: string, + version: number, + revisionAuthor?: string, + ): Promise { + const course = await this.courseRepo.findOne({ where: { id: courseId } }); + if (!course) { + throw new NotFoundException({ + error: 'COURSE_NOT_FOUND', + message: `Course with ID ${courseId} not found`, + }); + } + + const sourceRevision = await this.getRevisionByVersion(courseId, version); + if (!sourceRevision) { + throw new NotFoundException({ + error: 'REVISION_NOT_FOUND', + message: `Revision ${version} not found for course ${courseId}`, + }); + } + + const previousVersion = course.version; + const target = sourceRevision.snapshot; + course.title = target.title; + course.description = target.description; + course.level = target.level; + course.order = target.order; + course.learningPathId = target.learningPathId; + course.duration = target.duration; + course.prerequisites = [...target.prerequisites]; + course.skills = [...target.skills]; + course.xpReward = target.xpReward; + course.isActive = target.isActive; + course.version = previousVersion + 1; + course.updatedAt = new Date(); + + const saved = await this.courseRepo.save(course); + await this.appendRevision(saved, 'restore', { + changeNote: `Restored from version ${version}`, + revisionAuthor, + previousVersion, + referenceRevisionId: sourceRevision.id, + }); + return saved; + } + + /** + * Returns the total number of revisions recorded for a course. + */ + async getRevisionCount(courseId: string): Promise { + return this.revisionRepo.count({ where: { courseId } }); + } + + // --------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------- + + /** + * Persist a new revision snapshot of the course and update the course's + * `latestRevisionId` pointer in one round-trip each. + * + * Returns the saved revision so callers can read its id without an extra + * query. Revisions are immutable once recorded. + * + * Persistence order is forced by FK constraints: the course must exist + * before the revision that references it can be inserted. + */ + private async appendRevision( + course: CourseEntity, + reason: CourseRevisionReason, + options: { + changeNote?: string; + revisionAuthor?: string; + previousVersion?: number; + referenceRevisionId?: string; + } = {}, + ): Promise { + const revision = this.revisionRepo.create({ + id: crypto.randomUUID(), + courseId: course.id, + version: course.version, + snapshot: { + title: course.title, + description: course.description, + level: course.level, + order: course.order, + learningPathId: course.learningPathId, + duration: course.duration, + prerequisites: [...(course.prerequisites ?? [])], + skills: [...(course.skills ?? [])], + xpReward: course.xpReward, + isActive: course.isActive, + }, + changeNote: options.changeNote, + revisionAuthor: options.revisionAuthor, + reason, + previousVersion: options.previousVersion, + referenceRevisionId: options.referenceRevisionId, + }); + const savedRevision = await this.revisionRepo.save(revision); + + course.latestRevisionId = savedRevision.id; + course.updatedAt = new Date(); + await this.courseRepo.save(course); + return savedRevision; } async completeCourse(id: string, userId: string) { diff --git a/BackendAcademy/src/courses/dto/restore-revision.dto.ts b/BackendAcademy/src/courses/dto/restore-revision.dto.ts new file mode 100644 index 000000000..9ee1898f9 --- /dev/null +++ b/BackendAcademy/src/courses/dto/restore-revision.dto.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class RestoreRevisionDto { + @IsOptional() + @IsString() + @MaxLength(120) + revisionAuthor?: string; +} diff --git a/BackendAcademy/src/courses/dto/update-course.dto.ts b/BackendAcademy/src/courses/dto/update-course.dto.ts index e832bb208..97cd4a522 100644 --- a/BackendAcademy/src/courses/dto/update-course.dto.ts +++ b/BackendAcademy/src/courses/dto/update-course.dto.ts @@ -43,4 +43,12 @@ export class UpdateCourseDto { @IsOptional() @IsBoolean() isActive?: boolean; + + @IsOptional() + @IsString() + changeNote?: string; + + @IsOptional() + @IsString() + revisionAuthor?: string; } diff --git a/BackendAcademy/src/courses/index.ts b/BackendAcademy/src/courses/index.ts index fed20916e..c6070bab3 100644 --- a/BackendAcademy/src/courses/index.ts +++ b/BackendAcademy/src/courses/index.ts @@ -1,10 +1,15 @@ export { CourseModule } from './course.module'; export { CourseService } from './course.service'; export { CourseEntity } from './course.entity'; +export { + CourseRevisionEntity, + CourseRevisionReason, +} from './course-revision.entity'; export { CourseLevel } from './interfaces/course-level.enum'; export { ICourse, ILesson, ITask } from './interfaces/course.interface'; export { CreateCourseDto } from './dto/create-course.dto'; export { UpdateCourseDto } from './dto/update-course.dto'; +export { RestoreRevisionDto } from './dto/restore-revision.dto'; export { ProgressModule } from './progress/progress.module'; export { ProgressService } from './progress/progress.service'; export { ProgressController } from './progress/progress.controller';