@classytic/party 0.1.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 +83 -0
- package/LICENSE +1 -1
- package/README.md +29 -4
- package/dist/events.d.ts +46 -9
- package/dist/events.js +60 -34
- package/dist/index.d.ts +410 -90
- package/dist/index.js +1000 -382
- package/dist/maintenance.d.ts +216 -0
- package/dist/maintenance.js +353 -0
- package/package.json +21 -10
package/dist/index.js
CHANGED
|
@@ -1,80 +1,14 @@
|
|
|
1
|
-
import { Repository, isDuplicateKeyError, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
|
|
2
|
-
import {
|
|
1
|
+
import { Repository, defineModels, isDuplicateKeyError, multiTenantPlugin, transactionResolutionOf, withTransaction } from "@classytic/mongokit";
|
|
2
|
+
import { assertNoLegacyTenantKeys, resolveTenantConfig } from "@classytic/repo-core/tenant";
|
|
3
|
+
import { InProcessEventBus } from "@classytic/primitives/event-infra";
|
|
4
|
+
import { LEGAL_FORMS } from "@classytic/primitives/legal-form";
|
|
5
|
+
import { Schema, Types } from "mongoose";
|
|
3
6
|
import { defineStateMachine } from "@classytic/primitives/state-machine";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { createEvent as createEvent$1
|
|
7
|
+
import { normalizeEmail as normalizeEmail$1 } from "@classytic/contact/email";
|
|
8
|
+
import { PhoneFormatError, normalizePhone } from "@classytic/contact/phone";
|
|
9
|
+
import { createEvent as createEvent$1 } from "@classytic/primitives/events";
|
|
10
|
+
import { idToString } from "@classytic/primitives/reference";
|
|
7
11
|
import { z } from "zod";
|
|
8
|
-
//#region src/domain/errors.ts
|
|
9
|
-
/** Base class so hosts can `instanceof PartyError` across every party throwable. */
|
|
10
|
-
var PartyError = class extends Error {
|
|
11
|
-
status;
|
|
12
|
-
code;
|
|
13
|
-
meta;
|
|
14
|
-
constructor(status, code, message, meta) {
|
|
15
|
-
super(message);
|
|
16
|
-
this.name = new.target.name;
|
|
17
|
-
this.status = status;
|
|
18
|
-
this.code = code;
|
|
19
|
-
if (meta !== void 0) this.meta = meta;
|
|
20
|
-
}
|
|
21
|
-
};
|
|
22
|
-
/** A referenced party id does not exist (or is out of the caller's scope). */
|
|
23
|
-
var PartyNotFoundError = class extends PartyError {
|
|
24
|
-
constructor(id) {
|
|
25
|
-
super(404, "party.not_found", `Party "${id}" not found.`, { partyId: id });
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
/**
|
|
29
|
-
* A merge was attempted against an illegal target — self-merge, merging into an
|
|
30
|
-
* already-merged (redirected) party, or a cross-scope pair. Carries both ids.
|
|
31
|
-
*/
|
|
32
|
-
var PartyMergeError = class extends PartyError {
|
|
33
|
-
constructor(message, sourceId, targetId) {
|
|
34
|
-
super(409, "party.merge_conflict", message, {
|
|
35
|
-
sourceId,
|
|
36
|
-
targetId
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
/**
|
|
41
|
-
* `resolveOrCreate` lost the create race and the winning row could not be
|
|
42
|
-
* re-read (should be transient — surfaced rather than swallowed).
|
|
43
|
-
*/
|
|
44
|
-
var PartyResolveConflictError = class extends PartyError {
|
|
45
|
-
constructor(identityKey) {
|
|
46
|
-
super(409, "party.resolve_conflict", `Concurrent create for identity "${identityKey}" could not be reconciled.`, { identityKey });
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
/**
|
|
50
|
-
* Optimistic-concurrency guard failed — the party was modified by another
|
|
51
|
-
* writer between read and write. Callers re-fetch and retry.
|
|
52
|
-
*/
|
|
53
|
-
var PartyVersionConflictError = class extends PartyError {
|
|
54
|
-
constructor(id, expected) {
|
|
55
|
-
super(409, "party.version_conflict", `Party "${id}" was modified concurrently (expected v${expected}).`, {
|
|
56
|
-
partyId: id,
|
|
57
|
-
expectedVersion: expected
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
/**
|
|
62
|
-
* A Mongoose model name this package registers is already on the connection.
|
|
63
|
-
* Default init throws this; `forceRecreate: true` opts into delete+re-register
|
|
64
|
-
* (test fixtures / hot reload only). PACKAGE_RULES §21.
|
|
65
|
-
*/
|
|
66
|
-
var PartyModelCollisionError = class extends PartyError {
|
|
67
|
-
constructor(modelName) {
|
|
68
|
-
super(500, "party.model_collision", `Mongoose model "${modelName}" is already registered on this connection. Another @classytic/party engine or a stale hot-reload owns it. Pass forceRecreate: true (tests / hot reload only) or use a dedicated connection.`, { modelName });
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
/** Input failed a domain-verb precondition (before any DB write). */
|
|
72
|
-
var PartyValidationError = class extends PartyError {
|
|
73
|
-
constructor(message, meta) {
|
|
74
|
-
super(400, "party.validation", message, meta);
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
//#endregion
|
|
78
12
|
//#region src/domain/enums.ts
|
|
79
13
|
/**
|
|
80
14
|
* Party-native enumerations.
|
|
@@ -116,6 +50,17 @@ const PARTY_STATUSES = [
|
|
|
116
50
|
/** Channel kind for a contact point. Drives normalization + dedup keys. */
|
|
117
51
|
const CONTACT_POINT_TYPES = ["email", "phone"];
|
|
118
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
|
+
/**
|
|
119
64
|
* Address role. Mirrors Odoo's `type` (contact/invoice/delivery/other) but
|
|
120
65
|
* as a first-class enum. A party carries many; `isDefault` picks the fallback.
|
|
121
66
|
*/
|
|
@@ -125,6 +70,107 @@ const ADDRESS_TYPES = [
|
|
|
125
70
|
"contact",
|
|
126
71
|
"other"
|
|
127
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
|
+
];
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/events/dispatch.ts
|
|
90
|
+
/** Context key carrying a post-commit publish queue (attached by `withTransaction`). */
|
|
91
|
+
const PENDING_EVENTS = Symbol.for("@classytic/party:pendingEvents");
|
|
92
|
+
var PartyEventDispatcher = class {
|
|
93
|
+
name;
|
|
94
|
+
transport;
|
|
95
|
+
outbox;
|
|
96
|
+
logger;
|
|
97
|
+
constructor(opts) {
|
|
98
|
+
this.transport = opts.transport;
|
|
99
|
+
this.outbox = opts.outbox;
|
|
100
|
+
this.logger = opts.logger;
|
|
101
|
+
this.name = opts.transport.name;
|
|
102
|
+
}
|
|
103
|
+
/** Raw publish — hosts and the unit-of-work flush use this. */
|
|
104
|
+
publish(event) {
|
|
105
|
+
return this.transport.publish(event);
|
|
106
|
+
}
|
|
107
|
+
subscribe(pattern, handler) {
|
|
108
|
+
const subscribe = this.transport.subscribe?.bind(this.transport);
|
|
109
|
+
if (!subscribe) throw new Error("party: configured event transport does not support subscribe");
|
|
110
|
+
return subscribe(pattern, handler);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Route a domain event with full §P8 discipline. Reads the pending queue and
|
|
114
|
+
* session off `ctx`.
|
|
115
|
+
*/
|
|
116
|
+
async dispatch(event, ctx) {
|
|
117
|
+
if (this.outbox) try {
|
|
118
|
+
await this.outbox.save(event, ctx.session !== void 0 ? { session: ctx.session } : {});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
this.logger?.error?.(`[party] outbox.save failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
const queue = ctx[PENDING_EVENTS];
|
|
124
|
+
if (Array.isArray(queue)) {
|
|
125
|
+
queue.push(event);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await this.transport.publish(event);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
this.logger?.error?.(`[party] publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async close() {
|
|
135
|
+
await this.transport.close?.();
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Drain a pending-events queue to the transport AFTER the owning transaction
|
|
140
|
+
* commits. Per-event failures are swallowed — durable delivery is the outbox's
|
|
141
|
+
* job (PACKAGE_RULES §P8.1).
|
|
142
|
+
*/
|
|
143
|
+
async function flushPending(dispatcher, queue, logger) {
|
|
144
|
+
for (const event of queue) try {
|
|
145
|
+
await dispatcher.publish(event);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
logger?.error?.(`[party] post-commit publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/events/in-process-bus.ts
|
|
152
|
+
/**
|
|
153
|
+
* In-process event bus — default fallback when no `eventTransport` is given to
|
|
154
|
+
* `createParty`.
|
|
155
|
+
*
|
|
156
|
+
* A thin subclass of `@classytic/primitives/event-infra`'s shared
|
|
157
|
+
* `InProcessEventBus`. Prior semantics preserved: glob matching via
|
|
158
|
+
* `matchEventPattern`, per-handler error isolation, idempotent `close()`.
|
|
159
|
+
* Party historically SWALLOWED handler errors when no logger was supplied —
|
|
160
|
+
* that default is preserved by passing `logger: null` unless the host injects
|
|
161
|
+
* a `PartyLogger`, whose structured `error(msg, meta)` slot is bridged onto
|
|
162
|
+
* the shared error-logger contract.
|
|
163
|
+
*/
|
|
164
|
+
var InProcessPartyBus = class extends InProcessEventBus {
|
|
165
|
+
constructor(options) {
|
|
166
|
+
const partyLogger = options?.logger;
|
|
167
|
+
super({
|
|
168
|
+
name: "in-process-party",
|
|
169
|
+
logLabel: "party",
|
|
170
|
+
logger: partyLogger?.error ? { error: (message, ...args) => partyLogger.error?.(message, { args }) } : null
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
};
|
|
128
174
|
//#endregion
|
|
129
175
|
//#region src/models/tenant-field.ts
|
|
130
176
|
/**
|
|
@@ -144,15 +190,15 @@ const ADDRESS_TYPES = [
|
|
|
144
190
|
* company-wide (`multiTenant: false`) so the field is simply not added.
|
|
145
191
|
*/
|
|
146
192
|
function tenantFieldDef(config) {
|
|
147
|
-
if (!config.
|
|
148
|
-
return config.
|
|
193
|
+
if (!config.tenant.enabled) return void 0;
|
|
194
|
+
return config.tenant.fieldType === "objectId" ? {
|
|
149
195
|
type: Schema.Types.ObjectId,
|
|
150
|
-
required:
|
|
151
|
-
ref:
|
|
196
|
+
required: config.tenant.required,
|
|
197
|
+
ref: config.tenant.ref,
|
|
152
198
|
index: true
|
|
153
199
|
} : {
|
|
154
200
|
type: String,
|
|
155
|
-
required:
|
|
201
|
+
required: config.tenant.required,
|
|
156
202
|
index: true
|
|
157
203
|
};
|
|
158
204
|
}
|
|
@@ -202,6 +248,27 @@ const personNameSchema = new Schema({
|
|
|
202
248
|
maxlength: 120
|
|
203
249
|
}
|
|
204
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 });
|
|
205
272
|
const contactPointSchema = new Schema({
|
|
206
273
|
type: {
|
|
207
274
|
type: String,
|
|
@@ -218,6 +285,11 @@ const contactPointSchema = new Schema({
|
|
|
218
285
|
type: String,
|
|
219
286
|
required: true
|
|
220
287
|
},
|
|
288
|
+
input: {
|
|
289
|
+
type: String,
|
|
290
|
+
trim: true,
|
|
291
|
+
maxlength: 320
|
|
292
|
+
},
|
|
221
293
|
isPrimary: {
|
|
222
294
|
type: Boolean,
|
|
223
295
|
default: false
|
|
@@ -225,7 +297,8 @@ const contactPointSchema = new Schema({
|
|
|
225
297
|
verified: {
|
|
226
298
|
type: Boolean,
|
|
227
299
|
default: false
|
|
228
|
-
}
|
|
300
|
+
},
|
|
301
|
+
consent: { type: channelConsentSchema }
|
|
229
302
|
}, { _id: false });
|
|
230
303
|
const addressSchema = new Schema({
|
|
231
304
|
type: {
|
|
@@ -314,6 +387,44 @@ const taxIdentitySchema = new Schema({
|
|
|
314
387
|
maxlength: 2
|
|
315
388
|
}
|
|
316
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
|
+
});
|
|
317
428
|
const externalRefSchema$1 = new Schema({
|
|
318
429
|
sourceId: {
|
|
319
430
|
type: String,
|
|
@@ -327,7 +438,7 @@ const externalRefSchema$1 = new Schema({
|
|
|
327
438
|
function buildPartySchema(config) {
|
|
328
439
|
const tenantField = tenantFieldDef(config);
|
|
329
440
|
const schema = new Schema({
|
|
330
|
-
...tenantField ? {
|
|
441
|
+
...tenantField ? { [config.tenant.tenantField]: tenantField } : {},
|
|
331
442
|
kind: {
|
|
332
443
|
type: String,
|
|
333
444
|
enum: PARTY_KINDS,
|
|
@@ -390,6 +501,14 @@ function buildPartySchema(config) {
|
|
|
390
501
|
type: [String],
|
|
391
502
|
default: []
|
|
392
503
|
},
|
|
504
|
+
legalForm: {
|
|
505
|
+
type: String,
|
|
506
|
+
enum: LEGAL_FORMS
|
|
507
|
+
},
|
|
508
|
+
relationships: {
|
|
509
|
+
type: [relationshipSchema],
|
|
510
|
+
default: []
|
|
511
|
+
},
|
|
393
512
|
metadata: { type: Schema.Types.Mixed },
|
|
394
513
|
version: {
|
|
395
514
|
type: Number,
|
|
@@ -400,11 +519,14 @@ function buildPartySchema(config) {
|
|
|
400
519
|
toJSON: { virtuals: true },
|
|
401
520
|
toObject: { virtuals: true }
|
|
402
521
|
});
|
|
403
|
-
const scoped = (fields) => config.
|
|
404
|
-
|
|
522
|
+
const scoped = (fields) => config.tenant.enabled ? {
|
|
523
|
+
[config.tenant.tenantField]: 1,
|
|
405
524
|
...fields
|
|
406
525
|
} : fields;
|
|
407
|
-
schema.index(scoped({ identityKeys: 1 }), {
|
|
526
|
+
schema.index(scoped({ identityKeys: 1 }), {
|
|
527
|
+
unique: true,
|
|
528
|
+
partialFilterExpression: { "identityKeys.0": { $exists: true } }
|
|
529
|
+
});
|
|
408
530
|
schema.index(scoped({
|
|
409
531
|
roles: 1,
|
|
410
532
|
status: 1
|
|
@@ -412,6 +534,10 @@ function buildPartySchema(config) {
|
|
|
412
534
|
schema.index(scoped({ createdAt: -1 }));
|
|
413
535
|
schema.index(scoped({ parentId: 1 }));
|
|
414
536
|
schema.index(scoped({ userLinks: 1 }));
|
|
537
|
+
schema.index(scoped({
|
|
538
|
+
"relationships.kind": 1,
|
|
539
|
+
"relationships.relatedTo": 1
|
|
540
|
+
}));
|
|
415
541
|
schema.index({
|
|
416
542
|
"externalRefs.sourceModel": 1,
|
|
417
543
|
"externalRefs.sourceId": 1
|
|
@@ -428,21 +554,127 @@ function buildPartySchema(config) {
|
|
|
428
554
|
}
|
|
429
555
|
//#endregion
|
|
430
556
|
//#region src/models/create-models.ts
|
|
431
|
-
/**
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
557
|
+
/**
|
|
558
|
+
* Party model specification — the ONE authoritative place the Party model is described.
|
|
559
|
+
*
|
|
560
|
+
* `definePartyModels(config)` returns a connection-free mongokit `ModelBlueprint`; `defineParty`
|
|
561
|
+
* binds it during engine construction. Collision handling, auto-index and (future) collection
|
|
562
|
+
* config live here once (PACKAGE_RULES §20/§21). A name collision surfaces as mongokit's typed
|
|
563
|
+
* `ModelCollisionError`.
|
|
564
|
+
*/
|
|
565
|
+
/**
|
|
566
|
+
* Describe the Party model set as a connection-free blueprint. Pure — builds no schema and
|
|
567
|
+
* registers nothing until `.bind(connection)`.
|
|
568
|
+
*/
|
|
569
|
+
function definePartyModels(config) {
|
|
435
570
|
const autoIndex = config.autoIndex ?? true;
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
571
|
+
return defineModels({
|
|
572
|
+
models: [{
|
|
573
|
+
name: "Party",
|
|
574
|
+
schema: () => {
|
|
575
|
+
const schema = buildPartySchema(config);
|
|
576
|
+
schema.set("autoIndex", autoIndex);
|
|
577
|
+
return schema;
|
|
578
|
+
},
|
|
579
|
+
existing: config.forceRecreate ? {
|
|
580
|
+
mode: "replace",
|
|
581
|
+
environment: "test"
|
|
582
|
+
} : { mode: "throw" }
|
|
583
|
+
}],
|
|
584
|
+
assemble: (models) => Object.freeze({ Party: models.get("Party") })
|
|
585
|
+
});
|
|
444
586
|
}
|
|
445
587
|
//#endregion
|
|
588
|
+
//#region src/domain/errors.ts
|
|
589
|
+
/** Base class so hosts can `instanceof PartyError` across every party throwable. */
|
|
590
|
+
var PartyError = class extends Error {
|
|
591
|
+
status;
|
|
592
|
+
code;
|
|
593
|
+
meta;
|
|
594
|
+
constructor(status, code, message, meta) {
|
|
595
|
+
super(message);
|
|
596
|
+
this.name = new.target.name;
|
|
597
|
+
this.status = status;
|
|
598
|
+
this.code = code;
|
|
599
|
+
if (meta !== void 0) this.meta = meta;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
/** A referenced party id does not exist (or is out of the caller's scope). */
|
|
603
|
+
var PartyNotFoundError = class extends PartyError {
|
|
604
|
+
constructor(id) {
|
|
605
|
+
super(404, "party.not_found", `Party "${id}" not found.`, { partyId: id });
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
/**
|
|
609
|
+
* A merge was attempted against an illegal target — self-merge, merging into an
|
|
610
|
+
* already-merged (redirected) party, or a cross-scope pair. Carries both ids.
|
|
611
|
+
*/
|
|
612
|
+
var PartyMergeError = class extends PartyError {
|
|
613
|
+
constructor(message, sourceId, targetId) {
|
|
614
|
+
super(409, "party.merge_conflict", message, {
|
|
615
|
+
sourceId,
|
|
616
|
+
targetId
|
|
617
|
+
});
|
|
618
|
+
}
|
|
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
|
+
};
|
|
626
|
+
/**
|
|
627
|
+
* `resolveOrCreate` lost the create race and the winning row could not be
|
|
628
|
+
* re-read (should be transient — surfaced rather than swallowed).
|
|
629
|
+
*/
|
|
630
|
+
var PartyResolveConflictError = class extends PartyError {
|
|
631
|
+
constructor(identityKey) {
|
|
632
|
+
super(409, "party.resolve_conflict", `Concurrent create for identity "${identityKey}" could not be reconciled.`, { identityKey });
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
/**
|
|
636
|
+
* Optimistic-concurrency guard failed — the party was modified by another
|
|
637
|
+
* writer between read and write. Callers re-fetch and retry.
|
|
638
|
+
*/
|
|
639
|
+
var PartyVersionConflictError = class extends PartyError {
|
|
640
|
+
constructor(id, expected) {
|
|
641
|
+
super(409, "party.version_conflict", `Party "${id}" was modified concurrently (expected v${expected}).`, {
|
|
642
|
+
partyId: id,
|
|
643
|
+
expectedVersion: expected
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
/** Input failed a domain-verb precondition (before any DB write). */
|
|
648
|
+
var PartyValidationError = class extends PartyError {
|
|
649
|
+
constructor(message, meta) {
|
|
650
|
+
super(400, "party.validation", message, meta);
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
/**
|
|
654
|
+
* A phone contact point could not be normalized to E.164 — so it cannot become
|
|
655
|
+
* an identity key.
|
|
656
|
+
*
|
|
657
|
+
* This REFUSES rather than degrades, and that is the whole point. The previous
|
|
658
|
+
* behaviour fell back to `+${digits}`, which produced keys like
|
|
659
|
+
* `phone:+01309000993` — neither a national number nor E.164. Two spellings of
|
|
660
|
+
* one member therefore keyed differently and `resolveOrCreate` forked the
|
|
661
|
+
* identity, silently: every screen looked correct and the membership history
|
|
662
|
+
* simply split in two (FAIL LOUD rule 2 — an input we do not understand must
|
|
663
|
+
* fail, not widen).
|
|
664
|
+
*
|
|
665
|
+
* `meta` names the offending value and the region it was interpreted in, so an
|
|
666
|
+
* operator can fix the data instead of guessing which field a 400 refers to.
|
|
667
|
+
*/
|
|
668
|
+
var PartyPhoneFormatError = class extends PartyError {
|
|
669
|
+
constructor(value, defaultRegion, detail) {
|
|
670
|
+
super(400, "party.phone_invalid", `Phone ${JSON.stringify(value)} is not a valid ${defaultRegion ?? "E.164"} number (${detail}). Identity requires an E.164 phone; supply a dialable number or configure the deployment region (\`defaultPhoneRegion\`) so national formats can be interpreted. It is NOT stored as typed — a raw spelling used as an identity key is how one person becomes several parties.`, {
|
|
671
|
+
value,
|
|
672
|
+
...defaultRegion !== void 0 ? { defaultRegion } : {},
|
|
673
|
+
detail
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
//#endregion
|
|
446
678
|
//#region src/domain/state-machine.ts
|
|
447
679
|
/**
|
|
448
680
|
* Party identity lifecycle FSM — the compile-time twin of the `repo.claim()`
|
|
@@ -471,32 +703,89 @@ const PARTY_STATE_MACHINE = defineStateMachine({
|
|
|
471
703
|
* SAME key regardless of casing/spacing/punctuation, so `resolveOrCreate` finds
|
|
472
704
|
* the existing party instead of forking a duplicate.
|
|
473
705
|
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
706
|
+
* ## Both rules live in `@classytic/contact`, imported DIRECTLY
|
|
707
|
+
*
|
|
708
|
+
* Neither implementation is here. Both are in `@classytic/contact`, a kernel
|
|
709
|
+
* below this one, and this file is a CALLER of it — not a re-export of it. That
|
|
710
|
+
* distinction is the whole point: the phone rule had three implementations (here,
|
|
711
|
+
* be-prod's `shared/phone.ts`, and hotel's), and a re-export would have been a
|
|
712
|
+
* fourth NAME for one rule, which is how the drift started. A consumer that wants
|
|
713
|
+
* to normalize a contact point imports `@classytic/contact/phone` or
|
|
714
|
+
* `@classytic/contact/email`; it does not reach through party.
|
|
715
|
+
*
|
|
716
|
+
* `@classytic/primitives/phone` deliberately does not do the phone work — it is a
|
|
717
|
+
* zero-dependency E.164 value object whose own docblock says *"deeper
|
|
718
|
+
* validation … is the host's call to layer on top"*. `@classytic/contact` IS that
|
|
719
|
+
* layer, and `ParsedPhone` extends the primitive rather than re-declaring it.
|
|
720
|
+
*
|
|
721
|
+
* ## What this file still owns: the WIRE contract
|
|
722
|
+
*
|
|
723
|
+
* `@classytic/contact` throws plain `Error` subclasses — it sits below every
|
|
724
|
+
* domain kernel and knows nothing about HTTP. Party owns a 400 /
|
|
725
|
+
* `party.phone_invalid` contract that arc serializes natively, so the boundary
|
|
726
|
+
* translation happens here, once, in the one function that derives the key.
|
|
727
|
+
*
|
|
728
|
+
* `PhoneRegionError` is deliberately NOT translated: a misconfigured
|
|
729
|
+
* `defaultPhoneRegion` is a deployment fault, not a customer's bad phone number,
|
|
730
|
+
* and turning it into a 400 on `customer.phone` would blame the wrong party (in
|
|
731
|
+
* both senses) for every number in the country.
|
|
732
|
+
*
|
|
733
|
+
* ## `@classytic/contact` is a PEER dependency, and that is load-bearing
|
|
734
|
+
*
|
|
735
|
+
* Not a `dependencies` entry. npm may nest a regular dependency under
|
|
736
|
+
* `party/node_modules/`, which would give the host a SECOND module instance —
|
|
737
|
+
* and then `err instanceof PhoneFormatError` is `false` at a host catch site
|
|
738
|
+
* that imported its own copy. The guard would compile, run, and match nothing.
|
|
739
|
+
* A peer forces exactly one copy per host, so class identity holds across the
|
|
740
|
+
* boundary.
|
|
741
|
+
*
|
|
742
|
+
* ## The region is CONFIGURATION, never a constant
|
|
743
|
+
*
|
|
744
|
+
* `defaultRegion` is an ISO 3166-1 alpha-2 code supplied by the deployment
|
|
745
|
+
* (`PartyShape.defaultPhoneRegion` → `deployment.config.ts`). This kernel names
|
|
746
|
+
* no country. With no region configured, only explicit E.164 input is accepted:
|
|
747
|
+
* refusing is correct, because guessing a country is how you mint a plausible
|
|
748
|
+
* wrong number.
|
|
749
|
+
*/
|
|
750
|
+
/**
|
|
751
|
+
* Canonical email for the dedup key. Trim + lowercase, and deliberately NOT
|
|
752
|
+
* gmail dot / `+tag` folding — `a+shop@x.com` is a real, distinct inbox, and
|
|
753
|
+
* folding it MERGES two people, which is the irreversible direction.
|
|
754
|
+
*
|
|
755
|
+
* The rule moved to `@classytic/contact/email` unchanged for the canonical case,
|
|
756
|
+
* with one addition: an address that cannot be canonicalised now THROWS instead of
|
|
757
|
+
* becoming an identity key. Same reasoning as the phone side — a malformed address
|
|
758
|
+
* still makes a perfectly stable key, so admitting it does not fail, it forks.
|
|
479
759
|
*/
|
|
480
|
-
/** Lowercase + trim an email. Conservative — never strips dots/plus tags. */
|
|
481
760
|
function normalizeEmail(raw) {
|
|
482
|
-
return raw
|
|
761
|
+
return normalizeEmail$1(raw);
|
|
483
762
|
}
|
|
484
763
|
/**
|
|
485
|
-
* Canonical
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
764
|
+
* Canonical E.164 form of a phone number, for dedup AND for storage.
|
|
765
|
+
*
|
|
766
|
+
* Delegates to `@classytic/contact`, then re-throws its refusal as party's own
|
|
767
|
+
* `PartyPhoneFormatError` (400 / `party.phone_invalid`) so arc serializes it
|
|
768
|
+
* natively. There is no fallback at either layer: the old `+${digits}`
|
|
769
|
+
* degradation is precisely what split one member into several parties, and it
|
|
770
|
+
* did so without erroring.
|
|
771
|
+
*
|
|
772
|
+
* NOT exported from this kernel's public surface. A consumer that wants to
|
|
773
|
+
* normalize a phone imports `@classytic/contact/phone` directly — one rule,
|
|
774
|
+
* one name. What party owns is `buildContactPoints`, the single point every
|
|
775
|
+
* identity write crosses.
|
|
490
776
|
*/
|
|
491
|
-
function normalizePhone(raw) {
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
777
|
+
function normalizePhone$1(raw, options = {}) {
|
|
778
|
+
const defaultRegion = options.defaultRegion;
|
|
779
|
+
try {
|
|
780
|
+
return normalizePhone(raw, { ...defaultRegion !== void 0 ? { defaultRegion } : {} });
|
|
781
|
+
} catch (err) {
|
|
782
|
+
if (err instanceof PhoneFormatError) throw new PartyPhoneFormatError(err.input, defaultRegion, err.detail ?? err.code);
|
|
783
|
+
throw err;
|
|
784
|
+
}
|
|
496
785
|
}
|
|
497
786
|
/** The stable dedup key for a contact point (`email:ada@x.com`, `phone:+8801...`). */
|
|
498
|
-
function contactPointKey(type, value) {
|
|
499
|
-
return `${type}:${type === "email" ? normalizeEmail(value) : normalizePhone(value)}`;
|
|
787
|
+
function contactPointKey(type, value, options = {}) {
|
|
788
|
+
return `${type}:${type === "email" ? normalizeEmail(value) : normalizePhone$1(value, options)}`;
|
|
500
789
|
}
|
|
501
790
|
/** A tax-identity dedup key (`vat:...` / `bin:...`) — cross-scope-stable statutory id. */
|
|
502
791
|
function taxIdentityKey(kind, value) {
|
|
@@ -517,28 +806,39 @@ function dedupe(arr) {
|
|
|
517
806
|
/**
|
|
518
807
|
* Turn the input's `emails[]` / `phones[]` into normalized `ContactPoint`s. The
|
|
519
808
|
* first email and first phone are marked primary; blanks are skipped.
|
|
809
|
+
*
|
|
810
|
+
* THE SEAM. Both `resolveOrCreate` and `syncIdentity` funnel here, so this is
|
|
811
|
+
* the one place normalization can be enforced — a fix applied at N call sites
|
|
812
|
+
* is the same bug waiting at N+1.
|
|
813
|
+
*
|
|
814
|
+
* Two spellings of one number in a single input collapse to ONE point (they
|
|
815
|
+
* share a key). An unparseable phone THROWS rather than being skipped: skipping
|
|
816
|
+
* would create a party with no phone identity at all, which the next
|
|
817
|
+
* well-formed visit would not resolve onto — a silent fork by a different route.
|
|
520
818
|
*/
|
|
521
|
-
function buildContactPoints(input) {
|
|
819
|
+
function buildContactPoints(input, options = {}) {
|
|
522
820
|
const points = [];
|
|
523
|
-
|
|
524
|
-
|
|
821
|
+
const seen = /* @__PURE__ */ new Set();
|
|
822
|
+
const push = (type, raw, canonical, isFirst) => {
|
|
823
|
+
const key = `${type}:${canonical}`;
|
|
824
|
+
if (seen.has(key)) return;
|
|
825
|
+
seen.add(key);
|
|
525
826
|
points.push({
|
|
526
|
-
type
|
|
527
|
-
value,
|
|
528
|
-
key
|
|
529
|
-
|
|
827
|
+
type,
|
|
828
|
+
value: canonical,
|
|
829
|
+
key,
|
|
830
|
+
...raw.trim() !== canonical ? { input: raw.trim() } : {},
|
|
831
|
+
isPrimary: isFirst,
|
|
530
832
|
verified: false
|
|
531
833
|
});
|
|
834
|
+
};
|
|
835
|
+
(input.emails ?? []).forEach((value, i) => {
|
|
836
|
+
if (!value.trim()) return;
|
|
837
|
+
push("email", value, normalizeEmail(value), i === 0);
|
|
532
838
|
});
|
|
533
839
|
(input.phones ?? []).forEach((value, i) => {
|
|
534
840
|
if (!value.trim()) return;
|
|
535
|
-
|
|
536
|
-
type: "phone",
|
|
537
|
-
value,
|
|
538
|
-
key: contactPointKey("phone", value),
|
|
539
|
-
isPrimary: i === 0,
|
|
540
|
-
verified: false
|
|
541
|
-
});
|
|
841
|
+
push("phone", value, normalizePhone$1(value, options), i === 0);
|
|
542
842
|
});
|
|
543
843
|
return points;
|
|
544
844
|
}
|
|
@@ -547,8 +847,9 @@ function buildContactPoints(input) {
|
|
|
547
847
|
* statutory ids, and external-store refs. Blank/degenerate keys are dropped so
|
|
548
848
|
* they never poison the unique index.
|
|
549
849
|
*/
|
|
550
|
-
function deriveIdentityKeys(contactPoints, input) {
|
|
551
|
-
const
|
|
850
|
+
function deriveIdentityKeys(contactPoints, input, options = {}) {
|
|
851
|
+
const contactTypes = options.contactTypes ?? ["email", "phone"];
|
|
852
|
+
const keys = contactPoints.filter((c) => contactTypes.includes(c.type)).map((c) => c.key);
|
|
552
853
|
if (input.taxIdentity?.vat) keys.push(taxIdentityKey("vat", input.taxIdentity.vat));
|
|
553
854
|
if (input.taxIdentity?.bin) keys.push(taxIdentityKey("bin", input.taxIdentity.bin));
|
|
554
855
|
for (const ref of input.externalRefs ?? []) keys.push(`ref:${ref.sourceModel}:${ref.sourceId}`);
|
|
@@ -592,22 +893,116 @@ function unionRefs(a, b) {
|
|
|
592
893
|
return out;
|
|
593
894
|
}
|
|
594
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
|
+
/**
|
|
595
914
|
* Union contact points by their normalized `key`. The survivor keeps its own
|
|
596
915
|
* primaries; absorbed points come in as non-primary (a merge must never create
|
|
597
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.
|
|
598
921
|
*/
|
|
599
922
|
function unionContacts(a, b) {
|
|
600
|
-
const
|
|
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
|
+
};
|
|
942
|
+
}
|
|
943
|
+
return out;
|
|
944
|
+
}
|
|
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
|
+
/**
|
|
971
|
+
* Union postal addresses, deduping on `(type, line1, postalCode)` — the same
|
|
972
|
+
* address arriving from both sides collapses to one. Absorbed addresses keep
|
|
973
|
+
* their own recipient/label but yield `isDefault` to the survivor: a merge must
|
|
974
|
+
* never produce two default addresses (mirrors `unionContacts`' primary rule).
|
|
975
|
+
*/
|
|
976
|
+
function unionAddresses(a, b) {
|
|
977
|
+
const key = (ad) => `${ad.type}|${(ad.line1 ?? "").trim().toLowerCase()}|${(ad.postalCode ?? "").trim().toLowerCase()}`;
|
|
978
|
+
const seen = new Set(a.map(key));
|
|
601
979
|
const out = [...a];
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
980
|
+
let hasDefault = a.some((ad) => ad.isDefault);
|
|
981
|
+
for (const ad of b) {
|
|
982
|
+
const k = key(ad);
|
|
983
|
+
if (seen.has(k)) continue;
|
|
984
|
+
seen.add(k);
|
|
985
|
+
const next = hasDefault && ad.isDefault ? {
|
|
986
|
+
...ad,
|
|
987
|
+
isDefault: false
|
|
988
|
+
} : ad;
|
|
989
|
+
if (next.isDefault) hasDefault = true;
|
|
990
|
+
out.push(next);
|
|
608
991
|
}
|
|
609
992
|
return out;
|
|
610
993
|
}
|
|
994
|
+
/**
|
|
995
|
+
* Shallow-merge two metadata bags: the survivor (`a`) wins on every key it
|
|
996
|
+
* already holds; the loser (`b`) only FILLS keys the survivor is missing. Never
|
|
997
|
+
* overwrites a populated survivor field. `undefined` when both are empty.
|
|
998
|
+
*/
|
|
999
|
+
function unionMetadata(a, b) {
|
|
1000
|
+
if (a === void 0 && b === void 0) return void 0;
|
|
1001
|
+
return {
|
|
1002
|
+
...b ?? {},
|
|
1003
|
+
...a ?? {}
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
611
1006
|
//#endregion
|
|
612
1007
|
//#region src/events/event-constants.ts
|
|
613
1008
|
/** PACKAGE_RULES §12: `package:resource.verb`. Emit ONLY these catalog names. */
|
|
@@ -617,10 +1012,12 @@ const PARTY_EVENTS = {
|
|
|
617
1012
|
ROLE_ADDED: "party:party.role_added",
|
|
618
1013
|
ROLE_REMOVED: "party:party.role_removed",
|
|
619
1014
|
USER_LINKED: "party:party.user_linked",
|
|
1015
|
+
CONSENT_RECORDED: "party:party.consent_recorded",
|
|
620
1016
|
EXTERNAL_REF_ATTACHED: "party:party.external_ref_attached",
|
|
621
1017
|
ARCHIVED: "party:party.archived",
|
|
622
1018
|
RESTORED: "party:party.restored",
|
|
623
|
-
MERGED: "party:party.merged"
|
|
1019
|
+
MERGED: "party:party.merged",
|
|
1020
|
+
RELATIONSHIP_CHANGED: "party:party.relationship_changed"
|
|
624
1021
|
};
|
|
625
1022
|
//#endregion
|
|
626
1023
|
//#region src/events/helpers.ts
|
|
@@ -665,9 +1062,15 @@ function repoOptionsFromCtx(ctx) {
|
|
|
665
1062
|
const OBJECT_ID_HEX = /^[a-f0-9]{24}$/i;
|
|
666
1063
|
var PartyRepository = class extends Repository {
|
|
667
1064
|
events;
|
|
1065
|
+
/** The ONE interpretation of free-form contact input in this deployment. */
|
|
1066
|
+
normalize;
|
|
1067
|
+
/** Contact data can exist without being strong enough to collapse two parties. */
|
|
1068
|
+
identityContactTypes;
|
|
668
1069
|
constructor(model, deps, plugins = []) {
|
|
669
1070
|
super(model, plugins);
|
|
670
1071
|
this.events = deps.events;
|
|
1072
|
+
this.normalize = deps.defaultPhoneRegion !== void 0 ? { defaultRegion: deps.defaultPhoneRegion } : {};
|
|
1073
|
+
this.identityContactTypes = deps.identityContactTypes ?? ["email", "phone"];
|
|
671
1074
|
}
|
|
672
1075
|
/** `update` that never returns null — the party was loaded first; a null means a concurrent delete. */
|
|
673
1076
|
async mustUpdate(id, patch, ctx) {
|
|
@@ -676,9 +1079,9 @@ var PartyRepository = class extends Repository {
|
|
|
676
1079
|
return updated;
|
|
677
1080
|
}
|
|
678
1081
|
async resolveOrCreate(input, ctx) {
|
|
679
|
-
const contactPoints = buildContactPoints(input);
|
|
680
|
-
const keys = deriveIdentityKeys(contactPoints, input);
|
|
681
|
-
if (keys.length === 0 && !input.displayName && !input.personName) throw new PartyValidationError("resolveOrCreate needs at least one identity signal (email/phone/vat/bin/externalRef) or a
|
|
1082
|
+
const contactPoints = buildContactPoints(input, this.normalize);
|
|
1083
|
+
const keys = deriveIdentityKeys(contactPoints, input, { contactTypes: this.identityContactTypes });
|
|
1084
|
+
if (keys.length === 0 && contactPoints.length === 0 && !input.displayName?.trim() && !input.personName && !input.legalName?.trim()) throw new PartyValidationError("resolveOrCreate needs at least one contact/identity signal (email/phone/vat/bin/externalRef) or a name.");
|
|
682
1085
|
if (keys.length > 0) {
|
|
683
1086
|
const existing = await this.getByQuery({
|
|
684
1087
|
identityKeys: { $in: keys },
|
|
@@ -770,17 +1173,24 @@ var PartyRepository = class extends Repository {
|
|
|
770
1173
|
*/
|
|
771
1174
|
async syncIdentity(id, input, ctx) {
|
|
772
1175
|
const party = await this.loadParty(id, ctx);
|
|
773
|
-
const mergedContacts = unionContacts(party.contactPoints, buildContactPoints(input));
|
|
1176
|
+
const mergedContacts = unionContacts(party.contactPoints, buildContactPoints(input, this.normalize));
|
|
1177
|
+
const mergedKeys = unionStrings(party.identityKeys, deriveIdentityKeys(mergedContacts, input, { contactTypes: this.identityContactTypes }));
|
|
774
1178
|
const set = {
|
|
775
1179
|
contactPoints: mergedContacts,
|
|
776
|
-
identityKeys:
|
|
1180
|
+
identityKeys: mergedKeys
|
|
777
1181
|
};
|
|
778
1182
|
if (input.displayName?.trim()) set.displayName = input.displayName.trim();
|
|
779
1183
|
if (input.legalName?.trim()) set.legalName = input.legalName.trim();
|
|
780
1184
|
if (input.personName !== void 0) set.personName = input.personName;
|
|
781
1185
|
if (input.addresses !== void 0) set.addresses = input.addresses;
|
|
782
1186
|
if (input.taxIdentity !== void 0) set.taxIdentity = input.taxIdentity;
|
|
783
|
-
|
|
1187
|
+
let updated;
|
|
1188
|
+
try {
|
|
1189
|
+
updated = await this.mustUpdate(party._id, { $set: set }, ctx);
|
|
1190
|
+
} catch (err) {
|
|
1191
|
+
if (isDuplicateKeyError(err)) throw new PartyResolveConflictError(mergedKeys.find((k) => !party.identityKeys.includes(k)) ?? mergedKeys[0] ?? "");
|
|
1192
|
+
throw err;
|
|
1193
|
+
}
|
|
784
1194
|
await this.dispatch(PARTY_EVENTS.UPDATED, {
|
|
785
1195
|
partyId: String(party._id),
|
|
786
1196
|
changed: Object.keys(set)
|
|
@@ -807,6 +1217,65 @@ var PartyRepository = class extends Repository {
|
|
|
807
1217
|
}, updated, ctx);
|
|
808
1218
|
return updated;
|
|
809
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
|
+
}
|
|
810
1279
|
async linkUser(id, userId, ctx) {
|
|
811
1280
|
const party = await this.loadParty(id, ctx);
|
|
812
1281
|
if (party.userLinks.includes(userId)) return party;
|
|
@@ -817,10 +1286,64 @@ var PartyRepository = class extends Repository {
|
|
|
817
1286
|
}, updated, ctx);
|
|
818
1287
|
return updated;
|
|
819
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
|
+
}
|
|
820
1323
|
async attachExternalRef(id, ref, ctx) {
|
|
821
1324
|
const party = await this.loadParty(id, ctx);
|
|
822
1325
|
if (party.externalRefs.some((r) => r.sourceModel === ref.sourceModel && r.sourceId === ref.sourceId)) return party;
|
|
823
|
-
const
|
|
1326
|
+
const refKey = `ref:${ref.sourceModel}:${ref.sourceId}`;
|
|
1327
|
+
let updated;
|
|
1328
|
+
try {
|
|
1329
|
+
updated = await this.mustUpdate(party._id, { $addToSet: {
|
|
1330
|
+
externalRefs: ref,
|
|
1331
|
+
identityKeys: refKey
|
|
1332
|
+
} }, ctx);
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
if (isDuplicateKeyError(err)) {
|
|
1335
|
+
const owner = await this.getByQuery({
|
|
1336
|
+
identityKeys: refKey,
|
|
1337
|
+
status: { $ne: "merged" }
|
|
1338
|
+
}, {
|
|
1339
|
+
...repoOptionsFromCtx(ctx),
|
|
1340
|
+
throwOnNotFound: false
|
|
1341
|
+
});
|
|
1342
|
+
if (owner && String(owner._id) === String(party._id)) return owner;
|
|
1343
|
+
throw new PartyResolveConflictError(refKey);
|
|
1344
|
+
}
|
|
1345
|
+
throw err;
|
|
1346
|
+
}
|
|
824
1347
|
await this.dispatch(PARTY_EVENTS.EXTERNAL_REF_ATTACHED, {
|
|
825
1348
|
partyId: String(party._id),
|
|
826
1349
|
sourceModel: ref.sourceModel,
|
|
@@ -852,9 +1375,17 @@ var PartyRepository = class extends Repository {
|
|
|
852
1375
|
}
|
|
853
1376
|
/**
|
|
854
1377
|
* Merge `sourceId` into `targetId`: the survivor absorbs the loser's identity
|
|
855
|
-
* keys, contact points, addresses, roles, external refs,
|
|
856
|
-
*
|
|
857
|
-
*
|
|
1378
|
+
* keys, contact points, addresses, roles, external refs, user links, tags, and
|
|
1379
|
+
* metadata, and FILLS its empty scalar slots (`taxIdentity`, `personName`,
|
|
1380
|
+
* `legalName`, `parentId`) from the loser — never overwriting a populated
|
|
1381
|
+
* survivor field. The loser becomes a terminal `merged` redirect
|
|
1382
|
+
* (`mergedInto`). Two-document write → runs in a transaction (falls back to
|
|
1383
|
+
* sequential on standalone Mongo).
|
|
1384
|
+
*
|
|
1385
|
+
* Concurrency: the loser is claimed `active|archived → merged` via CAS
|
|
1386
|
+
* (`repo.claim`), so a second concurrent merge of the same source loses the
|
|
1387
|
+
* race and surfaces a typed `PartyMergeError` — never a raw `E11000`. A
|
|
1388
|
+
* duplicate-key collision while adopting the loser's keys maps to the same.
|
|
858
1389
|
*/
|
|
859
1390
|
async merge(sourceId, targetId, ctx) {
|
|
860
1391
|
if (sourceId === targetId) throw new PartyMergeError("Cannot merge a party into itself.", sourceId, targetId);
|
|
@@ -867,27 +1398,51 @@ var PartyRepository = class extends Repository {
|
|
|
867
1398
|
const mergedUsers = unionStrings(target.userLinks, source.userLinks);
|
|
868
1399
|
const mergedRefs = unionRefs(target.externalRefs, source.externalRefs);
|
|
869
1400
|
const mergedContacts = unionContacts(target.contactPoints, source.contactPoints);
|
|
1401
|
+
const mergedAddresses = unionAddresses(target.addresses, source.addresses);
|
|
1402
|
+
const mergedTags = unionStrings(target.tags, source.tags);
|
|
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)))];
|
|
870
1407
|
const absorbed = mergedKeys.length - target.identityKeys.length;
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1408
|
+
const fill = {};
|
|
1409
|
+
if (target.taxIdentity === void 0 && source.taxIdentity !== void 0) fill.taxIdentity = source.taxIdentity;
|
|
1410
|
+
if (target.personName === void 0 && source.personName !== void 0) fill.personName = source.personName;
|
|
1411
|
+
if (!target.legalName?.trim() && source.legalName?.trim()) fill.legalName = source.legalName;
|
|
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;
|
|
1414
|
+
try {
|
|
1415
|
+
await withTransaction(this.Model.db, async (session) => {
|
|
1416
|
+
if (!await this.claim(String(source._id), {
|
|
1417
|
+
from: ["active", "archived"],
|
|
1418
|
+
to: "merged"
|
|
1419
|
+
}, { $set: {
|
|
1420
|
+
mergedInto: target._id,
|
|
1421
|
+
identityKeys: []
|
|
1422
|
+
} }, {
|
|
1423
|
+
...repoOptionsFromCtx(ctx),
|
|
1424
|
+
session
|
|
1425
|
+
})) throw new PartyMergeError("Source party was concurrently modified (no longer mergeable).", sourceId, targetId);
|
|
1426
|
+
await this.update(target._id, { $set: {
|
|
1427
|
+
identityKeys: mergedKeys,
|
|
1428
|
+
roles: mergedRoles,
|
|
1429
|
+
userLinks: mergedUsers,
|
|
1430
|
+
externalRefs: mergedRefs,
|
|
1431
|
+
contactPoints: mergedContacts,
|
|
1432
|
+
addresses: mergedAddresses,
|
|
1433
|
+
tags: mergedTags,
|
|
1434
|
+
relationships: mergedRelationships,
|
|
1435
|
+
...fill,
|
|
1436
|
+
...mergedMetadata !== void 0 ? { metadata: mergedMetadata } : {}
|
|
1437
|
+
} }, {
|
|
1438
|
+
...repoOptionsFromCtx(ctx),
|
|
1439
|
+
session
|
|
1440
|
+
});
|
|
1441
|
+
}, { allowFallback: true });
|
|
1442
|
+
} catch (err) {
|
|
1443
|
+
if (isDuplicateKeyError(err)) throw new PartyMergeError("Concurrent merge collided on a shared identity key.", sourceId, targetId);
|
|
1444
|
+
throw err;
|
|
1445
|
+
}
|
|
891
1446
|
const survivor = await this.loadParty(targetId, ctx);
|
|
892
1447
|
await this.dispatch(PARTY_EVENTS.MERGED, {
|
|
893
1448
|
sourceId,
|
|
@@ -930,114 +1485,45 @@ var PartyRepository = class extends Repository {
|
|
|
930
1485
|
}), ctx);
|
|
931
1486
|
}
|
|
932
1487
|
};
|
|
1488
|
+
const overlaps = (aFrom, aTo, bFrom, bTo) => aFrom < (bTo ?? /* @__PURE__ */ new Date(864e13)) && bFrom < (aTo ?? /* @__PURE__ */ new Date(864e13));
|
|
933
1489
|
//#endregion
|
|
934
|
-
//#region src/
|
|
935
|
-
/** Context key carrying a post-commit publish queue (attached by `withTransaction`). */
|
|
936
|
-
const PENDING_EVENTS = Symbol.for("@classytic/party:pendingEvents");
|
|
937
|
-
var PartyEventDispatcher = class {
|
|
938
|
-
name;
|
|
939
|
-
transport;
|
|
940
|
-
outbox;
|
|
941
|
-
logger;
|
|
942
|
-
constructor(opts) {
|
|
943
|
-
this.transport = opts.transport;
|
|
944
|
-
this.outbox = opts.outbox;
|
|
945
|
-
this.logger = opts.logger;
|
|
946
|
-
this.name = opts.transport.name;
|
|
947
|
-
}
|
|
948
|
-
/** Raw publish — hosts and the unit-of-work flush use this. */
|
|
949
|
-
publish(event) {
|
|
950
|
-
return this.transport.publish(event);
|
|
951
|
-
}
|
|
952
|
-
subscribe(pattern, handler) {
|
|
953
|
-
return this.transport.subscribe(pattern, handler);
|
|
954
|
-
}
|
|
955
|
-
/**
|
|
956
|
-
* Route a domain event with full §P8 discipline. Reads the pending queue and
|
|
957
|
-
* session off `ctx`.
|
|
958
|
-
*/
|
|
959
|
-
async dispatch(event, ctx) {
|
|
960
|
-
if (this.outbox) try {
|
|
961
|
-
await this.outbox.save(event, ctx.session !== void 0 ? { session: ctx.session } : {});
|
|
962
|
-
} catch (err) {
|
|
963
|
-
this.logger?.error?.(`[party] outbox.save failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
|
|
964
|
-
throw err;
|
|
965
|
-
}
|
|
966
|
-
const queue = ctx[PENDING_EVENTS];
|
|
967
|
-
if (Array.isArray(queue)) {
|
|
968
|
-
queue.push(event);
|
|
969
|
-
return;
|
|
970
|
-
}
|
|
971
|
-
try {
|
|
972
|
-
await this.transport.publish(event);
|
|
973
|
-
} catch (err) {
|
|
974
|
-
this.logger?.error?.(`[party] publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
async close() {
|
|
978
|
-
await this.transport.close?.();
|
|
979
|
-
}
|
|
980
|
-
};
|
|
981
|
-
/**
|
|
982
|
-
* Drain a pending-events queue to the transport AFTER the owning transaction
|
|
983
|
-
* commits. Per-event failures are swallowed — durable delivery is the outbox's
|
|
984
|
-
* job (PACKAGE_RULES §P8.1).
|
|
985
|
-
*/
|
|
986
|
-
async function flushPending(dispatcher, queue, logger) {
|
|
987
|
-
for (const event of queue) try {
|
|
988
|
-
await dispatcher.publish(event);
|
|
989
|
-
} catch (err) {
|
|
990
|
-
logger?.error?.(`[party] post-commit publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
//#endregion
|
|
994
|
-
//#region src/events/in-process-bus.ts
|
|
1490
|
+
//#region src/engine/ensure-ready.ts
|
|
995
1491
|
/**
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1492
|
+
* Boot capability gate (PACKAGE_RULES §capabilities). `resolveOrCreate`'s
|
|
1493
|
+
* race-safety leans on the unique-index E11000 path, and `merge` writes two
|
|
1494
|
+
* documents in a transaction — so the backend MUST support `upsert` +
|
|
1495
|
+
* `duplicateKeyError`, and `transactions` (unless the host opts out).
|
|
1496
|
+
* Fail loud at engine creation, not with a cryptic error on the first merge.
|
|
1497
|
+
*
|
|
1498
|
+
* ## This gate could not fire until 2026-08-05
|
|
1499
|
+
*
|
|
1500
|
+
* `repo.capabilities` used to resolve to mongokit's STATIC
|
|
1501
|
+
* `MONGOKIT_CAPABILITIES`, whose `transactions` was hard-coded `true`. So the
|
|
1502
|
+
* check below was structurally unfalsifiable for every mongoose-backed
|
|
1503
|
+
* repository — including one pointed at a standalone `mongod`, the exact
|
|
1504
|
+
* deployment it exists to catch. (party's kernel-conformance run had to SKIP
|
|
1505
|
+
* `bind-verifies-requirements` for precisely this reason.) mongokit now
|
|
1506
|
+
* OBSERVES the live deployment topology per connection, so the same code path
|
|
1507
|
+
* now genuinely refuses a standalone.
|
|
1508
|
+
*
|
|
1509
|
+
* ## Three answers, not two
|
|
1510
|
+
*
|
|
1511
|
+
* `transactionResolutionOf(caps)` distinguishes an OBSERVED "no" (a standalone
|
|
1512
|
+
* — fix the deployment) from "we could not determine it" (bound before the
|
|
1513
|
+
* connection opened, an unreadable driver). Both refuse, because an unobserved
|
|
1514
|
+
* outcome is not a positive one (AGENTS.md FAIL LOUD rule 3) — but the message
|
|
1515
|
+
* differs, because the operator's fix does.
|
|
1000
1516
|
*/
|
|
1001
|
-
var InProcessPartyBus = class {
|
|
1002
|
-
name = "in-process-party";
|
|
1003
|
-
subs = [];
|
|
1004
|
-
logger;
|
|
1005
|
-
constructor(options) {
|
|
1006
|
-
this.logger = options?.logger;
|
|
1007
|
-
}
|
|
1008
|
-
async publish(event) {
|
|
1009
|
-
const matching = this.subs.filter((s) => matchEventPattern(s.pattern, event.type));
|
|
1010
|
-
for (const sub of matching) try {
|
|
1011
|
-
await sub.handler(event);
|
|
1012
|
-
} catch (err) {
|
|
1013
|
-
this.logger?.error?.(`[party] event handler error [${event.type}]`, { error: err instanceof Error ? err.message : String(err) });
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
async subscribe(pattern, handler) {
|
|
1017
|
-
const sub = {
|
|
1018
|
-
pattern,
|
|
1019
|
-
handler
|
|
1020
|
-
};
|
|
1021
|
-
this.subs.push(sub);
|
|
1022
|
-
return () => {
|
|
1023
|
-
this.subs = this.subs.filter((s) => s !== sub);
|
|
1024
|
-
};
|
|
1025
|
-
}
|
|
1026
|
-
async close() {
|
|
1027
|
-
this.subs = [];
|
|
1028
|
-
}
|
|
1029
|
-
};
|
|
1030
|
-
//#endregion
|
|
1031
|
-
//#region src/engine/ensure-ready.ts
|
|
1032
1517
|
function assertPartyCapabilities(repo, opts = {}) {
|
|
1033
1518
|
const caps = repo.capabilities;
|
|
1034
1519
|
if (!caps) throw new Error("[party] backend declares no `capabilities` descriptor (needs repo-core 0.6 / mongokit 3.16+).");
|
|
1035
1520
|
const missing = ["duplicateKeyError"].filter((flag) => caps[flag] !== true);
|
|
1036
1521
|
if (missing.length > 0) throw new Error(`[party] backend missing required capabilities: ${missing.join(", ")}. resolveOrCreate relies on unique-index conflict detection for race-safe identity dedup.`);
|
|
1037
1522
|
if (caps.transactions !== true) {
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1040
|
-
|
|
1523
|
+
const indeterminate = transactionResolutionOf(caps) === "unknown";
|
|
1524
|
+
const cause = indeterminate ? "[party] could NOT determine whether the backend supports transactions (the connection was not open at bind time, or the driver exposed no topology). An unconfirmed answer is treated as unsupported — `merge` writes two documents and needs atomicity." : "[party] backend does not support transactions — `merge` writes two documents and needs a replica set for atomicity.";
|
|
1525
|
+
if (opts.allowNonTransactional) opts.logger?.warn?.(`${cause} allowNonTransactional=true: merge falls back to sequential writes (partial-write risk).`);
|
|
1526
|
+
else throw new Error(`${cause} ${indeterminate ? "Bind AFTER `await mongoose.connect(...)`, or `await probeMongoCapabilities(connection)` first, or pass allowNonTransactional: true." : "Run MongoDB as a replica set, or pass allowNonTransactional: true."}`);
|
|
1041
1527
|
}
|
|
1042
1528
|
}
|
|
1043
1529
|
//#endregion
|
|
@@ -1072,83 +1558,173 @@ async function runUnit(config, ctx, body) {
|
|
|
1072
1558
|
return result;
|
|
1073
1559
|
}
|
|
1074
1560
|
//#endregion
|
|
1075
|
-
//#region src/engine/
|
|
1561
|
+
//#region src/engine/define-party.ts
|
|
1076
1562
|
/**
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
* 1. Validate `connection`.
|
|
1080
|
-
* 2. Resolve defaults (multiTenant=true, tenantFieldType=objectId, …).
|
|
1081
|
-
* 3. Create the Party model on the connection (collision policy §21).
|
|
1082
|
-
* 4. Resolve the EventTransport (or default in-process bus) + dispatcher.
|
|
1083
|
-
* 5. Build the repository — plugins composed in canonical order (§9.2).
|
|
1084
|
-
* 6. Boot-gate required capabilities, then freeze + return the engine.
|
|
1563
|
+
* `defineParty` — the DESCRIBE/BIND blueprint for the party engine
|
|
1564
|
+
* (STANDARDIZATION-PLAN §6). Splits construction into two phases:
|
|
1085
1565
|
*
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
1088
|
-
* import
|
|
1089
|
-
*
|
|
1566
|
+
* - **describe** (`defineParty(shape)`): pure. Validates the model set and freezes the
|
|
1567
|
+
* model names via mongokit's `defineModels()`. No connection, no schema built, no I/O —
|
|
1568
|
+
* safe to call at import / module-composition time.
|
|
1569
|
+
* - **bind** (`blueprint.bind(connection, runtime)`): compiles the schema on the supplied
|
|
1570
|
+
* connection, registers the model, wires the transport/dispatcher/repository, verifies
|
|
1571
|
+
* capabilities, and returns the frozen `PartyEngine`.
|
|
1090
1572
|
*
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
* const { party: p } = await party.port.resolveOrCreate(
|
|
1094
|
-
* { displayName: 'Ada Lovelace', emails: ['ada@x.com'], roles: ['customer'] },
|
|
1095
|
-
* {},
|
|
1096
|
-
* );
|
|
1097
|
-
* ```
|
|
1573
|
+
* This is the sole construction API — there is no `createParty` wrapper.
|
|
1098
1574
|
*/
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
...resolved.logger?.warn !== void 0 ? { logger: { warn: (m) => resolved.logger?.warn?.(m) } } : {}
|
|
1575
|
+
/**
|
|
1576
|
+
* Describe the party engine. Pure — registers no model, builds no schema, performs no I/O.
|
|
1577
|
+
*/
|
|
1578
|
+
function defineParty(shape = {}) {
|
|
1579
|
+
/**
|
|
1580
|
+
* The pre-consolidation tenant keys are REFUSED, never absorbed.
|
|
1581
|
+
*
|
|
1582
|
+
* This entry reads `shape.tenant`, so a leftover `multiTenant` is invisible and
|
|
1583
|
+
* the `?? true` default applies repo-core's defaults instead. Both outcomes
|
|
1584
|
+
* return plausible data:
|
|
1585
|
+
* - `multiTenant: false` -> tenancy ON, and the host loses its company-wide reads;
|
|
1586
|
+
* - `multiTenant: { tenantField: 'business' }` -> scoping on `organizationId`,
|
|
1587
|
+
* a field those documents do not carry.
|
|
1588
|
+
*
|
|
1589
|
+
* tsc cannot cover this: excess-property checking does not fire for a typed
|
|
1590
|
+
* variable, a spread, or a shape handed over from a dependency's dist — which is
|
|
1591
|
+
* how every real host composes one. Hence a describe-time check, shared from
|
|
1592
|
+
* repo-core so the nine kernels making this same rename cannot each forget it.
|
|
1593
|
+
*/
|
|
1594
|
+
assertNoLegacyTenantKeys(shape, "defineParty", [["tenantFieldType", "tenant.fieldType"]]);
|
|
1595
|
+
const tenant = resolveTenantConfig(shape.tenant ?? true);
|
|
1596
|
+
if (tenant.strategy === "custom") throw new Error("defineParty: tenant.strategy 'custom' is not supported");
|
|
1597
|
+
const autoIndex = shape.autoIndex ?? true;
|
|
1598
|
+
const identityContactTypes = [...new Set(shape.identityContactTypes ?? CONTACT_POINT_TYPES)];
|
|
1599
|
+
const invalidIdentityContactType = identityContactTypes.find((type) => !CONTACT_POINT_TYPES.includes(type));
|
|
1600
|
+
if (invalidIdentityContactType !== void 0) throw new Error(`defineParty: identityContactTypes contains unsupported value '${String(invalidIdentityContactType)}'. Expected: ${CONTACT_POINT_TYPES.join(", ")}.`);
|
|
1601
|
+
const modelBlueprint = definePartyModels({
|
|
1602
|
+
tenant,
|
|
1603
|
+
autoIndex,
|
|
1604
|
+
...shape.forceRecreate !== void 0 ? { forceRecreate: shape.forceRecreate } : {}
|
|
1130
1605
|
});
|
|
1131
|
-
return
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1606
|
+
return {
|
|
1607
|
+
id: "party",
|
|
1608
|
+
modelNames: modelBlueprint.modelNames,
|
|
1609
|
+
bind(connection, runtime = {}) {
|
|
1610
|
+
/**
|
|
1611
|
+
* BOOT GATE — an injected outbox MUST enlist `ctx.session`.
|
|
1612
|
+
*
|
|
1613
|
+
* FIRST statement, before the model blueprint binds: a pure config
|
|
1614
|
+
* assertion should report before any resource is allocated, and a refused
|
|
1615
|
+
* bind must leave the connection's model registry untouched.
|
|
1616
|
+
*
|
|
1617
|
+
* `OutboxWriteOptions.session` is best-effort BY CONTRACT (see
|
|
1618
|
+
* `@classytic/primitives/outbox`), so a hand-rolled store satisfies
|
|
1619
|
+
* `OutboxStore` and then keeps the row when the transaction aborts.
|
|
1620
|
+
* `PartyEventDispatcher.dispatch` saves under `ctx.session` precisely so it
|
|
1621
|
+
* cannot: identity is the ROOT every facet keys off, and a
|
|
1622
|
+
* `party.merged` that survives a rolled-back merge tells every downstream
|
|
1623
|
+
* consumer — customer, supplier, loyalty, accounting — to re-point at a
|
|
1624
|
+
* party that was never merged. Nothing throws; the divergence shows up as
|
|
1625
|
+
* facets pointing at an id that does not exist.
|
|
1626
|
+
*
|
|
1627
|
+
* Discovering that on the first real merge is far worse than at boot.
|
|
1628
|
+
*/
|
|
1629
|
+
if (runtime.outbox && runtime.outbox.transactionalSave !== true) throw new Error("defineParty.bind: the configured OutboxStore does not declare `transactionalSave: true`, so it does not promise that `save()` enlists `ctx.session` (see @classytic/primitives/outbox — the option is best-effort by contract). Party writes every event row inside the transaction of its caller; a store that ignores the session emits merge/archive events for identities that rolled back. Arc's `repositoryAsOutboxStore` declares it; the in-memory `MemoryOutboxStore` must not — pass no outbox instead.");
|
|
1630
|
+
const models = modelBlueprint.bind(connection);
|
|
1631
|
+
const resolved = {
|
|
1632
|
+
connection,
|
|
1633
|
+
tenant,
|
|
1634
|
+
forceRecreate: shape.forceRecreate ?? false,
|
|
1635
|
+
autoIndex,
|
|
1636
|
+
identityContactTypes: Object.freeze([...identityContactTypes]),
|
|
1637
|
+
...shape.defaultPhoneRegion !== void 0 ? { defaultPhoneRegion: shape.defaultPhoneRegion } : {},
|
|
1638
|
+
allowNonTransactional: runtime.allowNonTransactional ?? false,
|
|
1639
|
+
...runtime.logger !== void 0 ? { logger: runtime.logger } : {},
|
|
1640
|
+
...runtime.pagination !== void 0 ? { pagination: runtime.pagination } : {},
|
|
1641
|
+
plugins: runtime.plugins ?? []
|
|
1642
|
+
};
|
|
1643
|
+
const ownsTransport = runtime.eventTransport === void 0;
|
|
1644
|
+
const events = new PartyEventDispatcher({
|
|
1645
|
+
transport: runtime.eventTransport ?? new InProcessPartyBus(runtime.logger ? { logger: runtime.logger } : void 0),
|
|
1646
|
+
...runtime.outbox !== void 0 ? { outbox: runtime.outbox } : {},
|
|
1647
|
+
...runtime.logger !== void 0 ? { logger: runtime.logger } : {}
|
|
1648
|
+
});
|
|
1649
|
+
const plugins = [...tenant.enabled ? [multiTenantPlugin({
|
|
1650
|
+
tenantField: tenant.tenantField,
|
|
1651
|
+
contextKey: tenant.contextKey,
|
|
1652
|
+
fieldType: tenant.fieldType,
|
|
1653
|
+
required: tenant.required
|
|
1654
|
+
})] : [], ...resolved.plugins];
|
|
1655
|
+
const repository = new PartyRepository(models.Party, {
|
|
1140
1656
|
events,
|
|
1141
1657
|
...resolved.logger !== void 0 ? { logger: resolved.logger } : {},
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1658
|
+
...resolved.defaultPhoneRegion !== void 0 ? { defaultPhoneRegion: resolved.defaultPhoneRegion } : {},
|
|
1659
|
+
identityContactTypes: resolved.identityContactTypes
|
|
1660
|
+
}, plugins);
|
|
1661
|
+
assertPartyCapabilities(repository, {
|
|
1662
|
+
allowNonTransactional: resolved.allowNonTransactional,
|
|
1663
|
+
...resolved.logger?.warn !== void 0 ? { logger: { warn: (m) => resolved.logger?.warn?.(m) } } : {}
|
|
1664
|
+
});
|
|
1665
|
+
const close = async () => {
|
|
1666
|
+
if (ownsTransport) await events.close();
|
|
1667
|
+
};
|
|
1668
|
+
return Object.freeze({
|
|
1669
|
+
models,
|
|
1670
|
+
repositories: Object.freeze({ party: repository }),
|
|
1671
|
+
port: repository,
|
|
1672
|
+
events,
|
|
1673
|
+
config: Object.freeze(resolved),
|
|
1674
|
+
withTransaction(ctx, body) {
|
|
1675
|
+
return runUnit({
|
|
1676
|
+
connection,
|
|
1677
|
+
events,
|
|
1678
|
+
...resolved.logger !== void 0 ? { logger: resolved.logger } : {},
|
|
1679
|
+
allowNonTransactional: resolved.allowNonTransactional
|
|
1680
|
+
}, ctx, body);
|
|
1681
|
+
},
|
|
1682
|
+
async syncIndexes() {
|
|
1683
|
+
await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
|
|
1684
|
+
},
|
|
1685
|
+
close
|
|
1686
|
+
});
|
|
1150
1687
|
}
|
|
1151
|
-
}
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
//#endregion
|
|
1691
|
+
//#region src/domain/subject.ts
|
|
1692
|
+
/**
|
|
1693
|
+
* The canonical `subjectModel` value for a Party.
|
|
1694
|
+
*
|
|
1695
|
+
* Import this — never write `'Party'` in a host, a module, or a test.
|
|
1696
|
+
*/
|
|
1697
|
+
const PARTY_SUBJECT_MODEL = "Party";
|
|
1698
|
+
/**
|
|
1699
|
+
* Build the subject pair for a Party.
|
|
1700
|
+
*
|
|
1701
|
+
* Accepts anything id-shaped (`ObjectId`, hex string, a document's `_id`) and
|
|
1702
|
+
* stringifies through primitives' `idToString`, because the receiving kernels
|
|
1703
|
+
* store `subjectRef` as a String — a raw `ObjectId` would stringify implicitly
|
|
1704
|
+
* somewhere downstream and the two forms would not compare equal.
|
|
1705
|
+
*
|
|
1706
|
+
* Returns `null` for a missing id rather than a pair containing `"undefined"`.
|
|
1707
|
+
* That string is a real hazard here: it is a *valid* `subjectRef` as far as the
|
|
1708
|
+
* schema is concerned, so it persists, matches nothing, and looks like data.
|
|
1709
|
+
*/
|
|
1710
|
+
function partySubject(id) {
|
|
1711
|
+
if (id === null || id === void 0) return null;
|
|
1712
|
+
const subjectRef = idToString(id);
|
|
1713
|
+
if (subjectRef.length === 0 || subjectRef === "undefined" || subjectRef === "null") return null;
|
|
1714
|
+
return {
|
|
1715
|
+
subjectRef,
|
|
1716
|
+
subjectModel: PARTY_SUBJECT_MODEL
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Does this subject pair refer to a Party?
|
|
1721
|
+
*
|
|
1722
|
+
* For a resolver that fans a mixed subject stream out to the right kernel — an
|
|
1723
|
+
* attendance list can hold Party members and, in an HR deployment, employees
|
|
1724
|
+
* from another identity source.
|
|
1725
|
+
*/
|
|
1726
|
+
function isPartySubject(subject) {
|
|
1727
|
+
return subject.subjectModel === PARTY_SUBJECT_MODEL;
|
|
1152
1728
|
}
|
|
1153
1729
|
//#endregion
|
|
1154
1730
|
//#region src/events/event-catalog.ts
|
|
@@ -1207,6 +1783,13 @@ const userLinkedSchema = z.object({
|
|
|
1207
1783
|
partyId: z.string(),
|
|
1208
1784
|
userId: z.string()
|
|
1209
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
|
+
});
|
|
1210
1793
|
const externalRefSchema = z.object({
|
|
1211
1794
|
partyId: z.string(),
|
|
1212
1795
|
sourceModel: z.string(),
|
|
@@ -1218,52 +1801,87 @@ const mergedSchema = z.object({
|
|
|
1218
1801
|
targetId: z.string(),
|
|
1219
1802
|
absorbedKeys: z.number().int().nonnegative()
|
|
1220
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
|
+
});
|
|
1221
1869
|
const partyEventDefinitions = [
|
|
1870
|
+
PartyCreated,
|
|
1871
|
+
PartyUpdated,
|
|
1872
|
+
PartyRoleAdded,
|
|
1873
|
+
PartyRoleRemoved,
|
|
1874
|
+
PartyUserLinked,
|
|
1875
|
+
PartyConsentRecorded,
|
|
1876
|
+
PartyExternalRefAttached,
|
|
1877
|
+
PartyArchived,
|
|
1878
|
+
PartyRestored,
|
|
1879
|
+
PartyMerged,
|
|
1222
1880
|
definePartyEvent({
|
|
1223
|
-
name: PARTY_EVENTS.
|
|
1224
|
-
description: "A
|
|
1225
|
-
zodSchema:
|
|
1226
|
-
}),
|
|
1227
|
-
definePartyEvent({
|
|
1228
|
-
name: PARTY_EVENTS.UPDATED,
|
|
1229
|
-
description: "A party identity field changed.",
|
|
1230
|
-
zodSchema: updatedSchema
|
|
1231
|
-
}),
|
|
1232
|
-
definePartyEvent({
|
|
1233
|
-
name: PARTY_EVENTS.ROLE_ADDED,
|
|
1234
|
-
description: "A role (customer/supplier/…) was granted to a party.",
|
|
1235
|
-
zodSchema: roleChangedSchema
|
|
1236
|
-
}),
|
|
1237
|
-
definePartyEvent({
|
|
1238
|
-
name: PARTY_EVENTS.ROLE_REMOVED,
|
|
1239
|
-
description: "A role was revoked from a party.",
|
|
1240
|
-
zodSchema: roleChangedSchema
|
|
1241
|
-
}),
|
|
1242
|
-
definePartyEvent({
|
|
1243
|
-
name: PARTY_EVENTS.USER_LINKED,
|
|
1244
|
-
description: "An auth user was linked to a party (1:many).",
|
|
1245
|
-
zodSchema: userLinkedSchema
|
|
1246
|
-
}),
|
|
1247
|
-
definePartyEvent({
|
|
1248
|
-
name: PARTY_EVENTS.EXTERNAL_REF_ATTACHED,
|
|
1249
|
-
description: "An external-store reference was attached to a party (facet link).",
|
|
1250
|
-
zodSchema: externalRefSchema
|
|
1251
|
-
}),
|
|
1252
|
-
definePartyEvent({
|
|
1253
|
-
name: PARTY_EVENTS.ARCHIVED,
|
|
1254
|
-
description: "A party was archived.",
|
|
1255
|
-
zodSchema: lifecycleSchema
|
|
1256
|
-
}),
|
|
1257
|
-
definePartyEvent({
|
|
1258
|
-
name: PARTY_EVENTS.RESTORED,
|
|
1259
|
-
description: "An archived party was restored to active.",
|
|
1260
|
-
zodSchema: lifecycleSchema
|
|
1261
|
-
}),
|
|
1262
|
-
definePartyEvent({
|
|
1263
|
-
name: PARTY_EVENTS.MERGED,
|
|
1264
|
-
description: "A duplicate party was merged into a survivor; the source becomes a redirect.",
|
|
1265
|
-
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
|
|
1266
1884
|
})
|
|
1267
1885
|
];
|
|
1268
1886
|
//#endregion
|
|
1269
|
-
export { ADDRESS_TYPES, CONTACT_POINT_TYPES, InProcessPartyBus, PARTY_EVENTS, PARTY_KINDS,
|
|
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 };
|