/** * lib/capability-catalog.ts — Canonical catalogue of transverse PLATFORM * CAPABILITIES (services / seams shipped inside the NuGet backend + npm * frontend package). * * SINGLE SOURCE OF TRUTH for "which cross-cutting capabilities the platform * ALREADY provides" across the BA → dev pipeline. It is the SERVICE-level * analogue of `core-catalog.ts` (entities) and `platform-catalog.ts` * (navigation): those two index knowledge by ENTITY/APP NAME, so a capability * that is a service — file storage, global search, outbound email — has no * name a C-1..C-5 / CODE-005 rule can trigger on. Without this table a BA * facing "pièces jointes sur les interactions" concludes "no file mechanism * exists anywhere" and proposes building storage from scratch (varbinary in * the DB — the historical incident this catalogue closes). * * Mirrors the app contracts (edit HERE when those change): * SmartStack.app/src/SmartStack.Application/Common/Interfaces/IFileStorageService.cs * SmartStack.app/src/SmartStack.Infrastructure/Services/Search/SearchServiceCollectionExtensions.cs * SmartStack.app/src/SmartStack.Infrastructure/Services/TimeEntryRefs/TimeEntryRefServiceCollectionExtensions.cs * SmartStack.app/src/SmartStack.Application/Common/Interfaces/Communications/IEmailService.cs * SmartStack.app/src/SmartStack.Infrastructure/Services/CodeGeneration/CodeGenerationServiceCollectionExtensions.cs * * IMPORTANT: a capability match is a PATTERN steer, not an entity ban — the * client METADATA entity (e.g. `crm_InteractionDocuments`) is legitimate and * stays in the MCD. What the guidance forbids is re-implementing the * capability itself (binary content in the DB, a hand-rolled search index, a * client SMTP stack). * * The BA skills (deployed standalone, markdown-only) carry this catalogue as * an inline table between `` markers; * `lib/__tests__/capability-catalog-drift.test.ts` pins those tables to these * exports — edit BOTH or the suite fails. * * Matching is whole-token/phrase (normalized, accent/case/singular-insensitive) * on the full name — PLUS, for TRIGGER matching only, on the trailing word(s) * of a compound name: `InteractionDocuments` → last word `Documents` → hit * (the "{Parent}Documents" attachment-collection idiom is exactly the shape * the incident produced). NEVER substring inside a word: "Documentation" is a * single word and must NOT match "Document". Multi-word triggers list BOTH * the singular and plural phrase (`singularize` only inflects the last token * of a phrase). Tune detection HERE (under test), never at call sites. */ import { normalizeEntityToken } from './core-catalog.js' import { singularize } from './string-utils.js' export interface PlatformCapability { /** kebab key, stable identifier. */ readonly key: string /** Human label (EN). */ readonly label: string /** * FR/EN semantic triggers — whole-token/phrase match against ENTITY names, * SECTION/TAB labels and screen names. A hit means the BA is (re)modeling a * concept the platform capability already covers. */ readonly triggers: readonly string[] /** * Attribute-level triggers: an attribute NAME or declared TYPE that reveals * the need (e.g. `binary`, `fileContent`). Matched whole-token. */ readonly attributeTriggers?: readonly string[] /** The socle service/seam that already covers the capability. */ readonly seam: string /** Actionable guidance: the canonical extension pattern the rules print. */ readonly useInstead: string /** * Repo-relative reference doc carrying the full pattern (path under * `templates/skills/`), when one exists. */ readonly reference?: string } export const PLATFORM_CAPABILITIES: readonly PlatformCapability[] = [ { key: 'file-storage', label: 'File / document storage', triggers: [ 'Document', 'Attachment', 'PièceJointe', 'Pièce jointe', 'Pièces jointes', 'Fichier', 'File', 'GED', 'DMS', 'Justificatif', 'Annexe', 'Scan', 'Upload', 'Téléversement', 'Media', 'Média', 'Photo', ], attributeTriggers: [ 'binary', 'blob', 'varbinary', 'byte[]', 'image', 'filestream', 'fileContent', 'fileData', 'contenu', 'contenuFichier', ], seam: 'IFileStorageService (SmartStack.Application.Common.Interfaces) — Scoped via AddSmartStack, injectable from any extension handler/controller; StorageType Normal/Legal; Local + Azure Blob (config shipped in every generated appsettings)', useInstead: 'Client METADATA entity in extensions.* (FileName, StoredFileName, ContentType, FileSizeBytes + parent FK) + IFileStorageService for the bytes + dedicated AUTHENTICATED upload/download endpoints. NEVER binary content in the DB, never raw disk I/O outside the service.', reference: 'development/backend/data-layer/references/file-storage.md', }, { key: 'global-search', label: 'Global search', triggers: [ 'GlobalSearch', 'RechercheGlobale', 'Recherche globale', 'SearchIndex', 'SearchEngine', 'Moteur de recherche', 'Moteurs de recherche', 'Index de recherche', ], seam: 'AddExtensionSearch (socle search seam) — extension entities plug into the platform global search', useInstead: 'Register searchable entities through the search seam (scaffold-extension-search) — never a client-built search index or engine.', reference: 'development/backend/data-layer/references/global-search.md', }, { key: 'time-entry-refs', label: 'Time-entry bookable targets', // Deliberately NARROW: TimeEntry/Timesheet/Imputation entity names are // already covered by PLATFORM_HR_ENTITIES (CODE-005) — no double-flag. // These triggers catch the NEED ("make X bookable"), not the HR entities. triggers: ['Bookable', 'Imputable', 'TimeEntryRef', 'TimeEntryTarget'], seam: 'AddExtensionTimeEntryRefs — client entities become bookable targets of the platform HR time module', useInstead: 'Register the entity via the time-entry-refs seam (scaffold-time-entry-refs) — never re-model time entries (PLATFORM_HR_ENTITIES / CODE-005 covers those names).', reference: 'development/backend/data-layer/references/time-entry-refs.md', }, { key: 'code-generation', label: 'Business code / number allocation', // The incident this entry closes: the BA used to be TOLD to model a // `technical` counter entity for every gapless pattern, so each module // shipped its own `{Entity}Sequence` table next to an engine that already // allocates gaplessly. Trailing-word matching makes `OpportunitySequence` // and `DemandeCompteur` hits; a legitimate `Code` ATTRIBUTE never is. triggers: [ 'Sequence', 'Séquence', 'Sequences', 'Compteur', 'Counter', 'Numerotation', 'Numérotation', 'Numbering', 'CodePattern', 'Code pattern', 'Allocator', 'Allocateur', 'NumberSequence', 'CodeSequence', ], attributeTriggers: [ 'nextValue', 'nextNumber', 'nextSeq', 'lastValue', 'lastNumber', 'prochainNumero', 'dernierNumero', 'compteur', ], seam: 'ICodedEntity + ICodeKeyDescriptor registered via AddSmartStackCodeKey() — the shared CodedEntitySaveHandler allocates the Code atomically at insert on core.seq_Sequences (UPDLOCK/SERIALIZABLE = gapless by default), scope Tenant/Global, reset None/Yearly/Monthly/Daily; the key surfaces in Administration → Configuration → Code patterns, where a CodePattern DB row only OVERRIDES the built-in default. No seed, no migration', useInstead: 'Declare the `**Code pattern**` on the entity itself (format with {SEQ:n} + scope + reset + gapless) and register the key through scaffold-coded-entity — the socle allocates. NEVER model a counter/sequence/allocator entity, a nextValue column or a client numbering service: the gapless guarantee and the admin-side retuning are the platform\'s.', reference: 'development/backend/data-layer/references/coded-entities.md', }, { key: 'email-sending', // Deliberately NARROW: a bare `Email` entity may legitimately model // RECEIVED/imported mail (a client concern) — only OUTBOUND-send concepts // trigger. The `EmailTemplate` name itself stays in CORE_RESERVED. label: 'Outbound email', triggers: ['EnvoiEmail', 'EmailSortant', 'OutgoingEmail', 'EmailQueue', 'Mailing'], seam: 'IEmailService (Scoped) + Core email templates (email_)', useInstead: 'Send mail through IEmailService with Core email templates — never a client SMTP client, outbound-mail table or template store.', }, ] /** Normalized candidate forms: the token/phrase itself + its singular. */ function candidateForms(candidate: string): string[] { const norm = normalizeEntityToken(candidate) const sing = singularize(norm) return sing === norm ? [norm] : [norm, sing] } function keySet(...tokens: string[]): Set { const keys = new Set() for (const t of tokens) { const norm = normalizeEntityToken(t) keys.add(norm) keys.add(singularize(norm)) } return keys } /** * Split a compound name into words: PascalCase boundaries + any non-alphanumeric * separator (`InteractionDocuments` → [Interaction, Documents]; * `crm_InteractionDocuments`, `pièces-jointes` likewise). * Exported for code-pattern-grammar's code-like lexicon (whole-word matching * over attribute names) — the ONE canonical splitter, never re-encoded. */ export function splitWords(candidate: string): string[] { return candidate .replace(/([a-zà-ÿ0-9])([A-ZÀ-Ý])/g, '$1 $2') .split(/[^A-Za-zÀ-ÿ0-9]+/) .filter(Boolean) } /** * Whole-token/phrase match of an ENTITY / SECTION / TAB / SCREEN name against * the capability triggers. Tries the full name, then — for compound names — * the trailing word and trailing two-word phrase, so `InteractionDocuments`, * `ProjectPiecesJointes` or an onglet « Pièces jointes » all resolve to * "the platform already covers this — steer to the canonical pattern". * Never substring inside a word (`Documentation` does not match). */ export function matchCapabilityTrigger(candidate: string): PlatformCapability | undefined { const words = splitWords(candidate) const probes = [candidate] if (words.length > 1) { probes.push(words[words.length - 1]) probes.push(words.slice(-2).join(' ')) } const forms = probes.flatMap(candidateForms) return PLATFORM_CAPABILITIES.find(c => { const keys = keySet(...c.triggers) return forms.some(f => keys.has(f)) }) } /** * Whole-token match of an ATTRIBUTE name or declared type against the * capability attribute-triggers (e.g. `binary`, `varbinary`, `fileContent` → * file-storage). Never substring. */ export function matchCapabilityAttribute(attrNameOrType: string): PlatformCapability | undefined { const forms = candidateForms(attrNameOrType) return PLATFORM_CAPABILITIES.find(c => { if (!c.attributeTriggers?.length) return false const keys = keySet(...c.attributeTriggers) return forms.some(f => keys.has(f)) }) } /** Lookup a capability by its stable key. */ export function getCapability(key: string): PlatformCapability | undefined { return PLATFORM_CAPABILITIES.find(c => c.key === key) } /** The file-storage entry — the primary "never rebuild storage" target. */ export const FILE_STORAGE_CAPABILITY: PlatformCapability = PLATFORM_CAPABILITIES.find(c => c.key === 'file-storage')!