@hogsend/db 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/drizzle/0010_equal_legion.sql +8 -0
- package/drizzle/0011_robust_dracula.sql +35 -0
- package/drizzle/0012_fixed_sebastian_shaw.sql +2 -0
- package/drizzle/meta/0010_snapshot.json +2521 -0
- package/drizzle/meta/0011_snapshot.json +2804 -0
- package/drizzle/meta/0012_snapshot.json +2825 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +2 -1
- package/src/demo-seed.ts +763 -0
- package/src/schema/bucket-configs.ts +17 -0
- package/src/schema/bucket-memberships.ts +72 -0
- package/src/schema/email-sends.ts +9 -0
- package/src/schema/enums.ts +5 -0
- package/src/schema/index.ts +2 -0
- package/src/schema/relations.ts +15 -0
- package/src/stamp.ts +85 -0
package/src/demo-seed.ts
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo seed — populates the local database with a realistic dataset for
|
|
3
|
+
* Hogsend Studio screenshots / demos. Everything it writes is scoped to a
|
|
4
|
+
* `demo_` userId prefix so it is fully idempotent and never touches real data.
|
|
5
|
+
*
|
|
6
|
+
* It writes directly to the tables (contacts, journey_states, email_sends,
|
|
7
|
+
* email_preferences, user_events) — it does NOT run any journeys, so no real
|
|
8
|
+
* emails are ever sent through Resend.
|
|
9
|
+
*
|
|
10
|
+
* DATABASE_URL=... pnpm --filter @hogsend/db exec tsx src/demo-seed.ts
|
|
11
|
+
*/
|
|
12
|
+
import { like } from "drizzle-orm";
|
|
13
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
14
|
+
import postgres from "postgres";
|
|
15
|
+
import * as schema from "./schema/index.js";
|
|
16
|
+
|
|
17
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
18
|
+
if (!databaseUrl) {
|
|
19
|
+
console.error("DATABASE_URL environment variable is required");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const client = postgres(databaseUrl, { max: 1 });
|
|
24
|
+
const db = drizzle(client, { schema });
|
|
25
|
+
|
|
26
|
+
// --- Deterministic PRNG so the dataset is stable across runs ---
|
|
27
|
+
function mulberry32(seed: number) {
|
|
28
|
+
let a = seed;
|
|
29
|
+
return () => {
|
|
30
|
+
a |= 0;
|
|
31
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
32
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
33
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
34
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const rand = mulberry32(20260602);
|
|
38
|
+
const pick = <T>(arr: readonly T[]): T =>
|
|
39
|
+
arr[Math.floor(rand() * arr.length)] as T;
|
|
40
|
+
const int = (min: number, max: number) =>
|
|
41
|
+
Math.floor(rand() * (max - min + 1)) + min;
|
|
42
|
+
const chance = (p: number) => rand() < p;
|
|
43
|
+
|
|
44
|
+
const NOW = Date.now();
|
|
45
|
+
const DAY = 86_400_000;
|
|
46
|
+
const HOUR = 3_600_000;
|
|
47
|
+
const MIN = 60_000;
|
|
48
|
+
const at = (ms: number) => new Date(Math.min(ms, NOW - MIN));
|
|
49
|
+
const daysAgo = (d: number, jitterH = 12) =>
|
|
50
|
+
NOW - d * DAY - int(0, jitterH) * HOUR;
|
|
51
|
+
|
|
52
|
+
// --- People ---
|
|
53
|
+
const FIRST = [
|
|
54
|
+
"Ada",
|
|
55
|
+
"Lin",
|
|
56
|
+
"Marco",
|
|
57
|
+
"Priya",
|
|
58
|
+
"Sofia",
|
|
59
|
+
"Noah",
|
|
60
|
+
"Elena",
|
|
61
|
+
"Diego",
|
|
62
|
+
"Yuki",
|
|
63
|
+
"Omar",
|
|
64
|
+
"Hana",
|
|
65
|
+
"Theo",
|
|
66
|
+
"Maya",
|
|
67
|
+
"Ivan",
|
|
68
|
+
"Zoe",
|
|
69
|
+
"Liam",
|
|
70
|
+
"Nina",
|
|
71
|
+
"Caleb",
|
|
72
|
+
"Aria",
|
|
73
|
+
"Ravi",
|
|
74
|
+
"Mila",
|
|
75
|
+
"Felix",
|
|
76
|
+
"Sara",
|
|
77
|
+
"Jonas",
|
|
78
|
+
"Lena",
|
|
79
|
+
"Kofi",
|
|
80
|
+
"Amara",
|
|
81
|
+
"Tom",
|
|
82
|
+
"Grace",
|
|
83
|
+
"Bo",
|
|
84
|
+
"Iris",
|
|
85
|
+
"Sami",
|
|
86
|
+
"Otto",
|
|
87
|
+
"Vera",
|
|
88
|
+
"Hugo",
|
|
89
|
+
"Nadia",
|
|
90
|
+
"Pablo",
|
|
91
|
+
"Esme",
|
|
92
|
+
"Karl",
|
|
93
|
+
"Tara",
|
|
94
|
+
"Leo",
|
|
95
|
+
"Wren",
|
|
96
|
+
"Cyrus",
|
|
97
|
+
"Dahlia",
|
|
98
|
+
"Jude",
|
|
99
|
+
"Anya",
|
|
100
|
+
"Reza",
|
|
101
|
+
"Cleo",
|
|
102
|
+
"Milo",
|
|
103
|
+
"Freya",
|
|
104
|
+
"Said",
|
|
105
|
+
"Tess",
|
|
106
|
+
"Niko",
|
|
107
|
+
"Ines",
|
|
108
|
+
"Bram",
|
|
109
|
+
"Yara",
|
|
110
|
+
"Emil",
|
|
111
|
+
"Lucia",
|
|
112
|
+
"Arlo",
|
|
113
|
+
"Devi",
|
|
114
|
+
"Soren",
|
|
115
|
+
"Nora",
|
|
116
|
+
"Idris",
|
|
117
|
+
"Beatriz",
|
|
118
|
+
] as const;
|
|
119
|
+
const LAST = [
|
|
120
|
+
"Okafor",
|
|
121
|
+
"Reyes",
|
|
122
|
+
"Nguyen",
|
|
123
|
+
"Costa",
|
|
124
|
+
"Haddad",
|
|
125
|
+
"Lindqvist",
|
|
126
|
+
"Moreno",
|
|
127
|
+
"Petrov",
|
|
128
|
+
"Kim",
|
|
129
|
+
"Bauer",
|
|
130
|
+
"Silva",
|
|
131
|
+
"Novak",
|
|
132
|
+
"Adeyemi",
|
|
133
|
+
"Rossi",
|
|
134
|
+
"Mbeki",
|
|
135
|
+
"Kowalski",
|
|
136
|
+
"Tan",
|
|
137
|
+
"Fischer",
|
|
138
|
+
"Abate",
|
|
139
|
+
"Singh",
|
|
140
|
+
"Larsen",
|
|
141
|
+
"Mensah",
|
|
142
|
+
"Vasquez",
|
|
143
|
+
"Holm",
|
|
144
|
+
"Dubois",
|
|
145
|
+
"Bianchi",
|
|
146
|
+
"Sato",
|
|
147
|
+
"Walsh",
|
|
148
|
+
"Cohen",
|
|
149
|
+
"Park",
|
|
150
|
+
] as const;
|
|
151
|
+
const DOMAINS = [
|
|
152
|
+
"lumen.io",
|
|
153
|
+
"northstar.dev",
|
|
154
|
+
"kettle.app",
|
|
155
|
+
"fathom.co",
|
|
156
|
+
"peat.io",
|
|
157
|
+
"mosaic.so",
|
|
158
|
+
"driftly.com",
|
|
159
|
+
"corewave.io",
|
|
160
|
+
"pinely.app",
|
|
161
|
+
"brightloop.dev",
|
|
162
|
+
"tideline.io",
|
|
163
|
+
"quill.so",
|
|
164
|
+
] as const;
|
|
165
|
+
const PLANS = ["free", "trial", "pro", "growth"] as const;
|
|
166
|
+
const TZS = [
|
|
167
|
+
"America/New_York",
|
|
168
|
+
"America/Los_Angeles",
|
|
169
|
+
"Europe/London",
|
|
170
|
+
"Europe/Berlin",
|
|
171
|
+
"Asia/Singapore",
|
|
172
|
+
"Australia/Sydney",
|
|
173
|
+
"America/Sao_Paulo",
|
|
174
|
+
] as const;
|
|
175
|
+
|
|
176
|
+
const USER_COUNT = 64;
|
|
177
|
+
const usedEmails = new Set<string>();
|
|
178
|
+
const users = Array.from({ length: USER_COUNT }, (_, i) => {
|
|
179
|
+
const first = FIRST[i % FIRST.length] as string;
|
|
180
|
+
const last = pick(LAST);
|
|
181
|
+
let email = `${first}.${last}`.toLowerCase().replace(/[^a-z.]/g, "");
|
|
182
|
+
const domain = pick(DOMAINS);
|
|
183
|
+
let candidate = `${email}@${domain}`;
|
|
184
|
+
let n = 2;
|
|
185
|
+
while (usedEmails.has(candidate)) {
|
|
186
|
+
candidate = `${email}${n}@${domain}`;
|
|
187
|
+
n += 1;
|
|
188
|
+
}
|
|
189
|
+
usedEmails.add(candidate);
|
|
190
|
+
email = candidate;
|
|
191
|
+
return {
|
|
192
|
+
id: `demo_u${String(i + 1).padStart(3, "0")}`,
|
|
193
|
+
name: `${first} ${last}`,
|
|
194
|
+
email,
|
|
195
|
+
plan: pick(PLANS),
|
|
196
|
+
timezone: pick(TZS),
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// --- Templates (mirrors apps/api/src/emails/registry.ts) ---
|
|
201
|
+
const TEMPLATES: { key: string; subject: string; category: string }[] = [
|
|
202
|
+
{ key: "welcome", subject: "Welcome to Hogsend", category: "transactional" },
|
|
203
|
+
{
|
|
204
|
+
key: "password-reset",
|
|
205
|
+
subject: "Reset your password",
|
|
206
|
+
category: "transactional",
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
key: "activation-quickstart",
|
|
210
|
+
subject: "Your Hogsend setup guide",
|
|
211
|
+
category: "journey",
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
key: "activation-feature-highlight",
|
|
215
|
+
subject: "Journeys are just TypeScript",
|
|
216
|
+
category: "journey",
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
key: "activation-community",
|
|
220
|
+
subject: "See what other teams are shipping",
|
|
221
|
+
category: "journey",
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
key: "activation-nudge",
|
|
225
|
+
subject: "We haven't seen any events yet",
|
|
226
|
+
category: "journey",
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
key: "conversion-usage-milestone",
|
|
230
|
+
subject: "You've hit a Hogsend milestone",
|
|
231
|
+
category: "journey",
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
key: "conversion-trial-expiring",
|
|
235
|
+
subject: "Your Hogsend Cloud trial is ending soon",
|
|
236
|
+
category: "journey",
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
key: "conversion-winback-offer",
|
|
240
|
+
subject: "A little something to come back",
|
|
241
|
+
category: "journey",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
key: "retention-achievement",
|
|
245
|
+
subject: "You hit a milestone 🎉",
|
|
246
|
+
category: "journey",
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
key: "retention-weekly-digest",
|
|
250
|
+
subject: "Your Hogsend week",
|
|
251
|
+
category: "journey",
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
key: "reactivation-checkin",
|
|
255
|
+
subject: "Your project's gone quiet",
|
|
256
|
+
category: "journey",
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
key: "reactivation-final-nudge",
|
|
260
|
+
subject: "One last note from Hogsend",
|
|
261
|
+
category: "journey",
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
key: "feedback-nps-survey",
|
|
265
|
+
subject: "Quick question — how are we doing?",
|
|
266
|
+
category: "journey",
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
key: "churn-payment-failed",
|
|
270
|
+
subject: "Your payment didn't go through",
|
|
271
|
+
category: "transactional",
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
const tpl = (key: string): { key: string; subject: string; category: string } =>
|
|
275
|
+
TEMPLATES.find((t) => t.key === key) ?? {
|
|
276
|
+
key,
|
|
277
|
+
subject: key,
|
|
278
|
+
category: "journey",
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// --- Journeys (id/name mirror apps/api/src/journeys) with funnel targets and
|
|
282
|
+
// the templates each one sends. ---
|
|
283
|
+
type Counts = {
|
|
284
|
+
active: number;
|
|
285
|
+
waiting: number;
|
|
286
|
+
completed: number;
|
|
287
|
+
failed: number;
|
|
288
|
+
exited: number;
|
|
289
|
+
};
|
|
290
|
+
const JOURNEYS: {
|
|
291
|
+
id: string;
|
|
292
|
+
templates: string[];
|
|
293
|
+
counts: Counts;
|
|
294
|
+
}[] = [
|
|
295
|
+
{
|
|
296
|
+
id: "activation-welcome",
|
|
297
|
+
templates: ["activation-quickstart", "activation-nudge"],
|
|
298
|
+
counts: { active: 8, waiting: 5, completed: 22, failed: 1, exited: 3 },
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: "activation-nudge-series",
|
|
302
|
+
templates: [
|
|
303
|
+
"activation-feature-highlight",
|
|
304
|
+
"activation-community",
|
|
305
|
+
"activation-nudge",
|
|
306
|
+
],
|
|
307
|
+
counts: { active: 6, waiting: 4, completed: 14, failed: 1, exited: 2 },
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: "conversion-trial-upgrade",
|
|
311
|
+
templates: ["conversion-trial-expiring", "conversion-usage-milestone"],
|
|
312
|
+
counts: { active: 5, waiting: 3, completed: 9, failed: 2, exited: 4 },
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
id: "conversion-abandoned-checkout",
|
|
316
|
+
templates: ["conversion-usage-milestone", "conversion-winback-offer"],
|
|
317
|
+
counts: { active: 4, waiting: 2, completed: 7, failed: 0, exited: 6 },
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
id: "churn-prevention",
|
|
321
|
+
templates: ["churn-payment-failed"],
|
|
322
|
+
counts: { active: 3, waiting: 2, completed: 5, failed: 1, exited: 8 },
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
id: "reactivation-dormancy",
|
|
326
|
+
templates: ["reactivation-checkin", "reactivation-final-nudge"],
|
|
327
|
+
counts: { active: 5, waiting: 6, completed: 4, failed: 0, exited: 3 },
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: "retention-milestone",
|
|
331
|
+
templates: ["retention-achievement", "retention-weekly-digest"],
|
|
332
|
+
counts: { active: 4, waiting: 0, completed: 12, failed: 0, exited: 0 },
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
id: "feedback-nps",
|
|
336
|
+
templates: ["feedback-nps-survey"],
|
|
337
|
+
counts: { active: 2, waiting: 1, completed: 9, failed: 0, exited: 1 },
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: "referral-invite",
|
|
341
|
+
templates: ["retention-achievement"],
|
|
342
|
+
counts: { active: 3, waiting: 0, completed: 6, failed: 0, exited: 1 },
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
id: "test-onboarding",
|
|
346
|
+
templates: ["welcome"],
|
|
347
|
+
counts: { active: 1, waiting: 0, completed: 2, failed: 0, exited: 0 },
|
|
348
|
+
},
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
const ERRORS = [
|
|
352
|
+
"Resend API timeout after 3 retries",
|
|
353
|
+
"Template render failed: missing prop `name`",
|
|
354
|
+
"Recipient hard-bounced mid-journey",
|
|
355
|
+
"Hatchet task exceeded max duration",
|
|
356
|
+
];
|
|
357
|
+
|
|
358
|
+
type JS = typeof schema.journeyStates.$inferInsert;
|
|
359
|
+
type ES = typeof schema.emailSends.$inferInsert;
|
|
360
|
+
type EV = typeof schema.userEvents.$inferInsert;
|
|
361
|
+
|
|
362
|
+
const journeyStateRows: JS[] = [];
|
|
363
|
+
// Track (stateLocalId) -> meta so we can attach sends after insert returns ids.
|
|
364
|
+
const stateMeta: {
|
|
365
|
+
localKey: string;
|
|
366
|
+
journeyId: string;
|
|
367
|
+
templates: string[];
|
|
368
|
+
user: (typeof users)[number];
|
|
369
|
+
status: string;
|
|
370
|
+
createdMs: number;
|
|
371
|
+
}[] = [];
|
|
372
|
+
|
|
373
|
+
let cursor = 0;
|
|
374
|
+
const nextUser = () => users[cursor++ % users.length] as (typeof users)[number];
|
|
375
|
+
|
|
376
|
+
for (const j of JOURNEYS) {
|
|
377
|
+
cursor = JOURNEYS.indexOf(j) * 7; // rotate the starting user per journey
|
|
378
|
+
const seenInJourney = new Set<string>();
|
|
379
|
+
const take = (): (typeof users)[number] => {
|
|
380
|
+
let u = nextUser();
|
|
381
|
+
let guard = 0;
|
|
382
|
+
while (seenInJourney.has(u.id) && guard < users.length) {
|
|
383
|
+
u = nextUser();
|
|
384
|
+
guard += 1;
|
|
385
|
+
}
|
|
386
|
+
seenInJourney.add(u.id);
|
|
387
|
+
return u;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const emit = (status: keyof Counts, n: number) => {
|
|
391
|
+
for (let i = 0; i < n; i++) {
|
|
392
|
+
const u = take();
|
|
393
|
+
let createdMs: number;
|
|
394
|
+
let currentNodeId: string;
|
|
395
|
+
let completedAt: Date | null = null;
|
|
396
|
+
let exitedAt: Date | null = null;
|
|
397
|
+
let errorMessage: string | null = null;
|
|
398
|
+
let updatedMs: number;
|
|
399
|
+
|
|
400
|
+
if (status === "active") {
|
|
401
|
+
createdMs = daysAgo(int(0, 6));
|
|
402
|
+
currentNodeId = pick([
|
|
403
|
+
"welcome-sent",
|
|
404
|
+
"wait-2d",
|
|
405
|
+
"check-feature-used",
|
|
406
|
+
"nudge-decision",
|
|
407
|
+
]);
|
|
408
|
+
updatedMs = createdMs + int(1, 40) * HOUR;
|
|
409
|
+
} else if (status === "waiting") {
|
|
410
|
+
createdMs = daysAgo(int(1, 9));
|
|
411
|
+
currentNodeId = "sleeping";
|
|
412
|
+
updatedMs = createdMs + int(2, 30) * HOUR;
|
|
413
|
+
} else if (status === "completed") {
|
|
414
|
+
createdMs = daysAgo(int(5, 29));
|
|
415
|
+
const dur = int(1, 6) * DAY + int(0, 20) * HOUR;
|
|
416
|
+
completedAt = at(createdMs + dur);
|
|
417
|
+
currentNodeId = "journey-complete";
|
|
418
|
+
updatedMs = completedAt.getTime();
|
|
419
|
+
} else if (status === "failed") {
|
|
420
|
+
createdMs = daysAgo(int(2, 20));
|
|
421
|
+
errorMessage = pick(ERRORS);
|
|
422
|
+
currentNodeId = "send-email";
|
|
423
|
+
updatedMs = createdMs + int(1, 24) * HOUR;
|
|
424
|
+
} else {
|
|
425
|
+
createdMs = daysAgo(int(3, 25));
|
|
426
|
+
exitedAt = at(createdMs + int(0, 4) * DAY + int(1, 20) * HOUR);
|
|
427
|
+
currentNodeId = "exit-on-event";
|
|
428
|
+
updatedMs = exitedAt.getTime();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const localKey = `${j.id}:${status}:${i}:${u.id}`;
|
|
432
|
+
journeyStateRows.push({
|
|
433
|
+
userId: u.id,
|
|
434
|
+
userEmail: u.email,
|
|
435
|
+
journeyId: j.id,
|
|
436
|
+
currentNodeId,
|
|
437
|
+
status,
|
|
438
|
+
context: { plan: u.plan, source: "demo" },
|
|
439
|
+
errorMessage,
|
|
440
|
+
entryCount: 1,
|
|
441
|
+
completedAt,
|
|
442
|
+
exitedAt,
|
|
443
|
+
createdAt: at(createdMs),
|
|
444
|
+
updatedAt: at(updatedMs),
|
|
445
|
+
});
|
|
446
|
+
stateMeta.push({
|
|
447
|
+
localKey,
|
|
448
|
+
journeyId: j.id,
|
|
449
|
+
templates: j.templates,
|
|
450
|
+
user: u,
|
|
451
|
+
status,
|
|
452
|
+
createdMs,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
emit("active", j.counts.active);
|
|
458
|
+
emit("waiting", j.counts.waiting);
|
|
459
|
+
emit("completed", j.counts.completed);
|
|
460
|
+
emit("failed", j.counts.failed);
|
|
461
|
+
emit("exited", j.counts.exited);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// --- Build email_sends. One outcome funnel per send. ---
|
|
465
|
+
function buildSend(opts: {
|
|
466
|
+
templateKey: string;
|
|
467
|
+
user: (typeof users)[number];
|
|
468
|
+
journeyStateId: string | null;
|
|
469
|
+
baseMs: number;
|
|
470
|
+
}): ES {
|
|
471
|
+
const t = tpl(opts.templateKey);
|
|
472
|
+
const createdMs = opts.baseMs + int(0, 90) * MIN;
|
|
473
|
+
let status:
|
|
474
|
+
| "queued"
|
|
475
|
+
| "sent"
|
|
476
|
+
| "delivered"
|
|
477
|
+
| "opened"
|
|
478
|
+
| "clicked"
|
|
479
|
+
| "bounced"
|
|
480
|
+
| "complained"
|
|
481
|
+
| "failed" = "delivered";
|
|
482
|
+
let sentAt: Date | null = null;
|
|
483
|
+
let deliveredAt: Date | null = null;
|
|
484
|
+
let openedAt: Date | null = null;
|
|
485
|
+
let clickedAt: Date | null = null;
|
|
486
|
+
let bouncedAt: Date | null = null;
|
|
487
|
+
let complainedAt: Date | null = null;
|
|
488
|
+
let bounceType: string | null = null;
|
|
489
|
+
let bounceReason: string | null = null;
|
|
490
|
+
|
|
491
|
+
const r = rand();
|
|
492
|
+
if (r < 0.03) {
|
|
493
|
+
status = "queued";
|
|
494
|
+
} else if (r < 0.05) {
|
|
495
|
+
status = "failed";
|
|
496
|
+
sentAt = at(createdMs + int(1, 8) * MIN);
|
|
497
|
+
} else if (r < 0.075) {
|
|
498
|
+
status = "bounced";
|
|
499
|
+
sentAt = at(createdMs + int(1, 5) * MIN);
|
|
500
|
+
bouncedAt = at(sentAt.getTime() + int(1, 30) * MIN);
|
|
501
|
+
bounceType = pick(["hard", "soft", "transient"]);
|
|
502
|
+
bounceReason = pick([
|
|
503
|
+
"Mailbox does not exist",
|
|
504
|
+
"Mailbox full",
|
|
505
|
+
"Message rejected by recipient server",
|
|
506
|
+
]);
|
|
507
|
+
} else if (r < 0.085) {
|
|
508
|
+
status = "complained";
|
|
509
|
+
sentAt = at(createdMs + int(1, 5) * MIN);
|
|
510
|
+
deliveredAt = at(sentAt.getTime() + int(1, 20) * MIN);
|
|
511
|
+
complainedAt = at(deliveredAt.getTime() + int(2, 40) * HOUR);
|
|
512
|
+
} else {
|
|
513
|
+
// delivered, then maybe opened, then maybe clicked
|
|
514
|
+
sentAt = at(createdMs + int(1, 6) * MIN);
|
|
515
|
+
deliveredAt = at(sentAt.getTime() + int(1, 15) * MIN);
|
|
516
|
+
status = "delivered";
|
|
517
|
+
if (chance(0.52)) {
|
|
518
|
+
openedAt = at(deliveredAt.getTime() + int(5, 60 * 36) * MIN);
|
|
519
|
+
status = "opened";
|
|
520
|
+
if (chance(0.3)) {
|
|
521
|
+
clickedAt = at(openedAt.getTime() + int(1, 360) * MIN);
|
|
522
|
+
status = "clicked";
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
journeyStateId: opts.journeyStateId,
|
|
529
|
+
userId: opts.user.id,
|
|
530
|
+
userEmail: opts.user.email,
|
|
531
|
+
templateKey: t.key,
|
|
532
|
+
fromEmail: "noreply@hogsend.com",
|
|
533
|
+
toEmail: opts.user.email,
|
|
534
|
+
subject: t.subject,
|
|
535
|
+
category: t.category,
|
|
536
|
+
status,
|
|
537
|
+
sentAt,
|
|
538
|
+
deliveredAt,
|
|
539
|
+
openedAt,
|
|
540
|
+
clickedAt,
|
|
541
|
+
bouncedAt,
|
|
542
|
+
complainedAt,
|
|
543
|
+
bounceType,
|
|
544
|
+
bounceReason,
|
|
545
|
+
resendId:
|
|
546
|
+
status === "queued"
|
|
547
|
+
? null
|
|
548
|
+
: `re_${Math.floor(rand() * 1e16).toString(36)}`,
|
|
549
|
+
createdAt: at(createdMs),
|
|
550
|
+
updatedAt: at(
|
|
551
|
+
clickedAt?.getTime() ??
|
|
552
|
+
openedAt?.getTime() ??
|
|
553
|
+
deliveredAt?.getTime() ??
|
|
554
|
+
bouncedAt?.getTime() ??
|
|
555
|
+
complainedAt?.getTime() ??
|
|
556
|
+
sentAt?.getTime() ??
|
|
557
|
+
createdMs,
|
|
558
|
+
),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// --- email_preferences (drives the Suppressions view) ---
|
|
563
|
+
type EP = typeof schema.emailPreferences.$inferInsert;
|
|
564
|
+
const prefRows: EP[] = users.map((u, i) => {
|
|
565
|
+
// ~8% unsubscribed, ~6% bounced-suppressed, ~3% complained-suppressed
|
|
566
|
+
const roll = rand();
|
|
567
|
+
if (i < 5) {
|
|
568
|
+
// unsubscribed
|
|
569
|
+
return {
|
|
570
|
+
userId: u.id,
|
|
571
|
+
email: u.email,
|
|
572
|
+
unsubscribedAll: true,
|
|
573
|
+
suppressed: true,
|
|
574
|
+
bounceCount: 0,
|
|
575
|
+
categories: {},
|
|
576
|
+
suppressedAt: at(daysAgo(int(1, 20))),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
if (i >= 5 && i < 9) {
|
|
580
|
+
// bounced
|
|
581
|
+
const lb = at(daysAgo(int(0, 14)));
|
|
582
|
+
return {
|
|
583
|
+
userId: u.id,
|
|
584
|
+
email: u.email,
|
|
585
|
+
unsubscribedAll: false,
|
|
586
|
+
suppressed: true,
|
|
587
|
+
bounceCount: int(1, 4),
|
|
588
|
+
categories: {},
|
|
589
|
+
suppressedAt: lb,
|
|
590
|
+
lastBounceAt: lb,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
if (i >= 9 && i < 11) {
|
|
594
|
+
// complained
|
|
595
|
+
return {
|
|
596
|
+
userId: u.id,
|
|
597
|
+
email: u.email,
|
|
598
|
+
unsubscribedAll: false,
|
|
599
|
+
suppressed: true,
|
|
600
|
+
bounceCount: 0,
|
|
601
|
+
categories: {},
|
|
602
|
+
suppressedAt: at(daysAgo(int(0, 10))),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
const categories: Record<string, boolean> =
|
|
606
|
+
roll < 0.1 ? { marketing: false } : {};
|
|
607
|
+
return {
|
|
608
|
+
userId: u.id,
|
|
609
|
+
email: u.email,
|
|
610
|
+
unsubscribedAll: roll < 0.04,
|
|
611
|
+
suppressed: false,
|
|
612
|
+
bounceCount: 0,
|
|
613
|
+
categories,
|
|
614
|
+
};
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// --- user_events (event volume + contact timelines) ---
|
|
618
|
+
const EVENT_NAMES = [
|
|
619
|
+
"user.created",
|
|
620
|
+
"feature.used",
|
|
621
|
+
"checkout.started",
|
|
622
|
+
"checkout.completed",
|
|
623
|
+
"plan.upgraded",
|
|
624
|
+
"login",
|
|
625
|
+
"project.connected",
|
|
626
|
+
"invite.sent",
|
|
627
|
+
] as const;
|
|
628
|
+
const eventRows: EV[] = [];
|
|
629
|
+
for (const u of users) {
|
|
630
|
+
const nEvents = int(1, 6);
|
|
631
|
+
for (let i = 0; i < nEvents; i++) {
|
|
632
|
+
eventRows.push({
|
|
633
|
+
userId: u.id,
|
|
634
|
+
event: i === 0 ? "user.created" : pick(EVENT_NAMES),
|
|
635
|
+
properties: { plan: u.plan, source: "demo" },
|
|
636
|
+
occurredAt: at(daysAgo(int(0, 29))),
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function chunkInsert<T>(
|
|
642
|
+
rows: T[],
|
|
643
|
+
insert: (batch: T[]) => Promise<unknown>,
|
|
644
|
+
size = 100,
|
|
645
|
+
) {
|
|
646
|
+
for (let i = 0; i < rows.length; i += size) {
|
|
647
|
+
await insert(rows.slice(i, i + size));
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function main() {
|
|
652
|
+
console.log(
|
|
653
|
+
`Demo seed — ${users.length} contacts, ${journeyStateRows.length} journey states.`,
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
// 1) Clean any prior demo rows (FK-safe order).
|
|
657
|
+
await db
|
|
658
|
+
.delete(schema.emailSends)
|
|
659
|
+
.where(like(schema.emailSends.userId, "demo_%"));
|
|
660
|
+
await db
|
|
661
|
+
.delete(schema.journeyStates)
|
|
662
|
+
.where(like(schema.journeyStates.userId, "demo_%"));
|
|
663
|
+
await db
|
|
664
|
+
.delete(schema.emailPreferences)
|
|
665
|
+
.where(like(schema.emailPreferences.userId, "demo_%"));
|
|
666
|
+
await db
|
|
667
|
+
.delete(schema.userEvents)
|
|
668
|
+
.where(like(schema.userEvents.userId, "demo_%"));
|
|
669
|
+
await db
|
|
670
|
+
.delete(schema.contacts)
|
|
671
|
+
.where(like(schema.contacts.externalId, "demo_%"));
|
|
672
|
+
|
|
673
|
+
// 2) Contacts
|
|
674
|
+
await chunkInsert(users, (batch) =>
|
|
675
|
+
db.insert(schema.contacts).values(
|
|
676
|
+
batch.map((u) => ({
|
|
677
|
+
externalId: u.id,
|
|
678
|
+
email: u.email,
|
|
679
|
+
timezone: u.timezone,
|
|
680
|
+
properties: { name: u.name, plan: u.plan },
|
|
681
|
+
firstSeenAt: at(daysAgo(int(15, 60))),
|
|
682
|
+
lastSeenAt: at(daysAgo(int(0, 10))),
|
|
683
|
+
})),
|
|
684
|
+
),
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
// 3) Email preferences
|
|
688
|
+
await chunkInsert(prefRows, (batch) =>
|
|
689
|
+
db.insert(schema.emailPreferences).values(batch),
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
// 4) Journey states (capture ids)
|
|
693
|
+
const insertedStates: { id: string }[] = [];
|
|
694
|
+
for (let i = 0; i < journeyStateRows.length; i += 100) {
|
|
695
|
+
const batch = journeyStateRows.slice(i, i + 100);
|
|
696
|
+
const ret = await db
|
|
697
|
+
.insert(schema.journeyStates)
|
|
698
|
+
.values(batch)
|
|
699
|
+
.returning({ id: schema.journeyStates.id });
|
|
700
|
+
insertedStates.push(...ret);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// 5) Email sends — tie ~85% to a journey state, plus standalone transactional.
|
|
704
|
+
const sendRows: ES[] = [];
|
|
705
|
+
insertedStates.forEach((st, idx) => {
|
|
706
|
+
const meta = stateMeta[idx];
|
|
707
|
+
if (!meta) return;
|
|
708
|
+
// queued/failed states rarely have a send; others usually send 1-2.
|
|
709
|
+
const nSends =
|
|
710
|
+
meta.status === "completed"
|
|
711
|
+
? int(1, Math.min(2, meta.templates.length))
|
|
712
|
+
: meta.status === "exited"
|
|
713
|
+
? int(0, 1)
|
|
714
|
+
: meta.status === "failed"
|
|
715
|
+
? chance(0.6)
|
|
716
|
+
? 1
|
|
717
|
+
: 0
|
|
718
|
+
: int(0, 1);
|
|
719
|
+
for (let k = 0; k < nSends; k++) {
|
|
720
|
+
sendRows.push(
|
|
721
|
+
buildSend({
|
|
722
|
+
templateKey: meta.templates[k % meta.templates.length] as string,
|
|
723
|
+
user: meta.user,
|
|
724
|
+
journeyStateId: st.id,
|
|
725
|
+
baseMs: meta.createdMs + k * int(1, 3) * DAY,
|
|
726
|
+
}),
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
// Standalone transactional sends (no journey linkage)
|
|
731
|
+
for (let i = 0; i < 46; i++) {
|
|
732
|
+
const u = pick(users);
|
|
733
|
+
sendRows.push(
|
|
734
|
+
buildSend({
|
|
735
|
+
templateKey: pick([
|
|
736
|
+
"welcome",
|
|
737
|
+
"password-reset",
|
|
738
|
+
"churn-payment-failed",
|
|
739
|
+
]),
|
|
740
|
+
user: u,
|
|
741
|
+
journeyStateId: null,
|
|
742
|
+
baseMs: daysAgo(int(0, 29)),
|
|
743
|
+
}),
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
await chunkInsert(sendRows, (batch) =>
|
|
747
|
+
db.insert(schema.emailSends).values(batch),
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
// 6) User events
|
|
751
|
+
await chunkInsert(eventRows, (batch) =>
|
|
752
|
+
db.insert(schema.userEvents).values(batch),
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
console.log(
|
|
756
|
+
`Inserted: ${users.length} contacts, ${insertedStates.length} journey states, ${sendRows.length} email sends, ${prefRows.length} prefs, ${eventRows.length} events.`,
|
|
757
|
+
);
|
|
758
|
+
console.log("Demo seed complete.");
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
await main();
|
|
762
|
+
await client.end();
|
|
763
|
+
process.exit(0);
|