@hogsend/engine 0.6.0 → 0.7.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 +6 -6
- package/src/buckets/check-membership.ts +34 -15
- package/src/container.ts +33 -0
- package/src/env.ts +4 -0
- package/src/index.ts +13 -0
- package/src/journeys/journey-context.ts +5 -1
- package/src/lib/boot.ts +1 -1
- package/src/lib/bucket-emit.ts +2 -2
- package/src/lib/contacts.ts +1083 -18
- package/src/lib/email-service-types.ts +8 -0
- package/src/lib/ingestion.ts +63 -33
- package/src/lib/mailer.ts +1 -0
- package/src/lib/preferences.ts +106 -0
- package/src/lib/tracked.ts +159 -34
- package/src/lib/tracking-events.ts +1 -1
- package/src/lists/define-list.ts +81 -0
- package/src/lists/registry-singleton.ts +39 -0
- package/src/lists/registry.ts +95 -0
- package/src/middleware/api-key.ts +33 -7
- package/src/middleware/rate-limit.ts +73 -49
- package/src/routes/_shared.ts +30 -0
- package/src/routes/admin/api-keys.ts +1 -1
- package/src/routes/admin/bulk.ts +7 -3
- package/src/routes/admin/contacts.ts +66 -57
- package/src/routes/admin/events.ts +65 -0
- package/src/routes/admin/journeys.ts +3 -1
- package/src/routes/admin/preferences.ts +2 -2
- package/src/routes/admin/reporting.ts +3 -3
- package/src/routes/admin/timeline.ts +5 -2
- package/src/routes/campaigns/index.ts +252 -0
- package/src/routes/contacts/index.ts +188 -0
- package/src/routes/email/preferences.ts +27 -3
- package/src/routes/email/unsubscribe.ts +7 -49
- package/src/routes/emails/index.ts +133 -0
- package/src/routes/events/index.ts +119 -0
- package/src/routes/index.ts +52 -2
- package/src/routes/lists/index.ts +222 -0
- package/src/worker.ts +6 -0
- package/src/workflows/bucket-backfill.ts +32 -21
- package/src/workflows/bucket-reconcile.ts +20 -5
- package/src/workflows/import-contacts.ts +28 -20
- package/src/workflows/send-campaign.ts +589 -0
- package/src/routes/ingest.ts +0 -71
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
import { getBucketRegistrySingleton } from "../buckets/registry-singleton.js";
|
|
42
42
|
import { getJourneyRegistrySingleton } from "../journeys/registry-singleton.js";
|
|
43
43
|
import { emitBucketTransition } from "../lib/bucket-emit.js";
|
|
44
|
+
import { contactKeySql } from "../lib/contacts.js";
|
|
44
45
|
import { hatchet } from "../lib/hatchet.js";
|
|
45
46
|
import type { Logger } from "../lib/logger.js";
|
|
46
47
|
import { createLogger } from "../lib/logger.js";
|
|
@@ -841,18 +842,27 @@ async function reconcileBucketJoins(opts: {
|
|
|
841
842
|
? selectPresentInAllWindows(db, absenceLegs)
|
|
842
843
|
: null;
|
|
843
844
|
|
|
845
|
+
// The membership/event tables key on the RESOLVED string key (external_id ??
|
|
846
|
+
// anonymous_id ?? contact.id), NOT necessarily external_id — email-only /
|
|
847
|
+
// anonymous contacts have a NULL external_id and are keyed on their uuid /
|
|
848
|
+
// anonymous_id. Joining on contacts.externalId would force external_id NOT NULL
|
|
849
|
+
// for every candidate (the coalesce would collapse to external_id) and silently
|
|
850
|
+
// drop exactly the dormant email-only contacts this cron exists to reconcile.
|
|
851
|
+
// Join on the SAME coalesce expression so the projected key matches the join.
|
|
852
|
+
const contactKey = contactKeySql();
|
|
853
|
+
|
|
844
854
|
const baseQuery = db
|
|
845
855
|
.select({
|
|
846
|
-
userId:
|
|
856
|
+
userId: contactKey,
|
|
847
857
|
email: contacts.email,
|
|
848
858
|
})
|
|
849
859
|
.from(contacts)
|
|
850
|
-
.innerJoin(everFired, eq(everFired.userId,
|
|
851
|
-
.leftJoin(activeMembers, eq(activeMembers.userId,
|
|
860
|
+
.innerJoin(everFired, eq(everFired.userId, contactKey))
|
|
861
|
+
.leftJoin(activeMembers, eq(activeMembers.userId, contactKey));
|
|
852
862
|
|
|
853
863
|
const candidates = await (presentInAll
|
|
854
864
|
? baseQuery
|
|
855
|
-
.leftJoin(presentInAll, eq(presentInAll.userId,
|
|
865
|
+
.leftJoin(presentInAll, eq(presentInAll.userId, contactKey))
|
|
856
866
|
.where(
|
|
857
867
|
and(
|
|
858
868
|
isNull(contacts.deletedAt),
|
|
@@ -864,7 +874,12 @@ async function reconcileBucketJoins(opts: {
|
|
|
864
874
|
and(isNull(contacts.deletedAt), isNull(activeMembers.userId)),
|
|
865
875
|
)
|
|
866
876
|
)
|
|
867
|
-
|
|
877
|
+
// Deterministic scan order for the bounded re-run (no keyset cursor; the
|
|
878
|
+
// scan advances as reconciled matchers become active members and drop out).
|
|
879
|
+
// Order by contacts.id (the non-null unique PK) so the scan is null-safe and
|
|
880
|
+
// stable even for null-external_id contacts now that the join is on the
|
|
881
|
+
// coalesce key.
|
|
882
|
+
.orderBy(sql`${contacts.id} asc`)
|
|
868
883
|
.limit(BATCH_SIZE);
|
|
869
884
|
|
|
870
885
|
// SET-BASED / EXACT shapes (Fix #3) — every candidate row is a true matcher,
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { createDatabase, importJobs } from "@hogsend/db";
|
|
2
2
|
import { eq } from "drizzle-orm";
|
|
3
3
|
import Papa from "papaparse";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveOrCreateContact } from "../lib/contacts.js";
|
|
5
5
|
import { hatchet } from "../lib/hatchet.js";
|
|
6
6
|
|
|
7
7
|
const BATCH_SIZE = 500;
|
|
8
8
|
|
|
9
|
+
interface ImportRow {
|
|
10
|
+
externalId?: string;
|
|
11
|
+
email?: string;
|
|
12
|
+
properties?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
export const importContactsTask = hatchet.task({
|
|
10
16
|
name: "import-contacts",
|
|
11
17
|
retries: 0,
|
|
@@ -20,11 +26,7 @@ export const importContactsTask = hatchet.task({
|
|
|
20
26
|
.set({ status: "processing", updatedAt: new Date() })
|
|
21
27
|
.where(eq(importJobs.id, input.jobId));
|
|
22
28
|
|
|
23
|
-
let rows:
|
|
24
|
-
externalId: string;
|
|
25
|
-
email?: string;
|
|
26
|
-
properties?: Record<string, unknown>;
|
|
27
|
-
}>;
|
|
29
|
+
let rows: ImportRow[];
|
|
28
30
|
|
|
29
31
|
try {
|
|
30
32
|
if (input.format === "json") {
|
|
@@ -61,14 +63,22 @@ export const importContactsTask = hatchet.task({
|
|
|
61
63
|
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
|
62
64
|
const batch = rows.slice(i, i + BATCH_SIZE);
|
|
63
65
|
const results = await Promise.allSettled(
|
|
64
|
-
batch.map((row, idx) =>
|
|
65
|
-
|
|
66
|
+
batch.map((row, idx) => {
|
|
67
|
+
// Accept email-only rows (D1): require AT LEAST one identity key,
|
|
68
|
+
// not externalId specifically. The resolver upserts/merges on
|
|
69
|
+
// whichever keys are present.
|
|
70
|
+
if (!row.externalId && !row.email) {
|
|
71
|
+
return Promise.reject(
|
|
72
|
+
new Error("Row has neither externalId nor email"),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return resolveOrCreateContact({
|
|
66
76
|
db,
|
|
67
|
-
|
|
77
|
+
userId: row.externalId,
|
|
68
78
|
email: row.email,
|
|
69
|
-
|
|
70
|
-
}).then(() => ({ index: i + idx, ok: true }))
|
|
71
|
-
),
|
|
79
|
+
contactProperties: row.properties,
|
|
80
|
+
}).then(() => ({ index: i + idx, ok: true }));
|
|
81
|
+
}),
|
|
72
82
|
);
|
|
73
83
|
|
|
74
84
|
results.forEach((result, batchIdx) => {
|
|
@@ -108,25 +118,23 @@ export const importContactsTask = hatchet.task({
|
|
|
108
118
|
},
|
|
109
119
|
});
|
|
110
120
|
|
|
111
|
-
function parseCsv(data: string):
|
|
112
|
-
externalId: string;
|
|
113
|
-
email?: string;
|
|
114
|
-
properties?: Record<string, unknown>;
|
|
115
|
-
}> {
|
|
121
|
+
function parseCsv(data: string): ImportRow[] {
|
|
116
122
|
const result = Papa.parse<Record<string, string>>(data, {
|
|
117
123
|
header: true,
|
|
118
124
|
skipEmptyLines: true,
|
|
119
125
|
});
|
|
120
126
|
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
const fields = result.meta.fields ?? [];
|
|
128
|
+
// Accept email-only imports (D1): require AT LEAST one identity column.
|
|
129
|
+
if (!fields.includes("externalId") && !fields.includes("email")) {
|
|
130
|
+
throw new Error("CSV must have an externalId or email column");
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
return result.data.map((row) => {
|
|
126
134
|
const { externalId, email, ...rest } = row;
|
|
127
135
|
const properties = Object.keys(rest).length > 0 ? rest : undefined;
|
|
128
136
|
return {
|
|
129
|
-
externalId: externalId
|
|
137
|
+
externalId: externalId || undefined,
|
|
130
138
|
email: email || undefined,
|
|
131
139
|
properties: properties as Record<string, unknown> | undefined,
|
|
132
140
|
};
|