/** * lib/core-catalog.ts — Canonical catalogue of SmartStack Core entities. * * SINGLE SOURCE OF TRUTH for "what already exists in Core" across the whole * BA → dev pipeline. Mirrors the app contract documented in * `SmartStack.app/docs/extensions/cross-context-references.md`: * * - `CORE_CATALOG_V1` — the V1 whitelist: the 9 Core entities a client * extension may reference with a REAL cross-schema FK + navigation property * (via `SmartStackExtensionDbContext`). Each entry carries the FR/EN aliases * under which users commonly (re)model the concept — e.g. the app exposes * `TenantOrganisation` (a.k.a. "Organisation" / "Company"). A BA/codegen entity * matching a name OR alias is a DUPLICATE of Core data (e.g. * `tenant_TenantOrganisations` is the shared organisation directory) and must be rejected. * - `CORE_RESERVED` — Core concepts that are NOT FK-able (not whitelisted): * modeling one as a client entity is an error; consume via the named service. * - `PERSON_TRIGGERS` — entity names that suggest the person-extension pattern * (FK `UserId` → `core.auth_Users`). These are NOT aliases of `User`: the * entity stays client-owned, but its identity fields live in `auth_Users`. * - Attribute clusters (`PERSON_IDENTITY_ATTRIBUTES`, `COMPANY_IDENTITY_ATTRIBUTES`) * — structural hints that an entity overlaps `auth_Users` / `tenant_TenantOrganisations`. * * Matching is ALWAYS whole-token: normalized (accent/case-insensitive) equality * on the name or its singular — never substring ("UserStory" must NOT match * "User"). Tune detection HERE (under test), never at call sites. * * The BA skills (deployed standalone, markdown-only) carry this catalogue as * inline tables between `` / `` * markers; `lib/__tests__/core-catalog-drift.test.ts` pins those tables to these * exports — edit BOTH or the suite fails. */ import { singularize } from './string-utils.js' export type CoreTenantScope = 'none' | 'strict' | 'optional' export interface CoreCatalogEntry { /** PascalCase singular, as exposed by `SmartStackExtensionDbContext`. */ readonly name: string /** Physical table name, e.g. `auth_Users`. */ readonly table: string /** Schema-qualified table, e.g. `core.auth_Users`. */ readonly qualifiedTable: string /** Tenant filtering applied by the extension context (per the app doc). */ readonly tenantScope: CoreTenantScope /** FR/EN singular synonyms — BLOCK-grade duplicates, matched whole-token. */ readonly aliases: readonly string[] } /** The V1 whitelist — the ONLY Core entities a client extension may FK (scope `core`). */ export const CORE_CATALOG_V1: readonly CoreCatalogEntry[] = [ { name: 'User', table: 'auth_Users', qualifiedTable: 'core.auth_Users', tenantScope: 'none', aliases: ['Utilisateur', 'Usager', 'AppUser', 'ApplicationUser'] }, { name: 'Role', table: 'auth_Roles', qualifiedTable: 'core.auth_Roles', tenantScope: 'none', aliases: [] }, { name: 'Tenant', table: 'tenant_Tenants', qualifiedTable: 'core.tenant_Tenants', tenantScope: 'strict', aliases: ['Locataire'] }, // Company → Organisation unification (app ≥ 3.6x): the `ref_Companies` reference table was // dropped; the FK-able org aggregate is now `TenantOrganisation` (table `tenant_TenantOrganisations`, // exposed as the extension DbSet `Organisations`). `Company`/`Société`/… stay aliases so a BA that // models a customer company still resolves to this entry. See SmartStackExtensionDbContext. { name: 'TenantOrganisation', table: 'tenant_TenantOrganisations', qualifiedTable: 'core.tenant_TenantOrganisations', tenantScope: 'optional', aliases: ['Organisation', 'Organization', 'Société', 'Entreprise', 'Compagnie', 'Company'] }, { name: 'Department', table: 'ref_Departments', qualifiedTable: 'core.ref_Departments', tenantScope: 'optional', aliases: ['Département'] }, { name: 'JobTitle', table: 'ref_JobTitles', qualifiedTable: 'core.ref_JobTitles', tenantScope: 'optional', aliases: ['Fonction', 'Poste', 'JobFunction'] }, { name: 'Office', table: 'ref_Offices', qualifiedTable: 'core.ref_Offices', tenantScope: 'optional', aliases: ['Bureau', 'Bureaux'] }, { name: 'Language', table: 'loc_Languages', qualifiedTable: 'core.loc_Languages', tenantScope: 'none', aliases: ['Langue'] }, { name: 'Group', table: 'auth_Groups', qualifiedTable: 'core.auth_Groups', tenantScope: 'none', aliases: ['Groupe'] }, ] export interface CoreReservedEntry { readonly name: string readonly aliases: readonly string[] /** Actionable guidance: the service / platform feature that covers the concept. */ readonly useInstead: string } /** * Core concepts that are NOT FK-able (outside the V1 whitelist) — a client * entity carrying one of these names duplicates a platform feature. Never a * local table, never a `scope core` FK target: use the named service. */ export const CORE_RESERVED: readonly CoreReservedEntry[] = [ { name: 'Permission', aliases: ['Droit'], useInstead: 'IPermissionService (permission resolution)' }, { name: 'UserSession', aliases: ['Session'], useInstead: 'security-internal — never modeled nor FK-ed' }, { name: 'UserProfile', aliases: ['Profil'], useInstead: 'ICoreDataService.GetUserBasicInfoAsync' }, { name: 'UserPreference', aliases: ['Préférence'], useInstead: 'ICoreDataService' }, { name: 'Setting', aliases: ['Paramètre', 'Configuration'], useInstead: 'platform settings (cfg_) — not a client entity' }, { name: 'Notification', aliases: [], useInstead: 'Core notifications feature (ntf_)' }, { name: 'Ticket', aliases: ['SupportTicket'], useInstead: 'Core support/ticketing feature (tkt_)' }, { name: 'Workflow', aliases: [], useInstead: 'Core workflow feature (wkf_)' }, { name: 'EmailTemplate', aliases: ['ModèleEmail'], useInstead: 'Core email templates (email_)' }, { name: 'AuditLog', aliases: ['JournalAudit'], useInstead: 'Core audit logs — read-only platform feature' }, { name: 'License', aliases: ['Licence'], useInstead: 'Core licensing (lic_)' }, { name: 'Navigation', aliases: ['Menu'], useInstead: 'INavigationService (menu / nav tree)' }, ] export interface PersonTrigger { readonly word: string /** Suggested person mode: internal staff → mandatory, external parties → optional. */ readonly hint: 'mandatory' | 'optional' } /** * Entity names that suggest the person-extension pattern (FK `UserId` → * `core.auth_Users`, identity fields reused — never redeclared in `mandatory` * mode). NOT aliases: the entity stays client-owned. The hint is a PROPOSAL — * `Customer`/`Client` in particular may be a company, not a person (the * attribute clusters below disambiguate; the skill must ask). */ export const PERSON_TRIGGERS: readonly PersonTrigger[] = [ { word: 'Employee', hint: 'mandatory' }, { word: 'Employé', hint: 'mandatory' }, { word: 'Salarié', hint: 'mandatory' }, { word: 'Collaborateur', hint: 'mandatory' }, { word: 'Collaborator', hint: 'mandatory' }, { word: 'Staff', hint: 'mandatory' }, { word: 'Teacher', hint: 'mandatory' }, { word: 'Enseignant', hint: 'mandatory' }, { word: 'Professeur', hint: 'mandatory' }, { word: 'Technician', hint: 'mandatory' }, { word: 'Technicien', hint: 'mandatory' }, { word: 'Agent', hint: 'mandatory' }, { word: 'Manager', hint: 'mandatory' }, { word: 'Consultant', hint: 'mandatory' }, // Fleet family (§30 — the CONDUCTEURS module tripped no deterministic leg; // only the identity-attribute cluster / model judgment saved the modeling). { word: 'Driver', hint: 'mandatory' }, { word: 'Conducteur', hint: 'mandatory' }, { word: 'Chauffeur', hint: 'mandatory' }, { word: 'Customer', hint: 'optional' }, { word: 'Client', hint: 'optional' }, { word: 'Supplier', hint: 'optional' }, { word: 'Fournisseur', hint: 'optional' }, { word: 'Patient', hint: 'optional' }, { word: 'Candidate', hint: 'optional' }, { word: 'Candidat', hint: 'optional' }, { word: 'Contact', hint: 'optional' }, { word: 'Visitor', hint: 'optional' }, { word: 'Visiteur', hint: 'optional' }, { word: 'Member', hint: 'optional' }, { word: 'Membre', hint: 'optional' }, // Annuaire / directory family (the "person directory fully decorrelated from // auth_Users" incident closer — DM-018c err): generic person words + the // interlocutor cluster a client directory typically models. { word: 'Person', hint: 'optional' }, { word: 'Personne', hint: 'optional' }, { word: 'Interlocuteur', hint: 'optional' }, { word: 'Correspondant', hint: 'optional' }, { word: 'Intervenant', hint: 'optional' }, { word: 'Participant', hint: 'optional' }, { word: 'Beneficiary', hint: 'optional' }, { word: 'Bénéficiaire', hint: 'optional' }, ] /** ≥2 of these on an entity ⇒ it almost certainly overlaps `auth_Users` identity. */ export const PERSON_IDENTITY_ATTRIBUTES = ['email', 'firstName', 'lastName', 'displayName'] as const /** ≥1 of these on an entity ⇒ it overlaps `tenant_TenantOrganisations` (the shared organisation directory). */ export const COMPANY_IDENTITY_ATTRIBUTES = ['uid', 'ide', 'siret', 'siren', 'legalForm', 'companyName', 'raisonSociale', 'vatNumber', 'businessIdentifier'] as const /** NFD-strip combining marks → lowercase → trim (same pipeline as `slugifyRoleCode`). */ export function normalizeEntityToken(s: string): string { return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim() } /** Normalized candidate forms: the token itself + its singular. */ function candidateForms(candidate: string): string[] { const norm = normalizeEntityToken(candidate) const sing = singularize(norm) return sing === norm ? [norm] : [norm, sing] } function matchKeys(name: string, aliases: readonly string[]): Set { return new Set([name, ...aliases].map(normalizeEntityToken)) } /** * Whole-token match of `candidate` (singular-tolerant, accent/case-insensitive) * against the V1 whitelist names + aliases. NEVER substring — `UserStory`, * `CompanyVisit` do not match. */ export function matchCoreEntity(candidate: string): CoreCatalogEntry | undefined { const forms = candidateForms(candidate) return CORE_CATALOG_V1.find(e => { const keys = matchKeys(e.name, e.aliases) return forms.some(f => keys.has(f)) }) } /** Same algorithm over the reserved (service-only) Core names. */ export function matchReservedCoreName(candidate: string): CoreReservedEntry | undefined { const forms = candidateForms(candidate) return CORE_RESERVED.find(e => { const keys = matchKeys(e.name, e.aliases) return forms.some(f => keys.has(f)) }) } /** Same algorithm over the person-trigger words. */ export function matchPersonTrigger(candidate: string): PersonTrigger | undefined { const forms = candidateForms(candidate) return PERSON_TRIGGERS.find(t => forms.includes(normalizeEntityToken(t.word))) } /** * The V1 whitelist as a name set — the shape `scaffold-entity`'s relation * validation consumes (`targetScope: 'core'` targets must be in here). */ export const CORE_WHITELIST_V1 = new Set(CORE_CATALOG_V1.map(e => e.name)) /** * Namespace each whitelist entity ships under. Used to emit `using` statements * in the generated entity and configuration files. Best-effort — if a client * hits an "ambiguous reference" / "missing namespace" compile error, adjust the * matching entry rather than working around it locally. */ export const CORE_WHITELIST_V1_NAMESPACES: Record = { User: 'SmartStack.Domain.Platform.Administration.Users', Role: 'SmartStack.Domain.Platform.Administration.Roles', Tenant: 'SmartStack.Domain.Platform.Administration.Tenants', TenantOrganisation: 'SmartStack.Domain.Platform.Administration.Tenants.Organisation', Department: 'SmartStack.Domain.Platform.Administration.References', JobTitle: 'SmartStack.Domain.Platform.Administration.References', Office: 'SmartStack.Domain.Platform.Administration.References', Language: 'SmartStack.Domain.Localization', Group: 'SmartStack.Domain.Identity', } /** * Properties commonly projected from each whitelist entity into extension DTOs * (`fields[].source` in scaffold-business). Best-effort typo guard — validators * WARN (never block) on a property outside this map. */ export const CORE_PROJECTABLE_FIELDS: Record = { User: ['FirstName', 'LastName', 'Email', 'DisplayName'], Role: ['Name'], Tenant: ['Name'], TenantOrganisation: ['Name', 'LegalName', 'BusinessIdentifier', 'VatNumber'], Department: ['Name'], JobTitle: ['Name'], Office: ['Name'], Language: ['Name'], Group: ['Name'], } /** * The canonical lookup endpoint of a Core V1 catalogue — the ONE derivation * every producer must import (derive-fk-specs for FK fields, derive-action-specs * for custom-action lookup parameters): two producers with two derivations is * exactly how the same catalogue got called on /api/core/offices/lookup by * the list filter and /api/parc/offices/lookup by the « transférer » action. * Null: not a Core entity, or TenantOrganisation (its organization-references * adaptateur replaces the lookup). */ export function coreLookupEndpointFor(candidate: string): string | null { const core = matchCoreEntity(candidate) if (!core || core.name === 'TenantOrganisation') return null const plural = core.name.endsWith('s') ? core.name : `${core.name}s` const segment = plural.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() return `/api/core/${segment}/lookup` }