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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions BackendAcademy/src/courses/course-revision.entity.ts
Original file line number Diff line number Diff line change
@@ -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<CourseRevisionEntity> = {}) {
Object.assign(this, partial);
this.createdAt = this.createdAt || new Date();
this.reason = this.reason || 'update';
}
}

export type CourseRevisionReason = 'create' | 'update' | 'restore';
67 changes: 65 additions & 2 deletions BackendAcademy/src/courses/course.controller.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand Down Expand Up @@ -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<CourseEntity> {
return this.courseService.update(id, dto);
}

Expand All @@ -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<CourseRevisionEntity[]> {
return this.courseService.getRevisions(id);
}

@Get(':id/revisions/latest')
async getLatestRevision(
@Param('id') id: string,
): Promise<CourseRevisionEntity | null> {
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<CourseRevisionEntity | null> {
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<CourseEntity> {
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);
Expand Down
77 changes: 76 additions & 1 deletion BackendAcademy/src/courses/course.entity.ts
Original file line number Diff line number Diff line change
@@ -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<CourseEntity>) {
/**
* 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<CourseEntity> = {}) {
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;
}
}
6 changes: 6 additions & 0 deletions BackendAcademy/src/courses/course.module.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
Loading
Loading