@@ -0,0 +1,108 @@
|
||||
// Shared pure transformations used by the actual CRM core AND its approval
|
||||
// inspector. No database, network, permission or Root authority is established.
|
||||
import { actorRef } from "@conusai-kit/business"
|
||||
import { forbidden, type ProjectScope } from "@conusai-kit/core"
|
||||
import { type ContactView, SUBJECT_SCHEME } from "../contract/index.js"
|
||||
import type { CrmService } from "./ports.js"
|
||||
|
||||
export interface PartyActionInput {
|
||||
kind: "person" | "organization"
|
||||
displayName: string
|
||||
legalName?: string | undefined
|
||||
country: string
|
||||
linkSelf?: boolean | undefined
|
||||
requestId: string
|
||||
}
|
||||
export interface ContactActionInput {
|
||||
partyId: string
|
||||
kind: "email" | "phone" | "web"
|
||||
value: string
|
||||
purposes: ContactView["purposes"]
|
||||
isPrimary: boolean
|
||||
requestId: string
|
||||
}
|
||||
export const requestKey = (scope: Pick<ProjectScope, "projectId">, requestId: string): string =>
|
||||
`app-project:${scope.projectId}:${requestId}`
|
||||
export const dayOf = (at: Date): string => at.toISOString().slice(0, 10)
|
||||
export function partyActionData(
|
||||
scope: ProjectScope,
|
||||
bindingId: string,
|
||||
input: PartyActionInput,
|
||||
): Parameters<CrmService["createParty"]>[0] {
|
||||
const identifiers: { scheme: string; value: string }[] = []
|
||||
if (input.linkSelf) {
|
||||
if (input.kind !== "person") throw forbidden("only a person can be linked to a member")
|
||||
if (scope.actor.system) throw forbidden("a system actor is nobody's party")
|
||||
identifiers.push({ scheme: SUBJECT_SCHEME, value: actorRef(bindingId, scope.actor.userId) })
|
||||
}
|
||||
const legalName = input.legalName?.trim()
|
||||
return {
|
||||
kind: input.kind,
|
||||
displayName: input.displayName.trim(),
|
||||
...(legalName ? { legalName } : {}),
|
||||
country: input.country,
|
||||
identifiers,
|
||||
key: requestKey(scope, input.requestId),
|
||||
}
|
||||
}
|
||||
export function contactActionData(
|
||||
scope: ProjectScope,
|
||||
input: ContactActionInput,
|
||||
now: Date,
|
||||
): Parameters<CrmService["addContact"]>[0] {
|
||||
return {
|
||||
partyId: input.partyId,
|
||||
kind: input.kind,
|
||||
value: input.value.trim(),
|
||||
purposes: [...new Set(input.purposes)],
|
||||
isPrimary: input.isPrimary,
|
||||
effectiveFrom: dayOf(now),
|
||||
key: requestKey(scope, input.requestId),
|
||||
}
|
||||
}
|
||||
export function partyRequestBody(input: Parameters<CrmService["createParty"]>[0]) {
|
||||
return {
|
||||
data: {
|
||||
kind: input.kind,
|
||||
display_name: input.displayName,
|
||||
country: input.country,
|
||||
...(input.legalName === undefined ? {} : { legal_name: input.legalName }),
|
||||
...(input.identifiers.length === 0 ? {} : { identifiers: input.identifiers }),
|
||||
},
|
||||
idempotency_key: input.key,
|
||||
}
|
||||
}
|
||||
export function contactRequestBody(input: Parameters<CrmService["addContact"]>[0]) {
|
||||
return {
|
||||
data: {
|
||||
kind: input.kind,
|
||||
value: input.value,
|
||||
purposes: input.purposes,
|
||||
is_primary: input.isPrimary,
|
||||
effective: { from: input.effectiveFrom },
|
||||
},
|
||||
idempotency_key: input.key,
|
||||
}
|
||||
}
|
||||
export function partyEncodedRequest(orgSlug: string, input: Parameters<CrmService["createParty"]>[0]) {
|
||||
const body = partyRequestBody(input)
|
||||
return {
|
||||
service: "crm",
|
||||
operation: "crm_create_party",
|
||||
method: "POST" as const,
|
||||
path: `/v1/orgs/${encodeURIComponent(orgSlug)}/crm/parties`,
|
||||
body,
|
||||
arguments: { body },
|
||||
}
|
||||
}
|
||||
export function contactEncodedRequest(orgSlug: string, input: Parameters<CrmService["addContact"]>[0]) {
|
||||
const body = contactRequestBody(input)
|
||||
return {
|
||||
service: "crm",
|
||||
operation: "crm_add_contact_point",
|
||||
method: "POST" as const,
|
||||
path: `/v1/orgs/${encodeURIComponent(orgSlug)}/crm/parties/${encodeURIComponent(input.partyId)}/contacts`,
|
||||
body,
|
||||
arguments: { id: input.partyId, body },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// What the crm module does, as pure TypeScript over the port in ports.ts.
|
||||
//
|
||||
// Parties belong to the credential-bound app tenant and environment. Projects within
|
||||
// that partition share its address book. Linked people use the actual resolved
|
||||
// app member reference; another app tenant or environment has a different binding.
|
||||
|
||||
import type { ProjectScope } from "@conusai-kit/core"
|
||||
import { type ContactView, PAGE_LIMIT, type PartyView } from "../contract/index.js"
|
||||
import { type ContactActionInput, contactActionData, type PartyActionInput, partyActionData } from "./action-plan.js"
|
||||
import type { CrmReads, CrmService, Page } from "./ports.js"
|
||||
|
||||
export function listParties(
|
||||
reads: CrmReads,
|
||||
query: { q?: string; after?: string; limit?: number },
|
||||
): Promise<Page<PartyView>> {
|
||||
const q = query.q?.trim()
|
||||
return reads.listParties({
|
||||
...(q ? { q } : {}),
|
||||
...(query.after === undefined ? {} : { after: query.after }),
|
||||
limit: query.limit ?? PAGE_LIMIT,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getParty(
|
||||
reads: CrmReads,
|
||||
partyId: string,
|
||||
): Promise<{ party: PartyView; contacts: ContactView[] }> {
|
||||
const party = await reads.getParty(partyId)
|
||||
const contacts = await reads.listContacts(partyId, { limit: PAGE_LIMIT })
|
||||
return { party, contacts: contacts.rows }
|
||||
}
|
||||
|
||||
export async function createParty(
|
||||
service: CrmService,
|
||||
scope: ProjectScope,
|
||||
input: PartyActionInput,
|
||||
): Promise<PartyView> {
|
||||
return service.createParty(partyActionData(scope, service.bindingId, input))
|
||||
}
|
||||
export function addContact(
|
||||
service: CrmService,
|
||||
scope: ProjectScope,
|
||||
input: ContactActionInput,
|
||||
now: Date = new Date(),
|
||||
): Promise<ContactView> {
|
||||
return service.addContact(contactActionData(scope, input, now))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// The port the crm module's use cases are written against: the platform's CRM service, in this
|
||||
// module's own words. adapters/service.ts is the one place that knows `@conusai-sdk/crm` exists, and
|
||||
// it turns every refusal into the kit's app errors (`businessAppError` of
|
||||
// `@conusai-kit/business`) before core sees it.
|
||||
|
||||
import { token } from "@conusai-kit/core"
|
||||
import type { ContactView, PartyView } from "../contract/index.js"
|
||||
|
||||
export interface Page<T> {
|
||||
readonly rows: T[]
|
||||
readonly after?: string
|
||||
}
|
||||
|
||||
/** What a `read` procedure may ask the service: nothing that changes a row. */
|
||||
export interface CrmReads {
|
||||
/** The immutable app/environment binding UUID for attributed member references. */
|
||||
readonly bindingId: string
|
||||
listParties(query: { q?: string; after?: string; limit: number }): Promise<Page<PartyView>>
|
||||
getParty(partyId: string): Promise<PartyView>
|
||||
listContacts(partyId: string, query: { after?: string; limit: number }): Promise<Page<ContactView>>
|
||||
}
|
||||
|
||||
export interface CrmService extends CrmReads {
|
||||
createParty(input: {
|
||||
kind: "person" | "organization"
|
||||
displayName: string
|
||||
legalName?: string
|
||||
country: string
|
||||
identifiers: { scheme: string; value: string }[]
|
||||
key: string
|
||||
}): Promise<PartyView>
|
||||
addContact(input: {
|
||||
partyId: string
|
||||
kind: "email" | "phone" | "web"
|
||||
value: string
|
||||
purposes: ContactView["purposes"]
|
||||
isPrimary: boolean
|
||||
/** `YYYY-MM-DD`: the day the contact point starts to hold. */
|
||||
effectiveFrom: string
|
||||
key: string
|
||||
}): Promise<ContactView>
|
||||
}
|
||||
|
||||
/** The read view a `read` or `write` handler receives: the kind is a capability, not a label. */
|
||||
export const crmReadsOf = (service: CrmReads): CrmReads => ({
|
||||
bindingId: service.bindingId,
|
||||
listParties: (query) => service.listParties(query),
|
||||
getParty: (partyId) => service.getParty(partyId),
|
||||
listContacts: (partyId, query) => service.listContacts(partyId, query),
|
||||
})
|
||||
|
||||
/**
|
||||
* Bound by a composition root or a test that brings its own client (another organization, the
|
||||
* business-services fake). With nothing bound, the module calls this deployment's service.
|
||||
*/
|
||||
export const CRM_SERVICE = token<CrmService>("crm.service", { views: { read: crmReadsOf, write: crmReadsOf } })
|
||||
Reference in New Issue
Block a user