@blueforge-studio/service-crm

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

Package Info

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

VersionPublished
0.1.12026-10-16
0.1.02026-10-16

Per-version supply chain

VersionSigSBOMAtt
0.1.1
0.1.0

Click a version to view its details.

Supply chain

Provenance

Published by
web:5
When
2026-10-16T18:13:35Z
sha256 (registry-computed)
589fd511a34c0baee708f7b303ad371aca6675ce807663847fa16cb3caeedf80

Observed by the registry at publish.

Claimed by the build

The publisher claimed nothing about its build.

Attachments

Signature
none
SBOM
none

Dependencies

Loading dependency graph...

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.