@agent-native/core 0.84.21 → 0.84.23

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.
Files changed (137) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/model-config.ts +1 -1
  5. package/corpus/core/src/cli/skills.ts +17 -3
  6. package/corpus/core/src/client/AssistantChat.tsx +27 -4
  7. package/corpus/core/src/client/guided-questions.tsx +11 -6
  8. package/corpus/core/src/client/tool-display.ts +8 -0
  9. package/corpus/core/src/file-upload/builder.ts +7 -2
  10. package/corpus/core/src/notifications/channels.ts +235 -38
  11. package/corpus/core/src/notifications/index.ts +1 -0
  12. package/corpus/core/src/notifications/registry.ts +13 -1
  13. package/corpus/core/src/server/analytics.ts +32 -6
  14. package/corpus/core/src/server/ssr-handler.ts +31 -12
  15. package/corpus/core/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
  16. package/corpus/core/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
  17. package/corpus/templates/analytics/.agents/skills/frontend-design/SKILL.md +2 -0
  18. package/corpus/templates/analytics/AGENTS.md +21 -0
  19. package/corpus/templates/analytics/README.md +35 -0
  20. package/corpus/templates/analytics/actions/delete-analytics-alert-rule.ts +23 -0
  21. package/corpus/templates/analytics/actions/list-analytics-alert-rules.ts +20 -0
  22. package/corpus/templates/analytics/actions/run-analytics-alerts.ts +23 -0
  23. package/corpus/templates/analytics/actions/save-analytics-alert-rule.ts +69 -0
  24. package/corpus/templates/analytics/changelog/2026-07-01-analytics-can-alert-when-first-party-events-spike.md +6 -0
  25. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-play-back-in-production-instead-of-showing.md +6 -0
  26. package/corpus/templates/analytics/netlify.toml +3 -0
  27. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +126 -0
  28. package/corpus/templates/analytics/server/db/schema.ts +55 -0
  29. package/corpus/templates/analytics/server/handlers/session-replay.ts +5 -2
  30. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +74 -0
  31. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +803 -0
  32. package/corpus/templates/analytics/server/lib/session-replay.ts +10 -3
  33. package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +54 -0
  34. package/corpus/templates/analytics/server/plugins/db.ts +100 -0
  35. package/corpus/templates/analytics/server/routes/api/analytics-alerts/run.post.ts +60 -0
  36. package/corpus/templates/assets/.agents/skills/frontend-design/SKILL.md +2 -0
  37. package/corpus/templates/assets/README.md +30 -8
  38. package/corpus/templates/brain/.agents/skills/frontend-design/SKILL.md +2 -0
  39. package/corpus/templates/brain/README.md +20 -552
  40. package/corpus/templates/calendar/.agents/skills/frontend-design/SKILL.md +2 -0
  41. package/corpus/templates/calendar/README.md +22 -28
  42. package/corpus/templates/chat/.agents/skills/frontend-design/SKILL.md +2 -0
  43. package/corpus/templates/chat/README.md +32 -0
  44. package/corpus/templates/clips/.agents/skills/frontend-design/SKILL.md +2 -0
  45. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +30 -1
  46. package/corpus/templates/clips/README.md +37 -0
  47. package/corpus/templates/clips/actions/finalize-recording.ts +84 -4
  48. package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +256 -0
  49. package/corpus/templates/clips/actions/reprocess-recording.ts +160 -0
  50. package/corpus/templates/clips/app/components/library/library-grid.tsx +18 -7
  51. package/corpus/templates/clips/app/components/library/recording-card.tsx +42 -5
  52. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +41 -1
  53. package/corpus/templates/clips/changelog/2026-07-01-move-to-folder-now-opens-folder-choices-directly-instead-of.md +6 -0
  54. package/corpus/templates/clips/changelog/2026-07-01-shared-clips-now-start-playing-and-scrub-instantly-instead-o.md +6 -0
  55. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  56. package/corpus/templates/clips/server/lib/video-remux.ts +222 -0
  57. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +37 -0
  58. package/corpus/templates/content/.agents/skills/frontend-design/SKILL.md +2 -0
  59. package/corpus/templates/content/AGENTS.md +7 -3
  60. package/corpus/templates/content/README.md +22 -34
  61. package/corpus/templates/content/actions/list-trashed-content-databases.ts +4 -0
  62. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +58 -6
  63. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +257 -24
  64. package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +41 -15
  65. package/corpus/templates/content/app/i18n/zh-TW.ts +25 -7
  66. package/corpus/templates/content/app/i18n-data.ts +278 -40
  67. package/corpus/templates/content/changelog/2026-06-30-database-once-again-creates-an-inline-database-in-the-curren.md +6 -0
  68. package/corpus/templates/content/shared/api.ts +1 -0
  69. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +75 -19
  70. package/corpus/templates/design/.agents/skills/frontend-design/SKILL.md +2 -0
  71. package/corpus/templates/design/AGENTS.md +7 -6
  72. package/corpus/templates/design/README.md +33 -0
  73. package/corpus/templates/design/actions/delete-file.ts +86 -10
  74. package/corpus/templates/design/actions/get-design-snapshot.ts +13 -0
  75. package/corpus/templates/design/actions/present-design-variants.ts +11 -13
  76. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  77. package/corpus/templates/design/app/pages/DesignEditor.tsx +13 -3
  78. package/corpus/templates/design/changelog/2026-07-01-design-chat-no-longer-shows-an-empty-state-selection-chip-in-the-composer.md +6 -0
  79. package/corpus/templates/dispatch/.agents/skills/frontend-design/SKILL.md +2 -0
  80. package/corpus/templates/dispatch/README.md +34 -0
  81. package/corpus/templates/forms/.agents/skills/frontend-design/SKILL.md +2 -0
  82. package/corpus/templates/forms/README.md +34 -0
  83. package/corpus/templates/macros/.agents/skills/frontend-design/SKILL.md +2 -0
  84. package/corpus/templates/macros/README.md +28 -0
  85. package/corpus/templates/mail/.agents/skills/frontend-design/SKILL.md +2 -0
  86. package/corpus/templates/mail/README.md +35 -0
  87. package/corpus/templates/plan/.agents/skills/frontend-design/SKILL.md +2 -0
  88. package/corpus/templates/plan/README.md +30 -118
  89. package/corpus/templates/slides/.agents/skills/frontend-design/SKILL.md +2 -0
  90. package/corpus/templates/slides/README.md +34 -0
  91. package/corpus/templates/videos/.agents/skills/frontend-design/SKILL.md +2 -0
  92. package/corpus/templates/videos/README.md +22 -201
  93. package/dist/agent/model-config.js +1 -1
  94. package/dist/agent/model-config.js.map +1 -1
  95. package/dist/cli/skills.d.ts.map +1 -1
  96. package/dist/cli/skills.js +17 -3
  97. package/dist/cli/skills.js.map +1 -1
  98. package/dist/client/AssistantChat.d.ts.map +1 -1
  99. package/dist/client/AssistantChat.js +28 -4
  100. package/dist/client/AssistantChat.js.map +1 -1
  101. package/dist/client/guided-questions.d.ts.map +1 -1
  102. package/dist/client/guided-questions.js +10 -6
  103. package/dist/client/guided-questions.js.map +1 -1
  104. package/dist/client/tool-display.d.ts.map +1 -1
  105. package/dist/client/tool-display.js +8 -0
  106. package/dist/client/tool-display.js.map +1 -1
  107. package/dist/collab/routes.d.ts +1 -1
  108. package/dist/file-upload/builder.d.ts.map +1 -1
  109. package/dist/file-upload/builder.js +6 -2
  110. package/dist/file-upload/builder.js.map +1 -1
  111. package/dist/notifications/channels.d.ts +10 -0
  112. package/dist/notifications/channels.d.ts.map +1 -1
  113. package/dist/notifications/channels.js +172 -20
  114. package/dist/notifications/channels.js.map +1 -1
  115. package/dist/notifications/index.d.ts +1 -1
  116. package/dist/notifications/index.d.ts.map +1 -1
  117. package/dist/notifications/index.js +1 -1
  118. package/dist/notifications/index.js.map +1 -1
  119. package/dist/notifications/registry.d.ts +5 -0
  120. package/dist/notifications/registry.d.ts.map +1 -1
  121. package/dist/notifications/registry.js +4 -1
  122. package/dist/notifications/registry.js.map +1 -1
  123. package/dist/progress/routes.d.ts +1 -1
  124. package/dist/resources/handlers.d.ts +1 -1
  125. package/dist/server/analytics.d.ts +16 -0
  126. package/dist/server/analytics.d.ts.map +1 -1
  127. package/dist/server/analytics.js +30 -7
  128. package/dist/server/analytics.js.map +1 -1
  129. package/dist/server/ssr-handler.d.ts.map +1 -1
  130. package/dist/server/ssr-handler.js +28 -12
  131. package/dist/server/ssr-handler.js.map +1 -1
  132. package/dist/server/transcribe-voice.d.ts +1 -1
  133. package/dist/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
  134. package/dist/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
  135. package/package.json +1 -1
  136. package/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
  137. package/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
@@ -0,0 +1,803 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { notifyWithDelivery } from "@agent-native/core/notifications";
4
+ import { recordChange } from "@agent-native/core/server";
5
+ import { and, asc, desc, eq, gte, isNull, lte, or, sql } from "drizzle-orm";
6
+
7
+ import { getDb, schema } from "../db/index.js";
8
+
9
+ export type AnalyticsAlertFilterOp =
10
+ | "equals"
11
+ | "not_equals"
12
+ | "contains"
13
+ | "in"
14
+ | "exists";
15
+
16
+ export interface AnalyticsAlertFilter {
17
+ field: string;
18
+ op?: AnalyticsAlertFilterOp;
19
+ value?: unknown;
20
+ }
21
+
22
+ export type AnalyticsAlertThresholdMode = "event_count" | "distinct_count";
23
+ export type AnalyticsAlertSeverity = "warning" | "critical";
24
+ export type AnalyticsAlertStatus =
25
+ | "ok"
26
+ | "triggered"
27
+ | "cooldown"
28
+ | "error"
29
+ | "running";
30
+
31
+ export interface AnalyticsAlertRuleInput {
32
+ id?: string;
33
+ name: string;
34
+ description?: string;
35
+ eventName?: string | null;
36
+ filters?: AnalyticsAlertFilter[];
37
+ thresholdMode?: AnalyticsAlertThresholdMode;
38
+ distinctBy?: string | null;
39
+ threshold: number;
40
+ windowMinutes: number;
41
+ cooldownMinutes?: number;
42
+ severity?: AnalyticsAlertSeverity;
43
+ channels?: string[];
44
+ emailRecipients?: string[];
45
+ enabled?: boolean;
46
+ }
47
+
48
+ export interface AnalyticsAlertRule {
49
+ id: string;
50
+ name: string;
51
+ description: string;
52
+ eventName: string | null;
53
+ filters: AnalyticsAlertFilter[];
54
+ thresholdMode: AnalyticsAlertThresholdMode;
55
+ distinctBy: string | null;
56
+ threshold: number;
57
+ windowMinutes: number;
58
+ cooldownMinutes: number;
59
+ severity: AnalyticsAlertSeverity;
60
+ channels: string[];
61
+ emailRecipients: string[];
62
+ enabled: boolean;
63
+ lastEvaluatedAt: string | null;
64
+ lastTriggeredAt: string | null;
65
+ lastStatus: AnalyticsAlertStatus | null;
66
+ lastError: string | null;
67
+ createdAt: string;
68
+ updatedAt: string;
69
+ ownerEmail: string;
70
+ orgId: string | null;
71
+ }
72
+
73
+ export interface AnalyticsAlertEventRow {
74
+ id: string;
75
+ eventName: string;
76
+ userId?: string | null;
77
+ anonymousId?: string | null;
78
+ userKey?: string | null;
79
+ sessionId?: string | null;
80
+ timestamp: string;
81
+ eventDate?: string | null;
82
+ receivedAt?: string | null;
83
+ url?: string | null;
84
+ path?: string | null;
85
+ hostname?: string | null;
86
+ referrer?: string | null;
87
+ app?: string | null;
88
+ template?: string | null;
89
+ signedIn?: string | null;
90
+ properties?: string | null;
91
+ context?: string | null;
92
+ }
93
+
94
+ export interface AnalyticsAlertEvaluation {
95
+ triggered: boolean;
96
+ observedValue: number;
97
+ eventCount: number;
98
+ sampleEvents: Array<Record<string, unknown>>;
99
+ }
100
+
101
+ export interface AccessCtx {
102
+ email: string;
103
+ orgId: string | null;
104
+ }
105
+
106
+ export interface RunRuleResult extends AnalyticsAlertEvaluation {
107
+ ruleId: string;
108
+ status: AnalyticsAlertStatus;
109
+ notificationId?: string;
110
+ }
111
+
112
+ function nowIso(): string {
113
+ return new Date().toISOString();
114
+ }
115
+
116
+ const ALERT_RULE_RUNNING_STALE_MS = 15 * 60 * 1000;
117
+
118
+ function safeJsonParse<T>(raw: unknown, fallback: T): T {
119
+ if (typeof raw !== "string" || !raw.trim()) return fallback;
120
+ try {
121
+ const parsed = JSON.parse(raw) as T;
122
+ return parsed ?? fallback;
123
+ } catch {
124
+ return fallback;
125
+ }
126
+ }
127
+
128
+ function normalizeName(name: string): string {
129
+ const normalized = name.trim();
130
+ if (!normalized) throw new Error("Alert name is required");
131
+ return normalized.slice(0, 120);
132
+ }
133
+
134
+ function normalizeNullableText(
135
+ value: string | null | undefined,
136
+ ): string | null {
137
+ const normalized = value?.trim();
138
+ return normalized ? normalized : null;
139
+ }
140
+
141
+ function clampInt(value: number, min: number, max: number): number {
142
+ const normalized = Math.floor(Number(value));
143
+ if (!Number.isFinite(normalized)) return min;
144
+ return Math.max(min, Math.min(max, normalized));
145
+ }
146
+
147
+ function normalizeFilters(filters: AnalyticsAlertFilter[] | undefined) {
148
+ const normalized: AnalyticsAlertFilter[] = [];
149
+ for (const filter of filters ?? []) {
150
+ const field = filter.field?.trim();
151
+ if (!field) continue;
152
+ const op = filter.op ?? "equals";
153
+ if (!["equals", "not_equals", "contains", "in", "exists"].includes(op)) {
154
+ throw new Error(`Unsupported alert filter operator: ${op}`);
155
+ }
156
+ normalized.push({ field, op, value: filter.value });
157
+ }
158
+ return normalized;
159
+ }
160
+
161
+ function normalizeChannels(
162
+ channels: string[] | undefined,
163
+ emailRecipients: string[],
164
+ ): string[] {
165
+ const source =
166
+ channels && channels.length
167
+ ? channels
168
+ : emailRecipients.length
169
+ ? ["inbox", "email"]
170
+ : ["inbox"];
171
+ const seen = new Set<string>();
172
+ const normalized: string[] = [];
173
+ for (const raw of source) {
174
+ const channel = raw.trim();
175
+ if (!channel || seen.has(channel)) continue;
176
+ seen.add(channel);
177
+ normalized.push(channel);
178
+ }
179
+ return normalized.length ? normalized : ["inbox"];
180
+ }
181
+
182
+ function normalizeEmailRecipients(recipients: string[] | undefined): string[] {
183
+ const seen = new Set<string>();
184
+ const normalized: string[] = [];
185
+ for (const raw of recipients ?? []) {
186
+ const email = raw.trim().toLowerCase();
187
+ if (!email || seen.has(email)) continue;
188
+ seen.add(email);
189
+ normalized.push(email);
190
+ }
191
+ return normalized;
192
+ }
193
+
194
+ function rowToRule(row: any): AnalyticsAlertRule {
195
+ return {
196
+ id: row.id,
197
+ name: row.name,
198
+ description: row.description ?? "",
199
+ eventName: row.eventName ?? null,
200
+ filters: safeJsonParse<AnalyticsAlertFilter[]>(row.filters, []),
201
+ thresholdMode:
202
+ row.thresholdMode === "distinct_count" ? "distinct_count" : "event_count",
203
+ distinctBy: row.distinctBy ?? null,
204
+ threshold: Number(row.threshold ?? 1),
205
+ windowMinutes: Number(row.windowMinutes ?? 10),
206
+ cooldownMinutes: Number(row.cooldownMinutes ?? 30),
207
+ severity: row.severity === "critical" ? "critical" : "warning",
208
+ channels: safeJsonParse<string[]>(row.channels, ["inbox"]),
209
+ emailRecipients: safeJsonParse<string[]>(row.emailRecipients, []),
210
+ enabled: row.enabled === true || row.enabled === 1,
211
+ lastEvaluatedAt: row.lastEvaluatedAt ?? null,
212
+ lastTriggeredAt: row.lastTriggeredAt ?? null,
213
+ lastStatus: row.lastStatus ?? null,
214
+ lastError: row.lastError ?? null,
215
+ createdAt: row.createdAt,
216
+ updatedAt: row.updatedAt,
217
+ ownerEmail: row.ownerEmail,
218
+ orgId: row.orgId ?? null,
219
+ };
220
+ }
221
+
222
+ function ownerWhere(ctx: AccessCtx, id?: string) {
223
+ const table = schema.analyticsAlertRules;
224
+ const clauses = [
225
+ sql`lower(${table.ownerEmail}) = ${ctx.email.toLowerCase()}`,
226
+ ctx.orgId ? eq(table.orgId, ctx.orgId) : isNull(table.orgId),
227
+ ];
228
+ if (id) clauses.push(eq(table.id, id));
229
+ return and(...clauses);
230
+ }
231
+
232
+ export async function listAnalyticsAlertRules(
233
+ ctx: AccessCtx,
234
+ ): Promise<AnalyticsAlertRule[]> {
235
+ const db = getDb() as any;
236
+ const rows = await db
237
+ .select()
238
+ .from(schema.analyticsAlertRules)
239
+ .where(ownerWhere(ctx))
240
+ .orderBy(asc(schema.analyticsAlertRules.name));
241
+ return rows.map(rowToRule);
242
+ }
243
+
244
+ export async function getAnalyticsAlertRule(
245
+ id: string,
246
+ ctx: AccessCtx,
247
+ ): Promise<AnalyticsAlertRule | null> {
248
+ const db = getDb() as any;
249
+ const [row] = await db
250
+ .select()
251
+ .from(schema.analyticsAlertRules)
252
+ .where(ownerWhere(ctx, id));
253
+ return row ? rowToRule(row) : null;
254
+ }
255
+
256
+ export async function saveAnalyticsAlertRule(
257
+ input: AnalyticsAlertRuleInput,
258
+ ctx: AccessCtx,
259
+ ): Promise<AnalyticsAlertRule> {
260
+ const updatedAt = nowIso();
261
+ const id = input.id || randomUUID();
262
+ const name = normalizeName(input.name);
263
+ const description = input.description?.trim() ?? "";
264
+ const eventName = normalizeNullableText(input.eventName);
265
+ const filters = normalizeFilters(input.filters);
266
+ const thresholdMode =
267
+ input.thresholdMode === "distinct_count" ? "distinct_count" : "event_count";
268
+ const distinctBy =
269
+ thresholdMode === "distinct_count"
270
+ ? normalizeNullableText(input.distinctBy) || "user_key"
271
+ : normalizeNullableText(input.distinctBy);
272
+ const threshold = clampInt(input.threshold, 1, 1_000_000);
273
+ const windowMinutes = clampInt(input.windowMinutes, 1, 24 * 60);
274
+ const cooldownMinutes = clampInt(input.cooldownMinutes ?? 30, 0, 24 * 60);
275
+ const severity = input.severity === "critical" ? "critical" : "warning";
276
+ const emailRecipients = normalizeEmailRecipients(input.emailRecipients);
277
+ const channels = normalizeChannels(input.channels, emailRecipients);
278
+ const enabled = input.enabled ?? true;
279
+ const db = getDb() as any;
280
+
281
+ if (input.id) {
282
+ const existing = await getAnalyticsAlertRule(input.id, ctx);
283
+ if (!existing) {
284
+ throw Object.assign(new Error("Analytics alert rule not found"), {
285
+ statusCode: 404,
286
+ });
287
+ }
288
+ await db
289
+ .update(schema.analyticsAlertRules)
290
+ .set({
291
+ name,
292
+ description,
293
+ eventName,
294
+ filters: JSON.stringify(filters),
295
+ thresholdMode,
296
+ distinctBy,
297
+ threshold,
298
+ windowMinutes,
299
+ cooldownMinutes,
300
+ severity,
301
+ channels: JSON.stringify(channels),
302
+ emailRecipients: JSON.stringify(emailRecipients),
303
+ enabled,
304
+ updatedAt,
305
+ lastError: null,
306
+ })
307
+ .where(ownerWhere(ctx, id));
308
+ } else {
309
+ await db.insert(schema.analyticsAlertRules).values({
310
+ id,
311
+ name,
312
+ description,
313
+ eventName,
314
+ filters: JSON.stringify(filters),
315
+ thresholdMode,
316
+ distinctBy,
317
+ threshold,
318
+ windowMinutes,
319
+ cooldownMinutes,
320
+ severity,
321
+ channels: JSON.stringify(channels),
322
+ emailRecipients: JSON.stringify(emailRecipients),
323
+ enabled,
324
+ createdAt: updatedAt,
325
+ updatedAt,
326
+ ownerEmail: ctx.email,
327
+ orgId: ctx.orgId,
328
+ });
329
+ }
330
+
331
+ const saved = await getAnalyticsAlertRule(id, ctx);
332
+ if (!saved) throw new Error("Failed to save analytics alert rule");
333
+ recordChange({
334
+ source: "analytics-alert-rules",
335
+ type: "change",
336
+ key: saved.id,
337
+ owner: saved.ownerEmail,
338
+ orgId: saved.orgId ?? undefined,
339
+ });
340
+ return saved;
341
+ }
342
+
343
+ export async function deleteAnalyticsAlertRule(
344
+ id: string,
345
+ ctx: AccessCtx,
346
+ ): Promise<void> {
347
+ const existing = await getAnalyticsAlertRule(id, ctx);
348
+ if (!existing) {
349
+ throw Object.assign(new Error("Analytics alert rule not found"), {
350
+ statusCode: 404,
351
+ });
352
+ }
353
+ const db = getDb() as any;
354
+ await db.delete(schema.analyticsAlertRules).where(ownerWhere(ctx, id));
355
+ recordChange({
356
+ source: "analytics-alert-rules",
357
+ type: "delete",
358
+ key: existing.id,
359
+ owner: existing.ownerEmail,
360
+ orgId: existing.orgId ?? undefined,
361
+ });
362
+ }
363
+
364
+ export async function listEnabledAnalyticsAlertRules(options: {
365
+ limit: number;
366
+ ownerEmail?: string;
367
+ orgId?: string | null;
368
+ }): Promise<AnalyticsAlertRule[]> {
369
+ const db = getDb() as any;
370
+ const table = schema.analyticsAlertRules;
371
+ const clauses: any[] = [eq(table.enabled, true)];
372
+ if (options.ownerEmail) {
373
+ clauses.push(
374
+ sql`lower(${table.ownerEmail}) = ${options.ownerEmail.toLowerCase()}`,
375
+ );
376
+ }
377
+ if (options.orgId !== undefined) {
378
+ clauses.push(
379
+ options.orgId ? eq(table.orgId, options.orgId) : isNull(table.orgId),
380
+ );
381
+ }
382
+ clauses.push(alertRuleNotRunningWhere(new Date()));
383
+ const rows = await db
384
+ .select()
385
+ .from(table)
386
+ .where(and(...clauses))
387
+ .orderBy(
388
+ sql`case when ${table.lastEvaluatedAt} is null then 0 else 1 end`,
389
+ asc(table.lastEvaluatedAt),
390
+ asc(table.createdAt),
391
+ )
392
+ .limit(clampInt(options.limit, 1, 500));
393
+ return rows.map(rowToRule);
394
+ }
395
+
396
+ function alertRuleNotRunningWhere(now: Date) {
397
+ const table = schema.analyticsAlertRules;
398
+ const staleRunningBefore = new Date(
399
+ now.getTime() - ALERT_RULE_RUNNING_STALE_MS,
400
+ ).toISOString();
401
+ return or(
402
+ isNull(table.lastStatus),
403
+ sql`${table.lastStatus} <> 'running'`,
404
+ isNull(table.lastEvaluatedAt),
405
+ lte(table.lastEvaluatedAt, staleRunningBefore),
406
+ );
407
+ }
408
+
409
+ function alertRulePreviousEvaluationWhere(rule: AnalyticsAlertRule) {
410
+ const table = schema.analyticsAlertRules;
411
+ return rule.lastEvaluatedAt
412
+ ? eq(table.lastEvaluatedAt, rule.lastEvaluatedAt)
413
+ : isNull(table.lastEvaluatedAt);
414
+ }
415
+
416
+ export async function claimAnalyticsAlertRuleEvaluation(
417
+ rule: AnalyticsAlertRule,
418
+ now: Date = new Date(),
419
+ ): Promise<boolean> {
420
+ const db = getDb() as any;
421
+ const table = schema.analyticsAlertRules;
422
+ const claimedAt = now.toISOString();
423
+ const rows = await db
424
+ .update(table)
425
+ .set({
426
+ lastEvaluatedAt: claimedAt,
427
+ lastStatus: "running",
428
+ lastError: null,
429
+ })
430
+ .where(
431
+ and(
432
+ eq(table.id, rule.id),
433
+ eq(table.enabled, true),
434
+ alertRuleNotRunningWhere(now),
435
+ alertRulePreviousEvaluationWhere(rule),
436
+ ),
437
+ )
438
+ .returning({ id: table.id });
439
+ return rows.length > 0;
440
+ }
441
+
442
+ export async function evaluateAndNotifyAnalyticsAlertRule(
443
+ rule: AnalyticsAlertRule,
444
+ now: Date = new Date(),
445
+ ): Promise<RunRuleResult> {
446
+ const windowEnd = now.toISOString();
447
+ const windowStart = new Date(
448
+ now.getTime() - rule.windowMinutes * 60 * 1000,
449
+ ).toISOString();
450
+ const rows = await loadCandidateEvents(rule, windowStart, windowEnd);
451
+ const evaluation = evaluateAnalyticsAlertRuleRows(rule, rows);
452
+ const evaluatedAt = now.toISOString();
453
+
454
+ if (!evaluation.triggered) {
455
+ await markRuleStatus(rule.id, {
456
+ lastEvaluatedAt: evaluatedAt,
457
+ lastStatus: "ok",
458
+ lastError: null,
459
+ });
460
+ return { ruleId: rule.id, status: "ok", ...evaluation };
461
+ }
462
+
463
+ if (isCoolingDown(rule, now)) {
464
+ await markRuleStatus(rule.id, {
465
+ lastEvaluatedAt: evaluatedAt,
466
+ lastStatus: "cooldown",
467
+ lastError: null,
468
+ });
469
+ return { ruleId: rule.id, status: "cooldown", ...evaluation };
470
+ }
471
+
472
+ const body = alertBody(rule, evaluation);
473
+ const channels = ensureInboxNotificationChannel(rule.channels);
474
+ const delivery = await notifyWithDelivery(
475
+ {
476
+ severity: rule.severity,
477
+ title: `Analytics alert: ${rule.name}`,
478
+ body,
479
+ channels,
480
+ metadata: {
481
+ kind: "analytics_alert",
482
+ ruleId: rule.id,
483
+ ruleName: rule.name,
484
+ observedValue: evaluation.observedValue,
485
+ eventCount: evaluation.eventCount,
486
+ threshold: rule.threshold,
487
+ thresholdMode: rule.thresholdMode,
488
+ distinctBy: rule.distinctBy,
489
+ windowMinutes: rule.windowMinutes,
490
+ windowStart,
491
+ windowEnd,
492
+ eventName: rule.eventName,
493
+ filters: rule.filters,
494
+ sampleEvents: evaluation.sampleEvents,
495
+ emailRecipients: rule.emailRecipients,
496
+ requestedChannels: rule.channels,
497
+ },
498
+ },
499
+ { owner: rule.ownerEmail },
500
+ );
501
+ if (delivery.deliveredChannels.length === 0) {
502
+ await markRuleStatus(rule.id, {
503
+ lastEvaluatedAt: evaluatedAt,
504
+ lastStatus: "error",
505
+ lastError: "Analytics alert notification was not delivered.",
506
+ });
507
+ return { ruleId: rule.id, status: "error", ...evaluation };
508
+ }
509
+
510
+ await recordIncident(rule, {
511
+ notificationId: delivery.notification?.id,
512
+ triggeredAt: evaluatedAt,
513
+ windowStart,
514
+ windowEnd,
515
+ ...evaluation,
516
+ });
517
+ await markRuleStatus(rule.id, {
518
+ lastEvaluatedAt: evaluatedAt,
519
+ lastTriggeredAt: evaluatedAt,
520
+ lastStatus: "triggered",
521
+ lastError: null,
522
+ });
523
+ return {
524
+ ruleId: rule.id,
525
+ status: "triggered",
526
+ notificationId: delivery.notification?.id,
527
+ ...evaluation,
528
+ };
529
+ }
530
+
531
+ function ensureInboxNotificationChannel(channels: string[]): string[] {
532
+ const normalized = channels.map((channel) => channel.trim()).filter(Boolean);
533
+ return normalized.includes("inbox") ? normalized : ["inbox", ...normalized];
534
+ }
535
+
536
+ export async function markAnalyticsAlertRuleError(
537
+ id: string,
538
+ error: unknown,
539
+ ): Promise<void> {
540
+ const message = error instanceof Error ? error.message : String(error);
541
+ await markRuleStatus(id, {
542
+ lastEvaluatedAt: nowIso(),
543
+ lastStatus: "error",
544
+ lastError: message.slice(0, 1000),
545
+ });
546
+ }
547
+
548
+ export function evaluateAnalyticsAlertRuleRows(
549
+ rule: Pick<
550
+ AnalyticsAlertRule,
551
+ "filters" | "threshold" | "thresholdMode" | "distinctBy"
552
+ >,
553
+ rows: AnalyticsAlertEventRow[],
554
+ ): AnalyticsAlertEvaluation {
555
+ const matched = rows.filter((row) =>
556
+ rule.filters.every((filter) => matchesFilter(row, filter)),
557
+ );
558
+ const observedValue =
559
+ rule.thresholdMode === "distinct_count"
560
+ ? distinctCount(matched, rule.distinctBy || "user_key")
561
+ : matched.length;
562
+ return {
563
+ triggered: observedValue >= rule.threshold,
564
+ observedValue,
565
+ eventCount: matched.length,
566
+ sampleEvents: matched.slice(0, 5).map(sampleEvent),
567
+ };
568
+ }
569
+
570
+ async function loadCandidateEvents(
571
+ rule: AnalyticsAlertRule,
572
+ windowStart: string,
573
+ windowEnd: string,
574
+ ): Promise<AnalyticsAlertEventRow[]> {
575
+ const db = getDb() as any;
576
+ const table = schema.analyticsEvents;
577
+ const clauses: any[] = [
578
+ gte(table.timestamp, windowStart),
579
+ lte(table.timestamp, windowEnd),
580
+ ];
581
+ if (rule.orgId) {
582
+ clauses.push(eq(table.orgId, rule.orgId));
583
+ } else {
584
+ clauses.push(isNull(table.orgId));
585
+ clauses.push(
586
+ sql`lower(${table.ownerEmail}) = ${rule.ownerEmail.toLowerCase()}`,
587
+ );
588
+ }
589
+ if (rule.eventName) clauses.push(eq(table.eventName, rule.eventName));
590
+ const rows: AnalyticsAlertEventRow[] = [];
591
+ const batchSize = analyticsAlertEventBatchSize();
592
+ let cursor: { timestamp: string; id: string } | null = null;
593
+
594
+ while (true) {
595
+ const pageClauses = [...clauses];
596
+ if (cursor) {
597
+ pageClauses.push(
598
+ sql`(${table.timestamp} < ${cursor.timestamp} or (${table.timestamp} = ${cursor.timestamp} and ${table.id} < ${cursor.id}))`,
599
+ );
600
+ }
601
+ const page = await db
602
+ .select()
603
+ .from(table)
604
+ .where(and(...pageClauses))
605
+ .orderBy(desc(table.timestamp), desc(table.id))
606
+ .limit(batchSize);
607
+
608
+ if (!page.length) break;
609
+ rows.push(...page);
610
+ if (page.length < batchSize) break;
611
+
612
+ const last = page[page.length - 1];
613
+ cursor = { timestamp: last.timestamp, id: last.id };
614
+ }
615
+
616
+ return rows;
617
+ }
618
+
619
+ function analyticsAlertEventBatchSize(): number {
620
+ const raw =
621
+ process.env.ANALYTICS_ALERT_EVENT_BATCH_SIZE ??
622
+ process.env.ANALYTICS_ALERT_MAX_EVENTS_PER_RULE;
623
+ if (!raw) return 5000;
624
+ const parsed = Number.parseInt(raw, 10);
625
+ return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 10000) : 5000;
626
+ }
627
+
628
+ function isCoolingDown(rule: AnalyticsAlertRule, now: Date): boolean {
629
+ if (!rule.lastTriggeredAt || rule.cooldownMinutes <= 0) return false;
630
+ const last = Date.parse(rule.lastTriggeredAt);
631
+ if (!Number.isFinite(last)) return false;
632
+ return now.getTime() - last < rule.cooldownMinutes * 60 * 1000;
633
+ }
634
+
635
+ async function markRuleStatus(
636
+ id: string,
637
+ patch: {
638
+ lastEvaluatedAt?: string;
639
+ lastTriggeredAt?: string;
640
+ lastStatus: AnalyticsAlertStatus;
641
+ lastError: string | null;
642
+ },
643
+ ): Promise<void> {
644
+ const db = getDb() as any;
645
+ await db
646
+ .update(schema.analyticsAlertRules)
647
+ .set(patch)
648
+ .where(eq(schema.analyticsAlertRules.id, id));
649
+ }
650
+
651
+ async function recordIncident(
652
+ rule: AnalyticsAlertRule,
653
+ input: AnalyticsAlertEvaluation & {
654
+ triggeredAt: string;
655
+ windowStart: string;
656
+ windowEnd: string;
657
+ notificationId?: string;
658
+ },
659
+ ): Promise<void> {
660
+ const db = getDb() as any;
661
+ await db.insert(schema.analyticsAlertIncidents).values({
662
+ id: randomUUID(),
663
+ ruleId: rule.id,
664
+ triggeredAt: input.triggeredAt,
665
+ windowStart: input.windowStart,
666
+ windowEnd: input.windowEnd,
667
+ threshold: rule.threshold,
668
+ observedValue: input.observedValue,
669
+ eventCount: input.eventCount,
670
+ severity: rule.severity,
671
+ channels: JSON.stringify(rule.channels),
672
+ sampleEvents: JSON.stringify(input.sampleEvents),
673
+ notificationId: input.notificationId ?? null,
674
+ createdAt: input.triggeredAt,
675
+ ownerEmail: rule.ownerEmail,
676
+ orgId: rule.orgId,
677
+ });
678
+ }
679
+
680
+ function alertBody(
681
+ rule: AnalyticsAlertRule,
682
+ evaluation: AnalyticsAlertEvaluation,
683
+ ): string {
684
+ const metric =
685
+ rule.thresholdMode === "distinct_count"
686
+ ? `distinct ${rule.distinctBy || "user_key"} values`
687
+ : "events";
688
+ const target = rule.eventName ? ` for ${rule.eventName}` : "";
689
+ return `${evaluation.observedValue} ${metric}${target} matched in the last ${rule.windowMinutes} minutes; threshold is ${rule.threshold}.`;
690
+ }
691
+
692
+ function matchesFilter(
693
+ row: AnalyticsAlertEventRow,
694
+ filter: AnalyticsAlertFilter,
695
+ ): boolean {
696
+ const value = fieldValue(row, filter.field);
697
+ const op = filter.op ?? "equals";
698
+ if (op === "exists") {
699
+ const exists = value !== undefined && value !== null && value !== "";
700
+ return filter.value === false ? !exists : exists;
701
+ }
702
+ if (op === "not_equals") return !valueEquals(value, filter.value);
703
+ if (op === "contains") return valueContains(value, filter.value);
704
+ if (op === "in") return valueIn(value, filter.value);
705
+ return valueEquals(value, filter.value);
706
+ }
707
+
708
+ function distinctCount(rows: AnalyticsAlertEventRow[], field: string): number {
709
+ const values = new Set<string>();
710
+ for (const row of rows) {
711
+ const value = fieldValue(row, field);
712
+ if (value === undefined || value === null || value === "") continue;
713
+ values.add(String(value));
714
+ }
715
+ return values.size;
716
+ }
717
+
718
+ function fieldValue(row: AnalyticsAlertEventRow, field: string): unknown {
719
+ const normalized = field.trim();
720
+ if (normalized.startsWith("properties.")) {
721
+ return pathValue(
722
+ safeJsonParse<Record<string, unknown>>(row.properties, {}),
723
+ normalized.slice("properties.".length),
724
+ );
725
+ }
726
+ if (normalized.startsWith("context.")) {
727
+ return pathValue(
728
+ safeJsonParse<Record<string, unknown>>(row.context, {}),
729
+ normalized.slice("context.".length),
730
+ );
731
+ }
732
+ const aliases: Record<string, keyof AnalyticsAlertEventRow> = {
733
+ id: "id",
734
+ event_name: "eventName",
735
+ eventName: "eventName",
736
+ user_id: "userId",
737
+ userId: "userId",
738
+ anonymous_id: "anonymousId",
739
+ anonymousId: "anonymousId",
740
+ user_key: "userKey",
741
+ userKey: "userKey",
742
+ session_id: "sessionId",
743
+ sessionId: "sessionId",
744
+ timestamp: "timestamp",
745
+ event_date: "eventDate",
746
+ eventDate: "eventDate",
747
+ received_at: "receivedAt",
748
+ receivedAt: "receivedAt",
749
+ url: "url",
750
+ path: "path",
751
+ hostname: "hostname",
752
+ referrer: "referrer",
753
+ app: "app",
754
+ template: "template",
755
+ signed_in: "signedIn",
756
+ signedIn: "signedIn",
757
+ };
758
+ const key = aliases[normalized];
759
+ return key ? row[key] : undefined;
760
+ }
761
+
762
+ function pathValue(obj: Record<string, unknown>, path: string): unknown {
763
+ let current: unknown = obj;
764
+ for (const part of path.split(".")) {
765
+ if (!part) return undefined;
766
+ if (!current || typeof current !== "object") return undefined;
767
+ current = (current as Record<string, unknown>)[part];
768
+ }
769
+ return current;
770
+ }
771
+
772
+ function valueEquals(actual: unknown, expected: unknown): boolean {
773
+ if (actual === expected) return true;
774
+ if (actual === undefined || actual === null) return false;
775
+ if (expected === undefined || expected === null) return false;
776
+ return String(actual) === String(expected);
777
+ }
778
+
779
+ function valueContains(actual: unknown, expected: unknown): boolean {
780
+ if (actual === undefined || actual === null) return false;
781
+ if (Array.isArray(actual)) {
782
+ return actual.some((item) => valueEquals(item, expected));
783
+ }
784
+ return String(actual).includes(String(expected ?? ""));
785
+ }
786
+
787
+ function valueIn(actual: unknown, expected: unknown): boolean {
788
+ if (!Array.isArray(expected)) return valueEquals(actual, expected);
789
+ return expected.some((item) => valueEquals(actual, item));
790
+ }
791
+
792
+ function sampleEvent(row: AnalyticsAlertEventRow): Record<string, unknown> {
793
+ return {
794
+ id: row.id,
795
+ eventName: row.eventName,
796
+ timestamp: row.timestamp,
797
+ app: row.app,
798
+ template: row.template,
799
+ userKey: row.userKey,
800
+ sessionId: row.sessionId,
801
+ path: row.path,
802
+ };
803
+ }