@classytic/party 0.2.0 → 0.3.0

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,30 @@
3
3
  All notable changes to `@classytic/party` are documented here. Adheres to
4
4
  [Keep a Changelog](https://keepachangelog.com/) and [SemVer](https://semver.org/).
5
5
 
6
+ ## [0.3.0] — 2026-10-11
7
+
8
+ ### Added — marketing consent, per contact channel
9
+
10
+ - `ContactPoint.consent` (`{ status: 'granted' | 'withdrawn', at, source?, ip? }`) records permission to MARKET to one channel. Absent means "never asked", which a sender must treat as a refusal; `withdrawn` is deliberately not "absent", because only an opt-out has to survive a merge, a re-import or the person booking again.
11
+ - `recordConsent(id, input, ctx)` stores a decision against a channel, matching the channel by its NORMALIZED value (`07700 900000` and `+447700900000` reach the same contact point). A channel the party does not hold is a typed error, not a silent no-op. Emits `party:party.consent_recorded`.
12
+ - Merging two parties **reconciles consent** (`reconcileConsent`, exported): a withdrawal always beats a grant, whichever record is newer, because duplicate rows' clocks record when a row was touched, not when the person spoke. Between two records of the same status the later one wins. A channel one side verified stays verified. Before this, the loser's contact row was dropped wholesale and could discard an opt-out the duplicate alone carried.
13
+ - Contact points are copied field by field on merge: Mongoose subdocuments keep their values behind prototype getters, so spreading one yielded internals and dropped data.
14
+ - `CONSENT_STATUSES` / `ConsentStatus` and the `ChannelConsent` type.
15
+
16
+ ### Added — dated related-party relationships
17
+
18
+ - `relationships[]` on a party: `shareholder`, `director`, `key_management`, `partner_in_firm`, `member`, `employee`, `lessor`, `associate` (`RELATIONSHIP_KINDS`), each with `from` and an exclusive `to`, an optional `relatedTo` (absent means the reporting company), and `shareClass` / `sharePercent` for shareholders. One party can hold several at once.
19
+ - `addRelationship`, `endRelationship` and `relationshipsOn(id, date, ctx)` (the relationships holding on a date). A bad date range, an overlap or an unknown row throws `PartyRelationshipError` (`party.relationship_invalid`, 422). Emits `party:party.relationship_changed`.
20
+ - A party model index for "every shareholder" / "every lessor" queries.
21
+
22
+ ### Added — `legalForm`
23
+
24
+ `legalForm` on a party (`@classytic/primitives/legal-form`): what the law treats the party as — a sole trader is an `individual`. Absent means not stated.
25
+
26
+ ### Changed — peer floors raised to current
27
+
28
+ `@classytic/mongokit` `>=3.33.0` → **`>=3.41.0`**, `@classytic/primitives` `>=0.29.0` → **`>=0.31.1`**, `@classytic/repo-core` `>=0.24.0` → **`>=0.31.0`**, `@classytic/contact` `>=0.1.0` → **`>=0.2.0`**, `mongoose` `>=9.4.1` → **`>=9.11.0`** (mongokit 3.41 requires it). Verified on a fresh install. New parties and merges write the new fields; existing documents read unchanged (`consent` stays absent, `relationships` defaults to `[]`).
29
+
6
30
  ## [0.2.0] — 2026-08-17
7
31
 
8
32
  ### Added
package/dist/events.d.ts CHANGED
@@ -15,10 +15,12 @@ declare const PARTY_EVENTS: {
15
15
  readonly ROLE_ADDED: 'party:party.role_added';
16
16
  readonly ROLE_REMOVED: 'party:party.role_removed';
17
17
  readonly USER_LINKED: 'party:party.user_linked';
18
+ readonly CONSENT_RECORDED: 'party:party.consent_recorded';
18
19
  readonly EXTERNAL_REF_ATTACHED: 'party:party.external_ref_attached';
19
20
  readonly ARCHIVED: 'party:party.archived';
20
21
  readonly RESTORED: 'party:party.restored';
21
22
  readonly MERGED: 'party:party.merged';
23
+ readonly RELATIONSHIP_CHANGED: 'party:party.relationship_changed';
22
24
  };
23
25
  type PartyEventName = (typeof PARTY_EVENTS)[keyof typeof PARTY_EVENTS];
24
26
  //#endregion
@@ -65,6 +67,14 @@ interface PartyUserLinkedPayload {
65
67
  partyId: string;
66
68
  userId: string;
67
69
  }
70
+ interface PartyConsentRecordedPayload {
71
+ partyId: string;
72
+ channel: 'email' | 'phone';
73
+ /** The NORMALIZED channel — what a sender would actually dial or mail. */
74
+ value: string;
75
+ status: 'granted' | 'withdrawn';
76
+ source?: string;
77
+ }
68
78
  interface PartyExternalRefAttachedPayload {
69
79
  partyId: string;
70
80
  sourceModel: string;
@@ -88,6 +98,7 @@ interface PartyEventPayloads {
88
98
  [PARTY_EVENTS.ROLE_ADDED]: PartyRoleChangedPayload;
89
99
  [PARTY_EVENTS.ROLE_REMOVED]: PartyRoleChangedPayload;
90
100
  [PARTY_EVENTS.USER_LINKED]: PartyUserLinkedPayload;
101
+ [PARTY_EVENTS.CONSENT_RECORDED]: PartyConsentRecordedPayload;
91
102
  [PARTY_EVENTS.EXTERNAL_REF_ATTACHED]: PartyExternalRefAttachedPayload;
92
103
  [PARTY_EVENTS.ARCHIVED]: PartyLifecyclePayload;
93
104
  [PARTY_EVENTS.RESTORED]: PartyLifecyclePayload;
@@ -148,6 +159,19 @@ declare const PartyUserLinked: PartyEventDefinition<z.ZodObject<{
148
159
  partyId: z.ZodString;
149
160
  userId: z.ZodString;
150
161
  }, z.core.$strip>>;
162
+ declare const PartyConsentRecorded: PartyEventDefinition<z.ZodObject<{
163
+ partyId: z.ZodString;
164
+ channel: z.ZodEnum<{
165
+ email: "email";
166
+ phone: "phone";
167
+ }>;
168
+ value: z.ZodString;
169
+ status: z.ZodEnum<{
170
+ granted: "granted";
171
+ withdrawn: "withdrawn";
172
+ }>;
173
+ source: z.ZodOptional<z.ZodString>;
174
+ }, z.core.$strip>>;
151
175
  declare const PartyExternalRefAttached: PartyEventDefinition<z.ZodObject<{
152
176
  partyId: z.ZodString;
153
177
  sourceModel: z.ZodString;
@@ -164,6 +188,24 @@ declare const PartyMerged: PartyEventDefinition<z.ZodObject<{
164
188
  targetId: z.ZodString;
165
189
  absorbedKeys: z.ZodNumber;
166
190
  }, z.core.$strip>>;
191
+ declare const PartyRelationshipChanged: PartyEventDefinition<z.ZodObject<{
192
+ partyId: z.ZodString;
193
+ relationshipId: z.ZodString;
194
+ kind: z.ZodEnum<{
195
+ associate: "associate";
196
+ director: "director";
197
+ employee: "employee";
198
+ key_management: "key_management";
199
+ lessor: "lessor";
200
+ member: "member";
201
+ partner_in_firm: "partner_in_firm";
202
+ shareholder: "shareholder";
203
+ }>;
204
+ change: z.ZodEnum<{
205
+ added: "added";
206
+ ended: "ended";
207
+ }>;
208
+ }, z.core.$strip>>;
167
209
  declare const partyEventDefinitions: ReadonlyArray<PartyEventDefinition>;
168
210
  //#endregion
169
211
  //#region src/domain/ports.d.ts
@@ -230,4 +272,4 @@ declare class PartyEventDispatcher {
230
272
  */
231
273
  declare function flushPending(dispatcher: PartyEventDispatcher, queue: DomainEvent[], logger?: PartyLogger): Promise<void>;
232
274
  //#endregion
233
- export { InProcessPartyBus, PARTY_EVENTS, PENDING_EVENTS, PartyArchived, PartyCreated, PartyCreatedPayload, PartyEventDefinition, PartyEventDispatcher, PartyEventDispatcherOptions, PartyEventName, PartyEventPayloads, PartyExternalRefAttached, PartyExternalRefAttachedPayload, PartyLifecyclePayload, PartyMerged, PartyMergedPayload, PartyRestored, PartyRoleAdded, PartyRoleChangedPayload, PartyRoleRemoved, PartyUpdated, PartyUpdatedPayload, PartyUserLinked, PartyUserLinkedPayload, createEvent, flushPending, partyEventDefinitions };
275
+ export { InProcessPartyBus, PARTY_EVENTS, PENDING_EVENTS, PartyArchived, PartyConsentRecorded, PartyConsentRecordedPayload, PartyCreated, PartyCreatedPayload, PartyEventDefinition, PartyEventDispatcher, PartyEventDispatcherOptions, PartyEventName, PartyEventPayloads, PartyExternalRefAttached, PartyExternalRefAttachedPayload, PartyLifecyclePayload, PartyMerged, PartyMergedPayload, PartyRelationshipChanged, PartyRestored, PartyRoleAdded, PartyRoleChangedPayload, PartyRoleRemoved, PartyUpdated, PartyUpdatedPayload, PartyUserLinked, PartyUserLinkedPayload, createEvent, flushPending, partyEventDefinitions };
package/dist/events.js CHANGED
@@ -9,10 +9,12 @@ const PARTY_EVENTS = {
9
9
  ROLE_ADDED: "party:party.role_added",
10
10
  ROLE_REMOVED: "party:party.role_removed",
11
11
  USER_LINKED: "party:party.user_linked",
12
+ CONSENT_RECORDED: "party:party.consent_recorded",
12
13
  EXTERNAL_REF_ATTACHED: "party:party.external_ref_attached",
13
14
  ARCHIVED: "party:party.archived",
14
15
  RESTORED: "party:party.restored",
15
- MERGED: "party:party.merged"
16
+ MERGED: "party:party.merged",
17
+ RELATIONSHIP_CHANGED: "party:party.relationship_changed"
16
18
  };
17
19
  //#endregion
18
20
  //#region src/events/event-catalog.ts
@@ -71,6 +73,13 @@ const userLinkedSchema = z.object({
71
73
  partyId: z.string(),
72
74
  userId: z.string()
73
75
  });
76
+ const consentRecordedSchema = z.object({
77
+ partyId: z.string(),
78
+ channel: z.enum(["email", "phone"]),
79
+ value: z.string(),
80
+ status: z.enum(["granted", "withdrawn"]),
81
+ source: z.string().optional()
82
+ });
74
83
  const externalRefSchema = z.object({
75
84
  partyId: z.string(),
76
85
  sourceModel: z.string(),
@@ -107,6 +116,11 @@ const PartyUserLinked = definePartyEvent({
107
116
  description: "An auth user was linked to a party (1:many).",
108
117
  zodSchema: userLinkedSchema
109
118
  });
119
+ const PartyConsentRecorded = definePartyEvent({
120
+ name: PARTY_EVENTS.CONSENT_RECORDED,
121
+ description: "A marketing-consent decision was recorded against one contact channel.",
122
+ zodSchema: consentRecordedSchema
123
+ });
110
124
  const PartyExternalRefAttached = definePartyEvent({
111
125
  name: PARTY_EVENTS.EXTERNAL_REF_ATTACHED,
112
126
  description: "An external-store reference was attached to a party (facet link).",
@@ -127,16 +141,38 @@ const PartyMerged = definePartyEvent({
127
141
  description: "A duplicate party was merged into a survivor; the source becomes a redirect.",
128
142
  zodSchema: mergedSchema
129
143
  });
144
+ const relationshipChangedSchema = z.object({
145
+ partyId: z.string(),
146
+ relationshipId: z.string(),
147
+ kind: z.enum([
148
+ "shareholder",
149
+ "director",
150
+ "key_management",
151
+ "partner_in_firm",
152
+ "member",
153
+ "employee",
154
+ "lessor",
155
+ "associate"
156
+ ]),
157
+ change: z.enum(["added", "ended"])
158
+ });
159
+ const PartyRelationshipChanged = definePartyEvent({
160
+ name: PARTY_EVENTS.RELATIONSHIP_CHANGED,
161
+ description: "A dated relationship of the party (shareholder, director, lessor, …) was added or ended.",
162
+ zodSchema: relationshipChangedSchema
163
+ });
130
164
  const partyEventDefinitions = [
131
165
  PartyCreated,
132
166
  PartyUpdated,
133
167
  PartyRoleAdded,
134
168
  PartyRoleRemoved,
135
169
  PartyUserLinked,
170
+ PartyConsentRecorded,
136
171
  PartyExternalRefAttached,
137
172
  PartyArchived,
138
173
  PartyRestored,
139
- PartyMerged
174
+ PartyMerged,
175
+ PartyRelationshipChanged
140
176
  ];
141
177
  //#endregion
142
178
  //#region src/events/helpers.ts
@@ -242,4 +278,4 @@ async function flushPending(dispatcher, queue, logger) {
242
278
  }
243
279
  }
244
280
  //#endregion
245
- export { InProcessPartyBus, PARTY_EVENTS, PENDING_EVENTS, PartyArchived, PartyCreated, PartyEventDispatcher, PartyExternalRefAttached, PartyMerged, PartyRestored, PartyRoleAdded, PartyRoleRemoved, PartyUpdated, PartyUserLinked, createEvent, flushPending, partyEventDefinitions };
281
+ export { InProcessPartyBus, PARTY_EVENTS, PENDING_EVENTS, PartyArchived, PartyConsentRecorded, PartyCreated, PartyEventDispatcher, PartyExternalRefAttached, PartyMerged, PartyRelationshipChanged, PartyRestored, PartyRoleAdded, PartyRoleRemoved, PartyUpdated, PartyUserLinked, createEvent, flushPending, partyEventDefinitions };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ModelBlueprint, OperationOptions, PaginationConfig, PluginType, Repository } from "@classytic/mongokit";
2
2
  import { ResolvedTenantConfig, TenantConfig } from "@classytic/repo-core/tenant";
3
3
  import { InProcessEventBus } from "@classytic/primitives/event-infra";
4
+ import { LegalForm } from "@classytic/primitives/legal-form";
4
5
  import { Connection, Model, Schema, SchemaDefinitionProperty, Types } from "mongoose";
5
6
  import { DomainEvent, EventHandler, EventMeta, EventTransport } from "@classytic/primitives/events";
6
7
  import { ExternalRef, ExternalRef as ExternalRef$1, IdLike } from "@classytic/primitives/reference";
@@ -41,12 +42,31 @@ type PartyStatus = (typeof PARTY_STATUSES)[number];
41
42
  /** Channel kind for a contact point. Drives normalization + dedup keys. */
42
43
  declare const CONTACT_POINT_TYPES: readonly ['email', 'phone'];
43
44
  type ContactPointType = (typeof CONTACT_POINT_TYPES)[number];
45
+ /**
46
+ * Whether this channel may be used for MARKETING. Transactional messages (your
47
+ * booking is confirmed, your invoice is due) need no consent and are not what
48
+ * this records.
49
+ *
50
+ * Two states only, and `withdrawn` is deliberately not "absent": a channel that
51
+ * was never asked and a channel someone opted OUT of are the same silence to a
52
+ * sender but not to a regulator, and only the second one must survive a merge,
53
+ * a re-import, or the person booking again.
54
+ */
55
+ declare const CONSENT_STATUSES: readonly ['granted', 'withdrawn'];
56
+ type ConsentStatus = (typeof CONSENT_STATUSES)[number];
44
57
  /**
45
58
  * Address role. Mirrors Odoo's `type` (contact/invoice/delivery/other) but
46
59
  * as a first-class enum. A party carries many; `isDefault` picks the fallback.
47
60
  */
48
61
  declare const ADDRESS_TYPES: readonly ['billing', 'shipping', 'contact', 'other'];
49
62
  type AddressType = (typeof ADDRESS_TYPES)[number];
63
+ /**
64
+ * How a party is related to the reporting company (or to another party) — the related-party facts tax and
65
+ * disclosure rules read (IAS 24 in spirit). One party can hold several at once (a director who is also a
66
+ * shareholder); each is dated. A pack derives its own terms from these (a "sponsor shareholder").
67
+ */
68
+ declare const RELATIONSHIP_KINDS: readonly ['shareholder', 'director', 'key_management', 'partner_in_firm', 'member', 'employee', 'lessor', 'associate'];
69
+ type RelationshipKind = (typeof RELATIONSHIP_KINDS)[number];
50
70
  //#endregion
51
71
  //#region src/domain/value-objects.d.ts
52
72
  /**
@@ -86,6 +106,45 @@ interface ContactPoint {
86
106
  input?: string;
87
107
  isPrimary: boolean;
88
108
  verified: boolean;
109
+ /**
110
+ * Permission to MARKET to this channel, and the proof of it. Absent means
111
+ * never asked — which is a refusal to a sender, so treat it as one.
112
+ *
113
+ * Here rather than in a CRM package because consent is a property of the
114
+ * CHANNEL, exactly like `verified`: it is the answer to "may I mail this
115
+ * address", not a domain opinion about the person. Campaign subscriptions,
116
+ * sequences, suppression lists and unsubscribe tokens stay in CRM keyed by
117
+ * `partyId` — this is the one fact they all have to agree with, and a sender
118
+ * that disagrees with it is the bug this field exists to make visible.
119
+ *
120
+ * In a multi-tenant deployment a party row is already per-organization, so
121
+ * this is one business's permission and never a platform-wide one — which is
122
+ * what "specific and informed" requires.
123
+ */
124
+ consent?: ChannelConsent;
125
+ }
126
+ /**
127
+ * A consent decision, with what a regulator asks for when they ask you to
128
+ * prove it: what was decided, WHEN, and where the person was standing.
129
+ *
130
+ * Immutable by convention — a change of mind is a new record replacing this
131
+ * one, never an edit, so `at` always dates the CURRENT status.
132
+ */
133
+ interface ChannelConsent {
134
+ status: ConsentStatus;
135
+ /** The moment the person decided. */
136
+ at: Date;
137
+ /**
138
+ * The surface that asked — `'booking-form'`, `'dashboard'`, `'import:2019-mailchimp'`.
139
+ * Free text on purpose: the kernel cannot enumerate a host's screens, and an
140
+ * imported consent whose origin is unrecorded is one you cannot defend.
141
+ */
142
+ source?: string;
143
+ /**
144
+ * Caller IP at the moment of GRANT, when the host has one. Never backfilled:
145
+ * an IP attached later proves nothing and is then just retained PII.
146
+ */
147
+ ip?: string;
89
148
  }
90
149
  /**
91
150
  * Statutory tax identity. Country-agnostic slots — a BD deployment fills
@@ -202,6 +261,19 @@ interface SchemaBuildConfig {
202
261
  declare function tenantFieldDef(config: SchemaBuildConfig): SchemaDefinitionProperty | undefined;
203
262
  //#endregion
204
263
  //#region src/models/party.model.d.ts
264
+ interface PartyRelationship {
265
+ _id: Types.ObjectId;
266
+ kind: RelationshipKind;
267
+ /** The other party; absent means the reporting company itself. */
268
+ relatedTo?: Types.ObjectId | string | null;
269
+ /** Shareholding detail when `kind: 'shareholder'`. */
270
+ shareClass?: string | null;
271
+ sharePercent?: number | null;
272
+ from: Date;
273
+ /** Exclusive end; absent while it holds. */
274
+ to?: Date | null;
275
+ note?: string | null;
276
+ }
205
277
  interface IPartyDocument {
206
278
  _id: Types.ObjectId;
207
279
  /** Present only when the engine runs multi-tenant; absent for company-wide identity. */
@@ -224,6 +296,10 @@ interface IPartyDocument {
224
296
  /** Set when `status: 'merged'` — the survivor this party redirects to. */
225
297
  mergedInto?: Types.ObjectId | string;
226
298
  tags: string[];
299
+ /** What the law treats the party as (a sole trader is an `individual`). Absent: not stated. */
300
+ legalForm?: LegalForm;
301
+ /** Dated relationships to the reporting company or another party. */
302
+ relationships: PartyRelationship[];
227
303
  metadata?: Record<string, unknown>;
228
304
  /** Optimistic-concurrency counter (drives `claimVersion`). */
229
305
  version: number;
@@ -280,6 +356,22 @@ declare class PartyEventDispatcher {
280
356
  declare function flushPending(dispatcher: PartyEventDispatcher, queue: DomainEvent[], logger?: PartyLogger): Promise<void>;
281
357
  //#endregion
282
358
  //#region src/repositories/party.repository.d.ts
359
+ /** What a host asks `recordConsent` to write. */
360
+ interface RecordConsentInput {
361
+ channel: ContactPointType;
362
+ /** The channel as the person gave it; normalized here before it is matched. */
363
+ value: string;
364
+ status: ConsentStatus;
365
+ /** The surface that asked — see `ChannelConsent.source`. */
366
+ source?: string;
367
+ /** Caller IP. Kept only on a grant. */
368
+ ip?: string;
369
+ /**
370
+ * When they said it. Defaults to now; supply it only when replaying a real
371
+ * historical decision (a migration), never to backdate a fresh one.
372
+ */
373
+ at?: Date;
374
+ }
283
375
  interface PartyRepositoryDeps {
284
376
  events: PartyEventDispatcher;
285
377
  logger?: PartyLogger | undefined;
@@ -320,7 +412,30 @@ declare class PartyRepository extends Repository<IPartyDocument> implements Part
320
412
  syncIdentity(id: string, input: ResolvePartyInput, ctx: PartyContext): Promise<IPartyDocument>;
321
413
  addRole(id: string, role: PartyRole, ctx: PartyContext): Promise<IPartyDocument>;
322
414
  removeRole(id: string, role: PartyRole, ctx: PartyContext): Promise<IPartyDocument>;
415
+ /**
416
+ * Record a dated relationship (shareholder, director, lessor, …) to the reporting company or another
417
+ * party. Refused when one of the same kind with the same counterpart is already in force over the dates:
418
+ * two overlapping rows would double a shareholding, and only one of them could be ended.
419
+ */
420
+ addRelationship(id: string, input: AddRelationshipInput, ctx: PartyContext): Promise<IPartyDocument>;
421
+ /** End a relationship on a date (exclusive). Ending one already ended earlier is refused, never moved. */
422
+ endRelationship(id: string, relationshipId: string, to: Date, ctx: PartyContext): Promise<IPartyDocument>;
423
+ /** The relationships in force on a date — following a merged party to its survivor. */
424
+ relationshipsOn(id: string, on: Date, ctx: PartyContext): Promise<PartyRelationship[]>;
323
425
  linkUser(id: string, userId: string, ctx: PartyContext): Promise<IPartyDocument>;
426
+ /**
427
+ * Record what this person said about being MARKETED to on one channel.
428
+ *
429
+ * Addressed by the channel's raw value (what they typed) and normalized here,
430
+ * so a host passing `07700 900000` and a host passing `+447700900000` reach
431
+ * the same contact point — consent landing on a second, differently-spelled
432
+ * row is an opt-out that no sender would ever find.
433
+ *
434
+ * A channel the party does not hold is a typed error rather than a silent
435
+ * no-op: a grant that went nowhere looks identical to one that was stored,
436
+ * and the caller is usually a booking form that will never check again.
437
+ */
438
+ recordConsent(id: string, input: RecordConsentInput, ctx: PartyContext): Promise<IPartyDocument>;
324
439
  attachExternalRef(id: string, ref: ExternalRef, ctx: PartyContext): Promise<IPartyDocument>;
325
440
  archive(id: string, ctx: PartyContext): Promise<IPartyDocument>;
326
441
  restore(id: string, ctx: PartyContext): Promise<IPartyDocument>;
@@ -346,6 +461,16 @@ declare class PartyRepository extends Repository<IPartyDocument> implements Part
346
461
  /** Route a domain event with meta stamped from ctx + the party's org. */
347
462
  private dispatch;
348
463
  }
464
+ interface AddRelationshipInput {
465
+ kind: RelationshipKind;
466
+ /** The other party; omit for the reporting company. */
467
+ relatedTo?: string | null;
468
+ from: Date;
469
+ to?: Date | null;
470
+ shareClass?: string;
471
+ sharePercent?: number;
472
+ note?: string;
473
+ }
349
474
  //#endregion
350
475
  //#region src/engine/engine-types.d.ts
351
476
  interface ResolvedPartyConfig {
@@ -603,10 +728,27 @@ declare function pickPrimary(contactPoints: readonly ContactPoint[], type: 'emai
603
728
  declare function unionStrings(a: readonly string[], b: readonly string[]): string[];
604
729
  /** Union external refs by `(sourceModel, sourceId)` identity. */
605
730
  declare function unionRefs(a: readonly ExternalRef[], b: readonly ExternalRef[]): ExternalRef[];
731
+ /**
732
+ * Reconcile two consent records for the SAME channel.
733
+ *
734
+ * A withdrawal always beats a grant, regardless of which is newer. Recency is
735
+ * the intuitive rule and it is the wrong one here: the two records being
736
+ * reconciled came from duplicate rows whose clocks record when each row was
737
+ * TOUCHED, not the order the person actually spoke, so letting the later
738
+ * timestamp win means a merge can quietly re-subscribe someone who opted out.
739
+ * Being over-cautious costs a marketing email; being wrong costs a complaint
740
+ * you cannot defend. Between two records of the same status the later one wins,
741
+ * since only its timestamp is then load-bearing.
742
+ */
743
+ declare function reconcileConsent(a: ChannelConsent | undefined, b: ChannelConsent | undefined): ChannelConsent | undefined;
606
744
  /**
607
745
  * Union contact points by their normalized `key`. The survivor keeps its own
608
746
  * primaries; absorbed points come in as non-primary (a merge must never create
609
747
  * two primaries of the same channel).
748
+ *
749
+ * Where both sides hold the same channel the survivor's row is kept but its
750
+ * CONSENT is reconciled — dropping the loser's row wholesale would discard an
751
+ * opt-out that the duplicate was the only carrier of.
610
752
  */
611
753
  declare function unionContacts(a: readonly ContactPoint[], b: readonly ContactPoint[]): ContactPoint[];
612
754
  //#endregion
@@ -629,6 +771,10 @@ declare class PartyNotFoundError extends PartyError {
629
771
  declare class PartyMergeError extends PartyError {
630
772
  constructor(message: string, sourceId: string, targetId: string);
631
773
  }
774
+ /** A relationship that cannot be recorded or ended as asked (overlap, bad dates, unknown row). */
775
+ declare class PartyRelationshipError extends PartyError {
776
+ constructor(message: string, partyId: string);
777
+ }
632
778
  /**
633
779
  * `resolveOrCreate` lost the create race and the winning row could not be
634
780
  * re-read (should be transient — surfaced rather than swallowed).
@@ -674,10 +820,12 @@ declare const PARTY_EVENTS: {
674
820
  readonly ROLE_ADDED: 'party:party.role_added';
675
821
  readonly ROLE_REMOVED: 'party:party.role_removed';
676
822
  readonly USER_LINKED: 'party:party.user_linked';
823
+ readonly CONSENT_RECORDED: 'party:party.consent_recorded';
677
824
  readonly EXTERNAL_REF_ATTACHED: 'party:party.external_ref_attached';
678
825
  readonly ARCHIVED: 'party:party.archived';
679
826
  readonly RESTORED: 'party:party.restored';
680
827
  readonly MERGED: 'party:party.merged';
828
+ readonly RELATIONSHIP_CHANGED: 'party:party.relationship_changed';
681
829
  };
682
830
  type PartyEventName = (typeof PARTY_EVENTS)[keyof typeof PARTY_EVENTS];
683
831
  //#endregion
@@ -702,6 +850,14 @@ interface PartyUserLinkedPayload {
702
850
  partyId: string;
703
851
  userId: string;
704
852
  }
853
+ interface PartyConsentRecordedPayload {
854
+ partyId: string;
855
+ channel: 'email' | 'phone';
856
+ /** The NORMALIZED channel — what a sender would actually dial or mail. */
857
+ value: string;
858
+ status: 'granted' | 'withdrawn';
859
+ source?: string;
860
+ }
705
861
  interface PartyExternalRefAttachedPayload {
706
862
  partyId: string;
707
863
  sourceModel: string;
@@ -725,6 +881,7 @@ interface PartyEventPayloads {
725
881
  [PARTY_EVENTS.ROLE_ADDED]: PartyRoleChangedPayload;
726
882
  [PARTY_EVENTS.ROLE_REMOVED]: PartyRoleChangedPayload;
727
883
  [PARTY_EVENTS.USER_LINKED]: PartyUserLinkedPayload;
884
+ [PARTY_EVENTS.CONSENT_RECORDED]: PartyConsentRecordedPayload;
728
885
  [PARTY_EVENTS.EXTERNAL_REF_ATTACHED]: PartyExternalRefAttachedPayload;
729
886
  [PARTY_EVENTS.ARCHIVED]: PartyLifecyclePayload;
730
887
  [PARTY_EVENTS.RESTORED]: PartyLifecyclePayload;
@@ -752,4 +909,4 @@ declare class InProcessPartyBus extends InProcessEventBus {
752
909
  });
753
910
  }
754
911
  //#endregion
755
- export { ADDRESS_TYPES, type AddressType, CONTACT_POINT_TYPES, type ContactPoint, type ContactPointType, type ExternalRef, type IPartyDocument, type IdentityKeyOptions, InProcessPartyBus, type NormalizeOptions, PARTY_EVENTS, PARTY_KINDS, PARTY_ROLES, PARTY_STATE_MACHINE, PARTY_STATUSES, PARTY_SUBJECT_MODEL, PENDING_EVENTS, type PartyAddress, type PartyBlueprint, type PartyContext, type PartyEngine, PartyError, PartyEventDispatcher, type PartyEventName, type PartyEventPayloads, type PartyKind, type PartyLogger, PartyMergeError, type PartyModels, PartyNotFoundError, PartyPhoneFormatError, type PartyPort, type PartyRepositories, PartyRepository, type PartyRepositoryDeps, PartyResolveConflictError, type PartyRole, type PartyRuntime, type PartyShape, type PartySnapshot, type PartyStatus, type PartySubject, PartyValidationError, PartyVersionConflictError, type PersonName, type ResolvePartyInput, type ResolveResult, type ResolvedPartyConfig, type SchemaBuildConfig, type TaxIdentity, type TenantFieldType, buildContactPoints, buildPartySchema, contactPointKey, createEvent, dedupe, defineParty, definePartyModels, deriveIdentityKeys, flushPending, isPartySubject, normalizeEmail, partyEventDefinitions, partySubject, pickPrimary, repoOptionsFromCtx, resolveDisplayName, taxIdentityKey, tenantFieldDef, unionContacts, unionRefs, unionStrings };
912
+ export { ADDRESS_TYPES, type AddRelationshipInput, type AddressType, CONSENT_STATUSES, CONTACT_POINT_TYPES, type ChannelConsent, type ConsentStatus, type ContactPoint, type ContactPointType, type ExternalRef, type IPartyDocument, type IdentityKeyOptions, InProcessPartyBus, type NormalizeOptions, PARTY_EVENTS, PARTY_KINDS, PARTY_ROLES, PARTY_STATE_MACHINE, PARTY_STATUSES, PARTY_SUBJECT_MODEL, PENDING_EVENTS, type PartyAddress, type PartyBlueprint, type PartyContext, type PartyEngine, PartyError, PartyEventDispatcher, type PartyEventName, type PartyEventPayloads, type PartyKind, type PartyLogger, PartyMergeError, type PartyModels, PartyNotFoundError, PartyPhoneFormatError, type PartyPort, type PartyRelationship, PartyRelationshipError, type PartyRepositories, PartyRepository, type PartyRepositoryDeps, PartyResolveConflictError, type PartyRole, type PartyRuntime, type PartyShape, type PartySnapshot, type PartyStatus, type PartySubject, PartyValidationError, PartyVersionConflictError, type PersonName, RELATIONSHIP_KINDS, type RecordConsentInput, type RelationshipKind, type ResolvePartyInput, type ResolveResult, type ResolvedPartyConfig, type SchemaBuildConfig, type TaxIdentity, type TenantFieldType, buildContactPoints, buildPartySchema, contactPointKey, createEvent, dedupe, defineParty, definePartyModels, deriveIdentityKeys, flushPending, isPartySubject, normalizeEmail, partyEventDefinitions, partySubject, pickPrimary, reconcileConsent, repoOptionsFromCtx, resolveDisplayName, taxIdentityKey, tenantFieldDef, unionContacts, unionRefs, unionStrings };
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Repository, defineModels, isDuplicateKeyError, multiTenantPlugin, transactionResolutionOf, withTransaction } from "@classytic/mongokit";
2
2
  import { assertNoLegacyTenantKeys, resolveTenantConfig } from "@classytic/repo-core/tenant";
3
3
  import { InProcessEventBus } from "@classytic/primitives/event-infra";
4
- import { Schema } from "mongoose";
4
+ import { LEGAL_FORMS } from "@classytic/primitives/legal-form";
5
+ import { Schema, Types } from "mongoose";
5
6
  import { defineStateMachine } from "@classytic/primitives/state-machine";
6
7
  import { normalizeEmail as normalizeEmail$1 } from "@classytic/contact/email";
7
8
  import { PhoneFormatError, normalizePhone } from "@classytic/contact/phone";
@@ -49,6 +50,17 @@ const PARTY_STATUSES = [
49
50
  /** Channel kind for a contact point. Drives normalization + dedup keys. */
50
51
  const CONTACT_POINT_TYPES = ["email", "phone"];
51
52
  /**
53
+ * Whether this channel may be used for MARKETING. Transactional messages (your
54
+ * booking is confirmed, your invoice is due) need no consent and are not what
55
+ * this records.
56
+ *
57
+ * Two states only, and `withdrawn` is deliberately not "absent": a channel that
58
+ * was never asked and a channel someone opted OUT of are the same silence to a
59
+ * sender but not to a regulator, and only the second one must survive a merge,
60
+ * a re-import, or the person booking again.
61
+ */
62
+ const CONSENT_STATUSES = ["granted", "withdrawn"];
63
+ /**
52
64
  * Address role. Mirrors Odoo's `type` (contact/invoice/delivery/other) but
53
65
  * as a first-class enum. A party carries many; `isDefault` picks the fallback.
54
66
  */
@@ -58,6 +70,21 @@ const ADDRESS_TYPES = [
58
70
  "contact",
59
71
  "other"
60
72
  ];
73
+ /**
74
+ * How a party is related to the reporting company (or to another party) — the related-party facts tax and
75
+ * disclosure rules read (IAS 24 in spirit). One party can hold several at once (a director who is also a
76
+ * shareholder); each is dated. A pack derives its own terms from these (a "sponsor shareholder").
77
+ */
78
+ const RELATIONSHIP_KINDS = [
79
+ "shareholder",
80
+ "director",
81
+ "key_management",
82
+ "partner_in_firm",
83
+ "member",
84
+ "employee",
85
+ "lessor",
86
+ "associate"
87
+ ];
61
88
  //#endregion
62
89
  //#region src/events/dispatch.ts
63
90
  /** Context key carrying a post-commit publish queue (attached by `withTransaction`). */
@@ -221,6 +248,27 @@ const personNameSchema = new Schema({
221
248
  maxlength: 120
222
249
  }
223
250
  }, { _id: false });
251
+ const channelConsentSchema = new Schema({
252
+ status: {
253
+ type: String,
254
+ enum: CONSENT_STATUSES,
255
+ required: true
256
+ },
257
+ at: {
258
+ type: Date,
259
+ required: true
260
+ },
261
+ source: {
262
+ type: String,
263
+ trim: true,
264
+ maxlength: 120
265
+ },
266
+ ip: {
267
+ type: String,
268
+ trim: true,
269
+ maxlength: 64
270
+ }
271
+ }, { _id: false });
224
272
  const contactPointSchema = new Schema({
225
273
  type: {
226
274
  type: String,
@@ -249,7 +297,8 @@ const contactPointSchema = new Schema({
249
297
  verified: {
250
298
  type: Boolean,
251
299
  default: false
252
- }
300
+ },
301
+ consent: { type: channelConsentSchema }
253
302
  }, { _id: false });
254
303
  const addressSchema = new Schema({
255
304
  type: {
@@ -338,6 +387,44 @@ const taxIdentitySchema = new Schema({
338
387
  maxlength: 2
339
388
  }
340
389
  }, { _id: false });
390
+ const relationshipSchema = new Schema({
391
+ kind: {
392
+ type: String,
393
+ enum: RELATIONSHIP_KINDS,
394
+ required: true
395
+ },
396
+ relatedTo: {
397
+ type: Schema.Types.ObjectId,
398
+ ref: "Party",
399
+ default: null
400
+ },
401
+ shareClass: {
402
+ type: String,
403
+ trim: true,
404
+ maxlength: 60,
405
+ default: null
406
+ },
407
+ sharePercent: {
408
+ type: Number,
409
+ min: 0,
410
+ max: 100,
411
+ default: null
412
+ },
413
+ from: {
414
+ type: Date,
415
+ required: true
416
+ },
417
+ to: {
418
+ type: Date,
419
+ default: null
420
+ },
421
+ note: {
422
+ type: String,
423
+ trim: true,
424
+ maxlength: 300,
425
+ default: null
426
+ }
427
+ });
341
428
  const externalRefSchema$1 = new Schema({
342
429
  sourceId: {
343
430
  type: String,
@@ -414,6 +501,14 @@ function buildPartySchema(config) {
414
501
  type: [String],
415
502
  default: []
416
503
  },
504
+ legalForm: {
505
+ type: String,
506
+ enum: LEGAL_FORMS
507
+ },
508
+ relationships: {
509
+ type: [relationshipSchema],
510
+ default: []
511
+ },
417
512
  metadata: { type: Schema.Types.Mixed },
418
513
  version: {
419
514
  type: Number,
@@ -439,6 +534,10 @@ function buildPartySchema(config) {
439
534
  schema.index(scoped({ createdAt: -1 }));
440
535
  schema.index(scoped({ parentId: 1 }));
441
536
  schema.index(scoped({ userLinks: 1 }));
537
+ schema.index(scoped({
538
+ "relationships.kind": 1,
539
+ "relationships.relatedTo": 1
540
+ }));
442
541
  schema.index({
443
542
  "externalRefs.sourceModel": 1,
444
543
  "externalRefs.sourceId": 1
@@ -518,6 +617,12 @@ var PartyMergeError = class extends PartyError {
518
617
  });
519
618
  }
520
619
  };
620
+ /** A relationship that cannot be recorded or ended as asked (overlap, bad dates, unknown row). */
621
+ var PartyRelationshipError = class extends PartyError {
622
+ constructor(message, partyId) {
623
+ super(422, "party.relationship_invalid", message, { partyId });
624
+ }
625
+ };
521
626
  /**
522
627
  * `resolveOrCreate` lost the create race and the winning row could not be
523
628
  * re-read (should be transient — surfaced rather than swallowed).
@@ -788,23 +893,81 @@ function unionRefs(a, b) {
788
893
  return out;
789
894
  }
790
895
  /**
896
+ * Reconcile two consent records for the SAME channel.
897
+ *
898
+ * A withdrawal always beats a grant, regardless of which is newer. Recency is
899
+ * the intuitive rule and it is the wrong one here: the two records being
900
+ * reconciled came from duplicate rows whose clocks record when each row was
901
+ * TOUCHED, not the order the person actually spoke, so letting the later
902
+ * timestamp win means a merge can quietly re-subscribe someone who opted out.
903
+ * Being over-cautious costs a marketing email; being wrong costs a complaint
904
+ * you cannot defend. Between two records of the same status the later one wins,
905
+ * since only its timestamp is then load-bearing.
906
+ */
907
+ function reconcileConsent(a, b) {
908
+ if (!a) return b;
909
+ if (!b) return a;
910
+ if (a.status !== b.status) return a.status === "withdrawn" ? a : b;
911
+ return b.at > a.at ? b : a;
912
+ }
913
+ /**
791
914
  * Union contact points by their normalized `key`. The survivor keeps its own
792
915
  * primaries; absorbed points come in as non-primary (a merge must never create
793
916
  * two primaries of the same channel).
917
+ *
918
+ * Where both sides hold the same channel the survivor's row is kept but its
919
+ * CONSENT is reconciled — dropping the loser's row wholesale would discard an
920
+ * opt-out that the duplicate was the only carrier of.
794
921
  */
795
922
  function unionContacts(a, b) {
796
- const seen = new Set(a.map((c) => c.key));
797
- const out = [...a];
798
- for (const c of b) if (!seen.has(c.key)) {
799
- seen.add(c.key);
800
- out.push({
801
- ...c,
802
- isPrimary: false
803
- });
923
+ const out = a.map(plainPoint);
924
+ const at = new Map(out.map((c, i) => [c.key, i]));
925
+ for (const c of b) {
926
+ const existing = at.get(c.key);
927
+ if (existing === void 0) {
928
+ at.set(c.key, out.length);
929
+ out.push({
930
+ ...plainPoint(c),
931
+ isPrimary: false
932
+ });
933
+ continue;
934
+ }
935
+ const survivor = out[existing];
936
+ const consent = reconcileConsent(survivor.consent, c.consent);
937
+ out[existing] = {
938
+ ...survivor,
939
+ verified: survivor.verified || c.verified,
940
+ ...consent !== void 0 ? { consent } : {}
941
+ };
804
942
  }
805
943
  return out;
806
944
  }
807
945
  /**
946
+ * Copy a contact point FIELD BY FIELD.
947
+ *
948
+ * These arrive as Mongoose subdocuments, whose values live behind prototype
949
+ * getters — spreading one yields its internals (`_doc`, `$__`) and none of its
950
+ * data, and an override written alongside them is then dropped on cast. Reading
951
+ * each property goes through the getter and gets the real value, so this is the
952
+ * only shape that survives being handed back to `$set`.
953
+ */
954
+ function plainPoint(c) {
955
+ return {
956
+ type: c.type,
957
+ value: c.value,
958
+ key: c.key,
959
+ ...c.input !== void 0 ? { input: c.input } : {},
960
+ isPrimary: c.isPrimary,
961
+ verified: c.verified,
962
+ ...c.consent !== void 0 ? { consent: {
963
+ status: c.consent.status,
964
+ at: c.consent.at,
965
+ ...c.consent.source !== void 0 ? { source: c.consent.source } : {},
966
+ ...c.consent.ip !== void 0 ? { ip: c.consent.ip } : {}
967
+ } } : {}
968
+ };
969
+ }
970
+ /**
808
971
  * Union postal addresses, deduping on `(type, line1, postalCode)` — the same
809
972
  * address arriving from both sides collapses to one. Absorbed addresses keep
810
973
  * their own recipient/label but yield `isDefault` to the survivor: a merge must
@@ -849,10 +1012,12 @@ const PARTY_EVENTS = {
849
1012
  ROLE_ADDED: "party:party.role_added",
850
1013
  ROLE_REMOVED: "party:party.role_removed",
851
1014
  USER_LINKED: "party:party.user_linked",
1015
+ CONSENT_RECORDED: "party:party.consent_recorded",
852
1016
  EXTERNAL_REF_ATTACHED: "party:party.external_ref_attached",
853
1017
  ARCHIVED: "party:party.archived",
854
1018
  RESTORED: "party:party.restored",
855
- MERGED: "party:party.merged"
1019
+ MERGED: "party:party.merged",
1020
+ RELATIONSHIP_CHANGED: "party:party.relationship_changed"
856
1021
  };
857
1022
  //#endregion
858
1023
  //#region src/events/helpers.ts
@@ -1052,6 +1217,65 @@ var PartyRepository = class extends Repository {
1052
1217
  }, updated, ctx);
1053
1218
  return updated;
1054
1219
  }
1220
+ /**
1221
+ * Record a dated relationship (shareholder, director, lessor, …) to the reporting company or another
1222
+ * party. Refused when one of the same kind with the same counterpart is already in force over the dates:
1223
+ * two overlapping rows would double a shareholding, and only one of them could be ended.
1224
+ */
1225
+ async addRelationship(id, input, ctx) {
1226
+ const party = await this.loadParty(id, ctx);
1227
+ const relatedTo = input.relatedTo ?? null;
1228
+ const to = input.to ?? null;
1229
+ if (to && to <= input.from) throw new PartyRelationshipError("A relationship ends after it starts.", id);
1230
+ if (party.relationships.find((r) => r.kind === input.kind && String(r.relatedTo ?? "") === String(relatedTo ?? "") && overlaps(r.from, r.to ?? null, input.from, to))) throw new PartyRelationshipError(`A ${input.kind} relationship already holds over those dates.`, id);
1231
+ const row = {
1232
+ _id: new Types.ObjectId(),
1233
+ kind: input.kind,
1234
+ relatedTo,
1235
+ from: input.from,
1236
+ to,
1237
+ shareClass: input.shareClass ?? null,
1238
+ sharePercent: input.sharePercent ?? null,
1239
+ note: input.note ?? null
1240
+ };
1241
+ const updated = await this.mustUpdate(party._id, { $push: { relationships: row } }, ctx);
1242
+ await this.dispatch(PARTY_EVENTS.RELATIONSHIP_CHANGED, {
1243
+ partyId: String(party._id),
1244
+ relationshipId: String(row._id),
1245
+ kind: input.kind,
1246
+ change: "added"
1247
+ }, updated, ctx);
1248
+ return updated;
1249
+ }
1250
+ /** End a relationship on a date (exclusive). Ending one already ended earlier is refused, never moved. */
1251
+ async endRelationship(id, relationshipId, to, ctx) {
1252
+ const party = await this.loadParty(id, ctx);
1253
+ const row = party.relationships.find((r) => String(r._id) === relationshipId);
1254
+ if (!row) throw new PartyRelationshipError(`Relationship ${relationshipId} not found.`, id);
1255
+ if (row.to) throw new PartyRelationshipError("The relationship has already ended.", id);
1256
+ if (to <= row.from) throw new PartyRelationshipError("A relationship ends after it starts.", id);
1257
+ const updated = await this.update(party._id, { $set: { "relationships.$[r].to": to } }, {
1258
+ ...repoOptionsFromCtx(ctx),
1259
+ arrayFilters: [{
1260
+ "r._id": row._id,
1261
+ "r.to": null
1262
+ }]
1263
+ });
1264
+ if (!updated) throw new PartyNotFoundError(id);
1265
+ await this.dispatch(PARTY_EVENTS.RELATIONSHIP_CHANGED, {
1266
+ partyId: String(party._id),
1267
+ relationshipId,
1268
+ kind: row.kind,
1269
+ change: "ended"
1270
+ }, updated, ctx);
1271
+ return updated;
1272
+ }
1273
+ /** The relationships in force on a date — following a merged party to its survivor. */
1274
+ async relationshipsOn(id, on, ctx) {
1275
+ let party = await this.loadParty(id, ctx);
1276
+ if (party.status === "merged" && party.mergedInto) party = await this.loadParty(String(party.mergedInto), ctx);
1277
+ return party.relationships.filter((r) => r.from <= on && (!r.to || on < r.to));
1278
+ }
1055
1279
  async linkUser(id, userId, ctx) {
1056
1280
  const party = await this.loadParty(id, ctx);
1057
1281
  if (party.userLinks.includes(userId)) return party;
@@ -1062,6 +1286,40 @@ var PartyRepository = class extends Repository {
1062
1286
  }, updated, ctx);
1063
1287
  return updated;
1064
1288
  }
1289
+ /**
1290
+ * Record what this person said about being MARKETED to on one channel.
1291
+ *
1292
+ * Addressed by the channel's raw value (what they typed) and normalized here,
1293
+ * so a host passing `07700 900000` and a host passing `+447700900000` reach
1294
+ * the same contact point — consent landing on a second, differently-spelled
1295
+ * row is an opt-out that no sender would ever find.
1296
+ *
1297
+ * A channel the party does not hold is a typed error rather than a silent
1298
+ * no-op: a grant that went nowhere looks identical to one that was stored,
1299
+ * and the caller is usually a booking form that will never check again.
1300
+ */
1301
+ async recordConsent(id, input, ctx) {
1302
+ const party = await this.loadParty(id, ctx);
1303
+ const canonical = input.channel === "email" ? normalizeEmail(input.value) : normalizePhone$1(input.value, this.normalize);
1304
+ const key = `${input.channel}:${canonical}`;
1305
+ const index = party.contactPoints.findIndex((point) => point.key === key);
1306
+ if (index < 0) throw new PartyValidationError(`Party ${id} has no ${input.channel} contact point for ${canonical}; consent would be unreachable.`);
1307
+ const consent = {
1308
+ status: input.status,
1309
+ at: input.at ?? /* @__PURE__ */ new Date(),
1310
+ ...input.source !== void 0 ? { source: input.source } : {},
1311
+ ...input.status === "granted" && input.ip !== void 0 ? { ip: input.ip } : {}
1312
+ };
1313
+ const updated = await this.mustUpdate(party._id, { $set: { [`contactPoints.${index}.consent`]: consent } }, ctx);
1314
+ await this.dispatch(PARTY_EVENTS.CONSENT_RECORDED, {
1315
+ partyId: String(party._id),
1316
+ channel: input.channel,
1317
+ value: canonical,
1318
+ status: input.status,
1319
+ ...consent.source !== void 0 ? { source: consent.source } : {}
1320
+ }, updated, ctx);
1321
+ return updated;
1322
+ }
1065
1323
  async attachExternalRef(id, ref, ctx) {
1066
1324
  const party = await this.loadParty(id, ctx);
1067
1325
  if (party.externalRefs.some((r) => r.sourceModel === ref.sourceModel && r.sourceId === ref.sourceId)) return party;
@@ -1143,12 +1401,16 @@ var PartyRepository = class extends Repository {
1143
1401
  const mergedAddresses = unionAddresses(target.addresses, source.addresses);
1144
1402
  const mergedTags = unionStrings(target.tags, source.tags);
1145
1403
  const mergedMetadata = unionMetadata(target.metadata, source.metadata);
1404
+ const relKey = (r) => `${r.kind}|${String(r.relatedTo ?? "")}|${new Date(r.from).toISOString()}`;
1405
+ const ownRel = new Set(target.relationships.map(relKey));
1406
+ const mergedRelationships = [...target.relationships, ...source.relationships.filter((r) => !ownRel.has(relKey(r)))];
1146
1407
  const absorbed = mergedKeys.length - target.identityKeys.length;
1147
1408
  const fill = {};
1148
1409
  if (target.taxIdentity === void 0 && source.taxIdentity !== void 0) fill.taxIdentity = source.taxIdentity;
1149
1410
  if (target.personName === void 0 && source.personName !== void 0) fill.personName = source.personName;
1150
1411
  if (!target.legalName?.trim() && source.legalName?.trim()) fill.legalName = source.legalName;
1151
1412
  if (target.parentId === void 0 && source.parentId !== void 0) fill.parentId = source.parentId;
1413
+ if (target.legalForm === void 0 && source.legalForm !== void 0) fill.legalForm = source.legalForm;
1152
1414
  try {
1153
1415
  await withTransaction(this.Model.db, async (session) => {
1154
1416
  if (!await this.claim(String(source._id), {
@@ -1169,6 +1431,7 @@ var PartyRepository = class extends Repository {
1169
1431
  contactPoints: mergedContacts,
1170
1432
  addresses: mergedAddresses,
1171
1433
  tags: mergedTags,
1434
+ relationships: mergedRelationships,
1172
1435
  ...fill,
1173
1436
  ...mergedMetadata !== void 0 ? { metadata: mergedMetadata } : {}
1174
1437
  } }, {
@@ -1222,6 +1485,7 @@ var PartyRepository = class extends Repository {
1222
1485
  }), ctx);
1223
1486
  }
1224
1487
  };
1488
+ const overlaps = (aFrom, aTo, bFrom, bTo) => aFrom < (bTo ?? /* @__PURE__ */ new Date(864e13)) && bFrom < (aTo ?? /* @__PURE__ */ new Date(864e13));
1225
1489
  //#endregion
1226
1490
  //#region src/engine/ensure-ready.ts
1227
1491
  /**
@@ -1519,6 +1783,13 @@ const userLinkedSchema = z.object({
1519
1783
  partyId: z.string(),
1520
1784
  userId: z.string()
1521
1785
  });
1786
+ const consentRecordedSchema = z.object({
1787
+ partyId: z.string(),
1788
+ channel: z.enum(["email", "phone"]),
1789
+ value: z.string(),
1790
+ status: z.enum(["granted", "withdrawn"]),
1791
+ source: z.string().optional()
1792
+ });
1522
1793
  const externalRefSchema = z.object({
1523
1794
  partyId: z.string(),
1524
1795
  sourceModel: z.string(),
@@ -1530,52 +1801,87 @@ const mergedSchema = z.object({
1530
1801
  targetId: z.string(),
1531
1802
  absorbedKeys: z.number().int().nonnegative()
1532
1803
  });
1804
+ const PartyCreated = definePartyEvent({
1805
+ name: PARTY_EVENTS.CREATED,
1806
+ description: "A new canonical party identity was created.",
1807
+ zodSchema: createdSchema
1808
+ });
1809
+ const PartyUpdated = definePartyEvent({
1810
+ name: PARTY_EVENTS.UPDATED,
1811
+ description: "A party identity field changed.",
1812
+ zodSchema: updatedSchema
1813
+ });
1814
+ const PartyRoleAdded = definePartyEvent({
1815
+ name: PARTY_EVENTS.ROLE_ADDED,
1816
+ description: "A role (customer/supplier/…) was granted to a party.",
1817
+ zodSchema: roleChangedSchema
1818
+ });
1819
+ const PartyRoleRemoved = definePartyEvent({
1820
+ name: PARTY_EVENTS.ROLE_REMOVED,
1821
+ description: "A role was revoked from a party.",
1822
+ zodSchema: roleChangedSchema
1823
+ });
1824
+ const PartyUserLinked = definePartyEvent({
1825
+ name: PARTY_EVENTS.USER_LINKED,
1826
+ description: "An auth user was linked to a party (1:many).",
1827
+ zodSchema: userLinkedSchema
1828
+ });
1829
+ const PartyConsentRecorded = definePartyEvent({
1830
+ name: PARTY_EVENTS.CONSENT_RECORDED,
1831
+ description: "A marketing-consent decision was recorded against one contact channel.",
1832
+ zodSchema: consentRecordedSchema
1833
+ });
1834
+ const PartyExternalRefAttached = definePartyEvent({
1835
+ name: PARTY_EVENTS.EXTERNAL_REF_ATTACHED,
1836
+ description: "An external-store reference was attached to a party (facet link).",
1837
+ zodSchema: externalRefSchema
1838
+ });
1839
+ const PartyArchived = definePartyEvent({
1840
+ name: PARTY_EVENTS.ARCHIVED,
1841
+ description: "A party was archived.",
1842
+ zodSchema: lifecycleSchema
1843
+ });
1844
+ const PartyRestored = definePartyEvent({
1845
+ name: PARTY_EVENTS.RESTORED,
1846
+ description: "An archived party was restored to active.",
1847
+ zodSchema: lifecycleSchema
1848
+ });
1849
+ const PartyMerged = definePartyEvent({
1850
+ name: PARTY_EVENTS.MERGED,
1851
+ description: "A duplicate party was merged into a survivor; the source becomes a redirect.",
1852
+ zodSchema: mergedSchema
1853
+ });
1854
+ const relationshipChangedSchema = z.object({
1855
+ partyId: z.string(),
1856
+ relationshipId: z.string(),
1857
+ kind: z.enum([
1858
+ "shareholder",
1859
+ "director",
1860
+ "key_management",
1861
+ "partner_in_firm",
1862
+ "member",
1863
+ "employee",
1864
+ "lessor",
1865
+ "associate"
1866
+ ]),
1867
+ change: z.enum(["added", "ended"])
1868
+ });
1533
1869
  const partyEventDefinitions = [
1870
+ PartyCreated,
1871
+ PartyUpdated,
1872
+ PartyRoleAdded,
1873
+ PartyRoleRemoved,
1874
+ PartyUserLinked,
1875
+ PartyConsentRecorded,
1876
+ PartyExternalRefAttached,
1877
+ PartyArchived,
1878
+ PartyRestored,
1879
+ PartyMerged,
1534
1880
  definePartyEvent({
1535
- name: PARTY_EVENTS.CREATED,
1536
- description: "A new canonical party identity was created.",
1537
- zodSchema: createdSchema
1538
- }),
1539
- definePartyEvent({
1540
- name: PARTY_EVENTS.UPDATED,
1541
- description: "A party identity field changed.",
1542
- zodSchema: updatedSchema
1543
- }),
1544
- definePartyEvent({
1545
- name: PARTY_EVENTS.ROLE_ADDED,
1546
- description: "A role (customer/supplier/…) was granted to a party.",
1547
- zodSchema: roleChangedSchema
1548
- }),
1549
- definePartyEvent({
1550
- name: PARTY_EVENTS.ROLE_REMOVED,
1551
- description: "A role was revoked from a party.",
1552
- zodSchema: roleChangedSchema
1553
- }),
1554
- definePartyEvent({
1555
- name: PARTY_EVENTS.USER_LINKED,
1556
- description: "An auth user was linked to a party (1:many).",
1557
- zodSchema: userLinkedSchema
1558
- }),
1559
- definePartyEvent({
1560
- name: PARTY_EVENTS.EXTERNAL_REF_ATTACHED,
1561
- description: "An external-store reference was attached to a party (facet link).",
1562
- zodSchema: externalRefSchema
1563
- }),
1564
- definePartyEvent({
1565
- name: PARTY_EVENTS.ARCHIVED,
1566
- description: "A party was archived.",
1567
- zodSchema: lifecycleSchema
1568
- }),
1569
- definePartyEvent({
1570
- name: PARTY_EVENTS.RESTORED,
1571
- description: "An archived party was restored to active.",
1572
- zodSchema: lifecycleSchema
1573
- }),
1574
- definePartyEvent({
1575
- name: PARTY_EVENTS.MERGED,
1576
- description: "A duplicate party was merged into a survivor; the source becomes a redirect.",
1577
- zodSchema: mergedSchema
1881
+ name: PARTY_EVENTS.RELATIONSHIP_CHANGED,
1882
+ description: "A dated relationship of the party (shareholder, director, lessor, …) was added or ended.",
1883
+ zodSchema: relationshipChangedSchema
1578
1884
  })
1579
1885
  ];
1580
1886
  //#endregion
1581
- export { ADDRESS_TYPES, CONTACT_POINT_TYPES, InProcessPartyBus, PARTY_EVENTS, PARTY_KINDS, PARTY_ROLES, PARTY_STATE_MACHINE, PARTY_STATUSES, PARTY_SUBJECT_MODEL, PENDING_EVENTS, PartyError, PartyEventDispatcher, PartyMergeError, PartyNotFoundError, PartyPhoneFormatError, PartyRepository, PartyResolveConflictError, PartyValidationError, PartyVersionConflictError, buildContactPoints, buildPartySchema, contactPointKey, createEvent, dedupe, defineParty, definePartyModels, deriveIdentityKeys, flushPending, isPartySubject, normalizeEmail, partyEventDefinitions, partySubject, pickPrimary, repoOptionsFromCtx, resolveDisplayName, taxIdentityKey, tenantFieldDef, unionContacts, unionRefs, unionStrings };
1887
+ export { ADDRESS_TYPES, CONSENT_STATUSES, CONTACT_POINT_TYPES, InProcessPartyBus, PARTY_EVENTS, PARTY_KINDS, PARTY_ROLES, PARTY_STATE_MACHINE, PARTY_STATUSES, PARTY_SUBJECT_MODEL, PENDING_EVENTS, PartyError, PartyEventDispatcher, PartyMergeError, PartyNotFoundError, PartyPhoneFormatError, PartyRelationshipError, PartyRepository, PartyResolveConflictError, PartyValidationError, PartyVersionConflictError, RELATIONSHIP_KINDS, buildContactPoints, buildPartySchema, contactPointKey, createEvent, dedupe, defineParty, definePartyModels, deriveIdentityKeys, flushPending, isPartySubject, normalizeEmail, partyEventDefinitions, partySubject, pickPrimary, reconcileConsent, repoOptionsFromCtx, resolveDisplayName, taxIdentityKey, tenantFieldDef, unionContacts, unionRefs, unionStrings };
@@ -5,6 +5,18 @@ import "@classytic/primitives/reference";
5
5
  /** Channel kind for a contact point. Drives normalization + dedup keys. */
6
6
  declare const CONTACT_POINT_TYPES: readonly ['email', 'phone'];
7
7
  type ContactPointType = (typeof CONTACT_POINT_TYPES)[number];
8
+ /**
9
+ * Whether this channel may be used for MARKETING. Transactional messages (your
10
+ * booking is confirmed, your invoice is due) need no consent and are not what
11
+ * this records.
12
+ *
13
+ * Two states only, and `withdrawn` is deliberately not "absent": a channel that
14
+ * was never asked and a channel someone opted OUT of are the same silence to a
15
+ * sender but not to a regulator, and only the second one must survive a merge,
16
+ * a re-import, or the person booking again.
17
+ */
18
+ declare const CONSENT_STATUSES: readonly ['granted', 'withdrawn'];
19
+ type ConsentStatus = (typeof CONSENT_STATUSES)[number];
8
20
  //#endregion
9
21
  //#region src/domain/value-objects.d.ts
10
22
  /**
@@ -33,6 +45,45 @@ interface ContactPoint {
33
45
  input?: string;
34
46
  isPrimary: boolean;
35
47
  verified: boolean;
48
+ /**
49
+ * Permission to MARKET to this channel, and the proof of it. Absent means
50
+ * never asked — which is a refusal to a sender, so treat it as one.
51
+ *
52
+ * Here rather than in a CRM package because consent is a property of the
53
+ * CHANNEL, exactly like `verified`: it is the answer to "may I mail this
54
+ * address", not a domain opinion about the person. Campaign subscriptions,
55
+ * sequences, suppression lists and unsubscribe tokens stay in CRM keyed by
56
+ * `partyId` — this is the one fact they all have to agree with, and a sender
57
+ * that disagrees with it is the bug this field exists to make visible.
58
+ *
59
+ * In a multi-tenant deployment a party row is already per-organization, so
60
+ * this is one business's permission and never a platform-wide one — which is
61
+ * what "specific and informed" requires.
62
+ */
63
+ consent?: ChannelConsent;
64
+ }
65
+ /**
66
+ * A consent decision, with what a regulator asks for when they ask you to
67
+ * prove it: what was decided, WHEN, and where the person was standing.
68
+ *
69
+ * Immutable by convention — a change of mind is a new record replacing this
70
+ * one, never an edit, so `at` always dates the CURRENT status.
71
+ */
72
+ interface ChannelConsent {
73
+ status: ConsentStatus;
74
+ /** The moment the person decided. */
75
+ at: Date;
76
+ /**
77
+ * The surface that asked — `'booking-form'`, `'dashboard'`, `'import:2019-mailchimp'`.
78
+ * Free text on purpose: the kernel cannot enumerate a host's screens, and an
79
+ * imported consent whose origin is unrecorded is one you cannot defend.
80
+ */
81
+ source?: string;
82
+ /**
83
+ * Caller IP at the moment of GRANT, when the host has one. Never backfilled:
84
+ * an IP attached later proves nothing and is then just retained PII.
85
+ */
86
+ ip?: string;
36
87
  }
37
88
  //#endregion
38
89
  //#region src/maintenance/phone-identity-repair.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/party",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Engine-factory Party / Business-Partner identity kernel — one canonical party (person | company | group) carrying customer/supplier/contact/employee roles, contact points, addresses, B2B hierarchy, tax identity, and 1:many auth-user links. Dedup + merge + resolve. SAP-BP shape (thin identity + domain facets), NOT an Odoo god-object. PACKAGE_RULES compliant; Mongoose + mongokit + arc primitives.",
5
5
  "author": "Classytic",
6
6
  "license": "MIT",
@@ -14,18 +14,22 @@
14
14
  },
15
15
  "exports": {
16
16
  ".": {
17
+ "@classytic/source": "./src/index.ts",
17
18
  "types": "./dist/index.d.ts",
18
19
  "default": "./dist/index.js"
19
20
  },
20
21
  "./events": {
22
+ "@classytic/source": "./src/events/index.ts",
21
23
  "types": "./dist/events.d.ts",
22
24
  "default": "./dist/events.js"
23
25
  },
24
26
  "./maintenance": {
27
+ "@classytic/source": "./src/maintenance/phone-identity-repair.ts",
25
28
  "types": "./dist/maintenance.d.ts",
26
29
  "default": "./dist/maintenance.js"
27
30
  },
28
31
  "./schemas": {
32
+ "@classytic/source": "./src/schemas/index.ts",
29
33
  "types": "./dist/schemas.d.ts",
30
34
  "default": "./dist/schemas.js"
31
35
  },
@@ -61,21 +65,21 @@
61
65
  "mongodb": "7.2.0"
62
66
  },
63
67
  "peerDependencies": {
64
- "@classytic/mongokit": ">=3.33.0",
65
- "@classytic/contact": ">=0.1.0",
66
- "@classytic/primitives": ">=0.23.0",
67
- "@classytic/repo-core": ">=0.24.0",
68
- "mongoose": ">=9.4.1",
68
+ "@classytic/mongokit": ">=3.41.0",
69
+ "@classytic/contact": ">=0.2.0",
70
+ "@classytic/primitives": ">=0.31.1",
71
+ "@classytic/repo-core": ">=0.31.0",
72
+ "mongoose": ">=9.11.0",
69
73
  "zod": ">=4.0.0"
70
74
  },
71
75
  "devDependencies": {
72
- "@classytic/mongokit": "^3.33.0",
73
- "@classytic/contact": ">=0.1.0",
74
- "@classytic/primitives": ">=0.23.0",
75
- "@classytic/repo-core": ">=0.24.0",
76
+ "@classytic/mongokit": "^3.41.0",
77
+ "@classytic/contact": ">=0.2.0",
78
+ "@classytic/primitives": ">=0.31.1",
79
+ "@classytic/repo-core": ">=0.31.0",
76
80
  "@types/node": "^25.5.0",
77
81
  "mongodb-memory-server": "^10.1.4",
78
- "mongoose": "^9.4.1",
82
+ "mongoose": "^9.11.1",
79
83
  "publint": "^0.3.23",
80
84
  "tsdown": "^0.22.14",
81
85
  "typescript": "^7.0.2",