Compare commits

...

16 Commits

Author SHA1 Message Date
z3n
c8b3e393ea fix(search): correction recherche fuzzy catégories
- Backend : seuil word_similarity >= 0.4 (au lieu de > 0.4) sur cat.name
- Frontend : correction erreur TypeScript dans CategoryService.list()
  (params typé Record<string,string> pour satisfaire Angular HttpClient)
  — le code précédent ne compilait pas, la recherche était ignorée

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>
2026-06-19 13:31:49 +00:00
z3n
eb07ca085f feat(browse): tag version sur les tuiles + statut francisé sur la fiche détail
- Supprime le badge statut (GENERATED/PENDING/ERROR) des tuiles browse et cartes search
- Ajoute un badge version (ex: v1.2.0) sur les tuiles et cartes
- Traduit le statut en français sur la fiche détail (Générée / En attente / Erreur)

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>
2026-06-17 14:50:51 +00:00
z3n
dbf5af81ca fix(search): seuils word_similarity différenciés par champ
- name/provider : seuil 0.4 (textes courts, scores plus précis)
- description   : seuil 0.5 (textes longs, évite les faux positifs)
- "auten" trouve désormais "Authentification JWT" via fuzzy sur le nom

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>
2026-06-17 09:04:47 +00:00
z3n
498c6577e4 feat(search): recherche intelligente — parsing backend + pg_trgm fuzzy
- parseSearchTokens() côté backend : extrait type (async/openapi),
  version (v1, 1.2.3) et latestOnly depuis la chaîne brute
- Recherche fuzzy via word_similarity (pg_trgm, seuil 0.5) + ILIKE
- latestOnly automatique quand champ non vide sans version explicite
- Suppression de parseSearch() dans BrowseComponent et CatalogComponent
- Suppression de version/latestOnly de ApiListQuery (géré par le backend)
- Activation de pg_trgm via CREATE EXTENSION dans SeederService
- Tests unitaires Vitest pour parseSearchTokens (24 cas)

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>
2026-06-17 08:57:45 +00:00
z3n
e1a3dbc85b feat(api-detail): navigation entre versions + bouton nouvelle version
- GET /api/apis/:id/versions — retourne toutes les versions du même API
  (même name + convention), triées par version DESC
- Sélecteur DSFR sur la page détail quand plusieurs versions existent ;
  changement de version navigue vers /catalog/:newId en rechargeant
  le composant via abonnement à paramMap (fix réutilisation instance Angular)
- Bouton "Nouvelle version" → /upload?from=:id avec pré-remplissage du
  formulaire (convention, name, provider, contacts, version patch+1)
- Seeder : 2 versions démo supplémentaires de "Référentiel Produits"
  (v3.0.3, v3.0.2) pour tester le sélecteur
- Fix import ApiStatus manquant dans apis.service.ts

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>
2026-06-16 15:07:31 +00:00
z3n
1adb6820dd refactor: fix bugs et réduire duplication + extraire modales BrowseComponent
- fix(dto): ajouter @IsEmail() sur contactFunctionalEmail/TechnicalEmail dans UpdateApiEntryDto
- fix(docs): corriger stringify des erreurs (String(error) → error.message)
- refactor(mapper): extraire toApiEntryListItem() dans api-entry.mapper.ts partagé
- refactor(categories): supprimer CONVENTION_LABELS et toApiEntryListItem locaux dupliqués
- refactor(browse): extraire CategoryFormModalComponent et CategoryDeleteModalComponent
- refactor(api-detail): convertir editPreviewTitle() en computed()

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>
2026-06-11 15:10:38 +00:00
z3n
e939d592f8 feat(browse): modale de suppression + crayon d'édition sur le titre
- Remplace le confirm() natif par une vraie modale de confirmation avec gestion d'erreur inline
- Ajoute un bouton crayon dans le <h1> pour éditer la catégorie courante (visible uniquement dans /browse/:id)
- Signature openEdit() assouplie pour accepter Category (au lieu de CategoryWithCounts)

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>
2026-06-11 14:50:16 +00:00
z3n
b9d45c0c3c feat(api-detail): fil d'Ariane aligné sur la hiérarchie des catégories
Accueil > [ancêtres de catégorie] > [titre API], ou
Accueil > Parcourir > [titre API] si l'API n'a pas de catégorie.

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>
2026-06-11 13:54:24 +00:00
z3n
e85696079c 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>
2026-06-11 13:38:14 +00:00
z3n
ef3884c68d feat(upload): validation YAML côté serveur dès la sélection du fichier
- Nouvel endpoint POST /api/uploads/validate : validation en 3 niveaux
  (syntaxe js-yaml → détection type → asyncapi validate / redocly lint)
- Extraction automatique de info.title/version/description pour pré-remplir le formulaire
- Validation déclenchée immédiatement à la sélection, résultat affiché inline
- Bouton "Suivant" désactivé pendant la validation et si le fichier est invalide
- Sans fichier sélectionné, on peut toujours passer à l'étape 2

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>
2026-06-10 08:48:40 +00:00
z3n
abc20bc9e8 fix(forms): erreurs de validation par champ au blur
- Erreurs affichées sous chaque champ obligatoire (fr-error-text DSFR)
  au lieu d'un message global en haut du formulaire
- Déclenchement sur blur (sortie du champ) et effacement sur input
- Concerne la modale édition API et la modale catégorie

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>
2026-06-09 13:17:18 +00:00
z3n
af02cf6808 feat(api-detail): modale d'édition des métadonnées d'une API
- Bouton "Modifier" sur la page détail ouvre une modale avec tous les
  champs éditables (titre, version, catégorie, description, docs, contacts,
  droits d'accès)
- ApiService.update() étendu à tous les champs du UpdateApiEntryDto
- Remplacement des labels "Obligatoire"/"Optionnel" par un astérisque
  rouge sur les champs requis dans tous les formulaires

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>
2026-06-09 13:06:53 +00:00
z3n
ecb45e8248 feat(browse): icône crayon inline sur les tuiles + apiCount récursif
- Bouton modifier réduit à l'icône crayon, placé à côté du titre de la tuile
- apiCount dans CategoryWithCounts inclut désormais les APIs de tous les
  sous-dossiers récursivement (countApisRecursively)

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>
2026-06-09 13:01:46 +00:00
z3n
e54c21042f fix(assets): servir dist/icons/ au bon chemin pour dsfr.min.css
dsfr.min.css référence les SVG via ../icons/ (résout vers assets/icons/)
alors que icons.min.css les référence via ../../icons/ (assets/dsfr/icons/).
Les deux chemins sont maintenant mappés dans angular.json.

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>
2026-06-09 12:53:12 +00:00
z3n
9a8f28bbce feat(browse): recherche unifiée dossiers + APIs + fix icônes DSFR
- Recherche simultanée dans les dossiers (filtrage client-side) et les
  APIs (backend) via forkJoin — affiche dossiers en tuiles puis APIs en
  cartes, avec labels "Dossiers"/"APIs" si les deux ont des résultats
- Loader instantané dès la frappe (avant le debounce 400ms)
- Fix angular.json : ajout du mapping dist/icons/ → assets/dsfr/icons/
  pour servir les SVG référencés par le CSS DSFR (tous les icônes étaient
  cassés car le chemin relatif ../../icons/ donnait 404)

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>
2026-06-09 12:51:28 +00:00
z3n
a82c52c491 feat(browse): fusion Parcourir + Catalogue en une seule page
- Barre de recherche intégrée dans BrowseComponent
- Mode navigation (tuiles) si recherche vide, mode liste (cartes) si texte
- Debounce 400ms + pagination pour les résultats de recherche
- Suppression de l'entrée "Catalogue" du menu de navigation
- Redirection /catalog → /browse (vieux liens toujours fonctionnels)

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>
2026-06-09 09:41:10 +00:00
32 changed files with 2287 additions and 775 deletions

View File

@@ -12,6 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@datacat/shared": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^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 { ApiType, ApiStatus } from '@datacat/shared';
import { ApiType, ApiStatus, ApiConvention } from '@datacat/shared';
@Entity('api_entries')
export class ApiEntryEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
title!: string;
@Column({ type: 'varchar', length: 30, default: 'CONSULTER' })
convention!: ApiConvention;
@Column()
version!: string;
@Column({ type: 'varchar', length: 255, default: '' })
name!: string;
@Column({ nullable: true, type: 'text' })
description!: string | null;
@@ -18,6 +18,18 @@ export class ApiEntryEntity {
@Column({ type: 'varchar', length: 10 })
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' })
yamlContent!: string;
@@ -33,23 +45,23 @@ export class ApiEntryEntity {
@Column({ nullable: true, type: 'varchar', length: 36, name: 'category_id' })
categoryId!: string | null;
@Column({ nullable: true, type: 'text', name: 'functional_doc' })
functionalDoc!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_name' })
contactFunctionalName!: string;
@Column({ nullable: true, type: 'text', name: 'technical_doc' })
technicalDoc!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_entity' })
contactFunctionalEntity!: string;
@Column({ nullable: true, type: 'varchar', length: 500, name: 'external_doc_url' })
externalDocUrl!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_functional_email' })
contactFunctionalEmail!: string;
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_functional' })
contactFunctional!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_name' })
contactTechnicalName!: string;
@Column({ nullable: true, type: 'varchar', length: 255, name: 'contact_technical' })
contactTechnical!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_entity' })
contactTechnicalEntity!: string;
@Column({ nullable: true, type: 'text', name: 'access_rights' })
accessRights!: string | null;
@Column({ type: 'varchar', length: 255, default: '', name: 'contact_technical_email' })
contactTechnicalEmail!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;

View File

@@ -0,0 +1,19 @@
import { ApiEntryEntity } from './api-entry.entity';
import { ApiEntryListItem, API_CONVENTION_LABELS } from '@datacat/shared';
export function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return {
id: entity.id,
title: `${API_CONVENTION_LABELS[entity.convention]} ${entity.name}`,
name: entity.name,
convention: entity.convention,
version: `${entity.versionMajor}.${entity.versionMinor}.${entity.versionPatch}`,
description: entity.description,
type: entity.type,
provider: entity.provider,
status: entity.status,
categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}

View File

@@ -64,6 +64,11 @@ export class ApisController {
res.send(entry.yamlContent);
}
@Get(':id/versions')
getVersions(@Param('id') id: string) {
return this.apisService.findVersionsOf(id);
}
/** Sert les assets statiques (CSS, JS) générés par AsyncAPI CLI */
@Get(':id/*path')
async getDocAsset(@Param('id') id: string, @Req() req: Request, @Res() res: Response) {

View File

@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ApisService } from './apis.service';
import { ApiType } from '@datacat/shared';
/** Accès à la méthode privée pour les tests unitaires */
function parse(raw: string) {
return (ApisService.prototype as any).parseSearchTokens.call({}, raw) as {
text: string;
type?: ApiType;
version?: string;
latestOnly: boolean;
};
}
describe('ApisService.parseSearchTokens()', () => {
describe('chaîne vide', () => {
it('retourne text vide, pas de type, pas de version, latestOnly=false', () => {
expect(parse('')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false });
});
it('gère les espaces seuls', () => {
expect(parse(' ')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false });
});
});
describe('texte libre seul (sans version ni type)', () => {
it('"auth" → latestOnly=true automatique', () => {
expect(parse('auth')).toEqual({ text: 'auth', type: undefined, version: undefined, latestOnly: true });
});
it('"paiement commande" → texte libre multi-mots, latestOnly=true', () => {
expect(parse('paiement commande')).toEqual({
text: 'paiement commande',
type: undefined,
version: undefined,
latestOnly: true,
});
});
});
describe('détection du type', () => {
it('"async" → ASYNCAPI', () => {
const r = parse('async');
expect(r.type).toBe('ASYNCAPI');
});
it('"asyncapi" → ASYNCAPI', () => {
expect(parse('asyncapi').type).toBe('ASYNCAPI');
});
it('"openapi" → OPENAPI', () => {
expect(parse('openapi').type).toBe('OPENAPI');
});
it('"open" → OPENAPI', () => {
expect(parse('open').type).toBe('OPENAPI');
});
it('type seul → latestOnly=true (champ non vide, pas de version)', () => {
expect(parse('async').latestOnly).toBe(true);
});
it('mots de type ne font pas partie du texte libre', () => {
expect(parse('auth async').text).toBe('auth');
});
});
describe('détection de version', () => {
it('"v1" → version="1", latestOnly=false', () => {
expect(parse('v1')).toEqual({ text: '', type: undefined, version: '1', latestOnly: false });
});
it('"1.2" → version="1.2"', () => {
expect(parse('1.2').version).toBe('1.2');
});
it('"v1.2.3" → version="1.2.3" (préfixe v supprimé)', () => {
expect(parse('v1.2.3').version).toBe('1.2.3');
});
it('"1.0.0" → version exacte', () => {
expect(parse('1.0.0').version).toBe('1.0.0');
});
it('version présente → latestOnly=false', () => {
expect(parse('auth v1').latestOnly).toBe(false);
});
it('numéro de version ne fait pas partie du texte libre', () => {
expect(parse('auth v1').text).toBe('auth');
});
});
describe('mot-clé "latest"', () => {
it('"latest" seul → latestOnly=true, pas de texte', () => {
expect(parse('latest')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: true });
});
it('"latest" + type → latestOnly=true + type détecté', () => {
const r = parse('latest async');
expect(r.latestOnly).toBe(true);
expect(r.type).toBe('ASYNCAPI');
});
it('"latest" + version → les deux flags actifs', () => {
const r = parse('latest v1');
expect(r.latestOnly).toBe(true);
expect(r.version).toBe('1');
});
});
describe('combinaisons', () => {
it('"auth async" → texte=auth, type=ASYNCAPI, latestOnly=true', () => {
expect(parse('auth async')).toEqual({
text: 'auth',
type: 'ASYNCAPI',
version: undefined,
latestOnly: true,
});
});
it('"auth async v1" → texte=auth, type=ASYNCAPI, version=1, latestOnly=false', () => {
expect(parse('auth async v1')).toEqual({
text: 'auth',
type: 'ASYNCAPI',
version: '1',
latestOnly: false,
});
});
it('"auth 1.0.0" → texte=auth, version=1.0.0, latestOnly=false', () => {
expect(parse('auth 1.0.0')).toEqual({
text: 'auth',
type: undefined,
version: '1.0.0',
latestOnly: false,
});
});
it('"openapi latest" → type=OPENAPI, latestOnly=true', () => {
expect(parse('openapi latest')).toEqual({
text: '',
type: 'OPENAPI',
version: undefined,
latestOnly: true,
});
});
it('insensible à la casse pour les mots-clés', () => {
const r = parse('ASYNC Auth LATEST');
expect(r.type).toBe('ASYNCAPI');
expect(r.latestOnly).toBe(true);
expect(r.text).toBe('Auth');
});
});
});

View File

@@ -4,14 +4,12 @@ import { Repository } from 'typeorm';
import { ApiEntryEntity } from './api-entry.entity';
import { CreateApiEntryDto } from './dto/create-api-entry.dto';
import { UpdateApiEntryDto } from './dto/update-api-entry.dto';
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 { toApiEntryListItem } from './api-entry.mapper';
import { DocsGenerationService } from '../docs-generation/docs-generation.service';
/** Champs internes mis à jour par DocsGenerationService */
interface InternalUpdateFields {
title?: string;
version?: string;
description?: string;
htmlPath?: string | null;
status?: ApiStatus;
errorMessage?: string | null;
@@ -28,54 +26,130 @@ export class ApisService {
async create(dto: CreateApiEntryDto): Promise<ApiEntry> {
const entity = this.repo.create({
title: dto.title,
version: dto.version,
convention: dto.convention,
name: dto.name,
provider: dto.provider,
versionMajor: dto.versionMajor,
versionMinor: dto.versionMinor,
versionPatch: dto.versionPatch,
description: dto.description ?? null,
type: dto.type,
yamlContent: dto.yamlContent,
status: 'PENDING',
categoryId: dto.categoryId ?? null,
functionalDoc: dto.functionalDoc ?? null,
technicalDoc: dto.technicalDoc ?? null,
externalDocUrl: dto.externalDocUrl ?? null,
contactFunctional: dto.contactFunctional ?? null,
contactTechnical: dto.contactTechnical ?? null,
accessRights: dto.accessRights ?? null,
contactFunctionalName: dto.contactFunctionalName,
contactFunctionalEntity: dto.contactFunctionalEntity,
contactFunctionalEmail: dto.contactFunctionalEmail,
contactTechnicalName: dto.contactTechnicalName,
contactTechnicalEntity: dto.contactTechnicalEntity,
contactTechnicalEmail: dto.contactTechnicalEmail,
});
const saved = await this.repo.save(entity);
return toApiEntry(saved);
}
async findAll(query: ApiListQuery): Promise<ApiListResponse> {
const page = Math.max(1, Number(query.page ?? 1));
const page = Math.max(1, Number(query.page ?? 1));
const limit = Math.min(100, Math.max(1, Number(query.limit ?? 20)));
const offset = (page - 1) * limit;
const qb = this.repo.createQueryBuilder('api');
if (query.type) {
qb.andWhere('api.type = :type', { type: query.type as ApiType });
let effectiveType = query.type as ApiType | undefined;
let version: string | undefined;
let latestOnly = false;
if (query.search?.trim()) {
const parsed = this.parseSearchTokens(query.search);
// Le type détecté dans la saisie override le select UI
if (parsed.type) effectiveType = parsed.type;
version = parsed.version;
latestOnly = parsed.latestOnly;
if (parsed.text) {
// Recherche fuzzy via pg_trgm + ILIKE pour les matches partiels courts
qb.andWhere(
`(
api.name ILIKE :likeText
OR api.provider ILIKE :likeText
OR COALESCE(api.description, '') ILIKE :likeText
OR api.contact_functional_name ILIKE :likeText
OR api.contact_technical_name ILIKE :likeText
OR word_similarity(:text, api.name) > 0.4
OR word_similarity(:text, api.provider) > 0.4
OR word_similarity(:text, COALESCE(api.description, '')) > 0.5
)`,
{ likeText: `%${parsed.text}%`, text: parsed.text },
);
}
}
if (query.search) {
qb.andWhere(
'(LOWER(api.title) LIKE :search OR LOWER(api.description) LIKE :search)',
{ search: `%${query.search.toLowerCase()}%` },
);
if (effectiveType) {
qb.andWhere('api.type = :type', { type: effectiveType });
}
if (query.categoryId) {
qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId });
}
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
// Filtre par version (partielle ou complète)
if (version) {
const parts = version.split('.').map(Number);
if (parts.length >= 1 && !isNaN(parts[0]))
qb.andWhere('api.version_major = :major', { major: parts[0] });
if (parts.length >= 2 && !isNaN(parts[1]))
qb.andWhere('api.version_minor = :minor', { minor: parts[1] });
if (parts.length >= 3 && !isNaN(parts[2]))
qb.andWhere('api.version_patch = :patch', { patch: parts[2] });
}
// Latest-only : exclure les entrées dont il existe une version plus récente du même API
if (latestOnly) {
qb.andWhere(`NOT EXISTS (
SELECT 1 FROM api_entries e2
WHERE e2.name = api.name AND e2.convention = api.convention
AND (
e2.version_major > api.version_major
OR (e2.version_major = api.version_major AND e2.version_minor > api.version_minor)
OR (e2.version_major = api.version_major AND e2.version_minor = api.version_minor
AND e2.version_patch > api.version_patch)
)
)`);
}
qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit);
const [entities, total] = await qb.getManyAndCount();
return {
items: entities.map(toApiEntryListItem),
total,
page,
limit,
return { items: entities.map(toApiEntryListItem), total, page, limit };
}
private parseSearchTokens(raw: string): {
text: string;
type?: ApiType;
version?: string;
latestOnly: boolean;
} {
const TYPE_MAP: Record<string, ApiType> = {
async: 'ASYNCAPI', asyncapi: 'ASYNCAPI',
openapi: 'OPENAPI', open: 'OPENAPI',
};
const VERSION_RE = /^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/i;
let type: ApiType | undefined;
let version: string | undefined;
let latestOnly = false;
const remaining: string[] = [];
for (const word of raw.trim().split(/\s+/).filter(Boolean)) {
const lower = word.toLowerCase();
if (lower === 'latest') { latestOnly = true; }
else if (TYPE_MAP[lower]) { type = TYPE_MAP[lower]; }
else if (VERSION_RE.test(word)) { version = word.replace(/^v/i, ''); }
else { remaining.push(word); }
}
const text = remaining.join(' ');
// Pas de version explicite ET champ non vide → latest par défaut
if (!latestOnly && raw.trim().length > 0 && !version) latestOnly = true;
return { text, type, version, latestOnly };
}
async findOneOrThrow(id: string): Promise<ApiEntry> {
@@ -101,6 +175,19 @@ export class ApisService {
await this.repo.delete(id);
}
async findVersionsOf(id: string): Promise<ApiEntryListItem[]> {
const entry = await this.findOneOrThrow(id);
const entities = await this.repo.find({
where: { name: entry.name, convention: entry.convention },
order: {
versionMajor: 'DESC',
versionMinor: 'DESC',
versionPatch: 'DESC',
},
});
return entities.map(toApiEntryListItem);
}
async regenerate(id: string): Promise<ApiEntry> {
await this.findOneOrThrow(id);
await this.updateInternal(id, { status: 'PENDING', errorMessage: null });
@@ -109,39 +196,40 @@ 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 {
return {
id: entity.id,
title: entity.title,
version: entity.version,
title: buildTitle(entity.convention, entity.name),
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,
type: entity.type,
provider: entity.provider,
yamlContent: entity.yamlContent,
htmlPath: entity.htmlPath,
status: entity.status,
errorMessage: entity.errorMessage,
categoryId: entity.categoryId,
functionalDoc: entity.functionalDoc,
technicalDoc: entity.technicalDoc,
externalDocUrl: entity.externalDocUrl,
contactFunctional: entity.contactFunctional,
contactTechnical: entity.contactTechnical,
accessRights: entity.accessRights,
contactFunctionalName: entity.contactFunctionalName,
contactFunctionalEntity: entity.contactFunctionalEntity,
contactFunctionalEmail: entity.contactFunctionalEmail,
contactTechnicalName: entity.contactTechnicalName,
contactTechnicalEntity: entity.contactTechnicalEntity,
contactTechnicalEmail: entity.contactTechnicalEmail,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return {
id: entity.id,
title: entity.title,
version: entity.version,
description: entity.description,
type: entity.type,
status: entity.status,
categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}

View File

@@ -1,14 +1,33 @@
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID } from 'class-validator';
import { ApiType } from '@datacat/shared';
import { IsString, IsNotEmpty, IsIn, IsOptional, IsUUID, IsInt, Min, IsEmail } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiType, ApiConvention } from '@datacat/shared';
export class CreateApiEntryDto {
@IsString()
@IsNotEmpty()
title!: string;
@IsIn(['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'])
convention!: ApiConvention;
@IsString()
@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()
@IsOptional()
@@ -26,26 +45,24 @@ export class CreateApiEntryDto {
categoryId?: string;
@IsString()
@IsOptional()
functionalDoc?: string;
@IsNotEmpty()
contactFunctionalName!: string;
@IsString()
@IsOptional()
technicalDoc?: string;
@IsNotEmpty()
contactFunctionalEntity!: string;
@IsEmail()
contactFunctionalEmail!: string;
@IsString()
@IsOptional()
externalDocUrl?: string;
@IsNotEmpty()
contactTechnicalName!: string;
@IsString()
@IsOptional()
contactFunctional?: string;
@IsNotEmpty()
contactTechnicalEntity!: string;
@IsString()
@IsOptional()
contactTechnical?: string;
@IsString()
@IsOptional()
accessRights?: string;
@IsEmail()
contactTechnicalEmail!: string;
}

View File

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

View File

@@ -10,10 +10,8 @@ import {
CategoryWithCounts,
CategoryBrowseResponse,
CategoryListResponse,
ApiEntryListItem,
ApiType,
ApiStatus,
} from '@datacat/shared';
import { toApiEntryListItem } from '../apis/api-entry.mapper';
@Injectable()
export class CategoriesService {
@@ -24,7 +22,26 @@ export class CategoriesService {
private readonly apiRepo: Repository<ApiEntryEntity>,
) {}
async findAll(): Promise<CategoryListResponse> {
async findAll(search?: string): Promise<CategoryListResponse> {
if (search?.trim()) {
const text = search.trim();
const entities = await this.categoryRepo
.createQueryBuilder('cat')
.where(
`(
cat.name ILIKE :likeText
OR COALESCE(cat.description, '') ILIKE :likeText
OR word_similarity(:text, cat.name) >= 0.4
OR word_similarity(:text, COALESCE(cat.description, '')) > 0.5
)`,
{ likeText: `%${text}%`, text },
)
.orderBy('cat.order_index', 'ASC')
.addOrderBy('cat.name', 'ASC')
.getMany();
return { items: entities.map(toCategory) };
}
const entities = await this.categoryRepo.find({
order: { orderIndex: 'ASC', name: 'ASC' },
});
@@ -81,6 +98,13 @@ export class CategoriesService {
await this.categoryRepo.delete(id);
}
private async countApisRecursively(categoryId: string): Promise<number> {
const direct = await this.apiRepo.count({ where: { categoryId } });
const children = await this.categoryRepo.find({ where: { parentId: categoryId }, select: ['id'] });
const childCounts = await Promise.all(children.map((c) => this.countApisRecursively(c.id)));
return direct + childCounts.reduce((sum, n) => sum + n, 0);
}
async browse(categoryId: string | null): Promise<CategoryBrowseResponse> {
/* 1. Charge la catégorie courante */
let currentCategory: CategoryEntity | null = null;
@@ -111,7 +135,7 @@ export class CategoriesService {
const subcategories: CategoryWithCounts[] = await Promise.all(
directChildren.map(async (child) => {
const subcategoryCount = await this.categoryRepo.count({ where: { parentId: child.id } });
const apiCount = await this.apiRepo.count({ where: { categoryId: child.id } });
const apiCount = await this.countApisRecursively(child.id);
return { ...toCategory(child), subcategoryCount, apiCount };
}),
);
@@ -143,16 +167,3 @@ function toCategory(entity: CategoryEntity): Category {
};
}
function toApiEntryListItem(entity: ApiEntryEntity): ApiEntryListItem {
return {
id: entity.id,
title: entity.title,
version: entity.version,
description: entity.description,
type: entity.type as ApiType,
status: entity.status as ApiStatus,
categoryId: entity.categoryId,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
};
}

View File

@@ -46,7 +46,7 @@ export class DocsGenerationService {
} catch (error) {
await this.apisService.updateInternal(entryId, {
status: 'ERROR',
errorMessage: String(error),
errorMessage: error instanceof Error ? error.message : String(error),
});
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, DataSource } from 'typeorm';
import * as fs from 'fs/promises';
import { ApiEntryEntity } from '../apis/api-entry.entity';
import { CategoryEntity } from '../categories/category.entity';
@@ -593,9 +593,13 @@ export class SeederService implements OnApplicationBootstrap {
@InjectRepository(CategoryEntity)
private readonly categoryRepo: Repository<CategoryEntity>,
private readonly docsService: DocsGenerationService,
private readonly dataSource: DataSource,
) {}
async onApplicationBootstrap(): Promise<void> {
// Activer l'extension pg_trgm pour la recherche fuzzy
await this.dataSource.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
const forceReseed = process.env.FORCE_RESEED === 'true';
if (forceReseed) {
await this.reset();
@@ -688,85 +692,145 @@ export class SeederService implements OnApplicationBootstrap {
/* APIs de démo */
const entries = await this.apiRepo.save([
this.apiRepo.create({
title: 'Swagger Petstore',
version: '3.0.4',
convention: 'CONSULTER',
name: 'Référentiel Produits',
versionMajor: 3,
versionMinor: 0,
versionPatch: 4,
description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI',
yamlContent: PETSTORE_YAML,
status: 'PENDING',
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.',
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.',
externalDocUrl: null,
contactFunctional: 'Équipe Produit',
contactTechnical: 'Équipe API',
accessRights: 'API publique — aucune habilitation requise.',
provider: 'DINUM',
contactFunctionalName: 'Alice Martin',
contactFunctionalEntity: 'Équipe Produit',
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
contactTechnicalName: 'Bob Dupont',
contactTechnicalEntity: 'Équipe API',
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
}),
this.apiRepo.create({
title: 'Streetlights Kafka API',
version: '1.0.0',
convention: 'ETRE_NOTIFIE',
name: 'Éclairage Urbain Kafka',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description:
'The Smartylighting Streetlights API allows you to remotely manage the city lights using Kafka messaging.',
type: 'ASYNCAPI',
yamlContent: STREETLIGHTS_YAML,
status: 'PENDING',
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.',
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.',
externalDocUrl: null,
contactFunctional: 'Bureau Infrastructure',
contactTechnical: 'ops@example.com',
accessRights: 'Accès sur demande — habilitation DSI requise.',
provider: 'Bureau Infrastructure',
contactFunctionalName: 'Claire Leroy',
contactFunctionalEntity: 'Bureau Infrastructure',
contactFunctionalEmail: 'claire.leroy@example.gouv.fr',
contactTechnicalName: 'David Chen',
contactTechnicalEntity: 'Ops',
contactTechnicalEmail: 'ops@example.com',
}),
this.apiRepo.create({
title: 'Account Service',
version: '1.0.0',
convention: 'ETRE_NOTIFIE',
name: 'Comptes Utilisateurs',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: "Service de gestion des comptes utilisateurs avec publication d'événements.",
type: 'ASYNCAPI',
yamlContent: ACCOUNT_SERVICE_YAML,
status: 'PENDING',
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.',
technicalDoc: 'Consommer via broker AMQP ou Kafka. Canaux : user/signedup, user/passwordchanged. Payloads JSON avec horodatage ISO 8601. Spec AsyncAPI 3.0.',
externalDocUrl: null,
contactFunctional: 'Équipe Comptes',
contactTechnical: 'dev-core@example.com',
accessRights: 'Interne uniquement — accès réservé aux services du SI.',
provider: 'Équipe Comptes',
contactFunctionalName: 'Emma Roux',
contactFunctionalEntity: 'Équipe Comptes',
contactFunctionalEmail: 'emma.roux@example.gouv.fr',
contactTechnicalName: 'François Blanc',
contactTechnicalEntity: 'Dev Core',
contactTechnicalEmail: 'dev-core@example.com',
}),
this.apiRepo.create({
title: 'Orders API',
version: '1.0.0',
convention: 'ENREGISTRER',
name: 'Commandes Clients',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: 'API de gestion des commandes clients.',
type: 'OPENAPI',
yamlContent: ORDERS_YAML,
status: 'PENDING',
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).',
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.',
externalDocUrl: null,
contactFunctional: 'Équipe Commerce',
contactTechnical: 'api-orders@example.com',
accessRights: 'Partenaires agréés uniquement — convention de partenariat requise.',
provider: 'Équipe Commerce',
contactFunctionalName: 'Gaëlle Moreau',
contactFunctionalEntity: 'Équipe Commerce',
contactFunctionalEmail: 'gaelle.moreau@example.gouv.fr',
contactTechnicalName: 'Hugo Simon',
contactTechnicalEntity: 'API Commerce',
contactTechnicalEmail: 'api-orders@example.com',
}),
this.apiRepo.create({
title: 'Auth API',
version: '1.0.0',
convention: 'CONSULTER',
name: 'Référentiel Produits',
versionMajor: 3,
versionMinor: 0,
versionPatch: 3,
description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI',
yamlContent: PETSTORE_YAML,
status: 'PENDING',
categoryId: produits.id,
provider: 'DINUM',
contactFunctionalName: 'Alice Martin',
contactFunctionalEntity: 'Équipe Produit',
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
contactTechnicalName: 'Bob Dupont',
contactTechnicalEntity: 'Équipe API',
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
}),
this.apiRepo.create({
convention: 'CONSULTER',
name: 'Référentiel Produits',
versionMajor: 3,
versionMinor: 0,
versionPatch: 2,
description:
'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification.',
type: 'OPENAPI',
yamlContent: PETSTORE_YAML,
status: 'PENDING',
categoryId: produits.id,
provider: 'DINUM',
contactFunctionalName: 'Alice Martin',
contactFunctionalEntity: 'Équipe Produit',
contactFunctionalEmail: 'alice.martin@example.gouv.fr',
contactTechnicalName: 'Bob Dupont',
contactTechnicalEntity: 'Équipe API',
contactTechnicalEmail: 'bob.dupont@example.gouv.fr',
}),
this.apiRepo.create({
convention: 'CONSULTER',
name: 'Authentification JWT',
versionMajor: 1,
versionMinor: 0,
versionPatch: 0,
description: "Service d'authentification et d'autorisation.",
type: 'OPENAPI',
yamlContent: AUTH_YAML,
status: 'PENDING',
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é.',
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.',
externalDocUrl: null,
contactFunctional: 'Équipe Sécurité',
contactTechnical: 'securite@example.com',
accessRights: 'Restreint — habilitation RSSI requise avant intégration.',
provider: 'Équipe Sécurité',
contactFunctionalName: 'Isabelle Petit',
contactFunctionalEntity: 'Équipe Sécurité',
contactFunctionalEmail: 'isabelle.petit@example.gouv.fr',
contactTechnicalName: 'Julien Bernard',
contactTechnicalEntity: 'Sécurité SI',
contactTechnicalEmail: 'securite@example.com',
}),
]);
console.log('[Seeder] 8 catégories et 5 APIs de démo créées — génération docs en cours...');
console.log('[Seeder] 8 catégories et 7 APIs de démo créées — génération docs en cours...');
/* Déclenche la génération de docs en fire-and-forget pour chaque API */
for (const entry of entries) {

View File

@@ -39,6 +39,139 @@ export class UploadsController {
private readonly docsService: DocsGenerationService,
) {}
@Post('validate')
@UseInterceptors(
FilesInterceptor('files', 20, {
storage: diskStorage({ destination: path.join(os.tmpdir(), 'datacat-uploads') }),
fileFilter: (_req, file, cb) => {
if (!file.originalname.match(/\.(yaml|yml)$/i)) {
return cb(new BadRequestException('Only YAML files are allowed'), false);
}
cb(null, true);
},
}),
)
async validate(
@UploadedFiles() files: Express.Multer.File[],
): Promise<{
valid: boolean;
type: ApiType | null;
mainFile: string | null;
name: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null;
errors: Array<{ file: string; message: string }>;
}> {
if (!files || files.length === 0) {
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 */
const fileContents = files.map((file) => ({
name: file.originalname,
path: file.path,
content: fs.readFileSync(file.path, 'utf-8'),
}));
/* Nettoyage immédiat des fichiers temporaires */
for (const file of files) {
fsPromises.unlink(file.path).catch(() => undefined);
}
const errors: Array<{ file: string; message: string }> = [];
/* Validation syntaxique de chaque fichier */
for (const { name, content } of fileContents) {
try {
const parsed = yaml.load(content);
if (parsed === null || typeof parsed !== 'object') {
errors.push({ file: name, message: 'le fichier YAML est vide ou invalide' });
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push({ file: name, message: `erreur de syntaxe YAML — ${msg}` });
}
}
if (errors.length > 0) {
return { valid: false, type: null, mainFile: null, name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null, errors };
}
/* Détection du fichier principal */
let mainFileName: string | null = null;
let detectedType: ApiType | null = null;
for (const { name, content } of fileContents) {
const type = this.detectTypeFromContent(content);
if (type) {
mainFileName = name;
detectedType = type;
break;
}
}
if (!mainFileName) {
return {
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).' }],
};
}
/* Validation complète via CLI (asyncapi validate / redocly lint) */
const tempDir = path.join(os.tmpdir(), 'datacat-validate', randomUUID());
try {
await fsPromises.mkdir(tempDir, { recursive: true });
/* Écrire tous les fichiers dans le répertoire temp avec leurs noms d'origine (pour les $ref) */
for (const { name, content } of fileContents) {
await fsPromises.writeFile(path.join(tempDir, name), content, 'utf-8');
}
const mainPath = path.join(tempDir, mainFileName);
try {
if (detectedType === 'ASYNCAPI') {
await execFileAsync('asyncapi', ['validate', mainPath], { env: execEnv, timeout: 30000 });
} else {
await execFileAsync('redocly', ['lint', mainPath], { env: execEnv, timeout: 30000 });
}
} catch (cliError) {
const err = cliError as { stdout?: string; stderr?: string };
const output = (err.stdout || err.stderr || String(cliError)).trim();
return {
valid: false, type: detectedType, mainFile: mainFileName,
name: null, versionMajor: null, versionMinor: null, versionPatch: null, description: null,
errors: [{ file: mainFileName, message: output }],
};
}
} finally {
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
}
/* Extraction des métadonnées depuis le fichier principal */
const mainContent = fileContents.find((f) => f.name === mainFileName)!.content;
const parsed = yaml.load(mainContent) as Record<string, unknown>;
const info = parsed['info'] as Record<string, unknown> | undefined;
const name = typeof info?.['title'] === 'string' ? info['title'] : null;
const description = typeof info?.['description'] === 'string' ? info['description'] : null;
/* 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()
@UseInterceptors(
FilesInterceptor('files', 20, {
@@ -55,22 +188,26 @@ export class UploadsController {
@UploadedFiles() files: Express.Multer.File[],
@Body()
body: {
title: string;
version: string;
convention: string;
name: string;
provider: string;
versionMajor: string;
versionMinor: string;
versionPatch: string;
description?: string;
type?: ApiType;
categoryId?: string;
functionalDoc?: string;
technicalDoc?: string;
externalDocUrl?: string;
contactFunctional?: string;
contactTechnical?: string;
accessRights?: string;
contactFunctionalName: string;
contactFunctionalEntity: string;
contactFunctionalEmail: string;
contactTechnicalName: string;
contactTechnicalEntity: string;
contactTechnicalEmail: string;
},
) {
if (!files || files.length === 0) throw new BadRequestException('YAML file is required');
if (!body.title) throw new BadRequestException('title is required');
if (!body.version) throw new BadRequestException('version is required');
if (!body.name) throw new BadRequestException('name is required');
if (!body.convention) throw new BadRequestException('convention is required');
/* Crée le répertoire si absent */
const uploadDir = path.join(os.tmpdir(), 'datacat-uploads');
@@ -105,18 +242,22 @@ export class UploadsController {
}
const entry = await this.apisService.create({
title: body.title,
version: body.version,
convention: (body.convention as 'CONSULTER' | 'ENREGISTRER' | 'ETRE_NOTIFIE') ?? 'CONSULTER',
name: body.name,
provider: body.provider,
versionMajor: parseInt(body.versionMajor, 10) || 0,
versionMinor: parseInt(body.versionMinor, 10) || 0,
versionPatch: parseInt(body.versionPatch, 10) || 0,
description: body.description,
type: apiType,
yamlContent,
categoryId: body.categoryId || undefined,
functionalDoc: body.functionalDoc || undefined,
technicalDoc: body.technicalDoc || undefined,
externalDocUrl: body.externalDocUrl || undefined,
contactFunctional: body.contactFunctional || undefined,
contactTechnical: body.contactTechnical || undefined,
accessRights: body.accessRights || undefined,
contactFunctionalName: body.contactFunctionalName,
contactFunctionalEntity: body.contactFunctionalEntity,
contactFunctionalEmail: body.contactFunctionalEmail,
contactTechnicalName: body.contactTechnicalName,
contactTechnicalEntity: body.contactTechnicalEntity,
contactTechnicalEmail: body.contactTechnicalEmail,
});
/* Fire-and-forget : génération asynchrone */
@@ -180,7 +321,7 @@ export class UploadsController {
if (type === 'OPENAPI') {
/* 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');
} else {
/* @asyncapi/bundler API programmatique : aucun appel réseau */

View File

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

9
back/vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts'],
},
});

View File

@@ -36,6 +36,16 @@
"glob": "**/*",
"input": "node_modules/@gouvfr/dsfr/dist/utility",
"output": "assets/dsfr/utility"
},
{
"glob": "**/*",
"input": "node_modules/@gouvfr/dsfr/dist/icons",
"output": "assets/dsfr/icons"
},
{
"glob": "**/*",
"input": "node_modules/@gouvfr/dsfr/dist/icons",
"output": "assets/icons"
}
],
"styles": ["src/styles.scss"],

View File

@@ -1,10 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { RouterOutlet, RouterLink } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
imports: [RouterOutlet, RouterLink],
template: `
<header role="banner" class="fr-header">
<div class="fr-header__body">
@@ -17,7 +17,7 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
</div>
</div>
<div class="fr-header__service">
<a href="/" routerLink="/browse" title="Accueil — Datacat">
<a href="/" routerLink="/" title="Accueil — Datacat">
<p class="fr-header__service-title">Datacat</p>
</a>
<p class="fr-header__service-tagline">Catalogue de données d'API</p>
@@ -26,34 +26,6 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
</div>
</div>
</div>
<div class="fr-header__menu">
<div class="fr-container">
<nav class="fr-nav" role="navigation" aria-label="Menu principal">
<ul class="fr-nav__list">
<li class="fr-nav__item">
<a
class="fr-nav__link"
routerLink="/browse"
routerLinkActive="fr-nav__link--active"
[routerLinkActiveOptions]="{ exact: false }"
>
Parcourir
</a>
</li>
<li class="fr-nav__item">
<a
class="fr-nav__link"
routerLink="/catalog"
routerLinkActive="fr-nav__link--active"
[routerLinkActiveOptions]="{ exact: false }"
>
Catalogue
</a>
</li>
</ul>
</nav>
</div>
</div>
</header>
<main>
<router-outlet />

View File

@@ -3,8 +3,8 @@ import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
redirectTo: 'browse',
pathMatch: 'full',
loadComponent: () =>
import('./pages/browse/browse.component').then((m) => m.BrowseComponent),
},
{
path: 'browse',
@@ -18,8 +18,8 @@ export const routes: Routes = [
},
{
path: 'catalog',
loadComponent: () =>
import('./pages/catalog/catalog.component').then((m) => m.CatalogComponent),
redirectTo: 'browse',
pathMatch: 'full',
},
{
path: 'catalog/:id',
@@ -38,6 +38,6 @@ export const routes: Routes = [
},
{
path: '**',
redirectTo: 'browse',
redirectTo: '',
},
];

View File

@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiEntry, ApiListResponse, ApiListQuery } from '@datacat/shared';
import { ApiEntry, ApiEntryListItem, ApiListResponse, ApiListQuery } from '@datacat/shared';
@Injectable({ providedIn: 'root' })
export class ApiService {
@@ -22,7 +22,22 @@ export class ApiService {
return this.http.get<ApiEntry>(`${this.baseUrl}/${id}`);
}
update(id: string, data: { title?: string; version?: string; description?: string }): Observable<ApiEntry> {
update(id: string, data: {
convention?: string;
name?: string;
provider?: string;
versionMajor?: number;
versionMinor?: number;
versionPatch?: number;
description?: string | null;
categoryId?: string | null;
contactFunctionalName?: string;
contactFunctionalEntity?: string;
contactFunctionalEmail?: string;
contactTechnicalName?: string;
contactTechnicalEntity?: string;
contactTechnicalEmail?: string;
}): Observable<ApiEntry> {
return this.http.patch<ApiEntry>(`${this.baseUrl}/${id}`, data);
}
@@ -30,6 +45,10 @@ export class ApiService {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
getVersions(id: string): Observable<ApiEntryListItem[]> {
return this.http.get<ApiEntryListItem[]>(`${this.baseUrl}/${id}/versions`);
}
regenerate(id: string): Observable<ApiEntry> {
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
}
@@ -37,4 +56,28 @@ export class ApiService {
upload(formData: FormData): Observable<ApiEntry> {
return this.http.post<ApiEntry>('/api/uploads', formData);
}
validate(formData: FormData): Observable<{
valid: boolean;
type: 'ASYNCAPI' | 'OPENAPI' | null;
mainFile: string | null;
name: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null;
errors: Array<{ file: string; message: string }>;
}> {
return this.http.post<{
valid: boolean;
type: 'ASYNCAPI' | 'OPENAPI' | null;
mainFile: string | null;
name: string | null;
versionMajor: number | null;
versionMinor: number | null;
versionPatch: number | null;
description: string | null;
errors: Array<{ file: string; message: string }>;
}>('/api/uploads/validate', formData);
}
}

View File

@@ -12,8 +12,10 @@ export class CategoryService {
private http = inject(HttpClient);
private baseUrl = '/api/categories';
list(): Observable<CategoryListResponse> {
return this.http.get<CategoryListResponse>(this.baseUrl);
list(search?: string): Observable<CategoryListResponse> {
const params: Record<string, string> = {};
if (search?.trim()) params['search'] = search;
return this.http.get<CategoryListResponse>(this.baseUrl, { params });
}
browse(id: string | null): Observable<CategoryBrowseResponse> {

View File

@@ -1,20 +1,24 @@
import {
Component,
signal,
computed,
inject,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import { ApiService } from '../../core/api.service';
import { ApiEntry } from '@datacat/shared';
import { CategoryService } from '../../core/category.service';
import { ApiEntry, ApiEntryListItem, Category } from '@datacat/shared';
@Component({
selector: 'app-api-detail',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
@@ -22,8 +26,14 @@ import { ApiEntry } from '@datacat/shared';
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
<li>
<a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a>
<a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
</li>
@if (categoryBreadcrumb().length === 0) {
<li><a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a></li>
}
@for (crumb of categoryBreadcrumb(); track crumb.id) {
<li><a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a></li>
}
<li>
<a class="fr-breadcrumb__link" aria-current="page">
{{ api()?.title ?? 'Chargement...' }}
@@ -46,178 +56,355 @@ import { ApiEntry } from '@datacat/shared';
}
@if (api(); as entry) {
<!-- Retour -->
<a class="fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w" style="display:inline-flex;"
[routerLink]="backUrl()">Retour</a>
<!-- En-tête -->
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h1 class="fr-h2 fr-mb-1w">{{ entry.title }}</h1>
<div class="fr-tags-group">
<span class="fr-tag">{{ entry.type }}</span>
<div class="fr-mb-3w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
<!-- Gauche : titre + version -->
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;min-width:0;">
<h1 class="fr-h2 fr-mb-0">{{ entry.title }}</h1>
@if (versions().length <= 1) {
<span class="fr-tag">v{{ entry.version }}</span>
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span>
</div>
</div>
</div>
<!-- Alerte statut PENDING -->
@if (entry.status === 'PENDING') {
<div class="fr-alert fr-alert--info fr-mb-3w">
<p class="fr-alert__title">Génération en cours</p>
<p>La documentation est en cours de génération. Cette page se rafraîchit automatiquement.</p>
</div>
}
<!-- Alerte statut ERROR -->
@if (entry.status === 'ERROR') {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p class="fr-alert__title">Erreur de génération</p>
<p>{{ entry.errorMessage }}</p>
</div>
}
<!-- Métadonnées -->
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Informations</h2>
<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">
<dt class="fr-text--bold">Version</dt>
<dd>{{ entry.version }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Type</dt>
<dd>{{ entry.type }}</dd>
</div>
@if (entry.description) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
<dd>{{ entry.description }}</dd>
</div>
} @else {
<select class="fr-select" id="version-select" style="width:auto;" (change)="onVersionChange($event)">
@for (v of versions(); track v.id) {
<option [value]="v.id" [selected]="v.id === entry.id">
{{ v.version }}{{ v.isCurrent ? ' (courante)' : '' }}
</option>
}
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Créé le</dt>
<dd>{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Mis à jour le</dt>
<dd>{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
</dl>
</div>
</select>
}
</div>
<!-- Droite : bouton Actions + dropdown -->
<div style="position:relative;flex-shrink:0;">
<button class="fr-btn fr-btn--secondary fr-btn--icon-right fr-icon-arrow-down-s-line"
(click)="actionsOpen.set(!actionsOpen())">
Actions
</button>
@if (actionsOpen()) {
<div style="position:absolute;right:0;top:calc(100% + 4px);z-index:1000;background:#fff;border:1px solid #ddd;box-shadow:0 4px 12px rgba(0,0,0,.15);min-width:220px;border-radius:4px;overflow:hidden;">
<a [routerLink]="['/catalog', entry.id, 'docs']"
(click)="actionsOpen.set(false)"
style="display:block;padding:0.75rem 1rem;text-decoration:none;color:#161616;border-bottom:1px solid #eee;"
[style.opacity]="entry.status !== 'GENERATED' ? '0.5' : '1'"
[style.pointer-events]="entry.status !== 'GENERATED' ? 'none' : 'auto'">
Voir la documentation
</a>
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;border-bottom:1px solid #eee;cursor:pointer;color:#161616;"
(click)="onNewVersion(); actionsOpen.set(false)">
Nouvelle version
</button>
<a [href]="'/api/apis/' + entry.id + '/yaml'"
download
(click)="actionsOpen.set(false)"
style="display:block;padding:0.75rem 1rem;text-decoration:none;color:#161616;border-bottom:1px solid #eee;">
Télécharger YAML
</a>
@if (entry.status !== 'GENERATED') {
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;border-bottom:1px solid #eee;cursor:pointer;color:#161616;"
[disabled]="regenerating()"
(click)="onRegenerate(); actionsOpen.set(false)">
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
</button>
}
<button style="display:block;width:100%;text-align:left;padding:0.75rem 1rem;background:none;border:none;cursor:pointer;color:#CE0500;"
[disabled]="deleting()"
(click)="onDelete(); actionsOpen.set(false)">
{{ deleting() ? 'Suppression...' : 'Supprimer' }}
</button>
</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 -->
@if (entry.contactFunctional || entry.contactTechnical) {
<div class="fr-card fr-mb-4w">
<div class="fr-card__body">
<div class="fr-card__content">
<h2 class="fr-h5 fr-mb-2w">Contacts</h2>
<dl class="fr-grid-row fr-grid-row--gutters">
@if (entry.contactFunctional) {
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact fonctionnel</dt>
<dd>{{ entry.contactFunctional }}</dd>
</div>
}
@if (entry.contactTechnical) {
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Contact technique</dt>
<dd>{{ entry.contactTechnical }}</dd>
</div>
}
</dl>
</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>
}
<!-- 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 -->
<div class="fr-btns-group fr-btns-group--inline-sm">
<a
[routerLink]="['/catalog', entry.id, 'docs']"
class="fr-btn"
[class.fr-btn--disabled]="entry.status !== 'GENERATED'"
[attr.aria-disabled]="entry.status !== 'GENERATED' ? 'true' : null"
>
Voir la documentation
</a>
<a
[href]="'/api/apis/' + entry.id + '/yaml'"
class="fr-btn fr-btn--secondary"
download
>
Télécharger YAML
</a>
<button
class="fr-btn fr-btn--secondary"
(click)="onRegenerate()"
[disabled]="regenerating()"
>
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
<!-- Navigation onglets -->
<div style="display:flex;border-bottom:2px solid #e5e5e5;margin-bottom:1.5rem;">
<button (click)="activeTab.set('info')"
[style.border-bottom]="activeTab()==='info' ? '2px solid #000091' : '2px solid transparent'"
[style.color]="activeTab()==='info' ? '#000091' : '#3a3a3a'"
[style.font-weight]="activeTab()==='info' ? '700' : '400'"
style="padding:0.75rem 1.5rem;background:none;border-top:none;border-left:none;border-right:none;cursor:pointer;margin-bottom:-2px;">
Informations
</button>
<button
class="fr-btn fr-btn--secondary"
(click)="onDelete()"
[disabled]="deleting()"
>
{{ deleting() ? 'Suppression...' : 'Supprimer' }}
<button (click)="activeTab.set('contacts')"
[style.border-bottom]="activeTab()==='contacts' ? '2px solid #000091' : '2px solid transparent'"
[style.color]="activeTab()==='contacts' ? '#000091' : '#3a3a3a'"
[style.font-weight]="activeTab()==='contacts' ? '700' : '400'"
style="padding:0.75rem 1.5rem;background:none;border-top:none;border-left:none;border-right:none;cursor:pointer;margin-bottom:-2px;">
Contacts
</button>
</div>
<!-- Onglet Informations -->
@if (activeTab() === 'info') {
@if (!editingInfo()) {
<div class="fr-card fr-mb-3w">
<div class="fr-card__body">
<div class="fr-card__content">
<dl class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Version</dt>
<dd style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
{{ entry.version }}
@if (!entry.isCurrent) {
<button class="fr-btn fr-btn--sm fr-btn--secondary" [disabled]="settingCurrent()"
(click)="onSetCurrent()">
{{ settingCurrent() ? '...' : 'Passer en courante' }}
</button>
}
</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Type</dt>
<dd>{{ entry.type }}</dd>
</div>
<div class="fr-col-12 fr-col-md-4">
<dt class="fr-text--bold">Statut</dt>
<dd>
<span [class]="badgeClass(entry.status)">{{ statusLabel(entry.status) }}</span>
@if (entry.status === 'ERROR' && entry.errorMessage) {
<p class="fr-text--sm fr-mt-1w">{{ entry.errorMessage }}</p>
}
</dd>
</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) {
<div class="fr-col-12">
<dt class="fr-text--bold">Description</dt>
<dd>{{ entry.description }}</dd>
</div>
}
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Créé le</dt>
<dd>{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<dt class="fr-text--bold">Mis à jour le</dt>
<dd>{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}</dd>
</div>
</dl>
<div class="fr-mt-2w">
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-edit-line" (click)="startEditInfo()">
Modifier les informations
</button>
</div>
</div>
</div>
</div>
}
@if (editingInfo()) {
<form [formGroup]="infoForm" (ngSubmit)="submitInfo()" novalidate class="fr-mb-3w">
<!-- Convention + Nom -->
<p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-select-group">
<label class="fr-label" for="info-convention">Convention</label>
<select id="info-convention" class="fr-select" formControlName="convention">
<option value="CONSULTER">Consulter</option>
<option value="ENREGISTRER">Enregistrer</option>
<option value="ETRE_NOTIFIE">Être notifié</option>
</select>
</div>
</div>
<div class="fr-col-12 fr-col-md-8">
<div class="fr-input-group"
[class.fr-input-group--error]="infoForm.get('name')?.invalid && infoForm.get('name')?.touched">
<label class="fr-label" for="info-name">Nom de l'API <span style="color:var(--text-default-error)">*</span></label>
<input id="info-name" class="fr-input" type="text" formControlName="name"
[class.fr-input--error]="infoForm.get('name')?.invalid && infoForm.get('name')?.touched" />
@if (infoForm.get('name')?.invalid && infoForm.get('name')?.touched) {
<p class="fr-error-text">Le nom est obligatoire.</p>
}
</div>
</div>
</div>
<!-- Fournisseur -->
<div class="fr-input-group fr-mb-2w"
[class.fr-input-group--error]="infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched">
<label class="fr-label" for="info-provider">Fournisseur <span style="color:var(--text-default-error)">*</span></label>
<input id="info-provider" class="fr-input" type="text" formControlName="provider"
[class.fr-input--error]="infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched" />
@if (infoForm.get('provider')?.invalid && infoForm.get('provider')?.touched) {
<p class="fr-error-text">Le fournisseur est obligatoire.</p>
}
</div>
<!-- Version -->
<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="info-v-major">Majeure (X)</label>
<input id="info-v-major" class="fr-input" type="number" min="0" formControlName="versionMajor" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="info-v-minor">Mineure (Y)</label>
<input id="info-v-minor" class="fr-input" type="number" min="0" formControlName="versionMinor" />
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group">
<label class="fr-label" for="info-v-patch">Correctif (Z)</label>
<input id="info-v-patch" class="fr-input" type="number" min="0" formControlName="versionPatch" />
</div>
</div>
</div>
<!-- Description -->
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="info-description">Description</label>
<textarea id="info-description" class="fr-input" rows="3" formControlName="description"></textarea>
</div>
<!-- Catégorie -->
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="info-category">Catégorie</label>
<select id="info-category" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ cat.name }}</option>
}
</select>
</div>
<div class="fr-btns-group fr-btns-group--inline fr-btns-group--right">
<button type="button" class="fr-btn fr-btn--secondary" (click)="editingInfo.set(false)">Annuler</button>
<button type="submit" class="fr-btn" [disabled]="savingInfo()">
{{ savingInfo() ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</form>
}
}
<!-- Onglet Contacts -->
@if (activeTab() === 'contacts') {
@if (!editingContacts()) {
<div class="fr-card fr-mb-3w">
<div class="fr-card__body">
<div class="fr-card__content">
<div class="fr-grid-row fr-grid-row--gutters">
<div class="fr-col-12 fr-col-md-6">
<p class="fr-text--bold fr-mb-1w">Contact métier</p>
<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 class="fr-col-12 fr-col-md-6">
<p class="fr-text--bold fr-mb-1w">Contact technique</p>
<p class="fr-mb-0">{{ entry.contactTechnicalName }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEntity }}</p>
<p class="fr-mb-0 fr-text--sm">{{ entry.contactTechnicalEmail }}</p>
</div>
</div>
<div class="fr-mt-2w">
<button class="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-edit-line" (click)="startEditContacts()">
Modifier les contacts
</button>
</div>
</div>
</div>
</div>
}
@if (editingContacts()) {
<form [formGroup]="contactForm" (ngSubmit)="submitContacts()" novalidate class="fr-mb-3w">
<!-- 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-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched">
<label class="fr-label" for="cf-name">Nom et prénom</label>
<input id="cf-name" class="fr-input" type="text" formControlName="contactFunctionalName" placeholder="Prénom Nom"
[class.fr-input--error]="contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched" />
@if (contactForm.get('contactFunctionalName')?.invalid && contactForm.get('contactFunctionalName')?.touched) {
<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]="contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched">
<label class="fr-label" for="cf-entity">Entité</label>
<input id="cf-entity" class="fr-input" type="text" formControlName="contactFunctionalEntity" placeholder="Direction XYZ"
[class.fr-input--error]="contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched" />
@if (contactForm.get('contactFunctionalEntity')?.invalid && contactForm.get('contactFunctionalEntity')?.touched) {
<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]="contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched">
<label class="fr-label" for="cf-email">Email</label>
<input id="cf-email" class="fr-input" type="email" formControlName="contactFunctionalEmail" placeholder="prenom.nom@example.fr"
[class.fr-input--error]="contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched" />
@if (contactForm.get('contactFunctionalEmail')?.invalid && contactForm.get('contactFunctionalEmail')?.touched) {
<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-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group"
[class.fr-input-group--error]="contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched">
<label class="fr-label" for="ct-name">Nom et prénom</label>
<input id="ct-name" class="fr-input" type="text" formControlName="contactTechnicalName" placeholder="Prénom Nom"
[class.fr-input--error]="contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched" />
@if (contactForm.get('contactTechnicalName')?.invalid && contactForm.get('contactTechnicalName')?.touched) {
<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]="contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched">
<label class="fr-label" for="ct-entity">Entité</label>
<input id="ct-entity" class="fr-input" type="text" formControlName="contactTechnicalEntity" placeholder="Équipe Technique"
[class.fr-input--error]="contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched" />
@if (contactForm.get('contactTechnicalEntity')?.invalid && contactForm.get('contactTechnicalEntity')?.touched) {
<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]="contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched">
<label class="fr-label" for="ct-email">Email</label>
<input id="ct-email" class="fr-input" type="email" formControlName="contactTechnicalEmail" placeholder="tech@example.fr"
[class.fr-input--error]="contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched" />
@if (contactForm.get('contactTechnicalEmail')?.invalid && contactForm.get('contactTechnicalEmail')?.touched) {
<p class="fr-error-text">Email valide requis.</p>
}
</div>
</div>
</div>
<div class="fr-btns-group fr-btns-group--inline fr-btns-group--right">
<button type="button" class="fr-btn fr-btn--secondary" (click)="editingContacts.set(false)">Annuler</button>
<button type="submit" class="fr-btn" [disabled]="savingContacts()">
{{ savingContacts() ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</form>
}
}
}
</div>
`,
@@ -226,25 +413,103 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private apiService = inject(ApiService);
private categoryService = inject(CategoryService);
private fb = inject(FormBuilder);
api = signal<ApiEntry | null>(null);
versions = signal<ApiEntryListItem[]>([]);
categoryBreadcrumb = signal<Category[]>([]);
loading = signal(false);
error = signal<string | null>(null);
regenerating = signal(false);
deleting = signal(false);
settingCurrent = signal(false);
categories = signal<Category[]>([]);
actionsOpen = signal(false);
backUrl = computed<string[]>(() => {
const crumbs = this.categoryBreadcrumb();
if (crumbs.length > 0) return ['/browse', crumbs[crumbs.length - 1].id];
return ['/browse'];
});
/* Onglets */
activeTab = signal<'info' | 'contacts'>('info');
editingInfo = signal(false);
editingContacts = signal(false);
savingInfo = signal(false);
savingContacts = signal(false);
infoForm = this.fb.group({
convention: ['CONSULTER', 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)]],
description: [''],
categoryId: [''],
});
contactForm = this.fb.group({
contactFunctionalName: ['', Validators.required],
contactFunctionalEntity: ['', Validators.required],
contactFunctionalEmail: ['', [Validators.required, Validators.email]],
contactTechnicalName: ['', Validators.required],
contactTechnicalEntity: ['', Validators.required],
contactTechnicalEmail: ['', [Validators.required, Validators.email]],
});
private pollInterval: ReturnType<typeof setInterval> | null = null;
private routeSub: Subscription | null = null;
private id = '';
ngOnInit() {
this.id = this.route.snapshot.paramMap.get('id') ?? '';
this.loadApi();
this.categoryService.list().subscribe((res) => this.categories.set(res.items));
this.routeSub = this.route.paramMap.subscribe((params) => {
this.id = params.get('id') ?? '';
this.api.set(null);
this.versions.set([]);
this.error.set(null);
this.categoryBreadcrumb.set([]);
this.editingInfo.set(false);
this.editingContacts.set(false);
this.clearPoll();
this.loadApi();
});
}
ngOnDestroy() {
this.routeSub?.unsubscribe();
this.clearPoll();
}
onVersionChange(event: Event): void {
const id = (event.target as HTMLSelectElement).value;
if (id && id !== this.api()?.id) {
this.router.navigate(['/catalog', id]);
}
}
onNewVersion(): void {
this.router.navigate(['/upload'], { queryParams: { from: this.api()?.id } });
}
onSetCurrent() {
this.settingCurrent.set(true);
this.apiService.setCurrent(this.id).subscribe({
next: (entry) => {
this.api.set(entry);
this.settingCurrent.set(false);
this.apiService.getVersions(this.id).subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
},
error: () => this.settingCurrent.set(false),
});
}
onRegenerate() {
this.regenerating.set(true);
this.apiService.regenerate(this.id).subscribe({
@@ -257,6 +522,76 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
});
}
startEditInfo() {
const entry = this.api();
if (!entry) return;
this.infoForm.patchValue({
convention: entry.convention,
name: entry.name,
provider: entry.provider,
versionMajor: entry.versionMajor,
versionMinor: entry.versionMinor,
versionPatch: entry.versionPatch,
description: entry.description ?? '',
categoryId: entry.categoryId ?? '',
});
this.editingInfo.set(true);
}
startEditContacts() {
const entry = this.api();
if (!entry) return;
this.contactForm.patchValue({
contactFunctionalName: entry.contactFunctionalName,
contactFunctionalEntity: entry.contactFunctionalEntity,
contactFunctionalEmail: entry.contactFunctionalEmail,
contactTechnicalName: entry.contactTechnicalName,
contactTechnicalEntity: entry.contactTechnicalEntity,
contactTechnicalEmail: entry.contactTechnicalEmail,
});
this.editingContacts.set(true);
}
submitInfo() {
if (this.infoForm.invalid) { this.infoForm.markAllAsTouched(); return; }
const entry = this.api();
if (!entry) return;
this.savingInfo.set(true);
const raw = this.infoForm.getRawValue();
this.apiService.update(entry.id, {
convention: raw.convention ?? 'CONSULTER',
name: raw.name ?? '',
provider: raw.provider ?? '',
versionMajor: raw.versionMajor ?? 0,
versionMinor: raw.versionMinor ?? 0,
versionPatch: raw.versionPatch ?? 0,
categoryId: raw.categoryId || null,
description: raw.description || null,
}).subscribe({
next: (updated) => { this.api.set(updated); this.savingInfo.set(false); this.editingInfo.set(false); },
error: () => this.savingInfo.set(false),
});
}
submitContacts() {
if (this.contactForm.invalid) { this.contactForm.markAllAsTouched(); return; }
const entry = this.api();
if (!entry) return;
this.savingContacts.set(true);
const raw = this.contactForm.getRawValue();
this.apiService.update(entry.id, {
contactFunctionalName: raw.contactFunctionalName ?? '',
contactFunctionalEntity: raw.contactFunctionalEntity ?? '',
contactFunctionalEmail: raw.contactFunctionalEmail ?? '',
contactTechnicalName: raw.contactTechnicalName ?? '',
contactTechnicalEntity: raw.contactTechnicalEntity ?? '',
contactTechnicalEmail: raw.contactTechnicalEmail ?? '',
}).subscribe({
next: (updated) => { this.api.set(updated); this.savingContacts.set(false); this.editingContacts.set(false); },
error: () => this.savingContacts.set(false),
});
}
onDelete() {
if (!confirm('Supprimer cette API ?')) return;
this.deleting.set(true);
@@ -272,6 +607,12 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
return 'fr-badge fr-badge--info';
}
statusLabel(status: string): string {
if (status === 'GENERATED') return 'Générée';
if (status === 'ERROR') return 'Erreur';
return 'En attente';
}
private loadApi() {
this.loading.set(true);
this.apiService.get(this.id).subscribe({
@@ -279,6 +620,16 @@ export class ApiDetailComponent implements OnInit, OnDestroy {
this.api.set(entry);
this.loading.set(false);
this.startPollIfPending();
this.apiService.getVersions(this.id).subscribe({
next: (v) => this.versions.set(v),
error: () => {},
});
if (entry.categoryId) {
this.categoryService.browse(entry.categoryId).subscribe({
next: (data) => this.categoryBreadcrumb.set(data.breadcrumb),
error: () => {},
});
}
},
error: (err) => {
this.error.set(err.message ?? 'Erreur lors du chargement');

View File

@@ -12,12 +12,16 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { CategoryService } from '../../core/category.service';
import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/shared';
import { ApiService } from '../../core/api.service';
import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem } from '@datacat/shared';
import { forkJoin } from 'rxjs';
import { CategoryFormModalComponent } from '../../shared/category-form-modal/category-form-modal.component';
import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal/category-delete-modal.component';
@Component({
selector: 'app-browse',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, CategoryFormModalComponent, CategoryDeleteModalComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fr-container fr-mt-4w fr-mb-4w">
@@ -25,7 +29,7 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
<ol class="fr-breadcrumb__list">
<li>
<a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a>
<a class="fr-breadcrumb__link" routerLink="/">Accueil</a>
</li>
@for (crumb of ancestors(); track crumb.id) {
<li>
@@ -41,17 +45,44 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
</nav>
<!-- En-tête -->
@if (categoryId()) {
<a class="fr-link fr-icon-arrow-left-line fr-link--icon-left fr-mb-2w" style="display:inline-flex;"
[routerLink]="backUrl()">Retour</a>
}
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<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' }}
@if (browseData()?.category) {
<button
title="Modifier ce dossier"
style="background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
(click)="openEdit(browseData()!.category!)">
<span class="fr-icon-edit-line" aria-hidden="true" style="font-size:0.875rem"></span>
</button>
}
</h1>
</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()">
Nouveau dossier
</button>
</div>
</div>
<!-- Barre de recherche -->
<div class="fr-search-bar fr-mb-3w">
<label class="fr-label" for="search-api">Rechercher</label>
<input id="search-api" class="fr-input" type="search" placeholder="Ex : auth async v1.0, openapi latest..."
[value]="searchQuery()"
(input)="onSearchChange($any($event.target).value)" />
</div>
@if (loading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
@@ -64,134 +95,187 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
</div>
}
@if (!loading() && browseData()) {
<!-- Sous-catégories -->
@if (browseData()!.subcategories.length > 0) {
<h2 class="fr-h4 fr-mb-3w">Catégories</h2>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
@for (cat of browseData()!.subcategories; track cat.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
</h3>
@if (cat.description) {
<p class="fr-tile__desc">{{ cat.description }}</p>
}
<p class="fr-tile__detail">
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
</p>
<div style="position:relative;z-index:1;margin-top:0.5rem;display:flex;gap:0.25rem;order:5">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-edit-line"
(click)="openEdit(cat); $event.stopPropagation()">
Modifier
</button>
@if (cat.apiCount === 0 && cat.subcategoryCount === 0) {
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-delete-bin-line"
(click)="deleteCategory(cat); $event.stopPropagation()">
Supprimer
@if (!isSearchMode()) {
@if (!loading() && browseData()) {
<!-- Sous-catégories -->
@if (browseData()!.subcategories.length > 0) {
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
@for (cat of browseData()!.subcategories; track cat.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
<button title="Modifier"
style="position:relative;z-index:1;background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
(click)="openEdit(cat); $event.stopPropagation()">
<span class="fr-icon-edit-line" aria-hidden="true" style="font-size:0.75rem"></span>
</button>
@if (cat.apiCount === 0 && cat.subcategoryCount === 0) {
<button title="Supprimer"
style="position:relative;z-index:1;background:none;border:none;cursor:pointer;padding:0 0.25rem;vertical-align:middle;opacity:0.6"
(click)="openDelete(cat); $event.stopPropagation()">
<span class="fr-icon-delete-bin-line" aria-hidden="true" style="font-size:0.75rem"></span>
</button>
}
</h3>
@if (cat.description) {
<p class="fr-tile__desc">{{ cat.description }}</p>
}
<p class="fr-tile__detail">
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
</p>
</div>
</div>
</div>
</div>
}
</div>
}
<!-- APIs -->
@if (browseData()!.apis.length > 0) {
<div class="fr-grid-row fr-grid-row--gutters">
@for (api of browseData()!.apis; track api.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
</h3>
@if (api.description) {
<p class="fr-tile__desc">{{ api.description }}</p>
}
<p class="fr-tile__detail">
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
&nbsp;<span class="fr-badge fr-badge--sm">v{{ api.version }}</span>
@if (api.isCurrent) {
&nbsp;<span class="fr-badge fr-badge--success fr-badge--sm">Courante</span>
}
</p>
</div>
</div>
</div>
</div>
}
</div>
}
@if (browseData()!.subcategories.length === 0 && browseData()!.apis.length === 0) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
</div>
}
}
} @else {
<!-- Mode recherche -->
@if (searchLoading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
@if (!searchLoading()) {
<!-- Dossiers -->
@if (searchCategoryResults().length > 0) {
@if (searchResults().length > 0) {
<p class="fr-text--sm fr-text--bold fr-mb-2w">Dossiers</p>
}
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
@for (cat of searchCategoryResults(); track cat.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
</h3>
@if (cat.description) {
<p class="fr-tile__desc">{{ cat.description }}</p>
}
</div>
</div>
</div>
</div>
</div>
}
</div>
}
}
</div>
}
<!-- APIs -->
@if (browseData()!.apis.length > 0) {
<h2 class="fr-h4 fr-mb-3w">APIs</h2>
<div class="fr-grid-row fr-grid-row--gutters">
@for (api of browseData()!.apis; track api.id) {
<div class="fr-col-12 fr-col-md-4">
<div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__body">
<div class="fr-tile__content">
<h3 class="fr-tile__title">
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
</h3>
@if (api.description) {
<p class="fr-tile__desc">{{ api.description }}</p>
}
<p class="fr-tile__detail">
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
&nbsp;
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
</p>
<!-- APIs -->
@if (searchResults().length > 0) {
@if (searchCategoryResults().length > 0) {
<p class="fr-text--sm fr-text--bold fr-mb-2w">APIs</p>
}
<div class="fr-grid-row fr-grid-row--gutters">
@for (api of searchResults(); track api.id) {
<div class="fr-col-12 fr-col-md-6 fr-col-lg-4">
<div class="fr-card fr-card--sm fr-enlarge-link">
<div class="fr-card__body">
<div class="fr-card__content">
<h3 class="fr-card__title">
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
</h3>
@if (api.description) {
<p class="fr-card__desc">{{ api.description }}</p>
}
<div class="fr-badges-group fr-mt-1w">
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
<span class="fr-badge fr-badge--sm">v{{ api.version }}</span>
@if (api.isCurrent) {
<span class="fr-badge fr-badge--success fr-badge--sm">Courante</span>
}
</div>
</div>
</div>
</div>
</div>
}
</div>
@if (searchTotalPages() > 1) {
<div class="fr-grid-row fr-grid-row--center fr-mt-4w">
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === 1"
(click)="setSearchPage(searchPage() - 1)">Précédent</button>
<span class="fr-mx-2w">Page {{ searchPage() }} / {{ searchTotalPages() }}</span>
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === searchTotalPages()"
(click)="setSearchPage(searchPage() + 1)">Suivant</button>
</div>
}
</div>
}
}
@if (browseData()!.subcategories.length === 0 && browseData()!.apis.length === 0) {
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
</div>
<!-- Aucun résultat -->
@if (searchCategoryResults().length === 0 && searchResults().length === 0) {
<p class="fr-text--lead">Aucun résultat pour « {{ searchQuery() }} »</p>
}
}
}
<!-- Modale création / édition (overlay Angular, sans fr-modal pour éviter l'interférence du JS DSFR) -->
@if (showFormModal()) {
<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="modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:540px;margin:0 1rem 2rem">
<div class="fr-p-4w">
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h2 id="modal-title" class="fr-h3 fr-mb-0">
{{ formMode() === 'create' ? 'Nouveau dossier' : 'Modifier le dossier' }}
</h2>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
(click)="closeModal()">✕</button>
</div>
</div>
@if (formError()) {
<div class="fr-alert fr-alert--error fr-mb-2w">
<p>{{ formError() }}</p>
</div>
}
<div class="fr-input-group fr-mb-2w">
<label class="fr-label" for="form-name">
Nom <span class="fr-hint-text">Obligatoire</span>
</label>
<input id="form-name" class="fr-input" type="text"
[value]="formName()"
(input)="formName.set($any($event.target).value)" />
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="form-desc">
Description <span class="fr-hint-text">Optionnel</span>
</label>
<textarea id="form-desc" class="fr-input" rows="3"
[value]="formDescription()"
(input)="formDescription.set($any($event.target).value)"></textarea>
</div>
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="closeModal()">Annuler</button>
<button class="fr-btn" (click)="submitForm()" [disabled]="formLoading()">
{{ formMode() === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</div>
}
</div>
<!-- Modale de suppression -->
@if (deletingCategory()) {
<app-category-delete-modal
[category]="deletingCategory()!"
(confirmed)="onDeleteConfirmed()"
(cancelled)="deletingCategory.set(null)"
/>
}
<!-- Modale création / édition -->
@if (showFormModal()) {
<app-category-form-modal
[mode]="formMode()"
[category]="editingCategory()"
[parentId]="categoryId()"
(saved)="onFormSaved()"
(closed)="showFormModal.set(false)"
/>
}
`,
})
export class BrowseComponent implements OnInit {
private route = inject(ActivatedRoute);
private categoryService = inject(CategoryService);
private apiService = inject(ApiService);
private destroyRef = inject(DestroyRef);
browseData = signal<CategoryBrowseResponse | null>(null);
@@ -199,22 +283,38 @@ export class BrowseComponent implements OnInit {
error = signal<string | null>(null);
categoryId = signal<string | null>(null);
/* Signaux pour la modale */
/* Signaux recherche */
searchQuery = signal('');
searchResults = signal<ApiEntryListItem[]>([]);
searchCategoryResults = signal<Category[]>([]);
searchTotal = signal(0);
searchPage = signal(1);
searchLoading = signal(false);
readonly searchLimit = 12;
isSearchMode = computed(() => this.searchQuery().trim().length > 0);
searchTotalPages = computed(() => Math.ceil(this.searchTotal() / this.searchLimit));
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
/* Signaux pour les modales */
showFormModal = signal(false);
formMode = signal<'create' | 'edit'>('create');
editingCategoryId = signal<string | null>(null);
formName = signal('');
formDescription = signal('');
formError = signal<string | null>(null);
formLoading = signal(false);
editingCategory = signal<Category | null>(null);
deletingCategory = signal<CategoryWithCounts | null>(null);
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
ancestors = computed<Category[]>(() => {
const d = this.browseData();
if (!d || !d.category) return [];
return d.breadcrumb.slice(0, -1);
});
backUrl = computed<string[]>(() => {
const anc = this.ancestors();
if (anc.length > 0) return ['/browse', anc[anc.length - 1].id];
return ['/browse'];
});
ngOnInit() {
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -241,65 +341,70 @@ export class BrowseComponent implements OnInit {
openCreate() {
this.formMode.set('create');
this.editingCategoryId.set(null);
this.formName.set('');
this.formDescription.set('');
this.formError.set(null);
this.editingCategory.set(null);
this.showFormModal.set(true);
}
openEdit(cat: CategoryWithCounts) {
openEdit(cat: Category) {
this.formMode.set('edit');
this.editingCategoryId.set(cat.id);
this.formName.set(cat.name);
this.formDescription.set(cat.description ?? '');
this.formError.set(null);
this.editingCategory.set(cat);
this.showFormModal.set(true);
}
closeModal() {
openDelete(cat: CategoryWithCounts) {
this.deletingCategory.set(cat);
}
onFormSaved() {
this.showFormModal.set(false);
this.loadBrowse();
}
submitForm() {
if (!this.formName().trim()) {
this.formError.set('Le nom est obligatoire.');
return;
onDeleteConfirmed() {
this.deletingCategory.set(null);
this.loadBrowse();
}
onSearchChange(value: string) {
this.searchQuery.set(value);
this.searchPage.set(1);
if (this.searchDebounce) clearTimeout(this.searchDebounce);
if (value.trim()) {
this.searchLoading.set(true);
this.searchDebounce = setTimeout(() => this.loadSearch(), 400);
} else {
this.searchLoading.set(false);
this.searchCategoryResults.set([]);
}
this.formLoading.set(true);
const name = this.formName().trim();
const description = this.formDescription().trim() || undefined;
}
const obs =
this.formMode() === 'create'
? this.categoryService.create({
name,
description,
parentId: this.categoryId() ?? undefined,
})
: this.categoryService.update(this.editingCategoryId()!, { name, description });
loadSearch() {
this.searchLoading.set(true);
const raw = this.searchQuery();
const q = raw.trim().toLowerCase();
obs.subscribe({
next: () => {
this.formLoading.set(false);
this.showFormModal.set(false);
this.loadBrowse();
},
error: (err: { error?: { message?: string } }) => {
this.formLoading.set(false);
this.formError.set(err?.error?.message ?? 'Une erreur est survenue.');
forkJoin({
apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }),
categories: this.categoryService.list(),
}).subscribe({
next: ({ apis, categories }) => {
this.searchResults.set(apis.items);
this.searchTotal.set(apis.total);
this.searchCategoryResults.set(
categories.items.filter(
(c) => c.name.toLowerCase().includes(q) || (c.description ?? '').toLowerCase().includes(q),
),
);
this.searchLoading.set(false);
},
error: () => this.searchLoading.set(false),
});
}
deleteCategory(cat: CategoryWithCounts) {
if (cat.apiCount > 0 || cat.subcategoryCount > 0) return;
if (!confirm(`Supprimer le dossier "${cat.name}" ?`)) return;
this.categoryService.delete(cat.id).subscribe({
next: () => this.loadBrowse(),
error: (err: { error?: { message?: string } }) =>
alert(err?.error?.message ?? 'Erreur lors de la suppression.'),
});
setSearchPage(p: number) {
if (p < 1 || p > this.searchTotalPages()) return;
this.searchPage.set(p);
this.loadSearch();
}
typeBadgeClass(type: string): string {
@@ -308,9 +413,4 @@ export class BrowseComponent implements OnInit {
: 'fr-badge fr-badge--sm fr-badge--blue-ecume';
}
statusBadgeClass(status: string): string {
if (status === 'GENERATED') return 'fr-badge fr-badge--sm fr-badge--success';
if (status === 'ERROR') return 'fr-badge fr-badge--sm fr-badge--error';
return 'fr-badge fr-badge--sm fr-badge--info';
}
}

View File

@@ -41,7 +41,7 @@ import { ApiEntryListItem, ApiType } from '@datacat/shared';
id="search-input"
class="fr-input"
type="search"
placeholder="Rechercher par titre ou description..."
placeholder="Ex : auth async v1.0, openapi latest..."
[ngModel]="search()"
(ngModelChange)="onSearchChange($event)"
/>

View File

@@ -6,12 +6,13 @@ import {
OnInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
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 { CategoryService } from '../../core/category.service';
import { Category } from '@datacat/shared';
import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared';
@Component({
selector: 'app-upload',
@@ -85,6 +86,26 @@ import { Category } from '@datacat/shared';
</div>
}
</div>
@if (validating()) {
<div class="fr-callout fr-mb-3w">
<p>Validation en cours...</p>
</div>
} @else if (validationDone()) {
@if (validationErrors().length > 0) {
<div class="fr-alert fr-alert--error fr-mb-3w">
<p class="fr-alert__title">Fichier(s) invalide(s)</p>
@for (err of validationErrors(); track err.file) {
<p>{{ err.file ? err.file + ' : ' : '' }}{{ err.message }}</p>
}
</div>
} @else {
<div class="fr-alert fr-alert--success fr-mb-3w">
<p class="fr-alert__title">Fichier(s) valide(s)</p>
<p>Le fichier a été validé avec succès.</p>
</div>
}
}
}
<div class="fr-btns-group fr-btns-group--inline-sm">
@@ -102,53 +123,51 @@ import { Category } from '@datacat/shared';
<!-- Étape 2 : Métadonnées -->
@if (step() === 2) {
<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 class="fr-hint-text">Obligatoire</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()">
<label class="fr-label" for="version-input">
Version <span class="fr-hint-text">Obligatoire</span>
</label>
<input
id="version-input"
class="fr-input"
[class.fr-input--error]="versionInvalid()"
formControlName="version"
type="text"
placeholder="1.0.0"
/>
@if (versionInvalid()) {
<p class="fr-error-text">La version est obligatoire.</p>
}
</div>
<div class="fr-select-group fr-mb-3w">
<label class="fr-label" for="type-input">
Type <span class="fr-hint-text">Obligatoire</span>
</label>
<select id="type-input" class="fr-select" formControlName="type">
<option value="ASYNCAPI">AsyncAPI</option>
<option value="OPENAPI">OpenAPI</option>
</select>
<!-- Titre : convention + nom -->
<p class="fr-label fr-mb-1w">Titre <span style="color:var(--text-default-error)">*</span></p>
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-select-group" [class.fr-select-group--error]="conventionInvalid()">
<label class="fr-label" for="convention-input">Convention</label>
<select
id="convention-input"
class="fr-select"
[class.fr-select--error]="conventionInvalid()"
formControlName="convention"
>
<option value="CONSULTER">Consulter</option>
<option value="ENREGISTRER">Enregistrer</option>
<option value="ETRE_NOTIFIE">Être notifié</option>
</select>
@if (conventionInvalid()) {
<p class="fr-error-text">La convention est obligatoire.</p>
}
</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">
<label class="fr-label" for="category-input">
Catégorie <span class="fr-hint-text">Optionnel</span>
Catégorie
</label>
<select id="category-input" class="fr-select" formControlName="categoryId">
<option value="">— Sans catégorie —</option>
@@ -158,9 +177,42 @@ import { Category } from '@datacat/shared';
</select>
</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">
<label class="fr-label" for="description-input">
Description <span class="fr-hint-text">Optionnel</span>
Description
</label>
<textarea
id="description-input"
@@ -171,82 +223,127 @@ import { Category } from '@datacat/shared';
></textarea>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="functional-doc-input">
Documentation fonctionnelle <span class="fr-hint-text">Optionnel</span>
</label>
<textarea
id="functional-doc-input"
class="fr-input"
formControlName="functionalDoc"
rows="4"
placeholder="Cas d'usage, contexte métier..."
></textarea>
<!-- Version X.Y.Z -->
<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-3w">
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionMajorInvalid()">
<label class="fr-label" for="version-major-input">Majeure (X)</label>
<input
id="version-major-input"
class="fr-input"
[class.fr-input--error]="versionMajorInvalid()"
formControlName="versionMajor"
type="number"
min="0"
placeholder="1"
/>
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionMinorInvalid()">
<label class="fr-label" for="version-minor-input">Mineure (Y)</label>
<input
id="version-minor-input"
class="fr-input"
[class.fr-input--error]="versionMinorInvalid()"
formControlName="versionMinor"
type="number"
min="0"
placeholder="0"
/>
</div>
</div>
<div class="fr-col-4">
<div class="fr-input-group" [class.fr-input-group--error]="versionPatchInvalid()">
<label class="fr-label" for="version-patch-input">Correctif (Z)</label>
<input
id="version-patch-input"
class="fr-input"
[class.fr-input--error]="versionPatchInvalid()"
formControlName="versionPatch"
type="number"
min="0"
placeholder="0"
/>
</div>
</div>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="technical-doc-input">
Documentation technique <span class="fr-hint-text">Optionnel</span>
</label>
<textarea
id="technical-doc-input"
class="fr-input"
formControlName="technicalDoc"
rows="4"
placeholder="Guide d'intégration technique..."
></textarea>
<!-- 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-3w">
<div class="fr-col-12 fr-col-md-4">
<div class="fr-input-group" [class.fr-input-group--error]="fieldInvalid('contactFunctionalName')">
<label class="fr-label" for="cf-name-input">Nom et prénom</label>
<input id="cf-name-input" class="fr-input"
[class.fr-input--error]="fieldInvalid('contactFunctionalName')"
formControlName="contactFunctionalName" type="text" placeholder="Prénom Nom" />
@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>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="external-doc-url-input">
URL documentation externe <span class="fr-hint-text">Optionnel</span>
</label>
<input
id="external-doc-url-input"
class="fr-input"
formControlName="externalDocUrl"
type="text"
placeholder="https://..."
/>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="contact-functional-input">
Contact fonctionnel <span class="fr-hint-text">Optionnel</span>
</label>
<input
id="contact-functional-input"
class="fr-input"
formControlName="contactFunctional"
type="text"
placeholder="Nom, email, téléphone..."
/>
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="contact-technical-input">
Contact technique <span class="fr-hint-text">Optionnel</span>
</label>
<input
id="contact-technical-input"
class="fr-input"
formControlName="contactTechnical"
type="text"
placeholder="Nom, email, téléphone..."
/>
</div>
<div class="fr-input-group fr-mb-4w">
<label class="fr-label" for="access-rights-input">
Droits d'accès <span class="fr-hint-text">Optionnel</span>
</label>
<textarea
id="access-rights-input"
class="fr-input"
formControlName="accessRights"
rows="3"
placeholder="Processus d'habilitation, conditions d'accès..."
></textarea>
<!-- 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 class="fr-btns-group fr-btns-group--inline-sm">
@@ -286,13 +383,17 @@ import { Category } from '@datacat/shared';
<dt class="fr-text--bold">Type</dt>
<dd>{{ metaForm.value.type }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<div class="fr-col-12">
<dt class="fr-text--bold">Titre</dt>
<dd>{{ metaForm.value.title }}</dd>
<dd>{{ previewTitle() }}</dd>
</div>
<div class="fr-col-12 fr-col-md-6">
<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>
@if (metaForm.value.categoryId) {
<div class="fr-col-12 fr-col-md-6">
@@ -339,6 +440,7 @@ export class UploadComponent implements OnInit {
private apiService = inject(ApiService);
private categoryService = inject(CategoryService);
private router = inject(Router);
private route = inject(ActivatedRoute);
step = signal(1);
selectedFiles = signal<File[]>([]);
@@ -347,6 +449,9 @@ export class UploadComponent implements OnInit {
fileError = signal<string | null>(null);
uploading = signal(false);
uploadError = signal<string | null>(null);
validating = signal(false);
validationDone = signal(false);
validationErrors = signal<Array<{ file: string; message: string }>>([]);
categories = signal<Category[]>([]);
readonly stepTitles = [
@@ -356,40 +461,108 @@ export class UploadComponent implements OnInit {
];
metaForm = this.fb.group({
title: ['', Validators.required],
version: ['', Validators.required],
convention: ['CONSULTER', 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],
categoryId: [''],
description: [''],
functionalDoc: [''],
technicalDoc: [''],
externalDocUrl: [''],
contactFunctional: [''],
contactTechnical: [''],
accessRights: [''],
contactFunctionalName: ['', Validators.required],
contactFunctionalEntity: ['', Validators.required],
contactFunctionalEmail: ['', [Validators.required, Validators.email]],
contactTechnicalName: ['', Validators.required],
contactTechnicalEntity: ['', Validators.required],
contactTechnicalEmail: ['', [Validators.required, Validators.email]],
});
titleInvalid = computed(
() => !!(this.metaForm.get('title')?.invalid && this.metaForm.get('title')?.touched),
);
versionInvalid = computed(
() => !!(this.metaForm.get('version')?.invalid && this.metaForm.get('version')?.touched),
);
/* Convertit valueChanges en signal pour que les computed() trackent les changements du formulaire */
private readonly formValues = toSignal(this.metaForm.valueChanges, { initialValue: this.metaForm.value });
previewTitle = computed(() => {
const values = this.formValues();
const convention = (values.convention ?? 'CONSULTER') as ApiConvention;
const name = values.name ?? '';
return `${API_CONVENTION_LABELS[convention]} ${name}`.trim();
});
selectedCategoryName = computed(() => {
const id = this.metaForm.value.categoryId;
const id = this.formValues().categoryId;
if (!id) return '';
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(() => {
if (this.selectedFiles().length === 0) return false;
if (this.selectedFiles().length === 1) return true;
/* Plusieurs fichiers : un fichier principal doit être détecté */
return this.detectedMainFilename() !== null;
/* Sans fichier, on peut toujours passer à l'étape 2 */
if (this.selectedFiles().length === 0) return true;
/* Avec fichier : validation serveur doit être terminée et sans erreur */
if (this.validating()) return false;
return this.validationDone() && this.validationErrors().length === 0;
});
fieldInvalid(name: string): boolean {
const ctrl = this.metaForm.get(name);
return !!(ctrl?.invalid && ctrl?.touched);
}
ngOnInit() {
const params = this.route.snapshot.queryParamMap;
const preselectedCategoryId = params.get('categoryId');
const fromId = params.get('from');
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 });
}
},
});
if (fromId) {
this.apiService.get(fromId).subscribe({
next: (api) => this.prefillFromApi(api),
error: () => {},
});
}
}
private prefillFromApi(api: ApiEntry): void {
this.metaForm.patchValue({
convention: api.convention,
name: api.name,
provider: api.provider,
versionMajor: api.versionMajor,
versionMinor: api.versionMinor,
versionPatch: api.versionPatch + 1,
description: api.description ?? '',
categoryId: api.categoryId ?? '',
contactFunctionalName: api.contactFunctionalName,
contactFunctionalEntity: api.contactFunctionalEntity,
contactFunctionalEmail: api.contactFunctionalEmail,
contactTechnicalName: api.contactTechnicalName,
contactTechnicalEntity: api.contactTechnicalEntity,
contactTechnicalEmail: api.contactTechnicalEmail,
});
}
@@ -403,6 +576,9 @@ export class UploadComponent implements OnInit {
this.fileError.set(null);
this.detectedType.set(null);
this.detectedMainFilename.set(null);
this.validationErrors.set([]);
this.validationDone.set(false);
this.metaForm.get('type')?.enable();
const input = event.target as HTMLInputElement;
const fileList = input.files;
@@ -423,6 +599,9 @@ export class UploadComponent implements OnInit {
/* Détection côté client du fichier principal (pré-remplissage du type) */
void this.detectMainFileClient(files);
/* Validation serveur immédiate */
this.runValidation(files);
}
goToStep2() {
@@ -430,6 +609,48 @@ export class UploadComponent implements OnInit {
this.step.set(2);
}
private runValidation(files: File[]): void {
this.validating.set(true);
this.validationDone.set(false);
const formData = new FormData();
for (const f of files) {
formData.append('files', f);
}
this.apiService.validate(formData).subscribe({
next: (result) => {
this.validating.set(false);
this.validationDone.set(true);
if (!result.valid) {
this.validationErrors.set(result.errors);
this.metaForm.get('type')?.enable();
} else {
this.validationErrors.set([]);
if (result.type) {
this.detectedType.set(result.type);
this.metaForm.patchValue({ type: result.type });
this.metaForm.get('type')?.disable();
}
if (result.mainFile) {
this.detectedMainFilename.set(result.mainFile);
}
/* Pré-remplissage du formulaire depuis les métadonnées du fichier */
if (result.name) this.metaForm.patchValue({ name: result.name });
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 });
}
},
error: (err) => {
this.validating.set(false);
this.validationDone.set(true);
this.validationErrors.set([{ file: '', message: err?.error?.message ?? 'Erreur de validation.' }]);
},
});
}
goToStep3() {
if (this.metaForm.invalid) {
this.metaForm.markAllAsTouched();
@@ -445,37 +666,30 @@ export class UploadComponent implements OnInit {
this.uploading.set(true);
this.uploadError.set(null);
const raw = this.metaForm.getRawValue();
const formData = new FormData();
for (const f of files) {
formData.append('files', f);
}
formData.append('title', this.metaForm.value.title ?? '');
formData.append('version', this.metaForm.value.version ?? '');
formData.append('type', this.metaForm.value.type ?? 'ASYNCAPI');
if (this.metaForm.value.categoryId) {
formData.append('categoryId', this.metaForm.value.categoryId);
formData.append('convention', raw.convention ?? 'CONSULTER');
formData.append('name', raw.name ?? '');
formData.append('provider', raw.provider ?? '');
formData.append('versionMajor', String(raw.versionMajor ?? 0));
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) {
formData.append('description', this.metaForm.value.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);
if (raw.description) {
formData.append('description', raw.description);
}
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({
next: (entry) => {

View File

@@ -0,0 +1,79 @@
import { Component, signal, inject, ChangeDetectionStrategy, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { CategoryWithCounts } from '@datacat/shared';
@Component({
selector: 'app-category-delete-modal',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<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="delete-modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:480px;margin:0 1rem 2rem">
<div class="fr-p-4w">
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h2 id="delete-modal-title" class="fr-h3 fr-mb-0">Supprimer le dossier</h2>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
(click)="cancel()">✕</button>
</div>
</div>
<p class="fr-text--lead fr-mb-1w">
Êtes-vous sûr de vouloir supprimer le dossier
<strong>« {{ category().name }} »</strong> ?
</p>
<p class="fr-text--sm fr-text--mention-grey fr-mb-3w">
Cette action est irréversible.
</p>
@if (deleteError()) {
<div class="fr-alert fr-alert--error fr-mb-2w">
<p>{{ deleteError() }}</p>
</div>
}
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="cancel()"
[disabled]="deleteLoading()">Annuler</button>
<button class="fr-btn fr-btn--error" (click)="confirmDelete()"
[disabled]="deleteLoading()">
{{ deleteLoading() ? 'Suppression...' : 'Supprimer' }}
</button>
</div>
</div>
</div>
</div>
`,
})
export class CategoryDeleteModalComponent {
private categoryService = inject(CategoryService);
category = input.required<CategoryWithCounts>();
confirmed = output<void>();
cancelled = output<void>();
deleteLoading = signal(false);
deleteError = signal<string | null>(null);
cancel() {
this.cancelled.emit();
}
confirmDelete() {
const cat = this.category();
this.deleteLoading.set(true);
this.deleteError.set(null);
this.categoryService.delete(cat.id).subscribe({
next: () => {
this.deleteLoading.set(false);
this.confirmed.emit();
},
error: (err: { error?: { message?: string } }) => {
this.deleteLoading.set(false);
this.deleteError.set(err?.error?.message ?? 'Erreur lors de la suppression.');
},
});
}
}

View File

@@ -0,0 +1,173 @@
import {
Component,
signal,
computed,
inject,
OnInit,
ChangeDetectionStrategy,
input,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { CategoryService } from '../../core/category.service';
import { Category } from '@datacat/shared';
@Component({
selector: 'app-category-form-modal',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<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="modal-title">
<div style="background:white;width:calc(100% - 2rem);max-width:540px;margin:0 1rem 2rem">
<div class="fr-p-4w">
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
<div class="fr-col">
<h2 id="modal-title" class="fr-h3 fr-mb-0">
{{ mode() === 'create' ? 'Nouveau dossier' : 'Modifier le dossier' }}
</h2>
</div>
<div class="fr-col-auto">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm"
(click)="close()">✕</button>
</div>
</div>
@if (formError()) {
<div class="fr-alert fr-alert--error fr-mb-2w">
<p>{{ formError() }}</p>
</div>
}
@if (mode() === '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()">
<label class="fr-label" for="form-name">
Nom <span style="color:var(--text-default-error)">*</span>
</label>
<input id="form-name" class="fr-input" [class.fr-input--error]="formNameError()" type="text"
[value]="formName()"
(input)="formName.set($any($event.target).value); formNameError.set(false)"
(blur)="formNameError.set(!formName().trim())" />
@if (formNameError()) { <p class="fr-error-text">Le nom est obligatoire.</p> }
</div>
<div class="fr-input-group fr-mb-3w">
<label class="fr-label" for="form-desc">Description</label>
<textarea id="form-desc" class="fr-input" rows="3"
[value]="formDescription()"
(input)="formDescription.set($any($event.target).value)"></textarea>
</div>
<div class="fr-btns-group fr-btns-group--right fr-btns-group--inline">
<button class="fr-btn fr-btn--secondary" (click)="close()">Annuler</button>
<button class="fr-btn" (click)="submit()" [disabled]="formLoading()">
{{ mode() === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</div>
`,
})
export class CategoryFormModalComponent implements OnInit {
private categoryService = inject(CategoryService);
mode = input<'create' | 'edit'>('create');
category = input<Category | null>(null);
parentId = input<string | null>(null);
saved = output<void>();
closed = output<void>();
formName = signal('');
formDescription = signal('');
formParentId = signal<string | null>(null);
formError = signal<string | null>(null);
formNameError = signal(false);
formLoading = signal(false);
allCategories = signal<Category[]>([]);
editingCategoryId = signal<string | null>(null);
categoriesForSelect = computed<{ id: string; label: string }[]>(() => {
const cats = this.allCategories();
const map = new Map(cats.map((c) => [c.id, c]));
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}`;
};
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() {
const cat = this.category();
if (this.mode() === 'edit' && cat) {
this.editingCategoryId.set(cat.id);
this.formName.set(cat.name);
this.formDescription.set(cat.description ?? '');
} else {
this.formParentId.set(this.parentId());
this.categoryService.list().subscribe({
next: (res) => this.allCategories.set(res.items),
error: () => this.allCategories.set([]),
});
}
}
close() {
this.closed.emit();
}
submit() {
if (!this.formName().trim()) {
this.formNameError.set(true);
return;
}
this.formNameError.set(false);
this.formLoading.set(true);
const name = this.formName().trim();
const description = this.formDescription().trim() || undefined;
const obs =
this.mode() === 'create'
? this.categoryService.create({ name, description, parentId: this.formParentId() ?? undefined })
: this.categoryService.update(this.editingCategoryId()!, { name, description });
obs.subscribe({
next: () => {
this.formLoading.set(false);
this.saved.emit();
},
error: (err: { error?: { message?: string } }) => {
this.formLoading.set(false);
this.formError.set(err?.error?.message ?? 'Une erreur est survenue.');
},
});
}
}

View File

@@ -21,7 +21,7 @@ services:
NODE_ENV: development
DOCS_OUTPUT_DIR: /app/docs-output
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:
- network
depends_on:

51
pnpm-lock.yaml generated
View File

@@ -22,6 +22,9 @@ importers:
'@asyncapi/html-template':
specifier: 3.5.6
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':
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)
@@ -10189,7 +10192,7 @@ snapshots:
dependencies:
'@ampproject/remapping': 2.3.0
'@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/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)
@@ -10203,14 +10206,14 @@ snapshots:
'@babel/preset-env': 7.26.9(@babel/core@7.26.10)
'@babel/runtime': 7.26.10
'@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))
ansi-colors: 4.1.3
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
copy-webpack-plugin: 12.0.2(webpack@5.105.0(esbuild@0.28.0)(postcss@8.5.12))
css-loader: 7.1.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(postcss@8.5.12))
esbuild-wasm: 0.28.0
fast-glob: 3.3.3
http-proxy-middleware: 3.0.5
@@ -10218,22 +10221,22 @@ snapshots:
jsonc-parser: 3.3.1
karma-source-map-support: 1.4.0
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))
license-webpack-plugin: 4.0.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(postcss@8.5.12))
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
ora: 5.4.1
picomatch: 4.0.4
piscina: 4.8.0
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
rxjs: 7.8.1
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
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
terser: 5.39.0
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-server: 5.2.2(tslib@2.8.1)(webpack@5.105.0(postcss@8.5.12))
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:
esbuild: 0.28.0
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
- 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:
'@angular-devkit/architect': 0.1902.26(chokidar@4.0.3)
rxjs: 7.8.1
@@ -13785,7 +13788,7 @@ snapshots:
'@next/swc-win32-x64-msvc@14.2.33':
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:
'@angular/compiler-cli': 19.2.22(@angular/compiler@19.2.22)(typescript@5.8.3)
typescript: 5.8.3
@@ -16051,7 +16054,7 @@ snapshots:
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:
'@babel/core': 7.26.10
find-cache-dir: 4.0.0
@@ -16729,7 +16732,7 @@ snapshots:
dependencies:
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:
fast-glob: 3.3.3
glob-parent: 6.0.2
@@ -16804,7 +16807,7 @@ snapshots:
shebang-command: 2.0.0
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:
icss-utils: 5.1.0(postcss@8.5.12)
postcss: 8.5.12
@@ -18750,7 +18753,7 @@ snapshots:
dependencies:
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:
less: 4.2.2
optionalDependencies:
@@ -18776,7 +18779,7 @@ snapshots:
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:
webpack-sources: 3.4.1
optionalDependencies:
@@ -19068,7 +19071,7 @@ snapshots:
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:
schema-utils: 4.3.3
tapable: 2.3.3
@@ -20167,7 +20170,7 @@ snapshots:
postcss: 8.5.15
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:
cosmiconfig: 9.0.1(typescript@5.8.3)
jiti: 1.21.7
@@ -20863,7 +20866,7 @@ snapshots:
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:
neo-async: 2.6.2
optionalDependencies:
@@ -21241,7 +21244,7 @@ snapshots:
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:
iconv-lite: 0.6.3
source-map-js: 1.2.1
@@ -22344,7 +22347,7 @@ snapshots:
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:
typed-assert: 1.0.9
webpack: 5.105.0(esbuild@0.28.0)(postcss@8.5.12)

View File

@@ -2,6 +2,9 @@
"name": "@datacat/shared",
"version": "0.1.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts"
"main": "dist/index.js",
"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_STATUSES = ['PENDING', 'GENERATED', 'ERROR'] 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;
}
export const API_CONVENTIONS = ['CONSULTER', 'ENREGISTRER', 'ETRE_NOTIFIE'] as const;

View File

@@ -2,23 +2,37 @@ export type ApiType = 'ASYNCAPI' | 'OPENAPI';
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 {
id: string;
title: string;
name: string;
convention: ApiConvention;
version: string;
versionMajor: number;
versionMinor: number;
versionPatch: number;
description: string | null;
type: ApiType;
provider: string;
yamlContent: string;
htmlPath: string | null;
status: ApiStatus;
errorMessage: string | null;
categoryId: string | null;
functionalDoc: string | null;
technicalDoc: string | null;
externalDocUrl: string | null;
contactFunctional: string | null;
contactTechnical: string | null;
accessRights: string | null;
contactFunctionalName: string;
contactFunctionalEntity: string;
contactFunctionalEmail: string;
contactTechnicalName: string;
contactTechnicalEntity: string;
contactTechnicalEmail: string;
createdAt: string;
updatedAt: string;
}
@@ -26,9 +40,12 @@ export interface ApiEntry {
export interface ApiEntryListItem {
id: string;
title: string;
name: string;
convention: ApiConvention;
version: string;
description: string | null;
type: ApiType;
provider: string;
status: ApiStatus;
categoryId: string | null;
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"]
}