diff --git a/back/package.json b/back/package.json index d7adc04..c25487f 100644 --- a/back/package.json +++ b/back/package.json @@ -9,41 +9,47 @@ "start:debug": "nest start --debug --watch", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:cov": "vitest run --coverage", + "migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/data-source.ts", + "migration:run": "typeorm-ts-node-commonjs migration:run -d src/data-source.ts", + "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/data-source.ts", + "migration:show": "typeorm-ts-node-commonjs migration:show -d src/data-source.ts" }, "dependencies": { - "@datacat/shared": "workspace:*", - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "@nestjs/platform-express": "^11.0.0", - "@nestjs/typeorm": "^11.0.0", - "@nestjs/config": "^4.0.0", - "@nestjs/serve-static": "^5.0.0", - "typeorm": "^0.3.20", - "pg": "^8.13.0", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.0", - "multer": "^1.4.5-lts.1", - "uuid": "^11.0.0", - "pino": "^9.0.0", - "pino-http": "^10.0.0", - "class-validator": "^0.14.0", - "class-transformer": "^0.5.0", - "@asyncapi/cli": "6.0.0", "@asyncapi/bundler": "^1.0.1", - "js-yaml": "^4.1.0", + "@asyncapi/cli": "6.0.0", "@asyncapi/generator": "3.0.1", "@asyncapi/html-template": "3.5.6", - "@redocly/cli": "latest" + "@datacat/shared": "workspace:*", + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/serve-static": "^5.0.0", + "@nestjs/typeorm": "^11.0.0", + "@redocly/cli": "latest", + "class-transformer": "^0.5.0", + "class-validator": "^0.14.0", + "js-yaml": "^4.1.0", + "multer": "^1.4.5-lts.1", + "pg": "^8.13.0", + "pino": "^9.0.0", + "pino-http": "^10.0.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.0", + "typeorm": "^0.3.20", + "uuid": "^11.0.0" }, "devDependencies": { "@nestjs/cli": "^11.0.0", "@nestjs/testing": "^11.0.0", "@types/express": "^5.0.0", + "@types/js-yaml": "^4.0.9", "@types/multer": "^1.4.12", "@types/node": "^22.0.0", - "@types/js-yaml": "^4.0.9", "@types/uuid": "^10.0.0", + "@vitest/coverage-v8": "^3.2.6", "typescript": "^5.7.0", "vitest": "^3.0.0" } diff --git a/back/src/app.module.ts b/back/src/app.module.ts index 03c8aac..f9f83a7 100644 --- a/back/src/app.module.ts +++ b/back/src/app.module.ts @@ -7,21 +7,26 @@ import { UploadsModule } from './modules/uploads/uploads.module'; import { DocsGenerationModule } from './modules/docs-generation/docs-generation.module'; import { CategoriesModule } from './modules/categories/categories.module'; import { SeederModule } from './modules/seeder/seeder.module'; +import { validateEnv } from './config/env.validation'; @Module({ imports: [ - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ type: 'postgres', - host: config.get('DATABASE_HOST', 'localhost'), - port: config.get('DATABASE_PORT', 5432), - username: config.get('DATABASE_USER', 'datacat'), - password: config.get('DATABASE_PASSWORD', 'datacat'), - database: config.get('DATABASE_NAME', 'datacat'), + host: config.getOrThrow('DATABASE_HOST'), + port: config.getOrThrow('DATABASE_PORT'), + username: config.getOrThrow('DATABASE_USER'), + password: config.getOrThrow('DATABASE_PASSWORD'), + database: config.getOrThrow('DATABASE_NAME'), entities: [__dirname + '/**/*.entity{.ts,.js}'], + migrations: [__dirname + '/migrations/*{.ts,.js}'], + // Dev : synchronize auto (itération rapide). Prod : pas de synchronize, + // le schéma est appliqué par les migrations exécutées au démarrage. synchronize: config.get('NODE_ENV') !== 'production', + migrationsRun: config.get('NODE_ENV') === 'production', logging: config.get('NODE_ENV') === 'development', }), }), diff --git a/back/src/config/env.validation.spec.ts b/back/src/config/env.validation.spec.ts new file mode 100644 index 0000000..ead1ec7 --- /dev/null +++ b/back/src/config/env.validation.spec.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { validateEnv } from './env.validation'; + +const base = { + DATABASE_HOST: 'localhost', + DATABASE_PORT: '5432', + DATABASE_USER: 'datacat', + DATABASE_PASSWORD: 'datacat', + DATABASE_NAME: 'datacat', +}; + +describe('validateEnv', () => { + it('accepte une config valide, coerce le port et applique les défauts', () => { + const cfg = validateEnv({ ...base }); + expect(cfg.DATABASE_PORT).toBe(5432); + expect(typeof cfg.DATABASE_PORT).toBe('number'); + expect(cfg.NODE_ENV).toBe('development'); + expect(cfg.PORT).toBe(3000); + }); + + it('rejette une variable requise manquante (message explicite)', () => { + const { DATABASE_HOST: _omit, ...rest } = base; + expect(() => validateEnv(rest)).toThrow(/DATABASE_HOST/); + }); + + it('rejette un port hors plage', () => { + expect(() => validateEnv({ ...base, DATABASE_PORT: '70000' })).toThrow(); + }); + + it('rejette un NODE_ENV inconnu', () => { + expect(() => validateEnv({ ...base, NODE_ENV: 'staging' })).toThrow(); + }); +}); diff --git a/back/src/config/env.validation.ts b/back/src/config/env.validation.ts new file mode 100644 index 0000000..a7f9bc2 --- /dev/null +++ b/back/src/config/env.validation.ts @@ -0,0 +1,76 @@ +import { plainToInstance, Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, + validateSync, +} from 'class-validator'; + +export enum NodeEnv { + Development = 'development', + Production = 'production', + Test = 'test', +} + +/** + * Schéma des variables d'environnement. Validé au démarrage via `validateEnv` + * (branché sur `ConfigModule.forRoot({ validate })`) : l'app refuse de démarrer + * si une variable requise manque ou est mal typée, plutôt que d'utiliser + * silencieusement des valeurs par défaut (ex. credentials DB). + */ +export class EnvironmentVariables { + @IsEnum(NodeEnv) + @IsOptional() + NODE_ENV: NodeEnv = NodeEnv.Development; + + @IsString() + @IsNotEmpty() + DATABASE_HOST!: string; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(65535) + DATABASE_PORT!: number; + + @IsString() + @IsNotEmpty() + DATABASE_USER!: string; + + @IsString() + @IsNotEmpty() + DATABASE_PASSWORD!: string; + + @IsString() + @IsNotEmpty() + DATABASE_NAME!: string; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(65535) + @IsOptional() + PORT = 3000; + + @IsString() + @IsOptional() + DOCS_OUTPUT_DIR?: string; +} + +export function validateEnv(config: Record): EnvironmentVariables { + const validated = plainToInstance(EnvironmentVariables, config, { + enableImplicitConversion: true, + }); + const errors = validateSync(validated, { skipMissingProperties: false }); + if (errors.length > 0) { + const details = errors + .map((e) => Object.values(e.constraints ?? {}).join(', ')) + .join('\n - '); + throw new Error(`Configuration d'environnement invalide:\n - ${details}`); + } + return validated; +} diff --git a/back/src/data-source.ts b/back/src/data-source.ts new file mode 100644 index 0000000..271ab74 --- /dev/null +++ b/back/src/data-source.ts @@ -0,0 +1,20 @@ +import 'reflect-metadata'; +import { DataSource } from 'typeorm'; + +/** + * DataSource dédié à la CLI TypeORM (migrations). Lit l'environnement + * directement (les mêmes DATABASE_* que l'app). Utilisé par les scripts + * migration:generate / migration:run / migration:revert. + * + * NB : la config runtime de l'app vit dans app.module.ts (TypeOrmModule). + */ +export default new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST ?? 'localhost', + port: Number(process.env.DATABASE_PORT ?? 5432), + username: process.env.DATABASE_USER ?? 'datacat', + password: process.env.DATABASE_PASSWORD ?? 'datacat', + database: process.env.DATABASE_NAME ?? 'datacat', + entities: ['src/**/*.entity.ts'], + migrations: ['src/migrations/*.ts'], +}); diff --git a/back/src/main.ts b/back/src/main.ts index 405b19b..10c3d08 100644 --- a/back/src/main.ts +++ b/back/src/main.ts @@ -1,5 +1,5 @@ import { NestFactory } from '@nestjs/core'; -import { ValidationPipe, RequestMethod } from '@nestjs/common'; +import { Logger, ValidationPipe, RequestMethod } from '@nestjs/common'; import { AppModule } from './app.module'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; @@ -16,7 +16,7 @@ async function bootstrap() { const port = process.env.PORT ?? 3000; await app.listen(port); - console.log(`Datacat API running on port ${port}`); + Logger.log(`Datacat API running on port ${port}`, 'Bootstrap'); } bootstrap(); diff --git a/back/src/migrations/1782543826810-InitialSchema.ts b/back/src/migrations/1782543826810-InitialSchema.ts new file mode 100644 index 0000000..33355fc --- /dev/null +++ b/back/src/migrations/1782543826810-InitialSchema.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class InitialSchema1782543826810 implements MigrationInterface { + name = 'InitialSchema1782543826810' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "categories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "description" text, "parent_id" character varying(36), "order_index" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_24dbc6126a28ff948da33e97d3b" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "api_entries" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "convention" character varying(30) NOT NULL DEFAULT 'CONSULTER', "name" character varying(255) NOT NULL DEFAULT '', "description" text, "type" character varying(10) NOT NULL, "provider" character varying(255) NOT NULL DEFAULT '', "version_major" integer NOT NULL DEFAULT '0', "version_minor" integer NOT NULL DEFAULT '0', "version_patch" integer NOT NULL DEFAULT '0', "yaml_content" text NOT NULL, "html_path" character varying(500), "status" character varying(10) NOT NULL DEFAULT 'PENDING', "error_message" text, "category_id" character varying(36), "is_current" boolean NOT NULL DEFAULT true, "contact_functional_name" character varying(255) NOT NULL DEFAULT '', "contact_functional_entity" character varying(255) NOT NULL DEFAULT '', "contact_functional_email" character varying(255) NOT NULL DEFAULT '', "contact_technical_name" character varying(255) NOT NULL DEFAULT '', "contact_technical_entity" character varying(255) NOT NULL DEFAULT '', "contact_technical_email" character varying(255) NOT NULL DEFAULT '', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_d7b668541d2539a0c212b198d32" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "api_entries"`); + await queryRunner.query(`DROP TABLE "categories"`); + } + +} diff --git a/back/src/modules/apis/apis.service.spec.ts b/back/src/modules/apis/apis.service.spec.ts index 53d93df..ca5192f 100644 --- a/back/src/modules/apis/apis.service.spec.ts +++ b/back/src/modules/apis/apis.service.spec.ts @@ -170,11 +170,17 @@ const mockRepo = { const mockDocsService = { generate: vi.fn() }; +const mockDataSource = { + transaction: vi.fn(async (cb: (m: unknown) => unknown) => + cb({ getRepository: () => mockRepo }), + ), +}; + let service: ApisService; beforeEach(() => { vi.clearAllMocks(); - service = new ApisService(mockRepo as any, mockDocsService as any); + service = new ApisService(mockRepo as any, mockDataSource as any, mockDocsService as any); }); function makeEntity(overrides: Record = {}) { diff --git a/back/src/modules/apis/apis.service.ts b/back/src/modules/apis/apis.service.ts index a988027..4b47842 100644 --- a/back/src/modules/apis/apis.service.ts +++ b/back/src/modules/apis/apis.service.ts @@ -1,6 +1,6 @@ -import { Injectable, NotFoundException, ConflictException, Inject, forwardRef } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, Inject, forwardRef, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; import { ApiEntryEntity } from './api-entry.entity'; import { CreateApiEntryDto } from './dto/create-api-entry.dto'; import { UpdateApiEntryDto } from './dto/update-api-entry.dto'; @@ -17,9 +17,12 @@ interface InternalUpdateFields { @Injectable() export class ApisService { + private readonly logger = new Logger(ApisService.name); + constructor( @InjectRepository(ApiEntryEntity) private readonly repo: Repository, + private readonly dataSource: DataSource, @Inject(forwardRef(() => DocsGenerationService)) private readonly docsService: DocsGenerationService, ) {} @@ -213,20 +216,24 @@ export class ApisService { async setCurrent(id: string): Promise { const entry = await this.findOneOrThrow(id); - // Mettre tous les siblings à false - await this.repo.update( - { name: entry.name, convention: entry.convention }, - { isCurrent: false }, - ); - // Mettre cet entry à true - await this.repo.update({ id }, { isCurrent: true }); + // Transaction : éviter une fenêtre où aucun (ou deux) sibling n'est courant. + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(ApiEntryEntity); + await repo.update( + { name: entry.name, convention: entry.convention }, + { isCurrent: false }, + ); + await repo.update({ id }, { isCurrent: true }); + }); return this.findOneOrThrow(id); } async regenerate(id: string): Promise { await this.findOneOrThrow(id); await this.updateInternal(id, { status: 'PENDING', errorMessage: null }); - this.docsService.generate(id).catch(console.error); + this.docsService + .generate(id) + .catch((err) => this.logger.error(`Génération docs échouée pour ${id}`, err)); return this.findOneOrThrow(id); } } diff --git a/back/src/modules/apis/dto/create-api-entry.dto.ts b/back/src/modules/apis/dto/create-api-entry.dto.ts index 3a30d25..4b5654b 100644 --- a/back/src/modules/apis/dto/create-api-entry.dto.ts +++ b/back/src/modules/apis/dto/create-api-entry.dto.ts @@ -1,17 +1,19 @@ -import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail, MaxLength } from 'class-validator'; import { Type } from 'class-transformer'; -import { ApiType, ApiConvention } from '@datacat/shared'; +import { ApiType, ApiConvention, API_CONVENTIONS, API_TYPES } from '@datacat/shared'; export class CreateApiEntryDto { - @IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE']) + @IsIn([...API_CONVENTIONS]) convention!: ApiConvention; @IsString() @IsNotEmpty() + @MaxLength(255) name!: string; @IsString() @IsNotEmpty() + @MaxLength(255) provider!: string; @IsInt() @@ -33,7 +35,7 @@ export class CreateApiEntryDto { @IsOptional() description?: string; - @IsIn(['ASYNCAPI', 'OPENAPI']) + @IsIn([...API_TYPES]) type!: ApiType; @IsString() @@ -46,23 +48,29 @@ export class CreateApiEntryDto { @IsString() @IsNotEmpty() + @MaxLength(255) contactFunctionalName!: string; @IsString() @IsNotEmpty() + @MaxLength(255) contactFunctionalEntity!: string; @IsEmail() + @MaxLength(255) contactFunctionalEmail!: string; @IsString() @IsNotEmpty() + @MaxLength(255) contactTechnicalName!: string; @IsString() @IsNotEmpty() + @MaxLength(255) contactTechnicalEntity!: string; @IsEmail() + @MaxLength(255) contactTechnicalEmail!: string; } diff --git a/back/src/modules/apis/dto/update-api-entry.dto.ts b/back/src/modules/apis/dto/update-api-entry.dto.ts index 9e87bd9..5e15f80 100644 --- a/back/src/modules/apis/dto/update-api-entry.dto.ts +++ b/back/src/modules/apis/dto/update-api-entry.dto.ts @@ -1,9 +1,9 @@ import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min, IsEmail } from 'class-validator'; import { Type } from 'class-transformer'; -import { ApiConvention } from '@datacat/shared'; +import { ApiConvention, API_CONVENTIONS } from '@datacat/shared'; export class UpdateApiEntryDto { - @IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE']) + @IsIn([...API_CONVENTIONS]) @IsOptional() convention?: ApiConvention; diff --git a/back/src/modules/categories/categories.service.ts b/back/src/modules/categories/categories.service.ts index 6656069..94ff499 100644 --- a/back/src/modules/categories/categories.service.ts +++ b/back/src/modules/categories/categories.service.ts @@ -98,11 +98,51 @@ export class CategoriesService { await this.categoryRepo.delete(id); } - private async countApisRecursively(categoryId: string): Promise { - const direct = await this.apiRepo.count({ where: { categoryId } }); - const children = await this.categoryRepo.find({ where: { parentId: categoryId }, select: ['id'] }); - const childCounts = await Promise.all(children.map((c) => this.countApisRecursively(c.id))); - return direct + childCounts.reduce((sum, n) => sum + n, 0); + /** + * Comptages pour TOUTES les catégories en 2 requêtes (au lieu d'un N+1 + * récursif par enfant) : la hiérarchie et les APIs directes sont chargées en + * une fois, puis les compteurs récursifs sont calculés en mémoire. + */ + private async computeCategoryCounts(): Promise<{ + subcategoryCount: Map; + recursiveApiCount: Map; + }> { + const categories = await this.categoryRepo.find({ select: ['id', 'parentId'] }); + const directRows = await this.apiRepo + .createQueryBuilder('api') + .select('api.categoryId', 'categoryId') + .addSelect('COUNT(*)', 'count') + .where('api.categoryId IS NOT NULL') + .groupBy('api.categoryId') + .getRawMany<{ categoryId: string; count: string }>(); + + const directApiCount = new Map(directRows.map((r) => [r.categoryId, Number(r.count)])); + const childrenOf = new Map(); + for (const c of categories) { + if (c.parentId) { + const siblings = childrenOf.get(c.parentId) ?? []; + siblings.push(c.id); + childrenOf.set(c.parentId, siblings); + } + } + + const recursiveApiCount = new Map(); + const computeRecursive = (id: string): number => { + const cached = recursiveApiCount.get(id); + if (cached !== undefined) return cached; + let total = directApiCount.get(id) ?? 0; + for (const childId of childrenOf.get(id) ?? []) total += computeRecursive(childId); + recursiveApiCount.set(id, total); + return total; + }; + + const subcategoryCount = new Map(); + for (const c of categories) { + subcategoryCount.set(c.id, (childrenOf.get(c.id) ?? []).length); + computeRecursive(c.id); + } + + return { subcategoryCount, recursiveApiCount }; } async browse(categoryId: string | null): Promise { @@ -132,13 +172,12 @@ export class CategoriesService { order: { orderIndex: 'ASC', name: 'ASC' }, }); - const subcategories: CategoryWithCounts[] = await Promise.all( - directChildren.map(async (child) => { - const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } }); - const apiCount = await this.countApisRecursively(child.id); - return { ...toCategory(child), subcategoryCount, apiCount }; - }), - ); + const { subcategoryCount, recursiveApiCount } = await this.computeCategoryCounts(); + const subcategories: CategoryWithCounts[] = directChildren.map((child) => ({ + ...toCategory(child), + subcategoryCount: subcategoryCount.get(child.id) ?? 0, + apiCount: recursiveApiCount.get(child.id) ?? 0, + })); /* 4. APIs directement dans cette catégorie */ const apiEntities = await this.apiRepo.find({ diff --git a/back/src/modules/seeder/seeder.service.ts b/back/src/modules/seeder/seeder.service.ts index 5e09e04..67a37a5 100644 --- a/back/src/modules/seeder/seeder.service.ts +++ b/back/src/modules/seeder/seeder.service.ts @@ -1,4 +1,4 @@ -import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import * as fs from 'fs/promises'; @@ -587,6 +587,8 @@ components: @Injectable() export class SeederService implements OnApplicationBootstrap { + private readonly logger = new Logger(SeederService.name); + constructor( @InjectRepository(ApiEntryEntity) private readonly apiRepo: Repository, @@ -648,7 +650,7 @@ export class SeederService implements OnApplicationBootstrap { const docsDir = process.env.DOCS_OUTPUT_DIR ?? '/app/docs-output'; await fs.rm(docsDir, { recursive: true, force: true }); await fs.mkdir(docsDir, { recursive: true }); - console.log('[Seeder] Reset complet — données supprimées, docs nettoyées.'); + this.logger.log('Reset complet — données supprimées, docs nettoyées.'); } private async seed(): Promise { @@ -864,11 +866,13 @@ export class SeederService implements OnApplicationBootstrap { }), ]); - console.log('[Seeder] 8 catégories et 7 APIs de démo créées — génération docs en cours...'); + this.logger.log('8 catégories et 7 APIs de démo créées — génération docs en cours...'); /* Déclenche la génération de docs en fire-and-forget pour chaque API */ for (const entry of entries) { - this.docsService.generate(entry.id).catch(console.error); + this.docsService + .generate(entry.id) + .catch((err) => this.logger.error(`Génération docs échouée pour ${entry.id}`, err)); } } } diff --git a/back/src/modules/uploads/dto/create-api-upload.dto.spec.ts b/back/src/modules/uploads/dto/create-api-upload.dto.spec.ts new file mode 100644 index 0000000..ff7ff49 --- /dev/null +++ b/back/src/modules/uploads/dto/create-api-upload.dto.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { CreateApiUploadDto } from './create-api-upload.dto'; + +function validate(payload: Record) { + const dto = plainToInstance(CreateApiUploadDto, payload, { enableImplicitConversion: true }); + return validateSync(dto, { whitelist: true }); +} + +describe('CreateApiUploadDto', () => { + it('accepte un payload minimal (name seul requis)', () => { + expect(validate({ name: 'Mon API' })).toHaveLength(0); + }); + + it('rejette un name manquant', () => { + expect(validate({}).some((e) => e.property === 'name')).toBe(true); + }); + + it('accepte des emails de contact vides (@ValidateIf)', () => { + expect( + validate({ name: 'X', contactFunctionalEmail: '', contactTechnicalEmail: '' }), + ).toHaveLength(0); + }); + + it('rejette un email renseigné mais invalide', () => { + expect( + validate({ name: 'X', contactFunctionalEmail: 'pas-un-email' }).some( + (e) => e.property === 'contactFunctionalEmail', + ), + ).toBe(true); + }); + + it('accepte un email valide', () => { + expect(validate({ name: 'X', contactFunctionalEmail: 'a@b.fr' })).toHaveLength(0); + }); + + it('rejette une convention hors énumération', () => { + expect( + validate({ name: 'X', convention: 'FOO' }).some((e) => e.property === 'convention'), + ).toBe(true); + }); + + it('rejette un name trop long (>255)', () => { + expect( + validate({ name: 'a'.repeat(256) }).some((e) => e.property === 'name'), + ).toBe(true); + }); +}); diff --git a/back/src/modules/uploads/dto/create-api-upload.dto.ts b/back/src/modules/uploads/dto/create-api-upload.dto.ts new file mode 100644 index 0000000..e2a2e79 --- /dev/null +++ b/back/src/modules/uploads/dto/create-api-upload.dto.ts @@ -0,0 +1,96 @@ +import { Type } from 'class-transformer'; +import { + IsEmail, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + ValidateIf, +} from 'class-validator'; +import { ApiConvention, ApiType, API_CONVENTIONS, API_TYPES } from '@datacat/shared'; + +/** + * Corps du multipart POST /uploads. Les valeurs arrivent en `string` (form-data) + * et sont transformées par le ValidationPipe global (transform:true). + * + * Strictness alignée sur le comportement existant pour ne pas casser le front : + * seul `name` est réellement requis ; provider/contacts peuvent être vides ; les + * emails ne sont validés que s'ils sont renseignés (@ValidateIf). + */ +export class CreateApiUploadDto { + @IsIn([...API_CONVENTIONS]) + @IsOptional() + convention?: ApiConvention; + + @IsString() + @IsNotEmpty() + @MaxLength(255) + name!: string; + + @IsString() + @IsOptional() + @MaxLength(255) + provider?: string; + + @Type(() => Number) + @IsInt() + @Min(0) + @IsOptional() + versionMajor?: number; + + @Type(() => Number) + @IsInt() + @Min(0) + @IsOptional() + versionMinor?: number; + + @Type(() => Number) + @IsInt() + @Min(0) + @IsOptional() + versionPatch?: number; + + @IsString() + @IsOptional() + description?: string; + + @IsIn([...API_TYPES]) + @IsOptional() + type?: ApiType; + + @IsUUID() + @IsOptional() + categoryId?: string; + + @IsString() + @IsOptional() + @MaxLength(255) + contactFunctionalName?: string; + + @IsString() + @IsOptional() + @MaxLength(255) + contactFunctionalEntity?: string; + + @ValidateIf((o: CreateApiUploadDto) => !!o.contactFunctionalEmail) + @IsEmail() + contactFunctionalEmail?: string; + + @IsString() + @IsOptional() + @MaxLength(255) + contactTechnicalName?: string; + + @IsString() + @IsOptional() + @MaxLength(255) + contactTechnicalEntity?: string; + + @ValidateIf((o: CreateApiUploadDto) => !!o.contactTechnicalEmail) + @IsEmail() + contactTechnicalEmail?: string; +} diff --git a/back/src/modules/uploads/uploads.controller.ts b/back/src/modules/uploads/uploads.controller.ts index 23fa8ef..9891710 100644 --- a/back/src/modules/uploads/uploads.controller.ts +++ b/back/src/modules/uploads/uploads.controller.ts @@ -9,7 +9,6 @@ import { } from '@nestjs/common'; import { FilesInterceptor } from '@nestjs/platform-express'; import { diskStorage } from 'multer'; -import * as fs from 'fs'; import * as fsPromises from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; @@ -29,6 +28,7 @@ const execEnv = { import { ApisService } from '../apis/apis.service'; import { DocsGenerationService } from '../docs-generation/docs-generation.service'; import { ApiType } from '@datacat/shared'; +import { CreateApiUploadDto } from './dto/create-api-upload.dto'; @Controller('uploads') export class UploadsController { @@ -69,11 +69,13 @@ export class UploadsController { } /* Lire tous les contenus en mémoire avant toute suppression */ - const fileContents = files.map((file) => ({ - name: file.originalname, - path: file.path, - content: fs.readFileSync(file.path, 'utf-8'), - })); + const fileContents = await Promise.all( + files.map(async (file) => ({ + name: file.originalname, + path: file.path, + content: await fsPromises.readFile(file.path, 'utf-8'), + })), + ); /* Nettoyage immédiat des fichiers temporaires */ for (const file of files) { @@ -175,33 +177,11 @@ export class UploadsController { ) async upload( @UploadedFiles() files: Express.Multer.File[], - @Body() - body: { - convention: string; - name: string; - provider: string; - versionMajor: string; - versionMinor: string; - versionPatch: string; - description?: string; - type?: ApiType; - categoryId?: string; - contactFunctionalName: string; - contactFunctionalEntity: string; - contactFunctionalEmail: string; - contactTechnicalName: string; - contactTechnicalEntity: string; - contactTechnicalEmail: string; - }, + @Body() body: CreateApiUploadDto, ) { - if (!body.name) throw new BadRequestException('name is required'); - if (!body.convention) throw new BadRequestException('convention is required'); - - /* Crée le répertoire si absent */ + /* Crée le répertoire si absent (mkdir recursive est idempotent) */ const uploadDir = path.join(os.tmpdir(), 'datacat-uploads'); - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }); - } + await fsPromises.mkdir(uploadDir, { recursive: true }); let yamlContent: string; let detectedType: ApiType | null = null; @@ -211,7 +191,7 @@ export class UploadsController { yamlContent = ''; } else if (files.length === 1) { /* Cas simple : un seul fichier */ - yamlContent = fs.readFileSync(files[0].path, 'utf-8'); + yamlContent = await fsPromises.readFile(files[0].path, 'utf-8'); /* Validation syntaxique YAML */ this.validateYamlSyntax(yamlContent, files[0].originalname); @@ -219,7 +199,7 @@ export class UploadsController { detectedType = this.detectTypeFromContent(yamlContent); } else { /* Cas multi-fichiers : détecter le fichier principal et bundler */ - const { mainFile, type } = this.detectMainFile(files); + const { mainFile, type } = await this.detectMainFile(files); detectedType = type; this.logger.log(`Multi-file upload: main=${mainFile.originalname}, type=${type}, total=${files.length} files`); @@ -233,27 +213,29 @@ export class UploadsController { } const entry = await this.apisService.create({ - convention: (body.convention as 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE') ?? 'CONSULTER', + convention: body.convention ?? 'CONSULTER', name: body.name, - provider: body.provider, - versionMajor: parseInt(body.versionMajor, 10) || 0, - versionMinor: parseInt(body.versionMinor, 10) || 0, - versionPatch: parseInt(body.versionPatch, 10) || 0, + provider: body.provider ?? '', + versionMajor: body.versionMajor ?? 0, + versionMinor: body.versionMinor ?? 0, + versionPatch: body.versionPatch ?? 0, description: body.description, type: apiType, yamlContent, categoryId: body.categoryId || undefined, - contactFunctionalName: body.contactFunctionalName, - contactFunctionalEntity: body.contactFunctionalEntity, - contactFunctionalEmail: body.contactFunctionalEmail, - contactTechnicalName: body.contactTechnicalName, - contactTechnicalEntity: body.contactTechnicalEntity, - contactTechnicalEmail: body.contactTechnicalEmail, + contactFunctionalName: body.contactFunctionalName ?? '', + contactFunctionalEntity: body.contactFunctionalEntity ?? '', + contactFunctionalEmail: body.contactFunctionalEmail ?? '', + contactTechnicalName: body.contactTechnicalName ?? '', + contactTechnicalEntity: body.contactTechnicalEntity ?? '', + contactTechnicalEmail: body.contactTechnicalEmail ?? '', }); /* Fire-and-forget : génération asynchrone (seulement si YAML fourni) */ if (yamlContent) { - this.docsService.generate(entry.id).catch(console.error); + this.docsService + .generate(entry.id) + .catch((err) => this.logger.error(`Génération docs échouée pour ${entry.id}`, err)); } return entry; @@ -308,9 +290,11 @@ export class UploadsController { return null; } - private detectMainFile(files: Express.Multer.File[]): { mainFile: Express.Multer.File; type: ApiType } { + private async detectMainFile( + files: Express.Multer.File[], + ): Promise<{ mainFile: Express.Multer.File; type: ApiType }> { for (const file of files) { - const content = fs.readFileSync(file.path, 'utf-8'); + const content = await fsPromises.readFile(file.path, 'utf-8'); const type = this.detectTypeFromContent(content); if (type) { return { mainFile: file, type }; diff --git a/back/vitest-setup.ts b/back/vitest-setup.ts new file mode 100644 index 0000000..c0c1347 --- /dev/null +++ b/back/vitest-setup.ts @@ -0,0 +1,3 @@ +// Requis pour que les décorateurs class-validator / class-transformer +// (Reflect.getMetadata) fonctionnent dans les tests, comme dans l'app (main.ts). +import 'reflect-metadata'; diff --git a/back/vitest.config.ts b/back/vitest.config.ts index 3566066..e0dc0ad 100644 --- a/back/vitest.config.ts +++ b/back/vitest.config.ts @@ -4,6 +4,20 @@ export default defineConfig({ test: { globals: true, environment: 'node', + setupFiles: ['./vitest-setup.ts'], include: ['src/**/*.spec.ts'], + coverage: { + provider: 'v8', + reportsDirectory: './coverage', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.ts'], + exclude: [ + 'src/**/*.spec.ts', + 'src/**/*.module.ts', + 'src/**/*.entity.ts', + 'src/main.ts', + 'src/**/*.dto.ts', + ], + }, }, }); diff --git a/front-public/angular.json b/front-public/angular.json index be0490b..872a466 100644 --- a/front-public/angular.json +++ b/front-public/angular.json @@ -57,7 +57,13 @@ { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" }, { "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" } ], - "outputHashing": "all" + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] }, "development": { "optimization": false, diff --git a/front-public/eslint.config.js b/front-public/eslint.config.js new file mode 100644 index 0000000..975fd0e --- /dev/null +++ b/front-public/eslint.config.js @@ -0,0 +1,38 @@ +// @ts-check +const eslint = require('@eslint/js'); +const tseslint = require('typescript-eslint'); +const angular = require('angular-eslint'); + +module.exports = tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', '.angular/**', 'coverage/**'], + }, + { + files: ['**/*.ts'], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...angular.configs.tsRecommended, + ], + processor: angular.processInlineTemplates, + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { type: 'attribute', prefix: 'app', style: 'camelCase' }, + ], + '@angular-eslint/component-selector': [ + 'error', + { type: 'element', prefix: 'app', style: 'kebab-case' }, + ], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + }, + { + files: ['**/*.html'], + extends: [ + ...angular.configs.templateRecommended, + ...angular.configs.templateAccessibility, + ], + rules: {}, + }, +); diff --git a/front-public/package.json b/front-public/package.json index ab9604c..37d2bf7 100644 --- a/front-public/package.json +++ b/front-public/package.json @@ -6,8 +6,10 @@ "start": "ng serve", "build": "ng build", "build:prod": "ng build --configuration production", - "test": "ng test --watch=false --browsers=ChromeHeadless", - "lint": "ng lint" + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint .", + "lint:fix": "eslint . --fix" }, "dependencies": { "@angular/animations": "^19.0.0", @@ -18,6 +20,8 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", + "@datacat/shared": "workspace:*", + "@gouvfr/dsfr": "^1.13.0", "rxjs": "^7.8.0", "tslib": "^2.8.0", "zone.js": "^0.15.0" @@ -26,9 +30,14 @@ "@angular-devkit/build-angular": "^19.0.0", "@angular/cli": "^19.0.0", "@angular/compiler-cli": "^19.0.0", + "@eslint/js": "^9.39.4", "@types/node": "^22.0.0", - "@gouvfr/dsfr": "^1.13.0", + "angular-eslint": "^19.8.1", + "eslint": "^9.39.4", + "jsdom": "^29.1.1", + "sass": "^1.83.0", "typescript": "~5.8.3", - "sass": "^1.83.0" + "typescript-eslint": "^8.62.0", + "vitest": "^3.2.4" } } diff --git a/front-public/src/app/app.config.ts b/front-public/src/app/app.config.ts index 1c7fc6a..a8940e2 100644 --- a/front-public/src/app/app.config.ts +++ b/front-public/src/app/app.config.ts @@ -1,12 +1,13 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { provideRouter, withComponentInputBinding } from '@angular/router'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; +import { errorInterceptor } from './core/error.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes, withComponentInputBinding()), - provideHttpClient(withInterceptorsFromDi()), + provideHttpClient(withInterceptors([errorInterceptor])), ], }; diff --git a/front-public/src/app/core/api.service.ts b/front-public/src/app/core/api.service.ts index 79a9378..21b0410 100644 --- a/front-public/src/app/core/api.service.ts +++ b/front-public/src/app/core/api.service.ts @@ -1,12 +1,14 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery } from '@datacat/shared'; +import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery, ApiType } from '@datacat/shared'; +import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' }) export class ApiService { private http = inject(HttpClient); - private baseUrl = '/api/apis'; + private baseUrl = `${environment.apiBaseUrl}/apis`; + private uploadsUrl = `${environment.apiBaseUrl}/uploads`; list(query?: ApiListQuery): Observable { const params: Record = {}; @@ -58,30 +60,23 @@ export class ApiService { } upload(formData: FormData): Observable { - return this.http.post('/api/uploads', formData); + return this.http.post(this.uploadsUrl, formData); } - validate(formData: FormData): Observable<{ - valid: boolean; - type: 'ASYNCAPI' | 'OPENAPI' | null; - mainFile: string | null; - name: string | null; - versionMajor: number | null; - versionMinor: number | null; - versionPatch: number | null; - description: string | null; - errors: Array<{ file: string; message: string }>; - }> { - return this.http.post<{ - valid: boolean; - type: 'ASYNCAPI' | 'OPENAPI' | null; - mainFile: string | null; - name: string | null; - versionMajor: number | null; - versionMinor: number | null; - versionPatch: number | null; - description: string | null; - errors: Array<{ file: string; message: string }>; - }>('/api/uploads/validate', formData); + validate(formData: FormData): Observable { + return this.http.post(`${this.uploadsUrl}/validate`, formData); } } + +/** Réponse de l'endpoint de validation d'upload (POST /uploads/validate). */ +export interface ValidateResponse { + valid: boolean; + type: ApiType | null; + mainFile: string | null; + name: string | null; + versionMajor: number | null; + versionMinor: number | null; + versionPatch: number | null; + description: string | null; + errors: Array<{ file: string; message: string }>; +} diff --git a/front-public/src/app/core/category.service.ts b/front-public/src/app/core/category.service.ts index e375e79..a032d49 100644 --- a/front-public/src/app/core/category.service.ts +++ b/front-public/src/app/core/category.service.ts @@ -6,11 +6,12 @@ import { CategoryListResponse, CategoryBrowseResponse, } from '@datacat/shared'; +import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' }) export class CategoryService { private http = inject(HttpClient); - private baseUrl = '/api/categories'; + private baseUrl = `${environment.apiBaseUrl}/categories`; list(search?: string): Observable { const params: Record = {}; diff --git a/front-public/src/app/core/error.interceptor.ts b/front-public/src/app/core/error.interceptor.ts new file mode 100644 index 0000000..a31aa57 --- /dev/null +++ b/front-public/src/app/core/error.interceptor.ts @@ -0,0 +1,18 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { ErrorService } from './error.service'; +import { catchError, throwError } from 'rxjs'; + +/** + * Interceptor HTTP fonctionnel : journalise et normalise les erreurs réseau en + * un message lisible (via ErrorService), puis relaie l'erreur aux appelants. + */ +export const errorInterceptor: HttpInterceptorFn = (req, next) => { + const errorService = inject(ErrorService); + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + errorService.log(req.method, req.url, error); + return throwError(() => error); + }), + ); +}; diff --git a/front-public/src/app/core/error.service.spec.ts b/front-public/src/app/core/error.service.spec.ts new file mode 100644 index 0000000..4f35571 --- /dev/null +++ b/front-public/src/app/core/error.service.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ErrorService } from './error.service'; + +describe('ErrorService.toMessage', () => { + const svc = new ErrorService(); + + it('retourne un body string', () => { + const err = new HttpErrorResponse({ error: 'Boom', status: 500 }); + expect(svc.toMessage(err)).toBe('Boom'); + }); + + it('extrait body.message', () => { + const err = new HttpErrorResponse({ error: { message: 'Champ invalide' }, status: 400 }); + expect(svc.toMessage(err)).toBe('Champ invalide'); + }); + + it('joint un body.message tableau', () => { + const err = new HttpErrorResponse({ error: { message: ['a', 'b'] }, status: 400 }); + expect(svc.toMessage(err)).toBe('a, b'); + }); + + it('status 0 -> serveur injoignable', () => { + const err = new HttpErrorResponse({ status: 0 }); + expect(svc.toMessage(err)).toContain('injoignable'); + }); + + it('Error standard -> son message', () => { + expect(svc.toMessage(new Error('oops'))).toBe('oops'); + }); + + it('valeur inconnue -> fallback', () => { + expect(svc.toMessage(null)).toBe('Une erreur est survenue.'); + expect(svc.toMessage(undefined, 'custom')).toBe('custom'); + }); +}); diff --git a/front-public/src/app/core/error.service.ts b/front-public/src/app/core/error.service.ts new file mode 100644 index 0000000..3ec3055 --- /dev/null +++ b/front-public/src/app/core/error.service.ts @@ -0,0 +1,32 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +/** + * Centralise la traduction d'une erreur HTTP en message lisible et son + * journal. Les composants appellent `toMessage(err)` plutôt que de + * réimplémenter chacun leur extraction de message. + */ +@Injectable({ providedIn: 'root' }) +export class ErrorService { + /** Extrait un message lisible depuis une erreur HTTP (ou autre). */ + toMessage(error: unknown, fallback = 'Une erreur est survenue.'): string { + if (error instanceof HttpErrorResponse) { + const body = error.error; + if (typeof body === 'string' && body.trim()) return body; + if (body && typeof body === 'object') { + const message = (body as { message?: unknown }).message; + if (typeof message === 'string' && message) return message; + if (Array.isArray(message) && message.length) return message.join(', '); + } + if (error.status === 0) return 'Serveur injoignable. Vérifiez votre connexion.'; + return error.message || fallback; + } + if (error instanceof Error && error.message) return error.message; + return fallback; + } + + /** Journalise une erreur réseau (appelé par l'interceptor). */ + log(method: string, url: string, error: HttpErrorResponse): void { + console.error(`[HTTP ${error.status}] ${method} ${url} — ${this.toMessage(error)}`); + } +} diff --git a/front-public/src/app/pages/api-detail/api-detail.component.html b/front-public/src/app/pages/api-detail/api-detail.component.html new file mode 100644 index 0000000..1e1a89f --- /dev/null +++ b/front-public/src/app/pages/api-detail/api-detail.component.html @@ -0,0 +1,369 @@ +
+ + + @if (loading()) { +
+

Chargement...

+
+ } + + @if (error()) { +
+

Erreur

+

{{ error() }}

+
+ } + + @if (api(); as entry) { + + Retour + + +
+ +
+

{{ entry.title }}

+ @if (versions().length <= 1) { + v{{ entry.version }} + } @else { + + } +
+ +
+ + Voir la documentation + +
+ + @if (actionsOpen()) { +
+ + + Télécharger YAML + + @if (entry.status !== 'GENERATED') { + + } + +
+ } +
+
+
+ + +
+ + +
+ + + @if (activeTab() === 'info') { + @if (!editingInfo()) { +
+
+
+
+
+
Version
+
+ {{ entry.version }} + @if (!entry.isCurrent) { + + } +
+
+
+
Type
+
{{ entry.type }}
+
+
+
Statut
+
+ {{ statusLabel(entry.status) }} + @if (entry.status === 'ERROR' && entry.errorMessage) { +

{{ entry.errorMessage }}

+ } +
+
+
+
Fournisseur
+
{{ entry.provider }}
+
+ @if (entry.description) { +
+
Description
+
{{ entry.description }}
+
+ } +
+
Créé le
+
{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}
+
+
+
Mis à jour le
+
{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}
+
+
+
+ +
+
+
+
+ } + + @if (editingInfo()) { +
+ +

Titre *

+
+
+
+ + +
+
+
+
+ + + @if (infoForm.get('name')?.invalid && infoForm.get('name')?.touched) { +

Le nom est obligatoire.

+ } +
+
+
+ + +
+ + + @if (infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched) { +

Le fournisseur est obligatoire.

+ } +
+ + +

Version *

+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+ } + } + + + @if (activeTab() === 'contacts') { + @if (!editingContacts()) { +
+
+
+
+
+

Contact métier

+

{{ entry.contactFunctionalName }}

+

{{ entry.contactFunctionalEntity }}

+

{{ entry.contactFunctionalEmail }}

+
+
+

Contact technique

+

{{ entry.contactTechnicalName }}

+

{{ entry.contactTechnicalEntity }}

+

{{ entry.contactTechnicalEmail }}

+
+
+
+ +
+
+
+
+ } + + @if (editingContacts()) { +
+ +

Contact métier *

+
+
+
+ + + @if (contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched) { +

Obligatoire.

+ } +
+
+
+
+ + + @if (contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched) { +

Obligatoire.

+ } +
+
+
+
+ + + @if (contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched) { +

Email valide requis.

+ } +
+
+
+ + +

Contact technique *

+
+
+
+ + + @if (contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched) { +

Obligatoire.

+ } +
+
+
+
+ + + @if (contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched) { +

Obligatoire.

+ } +
+
+
+
+ + + @if (contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched) { +

Email valide requis.

+ } +
+
+
+ +
+ + +
+
+ } + } + + } +
diff --git a/front-public/src/app/pages/api-detail/api-detail.component.ts b/front-public/src/app/pages/api-detail/api-detail.component.ts index 563d3e6..fffd128 100644 --- a/front-public/src/app/pages/api-detail/api-detail.component.ts +++ b/front-public/src/app/pages/api-detail/api-detail.component.ts @@ -3,412 +3,26 @@ import { signal, computed, inject, + DestroyRef, OnInit, OnDestroy, ChangeDetectionStrategy, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { Subscription } from 'rxjs'; import { ApiService } from '../../core/api.service'; import { CategoryService } from '../../core/category.service'; +import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component'; import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared'; @Component({ selector: 'app-api-detail', standalone: true, - imports: [CommonModule, RouterLink, ReactiveFormsModule], + imports: [CommonModule, RouterLink, ReactiveFormsModule, BreadcrumbComponent], changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
- - - - @if (loading()) { -
-

Chargement...

-
- } - - @if (error()) { -
-

Erreur

-

{{ error() }}

-
- } - - @if (api(); as entry) { - - Retour - - -
- -
-

{{ entry.title }}

- @if (versions().length <= 1) { - v{{ entry.version }} - } @else { - - } -
- -
- - Voir la documentation - -
- - @if (actionsOpen()) { -
- - - Télécharger YAML - - @if (entry.status !== 'GENERATED') { - - } - -
- } -
-
-
- - -
- - -
- - - @if (activeTab() === 'info') { - @if (!editingInfo()) { -
-
-
-
-
-
Version
-
- {{ entry.version }} - @if (!entry.isCurrent) { - - } -
-
-
-
Type
-
{{ entry.type }}
-
-
-
Statut
-
- {{ statusLabel(entry.status) }} - @if (entry.status === 'ERROR' && entry.errorMessage) { -

{{ entry.errorMessage }}

- } -
-
-
-
Fournisseur
-
{{ entry.provider }}
-
- @if (entry.description) { -
-
Description
-
{{ entry.description }}
-
- } -
-
Créé le
-
{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}
-
-
-
Mis à jour le
-
{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}
-
-
-
- -
-
-
-
- } - - @if (editingInfo()) { -
- -

Titre *

-
-
-
- - -
-
-
-
- - - @if (infoForm.get('name')?.invalid && infoForm.get('name')?.touched) { -

Le nom est obligatoire.

- } -
-
-
- - -
- - - @if (infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched) { -

Le fournisseur est obligatoire.

- } -
- - -

Version *

-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
- - -
- - -
- - -
- - -
- -
- - -
-
- } - } - - - @if (activeTab() === 'contacts') { - @if (!editingContacts()) { -
-
-
-
-
-

Contact métier

-

{{ entry.contactFunctionalName }}

-

{{ entry.contactFunctionalEntity }}

-

{{ entry.contactFunctionalEmail }}

-
-
-

Contact technique

-

{{ entry.contactTechnicalName }}

-

{{ entry.contactTechnicalEntity }}

-

{{ entry.contactTechnicalEmail }}

-
-
-
- -
-
-
-
- } - - @if (editingContacts()) { -
- -

Contact métier *

-
-
-
- - - @if (contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched) { -

Obligatoire.

- } -
-
-
-
- - - @if (contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched) { -

Obligatoire.

- } -
-
-
-
- - - @if (contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched) { -

Email valide requis.

- } -
-
-
- - -

Contact technique *

-
-
-
- - - @if (contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched) { -

Obligatoire.

- } -
-
-
-
- - - @if (contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched) { -

Obligatoire.

- } -
-
-
-
- - - @if (contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched) { -

Email valide requis.

- } -
-
-
- -
- - -
-
- } - } - - } -
- `, + templateUrl: "./api-detail.component.html", }) export class ApiDetailComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); @@ -416,6 +30,7 @@ export class ApiDetailComponent implements OnInit, OnDestroy { private apiService = inject(ApiService); private categoryService = inject(CategoryService); private fb = inject(FormBuilder); + private destroyRef = inject(DestroyRef); api = signal(null); versions = signal([]); @@ -434,6 +49,15 @@ export class ApiDetailComponent implements OnInit, OnDestroy { return ['/browse']; }); + breadcrumbItems = computed(() => { + const crumbs = this.categoryBreadcrumb(); + const items: BreadcrumbItem[] = [{ label: 'Accueil', link: '/' }]; + if (crumbs.length === 0) items.push({ label: 'Parcourir', link: '/browse' }); + for (const crumb of crumbs) items.push({ label: crumb.name, link: ['/browse', crumb.id] }); + items.push({ label: this.api()?.title ?? 'Chargement...' }); + return items; + }); + /* Onglets */ activeTab = signal<'info' | 'contacts'>('info'); editingInfo = signal(false); @@ -462,26 +86,29 @@ export class ApiDetailComponent implements OnInit, OnDestroy { }); private pollInterval: ReturnType | null = null; - private routeSub: Subscription | null = null; private id = ''; ngOnInit() { - this.categoryService.list().subscribe((res) => this.categories.set(res.items)); - this.routeSub = this.route.paramMap.subscribe((params) => { - this.id = params.get('id') ?? ''; - this.api.set(null); - this.versions.set([]); - this.error.set(null); - this.categoryBreadcrumb.set([]); - this.editingInfo.set(false); - this.editingContacts.set(false); - this.clearPoll(); - this.loadApi(); - }); + this.categoryService + .list() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((res) => this.categories.set(res.items)); + this.route.paramMap + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((params) => { + this.id = params.get('id') ?? ''; + this.api.set(null); + this.versions.set([]); + this.error.set(null); + this.categoryBreadcrumb.set([]); + this.editingInfo.set(false); + this.editingContacts.set(false); + this.clearPoll(); + this.loadApi(); + }); } ngOnDestroy() { - this.routeSub?.unsubscribe(); this.clearPoll(); } @@ -498,29 +125,38 @@ export class ApiDetailComponent implements OnInit, OnDestroy { onSetCurrent() { this.settingCurrent.set(true); - this.apiService.setCurrent(this.id).subscribe({ - next: (entry) => { - this.api.set(entry); - this.settingCurrent.set(false); - this.apiService.getVersions(this.id).subscribe({ - next: (v) => this.versions.set(v), - error: () => {}, - }); - }, - error: () => this.settingCurrent.set(false), - }); + this.apiService + .setCurrent(this.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (entry) => { + this.api.set(entry); + this.settingCurrent.set(false); + this.apiService + .getVersions(this.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (v) => this.versions.set(v), + error: () => {}, + }); + }, + error: () => this.settingCurrent.set(false), + }); } onRegenerate() { this.regenerating.set(true); - this.apiService.regenerate(this.id).subscribe({ - next: (entry) => { - this.api.set(entry); - this.regenerating.set(false); - this.startPollIfPending(); - }, - error: () => this.regenerating.set(false), - }); + this.apiService + .regenerate(this.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (entry) => { + this.api.set(entry); + this.regenerating.set(false); + this.startPollIfPending(); + }, + error: () => this.regenerating.set(false), + }); } startEditInfo() { @@ -568,10 +204,12 @@ export class ApiDetailComponent implements OnInit, OnDestroy { versionPatch: raw.versionPatch ?? 0, categoryId: raw.categoryId || null, description: raw.description || null, - }).subscribe({ - next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); }, - error: () => this.savingInfo.set(false), - }); + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); }, + error: () => this.savingInfo.set(false), + }); } submitContacts() { @@ -587,10 +225,12 @@ export class ApiDetailComponent implements OnInit, OnDestroy { contactTechnicalName: raw.contactTechnicalName ?? '', contactTechnicalEntity: raw.contactTechnicalEntity ?? '', contactTechnicalEmail: raw.contactTechnicalEmail ?? '', - }).subscribe({ - next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); }, - error: () => this.savingContacts.set(false), - }); + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); }, + error: () => this.savingContacts.set(false), + }); } onDelete() { @@ -599,16 +239,19 @@ export class ApiDetailComponent implements OnInit, OnDestroy { /* Calculer la cible de redirection avant la suppression */ const otherVersions = this.versions().filter((v) => v.id !== this.id); const redirectTarget = otherVersions.find((v) => v.isCurrent) ?? otherVersions[0]; - this.apiService.delete(this.id).subscribe({ - next: () => { - if (redirectTarget) { - this.router.navigate(['/catalog', redirectTarget.id]); - } else { - this.router.navigate(this.backUrl()); - } - }, - error: () => this.deleting.set(false), - }); + this.apiService + .delete(this.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + if (redirectTarget) { + this.router.navigate(['/catalog', redirectTarget.id]); + } else { + this.router.navigate(this.backUrl()); + } + }, + error: () => this.deleting.set(false), + }); } badgeClass(status: string): string { @@ -627,27 +270,36 @@ export class ApiDetailComponent implements OnInit, OnDestroy { private loadApi() { this.loading.set(true); - this.apiService.get(this.id).subscribe({ - next: (entry) => { - this.api.set(entry); - this.loading.set(false); - this.startPollIfPending(); - this.apiService.getVersions(this.id).subscribe({ - next: (v) => this.versions.set(v), - error: () => {}, - }); - if (entry.categoryId) { - this.categoryService.browse(entry.categoryId).subscribe({ - next: (data) => this.categoryBreadcrumb.set(data.breadcrumb), - error: () => {}, - }); - } - }, - error: (err) => { - this.error.set(err.message ?? 'Erreur lors du chargement'); - this.loading.set(false); - }, - }); + this.apiService + .get(this.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (entry) => { + this.api.set(entry); + this.loading.set(false); + this.startPollIfPending(); + this.apiService + .getVersions(this.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (v) => this.versions.set(v), + error: () => {}, + }); + if (entry.categoryId) { + this.categoryService + .browse(entry.categoryId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (data) => this.categoryBreadcrumb.set(data.breadcrumb), + error: () => {}, + }); + } + }, + error: (err) => { + this.error.set(err.message ?? 'Erreur lors du chargement'); + this.loading.set(false); + }, + }); } private startPollIfPending() { @@ -658,12 +310,15 @@ export class ApiDetailComponent implements OnInit, OnDestroy { } private pollStatus() { - this.apiService.get(this.id).subscribe({ - next: (entry) => { - this.api.set(entry); - if (entry.status !== 'PENDING') this.clearPoll(); - }, - }); + this.apiService + .get(this.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (entry) => { + this.api.set(entry); + if (entry.status !== 'PENDING') this.clearPoll(); + }, + }); } private clearPoll() { diff --git a/front-public/src/app/pages/browse/browse.component.ts b/front-public/src/app/pages/browse/browse.component.ts index 99a45a9..6cd967d 100644 --- a/front-public/src/app/pages/browse/browse.component.ts +++ b/front-public/src/app/pages/browse/browse.component.ts @@ -17,32 +17,16 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem import { forkJoin } from 'rxjs'; import { CategoryFormModalComponent } from '../../shared/category-form-modal/category-form-modal.component'; import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal/category-delete-modal.component'; +import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component'; @Component({ selector: 'app-browse', standalone: true, - imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent], + imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent, BreadcrumbComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
- - + @if (categoryId()) { @@ -309,6 +293,16 @@ export class BrowseComponent implements OnInit { return d.breadcrumb.slice(0, -1); }); + breadcrumbItems = computed(() => { + const items: BreadcrumbItem[] = [{ label: 'Accueil', link: '/' }]; + for (const crumb of this.ancestors()) { + items.push({ label: crumb.name, link: ['/browse', crumb.id] }); + } + const current = this.browseData()?.category; + if (current) items.push({ label: current.name }); + return items; + }); + backUrl = computed(() => { const anc = this.ancestors(); if (anc.length > 0) return ['/browse', anc[anc.length - 1].id]; @@ -327,16 +321,19 @@ export class BrowseComponent implements OnInit { loadBrowse() { this.loading.set(true); this.error.set(null); - this.categoryService.browse(this.categoryId()).subscribe({ - next: (data) => { - this.browseData.set(data); - this.loading.set(false); - }, - error: () => { - this.error.set('Erreur lors du chargement des données.'); - this.loading.set(false); - }, - }); + this.categoryService + .browse(this.categoryId()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (data) => { + this.browseData.set(data); + this.loading.set(false); + }, + error: () => { + this.error.set('Erreur lors du chargement des données.'); + this.loading.set(false); + }, + }); } openCreate() { @@ -385,7 +382,9 @@ export class BrowseComponent implements OnInit { forkJoin({ apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }), categories: this.categoryService.list(raw || undefined), - }).subscribe({ + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ next: ({ apis, categories }) => { this.searchResults.set(apis.items); this.searchTotal.set(apis.total); diff --git a/front-public/src/app/pages/catalog/catalog.component.ts b/front-public/src/app/pages/catalog/catalog.component.ts index 5d64934..82958a5 100644 --- a/front-public/src/app/pages/catalog/catalog.component.ts +++ b/front-public/src/app/pages/catalog/catalog.component.ts @@ -4,9 +4,11 @@ import { computed, effect, inject, + DestroyRef, OnInit, ChangeDetectionStrategy, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; @@ -154,6 +156,7 @@ import { ApiEntryListItem, ApiType } from '@datacat/shared'; }) export class CatalogComponent implements OnInit { private apiService = inject(ApiService); + private destroyRef = inject(DestroyRef); apis = signal([]); loading = signal(false); @@ -173,9 +176,9 @@ export class CatalogComponent implements OnInit { private searchTimer: ReturnType | null = null; constructor() { - /* Rechargement si page change */ + /* Rechargement si page change (lecture du signal = dépendance de l'effet) */ effect(() => { - const _ = this.page(); + this.page(); this.loadApis(); }); } @@ -221,6 +224,7 @@ export class CatalogComponent implements OnInit { page: this.page(), limit: this.limit, }) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: (res) => { this.apis.set(res.items); diff --git a/front-public/src/app/pages/upload/upload.component.html b/front-public/src/app/pages/upload/upload.component.html new file mode 100644 index 0000000..df817ed --- /dev/null +++ b/front-public/src/app/pages/upload/upload.component.html @@ -0,0 +1,472 @@ +
+ + +

Importer une API

+ + +
+ @for (title of stepTitles; track $index) { +
+
+ @if (step() > $index + 1) { + + } @else { + {{ $index + 1 }} + } +
+ + {{ title }} + +
+ @if ($index < stepTitles.length - 1) { +
+
+ } + } +
+ + + @if (step() === 1) { +
+ + +
+ + @if (fileError()) { +
+

{{ fileError() }}

+
+ } + + @if (selectedFiles().length > 0) { +
+ @for (file of selectedFiles(); track file.name) { +
+ + {{ file.name }} + ({{ (file.size / 1024).toFixed(1) }} Ko) + + @if (file.name === detectedMainFilename()) { + Principal + } @else if (selectedFiles().length > 1) { + Secondaire + } +
+ } +
+ + @if (validating()) { +
+

Validation en cours...

+
+ } @else if (validationDone()) { + @if (validationErrors().length > 0) { +
+

Fichier(s) invalide(s)

+ @for (err of validationErrors(); track err.file) { +

{{ err.file ? err.file + ' : ' : '' }}{{ err.message }}

+ } +
+ } @else { +
+

Fichier(s) valide(s)

+

Le fichier a été validé avec succès.

+
+ } + } + } + +
+ + Annuler +
+ } + + + @if (step() === 2) { + + @if (fromApi(); as origin) { +
+

+ Nouvelle version de : {{ origin.title }} (v{{ origin.version }}) + — le nom et la convention sont verrouillés. +

+
+ } + +
+ + +

Titre *

+
+
+
+ + + @if (conventionInvalid()) { +

La convention est obligatoire.

+ } +
+
+
+
+ + + @if (nameInvalid()) { +

Le nom est obligatoire.

+ } +
+
+
+

Titre complet : {{ previewTitle() }}

+ + +
+ + +
+ + +
+ + + @if (providerInvalid()) { +

Le fournisseur est obligatoire.

+ } +
+ + +
+ + + @if (detectedType()) { +

Détecté automatiquement depuis le fichier YAML

+ } +
+ + +
+ + +
+ + +

Version *

+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + @if (versionAlreadyExists()) { +
+

Version déjà existante

+

+ La version {{ formValues().versionMajor }}.{{ formValues().versionMinor }}.{{ formValues().versionPatch }} + existe déjà pour cette API. Choisissez un numéro de version différent. +

+
+ } + +
+ + +
+
+ } + + + @if (step() === 3) { +
+ + +

Contact métier *

+
+
+
+ + + @if (fieldInvalid('contactFunctionalName')) { +

Obligatoire.

+ } +
+
+
+
+ + + @if (fieldInvalid('contactFunctionalEntity')) { +

Obligatoire.

+ } +
+
+
+
+ + + @if (fieldInvalid('contactFunctionalEmail')) { +

Email valide requis.

+ } +
+
+
+ + +

Contact technique *

+
+
+
+ + + @if (fieldInvalid('contactTechnicalName')) { +

Obligatoire.

+ } +
+
+
+
+ + + @if (fieldInvalid('contactTechnicalEntity')) { +

Obligatoire.

+ } +
+
+
+
+ + + @if (fieldInvalid('contactTechnicalEmail')) { +

Email valide requis.

+ } +
+
+
+ +
+ + +
+
+ } + + + @if (step() === 4) { +
+
+
+

Récapitulatif

+
+
+
Fichier{{ selectedFiles().length > 1 ? 's' : '' }}
+
+ @if (selectedFiles().length === 0) { + Aucun fichier + } @else if (selectedFiles().length === 1) { + {{ selectedFiles()[0].name }} + } @else { + @for (f of selectedFiles(); track f.name) { +
+ {{ f.name }} + @if (f.name === detectedMainFilename()) { + Principal + } +
+ } + } +
+
+
+
Type
+
{{ displayType() }}
+
+
+
Titre
+
{{ previewTitle() }}
+
+
+
Version
+
{{ metaForm.value.versionMajor }}.{{ metaForm.value.versionMinor }}.{{ metaForm.value.versionPatch }}
+
+
+
Fournisseur
+
{{ metaForm.value.provider }}
+
+ @if (metaForm.value.categoryId) { +
+
Catégorie
+
{{ selectedCategoryName() }}
+
+ } + @if (metaForm.value.description) { +
+
Description
+
{{ metaForm.value.description }}
+
+ } +
+
Contact métier
+
+ {{ metaForm.value.contactFunctionalName }}
+ {{ metaForm.value.contactFunctionalEntity }}
+ {{ metaForm.value.contactFunctionalEmail }} +
+
+
+
Contact technique
+
+ {{ metaForm.value.contactTechnicalName }}
+ {{ metaForm.value.contactTechnicalEntity }}
+ {{ metaForm.value.contactTechnicalEmail }} +
+
+
+
+
+
+ + @if (uploadError()) { +
+

Erreur lors de l'import

+

{{ uploadError() }}

+
+ } + +
+ + +
+ } +
diff --git a/front-public/src/app/pages/upload/upload.component.ts b/front-public/src/app/pages/upload/upload.component.ts index 669126d..5f717dc 100644 --- a/front-public/src/app/pages/upload/upload.component.ts +++ b/front-public/src/app/pages/upload/upload.component.ts @@ -3,503 +3,25 @@ import { signal, computed, inject, + DestroyRef, OnInit, ChangeDetectionStrategy, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router, RouterLink, ActivatedRoute } from '@angular/router'; import { ApiService } from '../../core/api.service'; import { CategoryService } from '../../core/category.service'; +import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component'; import { ApiEntry, ApiEntryListItem, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared'; @Component({ selector: 'app-upload', standalone: true, - imports: [CommonModule, ReactiveFormsModule, RouterLink], + imports: [CommonModule, ReactiveFormsModule, RouterLink, BreadcrumbComponent], changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
- - - -

Importer une API

- - -
- @for (title of stepTitles; track $index) { -
-
- @if (step() > $index + 1) { - - } @else { - {{ $index + 1 }} - } -
- - {{ title }} - -
- @if ($index < stepTitles.length - 1) { -
-
- } - } -
- - - @if (step() === 1) { -
- - -
- - @if (fileError()) { -
-

{{ fileError() }}

-
- } - - @if (selectedFiles().length > 0) { -
- @for (file of selectedFiles(); track file.name) { -
- - {{ file.name }} - ({{ (file.size / 1024).toFixed(1) }} Ko) - - @if (file.name === detectedMainFilename()) { - Principal - } @else if (selectedFiles().length > 1) { - Secondaire - } -
- } -
- - @if (validating()) { -
-

Validation en cours...

-
- } @else if (validationDone()) { - @if (validationErrors().length > 0) { -
-

Fichier(s) invalide(s)

- @for (err of validationErrors(); track err.file) { -

{{ err.file ? err.file + ' : ' : '' }}{{ err.message }}

- } -
- } @else { -
-

Fichier(s) valide(s)

-

Le fichier a été validé avec succès.

-
- } - } - } - -
- - Annuler -
- } - - - @if (step() === 2) { - - @if (fromApi(); as origin) { -
-

- Nouvelle version de : {{ origin.title }} (v{{ origin.version }}) - — le nom et la convention sont verrouillés. -

-
- } - -
- - -

Titre *

-
-
-
- - - @if (conventionInvalid()) { -

La convention est obligatoire.

- } -
-
-
-
- - - @if (nameInvalid()) { -

Le nom est obligatoire.

- } -
-
-
-

Titre complet : {{ previewTitle() }}

- - -
- - -
- - -
- - - @if (providerInvalid()) { -

Le fournisseur est obligatoire.

- } -
- - -
- - - @if (detectedType()) { -

Détecté automatiquement depuis le fichier YAML

- } -
- - -
- - -
- - -

Version *

-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
- - @if (versionAlreadyExists()) { -
-

Version déjà existante

-

- La version {{ formValues().versionMajor }}.{{ formValues().versionMinor }}.{{ formValues().versionPatch }} - existe déjà pour cette API. Choisissez un numéro de version différent. -

-
- } - -
- - -
-
- } - - - @if (step() === 3) { -
- - -

Contact métier *

-
-
-
- - - @if (fieldInvalid('contactFunctionalName')) { -

Obligatoire.

- } -
-
-
-
- - - @if (fieldInvalid('contactFunctionalEntity')) { -

Obligatoire.

- } -
-
-
-
- - - @if (fieldInvalid('contactFunctionalEmail')) { -

Email valide requis.

- } -
-
-
- - -

Contact technique *

-
-
-
- - - @if (fieldInvalid('contactTechnicalName')) { -

Obligatoire.

- } -
-
-
-
- - - @if (fieldInvalid('contactTechnicalEntity')) { -

Obligatoire.

- } -
-
-
-
- - - @if (fieldInvalid('contactTechnicalEmail')) { -

Email valide requis.

- } -
-
-
- -
- - -
-
- } - - - @if (step() === 4) { -
-
-
-

Récapitulatif

-
-
-
Fichier{{ selectedFiles().length > 1 ? 's' : '' }}
-
- @if (selectedFiles().length === 0) { - Aucun fichier - } @else if (selectedFiles().length === 1) { - {{ selectedFiles()[0].name }} - } @else { - @for (f of selectedFiles(); track f.name) { -
- {{ f.name }} - @if (f.name === detectedMainFilename()) { - Principal - } -
- } - } -
-
-
-
Type
-
{{ displayType() }}
-
-
-
Titre
-
{{ previewTitle() }}
-
-
-
Version
-
{{ metaForm.value.versionMajor }}.{{ metaForm.value.versionMinor }}.{{ metaForm.value.versionPatch }}
-
-
-
Fournisseur
-
{{ metaForm.value.provider }}
-
- @if (metaForm.value.categoryId) { -
-
Catégorie
-
{{ selectedCategoryName() }}
-
- } - @if (metaForm.value.description) { -
-
Description
-
{{ metaForm.value.description }}
-
- } -
-
Contact métier
-
- {{ metaForm.value.contactFunctionalName }}
- {{ metaForm.value.contactFunctionalEntity }}
- {{ metaForm.value.contactFunctionalEmail }} -
-
-
-
Contact technique
-
- {{ metaForm.value.contactTechnicalName }}
- {{ metaForm.value.contactTechnicalEntity }}
- {{ metaForm.value.contactTechnicalEmail }} -
-
-
-
-
-
- - @if (uploadError()) { -
-

Erreur lors de l'import

-

{{ uploadError() }}

-
- } - -
- - -
- } -
- `, + templateUrl: "./upload.component.html", }) export class UploadComponent implements OnInit { private fb = inject(FormBuilder); @@ -507,8 +29,16 @@ export class UploadComponent implements OnInit { private categoryService = inject(CategoryService); private router = inject(Router); private route = inject(ActivatedRoute); + private destroyRef = inject(DestroyRef); step = signal(1); + readonly breadcrumbItems: BreadcrumbItem[] = [ + { label: 'Parcourir', link: '/browse' }, + { label: 'Catalogue', link: '/catalog' }, + { label: 'Importer une API' }, + ]; + + private readonly maxFileSize = 5 * 1024 * 1024; // 5 Mo selectedFiles = signal([]); detectedType = signal<'ASYNCAPI' | 'OPENAPI' | null>(null); detectedMainFilename = signal(null); @@ -560,7 +90,7 @@ export class UploadComponent implements OnInit { }); /* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */ - private readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value }); + protected readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value }); previewTitle = computed(() => { const values = this.formValues(); @@ -628,24 +158,33 @@ export class UploadComponent implements OnInit { const preselectedCategoryId = params.get('categoryId'); const fromId = params.get('from'); - this.categoryService.list().subscribe({ - next: (res) => { - this.categories.set(res.items); - if (preselectedCategoryId) { - this.metaForm.patchValue({ categoryId: preselectedCategoryId }); - } - }, - }); + this.categoryService + .list() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (res) => { + this.categories.set(res.items); + if (preselectedCategoryId) { + this.metaForm.patchValue({ categoryId: preselectedCategoryId }); + } + }, + }); if (fromId) { - this.apiService.get(fromId).subscribe({ - next: (api) => this.prefillFromApi(api), - error: () => {}, - }); - this.apiService.getVersions(fromId).subscribe({ - next: (versions) => this.existingVersions.set(versions), - error: () => {}, - }); + this.apiService + .get(fromId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (api) => this.prefillFromApi(api), + error: () => {}, + }); + this.apiService + .getVersions(fromId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (versions) => this.existingVersions.set(versions), + error: () => {}, + }); } } @@ -706,6 +245,15 @@ export class UploadComponent implements OnInit { return; } + const tooLarge = files.find((f) => f.size > this.maxFileSize); + if (tooLarge) { + this.fileError.set( + `Fichier trop volumineux (${tooLarge.name}). Taille max : ${this.maxFileSize / (1024 * 1024)} Mo.`, + ); + this.selectedFiles.set([]); + return; + } + this.selectedFiles.set(files); /* Détection côté client du fichier principal (pré-remplissage du type) */ @@ -748,10 +296,13 @@ export class UploadComponent implements OnInit { formData.append('files', f); } - this.apiService.validate(formData).subscribe({ - next: (result) => { - this.validating.set(false); - this.validationDone.set(true); + this.apiService + .validate(formData) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (result) => { + this.validating.set(false); + this.validationDone.set(true); if (!result.valid) { this.validationErrors.set(result.errors); /* Ne pas réactiver le type s'il est verrouillé par fromApi */ @@ -826,16 +377,19 @@ export class UploadComponent implements OnInit { formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? ''); formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? ''); - this.apiService.upload(formData).subscribe({ - next: (entry) => { - this.uploading.set(false); - this.router.navigate(['/catalog', entry.id]); - }, - error: (err) => { - this.uploading.set(false); - this.uploadError.set(err.error?.message ?? "Erreur lors de l'import"); - }, - }); + this.apiService + .upload(formData) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (entry) => { + this.uploading.set(false); + this.router.navigate(['/catalog', entry.id]); + }, + error: (err) => { + this.uploading.set(false); + this.uploadError.set(err.error?.message ?? "Erreur lors de l'import"); + }, + }); } private async detectMainFileClient(files: File[]): Promise { diff --git a/front-public/src/app/shared/breadcrumb/breadcrumb.component.ts b/front-public/src/app/shared/breadcrumb/breadcrumb.component.ts new file mode 100644 index 0000000..17a854d --- /dev/null +++ b/front-public/src/app/shared/breadcrumb/breadcrumb.component.ts @@ -0,0 +1,37 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +/** Un maillon du fil d'Ariane. `link` absent => élément courant (aria-current). */ +export interface BreadcrumbItem { + label: string; + link?: string | unknown[]; +} + +/** + * Fil d'Ariane DSFR générique (présentationnel). Chaque page construit sa liste + * d'items et la passe ; le dernier item sans `link` est marqué comme courant. + */ +@Component({ + selector: 'app-breadcrumb', + standalone: true, + imports: [RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, +}) +export class BreadcrumbComponent { + items = input.required(); +} diff --git a/front-public/src/app/shared/category-delete-modal/category-delete-modal.component.ts b/front-public/src/app/shared/category-delete-modal/category-delete-modal.component.ts index d902f38..7c3eebb 100644 --- a/front-public/src/app/shared/category-delete-modal/category-delete-modal.component.ts +++ b/front-public/src/app/shared/category-delete-modal/category-delete-modal.component.ts @@ -1,4 +1,5 @@ -import { Component, signal, inject, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { Component, signal, inject, DestroyRef, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { CategoryService } from '../../core/category.service'; import { CategoryWithCounts } from '@datacat/shared'; @@ -49,6 +50,7 @@ import { CategoryWithCounts } from '@datacat/shared'; }) export class CategoryDeleteModalComponent { private categoryService = inject(CategoryService); + private destroyRef = inject(DestroyRef); category = input.required(); confirmed = output(); @@ -65,15 +67,18 @@ export class CategoryDeleteModalComponent { const cat = this.category(); this.deleteLoading.set(true); this.deleteError.set(null); - this.categoryService.delete(cat.id).subscribe({ - next: () => { - this.deleteLoading.set(false); - this.confirmed.emit(); - }, - error: (err: { error?: { message?: string } }) => { - this.deleteLoading.set(false); - this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.'); - }, - }); + this.categoryService + .delete(cat.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.deleteLoading.set(false); + this.confirmed.emit(); + }, + error: (err: { error?: { message?: string } }) => { + this.deleteLoading.set(false); + this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.'); + }, + }); } } diff --git a/front-public/src/app/shared/category-form-modal/category-form-modal.component.ts b/front-public/src/app/shared/category-form-modal/category-form-modal.component.ts index 9d58d20..a8d14cf 100644 --- a/front-public/src/app/shared/category-form-modal/category-form-modal.component.ts +++ b/front-public/src/app/shared/category-form-modal/category-form-modal.component.ts @@ -3,11 +3,13 @@ import { signal, computed, inject, + DestroyRef, OnInit, ChangeDetectionStrategy, input, output, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { CategoryService } from '../../core/category.service'; import { Category } from '@datacat/shared'; @@ -79,6 +81,7 @@ import { Category } from '@datacat/shared'; }) export class CategoryFormModalComponent implements OnInit { private categoryService = inject(CategoryService); + private destroyRef = inject(DestroyRef); mode = input<'create' | 'edit'>('create'); category = input(null); @@ -133,10 +136,13 @@ export class CategoryFormModalComponent implements OnInit { this.formDescription.set(cat.description ?? ''); } else { this.formParentId.set(this.parentId()); - this.categoryService.list().subscribe({ - next: (res) => this.allCategories.set(res.items), - error: () => this.allCategories.set([]), - }); + this.categoryService + .list() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (res) => this.allCategories.set(res.items), + error: () => this.allCategories.set([]), + }); } } @@ -159,7 +165,7 @@ export class CategoryFormModalComponent implements OnInit { ? this.categoryService.create({ name, description, parentId: this.formParentId() ?? undefined }) : this.categoryService.update(this.editingCategoryId()!, { name, description }); - obs.subscribe({ + obs.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: () => { this.formLoading.set(false); this.saved.emit(); diff --git a/front-public/src/environments/environment.prod.ts b/front-public/src/environments/environment.prod.ts new file mode 100644 index 0000000..9934449 --- /dev/null +++ b/front-public/src/environments/environment.prod.ts @@ -0,0 +1,4 @@ +export const environment = { + production: true, + apiBaseUrl: '/api', +}; diff --git a/front-public/src/environments/environment.ts b/front-public/src/environments/environment.ts new file mode 100644 index 0000000..54f6495 --- /dev/null +++ b/front-public/src/environments/environment.ts @@ -0,0 +1,5 @@ +export const environment = { + production: false, + // Préfixe des appels API (proxifié vers le back en dev, cf. proxy.conf*.json). + apiBaseUrl: '/api', +}; diff --git a/front-public/vitest-setup.ts b/front-public/vitest-setup.ts new file mode 100644 index 0000000..6c64aec --- /dev/null +++ b/front-public/vitest-setup.ts @@ -0,0 +1,3 @@ +// Charge le compilateur Angular pour permettre le fallback JIT en test +// (sinon l'import de symboles @angular/* partiellement compilés échoue). +import '@angular/compiler'; diff --git a/front-public/vitest.config.ts b/front-public/vitest.config.ts new file mode 100644 index 0000000..ce1398d --- /dev/null +++ b/front-public/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +// Vitest pour les tests de logique/services purs (cohérent avec le back). +// Les tests de composants avec TestBed nécessiteraient un plugin Angular dédié +// (ex. @analogjs/vitest-angular) — hors périmètre pour l'instant. +export default defineConfig({ + test: { + globals: true, + environment: 'node', + setupFiles: ['./vitest-setup.ts'], + include: ['src/**/*.spec.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ce2efc..939c9bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,12 +101,15 @@ importers: '@types/uuid': specifier: ^10.0.0 version: 10.0.0 + '@vitest/coverage-v8': + specifier: ^3.2.6 + version: 3.2.6(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@1.21.7)(jsdom@29.1.1(@noble/hashes@1.8.0))(less@4.2.2)(sass@1.99.0)(terser@5.47.1)(yaml@2.9.0)) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(less@4.2.2)(sass@1.99.0)(terser@5.47.1)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@1.21.7)(jsdom@29.1.1(@noble/hashes@1.8.0))(less@4.2.2)(sass@1.99.0)(terser@5.47.1)(yaml@2.9.0) front-public: dependencies: @@ -134,6 +137,12 @@ importers: '@angular/router': specifier: ^19.0.0 version: 19.2.22(@angular/common@19.2.22(@angular/core@19.2.22(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.22(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.22(@angular/animations@19.2.22(@angular/common@19.2.22(@angular/core@19.2.22(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.22(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.22(@angular/core@19.2.22(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.22(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) + '@datacat/shared': + specifier: workspace:* + version: link:../shared + '@gouvfr/dsfr': + specifier: ^1.13.0 + version: 1.14.4 rxjs: specifier: ^7.8.0 version: 7.8.2 @@ -153,18 +162,33 @@ importers: '@angular/compiler-cli': specifier: ^19.0.0 version: 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3) - '@gouvfr/dsfr': - specifier: ^1.13.0 - version: 1.14.4 + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 '@types/node': specifier: ^22.0.0 version: 22.19.19 + angular-eslint: + specifier: ^19.8.1 + version: 19.8.1(chokidar@4.0.3)(eslint@9.39.4(jiti@1.21.7))(typescript-eslint@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(typescript@5.8.3) + eslint: + specifier: ^9.39.4 + version: 9.39.4(jiti@1.21.7) + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@1.8.0) sass: specifier: ^1.83.0 version: 1.99.0 typescript: specifier: ~5.8.3 version: 5.8.3 + typescript-eslint: + specifier: ^8.62.0 + version: 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@1.21.7)(jsdom@29.1.1(@noble/hashes@1.8.0))(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0) shared: {} @@ -267,6 +291,47 @@ packages: resolution: {integrity: sha512-3PmlXbegeMsCNhK26jI068Rr8wArGGMWjvWd9IVHaFhXxkcc3xHjCiqYaz8uRj9Rz+Ra4ToC7qU/Rwgy72qJ9A==} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular-eslint/builder@19.8.1': + resolution: {integrity: sha512-NOMkw0xgDoDVCLkL5nkkvdd3ouDYkOGqtEmabTR7N4/kQnk1R4coOTWGCqAgMXCFdxlyjuxquDwuJ+yni81pRg==} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + + '@angular-eslint/bundled-angular-compiler@19.8.1': + resolution: {integrity: sha512-WXi1YbSs7SIQo48u+fCcc5Nt14/T4QzYQPLZUnjtsUXPgQG7ZoahhcGf7PPQ+n0V3pSopHOlSHwqK+tSsYK87A==} + + '@angular-eslint/eslint-plugin-template@19.8.1': + resolution: {integrity: sha512-0ZVQldndLrDfB0tzFe/uIwvkUcakw8qGxvkEU0l7kSbv/ngNQ/qrkRi7P64otB15inIDUNZI2jtmVat52dqSfQ==} + peerDependencies: + '@angular-eslint/template-parser': 19.8.1 + '@typescript-eslint/types': ^7.11.0 || ^8.0.0 + '@typescript-eslint/utils': ^7.11.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + + '@angular-eslint/eslint-plugin@19.8.1': + resolution: {integrity: sha512-wZEBMPwD2TRhifG751hcj137EMIEaFmsxRB2EI+vfINCgPnFGSGGOHXqi8aInn9fXqHs7VbXkAzXYdBsvy1m4Q==} + peerDependencies: + '@typescript-eslint/utils': ^7.11.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + + '@angular-eslint/schematics@19.8.1': + resolution: {integrity: sha512-MKzfO3puOCuQFgP8XDUkEr5eaqcCQLAdYLLMcywEO/iRs1eRHL46+rkW+SjDp1cUqlxKtu+rLiTYr0T/O4fi9Q==} + + '@angular-eslint/template-parser@19.8.1': + resolution: {integrity: sha512-pQiOg+se1AU/ncMlnJ9V6xYnMQ84qI1BGWuJpbU6A99VTXJg90scg0+T7DWmKssR1YjP5qmmBtrZfKsHEcLW/A==} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + + '@angular-eslint/utils@19.8.1': + resolution: {integrity: sha512-gVDKYWmAjeTPtaYmddT/HS03fCebXJtrk8G1MouQIviZbHqLjap6TbVlzlkBigRzaF0WnFnrDduQslkJzEdceA==} + peerDependencies: + '@typescript-eslint/utils': ^7.11.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + '@angular/animations@19.2.22': resolution: {integrity: sha512-Sl921GvzVrZ+DRLB0neRQQfcciYi9p8bDdeTSevFO4VhvX0L1ij8EH/NJD4/UfKo6y1E5RZLOulst7zJ5VVSwg==} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0} @@ -415,6 +480,10 @@ packages: '@asamuzakjp/dom-selector@6.8.1': resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + '@asamuzakjp/generational-cache@1.0.1': resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1254,6 +1323,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} @@ -1835,6 +1908,44 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1874,10 +1985,30 @@ packages: peerDependencies: react: ^16.8.6 || ^17.0.0 || ^18.0.0 + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + '@humanwhocodes/momoa@2.0.4': resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} engines: {node: '>=10.10.0'} + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@hyperjump/json-pointer@0.9.8': resolution: {integrity: sha512-6D6okhpH5VOS3oSYUtxu8nClsOcp59aC+sS06/tCxEta4T5Gk1yaycLiCkG8kE9eh+9AJUHsvQEJJrWBfLOjvA==} @@ -4019,6 +4150,65 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript-eslint/eslint-plugin@8.62.0': + resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.62.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.62.0': + resolution: {integrity: sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.62.0': + resolution: {integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.62.0': + resolution: {integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.62.0': + resolution: {integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.62.0': + resolution: {integrity: sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.62.0': + resolution: {integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.62.0': + resolution: {integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.62.0': + resolution: {integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.62.0': + resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} @@ -4031,6 +4221,15 @@ packages: peerDependencies: vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + '@vitest/coverage-v8@3.2.6': + resolution: {integrity: sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==} + peerDependencies: + '@vitest/browser': 3.2.6 + vitest: 3.2.6 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -4147,6 +4346,11 @@ packages: peerDependencies: acorn: ^8.14.0 + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -4234,6 +4438,13 @@ packages: resolution: {integrity: sha512-VqKTk8lX8LHVRvSOgEZDGPeEYOvrSOjlX/1PAi4el7ac8acC6/6a99HuVjfU6N1tNrHV5dU0sQDmuOjRvBf/Sw==} hasBin: true + angular-eslint@19.8.1: + resolution: {integrity: sha512-A6mPcVAXEDdJk7bKKBwd+1b/VA/xwpWWN2fExTGO1dkVNPz550LlgxBjEio9G7u4i+pD2aLrl6Cx6O+9o1iusQ==} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + typescript-eslint: ^8.0.0 + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -4324,6 +4535,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + arr-union@3.1.0: resolution: {integrity: sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==} engines: {node: '>=0.10.0'} @@ -4367,6 +4582,9 @@ packages: resolution: {integrity: sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==} engines: {node: '>=4'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -4411,6 +4629,10 @@ packages: resolution: {integrity: sha512-yOA4wFeI7ET3v32Di/sUybQ+ttP20JHSW3mxLuNGeO0uD6PPcvLrIQXSvy/rhJOWU5JrYh7U4OHplWMmtAtjMg==} engines: {node: '>=0.11'} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -5304,6 +5526,9 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -5615,11 +5840,45 @@ packages: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -5716,6 +5975,9 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-levenshtein@3.0.0: resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==} @@ -5772,6 +6034,10 @@ packages: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + file-type@21.3.4: resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} engines: {node: '>=20'} @@ -5803,6 +6069,10 @@ packages: resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} engines: {node: '>=14.16'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + find-up@6.3.0: resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5810,10 +6080,17 @@ packages: find-yarn-workspace-root@2.0.0: resolution: {integrity: sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + flat@5.0.2: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -6026,6 +6303,10 @@ packages: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} engines: {node: '>=10'} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -6135,6 +6416,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -6579,6 +6863,18 @@ packages: resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} engines: {node: '>=10'} + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterare@1.2.1: resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} engines: {node: '>=6'} @@ -6613,6 +6909,9 @@ packages: resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} engines: {node: '>=0.10.0'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -6636,6 +6935,15 @@ packages: canvas: optional: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsep@1.4.0: resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} engines: {node: '>= 10.16.0'} @@ -6829,6 +7137,10 @@ packages: resolution: {integrity: sha512-Yuraz7QnMX/JENJU1HA6UtdsbhRzoSFnGpVGVryjQgHtl2s/YmVgmNYkVs5yzVZ9aAvQR9wPBUH3lG755ylxGA==} hasBin: true + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + libphonenumber-js@1.13.2: resolution: {integrity: sha512-S3kmBrptp3yRTm83NUcHy9g1vbwiWMzI8WvY22+koBJ6zkRteLnedBL2VX0MIAGwx2yiyxX4J85pceZyQ6ffgg==} @@ -6875,6 +7187,10 @@ packages: resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} engines: {node: '>= 12.13.0'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + locate-path@7.2.0: resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -6885,6 +7201,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.topath@4.5.2: resolution: {integrity: sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg==} @@ -6953,10 +7272,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -7288,6 +7614,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + natural-orderby@2.0.3: resolution: {integrity: sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==} @@ -7740,6 +8069,10 @@ packages: openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -7758,10 +8091,18 @@ packages: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + p-limit@4.0.0: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-locate@6.0.0: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -7868,6 +8209,10 @@ packages: path-equal@1.2.5: resolution: {integrity: sha512-i73IctDr3F2W+bsOWDyyVm/lqsXO47aY9nsFZUjTT/aljSbkxHxxCoyZ9UUrM8jK0JVod+An+rl48RCsvWM+9g==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-exists@5.0.0: resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -8137,6 +8482,10 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -8700,6 +9049,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.0: resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} @@ -9059,6 +9413,10 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -9227,6 +9585,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -9353,6 +9715,12 @@ packages: ts-algebra@1.2.2: resolution: {integrity: sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -9399,6 +9767,10 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -9491,6 +9863,13 @@ packages: typeorm-aurora-data-api-driver: optional: true + typescript-eslint@8.62.0: + resolution: {integrity: sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript-json-schema@0.58.1: resolution: {integrity: sha512-EcmquhfGEmEJOAezLZC6CzY0rPNzfXuky+Z3zoXULEEncW8e13aAjmC2r8ppT1bvvDekJj1TJ4xVhOdkjYtkUA==} hasBin: true @@ -10014,6 +10393,10 @@ packages: resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} engines: {node: '>= 12.0.0'} + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -10137,6 +10520,10 @@ packages: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -10343,6 +10730,68 @@ snapshots: transitivePeerDependencies: - chokidar + '@angular-eslint/builder@19.8.1(chokidar@4.0.3)(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@angular-devkit/architect': 0.1902.26(chokidar@4.0.3) + '@angular-devkit/core': 19.2.26(chokidar@4.0.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.8.3 + transitivePeerDependencies: + - chokidar + + '@angular-eslint/bundled-angular-compiler@19.8.1': {} + + '@angular-eslint/eslint-plugin-template@19.8.1(@angular-eslint/template-parser@19.8.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(@typescript-eslint/types@8.62.0)(@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@angular-eslint/bundled-angular-compiler': 19.8.1 + '@angular-eslint/template-parser': 19.8.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@angular-eslint/utils': 19.8.1(@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/utils': 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + aria-query: 5.3.2 + axobject-query: 4.1.0 + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.8.3 + + '@angular-eslint/eslint-plugin@19.8.1(@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@angular-eslint/bundled-angular-compiler': 19.8.1 + '@angular-eslint/utils': 19.8.1(@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/utils': 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.8.3 + + '@angular-eslint/schematics@19.8.1(@angular-eslint/template-parser@19.8.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(@typescript-eslint/types@8.62.0)(@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(chokidar@4.0.3)(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@angular-devkit/core': 19.2.26(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.26(chokidar@4.0.3) + '@angular-eslint/eslint-plugin': 19.8.1(@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@angular-eslint/eslint-plugin-template': 19.8.1(@angular-eslint/template-parser@19.8.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(@typescript-eslint/types@8.62.0)(@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + ignore: 7.0.5 + semver: 7.7.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - '@angular-eslint/template-parser' + - '@typescript-eslint/types' + - '@typescript-eslint/utils' + - chokidar + - eslint + - typescript + + '@angular-eslint/template-parser@19.8.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@angular-eslint/bundled-angular-compiler': 19.8.1 + eslint: 9.39.4(jiti@1.21.7) + eslint-scope: 8.4.0 + typescript: 5.8.3 + + '@angular-eslint/utils@19.8.1(@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@angular-eslint/bundled-angular-compiler': 19.8.1 + '@typescript-eslint/utils': 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.8.3 + '@angular/animations@19.2.22(@angular/common@19.2.22(@angular/core@19.2.22(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.22(rxjs@7.8.2)(zone.js@0.15.1))': dependencies: '@angular/common': 19.2.22(@angular/core@19.2.22(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) @@ -10538,6 +10987,14 @@ snapshots: is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.0 + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + '@asamuzakjp/generational-cache@1.0.1': {} '@asamuzakjp/nwsapi@2.3.9': {} @@ -11422,7 +11879,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-annotate-as-pure@7.29.7': dependencies: @@ -11505,7 +11962,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -11552,7 +12009,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.28.6': {} @@ -11597,7 +12054,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -11621,7 +12078,7 @@ snapshots: dependencies: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12675,6 +13132,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + '@borewit/text-codec@0.2.2': {} '@braintree/sanitize-url@7.1.2': {} @@ -13034,6 +13493,52 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@8.1.1) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': optionalDependencies: '@noble/hashes': 1.8.0 @@ -13059,8 +13564,24 @@ snapshots: dependencies: react: 18.2.0 + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + '@humanwhocodes/momoa@2.0.4': {} + '@humanwhocodes/retry@0.4.3': {} + '@hyperjump/json-pointer@0.9.8': dependencies: just-curry-it: 5.3.0 @@ -13928,15 +14449,15 @@ snapshots: '@npmcli/fs@2.1.2': dependencies: '@gar/promisify': 1.1.3 - semver: 7.8.0 + semver: 7.8.1 '@npmcli/fs@4.0.0': dependencies: - semver: 7.7.1 + semver: 7.8.1 '@npmcli/fs@5.0.0': dependencies: - semver: 7.8.0 + semver: 7.8.1 '@npmcli/git@3.0.2': dependencies: @@ -13947,7 +14468,7 @@ snapshots: proc-log: 2.0.1 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.8.0 + semver: 7.8.1 which: 2.0.2 transitivePeerDependencies: - bluebird @@ -13961,7 +14482,7 @@ snapshots: proc-log: 4.2.0 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.8.0 + semver: 7.8.1 which: 4.0.0 transitivePeerDependencies: - bluebird @@ -13985,7 +14506,7 @@ snapshots: lru-cache: 11.5.0 npm-pick-manifest: 11.0.3 proc-log: 6.1.0 - semver: 7.8.0 + semver: 7.8.1 which: 6.0.1 '@npmcli/installed-package-contents@1.0.7': @@ -14040,7 +14561,7 @@ snapshots: json-parse-even-better-errors: 5.0.0 pacote: 21.5.0 proc-log: 6.1.0 - semver: 7.8.0 + semver: 7.8.1 transitivePeerDependencies: - supports-color @@ -14094,7 +14615,7 @@ snapshots: hosted-git-info: 9.0.3 json-parse-even-better-errors: 5.0.0 proc-log: 6.1.0 - semver: 7.8.0 + semver: 7.8.1 spdx-expression-parse: 4.0.0 '@npmcli/promise-spawn@3.0.0': @@ -15623,6 +16144,97 @@ snapshots: '@types/node': 22.19.19 optional: true + '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/type-utils': 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/utils': 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.62.0 + eslint: 9.39.4(jiti@1.21.7) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.62.0 + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.62.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.8.3) + '@typescript-eslint/types': 8.62.0 + debug: 4.4.3(supports-color@8.1.1) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.62.0': + dependencies: + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + + '@typescript-eslint/tsconfig-utils@8.62.0(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + + '@typescript-eslint/type-utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 9.39.4(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.62.0': {} + + '@typescript-eslint/typescript-estree@8.62.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.62.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.8.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 10.2.5 + semver: 7.8.1 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.8.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.62.0': + dependencies: + '@typescript-eslint/types': 8.62.0 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.1': {} '@upsetjs/venn.js@2.0.0': @@ -15638,6 +16250,25 @@ snapshots: dependencies: vite: 7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0) + '@vitest/coverage-v8@3.2.6(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@1.21.7)(jsdom@29.1.1(@noble/hashes@1.8.0))(less@4.2.2)(sass@1.99.0)(terser@5.47.1)(yaml@2.9.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@1.21.7)(jsdom@29.1.1(@noble/hashes@1.8.0))(less@4.2.2)(sass@1.99.0)(terser@5.47.1)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -15646,6 +16277,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0) + '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.47.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 @@ -15788,6 +16427,10 @@ snapshots: dependencies: acorn: 8.16.0 + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-walk@8.3.5: dependencies: acorn: 8.16.0 @@ -15889,6 +16532,24 @@ snapshots: transitivePeerDependencies: - encoding + angular-eslint@19.8.1(chokidar@4.0.3)(eslint@9.39.4(jiti@1.21.7))(typescript-eslint@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(typescript@5.8.3): + dependencies: + '@angular-devkit/core': 19.2.26(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.26(chokidar@4.0.3) + '@angular-eslint/builder': 19.8.1(chokidar@4.0.3)(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@angular-eslint/eslint-plugin': 19.8.1(@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@angular-eslint/eslint-plugin-template': 19.8.1(@angular-eslint/template-parser@19.8.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(@typescript-eslint/types@8.62.0)(@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@angular-eslint/schematics': 19.8.1(@angular-eslint/template-parser@19.8.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(@typescript-eslint/types@8.62.0)(@typescript-eslint/utils@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(chokidar@4.0.3)(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@angular-eslint/template-parser': 19.8.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/utils': 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.8.3 + typescript-eslint: 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + transitivePeerDependencies: + - chokidar + - supports-color + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -15971,6 +16632,8 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.2: {} + arr-union@3.1.0: {} array-buffer-byte-length@1.0.2: @@ -16012,6 +16675,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astral-regex@2.0.0: {} astring@1.9.0: {} @@ -16052,6 +16721,8 @@ snapshots: avsc@5.7.9: {} + axobject-query@4.1.0: {} + b4a@1.8.1: {} babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.105.0(postcss@8.5.12)): @@ -16297,7 +16968,7 @@ snapshots: builtins@5.1.0: dependencies: - semver: 7.8.0 + semver: 7.8.1 bundle-name@4.1.0: dependencies: @@ -17087,6 +17758,8 @@ snapshots: deep-extend@0.6.0: {} + deep-is@0.1.4: {} + deepmerge@4.3.1: {} default-browser-id@5.0.1: {} @@ -17487,8 +18160,70 @@ snapshots: esrecurse: 4.3.0 estraverse: 4.3.0 + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@8.1.1) + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -17636,6 +18371,8 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-levenshtein@2.0.6: {} + fast-levenshtein@3.0.0: dependencies: fastest-levenshtein: 1.0.16 @@ -17692,6 +18429,10 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + file-type@21.3.4: dependencies: '@tokenizer/inflate': 0.4.1 @@ -17745,6 +18486,11 @@ snapshots: common-path-prefix: 3.0.0 pkg-dir: 7.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + find-up@6.3.0: dependencies: locate-path: 7.2.0 @@ -17754,8 +18500,15 @@ snapshots: dependencies: micromatch: 4.0.8 + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + flat@5.0.2: {} + flatted@3.4.2: {} + fn.name@1.1.0: {} follow-redirects@1.16.0(debug@4.4.3): @@ -17989,6 +18742,8 @@ snapshots: dependencies: ini: 2.0.0 + globals@14.0.0: {} + globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -18113,6 +18868,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-escaper@2.0.2: {} + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -18550,6 +19307,25 @@ snapshots: transitivePeerDependencies: - supports-color + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterare@1.2.1: {} jackspeak@3.4.3: @@ -18580,6 +19356,8 @@ snapshots: js-levenshtein@1.1.6: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -18620,6 +19398,32 @@ snapshots: - '@noble/hashes' - supports-color + jsdom@29.1.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.0 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.27.1 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsep@1.4.0: {} jsesc@3.1.0: {} @@ -18777,6 +19581,11 @@ snapshots: levenshtein-edit-distance@2.0.5: {} + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + libphonenumber-js@1.13.2: {} license-webpack-plugin@4.0.2(webpack@5.105.0(postcss@8.5.12)): @@ -18828,6 +19637,10 @@ snapshots: loader-utils@3.3.1: {} + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + locate-path@7.2.0: dependencies: p-locate: 6.0.0 @@ -18836,6 +19649,8 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.merge@4.6.2: {} + lodash.topath@4.5.2: {} lodash@4.17.23: {} @@ -18904,12 +19719,22 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + make-dir@2.1.0: dependencies: pify: 4.0.1 semver: 5.7.2 optional: true + make-dir@4.0.0: + dependencies: + semver: 7.8.1 + make-error@1.3.6: {} make-fetch-happen@10.2.1: @@ -19293,6 +20118,8 @@ snapshots: nanoid@3.3.12: {} + natural-compare@1.4.0: {} + natural-orderby@2.0.3: {} ncp@0.4.2: {} @@ -19400,7 +20227,7 @@ snapshots: make-fetch-happen: 14.0.3 nopt: 8.1.0 proc-log: 5.0.0 - semver: 7.7.1 + semver: 7.8.1 tar: 7.5.15 tinyglobby: 0.2.16 which: 5.0.0 @@ -19414,7 +20241,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.8.0 + semver: 7.8.1 tar: 7.5.15 tinyglobby: 0.2.16 undici: 6.26.0 @@ -19430,7 +20257,7 @@ snapshots: nopt: 6.0.0 npmlog: 6.0.2 rimraf: 3.0.2 - semver: 7.8.0 + semver: 7.8.1 tar: 6.2.1 which: 2.0.2 transitivePeerDependencies: @@ -19470,7 +20297,7 @@ snapshots: dependencies: hosted-git-info: 5.2.1 is-core-module: 2.16.2 - semver: 7.8.0 + semver: 7.8.1 validate-npm-package-license: 3.0.4 normalize-package-data@6.0.2: @@ -19507,7 +20334,7 @@ snapshots: npm-install-checks@6.3.0: dependencies: - semver: 7.8.0 + semver: 7.8.1 npm-install-checks@7.1.2: dependencies: @@ -19515,7 +20342,7 @@ snapshots: npm-install-checks@8.0.0: dependencies: - semver: 7.8.0 + semver: 7.8.1 npm-normalize-package-bin@1.0.1: {} @@ -19545,7 +20372,7 @@ snapshots: dependencies: hosted-git-info: 9.0.3 proc-log: 6.1.0 - semver: 7.8.0 + semver: 7.8.1 validate-npm-package-name: 7.0.2 npm-package-arg@9.1.2: @@ -19583,7 +20410,7 @@ snapshots: npm-install-checks: 8.0.0 npm-normalize-package-bin: 5.0.0 npm-package-arg: 13.0.2 - semver: 7.8.0 + semver: 7.8.1 npm-pick-manifest@7.0.2: dependencies: @@ -19597,7 +20424,7 @@ snapshots: npm-install-checks: 6.3.0 npm-normalize-package-bin: 3.0.1 npm-package-arg: 11.0.3 - semver: 7.8.0 + semver: 7.8.1 npm-registry-fetch@13.3.1: dependencies: @@ -19784,6 +20611,15 @@ snapshots: openapi-types@12.1.3: {} + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + ora@5.4.1: dependencies: bl: 4.1.0 @@ -19809,10 +20645,18 @@ snapshots: p-cancelable@3.0.0: {} + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + p-limit@4.0.0: dependencies: yocto-queue: 1.2.2 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-locate@6.0.0: dependencies: p-limit: 4.0.0 @@ -19998,6 +20842,8 @@ snapshots: path-equal@1.2.5: {} + path-exists@4.0.0: {} + path-exists@5.0.0: {} path-expression-matcher@1.5.0: {} @@ -20249,6 +21095,8 @@ snapshots: dependencies: xtend: 4.0.2 + prelude-ls@1.2.1: {} + prettier@2.8.8: {} printable-characters@1.0.42: {} @@ -20928,6 +21776,8 @@ snapshots: semver@7.7.1: {} + semver@7.7.2: {} + semver@7.8.0: {} semver@7.8.1: {} @@ -21417,6 +22267,8 @@ snapshots: strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -21643,6 +22495,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + text-decoder@1.2.7: dependencies: b4a: 1.8.1 @@ -21748,6 +22606,10 @@ snapshots: ts-algebra@1.2.2: {} + ts-api-utils@2.5.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + ts-dedent@2.2.0: {} ts-interface-checker@0.1.13: {} @@ -21863,6 +22725,10 @@ snapshots: dependencies: safe-buffer: 5.2.1 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-fest@0.21.3: {} type-is@1.6.18: @@ -21939,6 +22805,17 @@ snapshots: - babel-plugin-macros - supports-color + typescript-eslint@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/parser': 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.62.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.8.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + typescript-json-schema@0.58.1: dependencies: '@types/json-schema': 7.0.15 @@ -22117,6 +22994,27 @@ snapshots: vary@1.1.2: {} + vite-node@3.2.4(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.47.1)(yaml@2.9.0): dependencies: cac: 6.7.14 @@ -22189,7 +23087,50 @@ snapshots: terser: 5.47.1 yaml: 2.9.0 - vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(less@4.2.2)(sass@1.99.0)(terser@5.47.1)(yaml@2.9.0): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@1.21.7)(jsdom@29.1.1(@noble/hashes@1.8.0))(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@8.1.1) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.3(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.19.19)(jiti@1.21.7)(less@4.2.2)(sass@1.99.0)(terser@5.39.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 22.19.19 + jsdom: 29.1.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.19)(jiti@1.21.7)(jsdom@29.1.1(@noble/hashes@1.8.0))(less@4.2.2)(sass@1.99.0)(terser@5.47.1)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -22217,7 +23158,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.13 '@types/node': 22.19.19 - jsdom: 28.1.0(@noble/hashes@1.8.0) + jsdom: 29.1.1(@noble/hashes@1.8.0) transitivePeerDependencies: - jiti - less @@ -22549,6 +23490,8 @@ snapshots: triple-beam: 1.4.1 winston-transport: 4.9.0 + word-wrap@1.2.5: {} + wordwrap@1.0.0: {} wrap-ansi@6.2.0: @@ -22645,6 +23588,8 @@ snapshots: yn@3.1.1: {} + yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} yoctocolors-cjs@2.1.3: {} diff --git a/shared/README.md b/shared/README.md new file mode 100644 index 0000000..456bcc8 --- /dev/null +++ b/shared/README.md @@ -0,0 +1,31 @@ +# @datacat/shared + +Types et constantes partagés entre le **back** (NestJS) et le **front** (Angular). + +## Contenu + +- `src/types/` — interfaces et types (`ApiEntry`, `Category`, réponses d'API…). +- `src/schemas/api-entry.schema.ts` — **source de vérité** des énumérations + (`API_TYPES`, `API_STATUSES`, `API_CONVENTIONS`). Les types `ApiType` / + `ApiStatus` / `ApiConvention` en sont **dérivés** (`typeof X[number]`), et la + validation back (`@IsIn`) réutilise ces mêmes constantes. Ajouter une valeur + dans le tableau suffit donc à propager type + validation. + +## Double build (piège récurrent) + +Ce paquet est consommé de **deux façons différentes** : + +| Consommateur | Mécanisme | Config | +|--------------|-----------|--------| +| **front** (Angular) | alias TypeScript vers la **source** | `tsconfig.json` (ES2022) + `paths` dans `front-public/tsconfig.json` | +| **back** (NestJS) | paquet **compilé** (`dist/`) en CommonJS | `tsconfig.build.json` (CommonJS) → `dist/` | + +⚠️ Le back lit `dist/`, pas la source. Après toute modification de `shared`, il +faut **rebuild** avant que le back voie le changement : + +```sh +pnpm --filter @datacat/shared build +# (le docker-compose le fait déjà : voir infra/docker-compose.yml, étape tsc) +``` + +Le front, lui, voit la source directement (pas de rebuild nécessaire). diff --git a/shared/package.json b/shared/package.json index d346909..16dc6b9 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,9 +1,17 @@ { "name": "@datacat/shared", "version": "0.1.0", + "description": "Types et constantes partagés entre le back (NestJS) et le front (Angular) de Datacat.", + "license": "UNLICENSED", "private": true, "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, "scripts": { "build": "tsc --project tsconfig.build.json" } diff --git a/shared/src/schemas/api-entry.schema.ts b/shared/src/schemas/api-entry.schema.ts index 762be48..d2e0532 100644 --- a/shared/src/schemas/api-entry.schema.ts +++ b/shared/src/schemas/api-entry.schema.ts @@ -1,3 +1,7 @@ +// Source de vérité unique pour les énumérations d'API. +// Les types (`ApiType`, `ApiStatus`, `ApiConvention`) sont dérivés de ces +// constantes dans `../types/api-entry.types.ts`, et la validation back +// (class-validator `@IsIn`) les réutilise — pas de duplication. export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const; -export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const; +export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR', 'NO_YAML'] as const; export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const; diff --git a/shared/src/types/api-entry.types.ts b/shared/src/types/api-entry.types.ts index 27ee89c..47a8c30 100644 --- a/shared/src/types/api-entry.types.ts +++ b/shared/src/types/api-entry.types.ts @@ -1,8 +1,12 @@ -export type ApiType = 'ASYNCAPI' | 'OPENAPI'; +import { API_TYPES, API_STATUSES, API_CONVENTIONS } from '../schemas/api-entry.schema'; -export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR' | 'NO_YAML'; +// Types dérivés des constantes (source de vérité dans api-entry.schema.ts) : +// ajouter une valeur dans le tableau suffit à mettre à jour le type. +export type ApiType = (typeof API_TYPES)[number]; -export type ApiConvention = 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE'; +export type ApiStatus = (typeof API_STATUSES)[number]; + +export type ApiConvention = (typeof API_CONVENTIONS)[number]; export const API_CONVENTION_LABELS: Record = { CONSULTER: 'Consulter',