/** * lib/platform-catalog.ts — Canonical catalogue of the SmartStack PLATFORM * navigation (built-in Application → Module → Section tree shipped inside the * NuGet backend + npm frontend package). * * SINGLE SOURCE OF TRUTH for "which apps/modules the platform ALREADY provides" * across the BA menu pipeline. It is the nav-level analogue of `core-catalog.ts` * (which stops at ENTITIES). The built-in modules ship inside the package, * invisible to a client's `src/` scan — so without this table `ba-create-menu` * cannot tell that "an HR application" duplicates the platform `hr` app: it would * model a greenfield duplicate instead of planning a client EXTENSION under the * existing app. This catalogue closes that blind spot. * * Mirrors the app seed source of truth (edit HERE when those change): * SmartStack.app/src/SmartStack.Infrastructure/Persistence/Seeding/Data/ * Navigation/Navigation{Application,Module,Section}SeedData.cs * * The BA skills (deployed standalone, markdown-only) carry the built-in-apps * table inline between `` markers and the HR * transactional-entity table between ``. * `lib/__tests__/platform-catalog-drift.test.ts` pins those tables to these * exports — edit BOTH or the suite fails. * * Matching is ALWAYS whole-token/phrase: normalized (accent/case-insensitive) * equality on the code, label or an alias — never substring ("HRManager" must not * match "HR"). Tune detection HERE (under test), never at call sites. */ import { normalizeEntityToken } from './core-catalog.js' import { singularize } from './string-utils.js' /** A built-in module (2nd nav level) under a platform application. */ export interface BuiltinModule { /** kebab code as seeded in `nav_Modules.Code`. */ readonly code: string /** English seed label. */ readonly label: string /** FR/EN domain synonyms an analyst may (re)propose for this module. */ readonly aliases: readonly string[] /** * Section codes seeded under the module (for the "here's what already exists, * extend under it" listing). Present for the HR app; omitted elsewhere. */ readonly sections?: readonly string[] /** * `true` ⇒ re-creating this concept as a client module is a MENU-003 collision. * Reserved for DISTINCTIVE platform concepts (e.g. HR absences / time). Generic * modules (dashboard, reporting, configuration, profile…) stay `false` — a * client legitimately owns those under its OWN app. */ readonly distinctive?: boolean } /** A built-in platform application (top nav level). */ export interface BuiltinApp { /** kebab code as seeded in `nav_Applications.Code`. */ readonly code: string /** English seed label. */ readonly label: string /** Seed GUID (`nav_Applications.Id`) — a client module extending this app FKs it. */ readonly guid: string /** `IsPersonal` — a personal app (myspace) is always visible, never client-extended. */ readonly isPersonal: boolean /** * `true` ⇒ a client adds its custom modules UNDER this existing app rather than * recreating it (the "extend, don't duplicate" path). `false` for the personal app. */ readonly extendable: boolean /** FR/EN domain names that resolve to this app (e.g. "RH" → `hr`). */ readonly aliases: readonly string[] readonly modules: readonly BuiltinModule[] } /** * The built-in applications, in seed order. Mirrors NavigationApplicationSeedData * + NavigationModuleSeedData (SmartStack.app). HR carries its sections because it * is the primary "extend a built-in module" target. */ export const PLATFORM_APPS: readonly BuiltinApp[] = [ { code: 'administration', label: 'Administration', guid: 'e6b3d9f5-2c7a-8e1d-4b9c-7f3a5d8e2c1b', isPersonal: false, extendable: false, aliases: ['Admin', 'System', 'Système', 'Paramétrage système'], modules: [ { code: 'users', label: 'Users', aliases: ['Utilisateurs'], distinctive: true }, { code: 'permissions', label: 'Permissions', aliases: ['Rôles', 'Roles', 'RBAC', 'Droits'], distinctive: true }, { code: 'applications', label: 'Applications', aliases: ['Navigation', 'Menu'] }, { code: 'ai', label: 'IA', aliases: ['AI', 'Intelligence artificielle'] }, { code: 'workflows', label: 'Workflows', aliases: ['Flux', 'Automatisations'], distinctive: true }, { code: 'entra', label: 'Microsoft Entra ID', aliases: ['Entra', 'Azure AD', 'SSO'], distinctive: true }, { code: 'configuration', label: 'Configuration', aliases: ['Réglages', 'Settings'] }, { code: 'tenants', label: 'Tenants', aliases: ['Locataires'], distinctive: true }, { code: 'uiconfiguration', label: 'UI Configuration', aliases: ['Thème', 'Branding'] }, { code: 'integrations', label: 'API', aliases: ['Intégrations', 'Integrations'] }, ], }, { code: 'support', label: 'Support', guid: '1ee9c5d7-44f3-4fdc-9bee-05936c066ae2', isPersonal: false, extendable: true, aliases: ['Helpdesk', 'Ticketing', 'Billetterie', 'Assistance', 'SAV'], modules: [ { code: 'tickets', label: 'Tickets', aliases: ['Billets', 'Demandes de support'], distinctive: true }, { code: 'my-tickets', label: 'My Tickets', aliases: ['Mes tickets'] }, { code: 'admin', label: 'Paramètres', aliases: ['SLA', 'Templates'] }, { code: 'impersonation', label: 'Impersonation', aliases: ['Usurpation'], distinctive: true }, ], }, { code: 'hr', label: 'Human Resources', guid: '9cbeae29-772f-43b1-ac93-c56f2cb90921', isPersonal: false, extendable: true, aliases: ['RH', 'HR', 'Ressources Humaines', 'Ressources humaines', 'Personnel', 'GRH', 'Human Resources'], modules: [ { code: 'employees', label: 'Employees', aliases: ['Employés', 'Salariés', 'Collaborateurs', 'Effectif', 'Trombinoscope'], sections: ['list', 'org-chart'], distinctive: true }, { code: 'organization', label: 'Organization', aliases: ['Organisation', 'Départements', 'Fonctions', 'Sites'], sections: ['departments', 'job-titles', 'offices', 'holidays', 'contract-types'] }, { code: 'absences', label: 'Absences', aliases: ['Congés', 'Absences', 'Vacances', 'Demandes de congés'], sections: ['requests', 'calendar', 'types'], distinctive: true }, { code: 'time', label: 'Time Tracking', aliases: ['Temps', 'Saisie du temps', 'Pointage', 'Feuilles de temps', 'Imputation'], sections: ['inbox', 'entries', 'activities', 'settings'], distinctive: true }, { code: 'my-absences', label: 'My Absences', aliases: ['Mes absences', 'Mes congés'], sections: ['requests', 'balance', 'team-calendar'], distinctive: true }, { code: 'my-time', label: 'My Time', aliases: ['Mon temps', 'Ma feuille de temps'], sections: ['week', 'overview'], distinctive: true }, { code: 'reporting', label: 'Reporting', aliases: ['Rapports', 'Indicateurs RH'], sections: ['overview'] }, ], }, { code: 'api', label: 'API', guid: '5c875c2b-8ef0-4274-ac73-c0358f41a070', isPersonal: false, extendable: true, aliases: ['External API', 'Data export', 'Export de données', 'Intégrations externes'], modules: [ { code: 'accounts', label: 'API Management', aliases: ['External applications', 'Applications externes', 'Clés API'], distinctive: true }, ], }, { code: 'myspace', label: 'My Space', guid: 'a2c8f4d7-9e1b-5a3c-6d8f-1b7e4c9a2d5f', isPersonal: true, extendable: false, aliases: ['Espace personnel', 'Mon espace', 'Personal workspace'], modules: [ { code: 'dashboard', label: 'Dashboard', aliases: ['Tableau de bord'] }, { code: 'profile', label: 'Profile', aliases: ['Profil'] }, { code: 'preferences', label: 'Preferences', aliases: ['Préférences'] }, { code: 'tenants', label: 'My Tenants', aliases: ['Mes espaces'] }, ], }, ] /** Platform HR transactional entities a client must NOT re-model (owned by the `hr` module). */ export interface PlatformHrEntity { /** PascalCase concept name. */ readonly name: string /** Physical table (schema `core`), for the finding message. */ readonly table: string /** FR/EN synonyms — matched whole-token. */ readonly aliases: readonly string[] } /** * The transactional heart of the built-in HR module. A client entity carrying one * of these names is (almost always) rebuilding a platform feature — CODE-005 emits * a `warn` steering it to EXTEND the `hr` app / plug into the time-entry-refs seam * / consume `ICoreDataService`, not fork the data. * * NOTE: `Employee` is intentionally ABSENT — it is a `PERSON_TRIGGER` in * `core-catalog.ts` (a client owns its own Employee FK-ing `auth_Users`), so it * must not be double-flagged here. Reference-data (`Department`/`JobTitle`/`Office`) * lives in `core-catalog.ts` as FK-able whitelist entries, not here. */ export const PLATFORM_HR_ENTITIES: readonly PlatformHrEntity[] = [ { name: 'Absence', table: 'core.hr_AbsenceRequests', aliases: ['Congé', 'AbsenceRequest', 'DemandeAbsence', 'LeaveRequest', 'Leave'] }, { name: 'AbsenceType', table: 'core.hr_AbsenceTypes', aliases: ['TypeAbsence', 'TypeCongé'] }, { name: 'TimeEntry', table: 'core.hr_TimeEntries', aliases: ['SaisieTemps', 'Imputation', 'Pointage', 'TimeLog'] }, { name: 'Timesheet', table: 'core.hr_TimesheetWeeks', aliases: ['FeuilleDeTemps', 'FeuilleTemps', 'TimesheetWeek'] }, { name: 'ContractType', table: 'core.hr_ContractTypes', aliases: ['TypeContrat'] }, ] /** 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 } /** * Whole-token/phrase match of an analyst-proposed APPLICATION name (code, label or * domain) against the built-in apps. This is what turns "une application RH" into * the `hr` app so the skill switches to the EXTEND path. Never substring. */ export function matchBuiltinApp(candidate: string): BuiltinApp | undefined { const forms = candidateForms(candidate) return PLATFORM_APPS.find(app => { const keys = keySet(app.code, app.label, ...app.aliases) return forms.some(f => keys.has(f)) }) } /** * Whole-token match of a proposed MODULE name against DISTINCTIVE built-in modules * only (generic ones — dashboard, configuration, reporting… — never match). Returns * the owning app + module so a caller can say "already provided by /". */ export function matchBuiltinModule(candidate: string): { app: BuiltinApp; module: BuiltinModule } | undefined { const forms = candidateForms(candidate) for (const app of PLATFORM_APPS) { for (const module of app.modules) { if (!module.distinctive) continue const keys = keySet(module.code, module.label, ...module.aliases) if (forms.some(f => keys.has(f))) return { app, module } } } return undefined } /** Whole-token match of a BA entity name against the platform HR transactional entities. */ export function matchPlatformHrEntity(candidate: string): PlatformHrEntity | undefined { const forms = candidateForms(candidate) return PLATFORM_HR_ENTITIES.find(e => { const keys = keySet(e.name, ...e.aliases) return forms.some(f => keys.has(f)) }) } /** Lookup a built-in app by its exact seed code. */ export function getBuiltinApp(code: string): BuiltinApp | undefined { return PLATFORM_APPS.find(a => a.code === code) } /** The HR app entry — the primary "extend, don't duplicate" target. */ export const HR_APP: BuiltinApp = PLATFORM_APPS.find(a => a.code === 'hr')!