@@ -0,0 +1,99 @@
|
||||
// Owning pure/adapter consistency controls, not a Root approval or live service.
|
||||
import { createHash } from "node:crypto"
|
||||
import { canonicalActionJson } from "@conusai-kit/business"
|
||||
import { installAppActionTransport } from "@conusai-kit/business/app-actions"
|
||||
import { Container, type ProjectScope } from "@conusai-kit/core"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { crmAppActionTargets } from "../adapters/app-actions.js"
|
||||
import { crmServiceOf } from "../adapters/service.js"
|
||||
import { partyCreate } from "../contract/index.js"
|
||||
import { contactActionData, contactEncodedRequest, partyActionData, partyEncodedRequest } from "../core/action-plan.js"
|
||||
import { CRM_SERVICE, type CrmService } from "../core/ports.js"
|
||||
|
||||
const binding = "11111111-1111-4111-8111-111111111111"
|
||||
const scope: ProjectScope = {
|
||||
level: "project",
|
||||
orgId: "22222222-2222-4222-8222-222222222222",
|
||||
workspaceId: "33333333-3333-4333-8333-333333333333",
|
||||
projectId: "44444444-4444-4444-8444-444444444444",
|
||||
actor: { userId: "55555555-5555-4555-8555-555555555555" },
|
||||
}
|
||||
const requestId = "66666666-6666-4666-8666-666666666666"
|
||||
describe("actual CRM action plan is shared with its owning effect", () => {
|
||||
it("preserves derived member/project scope and exact normalizations in the request", () => {
|
||||
const value = partyActionData(scope, binding, {
|
||||
kind: "person",
|
||||
displayName: " Synthetic ",
|
||||
legalName: " ",
|
||||
country: "LT",
|
||||
linkSelf: true,
|
||||
requestId,
|
||||
})
|
||||
const plan = partyEncodedRequest("platform", value)
|
||||
expect(plan.body.data.display_name).toBe("Synthetic")
|
||||
expect(plan.body.data.legal_name).toBeUndefined()
|
||||
expect(plan.body.data.identifiers?.[0]?.value).toBe(
|
||||
`conusai://app-partitions/${binding}/members/${scope.actor.userId}`,
|
||||
)
|
||||
expect(plan.body.idempotency_key).toBe(`app-project:${scope.projectId}:${requestId}`)
|
||||
expect(plan.arguments.body).toEqual(plan.body)
|
||||
})
|
||||
it("does not mask or renew the actual contact effective day across midnight", () => {
|
||||
const input = {
|
||||
partyId: "party",
|
||||
kind: "email" as const,
|
||||
value: " synthetic@example.test ",
|
||||
purposes: ["billing" as const],
|
||||
isPrimary: true,
|
||||
requestId,
|
||||
}
|
||||
const before = contactEncodedRequest(
|
||||
"platform",
|
||||
contactActionData(scope, input, new Date("2026-11-05T23:59:59.999Z")),
|
||||
)
|
||||
const after = contactEncodedRequest("platform", contactActionData(scope, input, new Date("2026-11-06T00:00:00Z")))
|
||||
expect(before.body.data.effective.from).toBe("2026-11-05")
|
||||
expect(after.body.data.effective.from).toBe("2026-11-06")
|
||||
expect(canonicalActionJson(before) === canonicalActionJson(after)).toBe(false)
|
||||
expect(before.arguments.id).toBe("party")
|
||||
})
|
||||
it("refuses custom effect ports before inspection or guarded dispatch can call them", () => {
|
||||
const container = new Container()
|
||||
const custom = { createParty: vi.fn(), addContact: vi.fn() } as unknown as CrmService
|
||||
container.value(CRM_SERVICE, custom)
|
||||
const target = crmAppActionTargets.find((candidate) => candidate.procedure === partyCreate)
|
||||
if (!target) throw new Error("missing declared CRM action target")
|
||||
expect(() => target.encode({}, { scope, container, now: new Date() })).toThrow()
|
||||
const request = partyEncodedRequest(
|
||||
"platform",
|
||||
partyActionData(scope, binding, { kind: "organization", displayName: "Synthetic", country: "LT", requestId }),
|
||||
)
|
||||
// Synthetic lower transport restriction ONLY, never a redeemed Root lease.
|
||||
installAppActionTransport(
|
||||
container,
|
||||
{
|
||||
bindingId: binding,
|
||||
environmentUuid: "77777777-7777-4777-8777-777777777777",
|
||||
appTenantId: scope.orgId,
|
||||
actorId: scope.actor.userId,
|
||||
orgSlug: "platform",
|
||||
platformProjectId: 9,
|
||||
},
|
||||
{
|
||||
action_id: requestId,
|
||||
dispatch_lease_id: requestId,
|
||||
source_id: requestId,
|
||||
source_epoch: "1",
|
||||
observation_sha256: "a".repeat(64),
|
||||
encoded_request_sha256: createHash("sha256").update(canonicalActionJson(request)).digest("hex"),
|
||||
expires_at_micros: String(BigInt(Date.now()) * 1000n + 20_000_000n),
|
||||
execution_context: "synthetic_non_authoritative_context_".repeat(2),
|
||||
},
|
||||
request,
|
||||
async () => {},
|
||||
)
|
||||
expect(() => crmServiceOf(container, "effect")).toThrow()
|
||||
expect(custom.createParty).not.toHaveBeenCalled()
|
||||
expect(custom.addContact).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
// Simulated adapter dispatch; these fixtures do not prove gateway or partition isolation.
|
||||
// The module on its own, wired the way apps/web wires a request -- tenancy on the in-memory data
|
||||
// source, the crm module, and the CRM port bound to the real generated client over the
|
||||
// business-services fake of @conusai-kit/testing -- so its procedures are tested whether or not this
|
||||
// app turned the module on.
|
||||
|
||||
import { businessClient } from "@conusai-kit/business"
|
||||
import { type Actor, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import { type FakeBusinessServices, fakeBusinessServices } from "@conusai-kit/testing"
|
||||
import { createClient } from "@conusai-sdk/crm/client"
|
||||
import { simulatedBusinessAdmission } from "../../../test/helpers/business-admission.js"
|
||||
import { sdkCrmService } from "../adapters/service.js"
|
||||
import { CRM_SERVICE } from "../core/ports.js"
|
||||
import { crmModule } from "../module.js"
|
||||
|
||||
/** The platform project of the fake deployment. */
|
||||
export const BINDING_ID = "01900000-0000-7000-8000-000000000001"
|
||||
|
||||
export interface Tenant {
|
||||
readonly owner: Actor
|
||||
readonly call: TransportCaller
|
||||
readonly orgSlug: string
|
||||
readonly orgId: string
|
||||
readonly workspaceSlug: string
|
||||
readonly workspaceId: string
|
||||
readonly projectSlug: string
|
||||
readonly projectId: string
|
||||
readonly slugs: { org: string; workspace: string; project: string }
|
||||
}
|
||||
|
||||
export function crmApp(options: Parameters<typeof fakeBusinessServices>[0] = {}) {
|
||||
const fake: FakeBusinessServices = fakeBusinessServices(options)
|
||||
const client = businessClient({ apiUrl: fake.apiUrl, token: fake.token, orgSlug: fake.orgSlug }, createClient)
|
||||
client.setConfig({ fetch: fake.fetch as typeof fetch })
|
||||
const registry = buildRegistry([tenancyModule, crmModule])
|
||||
const entities = entitiesOf(registry, "postgres")
|
||||
const data = createMemoryDataSource({ entities })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(CRM_SERVICE, sdkCrmService({ client, orgSlug: fake.orgSlug, bindingId: BINDING_ID }))
|
||||
|
||||
const caller = (actor: Actor): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy, serviceReadiness: simulatedBusinessAdmission }, { actor })
|
||||
}
|
||||
|
||||
const signUp = async (email: string): Promise<Actor> => {
|
||||
const user = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: email,
|
||||
email,
|
||||
displayName: email.split("@")[0] ?? email,
|
||||
locale: "en",
|
||||
})
|
||||
return { userId: user.id }
|
||||
}
|
||||
|
||||
/** An organization with one workspace and one project, owned by a new user. */
|
||||
const tenant = async (slug: string): Promise<Tenant> => {
|
||||
const owner = await signUp(`owner@${slug}.test`)
|
||||
const call = caller(owner)
|
||||
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string; slug: string }
|
||||
const workspace = (await call.callByName("tenancy.workspace.create", {
|
||||
org: slug,
|
||||
slug: `ws-${slug}`,
|
||||
name: `Workspace ${slug}`,
|
||||
visibility: "org",
|
||||
})) as { workspaceId: string; slug: string }
|
||||
const project = (await call.callByName("tenancy.project.create", {
|
||||
org: slug,
|
||||
workspace: workspace.slug,
|
||||
slug: `p-${slug}`,
|
||||
name: `Project ${slug}`,
|
||||
moduleKey: "crm",
|
||||
})) as { projectId: string; slug: string }
|
||||
return {
|
||||
owner,
|
||||
call,
|
||||
orgSlug: org.slug,
|
||||
orgId: org.orgId,
|
||||
workspaceSlug: workspace.slug,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectSlug: project.slug,
|
||||
projectId: project.projectId,
|
||||
slugs: { org: org.slug, workspace: workspace.slug, project: project.slug },
|
||||
}
|
||||
}
|
||||
|
||||
/** A second user who joins `t`'s organization with the org role `role`. */
|
||||
const member = async (t: Tenant, email: string, role: "viewer" | "member"): Promise<TransportCaller> => {
|
||||
const invite = (await t.call.callByName("tenancy.member.invite", { org: t.orgSlug, email, role })) as {
|
||||
token: string
|
||||
}
|
||||
const actor = await signUp(email)
|
||||
const call = caller(actor)
|
||||
await call.callByName("tenancy.invitation.accept", { org: t.orgSlug, token: invite.token })
|
||||
return call
|
||||
}
|
||||
|
||||
return { fake, registry, data, caller, signUp, tenant, member }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { dayOf, requestKey } from "../core/action-plan.js"
|
||||
// The crm use cases over a recording port: what each asks the service, with no client and no network.
|
||||
|
||||
import { actorRef } from "@conusai-kit/business"
|
||||
import type { ProjectScope } from "@conusai-kit/core"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { PartyView } from "../contract/index.js"
|
||||
import { addContact, createParty, listParties } from "../core/crm.js"
|
||||
import { type CrmService, crmReadsOf } from "../core/ports.js"
|
||||
|
||||
const scope: ProjectScope = {
|
||||
level: "project",
|
||||
actor: { userId: "01900000-0000-7000-8000-000000000002" },
|
||||
orgId: "o1",
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
}
|
||||
|
||||
const party: PartyView = {
|
||||
partyId: "x",
|
||||
kind: "person",
|
||||
displayName: "Ada",
|
||||
legalName: null,
|
||||
country: "LT",
|
||||
status: "active",
|
||||
linked: false,
|
||||
}
|
||||
|
||||
function recording() {
|
||||
const asked: Array<[string, unknown]> = []
|
||||
const service: CrmService = {
|
||||
bindingId: "01900000-0000-7000-8000-000000000001",
|
||||
listParties: async (query) => {
|
||||
asked.push(["listParties", query])
|
||||
return { rows: [] }
|
||||
},
|
||||
getParty: async () => party,
|
||||
listContacts: async () => ({ rows: [] }),
|
||||
createParty: async (input) => {
|
||||
asked.push(["createParty", input])
|
||||
return party
|
||||
},
|
||||
addContact: async (input) => {
|
||||
asked.push(["addContact", input])
|
||||
return {
|
||||
contactId: "c",
|
||||
kind: input.kind,
|
||||
value: input.value,
|
||||
purposes: input.purposes,
|
||||
isPrimary: input.isPrimary,
|
||||
erased: false,
|
||||
}
|
||||
},
|
||||
}
|
||||
return { service, asked }
|
||||
}
|
||||
|
||||
describe("the crm use cases", () => {
|
||||
it("list parties with a trimmed search and the module's page size", async () => {
|
||||
const { service, asked } = recording()
|
||||
await listParties(service, { q: " acme " })
|
||||
expect(asked).toEqual([["listParties", { q: "acme", limit: 50 }]])
|
||||
})
|
||||
|
||||
it("create a party with the project's request key and no identifier unless asked", async () => {
|
||||
const { service, asked } = recording()
|
||||
await createParty(service, scope, {
|
||||
kind: "organization",
|
||||
displayName: " Acme ",
|
||||
legalName: " ",
|
||||
country: "LT",
|
||||
requestId: "r1",
|
||||
})
|
||||
expect(asked).toEqual([
|
||||
[
|
||||
"createParty",
|
||||
{ kind: "organization", displayName: "Acme", country: "LT", identifiers: [], key: requestKey(scope, "r1") },
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it("link a person to the member with the member's subject reference, and only a person, and only a member", async () => {
|
||||
const { service, asked } = recording()
|
||||
await createParty(service, scope, {
|
||||
kind: "person",
|
||||
displayName: "Ada",
|
||||
country: "LT",
|
||||
linkSelf: true,
|
||||
requestId: "r2",
|
||||
})
|
||||
expect(asked[0]?.[1]).toMatchObject({
|
||||
identifiers: [
|
||||
{
|
||||
scheme: "conusai.subject",
|
||||
value: actorRef("01900000-0000-7000-8000-000000000001", "01900000-0000-7000-8000-000000000002"),
|
||||
},
|
||||
],
|
||||
})
|
||||
await expect(
|
||||
createParty(service, scope, {
|
||||
kind: "organization",
|
||||
displayName: "Acme",
|
||||
country: "LT",
|
||||
linkSelf: true,
|
||||
requestId: "r3",
|
||||
}),
|
||||
).rejects.toMatchObject({ problem: { status: 403 } })
|
||||
const system: ProjectScope = { ...scope, actor: { userId: "system:sweep", system: true } }
|
||||
await expect(
|
||||
createParty(service, system, {
|
||||
kind: "person",
|
||||
displayName: "Ada",
|
||||
country: "LT",
|
||||
linkSelf: true,
|
||||
requestId: "r4",
|
||||
}),
|
||||
).rejects.toMatchObject({ problem: { status: 403 } })
|
||||
expect(asked).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("add a contact point from today, each purpose once", async () => {
|
||||
const { service, asked } = recording()
|
||||
const now = new Date("2026-11-02T23:30:00.000Z")
|
||||
await addContact(
|
||||
service,
|
||||
scope,
|
||||
{
|
||||
partyId: "x",
|
||||
kind: "phone",
|
||||
value: " +370 600 00000 ",
|
||||
purposes: ["billing", "billing"],
|
||||
isPrimary: false,
|
||||
requestId: "r5",
|
||||
},
|
||||
now,
|
||||
)
|
||||
expect(asked).toEqual([
|
||||
[
|
||||
"addContact",
|
||||
{
|
||||
partyId: "x",
|
||||
kind: "phone",
|
||||
value: "+370 600 00000",
|
||||
purposes: ["billing"],
|
||||
isPrimary: false,
|
||||
effectiveFrom: "2026-11-02",
|
||||
key: requestKey(scope, "r5"),
|
||||
},
|
||||
],
|
||||
])
|
||||
expect(dayOf(now)).toBe("2026-11-02")
|
||||
})
|
||||
|
||||
it("hand a read handler a view without a single write", () => {
|
||||
expect(Object.keys(crmReadsOf(recording().service)).sort()).toEqual([
|
||||
"bindingId",
|
||||
"getParty",
|
||||
"listContacts",
|
||||
"listParties",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
// Every string a page of the module shows is a message in English and in Lithuanian, the Lithuanian
|
||||
// plural forms are real ones, and no component of the module carries a string of its own.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n, lintUiStrings } from "@conusai-kit/i18n"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { crmModule } from "../module.js"
|
||||
import { contactFormLabels, contactsLabels, partyFormLabels, partyListLabels } from "../ui/labels.js"
|
||||
|
||||
// The messages as an app that turned the module on has them: from the manifest, flattened by the kit.
|
||||
const catalogues = buildRegistry([tenancyModule, crmModule]).catalogues
|
||||
const en = catalogues.en?.crm ?? {}
|
||||
const lt = catalogues.lt?.crm ?? {}
|
||||
|
||||
function keysUsed(): string[] {
|
||||
const asked: string[] = []
|
||||
const t = (key: string) => {
|
||||
asked.push(key)
|
||||
return key
|
||||
}
|
||||
partyFormLabels(t, {})
|
||||
partyListLabels(t)
|
||||
contactsLabels(t, 2)
|
||||
contactFormLabels(t, {})
|
||||
return asked
|
||||
}
|
||||
|
||||
describe("the crm catalogues", () => {
|
||||
it("hold the same keys in English and in Lithuanian, none empty", () => {
|
||||
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
|
||||
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
|
||||
})
|
||||
|
||||
it("hold every message the labels ask for, and none that nothing asks for", () => {
|
||||
const asked = keysUsed()
|
||||
for (const key of asked) {
|
||||
expect(en[key as keyof typeof en], `en is missing crm.${key}`).toBeTruthy()
|
||||
expect(lt[key as keyof typeof lt], `lt is missing crm.${key}`).toBeTruthy()
|
||||
}
|
||||
// The page and the navigation ask for these directly.
|
||||
const direct = new Set(["title", "intro", "party.back", "party.linked"])
|
||||
expect(Object.keys(en).filter((key) => !asked.includes(key) && !direct.has(key))).toEqual([])
|
||||
})
|
||||
|
||||
it("answer the navigation label the manifest names", () => {
|
||||
expect(crmModule.ui?.nav?.map((item) => item.label)).toEqual(["crm.title"])
|
||||
})
|
||||
|
||||
it("count contact points with the Lithuanian one, few and other forms", () => {
|
||||
const count = (locale: "en" | "lt", n: number) =>
|
||||
createI18n(locale, { en: { crm: en }, lt: { crm: lt } }).message("crm.contacts.count", { count: n })
|
||||
expect(count("lt", 1)).toBe("1 kontaktas")
|
||||
expect(count("lt", 2)).toBe("2 kontaktai")
|
||||
expect(count("lt", 10)).toBe("10 kontaktų")
|
||||
expect(count("lt", 21)).toBe("21 kontaktas")
|
||||
expect(count("en", 2)).toBe("2 contact points")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the components", () => {
|
||||
const dir = new URL("../ui/", import.meta.url).pathname
|
||||
const sources = readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".tsx"))
|
||||
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }))
|
||||
|
||||
it("carry no hard-coded user-facing string and no colour literal", () => {
|
||||
expect(sources.length).toBeGreaterThanOrEqual(5)
|
||||
for (const { name, text } of sources) {
|
||||
expect(lintUiStrings(text, name), name).toEqual([])
|
||||
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this
|
||||
// folder: one fixture per tenant-scoped procedure, each called as a member of one organization with
|
||||
// another's slugs, which the resolver must refuse before the CRM service is reached.
|
||||
//
|
||||
// The module keeps no row of its own, so it seeds nothing: what a fixture names is the other tenant's
|
||||
// project, and an id is any well-formed one (the service is never asked). The suite reads this file by
|
||||
// its place whether or not the module is on, and checks only the procedures the app registers, so
|
||||
// while the module is off (the template's default) it brings no fixture; `procedures.test.ts` runs the
|
||||
// same fixtures against the module on its own, on or off.
|
||||
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
|
||||
import { businessModulesOn } from "../../index.js"
|
||||
|
||||
const SOMEWHERE = "00000000-0000-4000-8000-000000000001"
|
||||
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
|
||||
|
||||
export const crmIsolationFixtures: Record<string, IsolationFixture> = {
|
||||
"crm.party.list": { input: (t) => inProjectOf(t) },
|
||||
"crm.party.get": { input: (t) => ({ ...inProjectOf(t), partyId: SOMEWHERE }) },
|
||||
"crm.party.create": {
|
||||
input: (t) => ({
|
||||
...inProjectOf(t),
|
||||
kind: "organization",
|
||||
displayName: "Intruder",
|
||||
country: "LT",
|
||||
requestId: SOMEWHERE,
|
||||
}),
|
||||
},
|
||||
"crm.contact.add": {
|
||||
input: (t) => ({
|
||||
...inProjectOf(t),
|
||||
partyId: SOMEWHERE,
|
||||
kind: "email",
|
||||
value: "intruder@example.test",
|
||||
purposes: [],
|
||||
isPrimary: false,
|
||||
requestId: SOMEWHERE,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
/** The shape the suite reads (`modules/notes/__tests__/isolation.ts` names it), so no other module is imported. */
|
||||
export const isolation: {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
} = {
|
||||
seed: async () => ({}),
|
||||
fixtures: businessModulesOn.includes("crm") ? crmIsolationFixtures : {},
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// The crm procedures end to end: the caller, the tenancy resolver, the module and the real generated
|
||||
// client against the business-services fake -- what a member sees, what the service gets, and each
|
||||
// refusal as the kit's app error (`businessAppError`) a page or a form shows.
|
||||
|
||||
import { actorRef } from "@conusai-kit/business"
|
||||
import type { AppError } from "@conusai-kit/core"
|
||||
import { entitiesOf } from "@conusai-kit/data"
|
||||
import { TENANCY_ALSO_CHECK, tenancyIsolationExempt, tenancyIsolationFixtures } from "@conusai-kit/tenancy/testing"
|
||||
import { type IsolationTenant, runIsolation, snapshotOf } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { SUBJECT_SCHEME } from "../contract/index.js"
|
||||
import { BINDING_ID, crmApp } from "./app.js"
|
||||
import { crmIsolationFixtures } from "./isolation.js"
|
||||
|
||||
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
return error as AppError
|
||||
}
|
||||
throw new Error("the call was not refused")
|
||||
}
|
||||
|
||||
const acme = { kind: "organization", displayName: "Acme Supplies", country: "LT" }
|
||||
|
||||
describe("the crm procedures", () => {
|
||||
it("create a party once per request, list it, and read it with its contact points", async () => {
|
||||
const app = crmApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const requestId = crypto.randomUUID()
|
||||
const created = (await t.call.callByName("crm.party.create", { ...t.slugs, ...acme, requestId })) as {
|
||||
partyId: string
|
||||
}
|
||||
expect(created).toMatchObject({
|
||||
displayName: "Acme Supplies",
|
||||
kind: "organization",
|
||||
country: "LT",
|
||||
status: "active",
|
||||
linked: false,
|
||||
})
|
||||
expect(await t.call.callByName("crm.party.create", { ...t.slugs, ...acme, requestId })).toEqual(created)
|
||||
expect(app.fake.crm.parties.size).toBe(1)
|
||||
const listed = (await t.call.callByName("crm.party.list", t.slugs)) as { rows: unknown[] }
|
||||
expect(listed.rows).toEqual([created])
|
||||
|
||||
const contact = await t.call.callByName("crm.contact.add", {
|
||||
...t.slugs,
|
||||
partyId: created.partyId,
|
||||
kind: "email",
|
||||
value: " orders@acme.example ",
|
||||
purposes: ["invoice_email", "invoice_email"],
|
||||
isPrimary: true,
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
expect(contact).toMatchObject({
|
||||
kind: "email",
|
||||
value: "orders@acme.example",
|
||||
purposes: ["invoice_email"],
|
||||
isPrimary: true,
|
||||
erased: false,
|
||||
})
|
||||
const read = (await t.call.callByName("crm.party.get", { ...t.slugs, partyId: created.partyId })) as {
|
||||
party: unknown
|
||||
contacts: unknown[]
|
||||
}
|
||||
expect(read).toEqual({ party: created, contacts: [contact] })
|
||||
const [stored] = [...app.fake.crm.contacts.values()]
|
||||
if (!stored?.fields) throw new Error("the fake holds no contact point")
|
||||
expect((stored.fields as { effective: { from: string } }).effective.from).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
})
|
||||
|
||||
it("link a person to the member who creates it with the member's subject reference", async () => {
|
||||
const app = crmApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const created = (await t.call.callByName("crm.party.create", {
|
||||
...t.slugs,
|
||||
kind: "person",
|
||||
displayName: "Ada",
|
||||
country: "LT",
|
||||
linkSelf: true,
|
||||
requestId: crypto.randomUUID(),
|
||||
})) as { partyId: string; linked: boolean }
|
||||
expect(created.linked).toBe(true)
|
||||
expect(app.fake.crm.parties.get(created.partyId)?.identifiers).toEqual([
|
||||
{ scheme: SUBJECT_SCHEME, value: actorRef(BINDING_ID, t.owner.userId) },
|
||||
])
|
||||
const organization = await refusal(
|
||||
t.call.callByName("crm.party.create", { ...t.slugs, ...acme, linkSelf: true, requestId: crypto.randomUUID() }),
|
||||
)
|
||||
expect(organization.problem.status).toBe(403)
|
||||
})
|
||||
|
||||
it("show the service's refusals in the service's words", async () => {
|
||||
const app = crmApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const missing = await refusal(t.call.callByName("crm.party.get", { ...t.slugs, partyId: "nobody" }))
|
||||
expect(missing.problem).toMatchObject({ status: 404, code: "business_not_found" })
|
||||
const { partyId } = (await t.call.callByName("crm.party.create", {
|
||||
...t.slugs,
|
||||
...acme,
|
||||
requestId: crypto.randomUUID(),
|
||||
})) as {
|
||||
partyId: string
|
||||
}
|
||||
const bad = await refusal(
|
||||
t.call.callByName("crm.contact.add", {
|
||||
...t.slugs,
|
||||
partyId,
|
||||
kind: "email",
|
||||
value: "not an address",
|
||||
purposes: [],
|
||||
isPrimary: false,
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(bad.problem).toMatchObject({ status: 400, code: "business_invalid" })
|
||||
})
|
||||
|
||||
it("answer a disabled service, a missing scope and a silent service with the platform's own codes", async () => {
|
||||
const app = crmApp({ crm: false })
|
||||
const t = await app.tenant("alpha")
|
||||
const disabled = await refusal(t.call.callByName("crm.party.list", t.slugs))
|
||||
expect(disabled.problem).toMatchObject({ status: 404, code: "service_not_enabled" })
|
||||
app.fake.enable("crm")
|
||||
app.fake.revoke("crm.parties:write")
|
||||
const scoped = await refusal(
|
||||
t.call.callByName("crm.party.create", { ...t.slugs, ...acme, requestId: crypto.randomUUID() }),
|
||||
)
|
||||
expect(scoped.problem).toMatchObject({ status: 403, code: "service_permission_missing" })
|
||||
expect(scoped.problem.detail).not.toContain("crm.parties:write")
|
||||
app.fake.refuseNext(503)
|
||||
const silent = await refusal(t.call.callByName("crm.party.list", t.slugs))
|
||||
expect(silent.problem).toMatchObject({ status: 503, code: "business_unavailable" })
|
||||
})
|
||||
|
||||
it("let a viewer read and refuse their writes before the service is called", async () => {
|
||||
const app = crmApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const viewer = await app.member(t, "viewer@alpha.test", "viewer")
|
||||
expect(await viewer.callByName("crm.party.list", t.slugs)).toEqual({ rows: [] })
|
||||
const before = app.fake.calls.length
|
||||
// A member without the permission is answered as a non-member is (kit-core's caller, spec §3.3).
|
||||
const refused = await refusal(
|
||||
viewer.callByName("crm.party.create", { ...t.slugs, ...acme, requestId: crypto.randomUUID() }),
|
||||
)
|
||||
expect(refused.problem.status).toBe(404)
|
||||
expect(app.fake.calls.length).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the crm module's tenant isolation", () => {
|
||||
it("refuses every procedure called with another organization's slugs, and never reaches the service", async () => {
|
||||
const app = crmApp()
|
||||
const tenant = async (slug: string): Promise<IsolationTenant> => {
|
||||
const t = await app.tenant(slug)
|
||||
return {
|
||||
orgSlug: t.orgSlug,
|
||||
workspaceSlug: t.workspaceSlug,
|
||||
projectSlug: t.projectSlug,
|
||||
orgId: t.orgId,
|
||||
workspaceId: t.workspaceId,
|
||||
projectId: t.projectId,
|
||||
userId: t.owner.userId,
|
||||
email: `owner@${slug}.test`,
|
||||
extra: {},
|
||||
}
|
||||
}
|
||||
const a = await tenant("alpha")
|
||||
const b = await tenant("bravo")
|
||||
const result = await runIsolation({
|
||||
registry: app.registry,
|
||||
seed: async () => ({ a, b }),
|
||||
caller: (actor) => app.caller(actor),
|
||||
snapshot: () => snapshotOf(app.data, entitiesOf(app.registry, "postgres"), { outbox: app.data.outbox }),
|
||||
fixtures: { ...tenancyIsolationFixtures(), ...crmIsolationFixtures },
|
||||
alsoCheck: [...TENANCY_ALSO_CHECK],
|
||||
exempt: tenancyIsolationExempt,
|
||||
})
|
||||
expect(result.failures).toEqual([])
|
||||
for (const name of Object.keys(crmIsolationFixtures)) expect(result.passed, name).toContain(name)
|
||||
expect(app.fake.calls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
// The pages and components, rendered the way the shell renders them, from the loader's data: the platform's
|
||||
// refusals are the page's own states, a control is offered to whoever may use it, every table has a
|
||||
// caption and scoped headers.
|
||||
|
||||
import { AppError, buildRegistry, Container, type TransportCaller } from "@conusai-kit/core"
|
||||
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { createDataClient, DataProvider, type PermissionKey, PermissionsProvider } from "@conusai-kit/ui/data"
|
||||
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactNode } from "react"
|
||||
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { modulePages } from "../../pages.js"
|
||||
import { crmModule } from "../module.js"
|
||||
import { crmPage } from "../ui/pages.js"
|
||||
|
||||
const catalogues = buildRegistry([tenancyModule, crmModule]).catalogues
|
||||
const i18n = createI18n("en", { en: { crm: catalogues.en?.crm ?? {}, app: { failed: "That did not work: {detail}" } } })
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
const EVERYTHING: readonly PermissionKey[] = ["crm.party.read", "crm.party.write"]
|
||||
|
||||
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
||||
const renderToStaticMarkup = (node: React.ReactNode) =>
|
||||
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
||||
|
||||
const inProvider = (node: ReactNode, permissions: readonly PermissionKey[] = EVERYTHING) =>
|
||||
renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
<PermissionsProvider scope={slugs} seed={{ scope: slugs, permissions, revision: "1" }}>
|
||||
{node}
|
||||
</PermissionsProvider>
|
||||
</DataProvider>,
|
||||
)
|
||||
|
||||
const callerOf = (answers: Record<string, unknown>): TransportCaller =>
|
||||
({
|
||||
call: async (contract: { name: string }) => {
|
||||
const answer = answers[contract.name]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
}) as unknown as TransportCaller
|
||||
|
||||
const load = (segments: string[], answers: Record<string, unknown>) =>
|
||||
crmPage.load({ slugs, segments, search: {}, caller: callerOf(answers), i18n, container: new Container() })
|
||||
|
||||
/** The page as the shell renders it: the loader's data through JSON, as the browser receives it, then the view. */
|
||||
const render = async (segments: string[], answers: Record<string, unknown>, permissions?: readonly PermissionKey[]) => {
|
||||
const data = await load(segments, answers)
|
||||
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
|
||||
const { View } = crmPage
|
||||
return inProvider(
|
||||
<View slugs={slugs} segments={segments} locale="en" data={JSON.parse(JSON.stringify(data))} />,
|
||||
permissions,
|
||||
)
|
||||
}
|
||||
|
||||
const party = {
|
||||
partyId: "p-1",
|
||||
kind: "organization",
|
||||
displayName: "Acme Supplies",
|
||||
legalName: "Acme Supplies UAB",
|
||||
country: "LT",
|
||||
status: "active",
|
||||
linked: false,
|
||||
}
|
||||
|
||||
describe("the crm page", () => {
|
||||
it("lists the parties, with the form for whoever may add one", async () => {
|
||||
const answers = { "crm.party.list": { rows: [party] } }
|
||||
const html = await render([], answers)
|
||||
expect(html).toContain(">Customers</h1>")
|
||||
// The row's name is its link: the kit's row link (`kit-row-link`).
|
||||
const link = html.match(/<a\b[^>]*>Acme Supplies<\/a>/)?.[0] ?? ""
|
||||
expect(link).toContain('data-slot="row-link"')
|
||||
expect(link.match(/class="([^"]*)"/)?.[1]?.split(/\s+/)).toContain("kit-row-link")
|
||||
expect(link).toContain('href="/acme/ws/p/crm/p-1"')
|
||||
expect(link).toContain('aria-label="Open Acme Supplies"')
|
||||
expect(html).toContain('aria-label="Parties"')
|
||||
expect(html).toContain("New party")
|
||||
expect(await render([], answers, ["crm.party.read"])).not.toContain("New party")
|
||||
})
|
||||
|
||||
it("shows a party's contact points and the form that adds one", async () => {
|
||||
const html = await render(["p-1"], {
|
||||
"crm.party.get": {
|
||||
party: { ...party, linked: true },
|
||||
contacts: [
|
||||
{
|
||||
contactId: "c1",
|
||||
kind: "email",
|
||||
value: "orders@acme.example",
|
||||
purposes: ["invoice_email"],
|
||||
isPrimary: true,
|
||||
erased: false,
|
||||
},
|
||||
{ contactId: "c2", kind: "phone", value: "", purposes: [], isPrimary: false, erased: true },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(html).toContain(">Acme Supplies</h1>")
|
||||
expect(html).toContain("Linked to a member's account")
|
||||
expect(html).toContain("2 contact points")
|
||||
expect(html).toContain('aria-label="Contact points"')
|
||||
for (const text of ["E-mail", "orders@acme.example", "Invoices by e-mail", "Yes"]) expect(html).toContain(text)
|
||||
expect(html).toContain("Erased")
|
||||
expect(html).toContain("Add a contact point")
|
||||
})
|
||||
|
||||
it("shows the kit's not-enabled state when the organization has not enabled the service", async () => {
|
||||
const refused = new AppError({
|
||||
type: "/problems/service_not_enabled",
|
||||
title: "Service prerequisite unavailable",
|
||||
status: 404,
|
||||
code: "service_not_enabled",
|
||||
detail: "Service admission refused: not_enabled.",
|
||||
})
|
||||
const html = await render([], { "crm.party.list": refused })
|
||||
expect(html).toContain('data-state="service_not_enabled"')
|
||||
expect(html).toContain("This service is not enabled for your organization.")
|
||||
expect(html).not.toContain('role="grid"')
|
||||
})
|
||||
|
||||
it("tells a Lithuanian reader in the kit's Lithuanian sentences", async () => {
|
||||
const lt = createI18n("lt", { lt: { crm: catalogues.lt?.crm ?? {} } })
|
||||
const silent = new AppError({
|
||||
type: "/problems/business_unavailable",
|
||||
title: "Refused",
|
||||
status: 503,
|
||||
code: "business_unavailable",
|
||||
})
|
||||
const data = await crmPage.load({
|
||||
slugs,
|
||||
segments: [],
|
||||
search: {},
|
||||
caller: callerOf({ "crm.party.list": silent }),
|
||||
i18n: lt,
|
||||
container: new Container(),
|
||||
})
|
||||
expect(data).toMatchObject({
|
||||
kind: "state",
|
||||
notice: { code: "business_unavailable", text: "Paslauga neatsakė. Bandykite vėliau." },
|
||||
})
|
||||
const list = await crmPage.load({
|
||||
slugs,
|
||||
segments: [],
|
||||
search: {},
|
||||
caller: callerOf({ "crm.party.list": { rows: [] } }),
|
||||
i18n: lt,
|
||||
container: new Container(),
|
||||
})
|
||||
expect(list.kind === "list" && list.form.refusals.business_stale_revision).toBe(
|
||||
"Kažkas tai pakeitė, kol jūs dirbote. Atnaujinkite ir bandykite dar kartą.",
|
||||
)
|
||||
})
|
||||
|
||||
it("leaves a party that is not there, and a path it does not have, to the shell", async () => {
|
||||
const missing = new AppError({
|
||||
type: "/problems/business_not_found",
|
||||
title: "Not found",
|
||||
status: 404,
|
||||
code: "business_not_found",
|
||||
})
|
||||
await expect(render(["p-2"], { "crm.party.get": missing })).rejects.toMatchObject({
|
||||
problem: { code: "business_not_found" },
|
||||
})
|
||||
await expect(render(["p-1", "x"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
await expect(render(["%2Fetc"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
await expect(render(["%E0%A4%A"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
})
|
||||
|
||||
it("is listed for the shell", async () => {
|
||||
await expect(modulePages.crm?.()).resolves.toBe(crmPage)
|
||||
})
|
||||
})
|
||||
|
||||
// Explicit synthetic admission: module-local fixtures exercise the selected module.
|
||||
vi.mock("../../inclusion.json", () => ({ default: { schemaVersion: 1, selected: ["crm"], everAdopted: ["crm"] } }))
|
||||
@@ -0,0 +1,60 @@
|
||||
// Fixed server declarations. The same pure plan is used by the real core and
|
||||
// generated SDK body encoder; declarations alone grant no approval/permission.
|
||||
import { requestBusinessBinding } from "@conusai-kit/business"
|
||||
import { type Container, forbidden, type TenantScope } from "@conusai-kit/core"
|
||||
import { contactAdd, partyCreate } from "../contract/index.js"
|
||||
import { contactActionData, contactEncodedRequest, partyActionData, partyEncodedRequest } from "../core/action-plan.js"
|
||||
|
||||
import { CRM_SERVICE } from "../core/ports.js"
|
||||
|
||||
interface Context {
|
||||
readonly scope: TenantScope
|
||||
readonly container: Container
|
||||
readonly now: Date
|
||||
}
|
||||
function current(context: Context) {
|
||||
if (context.container.has(CRM_SERVICE)) throw forbidden("app action requires prepared business transport")
|
||||
if (context.scope.level !== "project" || context.scope.actor.system) throw forbidden("app action refused")
|
||||
const binding = requestBusinessBinding(context.container)
|
||||
if (binding.actorId !== context.scope.actor.userId || binding.appTenantId !== context.scope.orgId)
|
||||
throw forbidden("app action refused")
|
||||
return { binding, scope: context.scope }
|
||||
}
|
||||
function bindingFields(binding: ReturnType<typeof requestBusinessBinding>) {
|
||||
return {
|
||||
bindingId: binding.bindingId,
|
||||
environmentUuid: binding.environmentUuid,
|
||||
appTenantId: binding.appTenantId,
|
||||
actorId: binding.actorId,
|
||||
orgSlug: binding.orgSlug,
|
||||
platformProjectId: binding.platformProjectId,
|
||||
}
|
||||
}
|
||||
export const crmAppActionTargets = Object.freeze([
|
||||
{
|
||||
procedure: partyCreate,
|
||||
encode(input: unknown, context: Context) {
|
||||
const { binding, scope } = current(context)
|
||||
return {
|
||||
binding: bindingFields(binding),
|
||||
request: partyEncodedRequest(
|
||||
binding.orgSlug,
|
||||
partyActionData(scope, binding.bindingId, partyCreate.input.parse(input)),
|
||||
),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
procedure: contactAdd,
|
||||
encode(input: unknown, context: Context) {
|
||||
const { binding, scope } = current(context)
|
||||
return {
|
||||
binding: bindingFields(binding),
|
||||
request: contactEncodedRequest(
|
||||
binding.orgSlug,
|
||||
contactActionData(scope, contactAdd.input.parse(input), context.now),
|
||||
),
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,43 @@
|
||||
// The handlers of the module's procedures: each takes the service its kind may use and calls one use
|
||||
// case. No rule lives here.
|
||||
|
||||
import { implement } from "@conusai-kit/core"
|
||||
import { contactAdd, partyCreate, partyGet, partyList } from "../contract/index.js"
|
||||
import { addContact, createParty, getParty, listParties } from "../core/crm.js"
|
||||
import { crmServiceOf } from "./service.js"
|
||||
|
||||
export const partyListImpl = implement(partyList, ({ input, ctx }) =>
|
||||
listParties(crmServiceOf(ctx.container, ctx.kind), {
|
||||
...(input.q === undefined ? {} : { q: input.q }),
|
||||
...(input.after === undefined ? {} : { after: input.after }),
|
||||
...(input.limit === undefined ? {} : { limit: input.limit }),
|
||||
}),
|
||||
)
|
||||
|
||||
export const partyGetImpl = implement(partyGet, ({ input, ctx }) =>
|
||||
getParty(crmServiceOf(ctx.container, ctx.kind), input.partyId),
|
||||
)
|
||||
|
||||
export const partyCreateImpl = implement(partyCreate, ({ input, ctx }) =>
|
||||
createParty(crmServiceOf(ctx.container, "effect"), ctx.scope, {
|
||||
kind: input.kind,
|
||||
displayName: input.displayName,
|
||||
...(input.legalName === undefined ? {} : { legalName: input.legalName }),
|
||||
country: input.country,
|
||||
...(input.linkSelf === undefined ? {} : { linkSelf: input.linkSelf }),
|
||||
requestId: input.requestId,
|
||||
}),
|
||||
)
|
||||
|
||||
export const contactAddImpl = implement(contactAdd, ({ input, ctx }) =>
|
||||
addContact(crmServiceOf(ctx.container, "effect"), ctx.scope, {
|
||||
partyId: input.partyId,
|
||||
kind: input.kind,
|
||||
value: input.value,
|
||||
purposes: input.purposes,
|
||||
isPrimary: input.isPrimary,
|
||||
requestId: input.requestId,
|
||||
}),
|
||||
)
|
||||
|
||||
export const CRM_PROCEDURES = [partyListImpl, partyGetImpl, partyCreateImpl, contactAddImpl]
|
||||
@@ -0,0 +1,151 @@
|
||||
// Generated SDK adapter. Production dispatch requires a server-prepared request binding.
|
||||
|
||||
import {
|
||||
businessAppError,
|
||||
type Client,
|
||||
canonicalActionJson,
|
||||
requestBusinessBinding,
|
||||
requiresAppActionTransport,
|
||||
toBusinessError,
|
||||
} from "@conusai-kit/business"
|
||||
import { type Container, forbidden, type ProcedureKind } from "@conusai-kit/core"
|
||||
import {
|
||||
type ContactPoint,
|
||||
type ContactValue,
|
||||
crmAddContactPoint,
|
||||
crmCreateParty,
|
||||
crmGetParty,
|
||||
crmListContactPoints,
|
||||
crmListParties,
|
||||
type Party,
|
||||
} from "@conusai-sdk/crm"
|
||||
import { createClient } from "@conusai-sdk/crm/client"
|
||||
import { type ContactView, type PartyView, SUBJECT_SCHEME } from "../contract/index.js"
|
||||
import { contactRequestBody, partyRequestBody } from "../core/action-plan.js"
|
||||
import { CRM_SERVICE, type CrmReads, type CrmService, crmReadsOf } from "../core/ports.js"
|
||||
|
||||
const partyView = (party: Party): PartyView => ({
|
||||
partyId: party.id,
|
||||
kind: party.kind,
|
||||
displayName: party.display_name,
|
||||
legalName: party.legal_name ?? null,
|
||||
country: party.country,
|
||||
status: party.status,
|
||||
linked: (party.identifiers ?? []).some((identifier) => identifier.scheme === SUBJECT_SCHEME),
|
||||
})
|
||||
|
||||
/** A contact value as a person reads it. */
|
||||
function valueText(value: ContactValue): string {
|
||||
switch (value.kind) {
|
||||
case "postal":
|
||||
return [value.value.street, value.value.postal_code, value.value.city, value.value.country]
|
||||
.filter((part): part is string => typeof part === "string" && part !== "")
|
||||
.join(", ")
|
||||
case "peppol_endpoint":
|
||||
return `${value.value.eas}:${value.value.id}`
|
||||
default:
|
||||
return value.value
|
||||
}
|
||||
}
|
||||
|
||||
const contactView = (contact: ContactPoint): ContactView =>
|
||||
contact.fields
|
||||
? {
|
||||
contactId: contact.id,
|
||||
kind: contact.fields.kind,
|
||||
value: valueText(contact.fields),
|
||||
purposes: contact.fields.purposes,
|
||||
isPrimary: contact.fields.is_primary,
|
||||
erased: contact.erased,
|
||||
}
|
||||
: // Erased: no personal value remains, only that there was a contact point.
|
||||
{ contactId: contact.id, kind: "email", value: "", purposes: [], isPrimary: false, erased: true }
|
||||
|
||||
/** What a generated operation answered: its data, or the kit's app error for the refusal (`businessAppError`). */
|
||||
async function answer<T>(
|
||||
call: Promise<{ data?: T; error?: unknown; response?: Response }>,
|
||||
): Promise<Exclude<T, undefined>> {
|
||||
let result: { data?: T; error?: unknown; response?: Response }
|
||||
try {
|
||||
result = await call
|
||||
} catch (error) {
|
||||
throw businessAppError(toBusinessError(error, undefined))
|
||||
}
|
||||
if (result.error !== undefined || result.data === undefined) {
|
||||
throw businessAppError(toBusinessError(result.error, result.response))
|
||||
}
|
||||
return result.data as Exclude<T, undefined>
|
||||
}
|
||||
|
||||
/** The port over one client, in one organization of one platform project. */
|
||||
export function sdkCrmService(binding: { client: Client; orgSlug: string; bindingId: string }): CrmService {
|
||||
const { client } = binding
|
||||
const org = { org_slug: binding.orgSlug }
|
||||
return {
|
||||
bindingId: binding.bindingId,
|
||||
async listParties(query) {
|
||||
const page = await answer(crmListParties({ client, path: org, query: { ...query, limit: BigInt(query.limit) } }))
|
||||
return { rows: page.data.map(partyView), ...(page.next_after ? { after: page.next_after } : {}) }
|
||||
},
|
||||
async getParty(partyId) {
|
||||
return partyView(await answer(crmGetParty({ client, path: { ...org, id: partyId } })))
|
||||
},
|
||||
async listContacts(partyId, query) {
|
||||
const page = await answer(
|
||||
crmListContactPoints({
|
||||
client,
|
||||
path: { ...org, id: partyId },
|
||||
query: { ...query, limit: BigInt(query.limit) },
|
||||
}),
|
||||
)
|
||||
return { rows: page.data.map(contactView), ...(page.next_after ? { after: page.next_after } : {}) }
|
||||
},
|
||||
async createParty(input) {
|
||||
const party = await answer(
|
||||
crmCreateParty({
|
||||
client,
|
||||
path: org,
|
||||
body: partyRequestBody(input),
|
||||
bodySerializer: canonicalActionJson,
|
||||
}),
|
||||
)
|
||||
return partyView(party)
|
||||
},
|
||||
async addContact(input) {
|
||||
const contact = await answer(
|
||||
crmAddContactPoint({
|
||||
client,
|
||||
path: { ...org, id: input.partyId },
|
||||
body: contactRequestBody(input),
|
||||
bodySerializer: canonicalActionJson,
|
||||
}),
|
||||
)
|
||||
return contactView(contact)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The service a handler of `kind` may use: the bound one (the token's view for the kind applies), or
|
||||
* the current verified request's, narrowed to its reads for a `read` or `write` handler as the token would.
|
||||
*/
|
||||
export function crmServiceOf(container: Container, kind: "effect"): CrmService
|
||||
export function crmServiceOf(container: Container, kind: ProcedureKind): CrmReads
|
||||
export function crmServiceOf(container: Container, kind: ProcedureKind): CrmReads {
|
||||
if (container.has(CRM_SERVICE)) {
|
||||
if (requiresAppActionTransport(container)) throw forbidden("app action requires prepared business transport")
|
||||
return container.resolve(CRM_SERVICE) as CrmReads
|
||||
}
|
||||
let service: CrmService
|
||||
try {
|
||||
const binding = requestBusinessBinding(container)
|
||||
service = sdkCrmService({
|
||||
client: binding.client(createClient),
|
||||
orgSlug: binding.orgSlug,
|
||||
bindingId: binding.bindingId,
|
||||
})
|
||||
} catch (error) {
|
||||
throw businessAppError(error)
|
||||
}
|
||||
return kind === "effect" ? service : crmReadsOf(service)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// The module's names, in a file with no import: a browser bundle can take them without the schemas.
|
||||
|
||||
/** The platform service this module is a client of (B-wave contract §3.14). */
|
||||
export const CRM_SERVICE_NAME = "crm"
|
||||
|
||||
/** The app permissions of the module. */
|
||||
export const CRM_PERMISSIONS = {
|
||||
read: "crm.party.read",
|
||||
write: "crm.party.write",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The identifier scheme that links a party to one of this app's people: its value is the person's
|
||||
* partition-bound member reference (`actorRef` of @conusai-kit/business), which an export or an erasure of that
|
||||
* person through the platform reaches (B-wave contract §3.8).
|
||||
*/
|
||||
export const SUBJECT_SCHEME = "conusai.subject"
|
||||
|
||||
/** The contact kinds the module's form adds; the service keeps postal addresses and Peppol endpoints too. */
|
||||
export const CONTACT_KINDS = ["email", "phone", "web"] as const
|
||||
|
||||
/** What a contact point is for, as the service names it. */
|
||||
export const CONTACT_PURPOSES = ["billing", "delivery", "invoice_email", "site"] as const
|
||||
|
||||
/** The most rows one page of a list asks the service for. */
|
||||
export const PAGE_LIMIT = 50
|
||||
@@ -0,0 +1,21 @@
|
||||
// The public API of the crm module: what a page, another module and the OpenAPI document may import.
|
||||
// Nothing here reaches core/, adapters/ or a service client.
|
||||
|
||||
export {
|
||||
CONTACT_KINDS,
|
||||
CONTACT_PURPOSES,
|
||||
CRM_PERMISSIONS,
|
||||
CRM_SERVICE_NAME,
|
||||
PAGE_LIMIT,
|
||||
SUBJECT_SCHEME,
|
||||
} from "./constants.js"
|
||||
export {
|
||||
type ContactView,
|
||||
contactAdd,
|
||||
contactViewSchema,
|
||||
type PartyView,
|
||||
partyCreate,
|
||||
partyGet,
|
||||
partyList,
|
||||
partyViewSchema,
|
||||
} from "./procedures.js"
|
||||
@@ -0,0 +1,101 @@
|
||||
// The procedure contracts of the crm module: schemas and names, no handler and no port.
|
||||
//
|
||||
// The module is a client of the platform's CRM service (B-wave contract §3.14): parties and their
|
||||
// contact points live there, in the deployment's organization, and every procedure is one or two
|
||||
// service calls. A read reads; a change is an `effect` (it calls an external service and writes
|
||||
// nothing of this app's own).
|
||||
|
||||
import { procedure } from "@conusai-kit/core"
|
||||
import { z } from "zod"
|
||||
import { CONTACT_KINDS, CONTACT_PURPOSES, CRM_PERMISSIONS } from "./constants.js"
|
||||
|
||||
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
|
||||
/** What a form generates once per submission, so a retried request creates once. */
|
||||
const requestId = z.string().uuid()
|
||||
const partyId = z.string().min(1).max(64)
|
||||
|
||||
export const partyViewSchema = z.object({
|
||||
partyId: z.string(),
|
||||
kind: z.enum(["person", "organization"]),
|
||||
displayName: z.string(),
|
||||
legalName: z.string().nullable(),
|
||||
/** ISO 3166-1 alpha-2. */
|
||||
country: z.string(),
|
||||
status: z.enum(["active", "archived", "merged"]),
|
||||
/** Whether an identifier links the party to one of this app's people. */
|
||||
linked: z.boolean(),
|
||||
})
|
||||
export type PartyView = z.output<typeof partyViewSchema>
|
||||
|
||||
export const contactViewSchema = z.object({
|
||||
contactId: z.string(),
|
||||
kind: z.enum(["postal", "email", "phone", "web", "peppol_endpoint"]),
|
||||
/** The value as a person reads it; empty once the service erased it. */
|
||||
value: z.string(),
|
||||
purposes: z.array(z.enum(CONTACT_PURPOSES)),
|
||||
isPrimary: z.boolean(),
|
||||
erased: z.boolean(),
|
||||
})
|
||||
export type ContactView = z.output<typeof contactViewSchema>
|
||||
|
||||
export const partyList = procedure("crm.party.list")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission(CRM_PERMISSIONS.read)
|
||||
.input(
|
||||
z.object({
|
||||
...inProject,
|
||||
q: z.string().max(200).optional(),
|
||||
after: z.string().max(64).optional(),
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ rows: z.array(partyViewSchema), after: z.string().optional() }))
|
||||
.contract()
|
||||
|
||||
export const partyGet = procedure("crm.party.get")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission(CRM_PERMISSIONS.read)
|
||||
.input(z.object({ ...inProject, partyId }))
|
||||
.output(z.object({ party: partyViewSchema, contacts: z.array(contactViewSchema) }))
|
||||
.contract()
|
||||
|
||||
export const partyCreate = procedure("crm.party.create")
|
||||
.kind("effect")
|
||||
.tool({ approval: "human" })
|
||||
.scope("project")
|
||||
.permission(CRM_PERMISSIONS.write)
|
||||
.input(
|
||||
z.object({
|
||||
...inProject,
|
||||
kind: z.enum(["person", "organization"]),
|
||||
displayName: z.string().trim().min(1).max(300),
|
||||
legalName: z.string().trim().max(300).optional(),
|
||||
country: z.string().regex(/^[A-Z]{2}$/),
|
||||
/** A person who is the member creating it: the party is linked to them (an export or erasure of them reaches it). */
|
||||
linkSelf: z.boolean().optional(),
|
||||
requestId,
|
||||
}),
|
||||
)
|
||||
.output(partyViewSchema)
|
||||
.contract()
|
||||
|
||||
export const contactAdd = procedure("crm.contact.add")
|
||||
.kind("effect")
|
||||
.tool({ approval: "human" })
|
||||
.scope("project")
|
||||
.permission(CRM_PERMISSIONS.write)
|
||||
.input(
|
||||
z.object({
|
||||
...inProject,
|
||||
partyId,
|
||||
kind: z.enum(CONTACT_KINDS),
|
||||
value: z.string().trim().min(1).max(256),
|
||||
purposes: z.array(z.enum(CONTACT_PURPOSES)).max(CONTACT_PURPOSES.length),
|
||||
isPrimary: z.boolean(),
|
||||
requestId,
|
||||
}),
|
||||
)
|
||||
.output(contactViewSchema)
|
||||
.contract()
|
||||
@@ -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 } })
|
||||
@@ -0,0 +1,71 @@
|
||||
// English messages of the crm module. Every string a page of this module shows is a key here and in
|
||||
// lt.ts; `__tests__/i18n.test.ts` fails when the two differ.
|
||||
|
||||
export const en = {
|
||||
title: "Customers",
|
||||
intro:
|
||||
"Parties (the people and organizations you deal with) and how to reach them, from the platform's CRM service. They are your organization's, shared by every project.",
|
||||
kinds: {
|
||||
person: "Person",
|
||||
organization: "Organization",
|
||||
},
|
||||
parties: {
|
||||
caption: "Parties",
|
||||
empty: "No parties yet.",
|
||||
name: "Name",
|
||||
kind: "Kind",
|
||||
country: "Country",
|
||||
open: "Open {name}",
|
||||
search: "Find a party",
|
||||
},
|
||||
form: {
|
||||
heading: "New party",
|
||||
kind: "Kind",
|
||||
displayName: "Name",
|
||||
legalName: "Legal name",
|
||||
country: "Country",
|
||||
countryHint: "Two letters, such as LT or DE.",
|
||||
linkSelf: "This is me: link the party to my account",
|
||||
submit: "Add party",
|
||||
busy: "Adding",
|
||||
},
|
||||
party: {
|
||||
back: "All parties",
|
||||
linked: "Linked to a member's account: their data export and erasure include it.",
|
||||
},
|
||||
contacts: {
|
||||
title: "How to reach them",
|
||||
count: "{count, plural, one {# contact point} other {# contact points}}",
|
||||
caption: "Contact points",
|
||||
empty: "No contact points yet.",
|
||||
kind: "Kind",
|
||||
value: "Value",
|
||||
purposes: "Used for",
|
||||
primary: "Primary",
|
||||
erased: "Erased",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
},
|
||||
contactKinds: {
|
||||
postal: "Postal address",
|
||||
email: "E-mail",
|
||||
phone: "Phone",
|
||||
web: "Web",
|
||||
peppol_endpoint: "Peppol endpoint",
|
||||
},
|
||||
purposes: {
|
||||
billing: "Billing",
|
||||
delivery: "Delivery",
|
||||
invoice_email: "Invoices by e-mail",
|
||||
site: "Site",
|
||||
},
|
||||
contactForm: {
|
||||
heading: "Add a contact point",
|
||||
kind: "Kind",
|
||||
value: "Value",
|
||||
purposes: "Used for",
|
||||
primary: "Primary contact point",
|
||||
submit: "Add contact point",
|
||||
busy: "Adding",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// The crm module's messages, as the TypeScript objects its manifest declares (`i18n: { en, lt }`).
|
||||
|
||||
export { en } from "./en.js"
|
||||
export { lt } from "./lt.js"
|
||||
@@ -0,0 +1,75 @@
|
||||
// Lietuviški crm modulio pranešimai. Kiekvienas puslapio tekstas yra raktas čia ir en.ts faile;
|
||||
// `__tests__/i18n.test.ts` žlunga, kai jie skiriasi.
|
||||
|
||||
import type { en } from "./en.js"
|
||||
|
||||
type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
|
||||
|
||||
export const lt: Messages<typeof en> = {
|
||||
title: "Klientai",
|
||||
intro:
|
||||
"Šalys (asmenys ir organizacijos, su kuriais dirbate) ir kaip su jais susisiekti, iš platformos CRM paslaugos. Jos priklauso jūsų organizacijai ir yra bendros visiems projektams.",
|
||||
kinds: {
|
||||
person: "Asmuo",
|
||||
organization: "Organizacija",
|
||||
},
|
||||
parties: {
|
||||
caption: "Šalys",
|
||||
empty: "Šalių dar nėra.",
|
||||
name: "Pavadinimas",
|
||||
kind: "Rūšis",
|
||||
country: "Šalis",
|
||||
open: "Atidaryti: {name}",
|
||||
search: "Rasti šalį",
|
||||
},
|
||||
form: {
|
||||
heading: "Nauja šalis",
|
||||
kind: "Rūšis",
|
||||
displayName: "Pavadinimas",
|
||||
legalName: "Teisinis pavadinimas",
|
||||
country: "Valstybė",
|
||||
countryHint: "Dvi raidės, pvz., LT arba DE.",
|
||||
linkSelf: "Tai aš: susieti šalį su mano paskyra",
|
||||
submit: "Pridėti šalį",
|
||||
busy: "Pridedama",
|
||||
},
|
||||
party: {
|
||||
back: "Visos šalys",
|
||||
linked: "Susieta su nario paskyra: jo duomenų eksportas ir ištrynimas ją apima.",
|
||||
},
|
||||
contacts: {
|
||||
title: "Kaip susisiekti",
|
||||
count: "{count, plural, one {# kontaktas} few {# kontaktai} other {# kontaktų}}",
|
||||
caption: "Kontaktai",
|
||||
empty: "Kontaktų dar nėra.",
|
||||
kind: "Rūšis",
|
||||
value: "Reikšmė",
|
||||
purposes: "Paskirtis",
|
||||
primary: "Pagrindinis",
|
||||
erased: "Ištrinta",
|
||||
yes: "Taip",
|
||||
no: "Ne",
|
||||
},
|
||||
contactKinds: {
|
||||
postal: "Pašto adresas",
|
||||
email: "El. paštas",
|
||||
phone: "Telefonas",
|
||||
web: "Svetainė",
|
||||
peppol_endpoint: "Peppol adresas",
|
||||
},
|
||||
purposes: {
|
||||
billing: "Atsiskaitymai",
|
||||
delivery: "Pristatymas",
|
||||
invoice_email: "Sąskaitos el. paštu",
|
||||
site: "Objektas",
|
||||
},
|
||||
contactForm: {
|
||||
heading: "Pridėti kontaktą",
|
||||
kind: "Rūšis",
|
||||
value: "Reikšmė",
|
||||
purposes: "Paskirtis",
|
||||
primary: "Pagrindinis kontaktas",
|
||||
submit: "Pridėti kontaktą",
|
||||
busy: "Pridedama",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// The crm module's public entry: its manifest, its contract, and the port an app may bind (a test's
|
||||
// fake, or a client for another organization). A page imports `./ui`.
|
||||
|
||||
export { crmAppActionTargets } from "./adapters/app-actions.js"
|
||||
export { sdkCrmService } from "./adapters/service.js"
|
||||
export * from "./contract/index.js"
|
||||
export { CRM_SERVICE, type CrmReads, type CrmService } from "./core/ports.js"
|
||||
export { crmModule } from "./module.js"
|
||||
@@ -0,0 +1,48 @@
|
||||
// The crm module's manifest: a client of the platform's CRM business service (B-wave contract §3.14,
|
||||
// docs/business-services.md). It keeps no table of its own -- the parties and their contact points are
|
||||
// the service's, in the deployment's organization -- so it has procedures, permissions, pages and
|
||||
// messages, and no repository, migration, job or event.
|
||||
//
|
||||
// The template ships it off: `kit module:on crm` adds it to the list in modules/index.ts and asks the
|
||||
// deployment token for the service scopes it needs (`crm.parties:read|write`, `crm.contacts:write`)
|
||||
// in conusai.json.
|
||||
|
||||
import { defineModule } from "@conusai-kit/core"
|
||||
import { CRM_PROCEDURES } from "./adapters/procedures.js"
|
||||
import { en, lt } from "./i18n/index.js"
|
||||
|
||||
export const crmModule = defineModule({
|
||||
id: "crm",
|
||||
// A business module is its service, which this app reaches on these profiles (test/module-profiles.test.ts).
|
||||
profiles: ["postgres", "mariadb", "mongodb"],
|
||||
version: "0.1.0",
|
||||
kit: "^0.1.0",
|
||||
requires: ["tenancy"],
|
||||
permissions: {
|
||||
"crm.party.read": "viewer",
|
||||
"crm.party.write": "editor",
|
||||
},
|
||||
procedures: CRM_PROCEDURES,
|
||||
serviceRequirements: [
|
||||
{
|
||||
transport: "business",
|
||||
service: "crm",
|
||||
apiRange: "^1.1.0",
|
||||
profiles: ["postgres", "mariadb", "mongodb"],
|
||||
operations: {
|
||||
"crm.party.list": ["crm.parties:read"],
|
||||
"crm.party.get": ["crm.parties:read"],
|
||||
"crm.party.create": ["crm.parties:write"],
|
||||
"crm.contact.add": ["crm.contacts:write"],
|
||||
},
|
||||
configuration: [],
|
||||
dependencies: [],
|
||||
dataClasses: ["personal"],
|
||||
partitionRequired: true,
|
||||
remediation: "/docs/business-services.md",
|
||||
},
|
||||
],
|
||||
// `crm` is the segment the web shell's module mount routes to `crmPage` (ui/pages.tsx).
|
||||
ui: { nav: [{ scope: "project", label: "crm.title", href: "crm", permission: "crm.party.read" }] },
|
||||
i18n: { en, lt },
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
// Adds a contact point (an e-mail address, a phone number, a web address) to a party. The service
|
||||
// checks the value against its kind and answers a malformed one in its own words.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
||||
import {
|
||||
CheckField,
|
||||
FormLayout,
|
||||
type FormValues,
|
||||
PageSection,
|
||||
SelectField,
|
||||
serviceNotice,
|
||||
TextField,
|
||||
usePageNavigation,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import { FieldGroup, FieldLegend, FieldSet } from "@conusai-kit/ui/primitives"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import { CONTACT_KINDS, CONTACT_PURPOSES } from "../contract/constants.js"
|
||||
|
||||
export interface ContactFormLabels {
|
||||
readonly heading: string
|
||||
readonly kind: string
|
||||
readonly value: string
|
||||
readonly purposes: string
|
||||
readonly primary: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly kinds: Readonly<Record<(typeof CONTACT_KINDS)[number], string>>
|
||||
readonly purposeNames: Readonly<Record<(typeof CONTACT_PURPOSES)[number], string>>
|
||||
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
readonly refusals: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const INPUT_TYPE = { email: "email", phone: "tel", web: "url" } as const
|
||||
|
||||
export function ContactForm({
|
||||
slugs,
|
||||
partyId,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
partyId: string
|
||||
labels: ContactFormLabels
|
||||
}): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const add = useWrite<Record<string, unknown>, unknown>("crm.contact.add")
|
||||
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
|
||||
const [kind, setKind] = useState<(typeof CONTACT_KINDS)[number]>("email")
|
||||
|
||||
const submit = async (values: FormValues): Promise<void> => {
|
||||
await add.mutate({
|
||||
...slugs,
|
||||
partyId,
|
||||
kind,
|
||||
value: values.value,
|
||||
purposes: CONTACT_PURPOSES.filter((purpose) => values[`purpose-${purpose}`] === "on"),
|
||||
isPrimary: values.isPrimary === "on",
|
||||
requestId,
|
||||
})
|
||||
setRequestId(crypto.randomUUID())
|
||||
// The page is rendered on the server: a reload lists the new contact point.
|
||||
await navigation.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title={labels.heading} variant="card">
|
||||
<FormLayout
|
||||
submitLabel={labels.submit}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
describeError={(error) => serviceNotice(error, labels.refusals)?.text ?? describeError(error)}
|
||||
>
|
||||
<SelectField
|
||||
label={labels.kind}
|
||||
value={kind}
|
||||
onValueChange={(value) => setKind(CONTACT_KINDS.find((option) => option === value) ?? "email")}
|
||||
options={CONTACT_KINDS.map((option) => ({ value: option, label: labels.kinds[option] }))}
|
||||
/>
|
||||
<TextField name="value" label={labels.value} type={INPUT_TYPE[kind]} required maxLength={256} />
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label">{labels.purposes}</FieldLegend>
|
||||
<FieldGroup>
|
||||
{CONTACT_PURPOSES.map((purpose) => (
|
||||
<CheckField key={purpose} name={`purpose-${purpose}`} label={labels.purposeNames[purpose]} />
|
||||
))}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
<CheckField name="isPrimary" label={labels.primary} />
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// A party's contact points. Part of a page's view (F10): it holds no state, and every string is passed in.
|
||||
|
||||
import { PageSection, type ResourceColumn, ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
import type { ContactView } from "../contract/index.js"
|
||||
|
||||
export interface ContactsLabels {
|
||||
readonly title: string
|
||||
/** How many contact points, already filled with the plural form. */
|
||||
readonly count: string
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly kind: string
|
||||
readonly value: string
|
||||
readonly purposes: string
|
||||
readonly primary: string
|
||||
readonly erased: string
|
||||
readonly yes: string
|
||||
readonly no: string
|
||||
readonly kinds: Readonly<Record<ContactView["kind"], string>>
|
||||
readonly purposeNames: Readonly<Record<ContactView["purposes"][number], string>>
|
||||
}
|
||||
|
||||
export function Contacts({
|
||||
contacts,
|
||||
labels,
|
||||
}: {
|
||||
contacts: readonly ContactView[]
|
||||
labels: ContactsLabels
|
||||
}): ReactElement {
|
||||
const columns: ResourceColumn<ContactView>[] = [
|
||||
{ id: "kind", header: labels.kind, cell: (contact) => labels.kinds[contact.kind] },
|
||||
{
|
||||
id: "value",
|
||||
header: labels.value,
|
||||
wrap: true,
|
||||
cell: (contact) => (contact.erased ? labels.erased : contact.value),
|
||||
},
|
||||
{
|
||||
id: "purposes",
|
||||
header: labels.purposes,
|
||||
wrap: true,
|
||||
cell: (contact) => contact.purposes.map((purpose) => labels.purposeNames[purpose]).join(", "),
|
||||
},
|
||||
{ id: "isPrimary", header: labels.primary, cell: (contact) => (contact.isPrimary ? labels.yes : labels.no) },
|
||||
]
|
||||
return (
|
||||
<PageSection title={labels.title}>
|
||||
<ResourceList
|
||||
label={labels.caption}
|
||||
columns={columns}
|
||||
data={contacts}
|
||||
rowId={(contact) => contact.contactId}
|
||||
count={contacts.length === 0 ? undefined : labels.count}
|
||||
rowLabel={(contact) => labels.kinds[contact.kind]}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// The crm module's pages and components. `crmPage` is what the web shell mounts; the rest is exported
|
||||
// for an app that composes its own page from the parts.
|
||||
|
||||
export { ContactForm, type ContactFormLabels } from "./contact-form.js"
|
||||
export { Contacts, type ContactsLabels } from "./contacts.js"
|
||||
export {
|
||||
contactFormLabels,
|
||||
contactsLabels,
|
||||
partyFormLabels,
|
||||
partyListLabels,
|
||||
type Translate,
|
||||
} from "./labels.js"
|
||||
export { type CrmPage, crmPage } from "./pages.js"
|
||||
export { PartyForm, type PartyFormLabels } from "./party-form.js"
|
||||
export { PartyList, type PartyListLabels } from "./party-list.js"
|
||||
@@ -0,0 +1,90 @@
|
||||
// The labels each component takes, built from a message lookup. Every key this module's pages use is
|
||||
// named here or in pages.tsx, so `__tests__/i18n.test.ts` can run these builders against the
|
||||
// catalogues and fail on a key either language lacks.
|
||||
//
|
||||
// `t` takes a key relative to the module (`form.submit`); the page binds it to
|
||||
// `i18n.message("crm." + key)`. `refusals` is the kit's text for each business refusal in the reader's
|
||||
// language (`businessRefusalNotices`), which a form shows when the service refuses.
|
||||
|
||||
import { CONTACT_KINDS, CONTACT_PURPOSES } from "../contract/constants.js"
|
||||
import type { ContactFormLabels } from "./contact-form.js"
|
||||
import type { ContactsLabels } from "./contacts.js"
|
||||
import type { PartyFormLabels } from "./party-form.js"
|
||||
import type { PartyListLabels } from "./party-list.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
export type Refusals = Readonly<Record<string, string>>
|
||||
|
||||
const ALL_KINDS = ["postal", "email", "phone", "web", "peppol_endpoint"] as const
|
||||
|
||||
const purposeNames = (t: Translate) =>
|
||||
Object.fromEntries(
|
||||
CONTACT_PURPOSES.map((purpose) => [purpose, t(`purposes.${purpose}`)]),
|
||||
) as ContactsLabels["purposeNames"]
|
||||
|
||||
export function partyFormLabels(t: Translate, refusals: Refusals): PartyFormLabels {
|
||||
return {
|
||||
heading: t("form.heading"),
|
||||
kind: t("form.kind"),
|
||||
person: t("kinds.person"),
|
||||
organization: t("kinds.organization"),
|
||||
displayName: t("form.displayName"),
|
||||
legalName: t("form.legalName"),
|
||||
country: t("form.country"),
|
||||
countryHint: t("form.countryHint"),
|
||||
linkSelf: t("form.linkSelf"),
|
||||
submit: t("form.submit"),
|
||||
busy: t("form.busy"),
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function partyListLabels(t: Translate): PartyListLabels {
|
||||
return {
|
||||
caption: t("parties.caption"),
|
||||
empty: t("parties.empty"),
|
||||
name: t("parties.name"),
|
||||
kind: t("parties.kind"),
|
||||
country: t("parties.country"),
|
||||
person: t("kinds.person"),
|
||||
organization: t("kinds.organization"),
|
||||
// The placeholder is the component's to fill, so it is passed through untouched.
|
||||
open: t("parties.open", { name: "{name}" }),
|
||||
search: t("parties.search"),
|
||||
}
|
||||
}
|
||||
|
||||
export function contactsLabels(t: Translate, count: number): ContactsLabels {
|
||||
return {
|
||||
title: t("contacts.title"),
|
||||
count: t("contacts.count", { count }),
|
||||
caption: t("contacts.caption"),
|
||||
empty: t("contacts.empty"),
|
||||
kind: t("contacts.kind"),
|
||||
value: t("contacts.value"),
|
||||
purposes: t("contacts.purposes"),
|
||||
primary: t("contacts.primary"),
|
||||
erased: t("contacts.erased"),
|
||||
yes: t("contacts.yes"),
|
||||
no: t("contacts.no"),
|
||||
kinds: Object.fromEntries(ALL_KINDS.map((kind) => [kind, t(`contactKinds.${kind}`)])) as ContactsLabels["kinds"],
|
||||
purposeNames: purposeNames(t),
|
||||
}
|
||||
}
|
||||
|
||||
export function contactFormLabels(t: Translate, refusals: Refusals): ContactFormLabels {
|
||||
return {
|
||||
heading: t("contactForm.heading"),
|
||||
kind: t("contactForm.kind"),
|
||||
value: t("contactForm.value"),
|
||||
purposes: t("contactForm.purposes"),
|
||||
primary: t("contactForm.primary"),
|
||||
submit: t("contactForm.submit"),
|
||||
busy: t("contactForm.busy"),
|
||||
kinds: Object.fromEntries(
|
||||
CONTACT_KINDS.map((kind) => [kind, t(`contactKinds.${kind}`)]),
|
||||
) as ContactFormLabels["kinds"],
|
||||
purposeNames: purposeNames(t),
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// The crm module's pages, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web shell
|
||||
// runs on the server under `/<org>/<workspace>/<project>/crm`, and the view of what it returned.
|
||||
//
|
||||
// /crm the organization's parties, and the form that adds one
|
||||
// /crm/<partyId> one party: how to reach it, and the form that adds a contact point
|
||||
//
|
||||
// Every read is a procedure through the same caller the API uses, and every label is translated in the
|
||||
// loader. A refusal that is the platform's -- the service is not enabled for the organization, the
|
||||
// deployment lacks a scope, the service did not answer -- is the page's own state, not an error page, in the
|
||||
// kit's words for it (`businessServiceNotices` of `@conusai-kit/business/refusals`).
|
||||
|
||||
import { businessRefusalNotices, businessServiceNotices } from "@conusai-kit/business/refusals"
|
||||
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
|
||||
import { Can } from "@conusai-kit/ui/data"
|
||||
import {
|
||||
DetailLayout,
|
||||
Page,
|
||||
PageHeader,
|
||||
type ServiceNotice,
|
||||
ServiceStatePage,
|
||||
serviceNotice,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
import { type ContactView, type PartyView, partyGet, partyList } from "../contract/index.js"
|
||||
import { ContactForm, type ContactFormLabels } from "./contact-form.js"
|
||||
import { Contacts, type ContactsLabels } from "./contacts.js"
|
||||
import { contactFormLabels, contactsLabels, partyFormLabels, partyListLabels, type Translate } from "./labels.js"
|
||||
import { PartyForm, type PartyFormLabels } from "./party-form.js"
|
||||
import { PartyList, type PartyListLabels } from "./party-list.js"
|
||||
|
||||
/** The parties, and the form that adds one. */
|
||||
export interface CrmListData {
|
||||
readonly kind: "list"
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly form: PartyFormLabels
|
||||
readonly list: PartyListLabels
|
||||
readonly parties: { rows: PartyView[]; after?: string | undefined }
|
||||
}
|
||||
|
||||
/** One party, its kind and country already in a sentence. */
|
||||
export interface CrmPartyData {
|
||||
readonly kind: "party"
|
||||
readonly title: string
|
||||
readonly back: string
|
||||
readonly partyId: string
|
||||
readonly displayName: string
|
||||
readonly summary: string
|
||||
/** The note that the party is a member's account, when it is. */
|
||||
readonly linked?: string
|
||||
readonly contacts: readonly ContactView[]
|
||||
readonly contactLabels: ContactsLabels
|
||||
readonly form: ContactFormLabels
|
||||
}
|
||||
|
||||
/** The service cannot answer: the notice in place of the page. */
|
||||
export interface CrmStateData {
|
||||
readonly kind: "state"
|
||||
readonly title: string
|
||||
readonly notice: ServiceNotice
|
||||
}
|
||||
|
||||
export type CrmPageData = CrmListData | CrmPartyData | CrmStateData
|
||||
|
||||
export type CrmPage = ProjectPage<TransportCaller, I18n, Container, CrmPageData>
|
||||
|
||||
/** A party id as the service mints it: one path segment. */
|
||||
const PARTY_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
|
||||
|
||||
/** The party a path segment names; a segment that does not decode names none. */
|
||||
function partyIdOf(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch {
|
||||
throw notFound("no such page")
|
||||
}
|
||||
}
|
||||
|
||||
export const crmPage: CrmPage = projectPage({
|
||||
async load({ slugs, segments, caller, i18n }): Promise<CrmPageData> {
|
||||
const t: Translate = (key, values) => i18n.message(`crm.${key}`, values)
|
||||
const title = t("title")
|
||||
const refusals = businessRefusalNotices(i18n.locale)
|
||||
|
||||
const [segment, ...rest] = segments
|
||||
const partyId = segment === undefined ? undefined : partyIdOf(segment)
|
||||
if (rest.length > 0 || (partyId !== undefined && !PARTY_ID.test(partyId))) throw notFound("no such page")
|
||||
|
||||
try {
|
||||
if (partyId === undefined) {
|
||||
return {
|
||||
kind: "list",
|
||||
title,
|
||||
intro: t("intro"),
|
||||
form: partyFormLabels(t, refusals),
|
||||
list: partyListLabels(t),
|
||||
parties: await caller.call(partyList, slugs),
|
||||
}
|
||||
}
|
||||
const { party, contacts } = await caller.call(partyGet, { ...slugs, partyId })
|
||||
return {
|
||||
kind: "party",
|
||||
title,
|
||||
back: t("party.back"),
|
||||
partyId: party.partyId,
|
||||
displayName: party.displayName,
|
||||
summary: `${t(`kinds.${party.kind}`)}, ${party.country}${party.legalName ? `, ${party.legalName}` : ""}`,
|
||||
...(party.linked ? { linked: t("party.linked") } : {}),
|
||||
contacts,
|
||||
contactLabels: contactsLabels(t, contacts.length),
|
||||
form: contactFormLabels(t, refusals),
|
||||
}
|
||||
} catch (error) {
|
||||
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
|
||||
if (!notice) throw error
|
||||
return { kind: "state", title, notice }
|
||||
}
|
||||
},
|
||||
|
||||
View({ slugs, data }: ProjectPageViewProps<CrmPageData>): ReactElement {
|
||||
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/crm`
|
||||
if (data.kind === "state") return <ServiceStatePage title={data.title} notice={data.notice} />
|
||||
if (data.kind === "list") {
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={data.title} description={data.intro} />
|
||||
<DetailLayout
|
||||
aside={
|
||||
<Can permission="crm.party.write">
|
||||
<PartyForm slugs={slugs} listHref={listHref} labels={data.form} />
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<PartyList slugs={slugs} seed={data.parties} listHref={listHref} labels={data.list} />
|
||||
</DetailLayout>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={data.displayName} description={data.summary} breadcrumb={<a href={listHref}>{data.back}</a>}>
|
||||
{data.linked ? <span>{data.linked}</span> : null}
|
||||
</PageHeader>
|
||||
<DetailLayout
|
||||
aside={
|
||||
<Can permission="crm.party.write">
|
||||
<ContactForm slugs={slugs} partyId={data.partyId} labels={data.form} />
|
||||
</Can>
|
||||
}
|
||||
>
|
||||
<Contacts contacts={data.contacts} labels={data.contactLabels} />
|
||||
</DetailLayout>
|
||||
</Page>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
// Adds a party (a person or an organization), then opens it. "This is me" links a person to the
|
||||
// member's own account, so the platform's export or erasure of that member reaches the party.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
||||
import {
|
||||
CheckField,
|
||||
FormLayout,
|
||||
type FormValues,
|
||||
PageSection,
|
||||
SelectField,
|
||||
serviceNotice,
|
||||
TextField,
|
||||
usePageNavigation,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, useState } from "react"
|
||||
|
||||
export interface PartyFormLabels {
|
||||
readonly heading: string
|
||||
readonly kind: string
|
||||
readonly person: string
|
||||
readonly organization: string
|
||||
readonly displayName: string
|
||||
readonly legalName: string
|
||||
readonly country: string
|
||||
readonly countryHint: string
|
||||
readonly linkSelf: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
readonly refusals: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export function PartyForm({
|
||||
slugs,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
listHref: string
|
||||
labels: PartyFormLabels
|
||||
}): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const create = useWrite<Record<string, unknown>, { partyId: string }>("crm.party.create")
|
||||
const [requestId] = useState(() => crypto.randomUUID())
|
||||
const [kind, setKind] = useState("organization")
|
||||
|
||||
const submit = async ({ displayName, legalName, country = "", linkSelf }: FormValues): Promise<void> => {
|
||||
const created = await create.mutate({
|
||||
...slugs,
|
||||
kind,
|
||||
displayName,
|
||||
...(legalName ? { legalName } : {}),
|
||||
country: country.toUpperCase(),
|
||||
...(kind === "person" && linkSelf === "on" ? { linkSelf: true } : {}),
|
||||
requestId,
|
||||
})
|
||||
navigation.navigate(`${listHref}/${encodeURIComponent(created.partyId)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title={labels.heading}>
|
||||
<FormLayout
|
||||
submitLabel={labels.submit}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
describeError={(error) => serviceNotice(error, labels.refusals)?.text ?? describeError(error)}
|
||||
>
|
||||
<SelectField
|
||||
label={labels.kind}
|
||||
value={kind}
|
||||
onValueChange={setKind}
|
||||
options={[
|
||||
{ value: "organization", label: labels.organization },
|
||||
{ value: "person", label: labels.person },
|
||||
]}
|
||||
/>
|
||||
<TextField name="displayName" label={labels.displayName} required maxLength={300} />
|
||||
<TextField name="legalName" label={labels.legalName} maxLength={300} />
|
||||
<TextField
|
||||
name="country"
|
||||
label={labels.country}
|
||||
description={labels.countryHint}
|
||||
errorMessage={labels.countryHint}
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={2}
|
||||
pattern="[A-Za-z]{2}"
|
||||
/>
|
||||
{kind === "person" ? <CheckField name="linkSelf" label={labels.linkSelf} /> : null}
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
// The organization's parties, a page at a time. The server renders the first page (`seed`); a search
|
||||
// and "Show more" are reads made on a person's action, through the page's data client.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useDataClient } from "@conusai-kit/ui/data"
|
||||
import { type ResourceColumn, ResourceList, useResourceList } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
import type { PartyView } from "../contract/index.js"
|
||||
|
||||
export interface PartyListLabels {
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly name: string
|
||||
readonly kind: string
|
||||
readonly country: string
|
||||
readonly person: string
|
||||
readonly organization: string
|
||||
/** `Open {name}`: the link's accessible name. */
|
||||
readonly open: string
|
||||
readonly search: string
|
||||
}
|
||||
|
||||
type Page = { rows: PartyView[]; after?: string | undefined }
|
||||
|
||||
export function PartyList({
|
||||
slugs,
|
||||
seed,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: Page
|
||||
listHref: string
|
||||
labels: PartyListLabels
|
||||
}): ReactElement {
|
||||
const client = useDataClient()
|
||||
const { list } = useResourceList<PartyView>({
|
||||
seed,
|
||||
rowId: (party) => party.partyId,
|
||||
searchLabel: labels.search,
|
||||
read: async ({ q, after }) => {
|
||||
const input = { ...slugs, ...(q ? { q } : {}), ...(after ? { after } : {}) }
|
||||
return (await client.call<Page>("crm.party.list", input)).value
|
||||
},
|
||||
})
|
||||
const columns: ResourceColumn<PartyView>[] = [
|
||||
{ id: "displayName", header: labels.name, wrap: true, minWidth: "10rem" },
|
||||
{
|
||||
id: "kind",
|
||||
header: labels.kind,
|
||||
cell: (party) => (party.kind === "person" ? labels.person : labels.organization),
|
||||
},
|
||||
{ id: "country", header: labels.country },
|
||||
]
|
||||
return (
|
||||
<ResourceList
|
||||
{...list}
|
||||
label={labels.caption}
|
||||
columns={columns}
|
||||
rowHref={(party) => `${listHref}/${encodeURIComponent(party.partyId)}`}
|
||||
rowLinkLabel={(party) => labels.open.replace("{name}", party.displayName)}
|
||||
rowLabel={(party) => party.displayName}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user