From 4fa941c879f690a8b9e54bbebf8cd896c7ce768b Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Sat, 15 Nov 2025 10:35:27 +0000 Subject: [PATCH 1/2] feat: add experimental nest backend scaffold --- .gitignore | 9 ++++ README.md | 14 +++++ nest-backend/.env.example | 2 + nest-backend/.eslintrc.js | 21 ++++++++ nest-backend/README.md | 31 +++++++++++ nest-backend/jest.config.ts | 15 ++++++ nest-backend/nest-cli.json | 5 ++ nest-backend/package.json | 37 +++++++++++++ nest-backend/src/app.module.ts | 16 ++++++ nest-backend/src/common/config/app.config.ts | 9 ++++ nest-backend/src/main.ts | 34 ++++++++++++ .../src/projects/dto/create-project.dto.ts | 15 ++++++ .../src/projects/dto/update-project.dto.ts | 4 ++ .../src/projects/entities/project.entity.ts | 7 +++ .../src/projects/projects.controller.ts | 33 ++++++++++++ nest-backend/src/projects/projects.module.ts | 10 ++++ nest-backend/src/projects/projects.service.ts | 54 +++++++++++++++++++ nest-backend/src/users/dto/create-user.dto.ts | 16 ++++++ nest-backend/src/users/dto/update-user.dto.ts | 4 ++ .../src/users/entities/user.entity.ts | 6 +++ nest-backend/src/users/users.controller.ts | 33 ++++++++++++ nest-backend/src/users/users.module.ts | 10 ++++ nest-backend/src/users/users.service.ts | 51 ++++++++++++++++++ nest-backend/tsconfig.build.json | 8 +++ nest-backend/tsconfig.json | 21 ++++++++ 25 files changed, 465 insertions(+) create mode 100644 nest-backend/.env.example create mode 100644 nest-backend/.eslintrc.js create mode 100644 nest-backend/README.md create mode 100644 nest-backend/jest.config.ts create mode 100644 nest-backend/nest-cli.json create mode 100644 nest-backend/package.json create mode 100644 nest-backend/src/app.module.ts create mode 100644 nest-backend/src/common/config/app.config.ts create mode 100644 nest-backend/src/main.ts create mode 100644 nest-backend/src/projects/dto/create-project.dto.ts create mode 100644 nest-backend/src/projects/dto/update-project.dto.ts create mode 100644 nest-backend/src/projects/entities/project.entity.ts create mode 100644 nest-backend/src/projects/projects.controller.ts create mode 100644 nest-backend/src/projects/projects.module.ts create mode 100644 nest-backend/src/projects/projects.service.ts create mode 100644 nest-backend/src/users/dto/create-user.dto.ts create mode 100644 nest-backend/src/users/dto/update-user.dto.ts create mode 100644 nest-backend/src/users/entities/user.entity.ts create mode 100644 nest-backend/src/users/users.controller.ts create mode 100644 nest-backend/src/users/users.module.ts create mode 100644 nest-backend/src/users/users.service.ts create mode 100644 nest-backend/tsconfig.build.json create mode 100644 nest-backend/tsconfig.json diff --git a/.gitignore b/.gitignore index 248b3935e..c969ec7d1 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,11 @@ Icon # Ignore node_modules node_modules/ +nest-backend/node_modules/ + +# Ignore NestJS build artifacts +nest-backend/dist/ +nest-backend/coverage/ # Ignore lock files package-lock.json @@ -97,3 +102,7 @@ symfony.lock .idea/ runtime/ + +###> liip/imagine-bundle ### +/public/media/cache/ +###< liip/imagine-bundle ### diff --git a/README.md b/README.md index f83b42703..13b85a069 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,20 @@ More development tools, options, and real-time collaboration info are documented A SCSS watcher is implemented which compiles any style automatically, without the need to launch any command. SCSS can be laid out directly in the same way as CSS. +### Experimental NestJS backend + +The repository now ships with an experimental NestJS backend located in [`nest-backend/`](./nest-backend). It will eventually replace the Symfony API while both stacks coexist during the migration. + +To explore the new service locally: + +```bash +cd nest-backend +yarn install +yarn start:dev +``` + +By default the server listens on `http://localhost:3000/api/v2`, mirroring the current REST namespace. The implementation is intentionally lightweight (in-memory storage) so that contract tests and data adapters can be developed incrementally before wiring the definitive persistence layer. + ## Project Structure The application follows the standard Symfony project structure, with some specific folders for managing iDevices and educational resources. diff --git a/nest-backend/.env.example b/nest-backend/.env.example new file mode 100644 index 000000000..9614db6d2 --- /dev/null +++ b/nest-backend/.env.example @@ -0,0 +1,2 @@ +APP_NAME=eXeLearning API +PORT=3000 diff --git a/nest-backend/.eslintrc.js b/nest-backend/.eslintrc.js new file mode 100644 index 000000000..edc853135 --- /dev/null +++ b/nest-backend/.eslintrc.js @@ -0,0 +1,21 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json' + }, + plugins: ['@typescript-eslint'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking' + ], + env: { + node: true, + es2021: true + }, + ignorePatterns: ['dist', 'node_modules'], + rules: { + '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }] + } +}; diff --git a/nest-backend/README.md b/nest-backend/README.md new file mode 100644 index 000000000..0f9a439f1 --- /dev/null +++ b/nest-backend/README.md @@ -0,0 +1,31 @@ +# eXeLearning NestJS backend (experimental) + +Este directorio contiene el código inicial del backend NestJS que convivirá con el backend Symfony actual durante la migración. El objetivo es ofrecer un punto de partida funcional y probado para exponer la API REST v2 mediante Node.js y TypeScript. + +## Características incluidas + +- Configuración base de NestJS con `ConfigModule`, versionado de rutas y validación global. +- Módulos `Users` y `Projects` con endpoints CRUD mínimos para facilitar pruebas de contrato. +- Almacenamiento en memoria para desacoplar la capa HTTP de la persistencia mientras se preparan los repositorios definitivos. +- Esquema de linters, pruebas unitarias (Jest) y compilación TypeScript alineados con las prácticas del monorepo. + +## Primeros pasos + +```bash +cd nest-backend +# Instalar dependencias +yarn install +# Servidor de desarrollo con recarga en caliente +yarn start:dev +# Compilar a JavaScript listo para producción +yarn build +``` + +El servidor escucha en `http://localhost:3000/api/v2` por defecto y acepta la variable `PORT` para personalizar el puerto. + +## Próximos pasos sugeridos + +1. Sustituir el almacenamiento en memoria por repositorios conectados a la base de datos mediante TypeORM o Prisma. +2. Implementar autenticación y autorización equivalentes a Symfony (usuarios, roles, JWT, CAS/OIDC). +3. Añadir módulos para páginas, bloques e iDevices replicando las operaciones avanzadas de la API actual. +4. Incorporar pruebas de contrato compartidas entre Symfony y NestJS para garantizar paridad funcional. diff --git a/nest-backend/jest.config.ts b/nest-backend/jest.config.ts new file mode 100644 index 000000000..a94ca493e --- /dev/null +++ b/nest-backend/jest.config.ts @@ -0,0 +1,15 @@ +import type { Config } from 'jest'; + +const config: Config = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest' + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '../coverage', + testEnvironment: 'node' +}; + +export default config; diff --git a/nest-backend/nest-cli.json b/nest-backend/nest-cli.json new file mode 100644 index 000000000..256648114 --- /dev/null +++ b/nest-backend/nest-cli.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/nest-backend/package.json b/nest-backend/package.json new file mode 100644 index 000000000..b7104b4c2 --- /dev/null +++ b/nest-backend/package.json @@ -0,0 +1,37 @@ +{ + "name": "@exelearning/api", + "version": "0.1.0", + "private": true, + "description": "Experimental NestJS backend for eXeLearning", + "scripts": { + "start": "node dist/main.js", + "start:dev": "ts-node-dev --respawn --transpileOnly src/main.ts", + "build": "tsc -p tsconfig.build.json", + "lint": "eslint \"src/**/*.ts\"", + "test": "jest" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.3.0", + "@nestjs/mapped-types": "^2.2.0", + "@nestjs/platform-express": "^10.3.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/testing": "^10.3.0", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.1", + "@typescript-eslint/eslint-plugin": "^8.15.0", + "@typescript-eslint/parser": "^8.15.0", + "eslint": "^9.20.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "ts-node-dev": "^2.0.0", + "typescript": "^5.7.3" + } +} diff --git a/nest-backend/src/app.module.ts b/nest-backend/src/app.module.ts new file mode 100644 index 000000000..921b91272 --- /dev/null +++ b/nest-backend/src/app.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { UsersModule } from './users/users.module'; +import { ProjectsModule } from './projects/projects.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env', '.env.local'] + }), + UsersModule, + ProjectsModule + ] +}) +export class AppModule {} diff --git a/nest-backend/src/common/config/app.config.ts b/nest-backend/src/common/config/app.config.ts new file mode 100644 index 000000000..8196aeaf7 --- /dev/null +++ b/nest-backend/src/common/config/app.config.ts @@ -0,0 +1,9 @@ +export interface ApplicationConfig { + appName: string; + port: number; +} + +export const defaultConfig = (): ApplicationConfig => ({ + appName: process.env.APP_NAME ?? 'eXeLearning API', + port: process.env.PORT ? Number(process.env.PORT) : 3000 +}); diff --git a/nest-backend/src/main.ts b/nest-backend/src/main.ts new file mode 100644 index 000000000..d86aeb1d6 --- /dev/null +++ b/nest-backend/src/main.ts @@ -0,0 +1,34 @@ +import 'reflect-metadata'; +import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule, { + bufferLogs: true + }); + + app.setGlobalPrefix('api/v2'); + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '2' + }); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true + }) + ); + + const logger = new Logger('Bootstrap'); + const port = process.env.PORT ? Number(process.env.PORT) : 3000; + await app.listen(port); + logger.log(`NestJS backend listening on port ${port}`); +} + +bootstrap().catch((error) => { + // eslint-disable-next-line no-console + console.error('Failed to bootstrap NestJS backend', error); + process.exitCode = 1; +}); diff --git a/nest-backend/src/projects/dto/create-project.dto.ts b/nest-backend/src/projects/dto/create-project.dto.ts new file mode 100644 index 000000000..0802ad1d5 --- /dev/null +++ b/nest-backend/src/projects/dto/create-project.dto.ts @@ -0,0 +1,15 @@ +import { IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator'; + +export class CreateProjectDto { + @IsString() + @MinLength(1) + title!: string; + + @IsString() + ownerId!: string; + + @IsOptional() + @IsInt() + @Min(0) + pageCount?: number; +} diff --git a/nest-backend/src/projects/dto/update-project.dto.ts b/nest-backend/src/projects/dto/update-project.dto.ts new file mode 100644 index 000000000..4a5d8654c --- /dev/null +++ b/nest-backend/src/projects/dto/update-project.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateProjectDto } from './create-project.dto'; + +export class UpdateProjectDto extends PartialType(CreateProjectDto) {} diff --git a/nest-backend/src/projects/entities/project.entity.ts b/nest-backend/src/projects/entities/project.entity.ts new file mode 100644 index 000000000..4bdee9c43 --- /dev/null +++ b/nest-backend/src/projects/entities/project.entity.ts @@ -0,0 +1,7 @@ +export interface ProjectSummary { + id: string; + title: string; + ownerId: string; + pageCount: number; + updatedAt: Date; +} diff --git a/nest-backend/src/projects/projects.controller.ts b/nest-backend/src/projects/projects.controller.ts new file mode 100644 index 000000000..4e0eaf131 --- /dev/null +++ b/nest-backend/src/projects/projects.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; +import { ProjectsService } from './projects.service'; +import { CreateProjectDto } from './dto/create-project.dto'; +import { UpdateProjectDto } from './dto/update-project.dto'; +import { ProjectSummary } from './entities/project.entity'; + +@Controller({ path: 'projects', version: '2' }) +export class ProjectsController { + constructor(private readonly projectsService: ProjectsService) {} + + @Get() + async list(): Promise { + return this.projectsService.list(); + } + + @Get(':id') + async get(@Param('id') id: string): Promise { + return this.projectsService.findOne(id); + } + + @Post() + async create(@Body() payload: CreateProjectDto): Promise { + return this.projectsService.create(payload); + } + + @Put(':id') + async update( + @Param('id') id: string, + @Body() payload: UpdateProjectDto + ): Promise { + return this.projectsService.update(id, payload); + } +} diff --git a/nest-backend/src/projects/projects.module.ts b/nest-backend/src/projects/projects.module.ts new file mode 100644 index 000000000..8db2d8dd3 --- /dev/null +++ b/nest-backend/src/projects/projects.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ProjectsController } from './projects.controller'; +import { ProjectsService } from './projects.service'; + +@Module({ + controllers: [ProjectsController], + providers: [ProjectsService], + exports: [ProjectsService] +}) +export class ProjectsModule {} diff --git a/nest-backend/src/projects/projects.service.ts b/nest-backend/src/projects/projects.service.ts new file mode 100644 index 000000000..683c2dfcf --- /dev/null +++ b/nest-backend/src/projects/projects.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { CreateProjectDto } from './dto/create-project.dto'; +import { UpdateProjectDto } from './dto/update-project.dto'; +import { ProjectSummary } from './entities/project.entity'; + +@Injectable() +export class ProjectsService { + private readonly storage = new Map(); + + async list(): Promise { + return Array.from(this.storage.values()); + } + + async findOne(id: string): Promise { + return this.storage.get(id) ?? null; + } + + async create(payload: CreateProjectDto): Promise { + const id = randomUUID(); + const project: ProjectSummary = { + id, + title: payload.title, + ownerId: payload.ownerId, + pageCount: payload.pageCount ?? 0, + updatedAt: new Date() + }; + this.storage.set(id, project); + return project; + } + + async update(id: string, payload: UpdateProjectDto): Promise { + const existing = this.storage.get(id); + if (!existing) { + const created = await this.create({ + title: payload.title ?? id, + ownerId: payload.ownerId ?? 'unknown', + pageCount: payload.pageCount + }); + return created; + } + + const updated: ProjectSummary = { + ...existing, + ...payload, + title: payload.title ?? existing.title, + ownerId: payload.ownerId ?? existing.ownerId, + pageCount: payload.pageCount ?? existing.pageCount, + updatedAt: new Date() + }; + this.storage.set(id, updated); + return updated; + } +} diff --git a/nest-backend/src/users/dto/create-user.dto.ts b/nest-backend/src/users/dto/create-user.dto.ts new file mode 100644 index 000000000..c9b84a925 --- /dev/null +++ b/nest-backend/src/users/dto/create-user.dto.ts @@ -0,0 +1,16 @@ +import { IsArray, IsOptional, IsString, MinLength } from 'class-validator'; + +export class CreateUserDto { + @IsString() + @MinLength(3) + username!: string; + + @IsOptional() + @IsString() + displayName?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + roles?: string[]; +} diff --git a/nest-backend/src/users/dto/update-user.dto.ts b/nest-backend/src/users/dto/update-user.dto.ts new file mode 100644 index 000000000..dfd37fb1e --- /dev/null +++ b/nest-backend/src/users/dto/update-user.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateUserDto } from './create-user.dto'; + +export class UpdateUserDto extends PartialType(CreateUserDto) {} diff --git a/nest-backend/src/users/entities/user.entity.ts b/nest-backend/src/users/entities/user.entity.ts new file mode 100644 index 000000000..eed2c3daa --- /dev/null +++ b/nest-backend/src/users/entities/user.entity.ts @@ -0,0 +1,6 @@ +export interface UserSummary { + id: string; + username: string; + displayName: string; + roles: string[]; +} diff --git a/nest-backend/src/users/users.controller.ts b/nest-backend/src/users/users.controller.ts new file mode 100644 index 000000000..4c69605a1 --- /dev/null +++ b/nest-backend/src/users/users.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UserSummary } from './entities/user.entity'; + +@Controller({ path: 'users', version: '2' }) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + async list(): Promise { + return this.usersService.list(); + } + + @Get(':id') + async get(@Param('id') id: string): Promise { + return this.usersService.findOne(id); + } + + @Post() + async create(@Body() payload: CreateUserDto): Promise { + return this.usersService.create(payload); + } + + @Put(':id') + async update( + @Param('id') id: string, + @Body() payload: UpdateUserDto + ): Promise { + return this.usersService.update(id, payload); + } +} diff --git a/nest-backend/src/users/users.module.ts b/nest-backend/src/users/users.module.ts new file mode 100644 index 000000000..3f8d4d191 --- /dev/null +++ b/nest-backend/src/users/users.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService] +}) +export class UsersModule {} diff --git a/nest-backend/src/users/users.service.ts b/nest-backend/src/users/users.service.ts new file mode 100644 index 000000000..7b27348c3 --- /dev/null +++ b/nest-backend/src/users/users.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UserSummary } from './entities/user.entity'; + +@Injectable() +export class UsersService { + private readonly storage = new Map(); + + async list(): Promise { + return Array.from(this.storage.values()); + } + + async findOne(id: string): Promise { + return this.storage.get(id) ?? null; + } + + async create(payload: CreateUserDto): Promise { + const id = randomUUID(); + const user: UserSummary = { + id, + username: payload.username, + displayName: payload.displayName ?? payload.username, + roles: payload.roles ?? ['ROLE_USER'] + }; + this.storage.set(id, user); + return user; + } + + async update(id: string, payload: UpdateUserDto): Promise { + const existing = this.storage.get(id); + if (!existing) { + const created = await this.create({ + username: payload.username ?? id, + displayName: payload.displayName, + roles: payload.roles + }); + return created; + } + + const updated: UserSummary = { + ...existing, + ...payload, + displayName: payload.displayName ?? existing.displayName, + roles: payload.roles ?? existing.roles + }; + this.storage.set(id, updated); + return updated; + } +} diff --git a/nest-backend/tsconfig.build.json b/nest-backend/tsconfig.build.json new file mode 100644 index 000000000..159a736d7 --- /dev/null +++ b/nest-backend/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "noEmit": false + }, + "exclude": ["node_modules", "test", "dist", "**/*.spec.ts"] +} diff --git a/nest-backend/tsconfig.json b/nest-backend/tsconfig.json new file mode 100644 index 000000000..e9f98b1d4 --- /dev/null +++ b/nest-backend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "dist", + "baseUrl": "./", + "incremental": true, + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 913d992d13d6f96993a57106a5ca3c091e575c44 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Sat, 15 Nov 2025 15:00:22 +0000 Subject: [PATCH 2/2] refactor(nest): add validated ode export dtos (#13) --- nest-backend/package.json | 2 +- nest-backend/src/app.module.ts | 4 +- nest-backend/src/ode-export/constants.ts | 19 ++ .../dto/ode-export-download-params.dto.ts | 12 + .../dto/ode-export-file-request.dto.ts | 8 + .../dto/ode-export-preview-params.dto.ts | 8 + .../ode-export/dto/ode-export-response.dto.ts | 22 ++ .../src/ode-export/ode-export.controller.ts | 35 +++ .../src/ode-export/ode-export.module.ts | 10 + .../src/ode-export/ode-export.service.spec.ts | 71 +++++ .../src/ode-export/ode-export.service.ts | 281 ++++++++++++++++++ 11 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 nest-backend/src/ode-export/constants.ts create mode 100644 nest-backend/src/ode-export/dto/ode-export-download-params.dto.ts create mode 100644 nest-backend/src/ode-export/dto/ode-export-file-request.dto.ts create mode 100644 nest-backend/src/ode-export/dto/ode-export-preview-params.dto.ts create mode 100644 nest-backend/src/ode-export/dto/ode-export-response.dto.ts create mode 100644 nest-backend/src/ode-export/ode-export.controller.ts create mode 100644 nest-backend/src/ode-export/ode-export.module.ts create mode 100644 nest-backend/src/ode-export/ode-export.service.spec.ts create mode 100644 nest-backend/src/ode-export/ode-export.service.ts diff --git a/nest-backend/package.json b/nest-backend/package.json index b7104b4c2..4927de506 100644 --- a/nest-backend/package.json +++ b/nest-backend/package.json @@ -14,7 +14,7 @@ "@nestjs/common": "^10.3.0", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.3.0", - "@nestjs/mapped-types": "^2.2.0", + "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^10.3.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", diff --git a/nest-backend/src/app.module.ts b/nest-backend/src/app.module.ts index 921b91272..a338f2189 100644 --- a/nest-backend/src/app.module.ts +++ b/nest-backend/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { UsersModule } from './users/users.module'; import { ProjectsModule } from './projects/projects.module'; +import { OdeExportModule } from './ode-export/ode-export.module'; @Module({ imports: [ @@ -10,7 +11,8 @@ import { ProjectsModule } from './projects/projects.module'; envFilePath: ['.env', '.env.local'] }), UsersModule, - ProjectsModule + ProjectsModule, + OdeExportModule ] }) export class AppModule {} diff --git a/nest-backend/src/ode-export/constants.ts b/nest-backend/src/ode-export/constants.ts new file mode 100644 index 000000000..b8ad28362 --- /dev/null +++ b/nest-backend/src/ode-export/constants.ts @@ -0,0 +1,19 @@ +export const ODE_SESSION_ID_REGEX = /^[A-Za-z0-9_-]{3,128}$/; + +export const ODE_EXPORT_RELATIVE_PATH_REGEX = + /^(?:[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)$/; + +export const ODE_EXPORT_ALLOWED_TYPES = [ + 'elp', + 'html5', + 'html5-sp', + 'scorm12', + 'scorm2004', + 'ims', + 'epub3', + 'properties' +] as const; + +export type OdeExportType = (typeof ODE_EXPORT_ALLOWED_TYPES)[number]; + +export const ODE_EXPORT_PREVIEW_TYPE: OdeExportType = 'html5'; diff --git a/nest-backend/src/ode-export/dto/ode-export-download-params.dto.ts b/nest-backend/src/ode-export/dto/ode-export-download-params.dto.ts new file mode 100644 index 000000000..26170afd8 --- /dev/null +++ b/nest-backend/src/ode-export/dto/ode-export-download-params.dto.ts @@ -0,0 +1,12 @@ +import { IsIn, IsString, Matches } from 'class-validator'; +import { ODE_EXPORT_ALLOWED_TYPES, ODE_SESSION_ID_REGEX, OdeExportType } from '../constants'; + +export class OdeExportDownloadParamsDto { + @IsString() + @Matches(ODE_SESSION_ID_REGEX) + odeSessionId!: string; + + @IsString() + @IsIn(ODE_EXPORT_ALLOWED_TYPES) + exportType!: OdeExportType; +} diff --git a/nest-backend/src/ode-export/dto/ode-export-file-request.dto.ts b/nest-backend/src/ode-export/dto/ode-export-file-request.dto.ts new file mode 100644 index 000000000..9e5eda8ac --- /dev/null +++ b/nest-backend/src/ode-export/dto/ode-export-file-request.dto.ts @@ -0,0 +1,8 @@ +import { IsString, Matches } from 'class-validator'; +import { ODE_EXPORT_RELATIVE_PATH_REGEX } from '../constants'; + +export class OdeExportFileRequestDto { + @IsString() + @Matches(ODE_EXPORT_RELATIVE_PATH_REGEX) + relativePath!: string; +} diff --git a/nest-backend/src/ode-export/dto/ode-export-preview-params.dto.ts b/nest-backend/src/ode-export/dto/ode-export-preview-params.dto.ts new file mode 100644 index 000000000..40f4be056 --- /dev/null +++ b/nest-backend/src/ode-export/dto/ode-export-preview-params.dto.ts @@ -0,0 +1,8 @@ +import { IsString, Matches } from 'class-validator'; +import { ODE_SESSION_ID_REGEX } from '../constants'; + +export class OdeExportPreviewParamsDto { + @IsString() + @Matches(ODE_SESSION_ID_REGEX) + odeSessionId!: string; +} diff --git a/nest-backend/src/ode-export/dto/ode-export-response.dto.ts b/nest-backend/src/ode-export/dto/ode-export-response.dto.ts new file mode 100644 index 000000000..af4bcda41 --- /dev/null +++ b/nest-backend/src/ode-export/dto/ode-export-response.dto.ts @@ -0,0 +1,22 @@ +export class OdeExportResponseDto { + responseMessage!: 'OK' | string; + urlZipFile!: string; + zipFileName!: string; + exportProjectName!: string; + urlPreviewIndex!: string; + generatedAt!: string; + + constructor(init: Partial) { + Object.assign(this, init); + } +} + +export class OdeExportFileHandleDto { + fileName!: string; + mimeType!: string; + stream!: NodeJS.ReadableStream; + + constructor(init: Partial) { + Object.assign(this, init); + } +} diff --git a/nest-backend/src/ode-export/ode-export.controller.ts b/nest-backend/src/ode-export/ode-export.controller.ts new file mode 100644 index 000000000..79caf3d02 --- /dev/null +++ b/nest-backend/src/ode-export/ode-export.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Param, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { StreamableFile } from '@nestjs/common'; +import { OdeExportService } from './ode-export.service'; +import { OdeExportDownloadParamsDto } from './dto/ode-export-download-params.dto'; +import { OdeExportPreviewParamsDto } from './dto/ode-export-preview-params.dto'; +import { OdeExportResponseDto } from './dto/ode-export-response.dto'; +import { OdeExportFileRequestDto } from './dto/ode-export-file-request.dto'; + +@Controller({ path: 'ode-export', version: '2' }) +export class OdeExportController { + constructor(private readonly odeExportService: OdeExportService) {} + + @Get(':odeSessionId/:exportType/download') + async download(@Param() params: OdeExportDownloadParamsDto): Promise { + return this.odeExportService.generateDownload(params.odeSessionId, params.exportType); + } + + @Get(':odeSessionId/preview') + async preview(@Param() params: OdeExportPreviewParamsDto): Promise { + return this.odeExportService.generatePreview(params.odeSessionId); + } + + @Get('files/tmp/:relativePath(*)') + async getFile( + @Param() params: OdeExportFileRequestDto, + @Res({ passthrough: true }) res: Response + ): Promise { + const file = await this.odeExportService.openArtifact(params.relativePath); + res.setHeader('Content-Type', file.mimeType); + res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.fileName)}"`); + + return new StreamableFile(file.stream); + } +} diff --git a/nest-backend/src/ode-export/ode-export.module.ts b/nest-backend/src/ode-export/ode-export.module.ts new file mode 100644 index 000000000..a08f8b916 --- /dev/null +++ b/nest-backend/src/ode-export/ode-export.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { OdeExportController } from './ode-export.controller'; +import { OdeExportService } from './ode-export.service'; + +@Module({ + controllers: [OdeExportController], + providers: [OdeExportService], + exports: [OdeExportService] +}) +export class OdeExportModule {} diff --git a/nest-backend/src/ode-export/ode-export.service.spec.ts b/nest-backend/src/ode-export/ode-export.service.spec.ts new file mode 100644 index 000000000..a6ead0207 --- /dev/null +++ b/nest-backend/src/ode-export/ode-export.service.spec.ts @@ -0,0 +1,71 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { OdeExportService } from './ode-export.service'; + +async function streamToString(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }); +} + +describe('OdeExportService', () => { + let tmpDir: string; + let service: OdeExportService; + const originalFilesDir = process.env.FILES_DIR; + + beforeAll(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'ode-export-service-')); + process.env.FILES_DIR = tmpDir; + service = new OdeExportService(); + }); + + afterAll(async () => { + if (originalFilesDir === undefined) { + delete process.env.FILES_DIR; + } else { + process.env.FILES_DIR = originalFilesDir; + } + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('generates export bundles with downloadable artifacts', async () => { + const response = await service.generateDownload('sessionABC', 'html5'); + + expect(response.responseMessage).toBe('OK'); + expect(response.zipFileName).toBe('export-html5.zip'); + expect(response.urlZipFile).toMatch(/^\/files\/tmp\//); + + const relative = response.urlZipFile.replace(/^\/files\/tmp\//, ''); + const fileHandle = await service.openArtifact(relative); + const contents = await streamToString(fileHandle.stream); + + expect(fileHandle.mimeType).toBe('application/zip'); + expect(contents).toContain('Session: sessionABC'); + expect(contents).toContain('Type: html5'); + }); + + it('exposes preview html documents', async () => { + const response = await service.generatePreview('sessionXYZ'); + + expect(response.urlPreviewIndex).toMatch(/^\/files\/tmp\//); + const relativePreview = response.urlPreviewIndex.replace(/^\/files\/tmp\//, ''); + const previewHandle = await service.openArtifact(relativePreview); + const html = await streamToString(previewHandle.stream); + + expect(previewHandle.mimeType).toContain('text/html'); + expect(html).toContain('sessionXYZ'); + expect(html).toContain('Preview placeholder'); + }); + + it('rejects unsupported export types', async () => { + await expect(service.generateDownload('session123', 'invalid-type')).rejects.toThrow( + /Unsupported export type/ + ); + }); +}); diff --git a/nest-backend/src/ode-export/ode-export.service.ts b/nest-backend/src/ode-export/ode-export.service.ts new file mode 100644 index 000000000..90daf13d6 --- /dev/null +++ b/nest-backend/src/ode-export/ode-export.service.ts @@ -0,0 +1,281 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { randomBytes } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { access, mkdir, stat, writeFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import { OdeExportFileHandleDto, OdeExportResponseDto } from './dto/ode-export-response.dto'; +import { + ODE_EXPORT_ALLOWED_TYPES, + ODE_EXPORT_PREVIEW_TYPE, + OdeExportType +} from './constants'; + +interface ArtifactDescriptor { + absoluteDir: string; + relativeDir: string; + fileName: string; + previewFileName: string; + exportProjectName: string; +} + +@Injectable() +export class OdeExportService { + private readonly filesDir: string; + private readonly allowedExportTypes = new Set(ODE_EXPORT_ALLOWED_TYPES); + + constructor() { + this.filesDir = path.resolve( + process.env.FILES_DIR ?? path.join(process.cwd(), 'public', 'files') + ); + } + + async generateDownload( + odeSessionId: string, + exportType: string + ): Promise { + const sanitizedSessionId = this.sanitizeIdentifier(odeSessionId, 'odeSessionId'); + const normalizedExportType = this.normalizeExportType(exportType); + + const descriptor = await this.prepareArtifacts(sanitizedSessionId, normalizedExportType, { + createDownloadBundle: true + }); + + return this.buildResponse(descriptor); + } + + async generatePreview(odeSessionId: string): Promise { + const sanitizedSessionId = this.sanitizeIdentifier(odeSessionId, 'odeSessionId'); + const descriptor = await this.prepareArtifacts(sanitizedSessionId, ODE_EXPORT_PREVIEW_TYPE, { + createDownloadBundle: true + }); + + return this.buildResponse(descriptor); + } + + async openArtifact(relativePath: string): Promise { + const normalized = this.normalizeRelativePath(relativePath); + const absolutePath = path.join(this.filesDir, 'tmp', normalized); + await this.assertInsideFilesDir(absolutePath); + + try { + await access(absolutePath); + } catch (error) { + throw new NotFoundException('Requested file does not exist.'); + } + + const fileInfo = await stat(absolutePath); + if (!fileInfo.isFile()) { + throw new NotFoundException('Requested file is not available for download.'); + } + + return new OdeExportFileHandleDto({ + fileName: path.basename(absolutePath), + mimeType: this.detectMimeType(absolutePath), + stream: createReadStream(absolutePath) + }); + } + + private async prepareArtifacts( + odeSessionId: string, + exportType: OdeExportType, + options: { createDownloadBundle: boolean } + ): Promise { + const now = new Date(); + const year = String(now.getUTCFullYear()); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const day = String(now.getUTCDate()).padStart(2, '0'); + const randomSegment = randomBytes(5).toString('hex'); + + const subdir = `${odeSessionId}-${exportType}`.replace(/[^a-zA-Z0-9-_]/g, '-'); + const relativeDir = path.posix.join(year, month, day, randomSegment, subdir); + const absoluteDir = path.join(this.filesDir, 'tmp', relativeDir); + + await mkdir(absoluteDir, { recursive: true }); + + const { fileName, exportProjectName } = this.getExportFileMetadata(exportType); + const previewFileName = 'index.html'; + + if (options.createDownloadBundle) { + const absoluteFile = path.join(absoluteDir, fileName); + await writeFile( + absoluteFile, + this.composeBundleContents(odeSessionId, exportType, exportProjectName), + 'utf8' + ); + } + + const previewFilePath = path.join(absoluteDir, previewFileName); + await writeFile( + previewFilePath, + this.composePreviewHtml(odeSessionId, exportType, exportProjectName), + 'utf8' + ); + + return { + absoluteDir, + relativeDir, + fileName, + previewFileName, + exportProjectName + }; + } + + private buildResponse(descriptor: ArtifactDescriptor): OdeExportResponseDto { + const prefix = `/files/tmp/${descriptor.relativeDir}`.replace(/\\/g, '/'); + const timestamp = new Date().toISOString(); + + return new OdeExportResponseDto({ + responseMessage: 'OK', + urlZipFile: `${prefix}/${descriptor.fileName}`, + zipFileName: descriptor.fileName, + exportProjectName: descriptor.exportProjectName, + urlPreviewIndex: `${prefix}/${descriptor.previewFileName}`, + generatedAt: timestamp + }); + } + + private composeBundleContents( + odeSessionId: string, + exportType: OdeExportType, + exportProjectName: string + ): string { + return [ + `# eXeLearning export`, + `Session: ${odeSessionId}`, + `Type: ${exportType}`, + `Name: ${exportProjectName}`, + `Generated at: ${new Date().toISOString()}`, + '', + 'Este archivo es un marcador de posición generado por el backend NestJS durante la migración desde Symfony.' + ].join('\n'); + } + + private composePreviewHtml( + odeSessionId: string, + exportType: OdeExportType, + exportProjectName: string + ): string { + const timestamp = new Date().toISOString(); + return ` + + + + Preview ${exportProjectName} + + + +

Preview placeholder

+

Este contenido ha sido generado por el backend NestJS para simular la respuesta del exportador de Symfony.

+
+
Session ID
+
${odeSessionId}
+
Export type
+
${exportType}
+
Proyecto
+
${exportProjectName}
+
Generado
+
${timestamp}
+
+ +`; + } + + private getExportFileMetadata(exportType: OdeExportType): { + fileName: string; + exportProjectName: string; + } { + switch (exportType) { + case 'elp': + return { fileName: 'document.elp', exportProjectName: 'document.elp' }; + case 'properties': + return { + fileName: 'project-properties.json', + exportProjectName: 'project-properties.json' + }; + case 'epub3': + return { fileName: 'export-epub3.zip', exportProjectName: 'export-epub3.zip' }; + default: + return { + fileName: `export-${exportType}.zip`, + exportProjectName: `export-${exportType}.zip` + }; + } + } + + private sanitizeIdentifier(value: string, field: string): string { + if (!value || typeof value !== 'string') { + throw new BadRequestException(`Missing required parameter: ${field}`); + } + + const trimmed = value.trim(); + if (!/^[A-Za-z0-9_-]{3,128}$/.test(trimmed)) { + throw new BadRequestException(`Invalid value for ${field}.`); + } + + return trimmed; + } + + private normalizeExportType(exportType: string): OdeExportType { + if (!exportType || typeof exportType !== 'string') { + throw new BadRequestException('Missing required parameter: exportType'); + } + + const normalized = exportType.trim().toLowerCase(); + if (!this.allowedExportTypes.has(normalized as OdeExportType)) { + throw new BadRequestException(`Unsupported export type: ${exportType}`); + } + + return normalized as OdeExportType; + } + + private normalizeRelativePath(relativePath: string): string { + if (!relativePath || typeof relativePath !== 'string') { + throw new BadRequestException('Missing path parameter'); + } + + const sanitized = relativePath + .replace(/^\/+/, '') + .split('/') + .map((segment) => { + if (!segment || segment === '.' || segment === '..') { + throw new BadRequestException('Invalid path segment detected.'); + } + if (!/^[A-Za-z0-9._-]+$/.test(segment)) { + throw new BadRequestException('Unsafe path segment detected.'); + } + return segment; + }) + .join('/'); + + return sanitized; + } + + private async assertInsideFilesDir(absolutePath: string): Promise { + const resolved = path.resolve(absolutePath); + const root = path.resolve(path.join(this.filesDir, 'tmp')); + if (!resolved.startsWith(root + path.sep) && resolved !== root) { + throw new BadRequestException('The requested path is outside of the export directory.'); + } + } + + private detectMimeType(filePath: string): string { + const extension = path.extname(filePath).toLowerCase(); + switch (extension) { + case '.html': + case '.htm': + return 'text/html; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.elp': + return 'application/octet-stream'; + case '.zip': + return 'application/zip'; + default: + return 'application/octet-stream'; + } + } +}