@classytic/party 0.1.0 → 0.2.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 +59 -0
- package/LICENSE +1 -1
- package/README.md +29 -4
- package/dist/events.d.ts +3 -8
- package/dist/events.js +21 -31
- package/dist/index.d.ts +253 -90
- package/dist/index.js +641 -329
- package/dist/maintenance.d.ts +165 -0
- package/dist/maintenance.js +353 -0
- package/package.json +15 -8
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import "@classytic/primitives/address";
|
|
2
|
+
import "@classytic/primitives/person";
|
|
3
|
+
import "@classytic/primitives/reference";
|
|
4
|
+
//#region src/domain/enums.d.ts
|
|
5
|
+
/** Channel kind for a contact point. Drives normalization + dedup keys. */
|
|
6
|
+
declare const CONTACT_POINT_TYPES: readonly ['email', 'phone'];
|
|
7
|
+
type ContactPointType = (typeof CONTACT_POINT_TYPES)[number];
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/domain/value-objects.d.ts
|
|
10
|
+
/**
|
|
11
|
+
* A reachable identity channel. `value` is what the user typed; `key` is the
|
|
12
|
+
* normalized dedup form (`email:ada@x.com`, `phone:+8801...`). Structurally a
|
|
13
|
+
* normalized, verifiable superset of primitives' flat `ContactInfo` (§P5) — the
|
|
14
|
+
* kernel dedups on `key` but never mangles the display `value`.
|
|
15
|
+
*/
|
|
16
|
+
interface ContactPoint {
|
|
17
|
+
type: ContactPointType;
|
|
18
|
+
/**
|
|
19
|
+
* The CANONICAL form — a lowercased email, or an E.164 phone. Identity is the
|
|
20
|
+
* normalized value only; this is also what a notification transport should
|
|
21
|
+
* dial or mail, so a stored local spelling is a second bug waiting behind the
|
|
22
|
+
* dedup one.
|
|
23
|
+
*/
|
|
24
|
+
value: string;
|
|
25
|
+
/** Normalized dedup key — the `identityKeys` index entry. */
|
|
26
|
+
key: string;
|
|
27
|
+
/**
|
|
28
|
+
* What the operator actually TYPED, kept only when it differs from `value`.
|
|
29
|
+
* Support-facing provenance ("the till shows 01712345678, the invoice shows
|
|
30
|
+
* +8801712345678 — same number"). Never an identity signal: it is not keyed,
|
|
31
|
+
* not indexed and not matched on.
|
|
32
|
+
*/
|
|
33
|
+
input?: string;
|
|
34
|
+
isPrimary: boolean;
|
|
35
|
+
verified: boolean;
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/maintenance/phone-identity-repair.d.ts
|
|
39
|
+
/** The slice of a party this survey reads. Structural, so a raw lean doc fits. */
|
|
40
|
+
interface PartyIdentityRow {
|
|
41
|
+
_id: unknown;
|
|
42
|
+
displayName?: string;
|
|
43
|
+
kind?: string;
|
|
44
|
+
status?: string;
|
|
45
|
+
roles?: string[];
|
|
46
|
+
identityKeys?: string[];
|
|
47
|
+
contactPoints?: ContactPoint[];
|
|
48
|
+
createdAt?: Date;
|
|
49
|
+
}
|
|
50
|
+
/** One party's phone key, as stored and as it should be. */
|
|
51
|
+
interface PhoneKeyFinding {
|
|
52
|
+
partyId: string;
|
|
53
|
+
displayName: string;
|
|
54
|
+
status: string;
|
|
55
|
+
createdAt?: Date | undefined;
|
|
56
|
+
/** The key currently in `identityKeys` (`phone:+01309000993`). */
|
|
57
|
+
storedKey: string;
|
|
58
|
+
/** The key it should be (`phone:+8801309000993`), or `null` when unreadable. */
|
|
59
|
+
canonicalKey: string | null;
|
|
60
|
+
/** Why it could not be read — present only when `canonicalKey` is null. */
|
|
61
|
+
reason?: string;
|
|
62
|
+
}
|
|
63
|
+
/** Two or more parties that are one human. The only case needing a decision. */
|
|
64
|
+
interface PhoneCollisionGroup {
|
|
65
|
+
canonicalKey: string;
|
|
66
|
+
parties: PhoneKeyFinding[];
|
|
67
|
+
/**
|
|
68
|
+
* Fields on which the group DISAGREES. An empty list means the rows are
|
|
69
|
+
* consistent and the merge is uncontroversial; `displayName` disagreeing is
|
|
70
|
+
* normal (a nickname), `kind` disagreeing is a red flag worth a human look.
|
|
71
|
+
*/
|
|
72
|
+
conflicts: string[];
|
|
73
|
+
/**
|
|
74
|
+
* Per-party facet counts, when a host supplied a counter. A group with
|
|
75
|
+
* entitlements/cards/loyalty on MORE THAN ONE side is the expensive kind: the
|
|
76
|
+
* merge has to reconcile real balances, not just names.
|
|
77
|
+
*/
|
|
78
|
+
attachments?: Record<string, Record<string, number>>;
|
|
79
|
+
/** True when at least two parties in the group carry a non-zero facet count. */
|
|
80
|
+
attachmentsOnBothSides?: boolean;
|
|
81
|
+
}
|
|
82
|
+
interface PhoneRepairReport {
|
|
83
|
+
/** ISO 3166-1 alpha-2 region the survey interpreted local numbers in. */
|
|
84
|
+
defaultRegion: string | undefined;
|
|
85
|
+
scannedParties: number;
|
|
86
|
+
partiesWithPhone: number;
|
|
87
|
+
phoneKeys: number;
|
|
88
|
+
/** Already E.164 — no action. */
|
|
89
|
+
canonical: number;
|
|
90
|
+
/** Wrong key, no competitor: a safe mechanical rewrite. */
|
|
91
|
+
rekey: PhoneKeyFinding[];
|
|
92
|
+
/** THE corruption: one human, several parties. Needs a human decision. */
|
|
93
|
+
collisions: PhoneCollisionGroup[];
|
|
94
|
+
/** Distinct parties that would collapse if every collision were merged. */
|
|
95
|
+
partiesInCollisions: number;
|
|
96
|
+
/** How many parties disappear if every collision group is merged to one. */
|
|
97
|
+
partiesCollapsed: number;
|
|
98
|
+
/** Unreadable keys — reported, never rewritten. */
|
|
99
|
+
unparseable: PhoneKeyFinding[];
|
|
100
|
+
}
|
|
101
|
+
/** Optional host port: how many facet rows point at each party. */
|
|
102
|
+
type AttachmentCounter = (partyIds: string[]) => Promise<Record<string, Record<string, number>>>;
|
|
103
|
+
/**
|
|
104
|
+
* PURE. Turn a page of party rows into the repair plan. No I/O, no writes —
|
|
105
|
+
* unit-testable against fixtures, which is how the counts in an operator's
|
|
106
|
+
* report can be trusted.
|
|
107
|
+
*/
|
|
108
|
+
declare function planPhoneIdentityRepair(rows: readonly PartyIdentityRow[], options?: {
|
|
109
|
+
defaultRegion?: string | undefined;
|
|
110
|
+
}): PhoneRepairReport;
|
|
111
|
+
/**
|
|
112
|
+
* Attach per-party facet counts to every collision group, via a host-supplied
|
|
113
|
+
* counter. Optional because only the HOST knows what a "card" or an
|
|
114
|
+
* "entitlement" is — the identity kernel must not learn.
|
|
115
|
+
*/
|
|
116
|
+
declare function withAttachmentCounts(report: PhoneRepairReport, count: AttachmentCounter): Promise<PhoneRepairReport>;
|
|
117
|
+
/**
|
|
118
|
+
* The filter + projection the survey needs, as DATA.
|
|
119
|
+
*
|
|
120
|
+
* Exported rather than embedded in a query builder because a mongoose `Model`
|
|
121
|
+
* and a raw driver `Collection` have incompatible read APIs (`.select().lean()`
|
|
122
|
+
* vs `.find(filter, { projection })`), and every host will reach for whichever
|
|
123
|
+
* it already has open. Two hosts hand-writing "the phone-identity query" is two
|
|
124
|
+
* chances to diverge on the filter and report different damage from the same
|
|
125
|
+
* database.
|
|
126
|
+
*
|
|
127
|
+
* Deliberately NOT tenant-scoped. A collision IS two rows that should be one;
|
|
128
|
+
* scanning a branch at a time would see each half separately and find nothing —
|
|
129
|
+
* the same blindness that let the split happen.
|
|
130
|
+
*
|
|
131
|
+
* Deliberately a narrow projection over a plain read: this must be safe to run
|
|
132
|
+
* against production on a Tuesday afternoon.
|
|
133
|
+
*/
|
|
134
|
+
declare const PHONE_IDENTITY_QUERY: {
|
|
135
|
+
filter: {
|
|
136
|
+
readonly identityKeys: {
|
|
137
|
+
readonly $regex: '^phone:';
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
projection: {
|
|
141
|
+
readonly _id: 1;
|
|
142
|
+
readonly displayName: 1;
|
|
143
|
+
readonly kind: 1;
|
|
144
|
+
readonly status: 1;
|
|
145
|
+
readonly roles: 1;
|
|
146
|
+
readonly identityKeys: 1;
|
|
147
|
+
readonly contactPoints: 1;
|
|
148
|
+
readonly createdAt: 1;
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
/** How the host hands rows over. Whatever it uses to read, it returns these. */
|
|
152
|
+
type PartyIdentityReader = () => Promise<PartyIdentityRow[]>;
|
|
153
|
+
/**
|
|
154
|
+
* Read every party carrying a phone identity and plan the repair.
|
|
155
|
+
*
|
|
156
|
+
* Takes a READER rather than a model: the kernel states the query
|
|
157
|
+
* ({@link PHONE_IDENTITY_QUERY}), the host runs it with whatever handle it has.
|
|
158
|
+
*/
|
|
159
|
+
declare function scanPhoneIdentityRepair(read: PartyIdentityReader, options?: {
|
|
160
|
+
defaultRegion?: string | undefined;
|
|
161
|
+
}): Promise<PhoneRepairReport>;
|
|
162
|
+
/** Human-readable dry-run output. Deterministic — safe to diff between runs. */
|
|
163
|
+
declare function formatPhoneRepairReport(report: PhoneRepairReport): string;
|
|
164
|
+
//#endregion
|
|
165
|
+
export { AttachmentCounter, PHONE_IDENTITY_QUERY, PartyIdentityReader, PartyIdentityRow, PhoneCollisionGroup, PhoneKeyFinding, PhoneRepairReport, formatPhoneRepairReport, planPhoneIdentityRepair, scanPhoneIdentityRepair, withAttachmentCounts };
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import "@classytic/contact/email";
|
|
2
|
+
import { PhoneFormatError, normalizePhone } from "@classytic/contact/phone";
|
|
3
|
+
//#region src/domain/errors.ts
|
|
4
|
+
/** Base class so hosts can `instanceof PartyError` across every party throwable. */
|
|
5
|
+
var PartyError = class extends Error {
|
|
6
|
+
status;
|
|
7
|
+
code;
|
|
8
|
+
meta;
|
|
9
|
+
constructor(status, code, message, meta) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = new.target.name;
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = code;
|
|
14
|
+
if (meta !== void 0) this.meta = meta;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* A phone contact point could not be normalized to E.164 — so it cannot become
|
|
19
|
+
* an identity key.
|
|
20
|
+
*
|
|
21
|
+
* This REFUSES rather than degrades, and that is the whole point. The previous
|
|
22
|
+
* behaviour fell back to `+${digits}`, which produced keys like
|
|
23
|
+
* `phone:+01309000993` — neither a national number nor E.164. Two spellings of
|
|
24
|
+
* one member therefore keyed differently and `resolveOrCreate` forked the
|
|
25
|
+
* identity, silently: every screen looked correct and the membership history
|
|
26
|
+
* simply split in two (FAIL LOUD rule 2 — an input we do not understand must
|
|
27
|
+
* fail, not widen).
|
|
28
|
+
*
|
|
29
|
+
* `meta` names the offending value and the region it was interpreted in, so an
|
|
30
|
+
* operator can fix the data instead of guessing which field a 400 refers to.
|
|
31
|
+
*/
|
|
32
|
+
var PartyPhoneFormatError = class extends PartyError {
|
|
33
|
+
constructor(value, defaultRegion, detail) {
|
|
34
|
+
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.`, {
|
|
35
|
+
value,
|
|
36
|
+
...defaultRegion !== void 0 ? { defaultRegion } : {},
|
|
37
|
+
detail
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/domain/normalize.ts
|
|
43
|
+
/**
|
|
44
|
+
* Canonical E.164 form of a phone number, for dedup AND for storage.
|
|
45
|
+
*
|
|
46
|
+
* Delegates to `@classytic/contact`, then re-throws its refusal as party's own
|
|
47
|
+
* `PartyPhoneFormatError` (400 / `party.phone_invalid`) so arc serializes it
|
|
48
|
+
* natively. There is no fallback at either layer: the old `+${digits}`
|
|
49
|
+
* degradation is precisely what split one member into several parties, and it
|
|
50
|
+
* did so without erroring.
|
|
51
|
+
*
|
|
52
|
+
* NOT exported from this kernel's public surface. A consumer that wants to
|
|
53
|
+
* normalize a phone imports `@classytic/contact/phone` directly — one rule,
|
|
54
|
+
* one name. What party owns is `buildContactPoints`, the single point every
|
|
55
|
+
* identity write crosses.
|
|
56
|
+
*/
|
|
57
|
+
function normalizePhone$1(raw, options = {}) {
|
|
58
|
+
const defaultRegion = options.defaultRegion;
|
|
59
|
+
try {
|
|
60
|
+
return normalizePhone(raw, { ...defaultRegion !== void 0 ? { defaultRegion } : {} });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (err instanceof PhoneFormatError) throw new PartyPhoneFormatError(err.input, defaultRegion, err.detail ?? err.code);
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/maintenance/phone-identity-repair.ts
|
|
68
|
+
/**
|
|
69
|
+
* Phone-identity repair — the READ-ONLY survey of damage already in the
|
|
70
|
+
* database.
|
|
71
|
+
*
|
|
72
|
+
* Fixing the seam stops NEW splits. It does not heal the ones already written:
|
|
73
|
+
* a member who is two parties today stays two parties, and every facet keyed on
|
|
74
|
+
* `partyId` (entitlements, cards, loyalty, membership history, AR) stays split
|
|
75
|
+
* with them. This module answers "how bad is it, and which rows need a human?"
|
|
76
|
+
*
|
|
77
|
+
* ## Nothing here writes. On purpose.
|
|
78
|
+
*
|
|
79
|
+
* Merging identities is DESTRUCTIVE and irreversible in practice: `merge` turns
|
|
80
|
+
* the loser into a terminal redirect, and if two rows that are genuinely two
|
|
81
|
+
* people were collapsed, no undo restores the facets that followed. An
|
|
82
|
+
* automated merge would also be a self-fulfilling audit — the run that decides
|
|
83
|
+
* and the run that verifies would be the same code.
|
|
84
|
+
*
|
|
85
|
+
* So this produces a REPORT and stops. A human reads it and either calls
|
|
86
|
+
* `PartyRepository.merge(loser, survivor, ctx)` per group or fixes the data.
|
|
87
|
+
*
|
|
88
|
+
* ## Three outcomes, deliberately distinguished
|
|
89
|
+
*
|
|
90
|
+
* - **canonical** — the stored key already IS the E.164 form. Nothing to do.
|
|
91
|
+
* - **rekey** — the key is wrong (a local spelling, or the old `+${digits}`
|
|
92
|
+
* mangling) but no OTHER party owns the corrected key. Mechanical and safe:
|
|
93
|
+
* one party, one key, rewritten.
|
|
94
|
+
* - **collision** — the corrected key is owned by two or more parties. This is
|
|
95
|
+
* the actual corruption, and it is the only case that needs a decision.
|
|
96
|
+
*
|
|
97
|
+
* A fourth, `unparseable`, is reported rather than guessed at: a key we cannot
|
|
98
|
+
* interpret must not be rewritten into a plausible-looking one (FAIL LOUD rule
|
|
99
|
+
* 2). Reporting it costs an operator one look; guessing costs them a merged
|
|
100
|
+
* stranger.
|
|
101
|
+
*/
|
|
102
|
+
const PHONE_PREFIX = "phone:";
|
|
103
|
+
/**
|
|
104
|
+
* Recover the canonical E.164 form from what was stored.
|
|
105
|
+
*
|
|
106
|
+
* The ladder matters, and its ORDER is the whole correctness argument:
|
|
107
|
+
*
|
|
108
|
+
* 1. **Strict E.164, no region.** A correctly-keyed row — including a FOREIGN
|
|
109
|
+
* number — must be preserved exactly. Re-reading `+14155550182` through a
|
|
110
|
+
* `BD` region would turn a California number into a Bangladeshi one.
|
|
111
|
+
* 2. **The raw value as typed, through the region.** `01309000993` is what the
|
|
112
|
+
* operator entered and the most faithful signal we have.
|
|
113
|
+
* 3. **The key's digits, through the region.** Recovers the `+${digits}`
|
|
114
|
+
* mangling (`phone:+01309000993` → `01309000993` → `+8801309000993`) when
|
|
115
|
+
* the contact point's value is missing or equally mangled.
|
|
116
|
+
*
|
|
117
|
+
* Anything past that is unreadable and is REPORTED, not repaired.
|
|
118
|
+
*/
|
|
119
|
+
function recoverCanonical(storedKey, rawValue, defaultRegion) {
|
|
120
|
+
const stored = storedKey.slice(6);
|
|
121
|
+
const candidates = [{
|
|
122
|
+
input: stored,
|
|
123
|
+
region: void 0
|
|
124
|
+
}];
|
|
125
|
+
if (rawValue) candidates.push({
|
|
126
|
+
input: rawValue,
|
|
127
|
+
region: defaultRegion
|
|
128
|
+
});
|
|
129
|
+
candidates.push({
|
|
130
|
+
input: stored.replace(/\D/g, ""),
|
|
131
|
+
region: defaultRegion
|
|
132
|
+
});
|
|
133
|
+
let lastReason = "no candidate parsed";
|
|
134
|
+
for (const candidate of candidates) {
|
|
135
|
+
if (!candidate.input) continue;
|
|
136
|
+
try {
|
|
137
|
+
return { key: `${PHONE_PREFIX}${normalizePhone$1(candidate.input, { defaultRegion: candidate.region })}` };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
lastReason = err instanceof Error ? err.message : String(err);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { reason: lastReason };
|
|
143
|
+
}
|
|
144
|
+
/** Fields whose disagreement inside a collision group is worth surfacing. */
|
|
145
|
+
function conflictsIn(rows) {
|
|
146
|
+
const conflicts = [];
|
|
147
|
+
const distinct = (pick) => new Set(rows.map(pick).filter((v) => Boolean(v))).size;
|
|
148
|
+
if (distinct((r) => r.displayName?.trim().toLowerCase()) > 1) conflicts.push("displayName");
|
|
149
|
+
if (distinct((r) => r.kind) > 1) conflicts.push("kind");
|
|
150
|
+
if (distinct((r) => r.status) > 1) conflicts.push("status");
|
|
151
|
+
const emails = /* @__PURE__ */ new Set();
|
|
152
|
+
for (const row of rows) for (const key of row.identityKeys ?? []) if (key.startsWith("email:")) emails.add(key);
|
|
153
|
+
if (emails.size > 1) conflicts.push("email");
|
|
154
|
+
return conflicts;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* PURE. Turn a page of party rows into the repair plan. No I/O, no writes —
|
|
158
|
+
* unit-testable against fixtures, which is how the counts in an operator's
|
|
159
|
+
* report can be trusted.
|
|
160
|
+
*/
|
|
161
|
+
function planPhoneIdentityRepair(rows, options = {}) {
|
|
162
|
+
const defaultRegion = options.defaultRegion;
|
|
163
|
+
const byCanonical = /* @__PURE__ */ new Map();
|
|
164
|
+
const unparseable = [];
|
|
165
|
+
let partiesWithPhone = 0;
|
|
166
|
+
let phoneKeys = 0;
|
|
167
|
+
let canonical = 0;
|
|
168
|
+
for (const row of rows) {
|
|
169
|
+
const keys = (row.identityKeys ?? []).filter((k) => k.startsWith(PHONE_PREFIX));
|
|
170
|
+
if (keys.length === 0) continue;
|
|
171
|
+
partiesWithPhone += 1;
|
|
172
|
+
for (const storedKey of keys) {
|
|
173
|
+
phoneKeys += 1;
|
|
174
|
+
const rawValue = (row.contactPoints ?? []).find((c) => c.type === "phone" && c.key === storedKey)?.value;
|
|
175
|
+
const recovered = recoverCanonical(storedKey, rawValue, defaultRegion);
|
|
176
|
+
const base = {
|
|
177
|
+
partyId: String(row._id),
|
|
178
|
+
displayName: row.displayName ?? "(unnamed)",
|
|
179
|
+
status: row.status ?? "active",
|
|
180
|
+
createdAt: row.createdAt,
|
|
181
|
+
storedKey,
|
|
182
|
+
canonicalKey: null
|
|
183
|
+
};
|
|
184
|
+
if ("reason" in recovered) {
|
|
185
|
+
unparseable.push({
|
|
186
|
+
...base,
|
|
187
|
+
reason: recovered.reason
|
|
188
|
+
});
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (recovered.key === storedKey) canonical += 1;
|
|
192
|
+
const finding = {
|
|
193
|
+
...base,
|
|
194
|
+
canonicalKey: recovered.key
|
|
195
|
+
};
|
|
196
|
+
const bucket = byCanonical.get(recovered.key);
|
|
197
|
+
if (bucket) bucket.push({
|
|
198
|
+
finding,
|
|
199
|
+
row
|
|
200
|
+
});
|
|
201
|
+
else byCanonical.set(recovered.key, [{
|
|
202
|
+
finding,
|
|
203
|
+
row
|
|
204
|
+
}]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const rekey = [];
|
|
208
|
+
const collisions = [];
|
|
209
|
+
const collidingParties = /* @__PURE__ */ new Set();
|
|
210
|
+
for (const [canonicalKey, entries] of byCanonical) {
|
|
211
|
+
const parties = /* @__PURE__ */ new Map();
|
|
212
|
+
for (const entry of entries) parties.set(entry.finding.partyId, entry);
|
|
213
|
+
if (parties.size > 1) {
|
|
214
|
+
const members = [...parties.values()];
|
|
215
|
+
for (const id of parties.keys()) collidingParties.add(id);
|
|
216
|
+
collisions.push({
|
|
217
|
+
canonicalKey,
|
|
218
|
+
parties: members.map((m) => m.finding),
|
|
219
|
+
conflicts: conflictsIn(members.map((m) => m.row))
|
|
220
|
+
});
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
for (const entry of entries) if (entry.finding.storedKey !== canonicalKey) rekey.push(entry.finding);
|
|
224
|
+
}
|
|
225
|
+
collisions.sort((a, b) => a.canonicalKey.localeCompare(b.canonicalKey));
|
|
226
|
+
rekey.sort((a, b) => a.storedKey.localeCompare(b.storedKey));
|
|
227
|
+
unparseable.sort((a, b) => a.storedKey.localeCompare(b.storedKey));
|
|
228
|
+
return {
|
|
229
|
+
defaultRegion,
|
|
230
|
+
scannedParties: rows.length,
|
|
231
|
+
partiesWithPhone,
|
|
232
|
+
phoneKeys,
|
|
233
|
+
canonical,
|
|
234
|
+
rekey,
|
|
235
|
+
collisions,
|
|
236
|
+
partiesInCollisions: collidingParties.size,
|
|
237
|
+
partiesCollapsed: collisions.reduce((n, g) => n + (g.parties.length - 1), 0),
|
|
238
|
+
unparseable
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Attach per-party facet counts to every collision group, via a host-supplied
|
|
243
|
+
* counter. Optional because only the HOST knows what a "card" or an
|
|
244
|
+
* "entitlement" is — the identity kernel must not learn.
|
|
245
|
+
*/
|
|
246
|
+
async function withAttachmentCounts(report, count) {
|
|
247
|
+
const ids = [...new Set(report.collisions.flatMap((g) => g.parties.map((p) => p.partyId)))];
|
|
248
|
+
if (ids.length === 0) return report;
|
|
249
|
+
const counts = await count(ids);
|
|
250
|
+
const collisions = report.collisions.map((group) => {
|
|
251
|
+
const attachments = {};
|
|
252
|
+
let sidesWithData = 0;
|
|
253
|
+
for (const party of group.parties) {
|
|
254
|
+
const facets = counts[party.partyId] ?? {};
|
|
255
|
+
attachments[party.partyId] = facets;
|
|
256
|
+
if (Object.values(facets).some((n) => n > 0)) sidesWithData += 1;
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
...group,
|
|
260
|
+
attachments,
|
|
261
|
+
attachmentsOnBothSides: sidesWithData > 1
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
...report,
|
|
266
|
+
collisions
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The filter + projection the survey needs, as DATA.
|
|
271
|
+
*
|
|
272
|
+
* Exported rather than embedded in a query builder because a mongoose `Model`
|
|
273
|
+
* and a raw driver `Collection` have incompatible read APIs (`.select().lean()`
|
|
274
|
+
* vs `.find(filter, { projection })`), and every host will reach for whichever
|
|
275
|
+
* it already has open. Two hosts hand-writing "the phone-identity query" is two
|
|
276
|
+
* chances to diverge on the filter and report different damage from the same
|
|
277
|
+
* database.
|
|
278
|
+
*
|
|
279
|
+
* Deliberately NOT tenant-scoped. A collision IS two rows that should be one;
|
|
280
|
+
* scanning a branch at a time would see each half separately and find nothing —
|
|
281
|
+
* the same blindness that let the split happen.
|
|
282
|
+
*
|
|
283
|
+
* Deliberately a narrow projection over a plain read: this must be safe to run
|
|
284
|
+
* against production on a Tuesday afternoon.
|
|
285
|
+
*/
|
|
286
|
+
const PHONE_IDENTITY_QUERY = {
|
|
287
|
+
filter: { identityKeys: { $regex: "^phone:" } },
|
|
288
|
+
projection: {
|
|
289
|
+
_id: 1,
|
|
290
|
+
displayName: 1,
|
|
291
|
+
kind: 1,
|
|
292
|
+
status: 1,
|
|
293
|
+
roles: 1,
|
|
294
|
+
identityKeys: 1,
|
|
295
|
+
contactPoints: 1,
|
|
296
|
+
createdAt: 1
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
/**
|
|
300
|
+
* Read every party carrying a phone identity and plan the repair.
|
|
301
|
+
*
|
|
302
|
+
* Takes a READER rather than a model: the kernel states the query
|
|
303
|
+
* ({@link PHONE_IDENTITY_QUERY}), the host runs it with whatever handle it has.
|
|
304
|
+
*/
|
|
305
|
+
async function scanPhoneIdentityRepair(read, options = {}) {
|
|
306
|
+
return planPhoneIdentityRepair(await read(), options);
|
|
307
|
+
}
|
|
308
|
+
/** Human-readable dry-run output. Deterministic — safe to diff between runs. */
|
|
309
|
+
function formatPhoneRepairReport(report) {
|
|
310
|
+
const lines = [];
|
|
311
|
+
const push = (s = "") => void lines.push(s);
|
|
312
|
+
push("=== Party phone-identity repair — DRY RUN (nothing was written) ===");
|
|
313
|
+
push(`region for local numbers : ${report.defaultRegion ?? "(none — E.164 only)"}`);
|
|
314
|
+
push(`parties scanned : ${report.scannedParties}`);
|
|
315
|
+
push(` with a phone identity : ${report.partiesWithPhone}`);
|
|
316
|
+
push(`phone identity keys : ${report.phoneKeys}`);
|
|
317
|
+
push(` already canonical : ${report.canonical}`);
|
|
318
|
+
push(` need a rekey (safe) : ${report.rekey.length}`);
|
|
319
|
+
push(` unreadable (human) : ${report.unparseable.length}`);
|
|
320
|
+
push(`COLLISION GROUPS : ${report.collisions.length}`);
|
|
321
|
+
push(` parties involved : ${report.partiesInCollisions}`);
|
|
322
|
+
push(` parties that would go : ${report.partiesCollapsed} <- merges required`);
|
|
323
|
+
push(` with facets on 2+ sides: ${report.collisions.filter((g) => g.attachmentsOnBothSides).length}`);
|
|
324
|
+
push();
|
|
325
|
+
if (report.collisions.length > 0) {
|
|
326
|
+
push("--- COLLISIONS (one human, several parties) — MERGE IS A HUMAN DECISION ---");
|
|
327
|
+
for (const group of report.collisions) {
|
|
328
|
+
const flags = [group.conflicts.length > 0 ? `conflicts: ${group.conflicts.join(",")}` : "no field conflicts", group.attachmentsOnBothSides ? "FACETS ON BOTH SIDES" : ""].filter(Boolean);
|
|
329
|
+
push(`${group.canonicalKey} (${group.parties.length} parties; ${flags.join("; ")})`);
|
|
330
|
+
for (const party of group.parties) {
|
|
331
|
+
const facets = group.attachments?.[party.partyId];
|
|
332
|
+
const facetText = facets ? Object.entries(facets).filter(([, n]) => n > 0).map(([k, n]) => `${k}=${n}`).join(" ") : "";
|
|
333
|
+
push(` ${party.partyId} ${party.displayName.padEnd(24)} ${party.status.padEnd(9)} ${party.storedKey.padEnd(24)} ${facetText}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
push();
|
|
337
|
+
}
|
|
338
|
+
if (report.rekey.length > 0) {
|
|
339
|
+
push("--- REKEY (one party, wrong key; mechanical) ---");
|
|
340
|
+
for (const f of report.rekey) push(` ${f.partyId} ${f.storedKey} -> ${f.canonicalKey}`);
|
|
341
|
+
push();
|
|
342
|
+
}
|
|
343
|
+
if (report.unparseable.length > 0) {
|
|
344
|
+
push("--- UNREADABLE (reported, never rewritten) ---");
|
|
345
|
+
for (const f of report.unparseable) push(` ${f.partyId} ${f.storedKey} (${f.reason})`);
|
|
346
|
+
push();
|
|
347
|
+
}
|
|
348
|
+
push("No documents were modified. To act on a collision group, a human calls");
|
|
349
|
+
push("PartyRepository.merge(loserId, survivorId, ctx) after checking the facets.");
|
|
350
|
+
return lines.join("\n");
|
|
351
|
+
}
|
|
352
|
+
//#endregion
|
|
353
|
+
export { PHONE_IDENTITY_QUERY, formatPhoneRepairReport, planPhoneIdentityRepair, scanPhoneIdentityRepair, withAttachmentCounts };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/party",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Engine-factory Party / Business-Partner identity kernel — one canonical party (person | company | group) carrying customer/supplier/contact/employee roles, contact points, addresses, B2B hierarchy, tax identity, and 1:many auth-user links. Dedup + merge + resolve. SAP-BP shape (thin identity + domain facets), NOT an Odoo god-object. PACKAGE_RULES compliant; Mongoose + mongokit + arc primitives.",
|
|
5
5
|
"author": "Classytic",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"types": "./dist/events.d.ts",
|
|
22
22
|
"default": "./dist/events.js"
|
|
23
23
|
},
|
|
24
|
+
"./maintenance": {
|
|
25
|
+
"types": "./dist/maintenance.d.ts",
|
|
26
|
+
"default": "./dist/maintenance.js"
|
|
27
|
+
},
|
|
24
28
|
"./schemas": {
|
|
25
29
|
"types": "./dist/schemas.d.ts",
|
|
26
30
|
"default": "./dist/schemas.js"
|
|
@@ -57,20 +61,23 @@
|
|
|
57
61
|
"mongodb": "7.2.0"
|
|
58
62
|
},
|
|
59
63
|
"peerDependencies": {
|
|
60
|
-
"@classytic/mongokit": ">=3.
|
|
61
|
-
"@classytic/
|
|
62
|
-
"@classytic/
|
|
64
|
+
"@classytic/mongokit": ">=3.33.0",
|
|
65
|
+
"@classytic/contact": ">=0.1.0",
|
|
66
|
+
"@classytic/primitives": ">=0.23.0",
|
|
67
|
+
"@classytic/repo-core": ">=0.24.0",
|
|
63
68
|
"mongoose": ">=9.4.1",
|
|
64
69
|
"zod": ">=4.0.0"
|
|
65
70
|
},
|
|
66
71
|
"devDependencies": {
|
|
67
|
-
"@classytic/mongokit": "^3.
|
|
68
|
-
"@classytic/
|
|
69
|
-
"@classytic/
|
|
72
|
+
"@classytic/mongokit": "^3.33.0",
|
|
73
|
+
"@classytic/contact": ">=0.1.0",
|
|
74
|
+
"@classytic/primitives": ">=0.23.0",
|
|
75
|
+
"@classytic/repo-core": ">=0.24.0",
|
|
70
76
|
"@types/node": "^25.5.0",
|
|
71
77
|
"mongodb-memory-server": "^10.1.4",
|
|
72
78
|
"mongoose": "^9.4.1",
|
|
73
|
-
"
|
|
79
|
+
"publint": "^0.3.23",
|
|
80
|
+
"tsdown": "^0.22.14",
|
|
74
81
|
"typescript": "^7.0.2",
|
|
75
82
|
"vitest": "^3.2.4",
|
|
76
83
|
"zod": "^4.3.6"
|