@startsimpli/integrations 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,42 @@ import type {
31
31
  SourceDocument,
32
32
  } from './types';
33
33
 
34
+ /**
35
+ * The tenant data plane's typed EDGE, as `/api/v1/relationships/` returns it.
36
+ *
37
+ * People are stored as edges, not as an inline array. The corpus delivery
38
+ * (bd startsim-768w.38.18) writes `source_document --involves--> party` and
39
+ * `source_document --authored_by--> party`; the document's own `data` carries
40
+ * only a `participant_count`. A reader that only looks at `data` sees a corpus
41
+ * in which nobody was ever in the room (bd startsim-768w.38.22).
42
+ */
43
+ export interface TenantRelationship {
44
+ id?: string | number;
45
+ rel_type?: string;
46
+ relType?: string;
47
+ /** The record the edge starts at — the document, or the money event. */
48
+ source: string | number;
49
+ /** The record the edge points at — the party. */
50
+ target: string | number;
51
+ created_at?: string | null;
52
+ createdAt?: string | null;
53
+ }
54
+
55
+ /**
56
+ * Edges that mean "this party is ON this record", in the order they should be
57
+ * listed. DATA, not a closed set: a tenant that models its own edge passes its
58
+ * own list rather than editing this file.
59
+ *
60
+ * `authored_by` first because the author is who a reader looks for first;
61
+ * `billed_to` is here because a money event's party is the same question asked
62
+ * of a payment.
63
+ */
64
+ export const DEFAULT_PARTICIPANT_EDGES: readonly string[] = [
65
+ 'authored_by',
66
+ 'involves',
67
+ 'billed_to',
68
+ ];
69
+
34
70
  /** The tenant data plane's generic record envelope, as the API returns it. */
35
71
  export interface TenantEntity {
36
72
  id: string | number;
@@ -178,7 +214,13 @@ export function toParty(entity: TenantEntity, fallbackScope: CorpusScope): Party
178
214
  const email = asText(pick(data, 'email', 'primary_email', 'primaryEmail')) || emails[0] || '';
179
215
  return {
180
216
  id: String(entity.id),
181
- name: asText(pick(data, 'name', 'label', 'full_name', 'fullName')) || entity.name || String(entity.id),
217
+ // `display_name` is what the canonical party schema declares
218
+ // (startsimpli_connectors.canonical), so it is read FIRST among the
219
+ // spellings — a connector-written row has no `name` key inside `data`.
220
+ name:
221
+ asText(pick(data, 'display_name', 'displayName', 'name', 'label', 'full_name', 'fullName')) ||
222
+ entity.name ||
223
+ String(entity.id),
182
224
  kind: asText(pick(data, 'kind', 'subtype', 'party_type', 'partyType')) || undefined,
183
225
  ...(email ? { email } : {}),
184
226
  ...(emails.length ? { emails } : {}),
@@ -192,6 +234,95 @@ export function toParty(entity: TenantEntity, fallbackScope: CorpusScope): Party
192
234
  };
193
235
  }
194
236
 
237
+ /**
238
+ * Stable identity for de-duplication. A party that arrives once as an edge and
239
+ * once inline is ONE person, and listing them twice is the kind of small wrong
240
+ * that makes the whole panel look untrustworthy.
241
+ */
242
+ function partyKey(party: PartyRef): string {
243
+ return (party.id || party.email || party.name || '').toLowerCase();
244
+ }
245
+
246
+ /**
247
+ * Union two participant lists, keeping the first spelling of each person.
248
+ *
249
+ * INLINE WINS over the edge-derived copy, because a connector that wrote the
250
+ * participant onto the record itself knew something about the role it played
251
+ * there. Order is preserved so `authored_by` stays in front.
252
+ */
253
+ export function mergeParticipants(...lists: Array<PartyRef[] | undefined>): PartyRef[] {
254
+ const out: PartyRef[] = [];
255
+ const seen = new Set<string>();
256
+ for (const list of lists) {
257
+ for (const party of list ?? []) {
258
+ const key = partyKey(party);
259
+ if (!key || seen.has(key)) continue;
260
+ seen.add(key);
261
+ out.push(party);
262
+ }
263
+ }
264
+ return out;
265
+ }
266
+
267
+ /**
268
+ * The graph, read as "who is on which record".
269
+ *
270
+ * THIS IS THE JOIN THE PRODUCT ACTUALLY NEEDS, and the reason it lives here
271
+ * rather than in a composer: the tenant stores people as typed edges, and every
272
+ * surface — the Library's Parties block, the row subtitle, the retrieval field
273
+ * weights, the prompt — asks the same question of them. Doing the join once,
274
+ * next to the mapper that already knows the storage shape, keeps `entities.ts`
275
+ * the single file that knows how a corpus record is stored.
276
+ *
277
+ * Nothing here names a vendor and nothing here assumes an edge direction beyond
278
+ * the declared one (`source` is the record, `target` is the party), which is the
279
+ * contract `canonical_schema()` states for all four canonical edges.
280
+ */
281
+ export function indexParticipants(
282
+ relationships: TenantRelationship[],
283
+ parties: Iterable<PartyRef>,
284
+ edgeTypes: readonly string[] = DEFAULT_PARTICIPANT_EDGES,
285
+ ): Map<string, PartyRef[]> {
286
+ const byId = new Map<string, PartyRef>();
287
+ for (const party of parties) {
288
+ if (!party?.id) continue;
289
+ // NARROWED to a reference. A `Party` carries its own source and scope, and a
290
+ // document's participant list is a list of references — copying the whole
291
+ // record onto every document that mentions it is a second copy of the row.
292
+ byId.set(String(party.id), {
293
+ id: String(party.id),
294
+ name: party.name || String(party.id),
295
+ ...(party.kind ? { kind: party.kind } : {}),
296
+ ...(party.email ? { email: party.email } : {}),
297
+ });
298
+ }
299
+
300
+ // Rank by the DECLARED order of `edgeTypes` rather than by arrival order, so
301
+ // the author is listed first however the API happened to page the edges.
302
+ const rank = new Map(edgeTypes.map((type, index) => [type, index]));
303
+ const buckets = new Map<string, Array<{ order: number; party: PartyRef }>>();
304
+
305
+ for (const edge of relationships ?? []) {
306
+ if (!edge) continue;
307
+ const relType = asText(edge.rel_type ?? edge.relType);
308
+ const order = rank.get(relType);
309
+ if (order === undefined) continue; // `derived_from` and anything else: not a person
310
+ const recordId = asText(edge.source);
311
+ const party = byId.get(asText(edge.target));
312
+ if (!recordId || !party) continue; // an edge whose far end we cannot name adds nothing
313
+ const bucket = buckets.get(recordId);
314
+ if (bucket) bucket.push({ order, party });
315
+ else buckets.set(recordId, [{ order, party }]);
316
+ }
317
+
318
+ const out = new Map<string, PartyRef[]>();
319
+ for (const [recordId, entries] of buckets) {
320
+ entries.sort((a, b) => a.order - b.order);
321
+ out.set(recordId, mergeParticipants(entries.map((entry) => entry.party)));
322
+ }
323
+ return out;
324
+ }
325
+
195
326
  // ---------------------------------------------------------------------------
196
327
  // SourceDocument
197
328
  // ---------------------------------------------------------------------------
@@ -244,6 +375,13 @@ export function toSourceDocument(
244
375
  entity: TenantEntity,
245
376
  fallbackScope: CorpusScope,
246
377
  sourceLabels: Record<string, string> = {},
378
+ /**
379
+ * People resolved from the tenant's `authored_by` / `involves` EDGES — which
380
+ * is where the connector pipeline actually puts them (bd startsim-768w.38.18).
381
+ * Passed in rather than fetched here because this module is pure; the client
382
+ * adapter owns the round trip. See `indexParticipants`.
383
+ */
384
+ edgeParticipants: PartyRef[] = [],
247
385
  ): SourceDocument {
248
386
  const data = bag(entity);
249
387
  const source = asText(pick(data, 'source', 'source_key', 'sourceKey')) || 'unknown';
@@ -276,9 +414,17 @@ export function toSourceDocument(
276
414
  asText(pick(data, 'source_ref', 'sourceRef')) ||
277
415
  asText(entity.external_id ?? entity.externalId),
278
416
  url: asText(pick(data, 'url', 'web_url', 'webUrl', 'link')) || undefined,
279
- participants: asList(pick(data, 'participants', 'attendees', 'parties'))
280
- .map(toPartyRef)
281
- .filter((ref): ref is PartyRef => ref !== null),
417
+ // BOTH STORAGE SHAPES, because both are real. A connector that denormalizes
418
+ // its attendees onto the record writes an inline array; the shipped corpus
419
+ // delivery writes typed edges and a bare `participant_count`. Reading only
420
+ // the first is what made `participants` empty on every document the pipeline
421
+ // has ever delivered (bd startsim-768w.38.22).
422
+ participants: mergeParticipants(
423
+ asList(pick(data, 'participants', 'attendees', 'parties'))
424
+ .map(toPartyRef)
425
+ .filter((ref): ref is PartyRef => ref !== null),
426
+ edgeParticipants,
427
+ ),
282
428
  scope: scopeOf(entity, fallbackScope),
283
429
  ingestedAt: asIso(entity.created_at ?? entity.createdAt),
284
430
  };
@@ -292,10 +438,13 @@ export function toMoneyEvent(
292
438
  entity: TenantEntity,
293
439
  fallbackScope: CorpusScope,
294
440
  sourceLabels: Record<string, string> = {},
441
+ /** Resolved from the `billed_to` edge — the money side of the same seam. */
442
+ edgeParticipants: PartyRef[] = [],
295
443
  ): MoneyEvent {
296
444
  const data = bag(entity);
297
445
  const source = asText(pick(data, 'source', 'source_key', 'sourceKey')) || 'unknown';
298
- const party = toPartyRef(pick(data, 'party', 'customer', 'counterparty'));
446
+ const party =
447
+ toPartyRef(pick(data, 'party', 'customer', 'counterparty')) ?? edgeParticipants[0] ?? null;
299
448
  return {
300
449
  id: String(entity.id),
301
450
  subtype: asText(pick(data, 'subtype', 'kind', 'event_type', 'eventType')) || 'charge',