@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 +24 -0
- package/dist/events.d.ts +43 -1
- package/dist/events.js +39 -3
- package/dist/index.d.ts +158 -1
- package/dist/index.js +361 -55
- package/dist/maintenance.d.ts +51 -0
- package/package.json +15 -11
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 {
|
|
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
|
|
797
|
-
const
|
|
798
|
-
for (const c of b)
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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.
|
|
1536
|
-
description: "A
|
|
1537
|
-
zodSchema:
|
|
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 };
|
package/dist/maintenance.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
65
|
-
"@classytic/contact": ">=0.
|
|
66
|
-
"@classytic/primitives": ">=0.
|
|
67
|
-
"@classytic/repo-core": ">=0.
|
|
68
|
-
"mongoose": ">=9.
|
|
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.
|
|
73
|
-
"@classytic/contact": ">=0.
|
|
74
|
-
"@classytic/primitives": ">=0.
|
|
75
|
-
"@classytic/repo-core": ">=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.
|
|
82
|
+
"mongoose": "^9.11.1",
|
|
79
83
|
"publint": "^0.3.23",
|
|
80
84
|
"tsdown": "^0.22.14",
|
|
81
85
|
"typescript": "^7.0.2",
|