@classytic/party 0.1.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/events.d.ts +238 -0
- package/dist/events.js +255 -0
- package/dist/index.d.ts +592 -0
- package/dist/index.js +1269 -0
- package/dist/schemas.d.ts +125 -0
- package/dist/schemas.js +79 -0
- package/package.json +78 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1269 @@
|
|
|
1
|
+
import { Repository, isDuplicateKeyError, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
|
|
2
|
+
import { Schema } from "mongoose";
|
|
3
|
+
import { defineStateMachine } from "@classytic/primitives/state-machine";
|
|
4
|
+
import { parsePhone } from "@classytic/primitives/phone";
|
|
5
|
+
import { isOk } from "@classytic/primitives/result";
|
|
6
|
+
import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
|
|
7
|
+
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
|
+
//#region src/domain/enums.ts
|
|
79
|
+
/**
|
|
80
|
+
* Party-native enumerations.
|
|
81
|
+
*
|
|
82
|
+
* These are identity-kernel concepts (kind, role, lifecycle) — they have no
|
|
83
|
+
* one-to-one primitive to subset, so they are declared here as the source of
|
|
84
|
+
* truth. Domain FACETS (finance terms, CRM stage, loyalty tier) live in their
|
|
85
|
+
* OWN packages keyed by `partyId`; they are deliberately NOT modelled here —
|
|
86
|
+
* that separation is the whole point of the SAP-style thin identity kernel
|
|
87
|
+
* (vs. Odoo's `res.partner` god-object).
|
|
88
|
+
*/
|
|
89
|
+
/** What kind of real-world entity a party is. */
|
|
90
|
+
const PARTY_KINDS = [
|
|
91
|
+
"person",
|
|
92
|
+
"company",
|
|
93
|
+
"group"
|
|
94
|
+
];
|
|
95
|
+
/**
|
|
96
|
+
* Roles a party plays. NON-exclusive — one party can be a customer AND a
|
|
97
|
+
* supplier AND a contact simultaneously (the exact de-duplication a shared
|
|
98
|
+
* identity buys you). Roles are additive flags, never separate entities.
|
|
99
|
+
*/
|
|
100
|
+
const PARTY_ROLES = [
|
|
101
|
+
"customer",
|
|
102
|
+
"supplier",
|
|
103
|
+
"contact",
|
|
104
|
+
"employee",
|
|
105
|
+
"partner"
|
|
106
|
+
];
|
|
107
|
+
/**
|
|
108
|
+
* Identity lifecycle. `merged` is terminal — the row survives as a redirect
|
|
109
|
+
* (`mergedInto`) so historical references (orders, invoices) still resolve.
|
|
110
|
+
*/
|
|
111
|
+
const PARTY_STATUSES = [
|
|
112
|
+
"active",
|
|
113
|
+
"archived",
|
|
114
|
+
"merged"
|
|
115
|
+
];
|
|
116
|
+
/** Channel kind for a contact point. Drives normalization + dedup keys. */
|
|
117
|
+
const CONTACT_POINT_TYPES = ["email", "phone"];
|
|
118
|
+
/**
|
|
119
|
+
* Address role. Mirrors Odoo's `type` (contact/invoice/delivery/other) but
|
|
120
|
+
* as a first-class enum. A party carries many; `isDefault` picks the fallback.
|
|
121
|
+
*/
|
|
122
|
+
const ADDRESS_TYPES = [
|
|
123
|
+
"billing",
|
|
124
|
+
"shipping",
|
|
125
|
+
"contact",
|
|
126
|
+
"other"
|
|
127
|
+
];
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/models/tenant-field.ts
|
|
130
|
+
/**
|
|
131
|
+
* Per PACKAGE_RULES §9.2: never hardcode the tenant column's Mongoose type —
|
|
132
|
+
* the engine config drives both the schema and `multiTenantPlugin` so they
|
|
133
|
+
* stay in sync. Hosts with Better Auth pick `'objectId'` (default — enables
|
|
134
|
+
* `$lookup`, `.populate()`, QueryParser `?lookup=organizationId`); UUID/slug
|
|
135
|
+
* hosts pick `'string'`.
|
|
136
|
+
*
|
|
137
|
+
* NOTE on party scoping: identity is frequently COMPANY-WIDE (one customer is
|
|
138
|
+
* the same person in every branch). Such hosts wire `multiTenant: false` at the
|
|
139
|
+
* engine and this field is omitted entirely. When multi-tenant IS on (SaaS,
|
|
140
|
+
* true per-tenant isolation), this drives the column type.
|
|
141
|
+
*/
|
|
142
|
+
/**
|
|
143
|
+
* Tenant-column schema definition, or `undefined` when the engine is
|
|
144
|
+
* company-wide (`multiTenant: false`) so the field is simply not added.
|
|
145
|
+
*/
|
|
146
|
+
function tenantFieldDef(config) {
|
|
147
|
+
if (!config.multiTenant) return void 0;
|
|
148
|
+
return config.tenantFieldType === "objectId" ? {
|
|
149
|
+
type: Schema.Types.ObjectId,
|
|
150
|
+
required: true,
|
|
151
|
+
ref: "organization",
|
|
152
|
+
index: true
|
|
153
|
+
} : {
|
|
154
|
+
type: String,
|
|
155
|
+
required: true,
|
|
156
|
+
index: true
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/models/party.model.ts
|
|
161
|
+
/**
|
|
162
|
+
* Party — the single canonical identity. ONE document per real-world
|
|
163
|
+
* person/company/group, carrying non-exclusive roles, contact points,
|
|
164
|
+
* addresses, tax identity, B2B hierarchy, and 1:many auth-user links.
|
|
165
|
+
*
|
|
166
|
+
* This is a THIN identity kernel (SAP Business-Partner shape). Domain FACETS —
|
|
167
|
+
* finance terms (AR/AP, credit), CRM funnel stage, loyalty tier — live in their
|
|
168
|
+
* OWN packages keyed by `partyId`. Nothing here knows about accounting or CRM.
|
|
169
|
+
* That is the deliberate difference from Odoo's `res.partner` god-object.
|
|
170
|
+
*/
|
|
171
|
+
const personNameSchema = new Schema({
|
|
172
|
+
given: {
|
|
173
|
+
type: String,
|
|
174
|
+
required: true,
|
|
175
|
+
trim: true,
|
|
176
|
+
maxlength: 120
|
|
177
|
+
},
|
|
178
|
+
family: {
|
|
179
|
+
type: String,
|
|
180
|
+
default: "",
|
|
181
|
+
trim: true,
|
|
182
|
+
maxlength: 120
|
|
183
|
+
},
|
|
184
|
+
middle: {
|
|
185
|
+
type: String,
|
|
186
|
+
trim: true,
|
|
187
|
+
maxlength: 120
|
|
188
|
+
},
|
|
189
|
+
prefix: {
|
|
190
|
+
type: String,
|
|
191
|
+
trim: true,
|
|
192
|
+
maxlength: 32
|
|
193
|
+
},
|
|
194
|
+
suffix: {
|
|
195
|
+
type: String,
|
|
196
|
+
trim: true,
|
|
197
|
+
maxlength: 32
|
|
198
|
+
},
|
|
199
|
+
preferred: {
|
|
200
|
+
type: String,
|
|
201
|
+
trim: true,
|
|
202
|
+
maxlength: 120
|
|
203
|
+
}
|
|
204
|
+
}, { _id: false });
|
|
205
|
+
const contactPointSchema = new Schema({
|
|
206
|
+
type: {
|
|
207
|
+
type: String,
|
|
208
|
+
enum: CONTACT_POINT_TYPES,
|
|
209
|
+
required: true
|
|
210
|
+
},
|
|
211
|
+
value: {
|
|
212
|
+
type: String,
|
|
213
|
+
required: true,
|
|
214
|
+
trim: true,
|
|
215
|
+
maxlength: 320
|
|
216
|
+
},
|
|
217
|
+
key: {
|
|
218
|
+
type: String,
|
|
219
|
+
required: true
|
|
220
|
+
},
|
|
221
|
+
isPrimary: {
|
|
222
|
+
type: Boolean,
|
|
223
|
+
default: false
|
|
224
|
+
},
|
|
225
|
+
verified: {
|
|
226
|
+
type: Boolean,
|
|
227
|
+
default: false
|
|
228
|
+
}
|
|
229
|
+
}, { _id: false });
|
|
230
|
+
const addressSchema = new Schema({
|
|
231
|
+
type: {
|
|
232
|
+
type: String,
|
|
233
|
+
enum: ADDRESS_TYPES,
|
|
234
|
+
default: "contact"
|
|
235
|
+
},
|
|
236
|
+
isDefault: {
|
|
237
|
+
type: Boolean,
|
|
238
|
+
default: false
|
|
239
|
+
},
|
|
240
|
+
label: {
|
|
241
|
+
type: String,
|
|
242
|
+
trim: true,
|
|
243
|
+
maxlength: 120
|
|
244
|
+
},
|
|
245
|
+
name: {
|
|
246
|
+
type: String,
|
|
247
|
+
trim: true,
|
|
248
|
+
maxlength: 200
|
|
249
|
+
},
|
|
250
|
+
phone: {
|
|
251
|
+
type: String,
|
|
252
|
+
trim: true,
|
|
253
|
+
maxlength: 50
|
|
254
|
+
},
|
|
255
|
+
email: {
|
|
256
|
+
type: String,
|
|
257
|
+
trim: true,
|
|
258
|
+
lowercase: true,
|
|
259
|
+
maxlength: 320
|
|
260
|
+
},
|
|
261
|
+
line1: {
|
|
262
|
+
type: String,
|
|
263
|
+
required: true,
|
|
264
|
+
trim: true,
|
|
265
|
+
maxlength: 300
|
|
266
|
+
},
|
|
267
|
+
line2: {
|
|
268
|
+
type: String,
|
|
269
|
+
trim: true,
|
|
270
|
+
maxlength: 300
|
|
271
|
+
},
|
|
272
|
+
city: {
|
|
273
|
+
type: String,
|
|
274
|
+
required: true,
|
|
275
|
+
trim: true,
|
|
276
|
+
maxlength: 150
|
|
277
|
+
},
|
|
278
|
+
state: {
|
|
279
|
+
type: String,
|
|
280
|
+
trim: true,
|
|
281
|
+
maxlength: 150
|
|
282
|
+
},
|
|
283
|
+
postalCode: {
|
|
284
|
+
type: String,
|
|
285
|
+
trim: true,
|
|
286
|
+
maxlength: 40
|
|
287
|
+
},
|
|
288
|
+
country: {
|
|
289
|
+
type: String,
|
|
290
|
+
required: true,
|
|
291
|
+
trim: true,
|
|
292
|
+
maxlength: 2
|
|
293
|
+
}
|
|
294
|
+
}, { _id: false });
|
|
295
|
+
const taxIdentitySchema = new Schema({
|
|
296
|
+
vat: {
|
|
297
|
+
type: String,
|
|
298
|
+
trim: true,
|
|
299
|
+
maxlength: 64
|
|
300
|
+
},
|
|
301
|
+
bin: {
|
|
302
|
+
type: String,
|
|
303
|
+
trim: true,
|
|
304
|
+
maxlength: 64
|
|
305
|
+
},
|
|
306
|
+
fiscalPositionCode: {
|
|
307
|
+
type: String,
|
|
308
|
+
trim: true,
|
|
309
|
+
maxlength: 64
|
|
310
|
+
},
|
|
311
|
+
countryCode: {
|
|
312
|
+
type: String,
|
|
313
|
+
trim: true,
|
|
314
|
+
maxlength: 2
|
|
315
|
+
}
|
|
316
|
+
}, { _id: false });
|
|
317
|
+
const externalRefSchema$1 = new Schema({
|
|
318
|
+
sourceId: {
|
|
319
|
+
type: String,
|
|
320
|
+
required: true
|
|
321
|
+
},
|
|
322
|
+
sourceModel: {
|
|
323
|
+
type: String,
|
|
324
|
+
required: true
|
|
325
|
+
}
|
|
326
|
+
}, { _id: false });
|
|
327
|
+
function buildPartySchema(config) {
|
|
328
|
+
const tenantField = tenantFieldDef(config);
|
|
329
|
+
const schema = new Schema({
|
|
330
|
+
...tenantField ? { organizationId: tenantField } : {},
|
|
331
|
+
kind: {
|
|
332
|
+
type: String,
|
|
333
|
+
enum: PARTY_KINDS,
|
|
334
|
+
required: true,
|
|
335
|
+
default: "person"
|
|
336
|
+
},
|
|
337
|
+
displayName: {
|
|
338
|
+
type: String,
|
|
339
|
+
required: true,
|
|
340
|
+
trim: true,
|
|
341
|
+
maxlength: 300
|
|
342
|
+
},
|
|
343
|
+
legalName: {
|
|
344
|
+
type: String,
|
|
345
|
+
trim: true,
|
|
346
|
+
maxlength: 300
|
|
347
|
+
},
|
|
348
|
+
personName: { type: personNameSchema },
|
|
349
|
+
contactPoints: {
|
|
350
|
+
type: [contactPointSchema],
|
|
351
|
+
default: []
|
|
352
|
+
},
|
|
353
|
+
identityKeys: {
|
|
354
|
+
type: [String],
|
|
355
|
+
default: []
|
|
356
|
+
},
|
|
357
|
+
addresses: {
|
|
358
|
+
type: [addressSchema],
|
|
359
|
+
default: []
|
|
360
|
+
},
|
|
361
|
+
taxIdentity: { type: taxIdentitySchema },
|
|
362
|
+
roles: {
|
|
363
|
+
type: [String],
|
|
364
|
+
enum: PARTY_ROLES,
|
|
365
|
+
default: []
|
|
366
|
+
},
|
|
367
|
+
externalRefs: {
|
|
368
|
+
type: [externalRefSchema$1],
|
|
369
|
+
default: []
|
|
370
|
+
},
|
|
371
|
+
userLinks: {
|
|
372
|
+
type: [String],
|
|
373
|
+
default: []
|
|
374
|
+
},
|
|
375
|
+
parentId: {
|
|
376
|
+
type: Schema.Types.ObjectId,
|
|
377
|
+
ref: "Party"
|
|
378
|
+
},
|
|
379
|
+
status: {
|
|
380
|
+
type: String,
|
|
381
|
+
enum: PARTY_STATUSES,
|
|
382
|
+
default: "active",
|
|
383
|
+
index: true
|
|
384
|
+
},
|
|
385
|
+
mergedInto: {
|
|
386
|
+
type: Schema.Types.ObjectId,
|
|
387
|
+
ref: "Party"
|
|
388
|
+
},
|
|
389
|
+
tags: {
|
|
390
|
+
type: [String],
|
|
391
|
+
default: []
|
|
392
|
+
},
|
|
393
|
+
metadata: { type: Schema.Types.Mixed },
|
|
394
|
+
version: {
|
|
395
|
+
type: Number,
|
|
396
|
+
default: 0
|
|
397
|
+
}
|
|
398
|
+
}, {
|
|
399
|
+
timestamps: true,
|
|
400
|
+
toJSON: { virtuals: true },
|
|
401
|
+
toObject: { virtuals: true }
|
|
402
|
+
});
|
|
403
|
+
const scoped = (fields) => config.multiTenant ? {
|
|
404
|
+
organizationId: 1,
|
|
405
|
+
...fields
|
|
406
|
+
} : fields;
|
|
407
|
+
schema.index(scoped({ identityKeys: 1 }), { unique: true });
|
|
408
|
+
schema.index(scoped({
|
|
409
|
+
roles: 1,
|
|
410
|
+
status: 1
|
|
411
|
+
}));
|
|
412
|
+
schema.index(scoped({ createdAt: -1 }));
|
|
413
|
+
schema.index(scoped({ parentId: 1 }));
|
|
414
|
+
schema.index(scoped({ userLinks: 1 }));
|
|
415
|
+
schema.index({
|
|
416
|
+
"externalRefs.sourceModel": 1,
|
|
417
|
+
"externalRefs.sourceId": 1
|
|
418
|
+
});
|
|
419
|
+
schema.virtual("primaryEmail").get(function() {
|
|
420
|
+
const cps = this.contactPoints ?? [];
|
|
421
|
+
return (cps.find((c) => c.type === "email" && c.isPrimary) ?? cps.find((c) => c.type === "email"))?.value;
|
|
422
|
+
});
|
|
423
|
+
schema.virtual("primaryPhone").get(function() {
|
|
424
|
+
const cps = this.contactPoints ?? [];
|
|
425
|
+
return (cps.find((c) => c.type === "phone" && c.isPrimary) ?? cps.find((c) => c.type === "phone"))?.value;
|
|
426
|
+
});
|
|
427
|
+
return schema;
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/models/create-models.ts
|
|
431
|
+
/** Mongoose model names this package registers — drives the §21 collision check. */
|
|
432
|
+
const PARTY_MODEL_NAMES = ["Party"];
|
|
433
|
+
function createPartyModels(connection, config) {
|
|
434
|
+
const forceRecreate = config.forceRecreate ?? false;
|
|
435
|
+
const autoIndex = config.autoIndex ?? true;
|
|
436
|
+
if (forceRecreate) {
|
|
437
|
+
for (const name of PARTY_MODEL_NAMES) if (connection.models[name]) connection.deleteModel(name);
|
|
438
|
+
} else for (const name of PARTY_MODEL_NAMES) if (connection.models[name]) throw new PartyModelCollisionError(name);
|
|
439
|
+
const register = (name, schema) => {
|
|
440
|
+
schema.set("autoIndex", autoIndex);
|
|
441
|
+
return connection.model(name, schema);
|
|
442
|
+
};
|
|
443
|
+
return Object.freeze({ Party: register("Party", buildPartySchema(config)) });
|
|
444
|
+
}
|
|
445
|
+
//#endregion
|
|
446
|
+
//#region src/domain/state-machine.ts
|
|
447
|
+
/**
|
|
448
|
+
* Party identity lifecycle FSM — the compile-time twin of the `repo.claim()`
|
|
449
|
+
* CAS the repository uses for status transitions (PACKAGE_RULES §Concurrency).
|
|
450
|
+
*
|
|
451
|
+
* active ──archive──▶ archived ──restore──▶ active
|
|
452
|
+
* active ──merge────▶ merged (terminal; row survives as a `mergedInto` redirect)
|
|
453
|
+
*
|
|
454
|
+
* `merged` is terminal: a merged party is never resurrected — historical
|
|
455
|
+
* references resolve THROUGH it to the survivor, so the identity graph stays
|
|
456
|
+
* append-only and auditable.
|
|
457
|
+
*/
|
|
458
|
+
const PARTY_STATE_MACHINE = defineStateMachine({
|
|
459
|
+
name: "party",
|
|
460
|
+
transitions: {
|
|
461
|
+
active: ["archived", "merged"],
|
|
462
|
+
archived: ["active", "merged"],
|
|
463
|
+
merged: []
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/domain/normalize.ts
|
|
468
|
+
/**
|
|
469
|
+
* Identity-key normalization — pure, unit-tested. Derives the `identityKeys[]`
|
|
470
|
+
* an entity is deduped on: the SAME real-world email/phone always yields the
|
|
471
|
+
* SAME key regardless of casing/spacing/punctuation, so `resolveOrCreate` finds
|
|
472
|
+
* the existing party instead of forking a duplicate.
|
|
473
|
+
*
|
|
474
|
+
* Phone normalization DELEGATES to `@classytic/primitives/phone` (the canonical
|
|
475
|
+
* E.164 parser) — we never re-implement phone parsing (PACKAGE_RULES §P12).
|
|
476
|
+
* Email normalization is a conservative trim+lowercase: aggressive canonical-
|
|
477
|
+
* ization (gmail dot/plus stripping) is WRONG for identity — `a+shop@x.com` is
|
|
478
|
+
* a real, distinct inbox.
|
|
479
|
+
*/
|
|
480
|
+
/** Lowercase + trim an email. Conservative — never strips dots/plus tags. */
|
|
481
|
+
function normalizeEmail(raw) {
|
|
482
|
+
return raw.trim().toLowerCase();
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Canonical phone form for dedup. Uses primitives' `parsePhone`: a valid E.164
|
|
486
|
+
* input collapses to its canonical `+<cc><national>`; anything the strict parser
|
|
487
|
+
* rejects (local formats without a country code) falls back to a digit-only
|
|
488
|
+
* key so at least same-format duplicates still collapse. Hosts wanting global
|
|
489
|
+
* dedup pass already-E.164 numbers.
|
|
490
|
+
*/
|
|
491
|
+
function normalizePhone(raw) {
|
|
492
|
+
const parsed = parsePhone(raw.trim());
|
|
493
|
+
if (isOk(parsed)) return parsed.value.e164;
|
|
494
|
+
const digits = raw.replace(/\D/g, "");
|
|
495
|
+
return digits ? `+${digits}` : "";
|
|
496
|
+
}
|
|
497
|
+
/** 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)}`;
|
|
500
|
+
}
|
|
501
|
+
/** A tax-identity dedup key (`vat:...` / `bin:...`) — cross-scope-stable statutory id. */
|
|
502
|
+
function taxIdentityKey(kind, value) {
|
|
503
|
+
return `${kind}:${value.trim().toUpperCase().replace(/\s+/g, "")}`;
|
|
504
|
+
}
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/domain/identity.ts
|
|
507
|
+
/**
|
|
508
|
+
* Identity derivation — PURE functions that turn a `ResolvePartyInput` into the
|
|
509
|
+
* contact points, dedup keys, and display name the repository persists. Kept
|
|
510
|
+
* out of the repository so they are trivially unit-testable in isolation (no
|
|
511
|
+
* Mongo) and the repository stays pure orchestration.
|
|
512
|
+
*/
|
|
513
|
+
/** De-duplicate an array preserving first-seen order. */
|
|
514
|
+
function dedupe(arr) {
|
|
515
|
+
return [...new Set(arr)];
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Turn the input's `emails[]` / `phones[]` into normalized `ContactPoint`s. The
|
|
519
|
+
* first email and first phone are marked primary; blanks are skipped.
|
|
520
|
+
*/
|
|
521
|
+
function buildContactPoints(input) {
|
|
522
|
+
const points = [];
|
|
523
|
+
(input.emails ?? []).forEach((value, i) => {
|
|
524
|
+
if (!value.trim()) return;
|
|
525
|
+
points.push({
|
|
526
|
+
type: "email",
|
|
527
|
+
value,
|
|
528
|
+
key: contactPointKey("email", value),
|
|
529
|
+
isPrimary: i === 0,
|
|
530
|
+
verified: false
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
(input.phones ?? []).forEach((value, i) => {
|
|
534
|
+
if (!value.trim()) return;
|
|
535
|
+
points.push({
|
|
536
|
+
type: "phone",
|
|
537
|
+
value,
|
|
538
|
+
key: contactPointKey("phone", value),
|
|
539
|
+
isPrimary: i === 0,
|
|
540
|
+
verified: false
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
return points;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The de-dup keys a party is resolved on: normalized emails/phones, VAT/BIN
|
|
547
|
+
* statutory ids, and external-store refs. Blank/degenerate keys are dropped so
|
|
548
|
+
* they never poison the unique index.
|
|
549
|
+
*/
|
|
550
|
+
function deriveIdentityKeys(contactPoints, input) {
|
|
551
|
+
const keys = contactPoints.map((c) => c.key);
|
|
552
|
+
if (input.taxIdentity?.vat) keys.push(taxIdentityKey("vat", input.taxIdentity.vat));
|
|
553
|
+
if (input.taxIdentity?.bin) keys.push(taxIdentityKey("bin", input.taxIdentity.bin));
|
|
554
|
+
for (const ref of input.externalRefs ?? []) keys.push(`ref:${ref.sourceModel}:${ref.sourceId}`);
|
|
555
|
+
return dedupe(keys.filter((k) => k && !k.endsWith(":")));
|
|
556
|
+
}
|
|
557
|
+
/** Best display name from the available signals — never empty. */
|
|
558
|
+
function resolveDisplayName(input, contactPoints) {
|
|
559
|
+
if (input.displayName?.trim()) return input.displayName.trim();
|
|
560
|
+
if (input.personName) {
|
|
561
|
+
const { given, family, preferred } = input.personName;
|
|
562
|
+
const composed = (preferred?.trim() || [given, family].filter(Boolean).join(" ")).trim();
|
|
563
|
+
if (composed) return composed;
|
|
564
|
+
}
|
|
565
|
+
if (input.legalName?.trim()) return input.legalName.trim();
|
|
566
|
+
const firstEmail = contactPoints.find((c) => c.type === "email");
|
|
567
|
+
if (firstEmail) return firstEmail.value;
|
|
568
|
+
const firstPhone = contactPoints.find((c) => c.type === "phone");
|
|
569
|
+
if (firstPhone) return firstPhone.value;
|
|
570
|
+
return "Unnamed party";
|
|
571
|
+
}
|
|
572
|
+
/** The primary (or first) contact-point value of a channel type. */
|
|
573
|
+
function pickPrimary(contactPoints, type) {
|
|
574
|
+
const cps = contactPoints ?? [];
|
|
575
|
+
return (cps.find((c) => c.type === type && c.isPrimary) ?? cps.find((c) => c.type === type))?.value;
|
|
576
|
+
}
|
|
577
|
+
//#endregion
|
|
578
|
+
//#region src/domain/merge.ts
|
|
579
|
+
/** Union two string arrays, first-seen order, no duplicates. */
|
|
580
|
+
function unionStrings(a, b) {
|
|
581
|
+
return [.../* @__PURE__ */ new Set([...a, ...b])];
|
|
582
|
+
}
|
|
583
|
+
/** Union external refs by `(sourceModel, sourceId)` identity. */
|
|
584
|
+
function unionRefs(a, b) {
|
|
585
|
+
const key = (r) => `${r.sourceModel}:${r.sourceId}`;
|
|
586
|
+
const seen = new Set(a.map(key));
|
|
587
|
+
const out = [...a];
|
|
588
|
+
for (const r of b) if (!seen.has(key(r))) {
|
|
589
|
+
seen.add(key(r));
|
|
590
|
+
out.push(r);
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Union contact points by their normalized `key`. The survivor keeps its own
|
|
596
|
+
* primaries; absorbed points come in as non-primary (a merge must never create
|
|
597
|
+
* two primaries of the same channel).
|
|
598
|
+
*/
|
|
599
|
+
function unionContacts(a, b) {
|
|
600
|
+
const seen = new Set(a.map((c) => c.key));
|
|
601
|
+
const out = [...a];
|
|
602
|
+
for (const c of b) if (!seen.has(c.key)) {
|
|
603
|
+
seen.add(c.key);
|
|
604
|
+
out.push({
|
|
605
|
+
...c,
|
|
606
|
+
isPrimary: false
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
//#endregion
|
|
612
|
+
//#region src/events/event-constants.ts
|
|
613
|
+
/** PACKAGE_RULES §12: `package:resource.verb`. Emit ONLY these catalog names. */
|
|
614
|
+
const PARTY_EVENTS = {
|
|
615
|
+
CREATED: "party:party.created",
|
|
616
|
+
UPDATED: "party:party.updated",
|
|
617
|
+
ROLE_ADDED: "party:party.role_added",
|
|
618
|
+
ROLE_REMOVED: "party:party.role_removed",
|
|
619
|
+
USER_LINKED: "party:party.user_linked",
|
|
620
|
+
EXTERNAL_REF_ATTACHED: "party:party.external_ref_attached",
|
|
621
|
+
ARCHIVED: "party:party.archived",
|
|
622
|
+
RESTORED: "party:party.restored",
|
|
623
|
+
MERGED: "party:party.merged"
|
|
624
|
+
};
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/events/helpers.ts
|
|
627
|
+
/**
|
|
628
|
+
* `createEvent` — thin wrapper over primitives' `createEvent` that stamps
|
|
629
|
+
* `source: 'party'` and lifts `actorId`/`organizationId`/`correlationId` off the
|
|
630
|
+
* `PartyContext`. Use this instead of importing `createEvent` directly so the
|
|
631
|
+
* source stamp + meta stay consistent (PACKAGE_RULES §14).
|
|
632
|
+
*/
|
|
633
|
+
function createEvent(type, payload, ctx, meta) {
|
|
634
|
+
return createEvent$1(type, payload, {
|
|
635
|
+
source: "party",
|
|
636
|
+
...ctx?.actorId !== void 0 ? { actorId: String(ctx.actorId) } : {},
|
|
637
|
+
...ctx?.organizationId !== void 0 ? { organizationId: String(ctx.organizationId) } : {},
|
|
638
|
+
...ctx?.correlationId !== void 0 ? { correlationId: ctx.correlationId } : {},
|
|
639
|
+
...meta
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
//#endregion
|
|
643
|
+
//#region src/repositories/repo-options.ts
|
|
644
|
+
function repoOptionsFromCtx(ctx) {
|
|
645
|
+
return {
|
|
646
|
+
...ctx.session !== void 0 ? { session: ctx.session } : {},
|
|
647
|
+
...ctx.signal !== void 0 ? { signal: ctx.signal } : {},
|
|
648
|
+
...ctx.retryPolicy !== void 0 ? { retryPolicy: ctx.retryPolicy } : {},
|
|
649
|
+
...ctx.organizationId !== void 0 ? { organizationId: ctx.organizationId } : {}
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
//#endregion
|
|
653
|
+
//#region src/repositories/party.repository.ts
|
|
654
|
+
/**
|
|
655
|
+
* PartyRepository — the API surface (PACKAGE_RULES §1) and the concrete
|
|
656
|
+
* `PartyPort`. Domain verbs only; CRUD/pagination/query come from mongokit.
|
|
657
|
+
*
|
|
658
|
+
* The identity guarantees:
|
|
659
|
+
* • `resolveOrCreate` is race-safe — a unique multikey index on `identityKeys`
|
|
660
|
+
* is the authoritative guard; a concurrent create that loses re-resolves to
|
|
661
|
+
* the winner (§Concurrency: no check-then-save, E11000 → re-read).
|
|
662
|
+
* • `merge` transfers the loser's identity keys to the survivor and turns the
|
|
663
|
+
* loser into a terminal `merged` redirect — historical refs still resolve.
|
|
664
|
+
*/
|
|
665
|
+
const OBJECT_ID_HEX = /^[a-f0-9]{24}$/i;
|
|
666
|
+
var PartyRepository = class extends Repository {
|
|
667
|
+
events;
|
|
668
|
+
constructor(model, deps, plugins = []) {
|
|
669
|
+
super(model, plugins);
|
|
670
|
+
this.events = deps.events;
|
|
671
|
+
}
|
|
672
|
+
/** `update` that never returns null — the party was loaded first; a null means a concurrent delete. */
|
|
673
|
+
async mustUpdate(id, patch, ctx) {
|
|
674
|
+
const updated = await this.update(id, patch, repoOptionsFromCtx(ctx));
|
|
675
|
+
if (!updated) throw new PartyNotFoundError(String(id));
|
|
676
|
+
return updated;
|
|
677
|
+
}
|
|
678
|
+
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 displayName.");
|
|
682
|
+
if (keys.length > 0) {
|
|
683
|
+
const existing = await this.getByQuery({
|
|
684
|
+
identityKeys: { $in: keys },
|
|
685
|
+
status: { $ne: "merged" }
|
|
686
|
+
}, {
|
|
687
|
+
...repoOptionsFromCtx(ctx),
|
|
688
|
+
throwOnNotFound: false
|
|
689
|
+
});
|
|
690
|
+
if (existing) return {
|
|
691
|
+
party: await this.enrich(existing, input, ctx),
|
|
692
|
+
created: false
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
const displayName = resolveDisplayName(input, contactPoints);
|
|
696
|
+
try {
|
|
697
|
+
const created = await this.create({
|
|
698
|
+
kind: input.kind ?? (input.personName ? "person" : "company"),
|
|
699
|
+
displayName,
|
|
700
|
+
...input.legalName !== void 0 ? { legalName: input.legalName } : {},
|
|
701
|
+
...input.personName !== void 0 ? { personName: input.personName } : {},
|
|
702
|
+
contactPoints,
|
|
703
|
+
identityKeys: keys,
|
|
704
|
+
...input.addresses !== void 0 ? { addresses: input.addresses } : {},
|
|
705
|
+
...input.taxIdentity !== void 0 ? { taxIdentity: input.taxIdentity } : {},
|
|
706
|
+
roles: dedupe(input.roles ?? []),
|
|
707
|
+
externalRefs: input.externalRefs ?? [],
|
|
708
|
+
userLinks: input.userId ? [input.userId] : [],
|
|
709
|
+
...input.parentId !== void 0 ? { parentId: input.parentId } : {},
|
|
710
|
+
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
711
|
+
}, repoOptionsFromCtx(ctx));
|
|
712
|
+
await this.dispatch(PARTY_EVENTS.CREATED, {
|
|
713
|
+
partyId: String(created._id),
|
|
714
|
+
kind: created.kind,
|
|
715
|
+
displayName: created.displayName,
|
|
716
|
+
roles: created.roles,
|
|
717
|
+
...ctx.organizationId !== void 0 ? { organizationId: String(ctx.organizationId) } : {}
|
|
718
|
+
}, created, ctx);
|
|
719
|
+
return {
|
|
720
|
+
party: created,
|
|
721
|
+
created: true
|
|
722
|
+
};
|
|
723
|
+
} catch (err) {
|
|
724
|
+
if (isDuplicateKeyError(err) && keys.length > 0) {
|
|
725
|
+
const winner = await this.getByQuery({
|
|
726
|
+
identityKeys: { $in: keys },
|
|
727
|
+
status: { $ne: "merged" }
|
|
728
|
+
}, {
|
|
729
|
+
...repoOptionsFromCtx(ctx),
|
|
730
|
+
throwOnNotFound: false
|
|
731
|
+
});
|
|
732
|
+
if (winner) return {
|
|
733
|
+
party: await this.enrich(winner, input, ctx),
|
|
734
|
+
created: false
|
|
735
|
+
};
|
|
736
|
+
throw new PartyResolveConflictError(keys[0] ?? "");
|
|
737
|
+
}
|
|
738
|
+
throw err;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async resolveMany(inputs, ctx) {
|
|
742
|
+
const results = [];
|
|
743
|
+
for (const input of inputs) results.push(await this.resolveOrCreate(input, ctx));
|
|
744
|
+
return results;
|
|
745
|
+
}
|
|
746
|
+
async snapshot(partyId, ctx) {
|
|
747
|
+
let party = await this.loadParty(partyId, ctx);
|
|
748
|
+
if (party.status === "merged" && party.mergedInto) party = await this.loadParty(String(party.mergedInto), ctx);
|
|
749
|
+
const primaryEmail = pickPrimary(party.contactPoints, "email");
|
|
750
|
+
const primaryPhone = pickPrimary(party.contactPoints, "phone");
|
|
751
|
+
return {
|
|
752
|
+
partyId: String(party._id),
|
|
753
|
+
kind: party.kind,
|
|
754
|
+
displayName: party.displayName,
|
|
755
|
+
...party.legalName !== void 0 ? { legalName: party.legalName } : {},
|
|
756
|
+
...primaryEmail !== void 0 ? { primaryEmail } : {},
|
|
757
|
+
...primaryPhone !== void 0 ? { primaryPhone } : {},
|
|
758
|
+
roles: party.roles,
|
|
759
|
+
status: party.status,
|
|
760
|
+
...party.taxIdentity !== void 0 ? { taxIdentity: party.taxIdentity } : {}
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Sync identity onto a party — the canonical "identity changed" verb. The
|
|
765
|
+
* party OWNS identity mutation (name, contact points, addresses, tax), so
|
|
766
|
+
* hosts push a facet's current identity here instead of editing it locally.
|
|
767
|
+
* NEW contact points fold in (deduped, deriving their keys + extending
|
|
768
|
+
* `identityKeys`); existing primaries are preserved. Idempotent — syncing the
|
|
769
|
+
* same identity is a no-op. Emits `party:party.updated`.
|
|
770
|
+
*/
|
|
771
|
+
async syncIdentity(id, input, ctx) {
|
|
772
|
+
const party = await this.loadParty(id, ctx);
|
|
773
|
+
const mergedContacts = unionContacts(party.contactPoints, buildContactPoints(input));
|
|
774
|
+
const set = {
|
|
775
|
+
contactPoints: mergedContacts,
|
|
776
|
+
identityKeys: unionStrings(party.identityKeys, deriveIdentityKeys(mergedContacts, input))
|
|
777
|
+
};
|
|
778
|
+
if (input.displayName?.trim()) set.displayName = input.displayName.trim();
|
|
779
|
+
if (input.legalName?.trim()) set.legalName = input.legalName.trim();
|
|
780
|
+
if (input.personName !== void 0) set.personName = input.personName;
|
|
781
|
+
if (input.addresses !== void 0) set.addresses = input.addresses;
|
|
782
|
+
if (input.taxIdentity !== void 0) set.taxIdentity = input.taxIdentity;
|
|
783
|
+
const updated = await this.mustUpdate(party._id, { $set: set }, ctx);
|
|
784
|
+
await this.dispatch(PARTY_EVENTS.UPDATED, {
|
|
785
|
+
partyId: String(party._id),
|
|
786
|
+
changed: Object.keys(set)
|
|
787
|
+
}, updated, ctx);
|
|
788
|
+
return updated;
|
|
789
|
+
}
|
|
790
|
+
async addRole(id, role, ctx) {
|
|
791
|
+
const party = await this.loadParty(id, ctx);
|
|
792
|
+
if (party.roles.includes(role)) return party;
|
|
793
|
+
const updated = await this.mustUpdate(party._id, { $addToSet: { roles: role } }, ctx);
|
|
794
|
+
await this.dispatch(PARTY_EVENTS.ROLE_ADDED, {
|
|
795
|
+
partyId: String(party._id),
|
|
796
|
+
role
|
|
797
|
+
}, updated, ctx);
|
|
798
|
+
return updated;
|
|
799
|
+
}
|
|
800
|
+
async removeRole(id, role, ctx) {
|
|
801
|
+
const party = await this.loadParty(id, ctx);
|
|
802
|
+
if (!party.roles.includes(role)) return party;
|
|
803
|
+
const updated = await this.mustUpdate(party._id, { $pull: { roles: role } }, ctx);
|
|
804
|
+
await this.dispatch(PARTY_EVENTS.ROLE_REMOVED, {
|
|
805
|
+
partyId: String(party._id),
|
|
806
|
+
role
|
|
807
|
+
}, updated, ctx);
|
|
808
|
+
return updated;
|
|
809
|
+
}
|
|
810
|
+
async linkUser(id, userId, ctx) {
|
|
811
|
+
const party = await this.loadParty(id, ctx);
|
|
812
|
+
if (party.userLinks.includes(userId)) return party;
|
|
813
|
+
const updated = await this.mustUpdate(party._id, { $addToSet: { userLinks: userId } }, ctx);
|
|
814
|
+
await this.dispatch(PARTY_EVENTS.USER_LINKED, {
|
|
815
|
+
partyId: String(party._id),
|
|
816
|
+
userId
|
|
817
|
+
}, updated, ctx);
|
|
818
|
+
return updated;
|
|
819
|
+
}
|
|
820
|
+
async attachExternalRef(id, ref, ctx) {
|
|
821
|
+
const party = await this.loadParty(id, ctx);
|
|
822
|
+
if (party.externalRefs.some((r) => r.sourceModel === ref.sourceModel && r.sourceId === ref.sourceId)) return party;
|
|
823
|
+
const updated = await this.mustUpdate(party._id, { $addToSet: { externalRefs: ref } }, ctx);
|
|
824
|
+
await this.dispatch(PARTY_EVENTS.EXTERNAL_REF_ATTACHED, {
|
|
825
|
+
partyId: String(party._id),
|
|
826
|
+
sourceModel: ref.sourceModel,
|
|
827
|
+
sourceId: ref.sourceId
|
|
828
|
+
}, updated, ctx);
|
|
829
|
+
return updated;
|
|
830
|
+
}
|
|
831
|
+
async archive(id, ctx) {
|
|
832
|
+
const party = await this.loadParty(id, ctx);
|
|
833
|
+
PARTY_STATE_MACHINE.assertTransition(String(party._id), party.status, "archived");
|
|
834
|
+
const claimed = await this.claim(String(party._id), {
|
|
835
|
+
from: "active",
|
|
836
|
+
to: "archived"
|
|
837
|
+
}, void 0, repoOptionsFromCtx(ctx));
|
|
838
|
+
if (!claimed) throw new PartyVersionConflictError(id, party.version);
|
|
839
|
+
await this.dispatch(PARTY_EVENTS.ARCHIVED, { partyId: String(party._id) }, claimed, ctx);
|
|
840
|
+
return claimed;
|
|
841
|
+
}
|
|
842
|
+
async restore(id, ctx) {
|
|
843
|
+
const party = await this.loadParty(id, ctx);
|
|
844
|
+
PARTY_STATE_MACHINE.assertTransition(String(party._id), party.status, "active");
|
|
845
|
+
const claimed = await this.claim(String(party._id), {
|
|
846
|
+
from: "archived",
|
|
847
|
+
to: "active"
|
|
848
|
+
}, void 0, repoOptionsFromCtx(ctx));
|
|
849
|
+
if (!claimed) throw new PartyVersionConflictError(id, party.version);
|
|
850
|
+
await this.dispatch(PARTY_EVENTS.RESTORED, { partyId: String(party._id) }, claimed, ctx);
|
|
851
|
+
return claimed;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Merge `sourceId` into `targetId`: the survivor absorbs the loser's identity
|
|
855
|
+
* keys, contact points, addresses, roles, external refs, and user links; the
|
|
856
|
+
* loser becomes a terminal `merged` redirect (`mergedInto`). Two-document
|
|
857
|
+
* write → runs in a transaction (falls back to sequential on standalone Mongo).
|
|
858
|
+
*/
|
|
859
|
+
async merge(sourceId, targetId, ctx) {
|
|
860
|
+
if (sourceId === targetId) throw new PartyMergeError("Cannot merge a party into itself.", sourceId, targetId);
|
|
861
|
+
const source = await this.loadParty(sourceId, ctx);
|
|
862
|
+
const target = await this.loadParty(targetId, ctx);
|
|
863
|
+
if (source.status === "merged") throw new PartyMergeError("Source party is already merged.", sourceId, targetId);
|
|
864
|
+
if (target.status === "merged") throw new PartyMergeError("Target party is already merged (would create a redirect chain).", sourceId, targetId);
|
|
865
|
+
const mergedKeys = unionStrings(target.identityKeys, source.identityKeys);
|
|
866
|
+
const mergedRoles = unionStrings(target.roles, source.roles);
|
|
867
|
+
const mergedUsers = unionStrings(target.userLinks, source.userLinks);
|
|
868
|
+
const mergedRefs = unionRefs(target.externalRefs, source.externalRefs);
|
|
869
|
+
const mergedContacts = unionContacts(target.contactPoints, source.contactPoints);
|
|
870
|
+
const absorbed = mergedKeys.length - target.identityKeys.length;
|
|
871
|
+
await withTransaction(this.Model.db, async (session) => {
|
|
872
|
+
await this.update(source._id, { $set: {
|
|
873
|
+
status: "merged",
|
|
874
|
+
mergedInto: target._id,
|
|
875
|
+
identityKeys: []
|
|
876
|
+
} }, {
|
|
877
|
+
...repoOptionsFromCtx(ctx),
|
|
878
|
+
session
|
|
879
|
+
});
|
|
880
|
+
await this.update(target._id, { $set: {
|
|
881
|
+
identityKeys: mergedKeys,
|
|
882
|
+
roles: mergedRoles,
|
|
883
|
+
userLinks: mergedUsers,
|
|
884
|
+
externalRefs: mergedRefs,
|
|
885
|
+
contactPoints: mergedContacts
|
|
886
|
+
} }, {
|
|
887
|
+
...repoOptionsFromCtx(ctx),
|
|
888
|
+
session
|
|
889
|
+
});
|
|
890
|
+
}, { allowFallback: true });
|
|
891
|
+
const survivor = await this.loadParty(targetId, ctx);
|
|
892
|
+
await this.dispatch(PARTY_EVENTS.MERGED, {
|
|
893
|
+
sourceId,
|
|
894
|
+
targetId,
|
|
895
|
+
absorbedKeys: Math.max(0, absorbed)
|
|
896
|
+
}, survivor, ctx);
|
|
897
|
+
return survivor;
|
|
898
|
+
}
|
|
899
|
+
/** Load a party by ObjectId hex, or throw the typed not-found (§P9-style). */
|
|
900
|
+
async loadParty(id, ctx) {
|
|
901
|
+
const party = await this.getById(id, {
|
|
902
|
+
...repoOptionsFromCtx(ctx),
|
|
903
|
+
throwOnNotFound: false,
|
|
904
|
+
...OBJECT_ID_HEX.test(id) ? { idField: "_id" } : {}
|
|
905
|
+
});
|
|
906
|
+
if (!party) throw new PartyNotFoundError(id);
|
|
907
|
+
return party;
|
|
908
|
+
}
|
|
909
|
+
/** Add any new roles/refs/user/keys from `input` onto an already-resolved party. */
|
|
910
|
+
async enrich(party, input, ctx) {
|
|
911
|
+
const add = {};
|
|
912
|
+
const newRoles = (input.roles ?? []).filter((r) => !party.roles.includes(r));
|
|
913
|
+
if (newRoles.length > 0) add.roles = { $each: newRoles };
|
|
914
|
+
if (input.userId && !party.userLinks.includes(input.userId)) add.userLinks = input.userId;
|
|
915
|
+
const newRefs = (input.externalRefs ?? []).filter((r) => !party.externalRefs.some((e) => e.sourceModel === r.sourceModel && e.sourceId === r.sourceId));
|
|
916
|
+
if (newRefs.length > 0) add.externalRefs = { $each: newRefs };
|
|
917
|
+
if (Object.keys(add).length === 0) return party;
|
|
918
|
+
const updated = await this.mustUpdate(party._id, { $addToSet: add }, ctx);
|
|
919
|
+
for (const role of newRoles) await this.dispatch(PARTY_EVENTS.ROLE_ADDED, {
|
|
920
|
+
partyId: String(party._id),
|
|
921
|
+
role
|
|
922
|
+
}, updated, ctx);
|
|
923
|
+
return updated;
|
|
924
|
+
}
|
|
925
|
+
/** Route a domain event with meta stamped from ctx + the party's org. */
|
|
926
|
+
async dispatch(type, payload, party, ctx) {
|
|
927
|
+
await this.events.dispatch(createEvent(type, payload, ctx, {
|
|
928
|
+
resource: "party",
|
|
929
|
+
resourceId: String(party._id)
|
|
930
|
+
}), ctx);
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
//#endregion
|
|
934
|
+
//#region src/events/dispatch.ts
|
|
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
|
|
995
|
+
/**
|
|
996
|
+
* In-process event bus — default fallback when no `eventTransport` is given to
|
|
997
|
+
* `createParty`. Structurally identical to arc's `MemoryEventTransport`;
|
|
998
|
+
* pattern-matching delegates to primitives' `matchEventPattern` (PACKAGE_RULES
|
|
999
|
+
* §13). Handler errors are swallowed-and-logged — emission never blocks writes.
|
|
1000
|
+
*/
|
|
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
|
+
function assertPartyCapabilities(repo, opts = {}) {
|
|
1033
|
+
const caps = repo.capabilities;
|
|
1034
|
+
if (!caps) throw new Error("[party] backend declares no `capabilities` descriptor (needs repo-core 0.6 / mongokit 3.16+).");
|
|
1035
|
+
const missing = ["duplicateKeyError"].filter((flag) => caps[flag] !== true);
|
|
1036
|
+
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
|
+
if (caps.transactions !== true) {
|
|
1038
|
+
const msg = "[party] backend does not support transactions — `merge` writes two documents and needs a replica set for atomicity.";
|
|
1039
|
+
if (opts.allowNonTransactional) opts.logger?.warn?.(`${msg} allowNonTransactional=true: merge falls back to sequential writes (partial-write risk).`);
|
|
1040
|
+
else throw new Error(`${msg} Run MongoDB as a replica set, or pass allowNonTransactional: true.`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
//#endregion
|
|
1044
|
+
//#region src/engine/run-unit.ts
|
|
1045
|
+
/**
|
|
1046
|
+
* runUnit — transaction + post-commit-publish discipline (PACKAGE_RULES §P8.1),
|
|
1047
|
+
* identical in contract to crm / refit / review / cart / invoice.
|
|
1048
|
+
*
|
|
1049
|
+
* 1. Events dispatched inside the unit are QUEUED (`ctx[PENDING_EVENTS]`) and
|
|
1050
|
+
* flushed only AFTER commit (the queue resets on retry) — a rollback can't
|
|
1051
|
+
* leak ghost events to in-process subscribers. Outbox rows stay inside the
|
|
1052
|
+
* transaction; the relay reads only committed rows.
|
|
1053
|
+
* 2. A caller-owned `ctx.session` reuses the caller's queue when present.
|
|
1054
|
+
*/
|
|
1055
|
+
async function runUnit(config, ctx, body) {
|
|
1056
|
+
const bag = ctx;
|
|
1057
|
+
const externalQueue = bag[PENDING_EVENTS];
|
|
1058
|
+
if (ctx.session) return body(externalQueue ? {
|
|
1059
|
+
...bag,
|
|
1060
|
+
[PENDING_EVENTS]: externalQueue
|
|
1061
|
+
} : ctx);
|
|
1062
|
+
const pending = [];
|
|
1063
|
+
const result = await withTransaction(config.connection, async (session) => {
|
|
1064
|
+
pending.length = 0;
|
|
1065
|
+
return body({
|
|
1066
|
+
...bag,
|
|
1067
|
+
session,
|
|
1068
|
+
[PENDING_EVENTS]: pending
|
|
1069
|
+
});
|
|
1070
|
+
}, config.allowNonTransactional ? { allowFallback: true } : {});
|
|
1071
|
+
await flushPending(config.events, pending, config.logger);
|
|
1072
|
+
return result;
|
|
1073
|
+
}
|
|
1074
|
+
//#endregion
|
|
1075
|
+
//#region src/engine/create-party.ts
|
|
1076
|
+
/**
|
|
1077
|
+
* createParty — engine factory for @classytic/party.
|
|
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.
|
|
1085
|
+
*
|
|
1086
|
+
* @example
|
|
1087
|
+
* ```ts
|
|
1088
|
+
* import mongoose from 'mongoose';
|
|
1089
|
+
* import { createParty } from '@classytic/party';
|
|
1090
|
+
*
|
|
1091
|
+
* const party = await createParty({ connection: mongoose.connection, multiTenant: false });
|
|
1092
|
+
*
|
|
1093
|
+
* const { party: p } = await party.port.resolveOrCreate(
|
|
1094
|
+
* { displayName: 'Ada Lovelace', emails: ['ada@x.com'], roles: ['customer'] },
|
|
1095
|
+
* {},
|
|
1096
|
+
* );
|
|
1097
|
+
* ```
|
|
1098
|
+
*/
|
|
1099
|
+
async function createParty(config) {
|
|
1100
|
+
if (!config.connection) throw new Error("[party] PartyConfig.connection is required");
|
|
1101
|
+
const resolved = {
|
|
1102
|
+
connection: config.connection,
|
|
1103
|
+
multiTenant: config.multiTenant ?? true,
|
|
1104
|
+
tenantFieldType: config.tenantFieldType ?? "objectId",
|
|
1105
|
+
forceRecreate: config.forceRecreate ?? false,
|
|
1106
|
+
autoIndex: config.autoIndex ?? true,
|
|
1107
|
+
allowNonTransactional: config.allowNonTransactional ?? false,
|
|
1108
|
+
...config.logger !== void 0 ? { logger: config.logger } : {},
|
|
1109
|
+
...config.pagination !== void 0 ? { pagination: config.pagination } : {},
|
|
1110
|
+
plugins: config.plugins ?? []
|
|
1111
|
+
};
|
|
1112
|
+
const models = createPartyModels(config.connection, resolved);
|
|
1113
|
+
const events = new PartyEventDispatcher({
|
|
1114
|
+
transport: config.eventTransport ?? new InProcessPartyBus(config.logger ? { logger: config.logger } : void 0),
|
|
1115
|
+
...config.outbox !== void 0 ? { outbox: config.outbox } : {},
|
|
1116
|
+
...config.logger !== void 0 ? { logger: config.logger } : {}
|
|
1117
|
+
});
|
|
1118
|
+
const plugins = [...resolved.multiTenant ? [multiTenantPlugin({
|
|
1119
|
+
tenantField: "organizationId",
|
|
1120
|
+
fieldType: resolved.tenantFieldType,
|
|
1121
|
+
required: true
|
|
1122
|
+
})] : [], ...resolved.plugins];
|
|
1123
|
+
const repository = new PartyRepository(models.Party, {
|
|
1124
|
+
events,
|
|
1125
|
+
...resolved.logger !== void 0 ? { logger: resolved.logger } : {}
|
|
1126
|
+
}, plugins);
|
|
1127
|
+
assertPartyCapabilities(repository, {
|
|
1128
|
+
allowNonTransactional: resolved.allowNonTransactional,
|
|
1129
|
+
...resolved.logger?.warn !== void 0 ? { logger: { warn: (m) => resolved.logger?.warn?.(m) } } : {}
|
|
1130
|
+
});
|
|
1131
|
+
return Object.freeze({
|
|
1132
|
+
models,
|
|
1133
|
+
repositories: Object.freeze({ party: repository }),
|
|
1134
|
+
port: repository,
|
|
1135
|
+
events,
|
|
1136
|
+
config: Object.freeze(resolved),
|
|
1137
|
+
withTransaction(ctx, body) {
|
|
1138
|
+
return runUnit({
|
|
1139
|
+
connection: config.connection,
|
|
1140
|
+
events,
|
|
1141
|
+
...resolved.logger !== void 0 ? { logger: resolved.logger } : {},
|
|
1142
|
+
allowNonTransactional: resolved.allowNonTransactional
|
|
1143
|
+
}, ctx, body);
|
|
1144
|
+
},
|
|
1145
|
+
async syncIndexes() {
|
|
1146
|
+
await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
|
|
1147
|
+
},
|
|
1148
|
+
async dispose() {
|
|
1149
|
+
await events.close();
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
//#endregion
|
|
1154
|
+
//#region src/events/event-catalog.ts
|
|
1155
|
+
/**
|
|
1156
|
+
* Zod-source event catalog (PACKAGE_RULES §18). One Zod schema per event is the
|
|
1157
|
+
* single source of truth — runtime validation, static types, and arc's
|
|
1158
|
+
* `EventRegistry` (OpenAPI + `arc describe`) all derive from it. Structurally
|
|
1159
|
+
* identical to arc's `EventDefinitionOutput`; we never import arc.
|
|
1160
|
+
*
|
|
1161
|
+
* Timestamps use `z.iso.datetime()`, never `z.coerce.date()` (§25).
|
|
1162
|
+
*/
|
|
1163
|
+
function definePartyEvent(input) {
|
|
1164
|
+
const { name, version = 1, description, zodSchema } = input;
|
|
1165
|
+
return {
|
|
1166
|
+
name,
|
|
1167
|
+
version,
|
|
1168
|
+
description,
|
|
1169
|
+
zodSchema,
|
|
1170
|
+
schema: z.toJSONSchema(zodSchema),
|
|
1171
|
+
create(payload, meta) {
|
|
1172
|
+
return createEvent$1(name, payload, {
|
|
1173
|
+
source: "party",
|
|
1174
|
+
...meta
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
const partyKind = z.enum([
|
|
1180
|
+
"person",
|
|
1181
|
+
"company",
|
|
1182
|
+
"group"
|
|
1183
|
+
]);
|
|
1184
|
+
const partyRole = z.enum([
|
|
1185
|
+
"customer",
|
|
1186
|
+
"supplier",
|
|
1187
|
+
"contact",
|
|
1188
|
+
"employee",
|
|
1189
|
+
"partner"
|
|
1190
|
+
]);
|
|
1191
|
+
const createdSchema = z.object({
|
|
1192
|
+
partyId: z.string(),
|
|
1193
|
+
kind: partyKind,
|
|
1194
|
+
displayName: z.string(),
|
|
1195
|
+
roles: z.array(partyRole),
|
|
1196
|
+
organizationId: z.string().optional()
|
|
1197
|
+
});
|
|
1198
|
+
const updatedSchema = z.object({
|
|
1199
|
+
partyId: z.string(),
|
|
1200
|
+
changed: z.array(z.string())
|
|
1201
|
+
});
|
|
1202
|
+
const roleChangedSchema = z.object({
|
|
1203
|
+
partyId: z.string(),
|
|
1204
|
+
role: partyRole
|
|
1205
|
+
});
|
|
1206
|
+
const userLinkedSchema = z.object({
|
|
1207
|
+
partyId: z.string(),
|
|
1208
|
+
userId: z.string()
|
|
1209
|
+
});
|
|
1210
|
+
const externalRefSchema = z.object({
|
|
1211
|
+
partyId: z.string(),
|
|
1212
|
+
sourceModel: z.string(),
|
|
1213
|
+
sourceId: z.string()
|
|
1214
|
+
});
|
|
1215
|
+
const lifecycleSchema = z.object({ partyId: z.string() });
|
|
1216
|
+
const mergedSchema = z.object({
|
|
1217
|
+
sourceId: z.string(),
|
|
1218
|
+
targetId: z.string(),
|
|
1219
|
+
absorbedKeys: z.number().int().nonnegative()
|
|
1220
|
+
});
|
|
1221
|
+
const partyEventDefinitions = [
|
|
1222
|
+
definePartyEvent({
|
|
1223
|
+
name: PARTY_EVENTS.CREATED,
|
|
1224
|
+
description: "A new canonical party identity was created.",
|
|
1225
|
+
zodSchema: createdSchema
|
|
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
|
|
1266
|
+
})
|
|
1267
|
+
];
|
|
1268
|
+
//#endregion
|
|
1269
|
+
export { ADDRESS_TYPES, CONTACT_POINT_TYPES, InProcessPartyBus, PARTY_EVENTS, PARTY_KINDS, PARTY_MODEL_NAMES, PARTY_ROLES, PARTY_STATE_MACHINE, PARTY_STATUSES, PENDING_EVENTS, PartyError, PartyEventDispatcher, PartyMergeError, PartyModelCollisionError, PartyNotFoundError, PartyRepository, PartyResolveConflictError, PartyValidationError, PartyVersionConflictError, buildContactPoints, buildPartySchema, contactPointKey, createEvent, createParty, createParty as default, createPartyModels, dedupe, deriveIdentityKeys, flushPending, normalizeEmail, normalizePhone, partyEventDefinitions, pickPrimary, repoOptionsFromCtx, resolveDisplayName, taxIdentityKey, tenantFieldDef, unionContacts, unionRefs, unionStrings };
|