@@ -2,6 +2,7 @@ import { Injectable, Logger, NotFoundException, Optional, ConflictException } fr
22import { InjectRepository } from '@nestjs/typeorm' ;
33import { Repository } from 'typeorm' ;
44import { CourseEntity } from './course.entity' ;
5+ import { CourseLevel } from './interfaces/course-level.enum' ;
56import {
67 CourseRevisionEntity ,
78 CourseRevisionReason ,
@@ -71,12 +72,14 @@ export class CourseService {
7172 * observe a course without an audit trail entry.
7273 */
7374 async create ( dto : CreateCourseDto ) : Promise < CourseEntity > {
74- const slug = await this . createUniqueSlug ( dto . title ) ;
75+ // BA-047: bound + canonicalize taxonomy before anything is persisted.
76+ const normalized = this . normalizeCourseInput ( dto ) ;
77+ const slug = await this . createUniqueSlug ( normalized . title ?? dto . title ) ;
7578 const course = this . courseRepo . create ( {
7679 id : crypto . randomUUID ( ) ,
7780 version : CourseService . INITIAL_VERSION ,
7881 slug,
79- ...dto ,
82+ ...normalized ,
8083 } ) ;
8184
8285 const txResult = await this . transactionManager . runAtomic ( async ( tx ) => {
@@ -128,14 +131,17 @@ export class CourseService {
128131 const course = await this . courseRepo . findOne ( { where : { id } } ) ;
129132 if ( ! course ) return null ;
130133
134+ // BA-047: bound + canonicalize taxonomy so only canonical values persist.
135+ const normalized = this . normalizeCourseInput ( dto ) ;
136+
131137 const previousVersion = course . version ;
132138 course . version = previousVersion + 1 ;
133139 course . updatedAt = new Date ( ) ;
134- Object . assign ( course , dto ) ;
135- if ( dto . title !== undefined ) {
136- course . slug = await this . createUniqueSlug ( dto . title , course . id ) ;
140+ Object . assign ( course , normalized ) ;
141+ if ( normalized . title !== undefined ) {
142+ course . slug = await this . createUniqueSlug ( normalized . title , course . id ) ;
137143 }
138- this . syncCourseTaxonomy ( course , dto ) ;
144+ this . syncCourseTaxonomy ( course , normalized ) ;
139145 const saved = await this . courseRepo . save ( course ) ;
140146
141147 // #449: Rollback the course update if the revision append fails
@@ -524,6 +530,92 @@ export class CourseService {
524530 }
525531 }
526532
533+ /**
534+ * BA-047: Canonicalize course taxonomy input so that only bounded,
535+ * normalized values reach persistence.
536+ *
537+ * - Free-text fields (title, description, category) are trimmed and
538+ * inner whitespace is collapsed.
539+ * - The enum level is lower-cased to its canonical `CourseLevel` value.
540+ * - Taxonomy arrays (categories, tags, prerequisites, skills) are
541+ * trimmed, lower-cased, de-duplicated, and have blank items removed.
542+ */
543+ private normalizeCourseInput < T extends Partial < CreateCourseDto > > (
544+ input : T ,
545+ ) : Partial < CourseEntity > {
546+ const normalized : Partial < CourseEntity > = { ...input } as Partial <
547+ CourseEntity
548+ > ;
549+
550+ if ( typeof normalized . title === 'string' ) {
551+ normalized . title = this . collapseWhitespace ( normalized . title ) ;
552+ }
553+ if ( typeof normalized . description === 'string' ) {
554+ normalized . description = normalized . description . trim ( ) ;
555+ }
556+ if ( typeof normalized . category === 'string' ) {
557+ normalized . category = this . normalizeTaxonomyItem ( normalized . category ) ;
558+ }
559+ if ( typeof normalized . level === 'string' ) {
560+ normalized . level = this . canonicalizeLevel ( normalized . level ) ;
561+ }
562+
563+ // Only normalize taxonomy arrays that were actually provided, so an
564+ // absent field on update never overwrites already-persisted values with
565+ // an empty/undefined array.
566+ if ( Array . isArray ( input . categories ) ) {
567+ normalized . categories = this . normalizeTaxonomy ( input . categories ) ;
568+ }
569+ if ( Array . isArray ( input . tags ) ) {
570+ normalized . tags = this . normalizeTaxonomy ( input . tags ) ;
571+ }
572+ if ( Array . isArray ( input . prerequisites ) ) {
573+ normalized . prerequisites = this . normalizeTaxonomy ( input . prerequisites ) ;
574+ }
575+ if ( Array . isArray ( input . skills ) ) {
576+ normalized . skills = this . normalizeTaxonomy ( input . skills ) ;
577+ }
578+
579+ return normalized ;
580+ }
581+
582+ private collapseWhitespace ( value : string ) : string {
583+ return value . trim ( ) . replace ( / \s + / g, ' ' ) ;
584+ }
585+
586+ private normalizeTaxonomyItem ( value : string ) : string {
587+ return value
588+ . trim ( )
589+ . toLowerCase ( )
590+ . replace ( / \s + / g, ' ' ) ;
591+ }
592+
593+ private normalizeTaxonomy ( values : string [ ] ) : string [ ] {
594+ const seen = new Set < string > ( ) ;
595+ const result : string [ ] = [ ] ;
596+ for ( const raw of values ) {
597+ const item = this . normalizeTaxonomyItem ( raw ?? '' ) ;
598+ if ( item && ! seen . has ( item ) ) {
599+ seen . add ( item ) ;
600+ result . push ( item ) ;
601+ }
602+ }
603+ return result ;
604+ }
605+
606+ private canonicalizeLevel ( level : string ) : CourseLevel {
607+ const canonical = level . trim ( ) . toLowerCase ( ) ;
608+ if (
609+ canonical === CourseLevel . BEGINNER ||
610+ canonical === CourseLevel . INTERMEDIATE ||
611+ canonical === CourseLevel . ADVANCED ||
612+ canonical === CourseLevel . WEB3
613+ ) {
614+ return canonical as CourseLevel ;
615+ }
616+ return level as CourseLevel ;
617+ }
618+
527619 private async createUniqueSlug ( title : string , excludeId ?: string ) : Promise < string > {
528620 const baseSlug = this . normalizeSlug ( title ) ;
529621 let slug = baseSlug ;
0 commit comments