feat: refonte métadonnées API + UX dossiers et import

- Nouveau schéma api_entries : convention+name, version X.X.X, provider,
  contacts splitées (name/entity/email), suppression des anciens champs
- Formulaire upload étape 2 : convention+nom, version 3 champs, fournisseur,
  contacts métier et technique obligatoires ; type détecté auto → select désactivé
- Validation YAML : redocly.yaml (extends: spec), support noms avec espaces,
  extraction métadonnées (name, version, description) depuis info YAML
- Dossiers : modale création avec champ Emplacement pré-initialisé depuis
  le contexte de navigation, options └ avec indentation par profondeur
- Import depuis un dossier : pré-sélection de la catégorie via queryParam
- Recherche étendue : name, provider, description, contacts
- Résolution @datacat/shared via workspace:* + compilation CJS avant NestJS

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
z3n
2026-06-11 13:38:14 +00:00
parent ef3884c68d
commit e85696079c
20 changed files with 911 additions and 519 deletions

View File

@@ -12,6 +12,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@datacat/shared": "workspace:*",
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",

3
back/redocly.yaml Normal file
View File

@@ -0,0 +1,3 @@
# Validation structurelle uniquement — pas de règles de style
extends:
- spec

View File

@@ -1,16 +1,16 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { ApiType, ApiStatus } from '@datacat/shared'; import { ApiType, ApiStatus, ApiConvention } from '@datacat/shared';
@Entity('api_entries') @Entity('api_entries')
export class ApiEntryEntity { export class ApiEntryEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@Column() @Column({ type: 'varchar', length: 30, default: 'CONSULTER' })
title!: string; convention!: ApiConvention;
@Column() @Column({ type: 'varchar', length: 255, default: '' })
version!: string; name!: string;
@Column({ nullable: true, type: 'text' }) @Column({ nullable: true, type: 'text' })
description!: string | null; description!: string | null;
@@ -18,6 +18,18 @@ export class ApiEntryEntity {
@Column({ type: 'varchar', length: 10 }) @Column({ type: 'varchar', length: 10 })
type!: ApiType; type!: ApiType;
@Column({ type: 'varchar', length: 255, default: '', name: 'provider' })
provider!: string;
@Column({ type: 'int', default: 0, name: 'version_major' })
versionMajor!: number;
@Column({ type: 'int', default: 0, name: 'version_minor' })
versionMinor!: number;
@Column({ type: 'int', default: 0, name: 'version_patch' })
versionPatch!: number;
@Column({ type: 'text', name: 'yaml_content' }) @Column({ type: 'text', name: 'yaml_content' })
yamlContent!: string; yamlContent!: string;
@@ -33,23 +45,23 @@ export class ApiEntryEntity {
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' }) @Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
categoryId!: string | null; categoryId!: string | null;
@Column({ nullable: true, type: 'text', name: 'functional_doc' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
functionalDoc!: string | null; contactFunctionalName!: string;
@Column({ nullable: true, type: 'text', name: 'technical_doc' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_entity' })
technicalDoc!: string | null; contactFunctionalEntity!: string;
@Column({ nullable: true, type: 'varchar', length: 500, name: 'external_doc_url' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_email' })
externalDocUrl!: string | null; contactFunctionalEmail!: string;
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_functional' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_name' })
contactFunctional!: string | null; contactTechnicalName!: string;
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_technical' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_entity' })
contactTechnical!: string | null; contactTechnicalEntity!: string;
@Column({ nullable: true, type: 'text', name: 'access_rights' }) @Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_email' })
accessRights!: string | null; contactTechnicalEmail!: string;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt!: Date; createdAt!: Date;

View File

@@ -4,14 +4,11 @@ import { Repository } from 'typeorm';
import { ApiEntryEntity } from './api-entry.entity'; import { ApiEntryEntity } from './api-entry.entity';
import { CreateApiEntryDto } from './dto/create-api-entry.dto'; import { CreateApiEntryDto } from './dto/create-api-entry.dto';
import { UpdateApiEntryDto } from './dto/update-api-entry.dto'; import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus } from '@datacat/shared'; import { ApiListResponse, ApiListQuery, ApiEntry, ApiEntryListItem, ApiType, ApiStatus, ApiConvention, API_CONVENTION_LABELS } from '@datacat/shared';
import { DocsGenerationService } from '../docs-generation/docs-generation.service'; import { DocsGenerationService } from '../docs-generation/docs-generation.service';
/** Champs internes mis à jour par DocsGenerationService */ /** Champs internes mis à jour par DocsGenerationService */
interface InternalUpdateFields { interface InternalUpdateFields {
title?: string;
version?: string;
description?: string;
htmlPath?: string | null; htmlPath?: string | null;
status?: ApiStatus; status?: ApiStatus;
errorMessage?: string | null; errorMessage?: string | null;
@@ -28,19 +25,23 @@ export class ApisService {
async create(dto: CreateApiEntryDto): Promise<ApiEntry> { async create(dto: CreateApiEntryDto): Promise<ApiEntry> {
const entity = this.repo.create({ const entity = this.repo.create({
title: dto.title, convention: dto.convention,
version: dto.version, name: dto.name,
provider: dto.provider,
versionMajor: dto.versionMajor,
versionMinor: dto.versionMinor,
versionPatch: dto.versionPatch,
description: dto.description ?? null, description: dto.description ?? null,
type: dto.type, type: dto.type,
yamlContent: dto.yamlContent, yamlContent: dto.yamlContent,
status: 'PENDING', status: 'PENDING',
categoryId: dto.categoryId ?? null, categoryId: dto.categoryId ?? null,
functionalDoc: dto.functionalDoc ?? null, contactFunctionalName: dto.contactFunctionalName,
technicalDoc: dto.technicalDoc ?? null, contactFunctionalEntity: dto.contactFunctionalEntity,
externalDocUrl: dto.externalDocUrl ?? null, contactFunctionalEmail: dto.contactFunctionalEmail,
contactFunctional: dto.contactFunctional ?? null, contactTechnicalName: dto.contactTechnicalName,
contactTechnical: dto.contactTechnical ?? null, contactTechnicalEntity: dto.contactTechnicalEntity,
accessRights: dto.accessRights ?? null, contactTechnicalEmail: dto.contactTechnicalEmail,
}); });
const saved = await this.repo.save(entity); const saved = await this.repo.save(entity);
return toApiEntry(saved); return toApiEntry(saved);
@@ -58,7 +59,11 @@ export class ApisService {
} }
if (query.search) { if (query.search) {
qb.andWhere( qb.andWhere(
'(LOWER(api.title) LIKE :search OR LOWER(api.description) LIKE :search)', `(LOWER(api.name) LIKE :search
OR LOWER(api.provider) LIKE :search
OR LOWER(COALESCE(api.description, '')) LIKE :search
OR LOWER(api.contact_functional_name) LIKE :search
OR LOWER(api.contact_technical_name) LIKE :search)`,
{ search: `%${query.search.toLowerCase()}%` }, { search: `%${query.search.toLowerCase()}%` },
); );
} }
@@ -109,24 +114,38 @@ export class ApisService {
} }
} }
function buildTitle(convention: ApiConvention, name: string): string {
return `${API_CONVENTION_LABELS[convention]} ${name}`;
}
function buildVersion(major: number, minor: number, patch: number): string {
return `${major}.${minor}.${patch}`;
}
function toApiEntry(entity: ApiEntryEntity): ApiEntry { function toApiEntry(entity: ApiEntryEntity): ApiEntry {
return { return {
id: entity.id, id: entity.id,
title: entity.title, title: buildTitle(entity.convention, entity.name),
version: entity.version, name: entity.name,
convention: entity.convention,
version: buildVersion(entity.versionMajor, entity.versionMinor, entity.versionPatch),
versionMajor: entity.versionMajor,
versionMinor: entity.versionMinor,
versionPatch: entity.versionPatch,
description: entity.description, description: entity.description,
type: entity.type, type: entity.type,
provider: entity.provider,
yamlContent: entity.yamlContent, yamlContent: entity.yamlContent,
htmlPath: entity.htmlPath, htmlPath: entity.htmlPath,
status: entity.status, status: entity.status,
errorMessage: entity.errorMessage, errorMessage: entity.errorMessage,
categoryId: entity.categoryId, categoryId: entity.categoryId,
functionalDoc: entity.functionalDoc, contactFunctionalName: entity.contactFunctionalName,
technicalDoc: entity.technicalDoc, contactFunctionalEntity: entity.contactFunctionalEntity,
externalDocUrl: entity.externalDocUrl, contactFunctionalEmail: entity.contactFunctionalEmail,
contactFunctional: entity.contactFunctional, contactTechnicalName: entity.contactTechnicalName,
contactTechnical: entity.contactTechnical, contactTechnicalEntity: entity.contactTechnicalEntity,
accessRights: entity.accessRights, contactTechnicalEmail: entity.contactTechnicalEmail,
createdAt: entity.createdAt.toISOString(), createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(), updatedAt: entity.updatedAt.toISOString(),
}; };
@@ -135,10 +154,13 @@ function toApiEntry(entity: ApiEntryEntity): ApiEntry {
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem { function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return { return {
id: entity.id, id: entity.id,
title: entity.title, title: buildTitle(entity.convention, entity.name),
version: entity.version, name: entity.name,
convention: entity.convention,
version: buildVersion(entity.versionMajor, entity.versionMinor, entity.versionPatch),
description: entity.description, description: entity.description,
type: entity.type, type: entity.type,
provider: entity.provider,
status: entity.status, status: entity.status,
categoryId: entity.categoryId, categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(), createdAt: entity.createdAt.toISOString(),

View File

@@ -1,14 +1,33 @@
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID } from 'class-validator'; import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail } from 'class-validator';
import { ApiType } from '@datacat/shared'; import { Type } from 'class-transformer';
import { ApiType, ApiConvention } from '@datacat/shared';
export class CreateApiEntryDto { export class CreateApiEntryDto {
@IsString() @IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
@IsNotEmpty() convention!: ApiConvention;
title!: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
version!: string; name!: string;
@IsString()
@IsNotEmpty()
provider!: string;
@IsInt()
@Min(0)
@Type(() => Number)
versionMajor!: number;
@IsInt()
@Min(0)
@Type(() => Number)
versionMinor!: number;
@IsInt()
@Min(0)
@Type(() => Number)
versionPatch!: number;
@IsString() @IsString()
@IsOptional() @IsOptional()
@@ -26,26 +45,24 @@ export class CreateApiEntryDto {
categoryId?: string; categoryId?: string;
@IsString() @IsString()
@IsOptional() @IsNotEmpty()
functionalDoc?: string; contactFunctionalName!: string;
@IsString() @IsString()
@IsOptional() @IsNotEmpty()
technicalDoc?: string; contactFunctionalEntity!: string;
@IsEmail()
contactFunctionalEmail!: string;
@IsString() @IsString()
@IsOptional() @IsNotEmpty()
externalDocUrl?: string; contactTechnicalName!: string;
@IsString() @IsString()
@IsOptional() @IsNotEmpty()
contactFunctional?: string; contactTechnicalEntity!: string;
@IsString() @IsEmail()
@IsOptional() contactTechnicalEmail!: string;
contactTechnical?: string;
@IsString()
@IsOptional()
accessRights?: string;
} }

View File

@@ -1,17 +1,41 @@
import { IsString, IsOptional, IsUUID } from 'class-validator'; import { IsString, IsOptional, IsUUID, IsIn, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiConvention } from '@datacat/shared';
export class UpdateApiEntryDto { export class UpdateApiEntryDto {
@IsString() @IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
@IsOptional() @IsOptional()
title?: string; convention?: ApiConvention;
@IsString() @IsString()
@IsOptional() @IsOptional()
version?: string; name?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
description?: string; provider?: string;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionMajor?: number;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionMinor?: number;
@IsInt()
@Min(0)
@Type(() => Number)
@IsOptional()
versionPatch?: number;
@IsString()
@IsOptional()
description?: string | null;
@IsUUID() @IsUUID()
@IsOptional() @IsOptional()
@@ -19,25 +43,25 @@ export class UpdateApiEntryDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
functionalDoc?: string | null; contactFunctionalName?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
technicalDoc?: string | null; contactFunctionalEntity?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
externalDocUrl?: string | null; contactFunctionalEmail?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
contactFunctional?: string | null; contactTechnicalName?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
contactTechnical?: string | null; contactTechnicalEntity?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
accessRights?: string | null; contactTechnicalEmail?: string;
} }

View File

@@ -13,6 +13,7 @@ import {
ApiEntryListItem, ApiEntryListItem,
ApiType, ApiType,
ApiStatus, ApiStatus,
ApiConvention,
} from '@datacat/shared'; } from '@datacat/shared';
@Injectable() @Injectable()
@@ -150,13 +151,22 @@ function toCategory(entity: CategoryEntity): Category {
}; };
} }
const CONVENTION_LABELS: Record<ApiConvention, string> = {
CONSULTER: 'Consulter',
ENREGISTRER: 'Enregistrer',
ETRE_NOTIFIE: 'Être notifié',
};
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem { function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return { return {
id: entity.id, id: entity.id,
title: entity.title, title: `${CONVENTION_LABELS[entity.convention] ?? entity.convention} ${entity.name}`,
version: entity.version, name: entity.name,
convention: entity.convention,
version: `${entity.versionMajor}.${entity.versionMinor}.${entity.versionPatch}`,
description: entity.description, description: entity.description,
type: entity.type as ApiType, type: entity.type as ApiType,
provider: entity.provider,
status: entity.status as ApiStatus, status: entity.status as ApiStatus,
categoryId: entity.categoryId, categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(), createdAt: entity.createdAt.toISOString(),

View File

@@ -688,81 +688,101 @@ export class SeederService implements OnApplicationBootstrap {
/* APIs de démo */ /* APIs de démo */
const entries = await this.apiRepo.save([ const entries = await this.apiRepo.save([
this.apiRepo.create({ this.apiRepo.create({
title: 'Swagger Petstore', convention: 'CONSULTER',
version: '3.0.4', name: 'Référentiel Produits',
versionMajor: 3,
versionMinor: 0,
versionPatch: 4,
description: description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.', 'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI', type: 'OPENAPI',
yamlContent: PETSTORE_YAML, yamlContent: PETSTORE_YAML,
status: 'PENDING', status: 'PENDING',
categoryId: produits.id, categoryId: produits.id,
functionalDoc: 'Cas d\'usage : gestion du catalogue produits. Permet de lister, créer et consulter des animaux de compagnie. Usage typique : intégration e-commerce, démo API REST.', provider: 'DINUM',
technicalDoc: 'Authentification Bearer token requise. Pagination via paramètre `limit` (max 100). Réponses JSON selon schéma OpenAPI 3.0. Endpoint base : https://petstore.swagger.io/v3.', contactFunctionalName: 'Alice Martin',
externalDocUrl: null, contactFunctionalEntity: 'Équipe Produit',
contactFunctional: 'Équipe Produit', contactFunctionalEmail: 'alice.martin@example.gouv.fr',
contactTechnical: 'Équipe API', contactTechnicalName: 'Bob Dupont',
accessRights: 'API publique — aucune habilitation requise.', contactTechnicalEntity: 'Équipe API',
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
}), }),
this.apiRepo.create({ this.apiRepo.create({
title: 'Streetlights Kafka API', convention: 'ETRE_NOTIFIE',
version: '1.0.0', name: 'Éclairage Urbain Kafka',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: description:
'The Smartylighting Streetlights API allows you to remotely manage the city lights using Kafka messaging.', 'The Smartylighting Streetlights API allows you to remotely manage the city lights using Kafka messaging.',
type: 'ASYNCAPI', type: 'ASYNCAPI',
yamlContent: STREETLIGHTS_YAML, yamlContent: STREETLIGHTS_YAML,
status: 'PENDING', status: 'PENDING',
categoryId: evenements.id, categoryId: evenements.id,
functionalDoc: 'Gestion de l\'éclairage urbain IoT. Permet de recevoir les mesures de luminosité des lampadaires et d\'envoyer des commandes d\'allumage/extinction à distance.', provider: 'Bureau Infrastructure',
technicalDoc: 'Broker Kafka sécurisé avec SCRAM-SHA-512. Topics : smartylighting.streetlights.1.0.*. Schémas de messages définis en AsyncAPI 3.0. Adresse broker : test.mykafkacluster.org:18092.', contactFunctionalName: 'Claire Leroy',
externalDocUrl: null, contactFunctionalEntity: 'Bureau Infrastructure',
contactFunctional: 'Bureau Infrastructure', contactFunctionalEmail: 'claire.leroy@example.gouv.fr',
contactTechnical: 'ops@example.com', contactTechnicalName: 'David Chen',
accessRights: 'Accès sur demande — habilitation DSI requise.', contactTechnicalEntity: 'Ops',
contactTechnicalEmail: 'ops@example.com',
}), }),
this.apiRepo.create({ this.apiRepo.create({
title: 'Account Service', convention: 'ETRE_NOTIFIE',
version: '1.0.0', name: 'Comptes Utilisateurs',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: "Service de gestion des comptes utilisateurs avec publication d'événements.", description: "Service de gestion des comptes utilisateurs avec publication d'événements.",
type: 'ASYNCAPI', type: 'ASYNCAPI',
yamlContent: ACCOUNT_SERVICE_YAML, yamlContent: ACCOUNT_SERVICE_YAML,
status: 'PENDING', status: 'PENDING',
categoryId: notifications.id, categoryId: notifications.id,
functionalDoc: 'Cycle de vie du compte utilisateur. Publie des événements lors de l\'inscription et du changement de mot de passe. Utilisé par les services de notification et d\'audit.', provider: 'Équipe Comptes',
technicalDoc: 'Consommer via broker AMQP ou Kafka. Canaux : user/signedup, user/passwordchanged. Payloads JSON avec horodatage ISO 8601. Spec AsyncAPI 3.0.', contactFunctionalName: 'Emma Roux',
externalDocUrl: null, contactFunctionalEntity: 'Équipe Comptes',
contactFunctional: 'Équipe Comptes', contactFunctionalEmail: 'emma.roux@example.gouv.fr',
contactTechnical: 'dev-core@example.com', contactTechnicalName: 'François Blanc',
accessRights: 'Interne uniquement — accès réservé aux services du SI.', contactTechnicalEntity: 'Dev Core',
contactTechnicalEmail: 'dev-core@example.com',
}), }),
this.apiRepo.create({ this.apiRepo.create({
title: 'Orders API', convention: 'ENREGISTRER',
version: '1.0.0', name: 'Commandes Clients',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: 'API de gestion des commandes clients.', description: 'API de gestion des commandes clients.',
type: 'OPENAPI', type: 'OPENAPI',
yamlContent: ORDERS_YAML, yamlContent: ORDERS_YAML,
status: 'PENDING', status: 'PENDING',
categoryId: commandes.id, categoryId: commandes.id,
functionalDoc: 'Gestion des commandes clients : création, consultation, mise à jour du statut. Supporte les webhooks pour les transitions de statut (confirmed, shipped, delivered, cancelled).', provider: 'Équipe Commerce',
technicalDoc: 'REST + JWT Bearer. Pagination via paramètres `page` et `limit`. Webhook de statut : POST vers URL configurée lors de la transition. Base URL : https://api.example.com/v1.', contactFunctionalName: 'Gaëlle Moreau',
externalDocUrl: null, contactFunctionalEntity: 'Équipe Commerce',
contactFunctional: 'Équipe Commerce', contactFunctionalEmail: 'gaelle.moreau@example.gouv.fr',
contactTechnical: 'api-orders@example.com', contactTechnicalName: 'Hugo Simon',
accessRights: 'Partenaires agréés uniquement — convention de partenariat requise.', contactTechnicalEntity: 'API Commerce',
contactTechnicalEmail: 'api-orders@example.com',
}), }),
this.apiRepo.create({ this.apiRepo.create({
title: 'Auth API', convention: 'CONSULTER',
version: '1.0.0', name: 'Authentification JWT',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: "Service d'authentification et d'autorisation.", description: "Service d'authentification et d'autorisation.",
type: 'OPENAPI', type: 'OPENAPI',
yamlContent: AUTH_YAML, yamlContent: AUTH_YAML,
status: 'PENDING', status: 'PENDING',
categoryId: auth.id, categoryId: auth.id,
functionalDoc: 'SSO centralisé avec tokens JWT. Gère la connexion, le rafraîchissement de token et la déconnexion. Exposé à tous les services du SI comme fournisseur d\'identité.', provider: 'Équipe Sécurité',
technicalDoc: 'Bearer JWT (RS256). Endpoint principal : POST /auth/login. Refresh via POST /auth/refresh. Durée access token : 15 min. Durée refresh token : 7 jours. Base URL : https://auth.example.com/v1.', contactFunctionalName: 'Isabelle Petit',
externalDocUrl: null, contactFunctionalEntity: 'Équipe Sécurité',
contactFunctional: 'Équipe Sécurité', contactFunctionalEmail: 'isabelle.petit@example.gouv.fr',
contactTechnical: 'securite@example.com', contactTechnicalName: 'Julien Bernard',
accessRights: 'Restreint — habilitation RSSI requise avant intégration.', contactTechnicalEntity: 'Sécurité SI',
contactTechnicalEmail: 'securite@example.com',
}), }),
]); ]);

View File

@@ -57,13 +57,15 @@ export class UploadsController {
valid: boolean; valid: boolean;
type: ApiType | null; type: ApiType | null;
mainFile: string | null; mainFile: string | null;
title: string | null; name: string | null;
version: string | null; versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null; description: string | null;
errors: Array<{ file: string; message: string }>; errors: Array<{ file: string; message: string }>;
}> { }> {
if (!files || files.length === 0) { if (!files || files.length === 0) {
return { valid: false, type: null, mainFile: null, title: null, version: null, description: null, errors: [{ file: '', message: 'Aucun fichier fourni.' }] }; return { valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null, errors: [{ file: '', message: 'Aucun fichier fourni.' }] };
} }
/* Lire tous les contenus en mémoire avant toute suppression */ /* Lire tous les contenus en mémoire avant toute suppression */
@@ -94,7 +96,7 @@ export class UploadsController {
} }
if (errors.length > 0) { if (errors.length > 0) {
return { valid: false, type: null, mainFile: null, title: null, version: null, description: null, errors }; return { valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null, errors };
} }
/* Détection du fichier principal */ /* Détection du fichier principal */
@@ -112,7 +114,7 @@ export class UploadsController {
if (!mainFileName) { if (!mainFileName) {
return { return {
valid: false, type: null, mainFile: null, title: null, version: null, description: null, valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
errors: [{ file: '', message: 'Aucun fichier principal trouvé (clé openapi: ou asyncapi: manquante).' }], errors: [{ file: '', message: 'Aucun fichier principal trouvé (clé openapi: ou asyncapi: manquante).' }],
}; };
} }
@@ -133,14 +135,14 @@ export class UploadsController {
if (detectedType === 'ASYNCAPI') { if (detectedType === 'ASYNCAPI') {
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, timeout: 30000 }); await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, timeout: 30000 });
} else { } else {
await execFileAsync('redocly', ['lint', mainPath], { shell: true, env: execEnv, timeout: 30000 }); await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, timeout: 30000 });
} }
} catch (cliError) { } catch (cliError) {
const err = cliError as { stdout?: string; stderr?: string }; const err = cliError as { stdout?: string; stderr?: string };
const output = (err.stdout ?? err.stderr ?? String(cliError)).trim(); const output = (err.stdout || err.stderr || String(cliError)).trim();
return { return {
valid: false, type: detectedType, mainFile: mainFileName, valid: false, type: detectedType, mainFile: mainFileName,
title: null, version: null, description: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
errors: [{ file: mainFileName, message: output }], errors: [{ file: mainFileName, message: output }],
}; };
} }
@@ -152,11 +154,22 @@ export class UploadsController {
const mainContent = fileContents.find((f) => f.name === mainFileName)!.content; const mainContent = fileContents.find((f) => f.name === mainFileName)!.content;
const parsed = yaml.load(mainContent) as Record<string, unknown>; const parsed = yaml.load(mainContent) as Record<string, unknown>;
const info = parsed['info'] as Record<string, unknown> | undefined; const info = parsed['info'] as Record<string, unknown> | undefined;
const title = typeof info?.['title'] === 'string' ? info['title'] : null; const name = typeof info?.['title'] === 'string' ? info['title'] : null;
const version = typeof info?.['version'] === 'string' ? String(info['version']) : null;
const description = typeof info?.['description'] === 'string' ? info['description'] : null; const description = typeof info?.['description'] === 'string' ? info['description'] : null;
return { valid: true, type: detectedType, mainFile: mainFileName, title, version, description, errors: [] }; /* Décomposer la version en major.minor.patch */
let versionMajor: number | null = null;
let versionMinor: number | null = null;
let versionPatch: number | null = null;
const versionStr = typeof info?.['version'] === 'string' ? info['version'] : null;
if (versionStr) {
const parts = versionStr.split('.').map((p) => parseInt(p, 10));
if (parts.length >= 1 && !isNaN(parts[0])) versionMajor = parts[0];
if (parts.length >= 2 && !isNaN(parts[1])) versionMinor = parts[1];
if (parts.length >= 3 && !isNaN(parts[2])) versionPatch = parts[2];
}
return { valid: true, type: detectedType, mainFile: mainFileName, name, versionMajor, versionMinor, versionPatch, description, errors: [] };
} }
@Post() @Post()
@@ -175,22 +188,26 @@ export class UploadsController {
@UploadedFiles() files: Express.Multer.File[], @UploadedFiles() files: Express.Multer.File[],
@Body() @Body()
body: { body: {
title: string; convention: string;
version: string; name: string;
provider: string;
versionMajor: string;
versionMinor: string;
versionPatch: string;
description?: string; description?: string;
type?: ApiType; type?: ApiType;
categoryId?: string; categoryId?: string;
functionalDoc?: string; contactFunctionalName: string;
technicalDoc?: string; contactFunctionalEntity: string;
externalDocUrl?: string; contactFunctionalEmail: string;
contactFunctional?: string; contactTechnicalName: string;
contactTechnical?: string; contactTechnicalEntity: string;
accessRights?: string; contactTechnicalEmail: string;
}, },
) { ) {
if (!files || files.length === 0) throw new BadRequestException('YAML file is required'); if (!files || files.length === 0) throw new BadRequestException('YAML file is required');
if (!body.title) throw new BadRequestException('title is required'); if (!body.name) throw new BadRequestException('name is required');
if (!body.version) throw new BadRequestException('version 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 */
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads'); const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
@@ -225,18 +242,22 @@ export class UploadsController {
} }
const entry = await this.apisService.create({ const entry = await this.apisService.create({
title: body.title, convention: (body.convention as 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE') ?? 'CONSULTER',
version: body.version, name: body.name,
provider: body.provider,
versionMajor: parseInt(body.versionMajor, 10) || 0,
versionMinor: parseInt(body.versionMinor, 10) || 0,
versionPatch: parseInt(body.versionPatch, 10) || 0,
description: body.description, description: body.description,
type: apiType, type: apiType,
yamlContent, yamlContent,
categoryId: body.categoryId || undefined, categoryId: body.categoryId || undefined,
functionalDoc: body.functionalDoc || undefined, contactFunctionalName: body.contactFunctionalName,
technicalDoc: body.technicalDoc || undefined, contactFunctionalEntity: body.contactFunctionalEntity,
externalDocUrl: body.externalDocUrl || undefined, contactFunctionalEmail: body.contactFunctionalEmail,
contactFunctional: body.contactFunctional || undefined, contactTechnicalName: body.contactTechnicalName,
contactTechnical: body.contactTechnical || undefined, contactTechnicalEntity: body.contactTechnicalEntity,
accessRights: body.accessRights || undefined, contactTechnicalEmail: body.contactTechnicalEmail,
}); });
/* Fire-and-forget : génération asynchrone */ /* Fire-and-forget : génération asynchrone */
@@ -300,7 +321,7 @@ export class UploadsController {
if (type === 'OPENAPI') { if (type === 'OPENAPI') {
/* redocly bundle : pas d'appel réseau, fonctionne en local */ /* redocly bundle : pas d'appel réseau, fonctionne en local */
await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath], { shell: true, env: execEnv }); await execFileAsync('redocly', ['bundle', mainPath, '--output', bundledPath], { env: execEnv });
return await fsPromises.readFile(bundledPath, 'utf-8'); return await fsPromises.readFile(bundledPath, 'utf-8');
} else { } else {
/* @asyncapi/bundler API programmatique : aucun appel réseau */ /* @asyncapi/bundler API programmatique : aucun appel réseau */

View File

@@ -10,10 +10,9 @@
"target": "ES2021", "target": "ES2021",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "../",
"baseUrl": "./", "baseUrl": "./",
"incremental": true, "incremental": true
"paths": { },
"@datacat/shared": ["../shared/src/index.ts"] "include": ["src/**/*.ts"]
}
}
} }

View File

@@ -23,16 +23,20 @@ export class ApiService {
} }
update(id: string, data: { update(id: string, data: {
title?: string; convention?: string;
version?: string; name?: string;
description?: string; provider?: string;
versionMajor?: number;
versionMinor?: number;
versionPatch?: number;
description?: string | null;
categoryId?: string | null; categoryId?: string | null;
functionalDoc?: string | null; contactFunctionalName?: string;
technicalDoc?: string | null; contactFunctionalEntity?: string;
externalDocUrl?: string | null; contactFunctionalEmail?: string;
contactFunctional?: string | null; contactTechnicalName?: string;
contactTechnical?: string | null; contactTechnicalEntity?: string;
accessRights?: string | null; contactTechnicalEmail?: string;
}): Observable<ApiEntry> { }): Observable<ApiEntry> {
return this.http.patch<ApiEntry>(`${this.baseUrl}/${id}`, data); return this.http.patch<ApiEntry>(`${this.baseUrl}/${id}`, data);
} }
@@ -53,8 +57,10 @@ export class ApiService {
valid: boolean; valid: boolean;
type: 'ASYNCAPI' | 'OPENAPI' | null; type: 'ASYNCAPI' | 'OPENAPI' | null;
mainFile: string | null; mainFile: string | null;
title: string | null; name: string | null;
version: string | null; versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null; description: string | null;
errors: Array<{ file: string; message: string }>; errors: Array<{ file: string; message: string }>;
}> { }> {
@@ -62,8 +68,10 @@ export class ApiService {
valid: boolean; valid: boolean;
type: 'ASYNCAPI' | 'OPENAPI' | null; type: 'ASYNCAPI' | 'OPENAPI' | null;
mainFile: string | null; mainFile: string | null;
title: string | null; name: string | null;
version: string | null; versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null; description: string | null;
errors: Array<{ file: string; message: string }>; errors: Array<{ file: string; message: string }>;
}>('/api/uploads/validate', formData); }>('/api/uploads/validate', formData);

View File

@@ -10,7 +10,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ApiService } from '../../core/api.service'; import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service'; import { CategoryService } from '../../core/category.service';
import { ApiEntry, Category } from '@datacat/shared'; import { ApiEntry, Category, ApiConvention } from '@datacat/shared';
@Component({ @Component({
selector: 'app-api-detail', selector: 'app-api-detail',
@@ -81,10 +81,6 @@ import { ApiEntry, Category } from '@datacat/shared';
<div class="fr-card__content"> <div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Informations</h2> <h2 class="fr-h5 fr-mb-2w">Informations</h2>
<dl class="fr-grid-row fr-grid-row--gutters"> <dl class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Titre</dt>
<dd>{{ entry.title }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4"> <div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Version</dt> <dt class="fr-text--bold">Version</dt>
<dd>{{ entry.version }}</dd> <dd>{{ entry.version }}</dd>
@@ -93,6 +89,10 @@ import { ApiEntry, Category } from '@datacat/shared';
<dt class="fr-text--bold">Type</dt> <dt class="fr-text--bold">Type</dt>
<dd>{{ entry.type }}</dd> <dd>{{ entry.type }}</dd>
</div> </div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Fournisseur</dt>
<dd>{{ entry.provider }}</dd>
</div>
@if (entry.description) { @if (entry.description) {
<div class="fr-col-12"> <div class="fr-col-12">
<dt class="fr-text--bold">Description</dt> <dt class="fr-text--bold">Description</dt>
@@ -112,80 +112,28 @@ import { ApiEntry, Category } from '@datacat/shared';
</div> </div>
</div> </div>
<!-- Documentation fonctionnelle -->
@if (entry.functionalDoc) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Documentation fonctionnelle</h2>
<p style="white-space: pre-wrap">{{ entry.functionalDoc }}</p>
</div>
</div>
</div>
}
<!-- Documentation technique -->
@if (entry.technicalDoc) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Documentation technique</h2>
<p style="white-space: pre-wrap">{{ entry.technicalDoc }}</p>
</div>
</div>
</div>
}
<!-- Contacts --> <!-- Contacts -->
@if (entry.contactFunctional || entry.contactTechnical) {
<div class="fr-card fr-mb-4w"> <div class="fr-card fr-mb-4w">
<div class="fr-card__body"> <div class="fr-card__body">
<div class="fr-card__content"> <div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Contacts</h2> <h2 class="fr-h5 fr-mb-2w">Contacts</h2>
<dl class="fr-grid-row fr-grid-row--gutters"> <div class="fr-grid-row fr-grid-row--gutters">
@if (entry.contactFunctional) {
<div class="fr-col-12 fr-col-md-6"> <div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact fonctionnel</dt> <p class="fr-text--bold fr-mb-1w">Contact métier</p>
<dd>{{ entry.contactFunctional }}</dd> <p class="fr-mb-0">{{ entry.contactFunctionalName }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactFunctionalEntity }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactFunctionalEmail }}</p>
</div> </div>
}
@if (entry.contactTechnical) {
<div class="fr-col-12 fr-col-md-6"> <div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact technique</dt> <p class="fr-text--bold fr-mb-1w">Contact technique</p>
<dd>{{ entry.contactTechnical }}</dd> <p class="fr-mb-0">{{ entry.contactTechnicalName }}</p>
</div> <p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEntity }}</p>
} <p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEmail }}</p>
</dl>
</div> </div>
</div> </div>
</div> </div>
}
<!-- Droits d'accès -->
@if (entry.accessRights) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Droits d'accès</h2>
<p style="white-space: pre-wrap">{{ entry.accessRights }}</p>
</div> </div>
</div> </div>
</div>
}
<!-- Documentation externe -->
@if (entry.externalDocUrl) {
<div class="fr-mb-4w">
<a
[href]="entry.externalDocUrl"
target="_blank"
rel="noopener noreferrer"
class="fr-btn fr-btn--secondary fr-icon-external-link-line fr-btn--icon-left"
>
Documentation externe
</a>
</div>
}
<!-- Actions --> <!-- Actions -->
<div class="fr-btns-group fr-btns-group--inline-sm"> <div class="fr-btns-group fr-btns-group--inline-sm">
@@ -229,7 +177,7 @@ import { ApiEntry, Category } from '@datacat/shared';
@if (showEditModal()) { @if (showEditModal()) {
<div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto" <div style="position:fixed;inset:0;z-index:9999;background:rgba(22,22,22,.64);display:flex;align-items:flex-start;justify-content:center;padding-top:5vh;overflow-y:auto"
role="dialog" aria-modal="true" aria-labelledby="edit-modal-title"> role="dialog" aria-modal="true" aria-labelledby="edit-modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:600px;margin:0 1rem 2rem"> <div style="background:white;width:calc(100% - 2rem);max-width:640px;margin:0 1rem 2rem">
<div class="fr-p-4w"> <div class="fr-p-4w">
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w"> <div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col"> <div class="fr-col">
@@ -242,57 +190,144 @@ import { ApiEntry, Category } from '@datacat/shared';
@if (editError()) { @if (editError()) {
<div class="fr-alert fr-alert--error fr-mb-2w"><p>{{ editError() }}</p></div> <div class="fr-alert fr-alert--error fr-mb-2w"><p>{{ editError() }}</p></div>
} }
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="editTitleError()">
<label class="fr-label" for="edit-title">Titre <span style="color:var(--text-default-error)">*</span></label> <!-- Convention + Nom -->
<input id="edit-title" class="fr-input" [class.fr-input--error]="editTitleError()" type="text" [value]="editTitle()" <p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
(input)="editTitle.set($any($event.target).value); editTitleError.set(false)" <div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
(blur)="editTitleError.set(!editTitle().trim())" /> <div class="fr-col-12 fr-col-md-4">
@if (editTitleError()) { <p class="fr-error-text">Le titre est obligatoire.</p> } <div class="fr-select-group">
<label class="fr-label" for="edit-convention">Convention</label>
<select id="edit-convention" class="fr-select" [value]="editConvention()"
(change)="editConvention.set($any($event.target).value)">
<option value="CONSULTER">Consulter</option>
<option value="ENREGISTRER">Enregistrer</option>
<option value="ETRE_NOTIFIE">Être notifié</option>
</select>
</div> </div>
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="editVersionError()">
<label class="fr-label" for="edit-version">Version <span style="color:var(--text-default-error)">*</span></label>
<input id="edit-version" class="fr-input" [class.fr-input--error]="editVersionError()" type="text" [value]="editVersion()"
(input)="editVersion.set($any($event.target).value); editVersionError.set(false)"
(blur)="editVersionError.set(!editVersion().trim())" />
@if (editVersionError()) { <p class="fr-error-text">La version est obligatoire.</p> }
</div> </div>
<div class="fr-col-12 fr-col-md-8">
<div class="fr-input-group" [class.fr-input-group--error]="editNameError()">
<label class="fr-label" for="edit-name">Nom de l'API</label>
<input id="edit-name" class="fr-input" [class.fr-input--error]="editNameError()" type="text"
[value]="editName()"
(input)="editName.set($any($event.target).value); editNameError.set(false)"
(blur)="editNameError.set(!editName().trim())" />
@if (editNameError()) { <p class="fr-error-text">Le nom est obligatoire.</p> }
</div>
</div>
</div>
<p class="fr-hint-text fr-mb-2w">Titre : <strong>{{ editPreviewTitle() }}</strong></p>
<!-- Catégorie -->
<div class="fr-select-group fr-mb-2w"> <div class="fr-select-group fr-mb-2w">
<label class="fr-label" for="edit-category">Catégorie</label> <label class="fr-label" for="edit-category">Catégorie</label>
<select id="edit-category" class="fr-select" [value]="editCategoryId()" (change)="editCategoryId.set($any($event.target).value || null)"> <select id="edit-category" class="fr-select" [value]="editCategoryId()"
(change)="editCategoryId.set($any($event.target).value || null)">
<option value="">— Sans catégorie —</option> <option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) { @for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ cat.name }}</option> <option [value]="cat.id">{{ cat.name }}</option>
} }
</select> </select>
</div> </div>
<!-- Fournisseur -->
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="editProviderError()">
<label class="fr-label" for="edit-provider">Fournisseur <span style="color:var(--text-default-error)">*</span></label>
<input id="edit-provider" class="fr-input" [class.fr-input--error]="editProviderError()" type="text"
[value]="editProvider()"
(input)="editProvider.set($any($event.target).value); editProviderError.set(false)"
(blur)="editProviderError.set(!editProvider().trim())" />
@if (editProviderError()) { <p class="fr-error-text">Le fournisseur est obligatoire.</p> }
</div>
<!-- Description -->
<div class="fr-input-group fr-mb-2w"> <div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="edit-description">Description</label> <label class="fr-label" for="edit-description">Description</label>
<textarea id="edit-description" class="fr-input" rows="3" [value]="editDescription()" (input)="editDescription.set($any($event.target).value)"></textarea> <textarea id="edit-description" class="fr-input" rows="3" [value]="editDescription()"
(input)="editDescription.set($any($event.target).value)"></textarea>
</div> </div>
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="edit-func-doc">Documentation fonctionnelle</label> <!-- Version -->
<textarea id="edit-func-doc" class="fr-input" rows="3" [value]="editFunctionalDoc()" (input)="editFunctionalDoc.set($any($event.target).value)"></textarea> <p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-2w">
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-v-major">Majeure (X)</label>
<input id="edit-v-major" class="fr-input" type="number" min="0"
[value]="editVersionMajor()"
(input)="editVersionMajor.set(+$any($event.target).value)" />
</div> </div>
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="edit-tech-doc">Documentation technique</label>
<textarea id="edit-tech-doc" class="fr-input" rows="3" [value]="editTechnicalDoc()" (input)="editTechnicalDoc.set($any($event.target).value)"></textarea>
</div> </div>
<div class="fr-input-group fr-mb-2w"> <div class="fr-col-4">
<label class="fr-label" for="edit-ext-url">URL documentation externe</label> <div class="fr-input-group">
<input id="edit-ext-url" class="fr-input" type="text" [value]="editExternalDocUrl()" (input)="editExternalDocUrl.set($any($event.target).value)" /> <label class="fr-label" for="edit-v-minor">Mineure (Y)</label>
<input id="edit-v-minor" class="fr-input" type="number" min="0"
[value]="editVersionMinor()"
(input)="editVersionMinor.set(+$any($event.target).value)" />
</div> </div>
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="edit-contact-func">Contact fonctionnel</label>
<input id="edit-contact-func" class="fr-input" type="text" [value]="editContactFunctional()" (input)="editContactFunctional.set($any($event.target).value)" />
</div> </div>
<div class="fr-input-group fr-mb-2w"> <div class="fr-col-4">
<label class="fr-label" for="edit-contact-tech">Contact technique</label> <div class="fr-input-group">
<input id="edit-contact-tech" class="fr-input" type="text" [value]="editContactTechnical()" (input)="editContactTechnical.set($any($event.target).value)" /> <label class="fr-label" for="edit-v-patch">Correctif (Z)</label>
<input id="edit-v-patch" class="fr-input" type="number" min="0"
[value]="editVersionPatch()"
(input)="editVersionPatch.set(+$any($event.target).value)" />
</div> </div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="edit-access">Droits d'accès</label>
<textarea id="edit-access" class="fr-input" rows="3" [value]="editAccessRights()" (input)="editAccessRights.set($any($event.target).value)"></textarea>
</div> </div>
</div>
<!-- Contact métier -->
<p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-2w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-cf-name">Nom et prénom</label>
<input id="edit-cf-name" class="fr-input" type="text" [value]="editContactFunctionalName()"
(input)="editContactFunctionalName.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-cf-entity">Entité</label>
<input id="edit-cf-entity" class="fr-input" type="text" [value]="editContactFunctionalEntity()"
(input)="editContactFunctionalEntity.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-cf-email">Email</label>
<input id="edit-cf-email" class="fr-input" type="email" [value]="editContactFunctionalEmail()"
(input)="editContactFunctionalEmail.set($any($event.target).value)" />
</div>
</div>
</div>
<!-- Contact technique -->
<p class="fr-label fr-mb-1w">Contact technique <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-ct-name">Nom et prénom</label>
<input id="edit-ct-name" class="fr-input" type="text" [value]="editContactTechnicalName()"
(input)="editContactTechnicalName.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-ct-entity">Entité</label>
<input id="edit-ct-entity" class="fr-input" type="text" [value]="editContactTechnicalEntity()"
(input)="editContactTechnicalEntity.set($any($event.target).value)" />
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group">
<label class="fr-label" for="edit-ct-email">Email</label>
<input id="edit-ct-email" class="fr-input" type="email" [value]="editContactTechnicalEmail()"
(input)="editContactTechnicalEmail.set($any($event.target).value)" />
</div>
</div>
</div>
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline"> <div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="closeEdit()">Annuler</button> <button class="fr-btn fr-btn--secondary" (click)="closeEdit()">Annuler</button>
<button class="fr-btn" (click)="submitEdit()" [disabled]="editLoading()"> <button class="fr-btn" (click)="submitEdit()" [disabled]="editLoading()">
@@ -321,19 +356,28 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
showEditModal = signal(false); showEditModal = signal(false);
editLoading = signal(false); editLoading = signal(false);
editError = signal<string | null>(null); editError = signal<string | null>(null);
editTitleError = signal(false); editNameError = signal(false);
editVersionError = signal(false); editProviderError = signal(false);
categories = signal<Category[]>([]); categories = signal<Category[]>([]);
editTitle = signal(''); editConvention = signal<ApiConvention>('CONSULTER');
editVersion = signal(''); editName = signal('');
editProvider = signal('');
editVersionMajor = signal(0);
editVersionMinor = signal(0);
editVersionPatch = signal(0);
editCategoryId = signal<string | null>(null); editCategoryId = signal<string | null>(null);
editDescription = signal(''); editDescription = signal('');
editFunctionalDoc = signal(''); editContactFunctionalName = signal('');
editTechnicalDoc = signal(''); editContactFunctionalEntity = signal('');
editExternalDocUrl = signal(''); editContactFunctionalEmail = signal('');
editContactFunctional = signal(''); editContactTechnicalName = signal('');
editContactTechnical = signal(''); editContactTechnicalEntity = signal('');
editAccessRights = signal(''); editContactTechnicalEmail = signal('');
editPreviewTitle() {
const labels: Record<ApiConvention, string> = { CONSULTER: 'Consulter', ENREGISTRER: 'Enregistrer', ETRE_NOTIFIE: 'Être notifié' };
return `${labels[this.editConvention()]} ${this.editName()}`.trim();
}
private pollInterval: ReturnType<typeof setInterval> | null = null; private pollInterval: ReturnType<typeof setInterval> | null = null;
private id = ''; private id = '';
@@ -362,19 +406,23 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
openEdit() { openEdit() {
const entry = this.api(); const entry = this.api();
if (!entry) return; if (!entry) return;
this.editTitle.set(entry.title); this.editConvention.set(entry.convention);
this.editVersion.set(entry.version); this.editName.set(entry.name);
this.editProvider.set(entry.provider);
this.editVersionMajor.set(entry.versionMajor);
this.editVersionMinor.set(entry.versionMinor);
this.editVersionPatch.set(entry.versionPatch);
this.editCategoryId.set(entry.categoryId ?? null); this.editCategoryId.set(entry.categoryId ?? null);
this.editDescription.set(entry.description ?? ''); this.editDescription.set(entry.description ?? '');
this.editFunctionalDoc.set(entry.functionalDoc ?? ''); this.editContactFunctionalName.set(entry.contactFunctionalName);
this.editTechnicalDoc.set(entry.technicalDoc ?? ''); this.editContactFunctionalEntity.set(entry.contactFunctionalEntity);
this.editExternalDocUrl.set(entry.externalDocUrl ?? ''); this.editContactFunctionalEmail.set(entry.contactFunctionalEmail);
this.editContactFunctional.set(entry.contactFunctional ?? ''); this.editContactTechnicalName.set(entry.contactTechnicalName);
this.editContactTechnical.set(entry.contactTechnical ?? ''); this.editContactTechnicalEntity.set(entry.contactTechnicalEntity);
this.editAccessRights.set(entry.accessRights ?? ''); this.editContactTechnicalEmail.set(entry.contactTechnicalEmail);
this.editError.set(null); this.editError.set(null);
this.editTitleError.set(false); this.editNameError.set(false);
this.editVersionError.set(false); this.editProviderError.set(false);
this.categoryService.list().subscribe((res) => this.categories.set(res.items)); this.categoryService.list().subscribe((res) => this.categories.set(res.items));
this.showEditModal.set(true); this.showEditModal.set(true);
} }
@@ -384,21 +432,25 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
} }
submitEdit() { submitEdit() {
this.editTitleError.set(!this.editTitle().trim()); this.editNameError.set(!this.editName().trim());
this.editVersionError.set(!this.editVersion().trim()); this.editProviderError.set(!this.editProvider().trim());
if (!this.editTitle().trim() || !this.editVersion().trim()) return; if (!this.editName().trim() || !this.editProvider().trim()) return;
this.editLoading.set(true); this.editLoading.set(true);
this.apiService.update(this.id, { this.apiService.update(this.id, {
title: this.editTitle().trim(), convention: this.editConvention(),
version: this.editVersion().trim(), name: this.editName().trim(),
provider: this.editProvider().trim(),
versionMajor: this.editVersionMajor(),
versionMinor: this.editVersionMinor(),
versionPatch: this.editVersionPatch(),
categoryId: this.editCategoryId() || null, categoryId: this.editCategoryId() || null,
description: this.editDescription().trim() || undefined, description: this.editDescription().trim() || null,
functionalDoc: this.editFunctionalDoc().trim() || null, contactFunctionalName: this.editContactFunctionalName().trim(),
technicalDoc: this.editTechnicalDoc().trim() || null, contactFunctionalEntity: this.editContactFunctionalEntity().trim(),
externalDocUrl: this.editExternalDocUrl().trim() || null, contactFunctionalEmail: this.editContactFunctionalEmail().trim(),
contactFunctional: this.editContactFunctional().trim() || null, contactTechnicalName: this.editContactTechnicalName().trim(),
contactTechnical: this.editContactTechnical().trim() || null, contactTechnicalEntity: this.editContactTechnicalEntity().trim(),
accessRights: this.editAccessRights().trim() || null, contactTechnicalEmail: this.editContactTechnicalEmail().trim(),
}).subscribe({ }).subscribe({
next: (entry) => { next: (entry) => {
this.api.set(entry); this.api.set(entry);

View File

@@ -47,7 +47,12 @@ import { forkJoin } from 'rxjs';
<div class="fr-col"> <div class="fr-col">
<h1 class="fr-h2 fr-mb-0">{{ browseData()?.category?.name ?? 'Catalogue' }}</h1> <h1 class="fr-h2 fr-mb-0">{{ browseData()?.category?.name ?? 'Catalogue' }}</h1>
</div> </div>
<div class="fr-col-auto"> <div class="fr-col-auto" style="display:flex;gap:0.5rem">
<a class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-upload-2-line"
[routerLink]="['/upload']"
[queryParams]="categoryId() ? { categoryId: categoryId() } : {}">
Importer une API
</a>
<button class="fr-btn fr-btn--icon-left fr-icon-folder-2-fill" (click)="openCreate()"> <button class="fr-btn fr-btn--icon-left fr-icon-folder-2-fill" (click)="openCreate()">
Nouveau dossier Nouveau dossier
</button> </button>
@@ -246,6 +251,18 @@ import { forkJoin } from 'rxjs';
<p>{{ formError() }}</p> <p>{{ formError() }}</p>
</div> </div>
} }
@if (formMode() === 'create') {
<div class="fr-select-group fr-mb-2w">
<label class="fr-label" for="form-parent">Emplacement</label>
<select id="form-parent" class="fr-select"
(change)="formParentId.set($any($event.target).value || null)">
<option value="" [selected]="!formParentId()"></option>
@for (opt of categoriesForSelect(); track opt.id) {
<option [value]="opt.id" [selected]="opt.id === formParentId()">{{ opt.label }}</option>
}
</select>
</div>
}
<div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="formNameError()"> <div class="fr-input-group fr-mb-2w" [class.fr-input-group--error]="formNameError()">
<label class="fr-label" for="form-name"> <label class="fr-label" for="form-name">
Nom <span style="color:var(--text-default-error)">*</span> Nom <span style="color:var(--text-default-error)">*</span>
@@ -308,9 +325,11 @@ export class BrowseComponent implements OnInit {
editingCategoryId = signal<string | null>(null); editingCategoryId = signal<string | null>(null);
formName = signal(''); formName = signal('');
formDescription = signal(''); formDescription = signal('');
formParentId = signal<string | null>(null);
formError = signal<string | null>(null); formError = signal<string | null>(null);
formNameError = signal(false); formNameError = signal(false);
formLoading = signal(false); formLoading = signal(false);
allCategories = signal<Category[]>([]);
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */ /* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
ancestors = computed<Category[]>(() => { ancestors = computed<Category[]>(() => {
@@ -319,6 +338,39 @@ export class BrowseComponent implements OnInit {
return d.breadcrumb.slice(0, -1); return d.breadcrumb.slice(0, -1);
}); });
/* Options du select "Emplacement" avec indentation type └ similaire au formulaire d'import */
categoriesForSelect = computed<{ id: string; label: string }[]>(() => {
const cats = this.allCategories();
const map = new Map(cats.map((c) => [c.id, c]));
/* Chemin complet pour le tri */
const getSortPath = (id: string, depth = 0): string => {
if (depth > 10) return '';
const cat = map.get(id);
if (!cat) return '';
if (!cat.parentId) return cat.name;
return `${getSortPath(cat.parentId, depth + 1)} / ${cat.name}`;
};
/* Profondeur pour l'indentation */
const getDepth = (id: string, depth = 0): number => {
if (depth > 10) return depth;
const cat = map.get(id);
if (!cat?.parentId) return depth;
return getDepth(cat.parentId, depth + 1);
};
return cats
.map((c) => {
const depth = getDepth(c.id);
const indent = '\u00a0\u00a0'.repeat(depth);
const prefix = depth > 0 ? `${indent}\u00a0` : '';
return { id: c.id, label: `${prefix}${c.name}`, _sort: getSortPath(c.id) };
})
.sort((a, b) => a._sort.localeCompare(b._sort, 'fr'))
.map(({ id, label }) => ({ id, label }));
});
ngOnInit() { ngOnInit() {
this.route.paramMap this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -348,9 +400,20 @@ export class BrowseComponent implements OnInit {
this.editingCategoryId.set(null); this.editingCategoryId.set(null);
this.formName.set(''); this.formName.set('');
this.formDescription.set(''); this.formDescription.set('');
this.formParentId.set(this.categoryId());
this.formError.set(null); this.formError.set(null);
this.formNameError.set(false); this.formNameError.set(false);
/* Charger les catégories d'abord, puis afficher la modale pour que la pré-sélection fonctionne */
this.categoryService.list().subscribe({
next: (res) => {
this.allCategories.set(res.items);
this.showFormModal.set(true); this.showFormModal.set(true);
},
error: () => {
this.allCategories.set([]);
this.showFormModal.set(true);
},
});
} }
openEdit(cat: CategoryWithCounts) { openEdit(cat: CategoryWithCounts) {
@@ -382,7 +445,7 @@ export class BrowseComponent implements OnInit {
? this.categoryService.create({ ? this.categoryService.create({
name, name,
description, description,
parentId: this.categoryId() ?? undefined, parentId: this.formParentId() ?? undefined,
}) })
: this.categoryService.update(this.editingCategoryId()!, { name, description }); : this.categoryService.update(this.editingCategoryId()!, { name, description });

View File

@@ -6,12 +6,13 @@ import {
OnInit, OnInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
} from '@angular/core'; } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink, ActivatedRoute } from '@angular/router';
import { ApiService } from '../../core/api.service'; import { ApiService } from '../../core/api.service';
import { CategoryService } from '../../core/category.service'; import { CategoryService } from '../../core/category.service';
import { Category } from '@datacat/shared'; import { Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
@Component({ @Component({
selector: 'app-upload', selector: 'app-upload',
@@ -122,58 +123,52 @@ import { Category } from '@datacat/shared';
<!-- Étape 2 : Métadonnées --> <!-- Étape 2 : Métadonnées -->
@if (step() === 2) { @if (step() === 2) {
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate> <form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="titleInvalid()">
<label class="fr-label" for="title-input">
Titre <span style="color:var(--text-default-error)">*</span>
</label>
<input
id="title-input"
class="fr-input"
[class.fr-input--error]="titleInvalid()"
formControlName="title"
type="text"
placeholder="Nom de l'API"
/>
@if (titleInvalid()) {
<p class="fr-error-text">Le titre est obligatoire.</p>
}
</div>
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="versionInvalid()"> <!-- Titre : convention + nom -->
<label class="fr-label" for="version-input"> <p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
Version <span style="color:var(--text-default-error)">*</span> <div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
</label> <div class="fr-col-12 fr-col-md-4">
<input <div class="fr-select-group" [class.fr-select-group--error]="conventionInvalid()">
id="version-input" <label class="fr-label" for="convention-input">Convention</label>
class="fr-input" <select
[class.fr-input--error]="versionInvalid()" id="convention-input"
formControlName="version" class="fr-select"
type="text" [class.fr-select--error]="conventionInvalid()"
placeholder="1.0.0" formControlName="convention"
/> >
@if (versionInvalid()) { <option value="CONSULTER">Consulter</option>
<p class="fr-error-text">La version est obligatoire.</p> <option value="ENREGISTRER">Enregistrer</option>
} <option value="ETRE_NOTIFIE">Être notifié</option>
</div>
<div class="fr-mb-3w">
<p class="fr-label fr-mb-1w">Type <span style="color:var(--text-default-error)">*</span></p>
@if (detectedType()) {
<span class="fr-tag">{{ detectedType() }}</span>
<span class="fr-hint-text fr-mt-1w">Détecté automatiquement depuis le fichier YAML</span>
} @else {
<div class="fr-select-group">
<select id="type-input" class="fr-select" formControlName="type">
<option value="ASYNCAPI">AsyncAPI</option>
<option value="OPENAPI">OpenAPI</option>
</select> </select>
</div> @if (conventionInvalid()) {
<p class="fr-error-text">La convention est obligatoire.</p>
} }
</div> </div>
</div>
<div class="fr-col-12 fr-col-md-8">
<div class="fr-input-group" [class.fr-input-group--error]="nameInvalid()">
<label class="fr-label" for="name-input">Nom de l'API</label>
<input
id="name-input"
class="fr-input"
[class.fr-input--error]="nameInvalid()"
formControlName="name"
type="text"
placeholder="Référentiel Adresses"
/>
@if (nameInvalid()) {
<p class="fr-error-text">Le nom est obligatoire.</p>
}
</div>
</div>
</div>
<p class="fr-hint-text fr-mb-3w">Titre complet : <strong>{{ previewTitle() }}</strong></p>
<!-- Catégorie -->
<div class="fr-select-group fr-mb-3w"> <div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="category-input"> <label class="fr-label" for="category-input">
Catégorie </label> Catégorie
</label>
<select id="category-input" class="fr-select" formControlName="categoryId"> <select id="category-input" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option> <option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) { @for (cat of categories(); track cat.id) {
@@ -182,9 +177,43 @@ import { Category } from '@datacat/shared';
</select> </select>
</div> </div>
<!-- Fournisseur -->
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="providerInvalid()">
<label class="fr-label" for="provider-input">
Fournisseur <span style="color:var(--text-default-error)">*</span>
</label>
<input
id="provider-input"
class="fr-input"
[class.fr-input--error]="providerInvalid()"
formControlName="provider"
type="text"
placeholder="DINUM, Direction XYZ..."
/>
@if (providerInvalid()) {
<p class="fr-error-text">Le fournisseur est obligatoire.</p>
}
</div>
<!-- Type -->
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="type-input">
Type <span style="color:var(--text-default-error)">*</span>
</label>
<select id="type-input" class="fr-select" formControlName="type">
<option value="ASYNCAPI">AsyncAPI</option>
<option value="OPENAPI">OpenAPI</option>
</select>
@if (detectedType()) {
<p class="fr-hint-text">Détecté automatiquement depuis le fichier YAML</p>
}
</div>
<!-- Description -->
<div class="fr-input-group fr-mb-3w"> <div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="description-input"> <label class="fr-label" for="description-input">
Description </label> Description
</label>
<textarea <textarea
id="description-input" id="description-input"
class="fr-input" class="fr-input"
@@ -194,76 +223,127 @@ import { Category } from '@datacat/shared';
></textarea> ></textarea>
</div> </div>
<div class="fr-input-group fr-mb-3w"> <!-- Version X.Y.Z -->
<label class="fr-label" for="functional-doc-input"> <p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
Documentation fonctionnelle </label> <div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
<textarea <div class="fr-col-4">
id="functional-doc-input" <div class="fr-input-group" [class.fr-input-group--error]="versionMajorInvalid()">
class="fr-input" <label class="fr-label" for="version-major-input">Majeure (X)</label>
formControlName="functionalDoc"
rows="4"
placeholder="Cas d'usage, contexte métier..."
></textarea>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="technical-doc-input">
Documentation technique </label>
<textarea
id="technical-doc-input"
class="fr-input"
formControlName="technicalDoc"
rows="4"
placeholder="Guide d'intégration technique..."
></textarea>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="external-doc-url-input">
URL documentation externe </label>
<input <input
id="external-doc-url-input" id="version-major-input"
class="fr-input" class="fr-input"
formControlName="externalDocUrl" [class.fr-input--error]="versionMajorInvalid()"
type="text" formControlName="versionMajor"
placeholder="https://..." type="number"
min="0"
placeholder="1"
/> />
</div> </div>
</div>
<div class="fr-input-group fr-mb-3w"> <div class="fr-col-4">
<label class="fr-label" for="contact-functional-input"> <div class="fr-input-group" [class.fr-input-group--error]="versionMinorInvalid()">
Contact fonctionnel </label> <label class="fr-label" for="version-minor-input">Mineure (Y)</label>
<input <input
id="contact-functional-input" id="version-minor-input"
class="fr-input" class="fr-input"
formControlName="contactFunctional" [class.fr-input--error]="versionMinorInvalid()"
type="text" formControlName="versionMinor"
placeholder="Nom, email, téléphone..." type="number"
min="0"
placeholder="0"
/> />
</div> </div>
</div>
<div class="fr-input-group fr-mb-3w"> <div class="fr-col-4">
<label class="fr-label" for="contact-technical-input"> <div class="fr-input-group" [class.fr-input-group--error]="versionPatchInvalid()">
Contact technique </label> <label class="fr-label" for="version-patch-input">Correctif (Z)</label>
<input <input
id="contact-technical-input" id="version-patch-input"
class="fr-input" class="fr-input"
formControlName="contactTechnical" [class.fr-input--error]="versionPatchInvalid()"
type="text" formControlName="versionPatch"
placeholder="Nom, email, téléphone..." type="number"
min="0"
placeholder="0"
/> />
</div> </div>
</div>
</div>
<div class="fr-input-group fr-mb-4w"> <!-- Contact métier -->
<label class="fr-label" for="access-rights-input"> <p class="fr-label fr-mb-1w">Contact métier <span style="color:var(--text-default-error)">*</span></p>
Droits d'accès </label> <div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
<textarea <div class="fr-col-12 fr-col-md-4">
id="access-rights-input" <div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalName')">
class="fr-input" <label class="fr-label" for="cf-name-input">Nom et prénom</label>
formControlName="accessRights" <input id="cf-name-input" class="fr-input"
rows="3" [class.fr-input--error]="fieldInvalid('contactFunctionalName')"
placeholder="Processus d'habilitation, conditions d'accès..." formControlName="contactFunctionalName" type="text" placeholder="Prénom Nom" />
></textarea> @if (fieldInvalid('contactFunctionalName')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalEntity')">
<label class="fr-label" for="cf-entity-input">Entité</label>
<input id="cf-entity-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalEntity')"
formControlName="contactFunctionalEntity" type="text" placeholder="Direction XYZ" />
@if (fieldInvalid('contactFunctionalEntity')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalEmail')">
<label class="fr-label" for="cf-email-input">Email</label>
<input id="cf-email-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalEmail')"
formControlName="contactFunctionalEmail" type="email" placeholder="prenom.nom@example.fr" />
@if (fieldInvalid('contactFunctionalEmail')) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<!-- Contact technique -->
<p class="fr-label fr-mb-1w">Contact technique <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalName')">
<label class="fr-label" for="ct-name-input">Nom et prénom</label>
<input id="ct-name-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalName')"
formControlName="contactTechnicalName" type="text" placeholder="Prénom Nom" />
@if (fieldInvalid('contactTechnicalName')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEntity')">
<label class="fr-label" for="ct-entity-input">Entité</label>
<input id="ct-entity-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalEntity')"
formControlName="contactTechnicalEntity" type="text" placeholder="Équipe Technique" />
@if (fieldInvalid('contactTechnicalEntity')) {
<p class="fr-error-text">Obligatoire.</p>
}
</div>
</div>
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactTechnicalEmail')">
<label class="fr-label" for="ct-email-input">Email</label>
<input id="ct-email-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactTechnicalEmail')"
formControlName="contactTechnicalEmail" type="email" placeholder="tech@example.fr" />
@if (fieldInvalid('contactTechnicalEmail')) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div> </div>
<div class="fr-btns-group fr-btns-group--inline-sm"> <div class="fr-btns-group fr-btns-group--inline-sm">
@@ -303,13 +383,17 @@ import { Category } from '@datacat/shared';
<dt class="fr-text--bold">Type</dt> <dt class="fr-text--bold">Type</dt>
<dd>{{ metaForm.value.type }}</dd> <dd>{{ metaForm.value.type }}</dd>
</div> </div>
<div class="fr-col-12 fr-col-md-6"> <div class="fr-col-12">
<dt class="fr-text--bold">Titre</dt> <dt class="fr-text--bold">Titre</dt>
<dd>{{ metaForm.value.title }}</dd> <dd>{{ previewTitle() }}</dd>
</div> </div>
<div class="fr-col-12 fr-col-md-6"> <div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Version</dt> <dt class="fr-text--bold">Version</dt>
<dd>{{ metaForm.value.version }}</dd> <dd>{{ metaForm.value.versionMajor }}.{{ metaForm.value.versionMinor }}.{{ metaForm.value.versionPatch }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Fournisseur</dt>
<dd>{{ metaForm.value.provider }}</dd>
</div> </div>
@if (metaForm.value.categoryId) { @if (metaForm.value.categoryId) {
<div class="fr-col-12 fr-col-md-6"> <div class="fr-col-12 fr-col-md-6">
@@ -356,6 +440,7 @@ export class UploadComponent implements OnInit {
private apiService = inject(ApiService); private apiService = inject(ApiService);
private categoryService = inject(CategoryService); private categoryService = inject(CategoryService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute);
step = signal(1); step = signal(1);
selectedFiles = signal<File[]>([]); selectedFiles = signal<File[]>([]);
@@ -376,30 +461,57 @@ export class UploadComponent implements OnInit {
]; ];
metaForm = this.fb.group({ metaForm = this.fb.group({
title: ['', Validators.required], convention: ['CONSULTER', Validators.required],
version: ['', Validators.required], name: ['', Validators.required],
provider: ['', Validators.required],
versionMajor: [0, [Validators.required, Validators.min(0)]],
versionMinor: [0, [Validators.required, Validators.min(0)]],
versionPatch: [0, [Validators.required, Validators.min(0)]],
type: ['ASYNCAPI', Validators.required], type: ['ASYNCAPI', Validators.required],
categoryId: [''], categoryId: [''],
description: [''], description: [''],
functionalDoc: [''], contactFunctionalName: ['', Validators.required],
technicalDoc: [''], contactFunctionalEntity: ['', Validators.required],
externalDocUrl: [''], contactFunctionalEmail: ['', [Validators.required, Validators.email]],
contactFunctional: [''], contactTechnicalName: ['', Validators.required],
contactTechnical: [''], contactTechnicalEntity: ['', Validators.required],
accessRights: [''], contactTechnicalEmail: ['', [Validators.required, Validators.email]],
}); });
titleInvalid = computed( /* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */
() => !!(this.metaForm.get('title')?.invalid && this.metaForm.get('title')?.touched), private readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
);
versionInvalid = computed( previewTitle = computed(() => {
() => !!(this.metaForm.get('version')?.invalid && this.metaForm.get('version')?.touched), const values = this.formValues();
); const convention = (values.convention ?? 'CONSULTER') as ApiConvention;
const name = values.name ?? '';
return `${API_CONVENTION_LABELS[convention]} ${name}`.trim();
});
selectedCategoryName = computed(() => { selectedCategoryName = computed(() => {
const id = this.metaForm.value.categoryId; const id = this.formValues().categoryId;
if (!id) return ''; if (!id) return '';
return this.categories().find((c) => c.id === id)?.name ?? ''; return this.categories().find((c) => c.id === id)?.name ?? '';
}); });
/* Méthodes de validation (pas de computed — la re-exécution est déclenchée par les événements DOM blur/input) */
conventionInvalid(): boolean {
return !!(this.metaForm.get('convention')?.invalid && this.metaForm.get('convention')?.touched);
}
nameInvalid(): boolean {
return !!(this.metaForm.get('name')?.invalid && this.metaForm.get('name')?.touched);
}
providerInvalid(): boolean {
return !!(this.metaForm.get('provider')?.invalid && this.metaForm.get('provider')?.touched);
}
versionMajorInvalid(): boolean {
return !!(this.metaForm.get('versionMajor')?.invalid && this.metaForm.get('versionMajor')?.touched);
}
versionMinorInvalid(): boolean {
return !!(this.metaForm.get('versionMinor')?.invalid && this.metaForm.get('versionMinor')?.touched);
}
versionPatchInvalid(): boolean {
return !!(this.metaForm.get('versionPatch')?.invalid && this.metaForm.get('versionPatch')?.touched);
}
canProceedFromStep1 = computed(() => { canProceedFromStep1 = computed(() => {
/* Sans fichier, on peut toujours passer à l'étape 2 */ /* Sans fichier, on peut toujours passer à l'étape 2 */
if (this.selectedFiles().length === 0) return true; if (this.selectedFiles().length === 0) return true;
@@ -408,9 +520,20 @@ export class UploadComponent implements OnInit {
return this.validationDone() && this.validationErrors().length === 0; return this.validationDone() && this.validationErrors().length === 0;
}); });
fieldInvalid(name: string): boolean {
const ctrl = this.metaForm.get(name);
return !!(ctrl?.invalid && ctrl?.touched);
}
ngOnInit() { ngOnInit() {
const preselectedCategoryId = this.route.snapshot.queryParamMap.get('categoryId');
this.categoryService.list().subscribe({ this.categoryService.list().subscribe({
next: (res) => this.categories.set(res.items), next: (res) => {
this.categories.set(res.items);
if (preselectedCategoryId) {
this.metaForm.patchValue({ categoryId: preselectedCategoryId });
}
},
}); });
} }
@@ -426,6 +549,7 @@ export class UploadComponent implements OnInit {
this.detectedMainFilename.set(null); this.detectedMainFilename.set(null);
this.validationErrors.set([]); this.validationErrors.set([]);
this.validationDone.set(false); this.validationDone.set(false);
this.metaForm.get('type')?.enable();
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const fileList = input.files; const fileList = input.files;
@@ -471,18 +595,22 @@ export class UploadComponent implements OnInit {
this.validationDone.set(true); this.validationDone.set(true);
if (!result.valid) { if (!result.valid) {
this.validationErrors.set(result.errors); this.validationErrors.set(result.errors);
this.metaForm.get('type')?.enable();
} else { } else {
this.validationErrors.set([]); this.validationErrors.set([]);
if (result.type) { if (result.type) {
this.detectedType.set(result.type); this.detectedType.set(result.type);
this.metaForm.patchValue({ type: result.type }); this.metaForm.patchValue({ type: result.type });
this.metaForm.get('type')?.disable();
} }
if (result.mainFile) { if (result.mainFile) {
this.detectedMainFilename.set(result.mainFile); this.detectedMainFilename.set(result.mainFile);
} }
/* Pré-remplissage du formulaire depuis les métadonnées du fichier */ /* Pré-remplissage du formulaire depuis les métadonnées du fichier */
if (result.title) this.metaForm.patchValue({ title: result.title }); if (result.name) this.metaForm.patchValue({ name: result.name });
if (result.version) this.metaForm.patchValue({ version: String(result.version) }); if (result.versionMajor !== null) this.metaForm.patchValue({ versionMajor: result.versionMajor });
if (result.versionMinor !== null) this.metaForm.patchValue({ versionMinor: result.versionMinor });
if (result.versionPatch !== null) this.metaForm.patchValue({ versionPatch: result.versionPatch });
if (result.description) this.metaForm.patchValue({ description: result.description }); if (result.description) this.metaForm.patchValue({ description: result.description });
} }
}, },
@@ -509,37 +637,30 @@ export class UploadComponent implements OnInit {
this.uploading.set(true); this.uploading.set(true);
this.uploadError.set(null); this.uploadError.set(null);
const raw = this.metaForm.getRawValue();
const formData = new FormData(); const formData = new FormData();
for (const f of files) { for (const f of files) {
formData.append('files', f); formData.append('files', f);
} }
formData.append('title', this.metaForm.value.title ?? ''); formData.append('convention', raw.convention ?? 'CONSULTER');
formData.append('version', this.metaForm.value.version ?? ''); formData.append('name', raw.name ?? '');
formData.append('type', this.metaForm.value.type ?? 'ASYNCAPI'); formData.append('provider', raw.provider ?? '');
if (this.metaForm.value.categoryId) { formData.append('versionMajor', String(raw.versionMajor ?? 0));
formData.append('categoryId', this.metaForm.value.categoryId); formData.append('versionMinor', String(raw.versionMinor ?? 0));
formData.append('versionPatch', String(raw.versionPatch ?? 0));
formData.append('type', raw.type ?? 'ASYNCAPI');
if (raw.categoryId) {
formData.append('categoryId', raw.categoryId);
} }
if (this.metaForm.value.description) { if (raw.description) {
formData.append('description', this.metaForm.value.description); formData.append('description', raw.description);
}
if (this.metaForm.value.functionalDoc) {
formData.append('functionalDoc', this.metaForm.value.functionalDoc);
}
if (this.metaForm.value.technicalDoc) {
formData.append('technicalDoc', this.metaForm.value.technicalDoc);
}
if (this.metaForm.value.externalDocUrl) {
formData.append('externalDocUrl', this.metaForm.value.externalDocUrl);
}
if (this.metaForm.value.contactFunctional) {
formData.append('contactFunctional', this.metaForm.value.contactFunctional);
}
if (this.metaForm.value.contactTechnical) {
formData.append('contactTechnical', this.metaForm.value.contactTechnical);
}
if (this.metaForm.value.accessRights) {
formData.append('accessRights', this.metaForm.value.accessRights);
} }
formData.append('contactFunctionalName', raw.contactFunctionalName ?? '');
formData.append('contactFunctionalEntity', raw.contactFunctionalEntity ?? '');
formData.append('contactFunctionalEmail', raw.contactFunctionalEmail ?? '');
formData.append('contactTechnicalName', raw.contactTechnicalName ?? '');
formData.append('contactTechnicalEntity', raw.contactTechnicalEntity ?? '');
formData.append('contactTechnicalEmail', raw.contactTechnicalEmail ?? '');
this.apiService.upload(formData).subscribe({ this.apiService.upload(formData).subscribe({
next: (entry) => { next: (entry) => {

View File

@@ -21,7 +21,7 @@ services:
NODE_ENV: development NODE_ENV: development
DOCS_OUTPUT_DIR: /app/docs-output DOCS_OUTPUT_DIR: /app/docs-output
working_dir: /workspace/back working_dir: /workspace/back
command: sh -c "cd /workspace && pnpm install && cd /workspace/back && pnpm run start:dev" command: sh -c "cd /workspace && pnpm install && /workspace/back/node_modules/.bin/tsc --project /workspace/shared/tsconfig.build.json && cd /workspace/back && pnpm run start:dev"
networks: networks:
- network - network
depends_on: depends_on:

51
pnpm-lock.yaml generated
View File

@@ -22,6 +22,9 @@ importers:
'@asyncapi/html-template': '@asyncapi/html-template':
specifier: 3.5.6 specifier: 3.5.6
version: 3.5.6(@noble/hashes@1.8.0)(encoding@0.1.13)(typescript@5.9.3) version: 3.5.6(@noble/hashes@1.8.0)(encoding@0.1.13)(typescript@5.9.3)
'@datacat/shared':
specifier: workspace:*
version: link:../shared
'@nestjs/common': '@nestjs/common':
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.1.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 11.1.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -10189,7 +10192,7 @@ snapshots:
dependencies: dependencies:
'@ampproject/remapping': 2.3.0 '@ampproject/remapping': 2.3.0
'@angular-devkit/architect': 0.1902.26(chokidar@4.0.3) '@angular-devkit/architect': 0.1902.26(chokidar@4.0.3)
'@angular-devkit/build-webpack': 0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) '@angular-devkit/build-webpack': 0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(postcss@8.5.12))
'@angular-devkit/core': 19.2.26(chokidar@4.0.3) '@angular-devkit/core': 19.2.26(chokidar@4.0.3)
'@angular/build': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(@angular/compiler@19.2.22)(@types/node@22.19.19)(chokidar@4.0.3)(jiti@1.21.7)(less@4.2.2)(postcss@8.5.12)(tailwindcss@3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)))(terser@5.39.0)(typescript@5.8.3)(yaml@2.9.0) '@angular/build': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(@angular/compiler@19.2.22)(@types/node@22.19.19)(chokidar@4.0.3)(jiti@1.21.7)(less@4.2.2)(postcss@8.5.12)(tailwindcss@3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)))(terser@5.39.0)(typescript@5.8.3)(yaml@2.9.0)
'@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3) '@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3)
@@ -10203,14 +10206,14 @@ snapshots:
'@babel/preset-env': 7.26.9(@babel/core@7.26.10) '@babel/preset-env': 7.26.9(@babel/core@7.26.10)
'@babel/runtime': 7.26.10 '@babel/runtime': 7.26.10
'@discoveryjs/json-ext': 0.6.3 '@discoveryjs/json-ext': 0.6.3
'@ngtools/webpack': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) '@ngtools/webpack': 19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))
'@vitejs/plugin-basic-ssl': 1.2.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)) '@vitejs/plugin-basic-ssl': 1.2.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))
ansi-colors: 4.1.3 ansi-colors: 4.1.3
autoprefixer: 10.4.20(postcss@8.5.12) autoprefixer: 10.4.20(postcss@8.5.12)
babel-loader: 9.2.1(@babel/core@7.26.10)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) babel-loader: 9.2.1(@babel/core@7.26.10)(webpack@5.105.0(postcss@8.5.12))
browserslist: 4.28.2 browserslist: 4.28.2
copy-webpack-plugin: 12.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) copy-webpack-plugin: 12.0.2(webpack@5.105.0(postcss@8.5.12))
css-loader: 7.1.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) css-loader: 7.1.2(webpack@5.105.0(postcss@8.5.12))
esbuild-wasm: 0.28.0 esbuild-wasm: 0.28.0
fast-glob: 3.3.3 fast-glob: 3.3.3
http-proxy-middleware: 3.0.5 http-proxy-middleware: 3.0.5
@@ -10218,22 +10221,22 @@ snapshots:
jsonc-parser: 3.3.1 jsonc-parser: 3.3.1
karma-source-map-support: 1.4.0 karma-source-map-support: 1.4.0
less: 4.2.2 less: 4.2.2
less-loader: 12.2.0(less@4.2.2)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) less-loader: 12.2.0(less@4.2.2)(webpack@5.105.0(postcss@8.5.12))
license-webpack-plugin: 4.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) license-webpack-plugin: 4.0.2(webpack@5.105.0(postcss@8.5.12))
loader-utils: 3.3.1 loader-utils: 3.3.1
mini-css-extract-plugin: 2.9.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) mini-css-extract-plugin: 2.9.2(webpack@5.105.0(postcss@8.5.12))
open: 10.1.0 open: 10.1.0
ora: 5.4.1 ora: 5.4.1
picomatch: 4.0.4 picomatch: 4.0.4
piscina: 4.8.0 piscina: 4.8.0
postcss: 8.5.12 postcss: 8.5.12
postcss-loader: 8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) postcss-loader: 8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))
resolve-url-loader: 5.0.0 resolve-url-loader: 5.0.0
rxjs: 7.8.1 rxjs: 7.8.1
sass: 1.85.0 sass: 1.85.0
sass-loader: 16.0.5(sass@1.85.0)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) sass-loader: 16.0.5(sass@1.85.0)(webpack@5.105.0(postcss@8.5.12))
semver: 7.7.1 semver: 7.7.1
source-map-loader: 5.0.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) source-map-loader: 5.0.0(webpack@5.105.0(postcss@8.5.12))
source-map-support: 0.5.21 source-map-support: 0.5.21
terser: 5.39.0 terser: 5.39.0
tree-kill: 1.2.2 tree-kill: 1.2.2
@@ -10243,7 +10246,7 @@ snapshots:
webpack-dev-middleware: 7.4.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)) webpack-dev-middleware: 7.4.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12))
webpack-dev-server: 5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)) webpack-dev-server: 5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12))
webpack-merge: 6.0.1 webpack-merge: 6.0.1
webpack-subresource-integrity: 5.1.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)) webpack-subresource-integrity: 5.1.0(webpack@5.105.0(postcss@8.5.12))
optionalDependencies: optionalDependencies:
esbuild: 0.28.0 esbuild: 0.28.0
tailwindcss: 3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3)) tailwindcss: 3.3.3(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.8.3))
@@ -10277,7 +10280,7 @@ snapshots:
- webpack-cli - webpack-cli
- yaml - yaml
'@angular-devkit/build-webpack@0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))': '@angular-devkit/build-webpack@0.1902.26(chokidar@4.0.3)(webpack-dev-server@5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12)))(webpack@5.105.0(postcss@8.5.12))':
dependencies: dependencies:
'@angular-devkit/architect': 0.1902.26(chokidar@4.0.3) '@angular-devkit/architect': 0.1902.26(chokidar@4.0.3)
rxjs: 7.8.1 rxjs: 7.8.1
@@ -13785,7 +13788,7 @@ snapshots:
'@next/swc-win32-x64-msvc@14.2.33': '@next/swc-win32-x64-msvc@14.2.33':
optional: true optional: true
'@ngtools/webpack@19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))': '@ngtools/webpack@19.2.26(@angular/compiler-cli@19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12))':
dependencies: dependencies:
'@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3) '@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3)
typescript: 5.8.3 typescript: 5.8.3
@@ -16051,7 +16054,7 @@ snapshots:
b4a@1.8.1: {} b4a@1.8.1: {}
babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
'@babel/core': 7.26.10 '@babel/core': 7.26.10
find-cache-dir: 4.0.0 find-cache-dir: 4.0.0
@@ -16729,7 +16732,7 @@ snapshots:
dependencies: dependencies:
is-what: 3.14.1 is-what: 3.14.1
copy-webpack-plugin@12.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): copy-webpack-plugin@12.0.2(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
fast-glob: 3.3.3 fast-glob: 3.3.3
glob-parent: 6.0.2 glob-parent: 6.0.2
@@ -16804,7 +16807,7 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
css-loader@7.1.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): css-loader@7.1.2(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
icss-utils: 5.1.0(postcss@8.5.12) icss-utils: 5.1.0(postcss@8.5.12)
postcss: 8.5.12 postcss: 8.5.12
@@ -18750,7 +18753,7 @@ snapshots:
dependencies: dependencies:
readable-stream: 2.3.8 readable-stream: 2.3.8
less-loader@12.2.0(less@4.2.2)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): less-loader@12.2.0(less@4.2.2)(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
less: 4.2.2 less: 4.2.2
optionalDependencies: optionalDependencies:
@@ -18776,7 +18779,7 @@ snapshots:
libphonenumber-js@1.13.2: {} libphonenumber-js@1.13.2: {}
license-webpack-plugin@4.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): license-webpack-plugin@4.0.2(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
webpack-sources: 3.4.1 webpack-sources: 3.4.1
optionalDependencies: optionalDependencies:
@@ -19068,7 +19071,7 @@ snapshots:
mimic-response@4.0.0: {} mimic-response@4.0.0: {}
mini-css-extract-plugin@2.9.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): mini-css-extract-plugin@2.9.2(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
schema-utils: 4.3.3 schema-utils: 4.3.3
tapable: 2.3.3 tapable: 2.3.3
@@ -20167,7 +20170,7 @@ snapshots:
postcss: 8.5.15 postcss: 8.5.15
ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.9.3)
postcss-loader@8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): postcss-loader@8.1.1(postcss@8.5.12)(typescript@5.8.3)(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
cosmiconfig: 9.0.1(typescript@5.8.3) cosmiconfig: 9.0.1(typescript@5.8.3)
jiti: 1.21.7 jiti: 1.21.7
@@ -20863,7 +20866,7 @@ snapshots:
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
sass-loader@16.0.5(sass@1.85.0)(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): sass-loader@16.0.5(sass@1.85.0)(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
neo-async: 2.6.2 neo-async: 2.6.2
optionalDependencies: optionalDependencies:
@@ -21241,7 +21244,7 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
source-map-loader@5.0.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): source-map-loader@5.0.0(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
iconv-lite: 0.6.3 iconv-lite: 0.6.3
source-map-js: 1.2.1 source-map-js: 1.2.1
@@ -22344,7 +22347,7 @@ snapshots:
webpack-sources@3.4.1: {} webpack-sources@3.4.1: {}
webpack-subresource-integrity@5.1.0(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12)): webpack-subresource-integrity@5.1.0(webpack@5.105.0(postcss@8.5.12)):
dependencies: dependencies:
typed-assert: 1.0.9 typed-assert: 1.0.9
webpack: 5.105.0(esbuild@0.28.0)(postcss@8.5.12) webpack: 5.105.0(esbuild@0.28.0)(postcss@8.5.12)

View File

@@ -2,6 +2,9 @@
"name": "@datacat/shared", "name": "@datacat/shared",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "src/index.ts", "main": "dist/index.js",
"types": "src/index.ts" "types": "dist/index.d.ts",
"scripts": {
"build": "tsc --project tsconfig.build.json"
}
} }

View File

@@ -1,19 +1,3 @@
// Zod schemas for validation (used in backend)
// Install zod in back/ if needed: pnpm add zod
export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const; export const API_TYPES = ['ASYNCAPI', 'OPENAPI'] as const;
export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const; export const API_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] as const;
export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const;
export interface CreateApiEntryDto {
title: string;
version: string;
description?: string;
type: 'ASYNCAPI' | 'OPENAPI';
}
export interface UpdateApiEntryDto {
title?: string;
version?: string;
description?: string;
categoryId?: string | null;
}

View File

@@ -2,23 +2,37 @@ export type ApiType = 'ASYNCAPI' | 'OPENAPI';
export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR'; export type ApiStatus = 'PENDING' | 'GENERATED' | 'ERROR';
export type ApiConvention = 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE';
export const API_CONVENTION_LABELS: Record<ApiConvention, string> = {
CONSULTER: 'Consulter',
ENREGISTRER: 'Enregistrer',
ETRE_NOTIFIE: 'Être notifié',
};
export interface ApiEntry { export interface ApiEntry {
id: string; id: string;
title: string; title: string;
name: string;
convention: ApiConvention;
version: string; version: string;
versionMajor: number;
versionMinor: number;
versionPatch: number;
description: string | null; description: string | null;
type: ApiType; type: ApiType;
provider: string;
yamlContent: string; yamlContent: string;
htmlPath: string | null; htmlPath: string | null;
status: ApiStatus; status: ApiStatus;
errorMessage: string | null; errorMessage: string | null;
categoryId: string | null; categoryId: string | null;
functionalDoc: string | null; contactFunctionalName: string;
technicalDoc: string | null; contactFunctionalEntity: string;
externalDocUrl: string | null; contactFunctionalEmail: string;
contactFunctional: string | null; contactTechnicalName: string;
contactTechnical: string | null; contactTechnicalEntity: string;
accessRights: string | null; contactTechnicalEmail: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -26,9 +40,12 @@ export interface ApiEntry {
export interface ApiEntryListItem { export interface ApiEntryListItem {
id: string; id: string;
title: string; title: string;
name: string;
convention: ApiConvention;
version: string; version: string;
description: string | null; description: string | null;
type: ApiType; type: ApiType;
provider: string;
status: ApiStatus; status: ApiStatus;
categoryId: string | null; categoryId: string | null;
createdAt: string; createdAt: string;

View File

@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "ES2021",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts"]
}