From 29bbea48e848d9d4deae0ffe58e6a1111287a1ea Mon Sep 17 00:00:00 2001 From: dunnidev Date: Tue, 30 Jun 2026 01:39:17 +0000 Subject: [PATCH 1/2] feat(courses): add content versioning with immutable revision history Closes #218 Adds database fields for content revisions and version history to the courses module so each course change is recorded as an immutable revision that can be queried, counted, and restored. Schema additions (BackendAcademy/src/courses/) * course.entity.ts: add version (number, defaults to 1) and latestRevisionId (string | undefined) to CourseEntity so the current "head" revision can be located at O(1) from the course record. * course-revision.entity.ts (new): immutable CourseRevisionEntity capturing a per-version snapshot of title, description, level, order, learningPathId, duration, prerequisites, skills, xpReward, isActive plus changeNote, revisionAuthor, reason ("create" | "update" | "restore"), previousVersion, and referenceRevisionId for traceability. * dto/update-course.dto.ts: add changeNote and revisionAuthor so editors can attribute updates when calling PUT /courses/:id. Service (BackendAcademy/src/courses/course.service.ts) * Maintains a separate revisions Map keyed by revision id. * create()/update() now automatically append a revision. Each update bumps course.version by 1 and records the new state. * restoreRevision() copies the snapshot of a chosen historical version back onto the live course and records a new "restore" revision, keeping the history append-only. * Read-only methods getRevisions, getLatestRevision, getRevisionByVersion, getRevisionCount continue to work even after a course is deleted so the audit trail survives. * Snapshot arrays (prerequisites, skills) are cloned; scalar fields are values, so subsequent live-course mutations cannot alias historical state. Controller (BackendAcademy/src/courses/course.controller.ts) * GET /courses/:id/revisions list revisions * GET /courses/:id/revisions/latest latest revision * GET /courses/:id/revisions/count revision count * GET /courses/:id/revisions/:version specific version * POST /courses/:id/revisions/:version/restore restore a version * Restore uses new RestoreRevisionDto body so revisionAuthor is class-validator validated (MaxLength 120); route-order constraint (latest/count before :version) is documented inline so future maintainers do not silently break it. Tests (BackendAcademy/src/courses/course.service.spec.ts, 14 cases) * Verifies version incrementing and revision appendage on create/update, latestRevisionId pointer rotation, snapshot deep-copy of arrays, getRevisions/getLatestRevision/ getRevisionByVersion behavior, restore flow produces a new revision and resets content, NotFoundException for missing/ non-positive versions, and audit-trail-survives-removal. Out of scope (deliberately) * The existing courses/audit/ module is left untouched: its AuditLogModule is not imported by app.module.ts and its in-file provider is missing @Injectable(), so an integration here would not be DI-resolvable. Revisions are themselves a complete audit trail for course content; general system audit logging can be wired up in a separate change. --- .../src/courses/course-revision.entity.ts | 45 +++ .../src/courses/course.controller.ts | 69 +++- BackendAcademy/src/courses/course.entity.ts | 4 + .../src/courses/course.service.spec.ts | 313 ++++++++++++++++++ BackendAcademy/src/courses/course.service.ts | 182 +++++++++- .../src/courses/dto/restore-revision.dto.ts | 8 + .../src/courses/dto/update-course.dto.ts | 8 + BackendAcademy/src/courses/index.ts | 5 + 8 files changed, 627 insertions(+), 7 deletions(-) create mode 100644 BackendAcademy/src/courses/course-revision.entity.ts create mode 100644 BackendAcademy/src/courses/course.service.spec.ts create mode 100644 BackendAcademy/src/courses/dto/restore-revision.dto.ts diff --git a/BackendAcademy/src/courses/course-revision.entity.ts b/BackendAcademy/src/courses/course-revision.entity.ts new file mode 100644 index 000000000..35ed30684 --- /dev/null +++ b/BackendAcademy/src/courses/course-revision.entity.ts @@ -0,0 +1,45 @@ +import { CourseLevel } from './interfaces/course-level.enum'; + +/** + * Immutable historical snapshot of a course at a given version. + * + * Each `CourseRevisionEntity` represents one entry in a course's + * revision history. Revisions are append-only and never mutated + * after creation; subsequent updates create new revisions. + */ +export class CourseRevisionEntity { + id: string; + courseId: string; + version: number; + /** Immutable snapshot of the course content at this version */ + 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 */ + changeNote?: string; + /** Optional author/editor identifier who created this revision */ + revisionAuthor?: string; + /** Optional reason this revision was created (e.g. 'update', 'restore') */ + reason: CourseRevisionReason; + /** Version that was active immediately before this revision, for traceability */ + previousVersion?: number; + referenceRevisionId?: string; + createdAt: Date; + + 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 e979bbb52..201896282 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'; @Controller('courses') export class CourseController { @@ -28,7 +39,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); } @@ -36,4 +50,55 @@ export class CourseController { async remove(@Param('id') id: string) { 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, + ); + } } diff --git a/BackendAcademy/src/courses/course.entity.ts b/BackendAcademy/src/courses/course.entity.ts index dbc78c6b7..d6f7849a0 100644 --- a/BackendAcademy/src/courses/course.entity.ts +++ b/BackendAcademy/src/courses/course.entity.ts @@ -12,6 +12,8 @@ export class CourseEntity { skills: string[]; xpReward: number; isActive: boolean; + version: number; + latestRevisionId?: string; createdAt: Date; updatedAt: Date; @@ -22,5 +24,7 @@ export class CourseEntity { 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.service.spec.ts b/BackendAcademy/src/courses/course.service.spec.ts new file mode 100644 index 000000000..2c3acc1d3 --- /dev/null +++ b/BackendAcademy/src/courses/course.service.spec.ts @@ -0,0 +1,313 @@ +import { NotFoundException } from '@nestjs/common'; +import { CourseService } from './course.service'; +import { CourseLevel } from './interfaces/course-level.enum'; + +describe('CourseService', () => { + let service: CourseService; + + beforeEach(() => { + service = new CourseService(); + }); + + // --------------------------------------------------------------------------- + // 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 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 58010fbf1..7f53361d9 100644 --- a/BackendAcademy/src/courses/course.service.ts +++ b/BackendAcademy/src/courses/course.service.ts @@ -1,11 +1,16 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; 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'; @Injectable() export class CourseService { private readonly courses: Map = new Map(); + private readonly revisions: Map = new Map(); async create(dto: CreateCourseDto): Promise { const course = new CourseEntity({ @@ -13,16 +18,19 @@ export class CourseService { ...dto, }); this.courses.set(course.id, course); + this.recordRevision(course, 'create', { + changeNote: 'Initial version', + }); return course; } async findAll(): Promise { - return Array.from(this.courses.values()).filter(c => c.isActive); + return Array.from(this.courses.values()).filter((c) => c.isActive); } async findByLevel(level: string): Promise { return Array.from(this.courses.values()).filter( - c => c.isActive && c.level === level, + (c) => c.isActive && c.level === level, ); } @@ -33,11 +41,175 @@ export class CourseService { async update(id: string, dto: UpdateCourseDto): Promise { const course = this.courses.get(id); if (!course) return null; - Object.assign(course, dto, { updatedAt: new Date() }); + + const previousVersion = course.version; + Object.assign(course, dto, { + updatedAt: new Date(), + version: previousVersion + 1, + }); + this.recordRevision(course, 'update', { + changeNote: dto.changeNote, + revisionAuthor: dto.revisionAuthor, + previousVersion, + }); return course; } async remove(id: string): Promise { - return this.courses.delete(id); + const course = this.courses.get(id); + if (!course) return false; + this.courses.delete(id); + // Revisions are retained in the revisions Map even after the course is + // removed so admins can audit what content was previously published. + return true; + } + + /** + * 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 Array.from(this.revisions.values()) + .filter((revision) => revision.courseId === courseId) + .sort((a, b) => a.version - b.version); + } + + /** + * Returns the latest revision for a course, or null when no revisions exist. + */ + async getLatestRevision( + courseId: string, + ): Promise { + const courseRevisions = Array.from(this.revisions.values()).filter( + (revision) => revision.courseId === courseId, + ); + if (courseRevisions.length === 0) return null; + return courseRevisions.reduce((latest, current) => + current.version > latest.version ? current : latest, + ); + } + + /** + * Returns a specific revision by its numeric version for a given course. + * Returns null when the course or 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 ( + Array.from(this.revisions.values()).find( + (revision) => + revision.courseId === courseId && revision.version === version, + ) || null + ); + } + + /** + * 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 = this.courses.get(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(); + + this.recordRevision(course, 'restore', { + changeNote: `Restored from version ${version}`, + revisionAuthor, + previousVersion, + referenceRevisionId: sourceRevision.id, + }); + return course; + } + + /** + * Returns the total number of revisions recorded for a course. + */ + async getRevisionCount(courseId: string): Promise { + return Array.from(this.revisions.values()).filter( + (revision) => revision.courseId === courseId, + ).length; + } + + /** + * Internal helper: append a revision representing the current state of the + * course. Revisions are immutable once recorded. The course id is used to + * scope all subsequent revision lookups. + */ + private recordRevision( + course: CourseEntity, + reason: CourseRevisionReason, + options: { + changeNote?: string; + revisionAuthor?: string; + previousVersion?: number; + referenceRevisionId?: string; + } = {}, + ): CourseRevisionEntity { + const revision = new CourseRevisionEntity({ + 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, + }); + this.revisions.set(revision.id, revision); + course.latestRevisionId = revision.id; + return revision; } } 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 f4a7b3363..7ae128392 100644 --- a/BackendAcademy/src/courses/index.ts +++ b/BackendAcademy/src/courses/index.ts @@ -1,7 +1,12 @@ 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'; From f5a2540549e392f74c581ee38b700fb7c9dbc49c Mon Sep 17 00:00:00 2001 From: Codebuff Date: Tue, 30 Jun 2026 05:02:53 +0000 Subject: [PATCH 2/2] feat(courses): persist content versioning with TypeORM DB fields Add DB-persisted course content versioning and revision history to BackendAcademy/src/courses/. Closes #218. CourseEntity Persist id (uuid), title (varchar 200), description (text), level (enum), order (int), learning_path_id (uuid, indexed), prerequisites and skills as text[], xp_reward (int), is_active (bool), version (int), latest_revision_id (uuid, nullable), created_at and updated_at (timestamptz). CourseRevisionEntity Persist id (uuid), course_id (uuid, indexed), version (int), snapshot as jsonb, change_note (text, nullable), revision_author (varchar 120, nullable), reason (varchar 32), previous_version (int, nullable), reference_revision_id (uuid, nullable). Audit trail intentionally outlives deleted courses: no FK is declared on course_id so revisions remain queryable for audit even after the parent course row is removed. CourseService Replace the in-memory Maps with injected TypeORM repositories. Every meaningful change (create, update, restore) appends an immutable revision. Restore replays a chosen snapshot onto the course and writes a 'restore' revision that references the source revision id. latestRevisionId stays in sync through a single appendRevision helper. CourseModule Register CourseEntity and CourseRevisionEntity with TypeOrmModule.forFeature so the injected repositories resolve to the real Postgres tables. Tests Rewrite course.service.spec.ts to construct CourseService with a minimal in-memory repository mock that mirrors the TypeORM surface (create / save / find / findOne / count / remove) and invokes the entity constructor so default values still apply. All 14 prior behavioural assertions are preserved: create at v1, version bump on update, deep-copy snapshot retention, revision lookup, restore append + reference id, audit retention after course deletion. --- .../src/courses/course-revision.entity.ts | 71 ++++++++- BackendAcademy/src/courses/course.entity.ts | 73 ++++++++- BackendAcademy/src/courses/course.module.ts | 4 + .../src/courses/course.service.spec.ts | 118 +++++++++++++- BackendAcademy/src/courses/course.service.ts | 146 +++++++++++------- 5 files changed, 344 insertions(+), 68 deletions(-) diff --git a/BackendAcademy/src/courses/course-revision.entity.ts b/BackendAcademy/src/courses/course-revision.entity.ts index 35ed30684..ba74d118a 100644 --- a/BackendAcademy/src/courses/course-revision.entity.ts +++ b/BackendAcademy/src/courses/course-revision.entity.ts @@ -1,17 +1,41 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, +} from 'typeorm'; import { CourseLevel } from './interfaces/course-level.enum'; +import { CourseEntity } from './course.entity'; /** - * Immutable historical snapshot of a course at a given version. + * Append-only, immutable snapshot of a course at a specific version. * - * Each `CourseRevisionEntity` represents one entry in a course's - * revision history. Revisions are append-only and never mutated - * after creation; subsequent updates create new revisions. + * `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 snapshot of the course content at this version */ + + /** + * 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; @@ -24,18 +48,49 @@ export class CourseRevisionEntity { 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; - /** Optional reason this revision was created (e.g. 'update', 'restore') */ + + /** 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 */ + + /** + * 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; - constructor(partial: Partial) { + /** + * 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'; diff --git a/BackendAcademy/src/courses/course.entity.ts b/BackendAcademy/src/courses/course.entity.ts index d6f7849a0..b0af96716 100644 --- a/BackendAcademy/src/courses/course.entity.ts +++ b/BackendAcademy/src/courses/course.entity.ts @@ -1,23 +1,94 @@ +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(); diff --git a/BackendAcademy/src/courses/course.module.ts b/BackendAcademy/src/courses/course.module.ts index 0d9bddc58..77d987327 100644 --- a/BackendAcademy/src/courses/course.module.ts +++ b/BackendAcademy/src/courses/course.module.ts @@ -1,8 +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])], controllers: [CourseController], providers: [CourseService], exports: [CourseService], diff --git a/BackendAcademy/src/courses/course.service.spec.ts b/BackendAcademy/src/courses/course.service.spec.ts index 2c3acc1d3..effbe15fb 100644 --- a/BackendAcademy/src/courses/course.service.spec.ts +++ b/BackendAcademy/src/courses/course.service.spec.ts @@ -1,12 +1,124 @@ 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(() => { - service = new CourseService(); + courseRepo = new InMemoryCourseRepo(); + revisionRepo = new InMemoryRevisionRepo(); + service = new CourseService( + courseRepo as unknown as import('typeorm').Repository, + revisionRepo as unknown as import('typeorm').Repository, + ); }); // --------------------------------------------------------------------------- @@ -303,7 +415,9 @@ describe('CourseService', () => { // The course itself is gone expect(await service.findById(course.id)).toBeNull(); - // But the revision snapshot is preserved for audit purposes + // 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'); diff --git a/BackendAcademy/src/courses/course.service.ts b/BackendAcademy/src/courses/course.service.ts index 7f53361d9..b2aaeca81 100644 --- a/BackendAcademy/src/courses/course.service.ts +++ b/BackendAcademy/src/courses/course.service.ts @@ -1,4 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { CourseEntity } from './course.entity'; import { CourseRevisionEntity, @@ -7,72 +9,98 @@ import { import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; +/** + * 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(); - private readonly revisions: 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, + ) {} 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); - this.recordRevision(course, 'create', { + const saved = await this.courseRepo.save(course); + await this.appendRevision(saved, 'create', { changeNote: 'Initial version', }); - return course; + 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; const previousVersion = course.version; - Object.assign(course, dto, { - updatedAt: new Date(), - version: previousVersion + 1, - }); - this.recordRevision(course, 'update', { + 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 course; + return saved; } async remove(id: string): Promise { - const course = this.courses.get(id); + const course = await this.courseRepo.findOne({ where: { id } }); if (!course) return false; - this.courses.delete(id); - // Revisions are retained in the revisions Map even after the course is - // removed so admins can audit what content was previously published. + 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 Array.from(this.revisions.values()) - .filter((revision) => revision.courseId === courseId) - .sort((a, b) => a.version - b.version); + return this.revisionRepo.find({ + where: { courseId }, + order: { version: 'ASC' }, + }); } /** @@ -81,18 +109,15 @@ export class CourseService { async getLatestRevision( courseId: string, ): Promise { - const courseRevisions = Array.from(this.revisions.values()).filter( - (revision) => revision.courseId === courseId, - ); - if (courseRevisions.length === 0) return null; - return courseRevisions.reduce((latest, current) => - current.version > latest.version ? current : latest, - ); + 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 course or revision cannot be found. + * Returns null when the revision cannot be found. */ async getRevisionByVersion( courseId: string, @@ -104,25 +129,21 @@ export class CourseService { message: `Version must be a positive integer`, }); } - return ( - Array.from(this.revisions.values()).find( - (revision) => - revision.courseId === courseId && revision.version === version, - ) || null - ); + 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. + * 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 = this.courses.get(courseId); + const course = await this.courseRepo.findOne({ where: { id: courseId } }); if (!course) { throw new NotFoundException({ error: 'COURSE_NOT_FOUND', @@ -153,30 +174,38 @@ export class CourseService { course.version = previousVersion + 1; course.updatedAt = new Date(); - this.recordRevision(course, 'restore', { + const saved = await this.courseRepo.save(course); + await this.appendRevision(saved, 'restore', { changeNote: `Restored from version ${version}`, revisionAuthor, previousVersion, referenceRevisionId: sourceRevision.id, }); - return course; + return saved; } /** * Returns the total number of revisions recorded for a course. */ async getRevisionCount(courseId: string): Promise { - return Array.from(this.revisions.values()).filter( - (revision) => revision.courseId === courseId, - ).length; + return this.revisionRepo.count({ where: { courseId } }); } + // --------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------- + /** - * Internal helper: append a revision representing the current state of the - * course. Revisions are immutable once recorded. The course id is used to - * scope all subsequent revision lookups. + * 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 recordRevision( + private async appendRevision( course: CourseEntity, reason: CourseRevisionReason, options: { @@ -185,8 +214,8 @@ export class CourseService { previousVersion?: number; referenceRevisionId?: string; } = {}, - ): CourseRevisionEntity { - const revision = new CourseRevisionEntity({ + ): Promise { + const revision = this.revisionRepo.create({ id: crypto.randomUUID(), courseId: course.id, version: course.version, @@ -208,8 +237,11 @@ export class CourseService { previousVersion: options.previousVersion, referenceRevisionId: options.referenceRevisionId, }); - this.revisions.set(revision.id, revision); - course.latestRevisionId = revision.id; - return revision; + const savedRevision = await this.revisionRepo.save(revision); + + course.latestRevisionId = savedRevision.id; + course.updatedAt = new Date(); + await this.courseRepo.save(course); + return savedRevision; } }