/*
RFC-0168: the Integration Port. Vendor-agnostic contracts for delivering a captured
lead to outbound channels (Telegram, email, WhatsApp) and upserting it into a CRM (Pipedrive),
mirroring the growth (RFC-0027) and content-source (RFC-0141) port/adapter pattern. Pure — takes
secrets as parameters; never imports astro:env (the section handler reads secrets and passes them).
- Do not import vendor SDKs or astro:env here — adapters use fetch + injected secrets.
- Do not log or return secrets.
- RFC-0168: initial implementation.
- RFC-1186: INTEGRATION_EVENT_KINDS gains recommendation and referral-application (shared with the capability catalog enum); eventToLeadMessage prefers payload.formId over source so agent-bound events keep the real form discriminator.
- RFC-1186: step 5a — IntegrationEvent kind union + delivery formId parity
*/
import type { LifecycleEventPayload } from "./lifecycle.ts";
/** Secrets bag (env name → value). Adapters read only their declared requiredSecrets. */
export type IntegrationSecrets = Record;
export interface LeadMessage {
message: string;
formId: string;
locale: string;
/** ISO-8601 submission time. */
submittedAt: string;
contact?: { name?: string; email?: string; phone?: string };
/**
* RFC-1198: referral display text — resolved partner displayName when
* the slug matched an active partner, else the raw voluntary input.
* Absent when the visitor left the field empty.
*/
referrer?: string;
}
/** RFC-1198: notification label for the referral line — single home shared by
every channel formatter (telegram, email) so the user-facing label never
drifts. Returns undefined for absent/empty input. */
export function formatReferrerLine(referrer: string | undefined): string | undefined {
return referrer ? `Empfohlen von: ${referrer}` : undefined;
}
export interface Lead {
name?: string;
email?: string;
phone?: string;
note: string;
source: string;
}
export interface IntegrationChannelAdapter {
readonly id: string;
/** Env secret names required for this adapter to be active. */
readonly requiredSecrets: readonly string[];
/** Deliver the message. Throws on transport failure. */
deliver(message: LeadMessage, secrets: IntegrationSecrets): Promise;
}
export interface CrmAdapter {
readonly id: string;
readonly requiredSecrets: readonly string[];
/** Upsert a lead. Returns the CRM id, or null. Throws on transport failure. */
upsertLead(lead: Lead, secrets: IntegrationSecrets): Promise<{ id: string } | null>;
}
// ---------------------------------------------------------------------------
// RFC-0176: source-agnostic destination hub
// ---------------------------------------------------------------------------
/**
* Closed catalog of destination kinds the hub can route to (RFC-0176). The hub
* runs on the client's site with the client's tokens; "channel"/"crm" from
* RFC-0168 are subsumed here (a channel is a message/email destination).
*/
export const DESTINATION_KINDS = ["crm", "calendar", "email", "scheduler"] as const;
export type DestinationKind = (typeof DESTINATION_KINDS)[number];
/**
* How a configured destination is executed (RFC-0176):
* - `gogol-adapter` (default): an adapter runs on the client's site with the
* client's tokens — the studio's code routes, but on the client's deploy.
* - `vendor-native` (optional): an upstream vendor's own flow executes it; the
* studio holds no secret and runs no code for it (declared for validation +
* the RFC-0177 processor disclosure only).
*/
export const EXECUTION_MODES = ["gogol-adapter", "vendor-native"] as const;
export type ExecutionMode = (typeof EXECUTION_MODES)[number];
/**
* RFC-1186: closed catalog of normalized event kinds — the same vocabulary the
* capability catalog's `integration.eventKind` enum validates against
* (`@warpgogol/werkstatt-shared/ontology` z.enum consumes this tuple).
*/
export const INTEGRATION_EVENT_KINDS = [
"lead",
"message",
"appointment",
"recommendation",
"referral-application",
] as const;
export type IntegrationEventKind = (typeof INTEGRATION_EVENT_KINDS)[number];
/**
* Normalized event produced by any IntegrationSource (the send-message form, the
* chat widget, future sources). Generalizes LeadMessage/Lead. `eventId` is the
* idempotency key the queue consumer dedups on (RFC-0176).
*/
export interface IntegrationEvent {
/** Idempotency key (source-provided) — dedup on redelivery. */
eventId: string;
kind: IntegrationEventKind;
/** Source id, e.g. "send-message" | "uchat" | "stripe". */
source: string;
locale: string;
/** ISO-8601 occurrence time. */
occurredAt: string;
contact?: { name?: string; email?: string; phone?: string };
payload: Record;
/** RFC-0191: optional typed lifecycle payload for Stripe billing events. */
lifecycle?: LifecycleEventPayload;
}
/**
* A destination executed on the client's site (client tokens) — only for mode
* `gogol-adapter`. `vendor-native` destinations have no executed adapter.
*/
export interface DestinationAdapter {
readonly kind: DestinationKind;
readonly vendor: string;
readonly requiredSecrets: readonly string[];
/** Route the event to the destination. Returns its id, or null. Throws on transport failure. */
route(event: IntegrationEvent, secrets: IntegrationSecrets): Promise<{ id: string } | null>;
}
/** Map a normalized event onto the RFC-0168 LeadMessage shape (for channel fan-out). */
export function eventToLeadMessage(event: IntegrationEvent): LeadMessage {
const message =
typeof event.payload.message === "string"
? event.payload.message
: typeof event.payload.note === "string"
? (event.payload.note as string)
: "";
const name = event.contact?.name;
const email = event.contact?.email;
const phone = event.contact?.phone;
// RFC-1198: resolved partner displayName wins over the raw field input.
const referrer =
typeof event.payload.referralPartnerName === "string" &&
event.payload.referralPartnerName.length > 0
? event.payload.referralPartnerName
: typeof event.payload.referrer === "string" && event.payload.referrer.length > 0
? event.payload.referrer
: undefined;
return {
message,
// RFC-1186: agent-bound events carry the real form discriminator in
// payload.formId (source stays "agent"); the web path puts formId in
// source directly — prefer the payload when present.
formId:
typeof event.payload.formId === "string" && event.payload.formId.length > 0
? event.payload.formId
: event.source,
locale: event.locale,
submittedAt: event.occurredAt,
...(name || email || phone
? {
contact: {
...(name ? { name } : {}),
...(email ? { email } : {}),
...(phone ? { phone } : {}),
},
}
: {}),
...(referrer ? { referrer } : {}),
};
}
/** Map a normalized `lead`-kind event onto the RFC-0168 Lead shape (back-compat). */
export function eventToLead(event: IntegrationEvent): Lead {
const note =
typeof event.payload.note === "string"
? event.payload.note
: ((event.payload.message as string) ?? "");
return {
name: event.contact?.name,
email: event.contact?.email,
phone: event.contact?.phone,
note,
source: event.source,
};
}