@classytic/crm 0.9.0 → 1.0.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/package.json CHANGED
@@ -1,73 +1,79 @@
1
- {
2
- "name": "@classytic/crm",
3
- "version": "0.9.0",
4
- "description": "Engine-factory CRM package \u2014 leads, accounts, contacts, opportunities, pipelines, campaigns, subscriptions, activities, notes, email engagement. PACKAGE_RULES compliant; built on Mongoose + mongokit + arc primitives.",
5
- "author": "Classytic",
6
- "license": "MIT",
7
- "type": "module",
8
- "sideEffects": false,
9
- "engines": {
10
- "node": ">=22"
11
- },
12
- "publishConfig": {
13
- "access": "public"
14
- },
15
- "exports": {
16
- ".": {
17
- "types": "./dist/index.d.ts",
18
- "default": "./dist/index.js"
19
- },
20
- "./events": {
21
- "types": "./dist/events.d.ts",
22
- "default": "./dist/events.js"
23
- },
24
- "./package.json": "./package.json"
25
- },
26
- "main": "./dist/index.js",
27
- "types": "./dist/index.d.ts",
28
- "files": [
29
- "dist",
30
- "README.md",
31
- "CHANGELOG.md",
32
- "LICENSE"
33
- ],
34
- "repository": {
35
- "type": "git",
36
- "url": "git+https://github.com/classytic/crm.git"
37
- },
38
- "bugs": {
39
- "url": "https://github.com/classytic/crm/issues"
40
- },
41
- "homepage": "https://github.com/classytic/crm#readme",
42
- "scripts": {
43
- "build": "tsdown",
44
- "typecheck": "tsc --noEmit",
45
- "test": "vitest run --project unit --project integration",
46
- "test:unit": "vitest run --project unit",
47
- "test:integration": "vitest run --project integration",
48
- "test:e2e": "vitest run --project e2e",
49
- "test:all": "vitest run",
50
- "test:watch": "vitest --project unit --project integration",
51
- "push": "classytic-push"
52
- },
53
- "peerDependencies": {
54
- "@classytic/mongokit": ">=3.22.0",
55
- "@classytic/primitives": ">=0.11.0",
56
- "@classytic/repo-core": ">=0.13.0",
57
- "mongoose": ">=9.4.1",
58
- "zod": ">=4.0.0"
59
- },
60
- "devDependencies": {
61
- "@classytic/dev-tools": "^0.2.0",
62
- "@classytic/mongokit": "^3.22.0",
63
- "@classytic/primitives": "^0.11.0",
64
- "@classytic/repo-core": "^0.13.0",
65
- "@types/node": "^25.5.0",
66
- "mongodb-memory-server": "^10.1.4",
67
- "mongoose": "^9.4.1",
68
- "tsdown": "^0.21.10",
69
- "typescript": "^5.8.2",
70
- "vitest": "^3.2.4",
71
- "zod": "^4.3.6"
72
- }
73
- }
1
+ {
2
+ "name": "@classytic/crm",
3
+ "version": "1.0.0",
4
+ "description": "Engine-factory CRM package — leads, accounts, contacts, opportunities, pipelines, campaigns, subscriptions, activities, notes, email engagement. PACKAGE_RULES compliant; built on Mongoose + mongokit + arc primitives.",
5
+ "author": "Classytic",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "engines": {
10
+ "node": ">=22"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./events": {
21
+ "types": "./dist/events.d.ts",
22
+ "default": "./dist/events.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "files": [
29
+ "dist",
30
+ "scripts",
31
+ "README.md",
32
+ "CHANGELOG.md",
33
+ "LICENSE"
34
+ ],
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/classytic/crm.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/classytic/crm/issues"
41
+ },
42
+ "homepage": "https://github.com/classytic/crm#readme",
43
+ "scripts": {
44
+ "build": "tsdown",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run --project unit --project integration",
47
+ "test:unit": "vitest run --project unit",
48
+ "test:integration": "vitest run --project integration",
49
+ "test:e2e": "vitest run --project e2e",
50
+ "test:all": "vitest run",
51
+ "test:watch": "vitest --project unit --project integration",
52
+ "drift:suppression": "node scripts/suppression-key-drift.mjs",
53
+ "drift:email-keys": "node scripts/lead-contact-email-key-drift.mjs",
54
+ "push": "classytic-push"
55
+ },
56
+ "peerDependencies": {
57
+ "@classytic/contact": ">=0.1.0",
58
+ "@classytic/mongokit": ">=3.32.0",
59
+ "@classytic/primitives": ">=0.14.0",
60
+ "@classytic/repo-core": ">=0.13.0",
61
+ "mongoose": ">=9.4.1",
62
+ "zod": ">=4.0.0"
63
+ },
64
+ "devDependencies": {
65
+ "@classytic/contact": ">=0.1.0",
66
+ "@classytic/dev-tools": "^0.2.0",
67
+ "@classytic/mongokit": "^3.25.0",
68
+ "@classytic/primitives": ">=0.20.0",
69
+ "@classytic/repo-core": ">=0.20.0",
70
+ "@types/node": "^25.5.0",
71
+ "mongodb-memory-server": "^10.1.4",
72
+ "mongoose": "^9.4.1",
73
+ "publint": "^0.3.23",
74
+ "tsdown": "^0.22.14",
75
+ "typescript": "^5.8.2",
76
+ "vitest": "^3.2.4",
77
+ "zod": "^4.3.6"
78
+ }
79
+ }
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * DRY RUN — which Lead / Contact rows are forked or unreachable under canonical
4
+ * email keys?
5
+ *
6
+ * Lead `email` and Contact `contact.email` used to be folded by mongoose
7
+ * `lowercase: true` alone. That does not IDNA-fold, so `ada@bücher.de` and
8
+ * `ada@xn--bcher-kva.de` — one host in two encodings — were two distinct keys and
9
+ * the partial-unique `(organizationId, <email>)` index admitted both. One mailbox,
10
+ * two rows, no error. Both fields now key through `normalizeEmail` from
11
+ * `@classytic/contact/email` on every write and every query filter.
12
+ *
13
+ * Rows written under the old rule are NOT migrated automatically, and must not be.
14
+ * These are live customer records: a rekey can hit the unique index outright, and
15
+ * deciding which of two forked rows survives (and what happens to the
16
+ * opportunities, activities and subscriptions pointing at the loser) is a human
17
+ * call. This script only READS. It never writes, never drops, never creates an
18
+ * index, never merges.
19
+ *
20
+ * Four outcomes per row:
21
+ *
22
+ * unchanged the stored value already IS its canonical key. No action.
23
+ * rekey canonicalises to a DIFFERENT string, and nothing else in the
24
+ * org holds that key. A `$set` would succeed — but see the
25
+ * closing note: it is still not this script's call.
26
+ * collision rekeying it would land on a key another row in the same org
27
+ * already holds. The unique index means a naive `updateOne`
28
+ * FAILS. These are the forks: two rows, one mailbox. Merging
29
+ * them — and re-pointing every FK off the loser — is a human
30
+ * decision, not a script's.
31
+ * uncanonicalisable not an address the canonical grammar admits. It could never
32
+ * have matched a real send or a real dedup either. The write
33
+ * verbs now refuse these outright, so no NEW ones appear; what
34
+ * to do with the existing ones is a data-quality decision.
35
+ *
36
+ * Usage:
37
+ * node scripts/lead-contact-email-key-drift.mjs <mongodb-uri> [options]
38
+ *
39
+ * Options:
40
+ * --leads=NAME lead collection (default: leads)
41
+ * --contacts=NAME contact collection (default: contacts)
42
+ * --json machine-readable output
43
+ *
44
+ * Collections default to mongoose's pluralisation of the `Lead` / `Contact` model
45
+ * names. Pass the flags when the host set a `modelPrefix`.
46
+ */
47
+
48
+ import { MongoClient } from 'mongodb';
49
+ import { normalizeEmail } from '@classytic/contact/email';
50
+
51
+ const args = process.argv.slice(2);
52
+ const uri = args.find((a) => !a.startsWith('--'));
53
+ const asJson = args.includes('--json');
54
+ const flag = (name, fallback) =>
55
+ (args.find((a) => a.startsWith(`--${name}=`)) ?? '').split('=')[1] || fallback;
56
+
57
+ if (!uri) {
58
+ console.error(
59
+ 'usage: node scripts/lead-contact-email-key-drift.mjs <mongodb-uri> ' +
60
+ '[--leads=NAME] [--contacts=NAME] [--json]',
61
+ );
62
+ process.exit(2);
63
+ }
64
+
65
+ /** The schema's key rule, without importing mongoose. */
66
+ function key(email) {
67
+ try {
68
+ return { ok: true, key: normalizeEmail(email) };
69
+ } catch (err) {
70
+ return { ok: false, reason: err?.code ?? 'invalid' };
71
+ }
72
+ }
73
+
74
+ /** Read a dotted path off a plain document (`contact.email`). */
75
+ function at(doc, path) {
76
+ return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), doc);
77
+ }
78
+
79
+ /**
80
+ * Classify one collection. `emailPath` is the dotted field the unique index is
81
+ * built on, so the projection, the classification and the collision scope all
82
+ * derive from ONE declaration — a report that classified a different field from
83
+ * the one the index enforces would be confidently wrong.
84
+ */
85
+ async function scan(db, { collectionName, emailPath }) {
86
+ const coll = db.collection(collectionName);
87
+ const total = await coll.countDocuments({});
88
+ const rows = await coll
89
+ .find({}, { projection: { _id: 1, organizationId: 1, [emailPath]: 1 } })
90
+ .toArray();
91
+
92
+ // Canonical keys ALREADY claimed per org. A rekey landing on one of these
93
+ // collides with the unique index whether or not both source rows move.
94
+ const occupied = new Map(); // org -> Map<canonicalKey, count>
95
+ for (const row of rows) {
96
+ const stored = at(row, emailPath);
97
+ if (typeof stored !== 'string') continue;
98
+ const org = String(row.organizationId ?? '');
99
+ if (!occupied.has(org)) occupied.set(org, new Map());
100
+ const k = key(stored);
101
+ if (!k.ok) continue;
102
+ const bucket = occupied.get(org);
103
+ bucket.set(k.key, (bucket.get(k.key) ?? 0) + 1);
104
+ }
105
+
106
+ const buckets = { unchanged: [], rekey: [], collision: [], uncanonicalisable: [] };
107
+ let withEmail = 0;
108
+ for (const row of rows) {
109
+ const stored = at(row, emailPath);
110
+ // The index is PARTIAL on `$type: 'string'` — a row with no email is not in
111
+ // it and cannot fork. Counting it as `unchanged` would inflate the clean
112
+ // number with rows the question does not apply to.
113
+ if (typeof stored !== 'string') continue;
114
+ withEmail += 1;
115
+
116
+ const org = String(row.organizationId ?? '');
117
+ const k = key(stored);
118
+ if (!k.ok) {
119
+ buckets.uncanonicalisable.push({ _id: String(row._id), organizationId: org, stored, reason: k.reason });
120
+ continue;
121
+ }
122
+ if (k.key === stored) {
123
+ buckets.unchanged.push({ _id: String(row._id), organizationId: org, stored });
124
+ continue;
125
+ }
126
+ const contenders = occupied.get(org)?.get(k.key) ?? 0;
127
+ (contenders > 1 ? buckets.collision : buckets.rekey).push({
128
+ _id: String(row._id),
129
+ organizationId: org,
130
+ stored,
131
+ canonical: k.key,
132
+ });
133
+ }
134
+
135
+ return {
136
+ summary: {
137
+ collection: collectionName,
138
+ field: emailPath,
139
+ totalRows: total,
140
+ rowsWithEmail: withEmail,
141
+ unchanged: buckets.unchanged.length,
142
+ rekey: buckets.rekey.length,
143
+ collision: buckets.collision.length,
144
+ uncanonicalisable: buckets.uncanonicalisable.length,
145
+ unmatchableToday:
146
+ buckets.rekey.length + buckets.collision.length + buckets.uncanonicalisable.length,
147
+ },
148
+ buckets,
149
+ };
150
+ }
151
+
152
+ function report(result) {
153
+ const s = result.summary;
154
+ console.log(`\ncollection ${s.collection} (${s.field})`);
155
+ console.log(`rows ${s.totalRows}`);
156
+ console.log(` with an email ${s.rowsWithEmail}`);
157
+ console.log(` unchanged ${s.unchanged}`);
158
+ console.log(` rekey ${s.rekey}`);
159
+ console.log(` collision ${s.collision}`);
160
+ console.log(` uncanonicalisable ${s.uncanonicalisable}`);
161
+ console.log(`UNMATCHABLE TODAY ${s.unmatchableToday}`);
162
+
163
+ for (const name of ['collision', 'rekey', 'uncanonicalisable']) {
164
+ const rows = result.buckets[name];
165
+ if (rows.length === 0) continue;
166
+ console.log(`\n── ${name} — ${s.collection} ──`);
167
+ for (const r of rows) {
168
+ console.log(
169
+ ` ${r.organizationId} ${JSON.stringify(r.stored)} -> ${r.canonical ?? `(none: ${r.reason})`}`,
170
+ );
171
+ }
172
+ }
173
+
174
+ // "Zero rows" and "collection absent" are DIFFERENT answers, and both print a
175
+ // clean-looking report. Say which one this was.
176
+ if (s.totalRows === 0) {
177
+ console.log(
178
+ `\nSCANNED NOTHING — collection '${s.collection}' is empty or absent in this database.\n` +
179
+ 'That is NOT the same answer as "clean". Confirm the collection name (a host with a\n' +
180
+ '`modelPrefix` stores it elsewhere) and the database in the URI before concluding\n' +
181
+ 'there is no drift.',
182
+ );
183
+ } else if (s.rowsWithEmail === 0) {
184
+ console.log(
185
+ `\nNO ROW IN '${s.collection}' CARRIES AN EMAIL — ${s.totalRows} row(s) scanned, none in the\n` +
186
+ 'partial-unique index. Nothing here can fork; nothing here is evidence that the rest is clean.',
187
+ );
188
+ }
189
+ }
190
+
191
+ const client = new MongoClient(uri);
192
+ try {
193
+ await client.connect();
194
+ const db = client.db();
195
+
196
+ const results = [
197
+ await scan(db, { collectionName: flag('leads', 'leads'), emailPath: 'email' }),
198
+ await scan(db, { collectionName: flag('contacts', 'contacts'), emailPath: 'contact.email' }),
199
+ ];
200
+
201
+ if (asJson) {
202
+ console.log(JSON.stringify(results, null, 2));
203
+ } else {
204
+ for (const r of results) report(r);
205
+ const collisions = results.reduce((n, r) => n + r.summary.collision, 0);
206
+ if (collisions > 0) {
207
+ console.log(
208
+ `\n${collisions} COLLISION(S). Each is one mailbox living as two rows. A naive \`$set\` to the\n` +
209
+ 'canonical key hits the unique index and fails — the two rows must be MERGED, and which\n' +
210
+ "reason/owner/history survives, plus where the loser's opportunities, activities, notes\n" +
211
+ 'and subscriptions re-point, is a decision this script will not make for you.',
212
+ );
213
+ }
214
+ console.log('\nNOTHING WAS WRITTEN. This is a report, not a migration.');
215
+ }
216
+ } finally {
217
+ await client.close();
218
+ }
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * DRY RUN — which suppression rows are unreachable under canonical keys?
4
+ *
5
+ * `SuppressionRepository` used to key rows with `email.trim().toLowerCase()`. It
6
+ * now keys them with `normalizeEmail` from `@classytic/contact/email`, which also
7
+ * IDNA-folds the domain to punycode and REFUSES what it cannot canonicalise.
8
+ *
9
+ * Rows written under the old rule are not migrated automatically, and must not be:
10
+ * a suppression list is a do-not-contact record with legal weight, so a bad rewrite
11
+ * is worse than a stale row. This script only READS. It never writes, never drops,
12
+ * never creates an index. Run it, read the numbers, and decide.
13
+ *
14
+ * Four outcomes per row:
15
+ *
16
+ * unchanged the stored value already IS its canonical key. No action.
17
+ * rekey canonicalises to a DIFFERENT string — today this row can
18
+ * never match a lookup. It is a suppression that suppresses
19
+ * nothing.
20
+ * collision rekeying it would land on a key another row in the same org
21
+ * already holds. The (organizationId, email) index is unique,
22
+ * so a naive `updateOne` here FAILS — the two rows must be
23
+ * merged, and which `reason`/`suppressedAt` survives is a
24
+ * human decision, not a script's.
25
+ * uncanonicalisable not an address the canonical grammar admits. It could never
26
+ * have matched a real send either; deleting it is probably
27
+ * right, but "probably" is why this script does not.
28
+ *
29
+ * Usage:
30
+ * node scripts/suppression-key-drift.mjs <mongodb-uri> [--collection=suppressions] [--json]
31
+ *
32
+ * The collection defaults to `suppressions` (mongoose's pluralisation of the
33
+ * `Suppression` model). Pass `--collection` when the host set a `modelPrefix`.
34
+ */
35
+
36
+ import { MongoClient } from 'mongodb';
37
+ import { normalizeEmail } from '@classytic/contact/email';
38
+
39
+ const args = process.argv.slice(2);
40
+ const uri = args.find((a) => !a.startsWith('--'));
41
+ const collectionName = (args.find((a) => a.startsWith('--collection=')) ?? '').split('=')[1] || 'suppressions';
42
+ const asJson = args.includes('--json');
43
+
44
+ if (!uri) {
45
+ console.error('usage: node scripts/suppression-key-drift.mjs <mongodb-uri> [--collection=NAME] [--json]');
46
+ process.exit(2);
47
+ }
48
+
49
+ /** The repository's key rule, without importing the repository (no mongoose here). */
50
+ function key(email) {
51
+ try {
52
+ return { ok: true, key: normalizeEmail(email) };
53
+ } catch (err) {
54
+ return { ok: false, reason: err?.code ?? 'invalid' };
55
+ }
56
+ }
57
+
58
+ const client = new MongoClient(uri);
59
+ try {
60
+ await client.connect();
61
+ const coll = client.db().collection(collectionName);
62
+
63
+ const total = await coll.countDocuments({});
64
+ const rows = await coll
65
+ .find({}, { projection: { _id: 1, organizationId: 1, email: 1, reason: 1, suppressedAt: 1 } })
66
+ .toArray();
67
+
68
+ // Canonical keys ALREADY present per org — a rekey landing on one of these
69
+ // collides with the unique index whether or not the source rows both move.
70
+ const occupied = new Map(); // `${org}` -> Map<canonicalKey, count>
71
+ for (const row of rows) {
72
+ const org = String(row.organizationId ?? '');
73
+ if (!occupied.has(org)) occupied.set(org, new Map());
74
+ const k = key(String(row.email ?? ''));
75
+ if (!k.ok) continue;
76
+ const bucket = occupied.get(org);
77
+ bucket.set(k.key, (bucket.get(k.key) ?? 0) + 1);
78
+ }
79
+
80
+ const buckets = { unchanged: [], rekey: [], collision: [], uncanonicalisable: [] };
81
+ for (const row of rows) {
82
+ const stored = String(row.email ?? '');
83
+ const org = String(row.organizationId ?? '');
84
+ const k = key(stored);
85
+ if (!k.ok) {
86
+ buckets.uncanonicalisable.push({ _id: String(row._id), organizationId: org, stored, reason: k.reason });
87
+ continue;
88
+ }
89
+ if (k.key === stored) {
90
+ buckets.unchanged.push({ _id: String(row._id), organizationId: org, stored });
91
+ continue;
92
+ }
93
+ const contenders = occupied.get(org)?.get(k.key) ?? 0;
94
+ const target = contenders > 1;
95
+ (target ? buckets.collision : buckets.rekey).push({
96
+ _id: String(row._id),
97
+ organizationId: org,
98
+ stored,
99
+ canonical: k.key,
100
+ reason: row.reason,
101
+ });
102
+ }
103
+
104
+ const summary = {
105
+ collection: collectionName,
106
+ totalRows: total,
107
+ unchanged: buckets.unchanged.length,
108
+ rekey: buckets.rekey.length,
109
+ collision: buckets.collision.length,
110
+ uncanonicalisable: buckets.uncanonicalisable.length,
111
+ unmatchableToday: buckets.rekey.length + buckets.collision.length + buckets.uncanonicalisable.length,
112
+ };
113
+
114
+ if (asJson) {
115
+ console.log(JSON.stringify({ summary, rekey: buckets.rekey, collision: buckets.collision, uncanonicalisable: buckets.uncanonicalisable }, null, 2));
116
+ } else {
117
+ console.log(`collection ${summary.collection}`);
118
+ console.log(`rows ${summary.totalRows}`);
119
+ console.log(` unchanged ${summary.unchanged}`);
120
+ console.log(` rekey ${summary.rekey}`);
121
+ console.log(` collision ${summary.collision}`);
122
+ console.log(` uncanonicalisable ${summary.uncanonicalisable}`);
123
+ console.log(`UNMATCHABLE TODAY ${summary.unmatchableToday}`);
124
+ for (const name of ['rekey', 'collision', 'uncanonicalisable']) {
125
+ if (buckets[name].length === 0) continue;
126
+ console.log(`\n── ${name} ──`);
127
+ for (const r of buckets[name]) {
128
+ console.log(` ${r.organizationId} ${JSON.stringify(r.stored)} -> ${r.canonical ?? `(none: ${r.reason})`}`);
129
+ }
130
+ }
131
+ if (summary.totalRows === 0) {
132
+ console.log(
133
+ `\nSCANNED NOTHING — collection '${collectionName}' is empty or absent in this database.\n` +
134
+ 'That is NOT the same answer as "clean". Confirm the collection name (a host with a\n' +
135
+ '`modelPrefix` stores it elsewhere) and the database in the URI before concluding\n' +
136
+ 'there is no drift.',
137
+ );
138
+ }
139
+ console.log('\nNOTHING WAS WRITTEN. This is a report, not a migration.');
140
+ }
141
+ } finally {
142
+ await client.close();
143
+ }
File without changes