@blueforge-studio/service-crm

CRM service factory: createCrmService with memory and console adapters (contacts, tags, idempotent events); results never throw

Informations sur le paquet

Latest Version0.1.1
LicenseMIT
Keywords-
Repositorygit+https://github.com/blueforge-studio/blueforge-org.git
Author-
Homepagehttps://github.com/blueforge-studio/blueforge-org#readme
Bugs[object Object]

Versions

VersionPubliée
0.1.12026-10-16
0.1.02026-10-16

Chaîne d'approvisionnement par version

VersionSigSBOMAtt
0.1.1
0.1.0

Cliquez sur une version pour voir ses détails.

Chaîne d'approvisionnement

Provenance

Publié par
web:5
Quand
2026-10-16T18:13:35Z
sha256 (calculé par le registre)
589fd511a34c0baee708f7b303ad371aca6675ce807663847fa16cb3caeedf80

Observé par le registre au moment de la publication.

Déclaré par la compilation

L'éditeur n'a rien déclaré sur sa compilation.

Pièces jointes

Signature
aucune
SBOM
aucune

Dépendances

Chargement du graphe de dépendances...

README

@blueforge-studio/service-crm

Provider-neutral CRM access for apps: createCrmService() reads CRM_PROVIDER and returns a stable CrmService (upsertContact, addTags, recordEvent). Same pattern as service-support / service-analytics.

CRM_PROVIDERWhat it is
memory (default)In-memory, silent. Dev and tests; also the reference for the semantics a real adapter must match.
consoleMemory plus one log line per call. Prints e-mail addresses: development only.

There is no marketing-os adapter yet, on purpose. marketing-os has a CRM, but (2026-10-16, from that session) its only write path for another app is an HMAC ingest for a closed list of four source apps (each needs a code change and an operator-registered secret); the API-key route for other apps is a proposal (marketing-os PR #481, not merged), and its sdk-crm types have drifted from the server. When that route exists, an adapter slots in behind the same interface.

import { createCrmService } from "@blueforge-studio/service-crm";
const crm = createCrmService();

// 1. Keep YOUR record of the lead first (your inbox / database). It is the source of truth.
await saveToInbox(form);

// 2. Then tell the CRM, best-effort. This never throws.
const contact = await crm.upsertContact({ email: form.email, name: form.name, locale: "en", tags: ["waitlist"], source: "footer-waitlist" });
const event = await crm.recordEvent({ email: form.email, type: "waitlist", source: "footer-waitlist", intent: "waitlist" });
if (!contact.ok || !event.ok) log.warn({ contact, event });   // the lead is already safe

Naming: source, type, intent

  • source: where the person came from: the form or surface (footer-waitlist, hero, contact-form).
  • type: what happened: signup | waitlist | contact (other strings allowed).
  • intent: optional refinement of the type (newsletter vs waitlist).

Don't build source out of the type or intent (that is how you get waitlist-waitlist): they are already separate fields, and the default idempotency key is email|type|source|intent.

Inspecting state in tests

createCrmService() returns the CrmService interface. To assert on what was stored, construct the adapter directly:

import { MemoryCrmAdapter } from "@blueforge-studio/service-crm";
const crm = new MemoryCrmAdapter();      // inject it where your code takes a CrmService
await handler(crm);
expect(crm.contacts()).toHaveLength(1);  // normalised contacts
expect(crm.events()[0]).toMatchObject({ type: "waitlist", source: "footer-waitlist" });

Semantics

  • Results, not exceptions. Every call resolves to { ok: true, value } or { ok: false, error, retryable }. Bad input (invalid-email, missing-source, missing-type) and contact-not-found are not retryable; a backend adapter marks transient failures retryable. A failing CRM must never fail the user's request.
  • Identity is the normalised e-mail (trimmed, lower-cased).
  • upsertContact merges: tags are unioned, empty fields never overwrite existing ones, source and createdAt stay from the first write. consent ({ marketing, recordedAt }) is stored as given; the package does not decide what consent your use requires.
  • recordEvent is idempotent on email|type|source|intent by default (same person, same thing, same place = one event; the second call returns duplicate: true). Pass idempotencyKey (e.g. a submission id) when repeats are legitimate.
  • Event types signup | waitlist | contact are the known kinds; other strings are allowed.

Not here: reading contacts back, deleting/erasing a contact (GDPR requests need a real backend), segmentation, scoring.