@primocaredentgroup/customer-accounts
v0.5.6
Published
Convex component per la gestione clienti esterni del laboratorio — PrimoLabcore
Readme
@primocaredentgroup/customer-accounts
Componente Convex riusabile per l'anagrafica clienti del laboratorio: account, contatti, sedi, profili operativi, note, task, attività e richieste dal portale.
Prerequisiti
- Node.js 20+
- Convex
^1.43.0 - accesso npm a
@primocaredentgroup/migration-kit@^2.3.8, peer richiesto dal component source - un provider di autenticazione configurato nell'app host
Installazione
npm install @primocaredentgroup/customer-accountsRegistra il componente mantenendo l'handle canonico customerAccounts:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import customerAccounts from "@primocaredentgroup/customer-accounts/convex.config.js";
const app = defineApp();
app.use(customerAccounts);
export default app;Le capability di migrazione dichiarano componentName: "customerAccounts".
Un alias di mount differente richiede supporto esplicito del migration kit.
Boundary host autenticato
Il browser non deve chiamare riferimenti components.customerAccounts e non
deve fornire userId, authSubject, email o ruoli per autorizzarsi. L'host
deriva l'identità da ctx.auth, applica il proprio RBAC e usa exposeApi come
unico boundary pubblico per le operazioni CRM dello staff.
// convex/customerAccounts.ts
import {
CustomerAccountsClient,
exposeApi,
} from "@primocaredentgroup/customer-accounts";
import { components } from "./_generated/api.js";
export const customerAccountsClientForActor = (tokenIdentifier: string) =>
new CustomerAccountsClient(components.customerAccounts, {
callerTokenIdentifier: tokenIdentifier,
});
export const {
createAccount,
updateAccount,
getAccountByCustomerCode,
paginateAccounts,
addAccountContact,
addAccountLocation,
getAccountOverview,
// ...esporta soltanto le funzioni richieste dall'app
} = exposeApi(components.customerAccounts, {
getActorContext: async (ctx, permission) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Autenticazione richiesta");
await authorizeCustomerAccountsPermission(identity, permission);
return { tokenIdentifier: identity.tokenIdentifier };
},
});exposeApi espone intenzionalmente solo l'API CRM dello staff. Le operazioni
portale restano nel client backend perché l'associazione utente → account è
proprietà dell'host. Prima di chiamarle, l'host deve risolvere server-side
l'accountId consentito e creare il client con il tokenIdentifier corrente.
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Autenticazione richiesta");
const accountId = await resolvePortalAccountForIdentity(ctx, identity);
const client = customerAccountsClientForActor(identity.tokenIdentifier);
return await client.portal.createOrderRequest(ctx, {
accountId,
title: args.title,
});Le letture di dettaglio portale usate dal client sono sempre scoped per
accountId. I vecchi riferimenti raw non scoped restano disponibili soltanto
per compatibilità con la 0.3 e sono deprecati.
Liste e paginazione
Le API legacy list* continuano a restituire array, ma sono bounded:
- default 100 record;
- massimo 200;
- se esistono più record del limite richiesto, generano un errore esplicito che
indica la corrispondente API
paginate*.
Per sincronizzazioni, selettori completi e dataset non banali usa sempre le API
paginate con paginationOpts Convex. Il numero di elementi per pagina deve
essere compreso tra 1 e 200.
const firstPage = await client.accounts.paginate(ctx, {
isActive: true,
paginationOpts: { numItems: 100, cursor: null },
});Le API paginate* usano cursori compatibili con il boundary dei componenti;
trattali sempre come stringhe opache. searchAccountsPaginated usa un cursore
dedicato legato ai filtri della ricerca e una finestra bounded di massimo 1.000
risultati. Per questa API gli advanced options endCursor, maximumRowsRead e
maximumBytesRead non sono supportati.
Il filtro legacy tag richiede membership in un array e non può produrre una
paginazione corretta con lo schema attuale. È supportato solo da listAccounts
con scan bounded; non è accettato da paginateAccounts.
Per operazioni idempotenti non enumerare gli account: usa
getAccountByCustomerCode. customerCode viene trim-mato in creazione e
lookup, conserva il case ed è l'identità stabile e immutabile del master
cliente.
Tabelle isolate
| Tabella | Responsabilità |
| --- | --- |
| lookupValues | Lookup configurabili |
| accounts | Anagrafica master cliente |
| accountContacts | Contatti dell'account |
| accountLocations | Sedi dell'account |
| accountOperationalProfiles | Profilo operativo 1:1 |
| accountNotes | Note interne |
| accountTasks | Task e follow-up |
| accountActivities | Timeline audit |
| portalClinicExternalLinks | Associazioni clinica esterna → account |
| portalOrderRequests | Richieste del portale |
| portalOrderAttachments | Allegati delle richieste |
API per dominio
Il client CustomerAccountsClient raggruppa le operazioni nei domini:
accounts: create/update/lifecycle/get/list/paginate/search;contactselocations: lifecycle, primario unico, list/paginate;operationalProfiles: upsert 1:1 e get;notes,tasks,activities: timeline operativa bounded/paginata;lookups: lifecycle, lookup diretto, list/paginate;overview: aggregato bounded dell'account;portal: link cliniche, richieste e allegati con ownership verificata.
Tutti i validator degli argomenti, documenti, pagine e ritorni sono esportati
da @primocaredentgroup/customer-accounts/validators e
@primocaredentgroup/customer-accounts/returns.
I campi opzionali delle mutation update possono essere:
- omessi per lasciarli invariati;
- valorizzati per aggiornarli;
- impostati a
nullper cancellarli, dove previsto dal validator.
Il client generico non permette di creare lookup con isSystem: true: quel
flag rende il record non disattivabile e richiede una capability privilegiata
progettata esplicitamente dall'host. I seed dimostrativi devono creare lookup
normali.
Migration kit
Il pacchetto espone una row storica verificata, con contratto v2:
CustomerAccount.Identity, identità originaleexternalId(contratto v3).
Contatti, sedi e transazioni portale sono rinviati finché non esistono chiavi esterne stabili e un contratto esplicito per le relazioni possedute dall'host.
Con migration-kit 2.3.8 o successivo configura il modulo npm esplicitamente:
// migration-kit.config.ts
export default {
componentGlobs: [],
modules: ["@primocaredentgroup/customer-accounts/migration"],
dispatcher: { visibility: "internal" },
};La capability supporta preview e commit, con batch fino a 100 record.
preview non scrive. La row dichiara reconcileStrategy: "reconcile": il replay
richiede il mapping kit approvato, la provenienza coerente e originali invariati.
Collisioni con account operativi o gruppi sorgente discordanti vengono rifiutati;
non viene eseguito un upsert per codice. I valori originali assenti restano assenti.
I mapping discordanti richiedono una riconciliazione esplicita: non vanno cancellati
per forzare un reimport. Il nuovo input.externalId è la chiave sorgente stabile e
deve coincidere col batch; customerCode è un attributo CRM opzionale distinto.
I lockfile del pacchetto e del workspace usano migration-kit 2.3.10. La verifica
npm run check:exports, inclusa in verify, importa il client e i subpath di
migrazione con Node nativo. convex-helpers resta fissato a 0.1.123, compatibile
con Convex 1.44.0 usato dal pacchetto; versioni successive che richiedono un peer
Convex più recente vanno aggiornate insieme al relativo contratto di compatibilità.
Upgrade dalla 0.3
Prima di installare la 0.4 su un deployment esistente esegui un preflight in sola lettura e blocca il rollout se trovi:
customerCodevuoti, con spazi esterni o duplicati esatti;customerTypevuoti o con spazi esterni (la 0.4 normalizza i nuovi valori e i filtri, ma non riscrive automaticamente le righe legacy);- duplicati
(category, code)nei lookup; - più profili operativi per account;
- più contatti o sedi primarie attive per account;
- link portale duplicati per account/clinica;
- richieste portale in bozza create con
subject, email o user id invece deltokenIdentifiercanonico: il confronto ownership della 0.4 è esatto, quindi completale prima dell'upgrade oppure esegui un backfill con una mapping identità verificata; - metadata attività non conformi al record JSON flat supportato: primitive o array di primitive, senza oggetti annidati.
La release aggiunge indici a tabelle esistenti. Sui deployment PrimoCare verificati il dataset è piccolo; per un'installazione con tabelle grandi applica il rollout staged prescritto da Convex: prima indici staged, backfill/verifica, poi indici attivi e codice che li interroga.
Non eseguire migrazioni o deploy di produzione come parte dell'installazione
npm. Prima usa preview, conserva un export/backup, applica batch bounded e
verifica mapping e conteggi prima/dopo. Un retry orchestrato di identità già
mappate deve risultare skipped; il test di upsert con lo stesso ID riguarda la
capability diretta, non sostituisce questa verifica dell'orchestratore.
Sviluppo del package
npm install
npm run verify
npm pack --dry-runverify esegue build, test e typecheck usando i binding ufficiali tracciati nel
repository. Dopo una modifica alla superficie Convex, il maintainer esegue
separatamente npm run codegen, verifica il diff dei binding e solo dopo lancia
verify. Il publish richiede il normale 2FA npm dell'organizzazione.
Commercial configuration (0.5.0)
client.commercial stores revisioned scope configurations and per-service rules for
fastlab and centro_fresaggio. Scopes are account/company/clinic/doctor. The host
must authenticate, authorize and validate every external scope/catalog reference;
the component does not grant access to clinics or users. saveScope and saveRule
require the revision last read (0 for creation), preventing lost updates. Rule and
scope history is immutable and paginated. Empty allowlists and disabled scopes
remain distinct from missing configuration. Company sharing is exclusive, even
when no company rule exists; doctor rules overlay clinic rules by service.
Use @primocaredentgroup/customer-accounts/commercial for integer-cent quotes,
quantity modes, phase selection, date-only validation and commission calculations.
All catalog prices and phases must be loaded server-side. Explicit net zero means
free; legacyFastlabNet alone interprets historical zero as absent discount.
Model surcharge is added once per order. Agreed milling price is an order total;
commission takes that total once and excludes separately stored shipping costs.
saveRule receives an authoritative clinical phase catalogue and a host-resolved
canChangeSkipPolicies; never copy this capability from public request arguments.
It requires delivery and rejects removal of protected phases without permission.
Clinic configuration must not reuse internal production phase identifiers.
client.partnership manages partners, case-normalized unique referral codes,
customer assignment and inclusive non-overlapping date periods. Snapshot the
result of commissionSnapshot when creating an order; later changes must not
rewrite old orders. Time boundaries use Europe/Rome. Mutations record actor,
revision and immutable audit rows. Partner status does not itself authorize a
portal member to view reports; that is a separate host permission.
This package does not own order status, uploads, patient records or production workflow. These are orchestrated by PrimoLabCore. No import or remote deployment is performed by installing the package.
Historical master migration (contract v3)
/migration exports CustomerAccount.Identity; /migration/validators exports
its table-derived schema and typed batch/result contracts. Use
CustomerAccountsClient.migration.loadCustomerAccountIdentity through an internal
host dispatcher. The catalog requires the stable original externalId and
legalName. customerCode, displayName, customerType, relationshipStatus,
serviceProfiles, isActive, createdBy and updatedBy are optional originals.
Missing values stay absent; false, empty arrays, whitespace and zero timestamps
are preserved where valid. The CRM code is separate from the source identity.
No synthetic actors, customer codes, service grants, state or activities are created.
For compatibility, direct callers of the former code-based loader can still omit
input.externalId when input.customerCode equals the batch source identity.
New mappings use the v3 catalog; review existing mappings before switching contracts.
Incomplete imported accounts cannot operate services or be reactivated until the CRM code, display name, type, relationship status and service profiles are supplied. The regular update API can complete those fields without activation and can assign an absent CRM code after checking uniqueness. An assigned code stays immutable. Setting relationship status and reactivation remain separate explicit operations; original absent creation actors are not fabricated during completion.
A preexisting business code is never claimed or overwritten. Replay requires an
approved existingConvexId mapping, matching provider provenance and identical
original master fields; missing/changed optional values require reconciliation.
Conflicting duplicate groups all fail before writes, independently of input order;
identical duplicates share one insertion. Preview performs validation without writes.
Unexpected write failures escape so the host transaction can roll back provider
writes and migration identities together.
ownerUserId and listiniListId are rejected by the master input schema rather
than dropped. Operational ownership/access and the master list connection are separate from historical preservation.
The optional historicalOwnerEmail preserves the institutional/clinic-owner email
exactly, including case, spaces, empty value or absence. It never populates
ownerUserId, changes contact email, grants portal access or creates activities.
It is accepted only by the historical loader; normal account create/update cannot
set it. Replay rejects changed or omitted originals, including adding a value to
a record previously imported without one.
Commercial/portal children, lookups and storage bindings use the contracts below. No original actor is inferred to be a Core User.
Releases use the existing push-to-main GitHub Action with automatic committed patch
and public npm visibility; the private demo app is not published.
Historical customer children
The same module now declares CustomerContact.Identity, CustomerLocation.Identity,
CustomerOperationalProfile.Identity, CustomerNote.Identity, CustomerTask.Identity
and CustomerActivity.Identity. Each schema derives from its destination table and
requires externalId (the original, qualified record PK) and accountExternalId
(the approved CustomerAccount original source ID). Do not derive child keys from names,
titles or parent pairs. The host must use the kit-resolved
fields.accountExternalId.id; the provider verifies the actual account and its
source provenance before writing. Deleted/inactive masters may retain their history.
All six loaders preserve original flags, text, actor strings, array order and optional
externalCreationTime, including zero and absence. They never call operational CRUD,
log new activities, complete tasks, reactivate accounts, grant portal access or emit
notifications. A completed task requires its original completion time and actor.
Conflicting active primary contacts/locations and multiple operational profiles stop;
no primary is demoted and no profile is overwritten. Replay requires the approved
physical ID and unchanged originals. Preview runs the same preflight without writes.
A distinct source ID remains a distinct record even if its business content is equal.
Commercial and portal history
The module also declares identities for lookup values, portal clinic links, requests and attachments, commercial scopes/rules/revisions/sharing, partners, referral codes, referrals, commission periods and partner revisions: all 21 existing tables have provider loaders. This does not certify host adoption or a real import; the master historical owner email is supported; operational ownership and the master list connection remain separate.
Every new row retains its qualified source PK and original payload in
migrationOriginal, resolves FK fields explicitly, and sets the technical
migrationPendingActivation marker. Original enabled/isActive/status values remain
unchanged. Ordinary APIs hide pending history or reject operations with
CUSTOMER_HISTORY_ACTIVATION_REQUIRED; no activation endpoint is provided.
Operational writes cannot overwrite pending records. Activation requires a separate,
reviewed migration and must check then-current ownership and catalog compatibility.
scopeExternalIdlinks a rule toCustomerCommercialScope.Identity.partnerExternalIdlinks referral codes/referrals/commission periods toCustomerPartner.Identity; referral account and partner account are separate roles.orderExternalIdlinks an attachment toCustomerPortalOrder.Identityand must match the account. Storage stays in the host; files are not uploaded by the loader.Company.Identity,Clinic.Identity,Doctor.Identityare host-owned references.CustomerListiniList,CustomerListiniRow,CustomerListiniService,CustomerListiniPhaseidentities must bind explicitly to existing catalog records.CustomerPortalStorage.Identitymust bind a verified host-storage file (hash, size, content type). The host validates physical targets and every approved kit mapping in the same mutation; the typed migration client requires a guard callback.
Use fields.<field>.id and ordered fields.phaseExternalIds.ids roles. Never resolve
parents by names, emails or titles. Scope kinds and original scope keys must agree;
source phase lists must exactly mirror the original terms, including order. Commercial
amounts preserve zero; incompatible native terms stop rather than being recalculated.
Referrals preserve the case of original codes; activating them would separately require
review against the native case-normalizing operational API. Commission overlaps fail
closed, including old long intervals; over 1000 candidate intervals requires review.
Opaque partner revision JSON is retained literally; embedded snapshots are not live FKs.
The batch limit is 100. Preview performs the complete preflight without writes; exact
replay requires an approved physical ID and identical originals. All conflicting source
and business-key groups fail before writes. Provider writes participate in the host
transaction with kit mappings. Source completeness, operational owner binding, consumer bindings
and actual import rehearsal remain independent checks.
Institutional/clinic-owner email is preserved in historicalOwnerEmail, not treated as an authenticated identity.
