@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.
@@ -0,0 +1,216 @@
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
+ /**
9
+ * Whether this channel may be used for MARKETING. Transactional messages (your
10
+ * booking is confirmed, your invoice is due) need no consent and are not what
11
+ * this records.
12
+ *
13
+ * Two states only, and `withdrawn` is deliberately not "absent": a channel that
14
+ * was never asked and a channel someone opted OUT of are the same silence to a
15
+ * sender but not to a regulator, and only the second one must survive a merge,
16
+ * a re-import, or the person booking again.
17
+ */
18
+ declare const CONSENT_STATUSES: readonly ['granted', 'withdrawn'];
19
+ type ConsentStatus = (typeof CONSENT_STATUSES)[number];
20
+ //#endregion
21
+ //#region src/domain/value-objects.d.ts
22
+ /**
23
+ * A reachable identity channel. `value` is what the user typed; `key` is the
24
+ * normalized dedup form (`email:ada@x.com`, `phone:+8801...`). Structurally a
25
+ * normalized, verifiable superset of primitives' flat `ContactInfo` (§P5) — the
26
+ * kernel dedups on `key` but never mangles the display `value`.
27
+ */
28
+ interface ContactPoint {
29
+ type: ContactPointType;
30
+ /**
31
+ * The CANONICAL form — a lowercased email, or an E.164 phone. Identity is the
32
+ * normalized value only; this is also what a notification transport should
33
+ * dial or mail, so a stored local spelling is a second bug waiting behind the
34
+ * dedup one.
35
+ */
36
+ value: string;
37
+ /** Normalized dedup key — the `identityKeys` index entry. */
38
+ key: string;
39
+ /**
40
+ * What the operator actually TYPED, kept only when it differs from `value`.
41
+ * Support-facing provenance ("the till shows 01712345678, the invoice shows
42
+ * +8801712345678 — same number"). Never an identity signal: it is not keyed,
43
+ * not indexed and not matched on.
44
+ */
45
+ input?: string;
46
+ isPrimary: boolean;
47
+ verified: boolean;
48
+ /**
49
+ * Permission to MARKET to this channel, and the proof of it. Absent means
50
+ * never asked — which is a refusal to a sender, so treat it as one.
51
+ *
52
+ * Here rather than in a CRM package because consent is a property of the
53
+ * CHANNEL, exactly like `verified`: it is the answer to "may I mail this
54
+ * address", not a domain opinion about the person. Campaign subscriptions,
55
+ * sequences, suppression lists and unsubscribe tokens stay in CRM keyed by
56
+ * `partyId` — this is the one fact they all have to agree with, and a sender
57
+ * that disagrees with it is the bug this field exists to make visible.
58
+ *
59
+ * In a multi-tenant deployment a party row is already per-organization, so
60
+ * this is one business's permission and never a platform-wide one — which is
61
+ * what "specific and informed" requires.
62
+ */
63
+ consent?: ChannelConsent;
64
+ }
65
+ /**
66
+ * A consent decision, with what a regulator asks for when they ask you to
67
+ * prove it: what was decided, WHEN, and where the person was standing.
68
+ *
69
+ * Immutable by convention — a change of mind is a new record replacing this
70
+ * one, never an edit, so `at` always dates the CURRENT status.
71
+ */
72
+ interface ChannelConsent {
73
+ status: ConsentStatus;
74
+ /** The moment the person decided. */
75
+ at: Date;
76
+ /**
77
+ * The surface that asked — `'booking-form'`, `'dashboard'`, `'import:2019-mailchimp'`.
78
+ * Free text on purpose: the kernel cannot enumerate a host's screens, and an
79
+ * imported consent whose origin is unrecorded is one you cannot defend.
80
+ */
81
+ source?: string;
82
+ /**
83
+ * Caller IP at the moment of GRANT, when the host has one. Never backfilled:
84
+ * an IP attached later proves nothing and is then just retained PII.
85
+ */
86
+ ip?: string;
87
+ }
88
+ //#endregion
89
+ //#region src/maintenance/phone-identity-repair.d.ts
90
+ /** The slice of a party this survey reads. Structural, so a raw lean doc fits. */
91
+ interface PartyIdentityRow {
92
+ _id: unknown;
93
+ displayName?: string;
94
+ kind?: string;
95
+ status?: string;
96
+ roles?: string[];
97
+ identityKeys?: string[];
98
+ contactPoints?: ContactPoint[];
99
+ createdAt?: Date;
100
+ }
101
+ /** One party's phone key, as stored and as it should be. */
102
+ interface PhoneKeyFinding {
103
+ partyId: string;
104
+ displayName: string;
105
+ status: string;
106
+ createdAt?: Date | undefined;
107
+ /** The key currently in `identityKeys` (`phone:+01309000993`). */
108
+ storedKey: string;
109
+ /** The key it should be (`phone:+8801309000993`), or `null` when unreadable. */
110
+ canonicalKey: string | null;
111
+ /** Why it could not be read — present only when `canonicalKey` is null. */
112
+ reason?: string;
113
+ }
114
+ /** Two or more parties that are one human. The only case needing a decision. */
115
+ interface PhoneCollisionGroup {
116
+ canonicalKey: string;
117
+ parties: PhoneKeyFinding[];
118
+ /**
119
+ * Fields on which the group DISAGREES. An empty list means the rows are
120
+ * consistent and the merge is uncontroversial; `displayName` disagreeing is
121
+ * normal (a nickname), `kind` disagreeing is a red flag worth a human look.
122
+ */
123
+ conflicts: string[];
124
+ /**
125
+ * Per-party facet counts, when a host supplied a counter. A group with
126
+ * entitlements/cards/loyalty on MORE THAN ONE side is the expensive kind: the
127
+ * merge has to reconcile real balances, not just names.
128
+ */
129
+ attachments?: Record<string, Record<string, number>>;
130
+ /** True when at least two parties in the group carry a non-zero facet count. */
131
+ attachmentsOnBothSides?: boolean;
132
+ }
133
+ interface PhoneRepairReport {
134
+ /** ISO 3166-1 alpha-2 region the survey interpreted local numbers in. */
135
+ defaultRegion: string | undefined;
136
+ scannedParties: number;
137
+ partiesWithPhone: number;
138
+ phoneKeys: number;
139
+ /** Already E.164 — no action. */
140
+ canonical: number;
141
+ /** Wrong key, no competitor: a safe mechanical rewrite. */
142
+ rekey: PhoneKeyFinding[];
143
+ /** THE corruption: one human, several parties. Needs a human decision. */
144
+ collisions: PhoneCollisionGroup[];
145
+ /** Distinct parties that would collapse if every collision were merged. */
146
+ partiesInCollisions: number;
147
+ /** How many parties disappear if every collision group is merged to one. */
148
+ partiesCollapsed: number;
149
+ /** Unreadable keys — reported, never rewritten. */
150
+ unparseable: PhoneKeyFinding[];
151
+ }
152
+ /** Optional host port: how many facet rows point at each party. */
153
+ type AttachmentCounter = (partyIds: string[]) => Promise<Record<string, Record<string, number>>>;
154
+ /**
155
+ * PURE. Turn a page of party rows into the repair plan. No I/O, no writes —
156
+ * unit-testable against fixtures, which is how the counts in an operator's
157
+ * report can be trusted.
158
+ */
159
+ declare function planPhoneIdentityRepair(rows: readonly PartyIdentityRow[], options?: {
160
+ defaultRegion?: string | undefined;
161
+ }): PhoneRepairReport;
162
+ /**
163
+ * Attach per-party facet counts to every collision group, via a host-supplied
164
+ * counter. Optional because only the HOST knows what a "card" or an
165
+ * "entitlement" is — the identity kernel must not learn.
166
+ */
167
+ declare function withAttachmentCounts(report: PhoneRepairReport, count: AttachmentCounter): Promise<PhoneRepairReport>;
168
+ /**
169
+ * The filter + projection the survey needs, as DATA.
170
+ *
171
+ * Exported rather than embedded in a query builder because a mongoose `Model`
172
+ * and a raw driver `Collection` have incompatible read APIs (`.select().lean()`
173
+ * vs `.find(filter, { projection })`), and every host will reach for whichever
174
+ * it already has open. Two hosts hand-writing "the phone-identity query" is two
175
+ * chances to diverge on the filter and report different damage from the same
176
+ * database.
177
+ *
178
+ * Deliberately NOT tenant-scoped. A collision IS two rows that should be one;
179
+ * scanning a branch at a time would see each half separately and find nothing —
180
+ * the same blindness that let the split happen.
181
+ *
182
+ * Deliberately a narrow projection over a plain read: this must be safe to run
183
+ * against production on a Tuesday afternoon.
184
+ */
185
+ declare const PHONE_IDENTITY_QUERY: {
186
+ filter: {
187
+ readonly identityKeys: {
188
+ readonly $regex: '^phone:';
189
+ };
190
+ };
191
+ projection: {
192
+ readonly _id: 1;
193
+ readonly displayName: 1;
194
+ readonly kind: 1;
195
+ readonly status: 1;
196
+ readonly roles: 1;
197
+ readonly identityKeys: 1;
198
+ readonly contactPoints: 1;
199
+ readonly createdAt: 1;
200
+ };
201
+ };
202
+ /** How the host hands rows over. Whatever it uses to read, it returns these. */
203
+ type PartyIdentityReader = () => Promise<PartyIdentityRow[]>;
204
+ /**
205
+ * Read every party carrying a phone identity and plan the repair.
206
+ *
207
+ * Takes a READER rather than a model: the kernel states the query
208
+ * ({@link PHONE_IDENTITY_QUERY}), the host runs it with whatever handle it has.
209
+ */
210
+ declare function scanPhoneIdentityRepair(read: PartyIdentityReader, options?: {
211
+ defaultRegion?: string | undefined;
212
+ }): Promise<PhoneRepairReport>;
213
+ /** Human-readable dry-run output. Deterministic — safe to diff between runs. */
214
+ declare function formatPhoneRepairReport(report: PhoneRepairReport): string;
215
+ //#endregion
216
+ 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.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Engine-factory Party / Business-Partner identity kernel — one canonical party (person | company | group) carrying customer/supplier/contact/employee roles, contact points, addresses, B2B hierarchy, tax identity, and 1:many auth-user links. Dedup + merge + resolve. SAP-BP shape (thin identity + domain facets), NOT an Odoo god-object. PACKAGE_RULES compliant; Mongoose + mongokit + arc primitives.",
5
5
  "author": "Classytic",
6
6
  "license": "MIT",
@@ -14,14 +14,22 @@
14
14
  },
15
15
  "exports": {
16
16
  ".": {
17
+ "@classytic/source": "./src/index.ts",
17
18
  "types": "./dist/index.d.ts",
18
19
  "default": "./dist/index.js"
19
20
  },
20
21
  "./events": {
22
+ "@classytic/source": "./src/events/index.ts",
21
23
  "types": "./dist/events.d.ts",
22
24
  "default": "./dist/events.js"
23
25
  },
26
+ "./maintenance": {
27
+ "@classytic/source": "./src/maintenance/phone-identity-repair.ts",
28
+ "types": "./dist/maintenance.d.ts",
29
+ "default": "./dist/maintenance.js"
30
+ },
24
31
  "./schemas": {
32
+ "@classytic/source": "./src/schemas/index.ts",
25
33
  "types": "./dist/schemas.d.ts",
26
34
  "default": "./dist/schemas.js"
27
35
  },
@@ -57,20 +65,23 @@
57
65
  "mongodb": "7.2.0"
58
66
  },
59
67
  "peerDependencies": {
60
- "@classytic/mongokit": ">=3.20.0",
61
- "@classytic/primitives": ">=0.9.1",
62
- "@classytic/repo-core": ">=0.9.0",
63
- "mongoose": ">=9.4.1",
68
+ "@classytic/mongokit": ">=3.41.0",
69
+ "@classytic/contact": ">=0.2.0",
70
+ "@classytic/primitives": ">=0.31.1",
71
+ "@classytic/repo-core": ">=0.31.0",
72
+ "mongoose": ">=9.11.0",
64
73
  "zod": ">=4.0.0"
65
74
  },
66
75
  "devDependencies": {
67
- "@classytic/mongokit": "^3.20.0",
68
- "@classytic/primitives": "^0.9.0",
69
- "@classytic/repo-core": "^0.9.0",
76
+ "@classytic/mongokit": "^3.41.0",
77
+ "@classytic/contact": ">=0.2.0",
78
+ "@classytic/primitives": ">=0.31.1",
79
+ "@classytic/repo-core": ">=0.31.0",
70
80
  "@types/node": "^25.5.0",
71
81
  "mongodb-memory-server": "^10.1.4",
72
- "mongoose": "^9.4.1",
73
- "tsdown": "^0.22.5",
82
+ "mongoose": "^9.11.1",
83
+ "publint": "^0.3.23",
84
+ "tsdown": "^0.22.14",
74
85
  "typescript": "^7.0.2",
75
86
  "vitest": "^3.2.4",
76
87
  "zod": "^4.3.6"