Skip to content
Open
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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,3 +102,7 @@ symfony.lock
.idea/

runtime/

###> liip/imagine-bundle ###
/public/media/cache/
###< liip/imagine-bundle ###
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions nest-backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
APP_NAME=eXeLearning API
PORT=3000
21 changes: 21 additions & 0 deletions nest-backend/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -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 }]
}
};
31 changes: 31 additions & 0 deletions nest-backend/README.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions nest-backend/jest.config.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions nest-backend/nest-cli.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}
37 changes: 37 additions & 0 deletions nest-backend/package.json
Original file line number Diff line number Diff line change
@@ -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.1.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"
}
}
18 changes: 18 additions & 0 deletions nest-backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env', '.env.local']
}),
UsersModule,
ProjectsModule,
OdeExportModule
]
})
export class AppModule {}
9 changes: 9 additions & 0 deletions nest-backend/src/common/config/app.config.ts
Original file line number Diff line number Diff line change
@@ -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
});
34 changes: 34 additions & 0 deletions nest-backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const app = await NestFactory.create(AppModule, {
bufferLogs: true
});

app.setGlobalPrefix('api/v2');
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '2'
Comment on lines +11 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid double /v2 prefix when enabling URI versioning

The bootstrap configuration sets app.setGlobalPrefix('api/v2') and also enables URI versioning with default version '2' while every controller is registered for version '2'. In NestJS the version segment is appended after the global prefix, so the effective routes become /api/v2/v2/users and /api/v2/v2/projects. The README states the service should be reachable under http://localhost:3000/api/v2, but those endpoints will 404 because they are mounted under /api/v2/v2/.... Either drop the hard-coded /v2 from the global prefix or disable explicit versioning to expose the endpoints at /api/v2/* as documented.

Useful? React with 👍 / 👎.

});
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;
});
19 changes: 19 additions & 0 deletions nest-backend/src/ode-export/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
12 changes: 12 additions & 0 deletions nest-backend/src/ode-export/dto/ode-export-download-params.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 22 additions & 0 deletions nest-backend/src/ode-export/dto/ode-export-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export class OdeExportResponseDto {
responseMessage!: 'OK' | string;
urlZipFile!: string;
zipFileName!: string;
exportProjectName!: string;
urlPreviewIndex!: string;
generatedAt!: string;

constructor(init: Partial<OdeExportResponseDto>) {
Object.assign(this, init);
}
}

export class OdeExportFileHandleDto {
fileName!: string;
mimeType!: string;
stream!: NodeJS.ReadableStream;

constructor(init: Partial<OdeExportFileHandleDto>) {
Object.assign(this, init);
}
}
35 changes: 35 additions & 0 deletions nest-backend/src/ode-export/ode-export.controller.ts
Original file line number Diff line number Diff line change
@@ -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<OdeExportResponseDto> {
return this.odeExportService.generateDownload(params.odeSessionId, params.exportType);
}

@Get(':odeSessionId/preview')
async preview(@Param() params: OdeExportPreviewParamsDto): Promise<OdeExportResponseDto> {
return this.odeExportService.generatePreview(params.odeSessionId);
}

@Get('files/tmp/:relativePath(*)')
async getFile(
@Param() params: OdeExportFileRequestDto,
@Res({ passthrough: true }) res: Response
): Promise<StreamableFile> {
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);
}
}
10 changes: 10 additions & 0 deletions nest-backend/src/ode-export/ode-export.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading