@agent-native/core 0.77.24 → 0.78.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.
Files changed (212) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +20 -0
  3. package/corpus/core/docs/content/template-analytics.md +5 -0
  4. package/corpus/core/docs/content/tracking.md +45 -0
  5. package/corpus/core/package.json +3 -1
  6. package/corpus/core/src/agent/types.ts +22 -0
  7. package/corpus/core/src/client/AgentPanel.tsx +34 -18
  8. package/corpus/core/src/client/AssistantChat.tsx +40 -0
  9. package/corpus/core/src/client/agent-chat.ts +173 -0
  10. package/corpus/core/src/client/analytics.ts +141 -5
  11. package/corpus/core/src/client/composer/TiptapComposer.tsx +316 -60
  12. package/corpus/core/src/client/composer/extensions/MentionReference.tsx +3 -0
  13. package/corpus/core/src/client/composer/types.ts +22 -0
  14. package/corpus/core/src/client/index.ts +14 -0
  15. package/corpus/core/src/client/session-replay.ts +800 -0
  16. package/corpus/core/src/db/schema.ts +12 -0
  17. package/corpus/core/src/deploy/build.ts +75 -0
  18. package/corpus/core/src/private-blob/index.ts +18 -0
  19. package/corpus/core/src/private-blob/registry.ts +241 -0
  20. package/corpus/core/src/private-blob/types.ts +55 -0
  21. package/corpus/core/src/scripts/utils.ts +34 -6
  22. package/corpus/core/src/server/agent-chat-plugin.ts +10 -0
  23. package/corpus/core/src/server/email.ts +5 -0
  24. package/corpus/core/src/server/index.ts +1 -0
  25. package/corpus/core/src/styles/agent-native.css +24 -0
  26. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +1 -1
  27. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +43 -0
  28. package/corpus/templates/analytics/AGENTS.md +32 -1
  29. package/corpus/templates/analytics/DEVELOPING.md +9 -1
  30. package/corpus/templates/analytics/actions/delete-dashboard-report-subscription.ts +24 -0
  31. package/corpus/templates/analytics/actions/get-session-replay-events.ts +50 -0
  32. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +27 -0
  33. package/corpus/templates/analytics/actions/list-dashboard-report-subscriptions.ts +24 -0
  34. package/corpus/templates/analytics/actions/list-session-recordings.ts +59 -0
  35. package/corpus/templates/analytics/actions/navigate.ts +15 -5
  36. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +2 -2
  37. package/corpus/templates/analytics/actions/save-dashboard-report-subscription.ts +36 -0
  38. package/corpus/templates/analytics/actions/send-dashboard-report-now.ts +60 -0
  39. package/corpus/templates/analytics/actions/view-screen.ts +50 -2
  40. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +51 -2
  41. package/corpus/templates/analytics/app/components/layout/Header.tsx +4 -1
  42. package/corpus/templates/analytics/app/components/layout/Layout.tsx +14 -2
  43. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +6 -6
  44. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +35 -6
  45. package/corpus/templates/analytics/app/i18n-data.ts +1119 -0
  46. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/EmailReportDialog.tsx +437 -0
  47. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +11 -12
  48. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +43 -0
  49. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +136 -43
  50. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-filters.ts +26 -0
  51. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +450 -0
  52. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +419 -0
  53. package/corpus/templates/analytics/app/routes/overview.tsx +13 -6
  54. package/corpus/templates/analytics/app/routes/sessions.$recordingId.tsx +10 -0
  55. package/corpus/templates/analytics/app/routes/sessions._index.tsx +10 -0
  56. package/corpus/templates/analytics/changelog/2026-06-25-analytics-now-opens-directly-to-ask-instead-of-the-old-overv.md +6 -0
  57. package/corpus/templates/analytics/changelog/2026-06-25-dashboard-charts-now-show-reliable-blue-placement-lines-whil.md +6 -0
  58. package/corpus/templates/analytics/changelog/2026-06-26-dashboards-can-now-send-scheduled-daily-email-reports-from-t.md +6 -0
  59. package/corpus/templates/analytics/changelog/2026-06-26-session-replay-browser-sessions-can-now-be-reviewed-from.md +6 -0
  60. package/corpus/templates/analytics/package.json +5 -2
  61. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +131 -0
  62. package/corpus/templates/analytics/scripts/seed-session-replay.ts +198 -0
  63. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +140 -27
  64. package/corpus/templates/analytics/server/db/index.ts +12 -0
  65. package/corpus/templates/analytics/server/db/schema.ts +131 -0
  66. package/corpus/templates/analytics/server/handlers/session-replay.ts +281 -0
  67. package/corpus/templates/analytics/server/handlers/sql-query.ts +8 -348
  68. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +66 -0
  69. package/corpus/templates/analytics/server/jobs/session-replay-retention.ts +29 -0
  70. package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +365 -0
  71. package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +473 -0
  72. package/corpus/templates/analytics/server/lib/dashboard-report.ts +405 -0
  73. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +63 -18
  74. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +98 -26
  75. package/corpus/templates/analytics/server/lib/session-replay.ts +1645 -0
  76. package/corpus/templates/analytics/server/plugins/auth.ts +1 -1
  77. package/corpus/templates/analytics/server/plugins/dashboard-report-jobs.ts +47 -0
  78. package/corpus/templates/analytics/server/plugins/db.ts +200 -0
  79. package/corpus/templates/analytics/server/plugins/session-replay-retention-jobs.ts +41 -0
  80. package/corpus/templates/analytics/server/routes/api/analytics/replay.options.ts +1 -0
  81. package/corpus/templates/analytics/server/routes/api/analytics/replay.post.ts +1 -0
  82. package/corpus/templates/analytics/server/routes/api/dashboard-reports/run.post.ts +61 -0
  83. package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId]/chunks/[seq].get.ts +1 -0
  84. package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId]/events.get.ts +1 -0
  85. package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId]/manifest.get.ts +1 -0
  86. package/corpus/templates/analytics/server/routes/api/session-replay/recordings/[recordingId].get.ts +1 -0
  87. package/corpus/templates/analytics/server/routes/api/session-replay/recordings.get.ts +1 -0
  88. package/corpus/templates/assets/.agents/skills/asset-generation/SKILL.md +10 -2
  89. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +14 -7
  90. package/corpus/templates/assets/.agents/skills/library-management/SKILL.md +12 -0
  91. package/corpus/templates/assets/AGENTS.md +18 -3
  92. package/corpus/templates/assets/actions/_image-model-default.ts +24 -0
  93. package/corpus/templates/assets/actions/dismiss-variant-slots.ts +15 -22
  94. package/corpus/templates/assets/actions/generate-asset.ts +30 -23
  95. package/corpus/templates/assets/actions/generate-image-batch.ts +76 -29
  96. package/corpus/templates/assets/actions/generate-image.ts +46 -30
  97. package/corpus/templates/assets/actions/generate-video.ts +18 -3
  98. package/corpus/templates/assets/actions/list-assets.ts +39 -8
  99. package/corpus/templates/assets/actions/list-libraries.ts +24 -2
  100. package/corpus/templates/assets/actions/navigate.ts +1 -1
  101. package/corpus/templates/assets/actions/open-asset-picker.ts +38 -1
  102. package/corpus/templates/assets/actions/refresh-generation-run.ts +10 -0
  103. package/corpus/templates/assets/actions/save-generated-image.ts +21 -19
  104. package/corpus/templates/assets/actions/variant-slots.ts +131 -28
  105. package/corpus/templates/assets/actions/view-screen.ts +9 -0
  106. package/corpus/templates/assets/app/components/generation/GenerationResults.tsx +425 -0
  107. package/corpus/templates/assets/app/components/layout/Header.tsx +3 -1
  108. package/corpus/templates/assets/app/components/layout/Layout.tsx +5 -0
  109. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +4 -6
  110. package/corpus/templates/assets/app/hooks/use-navigation-state.ts +47 -21
  111. package/corpus/templates/assets/app/i18n-data.ts +1815 -0
  112. package/corpus/templates/assets/app/lib/libraries.ts +6 -0
  113. package/corpus/templates/assets/app/lib/picker-chat-handoff.ts +71 -0
  114. package/corpus/templates/assets/app/routes/_index.tsx +4 -0
  115. package/corpus/templates/assets/app/routes/asset.$id.tsx +2 -2
  116. package/corpus/templates/assets/app/routes/audit.tsx +1 -1
  117. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +1557 -2164
  118. package/corpus/templates/assets/app/routes/brand-kits._index.tsx +7 -191
  119. package/corpus/templates/assets/app/routes/brand-kits.tsx +1 -5
  120. package/corpus/templates/assets/app/routes/libraries.tsx +4 -4
  121. package/corpus/templates/assets/app/routes/library.$id.tsx +9 -7
  122. package/corpus/templates/assets/app/routes/library.tsx +1454 -175
  123. package/corpus/templates/assets/server/db/index.ts +1 -1
  124. package/corpus/templates/assets/server/lib/generation.ts +86 -15
  125. package/corpus/templates/assets/server/plugins/agent-chat.ts +196 -0
  126. package/corpus/templates/assets/server/plugins/db.ts +5 -0
  127. package/corpus/templates/assets/shared/api.ts +6 -3
  128. package/dist/agent/types.d.ts +21 -0
  129. package/dist/agent/types.d.ts.map +1 -1
  130. package/dist/agent/types.js.map +1 -1
  131. package/dist/client/AgentPanel.d.ts +6 -2
  132. package/dist/client/AgentPanel.d.ts.map +1 -1
  133. package/dist/client/AgentPanel.js +10 -4
  134. package/dist/client/AgentPanel.js.map +1 -1
  135. package/dist/client/AssistantChat.d.ts +6 -0
  136. package/dist/client/AssistantChat.d.ts.map +1 -1
  137. package/dist/client/AssistantChat.js +15 -3
  138. package/dist/client/AssistantChat.js.map +1 -1
  139. package/dist/client/agent-chat.d.ts +32 -0
  140. package/dist/client/agent-chat.d.ts.map +1 -1
  141. package/dist/client/agent-chat.js +113 -0
  142. package/dist/client/agent-chat.js.map +1 -1
  143. package/dist/client/analytics.d.ts +22 -3
  144. package/dist/client/analytics.d.ts.map +1 -1
  145. package/dist/client/analytics.js +96 -4
  146. package/dist/client/analytics.js.map +1 -1
  147. package/dist/client/composer/TiptapComposer.d.ts +2 -1
  148. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  149. package/dist/client/composer/TiptapComposer.js +228 -56
  150. package/dist/client/composer/TiptapComposer.js.map +1 -1
  151. package/dist/client/composer/extensions/MentionReference.d.ts.map +1 -1
  152. package/dist/client/composer/extensions/MentionReference.js +3 -0
  153. package/dist/client/composer/extensions/MentionReference.js.map +1 -1
  154. package/dist/client/composer/types.d.ts +21 -0
  155. package/dist/client/composer/types.d.ts.map +1 -1
  156. package/dist/client/composer/types.js.map +1 -1
  157. package/dist/client/index.d.ts +2 -2
  158. package/dist/client/index.d.ts.map +1 -1
  159. package/dist/client/index.js +2 -2
  160. package/dist/client/index.js.map +1 -1
  161. package/dist/client/session-replay.d.ts +41 -0
  162. package/dist/client/session-replay.d.ts.map +1 -0
  163. package/dist/client/session-replay.js +595 -0
  164. package/dist/client/session-replay.js.map +1 -0
  165. package/dist/collab/awareness.d.ts +2 -2
  166. package/dist/collab/awareness.d.ts.map +1 -1
  167. package/dist/collab/routes.d.ts +2 -2
  168. package/dist/db/schema.d.ts +3 -1
  169. package/dist/db/schema.d.ts.map +1 -1
  170. package/dist/db/schema.js +6 -2
  171. package/dist/db/schema.js.map +1 -1
  172. package/dist/deploy/build.d.ts +8 -0
  173. package/dist/deploy/build.d.ts.map +1 -1
  174. package/dist/deploy/build.js +66 -0
  175. package/dist/deploy/build.js.map +1 -1
  176. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  177. package/dist/notifications/routes.d.ts +3 -3
  178. package/dist/observability/routes.d.ts +8 -8
  179. package/dist/private-blob/index.d.ts +3 -0
  180. package/dist/private-blob/index.d.ts.map +1 -0
  181. package/dist/private-blob/index.js +2 -0
  182. package/dist/private-blob/index.js.map +1 -0
  183. package/dist/private-blob/registry.d.ts +10 -0
  184. package/dist/private-blob/registry.d.ts.map +1 -0
  185. package/dist/private-blob/registry.js +152 -0
  186. package/dist/private-blob/registry.js.map +1 -0
  187. package/dist/private-blob/types.d.ts +51 -0
  188. package/dist/private-blob/types.d.ts.map +1 -0
  189. package/dist/private-blob/types.js +2 -0
  190. package/dist/private-blob/types.js.map +1 -0
  191. package/dist/resources/handlers.d.ts +3 -3
  192. package/dist/scripts/utils.d.ts +4 -4
  193. package/dist/scripts/utils.d.ts.map +1 -1
  194. package/dist/scripts/utils.js +30 -6
  195. package/dist/scripts/utils.js.map +1 -1
  196. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  197. package/dist/server/agent-chat-plugin.js +5 -0
  198. package/dist/server/agent-chat-plugin.js.map +1 -1
  199. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  200. package/dist/server/email.d.ts +2 -0
  201. package/dist/server/email.d.ts.map +1 -1
  202. package/dist/server/email.js +3 -0
  203. package/dist/server/email.js.map +1 -1
  204. package/dist/server/index.d.ts +1 -1
  205. package/dist/server/index.d.ts.map +1 -1
  206. package/dist/server/index.js.map +1 -1
  207. package/dist/server/transcribe-voice.d.ts +1 -1
  208. package/dist/styles/agent-native.css +24 -0
  209. package/docs/content/template-analytics.md +5 -0
  210. package/docs/content/tracking.md +45 -0
  211. package/package.json +3 -1
  212. package/corpus/templates/analytics/app/pages/overview/OverviewPage.tsx +0 -209
@@ -0,0 +1,473 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { recordChange } from "@agent-native/core/server";
4
+ import { and, asc, eq, isNull, lte, or, sql } from "drizzle-orm";
5
+
6
+ import { getDb, schema } from "../db/index.js";
7
+ import { loadDashboardSeed } from "./dashboard-seeds";
8
+ import { getDashboard } from "./dashboards-store";
9
+
10
+ export interface ReportSubscriptionInput {
11
+ id?: string;
12
+ dashboardId: string;
13
+ name?: string;
14
+ recipients: string[];
15
+ filters?: Record<string, string>;
16
+ timeOfDay: string;
17
+ timezone: string;
18
+ enabled: boolean;
19
+ }
20
+
21
+ export interface DashboardReportSubscription {
22
+ id: string;
23
+ dashboardId: string;
24
+ name: string;
25
+ recipients: string[];
26
+ filters: Record<string, string>;
27
+ frequency: "daily";
28
+ timeOfDay: string;
29
+ timezone: string;
30
+ enabled: boolean;
31
+ nextRunAt: string | null;
32
+ lastRunAt: string | null;
33
+ lastStatus: "success" | "error" | "running" | null;
34
+ lastError: string | null;
35
+ createdAt: string;
36
+ updatedAt: string;
37
+ ownerEmail: string;
38
+ orgId: string | null;
39
+ }
40
+
41
+ export interface AccessCtx {
42
+ email: string;
43
+ orgId: string | null;
44
+ }
45
+
46
+ export interface ReportDashboard {
47
+ id: string;
48
+ title: string;
49
+ config: Record<string, unknown>;
50
+ }
51
+
52
+ export async function getReportDashboard(
53
+ dashboardId: string,
54
+ ctx: AccessCtx,
55
+ ): Promise<ReportDashboard | null> {
56
+ const dashboard = await getDashboard(dashboardId, ctx);
57
+ if (dashboard?.kind === "sql") {
58
+ return {
59
+ id: dashboard.id,
60
+ title: dashboard.title,
61
+ config: dashboard.config,
62
+ };
63
+ }
64
+
65
+ const seed = loadDashboardSeed(dashboardId);
66
+ if (!seed) return null;
67
+ return {
68
+ id: dashboardId,
69
+ title:
70
+ typeof seed.name === "string" && seed.name.trim()
71
+ ? seed.name.trim()
72
+ : dashboardId,
73
+ config: seed,
74
+ };
75
+ }
76
+
77
+ function nowIso(): string {
78
+ return new Date().toISOString();
79
+ }
80
+
81
+ function safeJsonParse<T>(raw: unknown, fallback: T): T {
82
+ if (typeof raw !== "string") return fallback;
83
+ try {
84
+ const parsed = JSON.parse(raw) as T;
85
+ return parsed ?? fallback;
86
+ } catch {
87
+ return fallback;
88
+ }
89
+ }
90
+
91
+ function normalizeRecipients(recipients: string[]): string[] {
92
+ const seen = new Set<string>();
93
+ const normalized: string[] = [];
94
+ for (const raw of recipients) {
95
+ const email = raw.trim().toLowerCase();
96
+ if (!email || seen.has(email)) continue;
97
+ seen.add(email);
98
+ normalized.push(email);
99
+ }
100
+ return normalized;
101
+ }
102
+
103
+ function normalizeFilters(filters: Record<string, string> | undefined) {
104
+ const out: Record<string, string> = {};
105
+ for (const [key, value] of Object.entries(filters ?? {})) {
106
+ const k = key.trim();
107
+ const v = String(value ?? "").trim();
108
+ if (k && v) out[k] = v;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ function assertTimeOfDay(value: string): string {
114
+ const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
115
+ if (!m) throw new Error("timeOfDay must be HH:mm");
116
+ return value;
117
+ }
118
+
119
+ function assertTimezone(value: string): string {
120
+ const timezone = value.trim() || "UTC";
121
+ try {
122
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date());
123
+ return timezone;
124
+ } catch {
125
+ throw new Error("timezone must be a valid IANA timezone");
126
+ }
127
+ }
128
+
129
+ function getZonedParts(date: Date, timeZone: string) {
130
+ const parts = new Intl.DateTimeFormat("en-US", {
131
+ timeZone,
132
+ hourCycle: "h23",
133
+ year: "numeric",
134
+ month: "2-digit",
135
+ day: "2-digit",
136
+ hour: "2-digit",
137
+ minute: "2-digit",
138
+ second: "2-digit",
139
+ }).formatToParts(date);
140
+ const pick = (type: string) =>
141
+ Number(parts.find((part) => part.type === type)?.value ?? 0);
142
+ return {
143
+ year: pick("year"),
144
+ month: pick("month"),
145
+ day: pick("day"),
146
+ hour: pick("hour"),
147
+ minute: pick("minute"),
148
+ second: pick("second"),
149
+ };
150
+ }
151
+
152
+ function calendarDayAfter(parts: { year: number; month: number; day: number }) {
153
+ const next = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + 1));
154
+ return {
155
+ year: next.getUTCFullYear(),
156
+ month: next.getUTCMonth() + 1,
157
+ day: next.getUTCDate(),
158
+ };
159
+ }
160
+
161
+ function zonedTimeToUtc(
162
+ parts: { year: number; month: number; day: number },
163
+ hour: number,
164
+ minute: number,
165
+ timeZone: string,
166
+ ): Date {
167
+ const targetUtc = Date.UTC(
168
+ parts.year,
169
+ parts.month - 1,
170
+ parts.day,
171
+ hour,
172
+ minute,
173
+ 0,
174
+ );
175
+ let guess = targetUtc;
176
+ for (let i = 0; i < 4; i++) {
177
+ const actual = getZonedParts(new Date(guess), timeZone);
178
+ const actualAsUtc = Date.UTC(
179
+ actual.year,
180
+ actual.month - 1,
181
+ actual.day,
182
+ actual.hour,
183
+ actual.minute,
184
+ actual.second,
185
+ );
186
+ const diff = actualAsUtc - targetUtc;
187
+ if (Math.abs(diff) < 1000) break;
188
+ guess -= diff;
189
+ }
190
+ return new Date(guess);
191
+ }
192
+
193
+ export function nextDailyRunAt(
194
+ timeOfDay: string,
195
+ timeZone: string,
196
+ from: Date = new Date(),
197
+ ): string {
198
+ const [hour, minute] = assertTimeOfDay(timeOfDay).split(":").map(Number);
199
+ const timezone = assertTimezone(timeZone);
200
+ const today = getZonedParts(from, timezone);
201
+ let candidate = zonedTimeToUtc(today, hour, minute, timezone);
202
+ if (candidate.getTime() <= from.getTime() + 30_000) {
203
+ candidate = zonedTimeToUtc(calendarDayAfter(today), hour, minute, timezone);
204
+ }
205
+ return candidate.toISOString();
206
+ }
207
+
208
+ function rowToSubscription(row: any): DashboardReportSubscription {
209
+ return {
210
+ id: row.id,
211
+ dashboardId: row.dashboardId,
212
+ name: row.name,
213
+ recipients: safeJsonParse<string[]>(row.recipients, []),
214
+ filters: safeJsonParse<Record<string, string>>(row.filters, {}),
215
+ frequency: "daily",
216
+ timeOfDay: row.timeOfDay,
217
+ timezone: row.timezone,
218
+ enabled: row.enabled === true || row.enabled === 1,
219
+ nextRunAt: row.nextRunAt ?? null,
220
+ lastRunAt: row.lastRunAt ?? null,
221
+ lastStatus: row.lastStatus ?? null,
222
+ lastError: row.lastError ?? null,
223
+ createdAt: row.createdAt,
224
+ updatedAt: row.updatedAt,
225
+ ownerEmail: row.ownerEmail,
226
+ orgId: row.orgId ?? null,
227
+ };
228
+ }
229
+
230
+ function ownerWhere(ctx: AccessCtx, dashboardId?: string, id?: string) {
231
+ const table = schema.dashboardReportSubscriptions;
232
+ const clauses = [
233
+ sql`lower(${table.ownerEmail}) = ${ctx.email.toLowerCase()}`,
234
+ ];
235
+ clauses.push(ctx.orgId ? eq(table.orgId, ctx.orgId) : isNull(table.orgId));
236
+ if (dashboardId) clauses.push(eq(table.dashboardId, dashboardId));
237
+ if (id) clauses.push(eq(table.id, id));
238
+ return and(...clauses);
239
+ }
240
+
241
+ function dueReportWhere(now: Date) {
242
+ const table = schema.dashboardReportSubscriptions;
243
+ return and(
244
+ eq(table.enabled, true),
245
+ lte(table.nextRunAt, now.toISOString()),
246
+ reportNotRunningWhere(now),
247
+ );
248
+ }
249
+
250
+ function reportNotRunningWhere(now: Date) {
251
+ const table = schema.dashboardReportSubscriptions;
252
+ const staleRunningBefore = new Date(
253
+ now.getTime() - 30 * 60 * 1000,
254
+ ).toISOString();
255
+ return or(
256
+ isNull(table.lastStatus),
257
+ sql`${table.lastStatus} <> 'running'`,
258
+ isNull(table.lastRunAt),
259
+ lte(table.lastRunAt, staleRunningBefore),
260
+ );
261
+ }
262
+
263
+ export async function listDashboardReportSubscriptions(
264
+ ctx: AccessCtx,
265
+ dashboardId?: string,
266
+ ): Promise<DashboardReportSubscription[]> {
267
+ const db = getDb() as any;
268
+ const rows = await db
269
+ .select()
270
+ .from(schema.dashboardReportSubscriptions)
271
+ .where(ownerWhere(ctx, dashboardId))
272
+ .orderBy(asc(schema.dashboardReportSubscriptions.createdAt));
273
+ return rows.map(rowToSubscription);
274
+ }
275
+
276
+ export async function getDashboardReportSubscription(
277
+ id: string,
278
+ ctx: AccessCtx,
279
+ ): Promise<DashboardReportSubscription | null> {
280
+ const db = getDb() as any;
281
+ const [row] = await db
282
+ .select()
283
+ .from(schema.dashboardReportSubscriptions)
284
+ .where(ownerWhere(ctx, undefined, id));
285
+ return row ? rowToSubscription(row) : null;
286
+ }
287
+
288
+ export async function saveDashboardReportSubscription(
289
+ input: ReportSubscriptionInput,
290
+ ctx: AccessCtx,
291
+ ): Promise<DashboardReportSubscription> {
292
+ const dashboard = await getReportDashboard(input.dashboardId, ctx);
293
+ if (!dashboard) {
294
+ throw Object.assign(new Error("Dashboard not found"), { statusCode: 404 });
295
+ }
296
+
297
+ const recipients = normalizeRecipients(input.recipients);
298
+ if (recipients.length === 0) {
299
+ throw new Error("At least one recipient is required");
300
+ }
301
+
302
+ const timeOfDay = assertTimeOfDay(input.timeOfDay);
303
+ const timezone = assertTimezone(input.timezone);
304
+ const filters = normalizeFilters(input.filters);
305
+ const updatedAt = nowIso();
306
+ const nextRunAt = input.enabled ? nextDailyRunAt(timeOfDay, timezone) : null;
307
+ const name =
308
+ input.name?.trim() || `${dashboard.title || "Dashboard"} daily email`;
309
+ const db = getDb() as any;
310
+ const id = input.id || randomUUID();
311
+
312
+ if (input.id) {
313
+ const existing = await getDashboardReportSubscription(input.id, ctx);
314
+ if (!existing) {
315
+ throw Object.assign(new Error("Report subscription not found"), {
316
+ statusCode: 404,
317
+ });
318
+ }
319
+ await db
320
+ .update(schema.dashboardReportSubscriptions)
321
+ .set({
322
+ dashboardId: input.dashboardId,
323
+ name,
324
+ recipients: JSON.stringify(recipients),
325
+ filters: JSON.stringify(filters),
326
+ timeOfDay,
327
+ timezone,
328
+ enabled: input.enabled,
329
+ nextRunAt,
330
+ updatedAt,
331
+ lastError: null,
332
+ })
333
+ .where(ownerWhere(ctx, undefined, id));
334
+ } else {
335
+ await db.insert(schema.dashboardReportSubscriptions).values({
336
+ id,
337
+ dashboardId: input.dashboardId,
338
+ name,
339
+ recipients: JSON.stringify(recipients),
340
+ filters: JSON.stringify(filters),
341
+ frequency: "daily",
342
+ timeOfDay,
343
+ timezone,
344
+ enabled: input.enabled,
345
+ nextRunAt,
346
+ createdAt: updatedAt,
347
+ updatedAt,
348
+ ownerEmail: ctx.email,
349
+ orgId: ctx.orgId,
350
+ });
351
+ }
352
+
353
+ const saved = await getDashboardReportSubscription(id, ctx);
354
+ if (!saved) throw new Error("Failed to save report subscription");
355
+ recordChange({
356
+ source: "dashboard-report-subscriptions",
357
+ type: "change",
358
+ key: saved.dashboardId,
359
+ owner: saved.ownerEmail,
360
+ orgId: saved.orgId ?? undefined,
361
+ });
362
+ return saved;
363
+ }
364
+
365
+ export async function deleteDashboardReportSubscription(
366
+ id: string,
367
+ ctx: AccessCtx,
368
+ ): Promise<void> {
369
+ const existing = await getDashboardReportSubscription(id, ctx);
370
+ if (!existing) {
371
+ throw Object.assign(new Error("Report subscription not found"), {
372
+ statusCode: 404,
373
+ });
374
+ }
375
+ const db = getDb() as any;
376
+ await db
377
+ .delete(schema.dashboardReportSubscriptions)
378
+ .where(ownerWhere(ctx, undefined, id));
379
+ recordChange({
380
+ source: "dashboard-report-subscriptions",
381
+ type: "delete",
382
+ key: existing.dashboardId,
383
+ owner: existing.ownerEmail,
384
+ orgId: existing.orgId ?? undefined,
385
+ });
386
+ }
387
+
388
+ export async function listDueDashboardReportSubscriptions(
389
+ now: Date = new Date(),
390
+ ): Promise<DashboardReportSubscription[]> {
391
+ const db = getDb() as any;
392
+ const rows = await db
393
+ .select()
394
+ .from(schema.dashboardReportSubscriptions)
395
+ .where(dueReportWhere(now))
396
+ .orderBy(asc(schema.dashboardReportSubscriptions.nextRunAt));
397
+ return rows.map(rowToSubscription);
398
+ }
399
+
400
+ export async function claimDueDashboardReportSubscriptions(
401
+ limit: number,
402
+ now: Date = new Date(),
403
+ ): Promise<DashboardReportSubscription[]> {
404
+ const db = getDb() as any;
405
+ const claimLimit = Math.max(1, Math.min(100, Math.floor(limit)));
406
+ const candidates = await db
407
+ .select()
408
+ .from(schema.dashboardReportSubscriptions)
409
+ .where(dueReportWhere(now))
410
+ .orderBy(asc(schema.dashboardReportSubscriptions.nextRunAt))
411
+ .limit(claimLimit);
412
+ const claimed: DashboardReportSubscription[] = [];
413
+ for (const candidate of candidates) {
414
+ const startedAt = nowIso();
415
+ const rows = await db
416
+ .update(schema.dashboardReportSubscriptions)
417
+ .set({
418
+ lastRunAt: startedAt,
419
+ lastStatus: "running",
420
+ lastError: null,
421
+ updatedAt: startedAt,
422
+ })
423
+ .where(
424
+ and(
425
+ eq(schema.dashboardReportSubscriptions.id, candidate.id),
426
+ dueReportWhere(now),
427
+ ),
428
+ )
429
+ .returning();
430
+ if (rows[0]) claimed.push(rowToSubscription(rows[0]));
431
+ }
432
+ return claimed;
433
+ }
434
+
435
+ export async function claimDashboardReportSubscription(
436
+ id: string,
437
+ ctx: AccessCtx,
438
+ now: Date = new Date(),
439
+ ): Promise<DashboardReportSubscription | null> {
440
+ const db = getDb() as any;
441
+ const startedAt = now.toISOString();
442
+ const rows = await db
443
+ .update(schema.dashboardReportSubscriptions)
444
+ .set({
445
+ lastRunAt: startedAt,
446
+ lastStatus: "running",
447
+ lastError: null,
448
+ updatedAt: startedAt,
449
+ })
450
+ .where(and(ownerWhere(ctx, undefined, id), reportNotRunningWhere(now)))
451
+ .returning();
452
+ return rows[0] ? rowToSubscription(rows[0]) : null;
453
+ }
454
+
455
+ export async function markDashboardReportResult(
456
+ sub: DashboardReportSubscription,
457
+ status: "success" | "error",
458
+ error?: string,
459
+ ): Promise<void> {
460
+ const now = nowIso();
461
+ const db = getDb() as any;
462
+ await db
463
+ .update(schema.dashboardReportSubscriptions)
464
+ .set({
465
+ lastStatus: status,
466
+ lastError: error ? error.slice(0, 500) : null,
467
+ nextRunAt: sub.enabled
468
+ ? nextDailyRunAt(sub.timeOfDay, sub.timezone, new Date())
469
+ : null,
470
+ updatedAt: now,
471
+ })
472
+ .where(eq(schema.dashboardReportSubscriptions.id, sub.id));
473
+ }