@hogsend/engine 0.0.1
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/LICENSE +93 -0
- package/README.md +18 -0
- package/package.json +58 -0
- package/src/app.ts +102 -0
- package/src/container.ts +172 -0
- package/src/env.ts +56 -0
- package/src/index.ts +114 -0
- package/src/journeys/define-journey.ts +188 -0
- package/src/journeys/journey-context.ts +179 -0
- package/src/journeys/registry-singleton.ts +21 -0
- package/src/journeys/registry.ts +53 -0
- package/src/lib/alerting.ts +205 -0
- package/src/lib/api-key-hash.ts +19 -0
- package/src/lib/auth.ts +39 -0
- package/src/lib/backfill.ts +84 -0
- package/src/lib/contacts.ts +68 -0
- package/src/lib/db.ts +13 -0
- package/src/lib/email-service-types.ts +115 -0
- package/src/lib/email-stats.ts +33 -0
- package/src/lib/email.ts +94 -0
- package/src/lib/enrollment-guards.ts +56 -0
- package/src/lib/hatchet.ts +20 -0
- package/src/lib/html.ts +25 -0
- package/src/lib/ingestion.ts +162 -0
- package/src/lib/logger.ts +32 -0
- package/src/lib/mailer.ts +266 -0
- package/src/lib/notifications.ts +61 -0
- package/src/lib/posthog.ts +19 -0
- package/src/lib/redis.ts +30 -0
- package/src/lib/schemas.ts +8 -0
- package/src/lib/tracked.ts +175 -0
- package/src/lib/tracking-event-names.ts +5 -0
- package/src/lib/tracking-events.ts +84 -0
- package/src/lib/tracking.ts +78 -0
- package/src/middleware/api-key.ts +129 -0
- package/src/middleware/audit.ts +47 -0
- package/src/middleware/auth.ts +24 -0
- package/src/middleware/error-handler.ts +22 -0
- package/src/middleware/rate-limit.ts +65 -0
- package/src/middleware/request-logger.ts +19 -0
- package/src/routes/admin/alerts.ts +347 -0
- package/src/routes/admin/api-keys.ts +211 -0
- package/src/routes/admin/audit-logs.ts +102 -0
- package/src/routes/admin/bulk.ts +503 -0
- package/src/routes/admin/contacts.ts +342 -0
- package/src/routes/admin/dlq.ts +202 -0
- package/src/routes/admin/emails.ts +269 -0
- package/src/routes/admin/events.ts +132 -0
- package/src/routes/admin/index.ts +36 -0
- package/src/routes/admin/journey-logs.ts +117 -0
- package/src/routes/admin/journeys.ts +677 -0
- package/src/routes/admin/metrics.ts +559 -0
- package/src/routes/admin/preferences.ts +165 -0
- package/src/routes/admin/timeline.ts +221 -0
- package/src/routes/email/index.ts +8 -0
- package/src/routes/email/preferences.ts +144 -0
- package/src/routes/email/unsubscribe.ts +161 -0
- package/src/routes/health.ts +131 -0
- package/src/routes/index.ts +32 -0
- package/src/routes/ingest.ts +71 -0
- package/src/routes/tracking/click.ts +103 -0
- package/src/routes/tracking/index.ts +9 -0
- package/src/routes/tracking/open.ts +71 -0
- package/src/routes/webhooks/index.ts +17 -0
- package/src/routes/webhooks/resend.ts +68 -0
- package/src/routes/webhooks/sources.ts +97 -0
- package/src/webhook-sources/define-webhook-source.ts +34 -0
- package/src/worker.ts +64 -0
- package/src/workflows/check-alerts.ts +24 -0
- package/src/workflows/import-contacts.ts +134 -0
- package/src/workflows/send-email.ts +54 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { JsonValue } from "@hatchet-dev/typescript-sdk/v1/types.js";
|
|
2
|
+
import { evaluatePropertyConditions } from "@hogsend/core";
|
|
3
|
+
import type {
|
|
4
|
+
JourneyMeta,
|
|
5
|
+
JourneyRunFn,
|
|
6
|
+
JourneyUser,
|
|
7
|
+
} from "@hogsend/core/types";
|
|
8
|
+
import { journeyConfigs, journeyStates } from "@hogsend/db";
|
|
9
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
10
|
+
import { getDb } from "../lib/db.js";
|
|
11
|
+
import {
|
|
12
|
+
checkEmailPreferences,
|
|
13
|
+
checkEntryLimit,
|
|
14
|
+
} from "../lib/enrollment-guards.js";
|
|
15
|
+
import { hatchet } from "../lib/hatchet.js";
|
|
16
|
+
import { createLogger } from "../lib/logger.js";
|
|
17
|
+
import { getPostHog } from "../lib/posthog.js";
|
|
18
|
+
import { createJourneyContext } from "./journey-context.js";
|
|
19
|
+
import { getJourneyRegistrySingleton } from "./registry-singleton.js";
|
|
20
|
+
|
|
21
|
+
const logger = createLogger(process.env.LOG_LEVEL);
|
|
22
|
+
|
|
23
|
+
interface EventPayloadInput {
|
|
24
|
+
userId: JsonValue;
|
|
25
|
+
userEmail: JsonValue;
|
|
26
|
+
properties: JsonValue;
|
|
27
|
+
[key: string]: JsonValue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DefinedJourney {
|
|
31
|
+
meta: JourneyMeta;
|
|
32
|
+
task: ReturnType<typeof hatchet.durableTask>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function defineJourney(options: {
|
|
36
|
+
meta: JourneyMeta;
|
|
37
|
+
run: JourneyRunFn;
|
|
38
|
+
}): DefinedJourney {
|
|
39
|
+
const { meta } = options;
|
|
40
|
+
|
|
41
|
+
const task = hatchet.durableTask({
|
|
42
|
+
name: `journey-${meta.id}`,
|
|
43
|
+
onEvents: [meta.trigger.event],
|
|
44
|
+
executionTimeout: "720h",
|
|
45
|
+
retries: 0,
|
|
46
|
+
fn: async (input: EventPayloadInput, hatchetCtx) => {
|
|
47
|
+
const db = getDb();
|
|
48
|
+
const userId = input.userId as string;
|
|
49
|
+
const userEmail = input.userEmail as string;
|
|
50
|
+
const properties = (input.properties ?? {}) as Record<
|
|
51
|
+
string,
|
|
52
|
+
string | number | boolean | null
|
|
53
|
+
>;
|
|
54
|
+
|
|
55
|
+
if (!meta.enabled) {
|
|
56
|
+
return { status: "skipped", reason: "journey_disabled" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const configOverride = await db.query.journeyConfigs.findFirst({
|
|
60
|
+
where: eq(journeyConfigs.journeyId, meta.id),
|
|
61
|
+
});
|
|
62
|
+
if (configOverride && !configOverride.enabled) {
|
|
63
|
+
return { status: "skipped", reason: "journey_disabled_by_admin" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (meta.trigger.where?.length) {
|
|
67
|
+
if (
|
|
68
|
+
!evaluatePropertyConditions({
|
|
69
|
+
conditions: meta.trigger.where,
|
|
70
|
+
properties,
|
|
71
|
+
})
|
|
72
|
+
) {
|
|
73
|
+
return { status: "skipped", reason: "trigger_conditions_not_met" };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const entryAllowed = await checkEntryLimit({
|
|
78
|
+
db,
|
|
79
|
+
journey: meta,
|
|
80
|
+
userId,
|
|
81
|
+
});
|
|
82
|
+
if (!entryAllowed.allowed) {
|
|
83
|
+
return { status: "skipped", reason: entryAllowed.reason };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const prefs = await checkEmailPreferences({ db, userId });
|
|
87
|
+
if (prefs.unsubscribed) {
|
|
88
|
+
return { status: "skipped", reason: "user_unsubscribed" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const activeState = await db.query.journeyStates.findFirst({
|
|
92
|
+
where: and(
|
|
93
|
+
eq(journeyStates.userId, userId),
|
|
94
|
+
eq(journeyStates.journeyId, meta.id),
|
|
95
|
+
inArray(journeyStates.status, ["active", "waiting"]),
|
|
96
|
+
),
|
|
97
|
+
});
|
|
98
|
+
if (activeState) {
|
|
99
|
+
return { status: "skipped", reason: "already_active" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const [state] = await db
|
|
103
|
+
.insert(journeyStates)
|
|
104
|
+
.values({
|
|
105
|
+
userId,
|
|
106
|
+
userEmail,
|
|
107
|
+
journeyId: meta.id,
|
|
108
|
+
currentNodeId: "start",
|
|
109
|
+
status: "active",
|
|
110
|
+
context: properties,
|
|
111
|
+
hatchetRunId: hatchetCtx.workflowRunId(),
|
|
112
|
+
})
|
|
113
|
+
.returning();
|
|
114
|
+
|
|
115
|
+
if (!state) {
|
|
116
|
+
return { status: "skipped", reason: "state_creation_failed" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const stateId = state.id;
|
|
120
|
+
|
|
121
|
+
const user: JourneyUser = {
|
|
122
|
+
id: userId,
|
|
123
|
+
email: userEmail,
|
|
124
|
+
properties,
|
|
125
|
+
stateId,
|
|
126
|
+
journeyId: meta.id,
|
|
127
|
+
journeyName: meta.name,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const ctx = createJourneyContext({
|
|
131
|
+
db,
|
|
132
|
+
hatchet,
|
|
133
|
+
hatchetCtx,
|
|
134
|
+
registry: getJourneyRegistrySingleton(),
|
|
135
|
+
logger,
|
|
136
|
+
posthog: getPostHog(),
|
|
137
|
+
stateId,
|
|
138
|
+
userId,
|
|
139
|
+
userEmail,
|
|
140
|
+
journeyContext: { ...properties },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await options.run(user, ctx);
|
|
145
|
+
|
|
146
|
+
await db
|
|
147
|
+
.update(journeyStates)
|
|
148
|
+
.set({
|
|
149
|
+
status: "completed",
|
|
150
|
+
completedAt: new Date(),
|
|
151
|
+
updatedAt: new Date(),
|
|
152
|
+
})
|
|
153
|
+
.where(eq(journeyStates.id, stateId));
|
|
154
|
+
|
|
155
|
+
await hatchet.events.push("journey:completed", {
|
|
156
|
+
journeyId: meta.id,
|
|
157
|
+
stateId,
|
|
158
|
+
userId,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return { stateId, status: "completed" };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const message =
|
|
164
|
+
err instanceof Error ? err.message : "Unknown error during journey";
|
|
165
|
+
|
|
166
|
+
await db
|
|
167
|
+
.update(journeyStates)
|
|
168
|
+
.set({
|
|
169
|
+
status: "failed",
|
|
170
|
+
errorMessage: message,
|
|
171
|
+
updatedAt: new Date(),
|
|
172
|
+
})
|
|
173
|
+
.where(eq(journeyStates.id, stateId));
|
|
174
|
+
|
|
175
|
+
await hatchet.events.push("journey:failed", {
|
|
176
|
+
journeyId: meta.id,
|
|
177
|
+
stateId,
|
|
178
|
+
userId,
|
|
179
|
+
error: message,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return { meta, task };
|
|
188
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
+
import type { DurationObject } from "@hogsend/core";
|
|
3
|
+
import { evaluateEventCondition } from "@hogsend/core";
|
|
4
|
+
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
5
|
+
import type { JourneyContext } from "@hogsend/core/types";
|
|
6
|
+
import { type Database, emailSends, journeyStates } from "@hogsend/db";
|
|
7
|
+
import type { PostHogService } from "@hogsend/plugin-posthog";
|
|
8
|
+
import { and, count, eq, max } from "drizzle-orm";
|
|
9
|
+
import { checkEmailPreferences } from "../lib/enrollment-guards.js";
|
|
10
|
+
import { ingestEvent } from "../lib/ingestion.js";
|
|
11
|
+
import type { Logger } from "../lib/logger.js";
|
|
12
|
+
|
|
13
|
+
interface JourneyContextConfig {
|
|
14
|
+
db: Database;
|
|
15
|
+
hatchet: HatchetClient;
|
|
16
|
+
hatchetCtx: { sleepFor: (duration: DurationObject) => Promise<unknown> };
|
|
17
|
+
registry: JourneyRegistry;
|
|
18
|
+
logger: Logger;
|
|
19
|
+
posthog?: PostHogService;
|
|
20
|
+
stateId: string;
|
|
21
|
+
userId: string;
|
|
22
|
+
userEmail: string;
|
|
23
|
+
journeyContext: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createJourneyContext(
|
|
27
|
+
config: JourneyContextConfig,
|
|
28
|
+
): JourneyContext {
|
|
29
|
+
const {
|
|
30
|
+
db,
|
|
31
|
+
hatchet,
|
|
32
|
+
hatchetCtx,
|
|
33
|
+
registry,
|
|
34
|
+
logger,
|
|
35
|
+
posthog,
|
|
36
|
+
stateId,
|
|
37
|
+
userId,
|
|
38
|
+
userEmail,
|
|
39
|
+
journeyContext,
|
|
40
|
+
} = config;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
async sleep({ duration, label }) {
|
|
44
|
+
const sleptAt = new Date().toISOString();
|
|
45
|
+
|
|
46
|
+
await db
|
|
47
|
+
.update(journeyStates)
|
|
48
|
+
.set({
|
|
49
|
+
status: "waiting",
|
|
50
|
+
currentNodeId: label ?? `wait:${JSON.stringify(duration)}`,
|
|
51
|
+
updatedAt: new Date(),
|
|
52
|
+
})
|
|
53
|
+
.where(eq(journeyStates.id, stateId));
|
|
54
|
+
|
|
55
|
+
await hatchetCtx.sleepFor(duration);
|
|
56
|
+
|
|
57
|
+
const resumedAt = new Date().toISOString();
|
|
58
|
+
|
|
59
|
+
await db
|
|
60
|
+
.update(journeyStates)
|
|
61
|
+
.set({ status: "active", updatedAt: new Date() })
|
|
62
|
+
.where(eq(journeyStates.id, stateId));
|
|
63
|
+
|
|
64
|
+
return { sleptAt, resumedAt };
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async checkpoint(label) {
|
|
68
|
+
await db
|
|
69
|
+
.update(journeyStates)
|
|
70
|
+
.set({ currentNodeId: label, updatedAt: new Date() })
|
|
71
|
+
.where(eq(journeyStates.id, stateId));
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async trigger({
|
|
75
|
+
event,
|
|
76
|
+
userId: targetUserId,
|
|
77
|
+
userEmail: targetEmail,
|
|
78
|
+
properties,
|
|
79
|
+
}) {
|
|
80
|
+
await ingestEvent({
|
|
81
|
+
db,
|
|
82
|
+
registry,
|
|
83
|
+
hatchet,
|
|
84
|
+
logger,
|
|
85
|
+
event: {
|
|
86
|
+
event,
|
|
87
|
+
userId: targetUserId,
|
|
88
|
+
userEmail: targetEmail ?? userEmail,
|
|
89
|
+
properties: properties ?? {},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
identify(properties) {
|
|
95
|
+
posthog?.identify(userId, properties);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
guard: {
|
|
99
|
+
async isSubscribed() {
|
|
100
|
+
const prefs = await checkEmailPreferences({ db, userId });
|
|
101
|
+
return !prefs.unsubscribed;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
history: {
|
|
106
|
+
async hasEvent({ userId: targetUserId, event, within }) {
|
|
107
|
+
const result = await evaluateEventCondition({
|
|
108
|
+
condition: {
|
|
109
|
+
type: "event",
|
|
110
|
+
eventName: event,
|
|
111
|
+
check: "exists",
|
|
112
|
+
within,
|
|
113
|
+
},
|
|
114
|
+
ctx: { db, userId: targetUserId, journeyContext },
|
|
115
|
+
});
|
|
116
|
+
return { found: result.matched, count: result.count };
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async journey({ userId: targetUserId, journeyId: targetJourneyId }) {
|
|
120
|
+
const [result] = await db
|
|
121
|
+
.select({
|
|
122
|
+
entryCount: count(),
|
|
123
|
+
lastCompletedAt: max(journeyStates.completedAt),
|
|
124
|
+
})
|
|
125
|
+
.from(journeyStates)
|
|
126
|
+
.where(
|
|
127
|
+
and(
|
|
128
|
+
eq(journeyStates.userId, targetUserId),
|
|
129
|
+
eq(journeyStates.journeyId, targetJourneyId),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
completed: result?.lastCompletedAt !== null,
|
|
135
|
+
lastCompletedAt:
|
|
136
|
+
result?.lastCompletedAt instanceof Date
|
|
137
|
+
? result.lastCompletedAt.toISOString()
|
|
138
|
+
: null,
|
|
139
|
+
entryCount: result?.entryCount ?? 0,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async email({ email: targetEmail, template }) {
|
|
144
|
+
const [result] = await db
|
|
145
|
+
.select({
|
|
146
|
+
count: count(),
|
|
147
|
+
lastSentAt: max(emailSends.sentAt),
|
|
148
|
+
})
|
|
149
|
+
.from(emailSends)
|
|
150
|
+
.where(
|
|
151
|
+
and(
|
|
152
|
+
eq(emailSends.toEmail, targetEmail),
|
|
153
|
+
eq(emailSends.templateKey, template),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const total = result?.count ?? 0;
|
|
158
|
+
return {
|
|
159
|
+
sent: total > 0,
|
|
160
|
+
lastSentAt:
|
|
161
|
+
result?.lastSentAt instanceof Date
|
|
162
|
+
? result.lastSentAt.toISOString()
|
|
163
|
+
: null,
|
|
164
|
+
count: total,
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
posthog: {
|
|
170
|
+
capture({ event, properties }) {
|
|
171
|
+
posthog?.captureEvent({
|
|
172
|
+
distinctId: userId,
|
|
173
|
+
event,
|
|
174
|
+
properties,
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
2
|
+
|
|
3
|
+
let _registry: JourneyRegistry | undefined;
|
|
4
|
+
|
|
5
|
+
export function setJourneyRegistry(registry: JourneyRegistry): void {
|
|
6
|
+
_registry = registry;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getJourneyRegistrySingleton(): JourneyRegistry {
|
|
10
|
+
if (!_registry) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
"Journey registry not initialized. Call setJourneyRegistry() at startup.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return _registry;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Reset the singleton — only for test cleanup. */
|
|
19
|
+
export function resetJourneyRegistry(): void {
|
|
20
|
+
_registry = undefined;
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { JourneyRegistry } from "@hogsend/core/registry";
|
|
2
|
+
import type { DefinedJourney } from "./define-journey.js";
|
|
3
|
+
import { setJourneyRegistry } from "./registry-singleton.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse the `ENABLED_JOURNEYS` filter. Returns `"*"` to enable all journeys, or
|
|
7
|
+
* a `Set` of journey ids to enable. An empty/whitespace/`*` value means all.
|
|
8
|
+
*/
|
|
9
|
+
export function parseEnabledFilter(filter?: string): "*" | Set<string> {
|
|
10
|
+
if (!filter || filter.trim() === "*") return "*";
|
|
11
|
+
return new Set(
|
|
12
|
+
filter
|
|
13
|
+
.split(",")
|
|
14
|
+
.map((s) => s.trim())
|
|
15
|
+
.filter(Boolean),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a {@link JourneyRegistry} from an injected array of journeys, applying
|
|
21
|
+
* the enabled filter, and install it as the process singleton (so durable tasks
|
|
22
|
+
* can resolve it). Returns the registry.
|
|
23
|
+
*/
|
|
24
|
+
export function buildJourneyRegistry(
|
|
25
|
+
journeys: DefinedJourney[],
|
|
26
|
+
enabledFilter?: string,
|
|
27
|
+
): JourneyRegistry {
|
|
28
|
+
const registry = new JourneyRegistry();
|
|
29
|
+
const enabled = parseEnabledFilter(enabledFilter);
|
|
30
|
+
|
|
31
|
+
for (const journey of journeys) {
|
|
32
|
+
if (enabled === "*" || enabled.has(journey.meta.id)) {
|
|
33
|
+
registry.register(journey.meta);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setJourneyRegistry(registry);
|
|
38
|
+
return registry;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Select the Hatchet durable tasks for the enabled journeys from an injected
|
|
43
|
+
* array of journeys.
|
|
44
|
+
*/
|
|
45
|
+
export function selectJourneyTasks(
|
|
46
|
+
journeys: DefinedJourney[],
|
|
47
|
+
enabledFilter?: string,
|
|
48
|
+
) {
|
|
49
|
+
const enabled = parseEnabledFilter(enabledFilter);
|
|
50
|
+
return journeys
|
|
51
|
+
.filter((j) => enabled === "*" || enabled.has(j.meta.id))
|
|
52
|
+
.map((j) => j.task);
|
|
53
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
alertHistory,
|
|
3
|
+
alertRules,
|
|
4
|
+
type Database,
|
|
5
|
+
journeyStates,
|
|
6
|
+
} from "@hogsend/db";
|
|
7
|
+
import { and, count, eq, gte } from "drizzle-orm";
|
|
8
|
+
import { getEmailStats } from "./email-stats.js";
|
|
9
|
+
import type { Logger } from "./logger.js";
|
|
10
|
+
import {
|
|
11
|
+
sendEmailNotification,
|
|
12
|
+
sendSlackNotification,
|
|
13
|
+
sendWebhook,
|
|
14
|
+
} from "./notifications.js";
|
|
15
|
+
|
|
16
|
+
async function dispatchAlert(opts: {
|
|
17
|
+
rule: typeof alertRules.$inferSelect;
|
|
18
|
+
message: string;
|
|
19
|
+
payload: Record<string, unknown>;
|
|
20
|
+
resendApiKey?: string;
|
|
21
|
+
}): Promise<{ deliveryStatus: string; error?: string }> {
|
|
22
|
+
const config = opts.rule.channelConfig as Record<string, string>;
|
|
23
|
+
switch (opts.rule.channel) {
|
|
24
|
+
case "webhook": {
|
|
25
|
+
const result = await sendWebhook(config.url ?? "", {
|
|
26
|
+
rule: opts.rule.name,
|
|
27
|
+
type: opts.rule.type,
|
|
28
|
+
...opts.payload,
|
|
29
|
+
});
|
|
30
|
+
return result.ok
|
|
31
|
+
? { deliveryStatus: "sent" }
|
|
32
|
+
: { deliveryStatus: "failed", error: result.error };
|
|
33
|
+
}
|
|
34
|
+
case "slack": {
|
|
35
|
+
const result = await sendSlackNotification(
|
|
36
|
+
config.webhookUrl ?? "",
|
|
37
|
+
opts.message,
|
|
38
|
+
);
|
|
39
|
+
return result.ok
|
|
40
|
+
? { deliveryStatus: "sent" }
|
|
41
|
+
: { deliveryStatus: "failed", error: result.error };
|
|
42
|
+
}
|
|
43
|
+
case "email": {
|
|
44
|
+
const result = await sendEmailNotification({
|
|
45
|
+
to: config.to ?? "",
|
|
46
|
+
subject: `[Hogsend Alert] ${opts.rule.name}`,
|
|
47
|
+
body: `<p>${opts.message}</p><pre>${JSON.stringify(opts.payload, null, 2)}</pre>`,
|
|
48
|
+
resendApiKey: opts.resendApiKey ?? "",
|
|
49
|
+
});
|
|
50
|
+
return result.ok
|
|
51
|
+
? { deliveryStatus: "sent" }
|
|
52
|
+
: { deliveryStatus: "failed", error: result.error };
|
|
53
|
+
}
|
|
54
|
+
default:
|
|
55
|
+
return {
|
|
56
|
+
deliveryStatus: "failed",
|
|
57
|
+
error: `Unknown channel: ${opts.rule.channel}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function checkAlertRules(opts: {
|
|
63
|
+
db: Database;
|
|
64
|
+
logger: Logger;
|
|
65
|
+
resendApiKey?: string;
|
|
66
|
+
}): Promise<void> {
|
|
67
|
+
const { db, logger } = opts;
|
|
68
|
+
|
|
69
|
+
const rules = await db
|
|
70
|
+
.select()
|
|
71
|
+
.from(alertRules)
|
|
72
|
+
.where(eq(alertRules.enabled, true));
|
|
73
|
+
|
|
74
|
+
for (const rule of rules) {
|
|
75
|
+
try {
|
|
76
|
+
if (rule.lastFiredAt) {
|
|
77
|
+
const cooldownMs = rule.cooldownMinutes * 60 * 1000;
|
|
78
|
+
if (Date.now() - rule.lastFiredAt.getTime() < cooldownMs) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const threshold = rule.threshold as Record<string, number>;
|
|
84
|
+
let triggered = false;
|
|
85
|
+
let payload: Record<string, unknown> = {};
|
|
86
|
+
|
|
87
|
+
switch (rule.type) {
|
|
88
|
+
case "bounce_rate_exceeded": {
|
|
89
|
+
const windowMinutes = threshold.windowMinutes ?? 60;
|
|
90
|
+
const since = new Date(Date.now() - windowMinutes * 60 * 1000);
|
|
91
|
+
const stats = await getEmailStats({ db, since });
|
|
92
|
+
const rate = stats.total > 0 ? stats.bounced / stats.total : 0;
|
|
93
|
+
const maxRate = threshold.rate ?? 0.05;
|
|
94
|
+
if (rate > maxRate && stats.total >= 10) {
|
|
95
|
+
triggered = true;
|
|
96
|
+
payload = {
|
|
97
|
+
bounced: stats.bounced,
|
|
98
|
+
total: stats.total,
|
|
99
|
+
rate,
|
|
100
|
+
threshold: maxRate,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case "journey_failure_spike": {
|
|
107
|
+
const windowMinutes = threshold.windowMinutes ?? 60;
|
|
108
|
+
const maxFailures = threshold.count ?? 5;
|
|
109
|
+
const since = new Date(Date.now() - windowMinutes * 60 * 1000);
|
|
110
|
+
const failures = await db
|
|
111
|
+
.select({ count: count() })
|
|
112
|
+
.from(journeyStates)
|
|
113
|
+
.where(
|
|
114
|
+
and(
|
|
115
|
+
eq(journeyStates.status, "failed"),
|
|
116
|
+
gte(journeyStates.createdAt, since),
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
.then((r) => r[0]?.count ?? 0);
|
|
120
|
+
if (failures >= maxFailures) {
|
|
121
|
+
triggered = true;
|
|
122
|
+
payload = {
|
|
123
|
+
failures,
|
|
124
|
+
threshold: maxFailures,
|
|
125
|
+
windowMinutes,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case "delivery_issue": {
|
|
132
|
+
const windowMinutes = threshold.windowMinutes ?? 60;
|
|
133
|
+
const since = new Date(Date.now() - windowMinutes * 60 * 1000);
|
|
134
|
+
const stats = await getEmailStats({ db, since });
|
|
135
|
+
const deliveryRate =
|
|
136
|
+
stats.total > 0 ? stats.delivered / stats.total : 1;
|
|
137
|
+
const minRate = threshold.minDeliveryRate ?? 0.9;
|
|
138
|
+
if (deliveryRate < minRate && stats.total >= 10) {
|
|
139
|
+
triggered = true;
|
|
140
|
+
payload = {
|
|
141
|
+
delivered: stats.delivered,
|
|
142
|
+
total: stats.total,
|
|
143
|
+
deliveryRate,
|
|
144
|
+
threshold: minRate,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case "high_complaint_rate": {
|
|
151
|
+
const windowMinutes = threshold.windowMinutes ?? 60;
|
|
152
|
+
const since = new Date(Date.now() - windowMinutes * 60 * 1000);
|
|
153
|
+
const stats = await getEmailStats({ db, since });
|
|
154
|
+
const rate = stats.total > 0 ? stats.complained / stats.total : 0;
|
|
155
|
+
const maxRate = threshold.rate ?? 0.01;
|
|
156
|
+
if (rate > maxRate && stats.total >= 10) {
|
|
157
|
+
triggered = true;
|
|
158
|
+
payload = {
|
|
159
|
+
complained: stats.complained,
|
|
160
|
+
total: stats.total,
|
|
161
|
+
rate,
|
|
162
|
+
threshold: maxRate,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!triggered) continue;
|
|
170
|
+
|
|
171
|
+
const message = `[Hogsend Alert] ${rule.name}: ${rule.type} triggered — ${JSON.stringify(payload)}`;
|
|
172
|
+
|
|
173
|
+
const { deliveryStatus, error } = await dispatchAlert({
|
|
174
|
+
rule,
|
|
175
|
+
message,
|
|
176
|
+
payload,
|
|
177
|
+
resendApiKey: opts.resendApiKey,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await Promise.all([
|
|
181
|
+
db.insert(alertHistory).values({
|
|
182
|
+
alertRuleId: rule.id,
|
|
183
|
+
payload,
|
|
184
|
+
deliveryStatus,
|
|
185
|
+
error: error ?? null,
|
|
186
|
+
}),
|
|
187
|
+
db
|
|
188
|
+
.update(alertRules)
|
|
189
|
+
.set({ lastFiredAt: new Date(), updatedAt: new Date() })
|
|
190
|
+
.where(eq(alertRules.id, rule.id)),
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
logger.info("Alert fired", {
|
|
194
|
+
rule: rule.name,
|
|
195
|
+
type: rule.type,
|
|
196
|
+
deliveryStatus,
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
logger.error("Alert evaluation failed", {
|
|
200
|
+
ruleId: rule.id,
|
|
201
|
+
error: err instanceof Error ? err.message : String(err),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const PREFIX_LENGTH = 8;
|
|
4
|
+
|
|
5
|
+
export function generateApiKey(): {
|
|
6
|
+
key: string;
|
|
7
|
+
prefix: string;
|
|
8
|
+
hash: string;
|
|
9
|
+
} {
|
|
10
|
+
const raw = randomBytes(32).toString("base64url");
|
|
11
|
+
const key = `hsk_${raw}`;
|
|
12
|
+
const prefix = key.slice(0, PREFIX_LENGTH);
|
|
13
|
+
const hash = hashApiKey(key);
|
|
14
|
+
return { key, prefix, hash };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function hashApiKey(key: string): string {
|
|
18
|
+
return createHash("sha256").update(key).digest("hex");
|
|
19
|
+
}
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Database } from "@hogsend/db";
|
|
2
|
+
import * as schema from "@hogsend/db/schema";
|
|
3
|
+
import { betterAuth } from "better-auth";
|
|
4
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
5
|
+
import { organization } from "better-auth/plugins/organization";
|
|
6
|
+
|
|
7
|
+
export function createAuth(opts: {
|
|
8
|
+
db: Database;
|
|
9
|
+
secret: string;
|
|
10
|
+
baseURL: string;
|
|
11
|
+
}) {
|
|
12
|
+
const { db, secret, baseURL } = opts;
|
|
13
|
+
return betterAuth({
|
|
14
|
+
basePath: "/api/auth",
|
|
15
|
+
secret,
|
|
16
|
+
baseURL,
|
|
17
|
+
database: drizzleAdapter(db, {
|
|
18
|
+
provider: "pg",
|
|
19
|
+
schema,
|
|
20
|
+
}),
|
|
21
|
+
emailAndPassword: {
|
|
22
|
+
enabled: true,
|
|
23
|
+
minPasswordLength: 8,
|
|
24
|
+
maxPasswordLength: 128,
|
|
25
|
+
},
|
|
26
|
+
session: {
|
|
27
|
+
expiresIn: 60 * 60 * 24 * 7,
|
|
28
|
+
updateAge: 60 * 60 * 24,
|
|
29
|
+
},
|
|
30
|
+
plugins: [
|
|
31
|
+
organization({
|
|
32
|
+
organizationLimit: 5,
|
|
33
|
+
membershipLimit: 100,
|
|
34
|
+
}),
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type Auth = ReturnType<typeof createAuth>;
|