@agent-native/core 0.128.1 → 0.128.3

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 (84) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +2 -1
  4. package/corpus/core/src/server/email-template.ts +33 -0
  5. package/corpus/templates/chat/_gitignore +2 -0
  6. package/corpus/templates/clips/AGENTS.md +6 -1
  7. package/corpus/templates/clips/actions/complete-transactional-email-summary.ts +56 -0
  8. package/corpus/templates/clips/actions/import-loom-recording.ts +258 -76
  9. package/corpus/templates/clips/actions/lib/direct-video.ts +96 -0
  10. package/corpus/templates/clips/actions/lib/loom-import-job.ts +53 -2
  11. package/corpus/templates/clips/actions/lib/loom-video.ts +2 -62
  12. package/corpus/templates/clips/actions/lib/video-download-limits.ts +62 -0
  13. package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +249 -0
  14. package/corpus/templates/clips/app/components/library/library-grid.tsx +65 -0
  15. package/corpus/templates/clips/app/components/library/library-layout.tsx +55 -0
  16. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +28 -126
  17. package/corpus/templates/clips/app/global.css +23 -0
  18. package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +98 -0
  19. package/corpus/templates/clips/app/i18n/en-US.ts +20 -0
  20. package/corpus/templates/clips/app/routes/_app.tsx +2 -0
  21. package/corpus/templates/clips/app/routes/import.tsx +396 -0
  22. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +8 -6
  23. package/corpus/templates/clips/app/routes/record.tsx +13 -71
  24. package/corpus/templates/clips/changelog/2026-07-25-two-clip-emails-now-include-a-concise-summary-generated-secu.md +6 -0
  25. package/corpus/templates/clips/data/clips-transactional-emails/config.json +3 -0
  26. package/corpus/templates/clips/server/jobs/transactional-emails.ts +1034 -0
  27. package/corpus/templates/clips/server/lib/transactional-email-store.ts +865 -0
  28. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +270 -0
  29. package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
  30. package/corpus/templates/clips/server/routes/api/view-event.post.ts +59 -2
  31. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
  32. package/corpus/templates/content/AGENTS.md +8 -0
  33. package/corpus/templates/content/actions/_content-database-personal-view.ts +28 -3
  34. package/corpus/templates/content/actions/_property-utils.ts +32 -2
  35. package/corpus/templates/content/actions/configure-document-property.ts +12 -2
  36. package/corpus/templates/content/actions/delete-document-property.ts +13 -3
  37. package/corpus/templates/content/actions/duplicate-document-property.ts +13 -3
  38. package/corpus/templates/content/actions/list-content-spaces.ts +5 -2
  39. package/corpus/templates/content/actions/list-document-properties.ts +12 -3
  40. package/corpus/templates/content/actions/move-database-item.ts +74 -55
  41. package/corpus/templates/content/actions/reorder-document-property.ts +20 -4
  42. package/corpus/templates/content/actions/set-document-property.ts +10 -1
  43. package/corpus/templates/content/actions/update-content-database-personal-view.ts +7 -0
  44. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +31 -21
  45. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +30 -3
  46. package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +8 -1
  47. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +72 -20
  48. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +79 -10
  49. package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +8 -1
  50. package/corpus/templates/content/app/components/editor/database/settings.tsx +6 -0
  51. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +246 -26
  52. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +491 -197
  53. package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +3 -3
  54. package/corpus/templates/content/app/components/sidebar/sidebar-reorder.tsx +409 -0
  55. package/corpus/templates/content/app/hooks/use-content-database.ts +117 -3
  56. package/corpus/templates/content/app/hooks/use-content-spaces.ts +1 -0
  57. package/corpus/templates/content/app/hooks/use-document-properties.ts +62 -18
  58. package/corpus/templates/content/app/hooks/use-documents.ts +13 -5
  59. package/corpus/templates/content/app/i18n-data.ts +261 -3
  60. package/corpus/templates/content/app/routes/_app.page.$id.tsx +9 -2
  61. package/corpus/templates/content/changelog/2026-07-24-database-page-properties-keep-their-context.md +6 -0
  62. package/corpus/templates/content/changelog/2026-07-26-sidebar-references-can-now-be-personally-reordered.md +6 -0
  63. package/corpus/templates/content/docs/solutions/2026-07-24-database-property-context-switch-diagnosis.md +249 -0
  64. package/corpus/templates/content/docs/solutions/content-sidebar-order-shape.md +389 -0
  65. package/corpus/templates/content/shared/api.ts +28 -0
  66. package/corpus/toolkit/CHANGELOG.md +6 -0
  67. package/corpus/toolkit/package.json +1 -1
  68. package/corpus/toolkit/src/editor/DragHandle.ts +63 -9
  69. package/dist/collab/struct-routes.d.ts +1 -1
  70. package/dist/server/email-template.d.ts +7 -0
  71. package/dist/server/email-template.d.ts.map +1 -1
  72. package/dist/server/email-template.js +24 -0
  73. package/dist/server/email-template.js.map +1 -1
  74. package/dist/server/realtime-token.d.ts +1 -1
  75. package/dist/templates/chat/_gitignore +2 -0
  76. package/package.json +3 -2
  77. package/src/server/email-template.ts +33 -0
  78. package/src/templates/chat/_gitignore +2 -0
  79. package/corpus/core/src/cli/sync-builder-starter-manifest.ts +0 -631
  80. package/dist/cli/sync-builder-starter-manifest.d.ts +0 -79
  81. package/dist/cli/sync-builder-starter-manifest.d.ts.map +0 -1
  82. package/dist/cli/sync-builder-starter-manifest.js +0 -426
  83. package/dist/cli/sync-builder-starter-manifest.js.map +0 -1
  84. package/src/cli/sync-builder-starter-manifest.ts +0 -631
@@ -0,0 +1,1034 @@
1
+ import { isEmailConfigured } from "@agent-native/core/server";
2
+ import { runWithRequestContext } from "@agent-native/core/server/request-context";
3
+ import { getUserProfile } from "@agent-native/core/user-profile/server";
4
+ import {
5
+ and,
6
+ asc,
7
+ eq,
8
+ gt,
9
+ gte,
10
+ inArray,
11
+ isNotNull,
12
+ isNull,
13
+ lte,
14
+ min,
15
+ not,
16
+ or,
17
+ sql,
18
+ } from "drizzle-orm";
19
+
20
+ import { getDb, schema } from "../db/index.js";
21
+ import { ownerEmailMatches } from "../lib/recordings.js";
22
+ import {
23
+ AI_DISPATCH_STALE_MS,
24
+ transactionalEmailRecipientSchema,
25
+ transactionalEmailStore,
26
+ type TransactionalEmailJob,
27
+ } from "../lib/transactional-email-store.js";
28
+ import {
29
+ sendClipsTransactionalEmail,
30
+ type ClipsTransactionalEmailInput,
31
+ } from "../lib/transactional-email-templates.js";
32
+
33
+ const JOB_INTERVAL_MS = 60_000;
34
+ const RECONCILIATION_BATCH_SIZE = 100;
35
+ const RECIPIENT_SHARE_BATCH_SIZE = 2;
36
+ const DELIVERY_BATCH_SIZE = 25;
37
+ const REMINDER_DELAY_MS = 48 * 60 * 60 * 1000;
38
+ const SENDING_LEASE_MS = 2 * 60 * 1000;
39
+ const MAX_ATTEMPTS = 3;
40
+ const RETRY_BASE_DELAY_MS = 60_000;
41
+ let skippingLogged = false;
42
+
43
+ type DirectShare = {
44
+ id: string;
45
+ recordingId: string;
46
+ recipient: string;
47
+ createdBy: string;
48
+ createdAt: string;
49
+ };
50
+
51
+ type RecordingState = {
52
+ id: string;
53
+ organizationId: string;
54
+ ownerEmail: string;
55
+ title: string;
56
+ titleSource: string;
57
+ sourceAppName: string | null;
58
+ createdAt: string;
59
+ status: string;
60
+ archivedAt: string | null;
61
+ trashedAt: string | null;
62
+ };
63
+
64
+ type CountedView = {
65
+ id: string;
66
+ viewedAt: string;
67
+ viewerEmail: string | null;
68
+ };
69
+
70
+ type FirstViewCandidate = CountedView & {
71
+ recordingId: string;
72
+ ownerEmail: string;
73
+ };
74
+
75
+ type ReconciliationCursor = {
76
+ createdAt: string;
77
+ id: string;
78
+ };
79
+
80
+ export type TransactionalEmailStore = Pick<
81
+ typeof transactionalEmailStore,
82
+ | "ensureEnabledAt"
83
+ | "enqueue"
84
+ | "listJobs"
85
+ | "transition"
86
+ | "acquireSendingLease"
87
+ | "transitionSending"
88
+ | "updateReconciliationCursor"
89
+ | "enqueueOrConvergeFirstImport"
90
+ >;
91
+
92
+ export interface TransactionalEmailRepository {
93
+ listDirectShares(
94
+ enabledAt: string,
95
+ cursor: ReconciliationCursor | null,
96
+ limit: number,
97
+ ): Promise<DirectShare[]>;
98
+ listDueDirectShares(
99
+ enabledAt: string,
100
+ dueBefore: string,
101
+ cursor: ReconciliationCursor | null,
102
+ limit: number,
103
+ ): Promise<DirectShare[]>;
104
+ listRecipientDistinctShares(
105
+ recipient: string,
106
+ enabledAt: string,
107
+ limit: number,
108
+ ): Promise<DirectShare[]>;
109
+ listCountedNonOwnerViews(
110
+ enabledAt: string,
111
+ cursor: ReconciliationCursor | null,
112
+ limit: number,
113
+ ): Promise<FirstViewCandidate[]>;
114
+ listReadyImports(
115
+ enabledAt: string,
116
+ cursor: ReconciliationCursor | null,
117
+ limit: number,
118
+ ): Promise<RecordingState[]>;
119
+ getRecording(recordingId: string): Promise<RecordingState | null>;
120
+ getUserDisplayName(email: string): Promise<string | null>;
121
+ getOrganizationBrandLogoUrl(organizationId: string): Promise<string | null>;
122
+ recipientOwnsRecording(recipient: string): Promise<boolean>;
123
+ recipientHasShare(
124
+ recipient: string,
125
+ recordingId: string,
126
+ shareId: string,
127
+ ): Promise<boolean>;
128
+ recipientHasShares(
129
+ recipient: string,
130
+ recordingIds: readonly string[],
131
+ ): Promise<boolean>;
132
+ recipientHasCountedView(
133
+ recipient: string,
134
+ recordingId: string,
135
+ ): Promise<boolean>;
136
+ getFirstNonOwnerCountedView(
137
+ recordingId: string,
138
+ ownerEmail: string,
139
+ ): Promise<CountedView | null>;
140
+ isFirstImport(
141
+ recording: RecordingState,
142
+ recipient: string,
143
+ enabledAt: string,
144
+ ): Promise<boolean>;
145
+ }
146
+
147
+ export interface TransactionalEmailWorkerDependencies {
148
+ store?: TransactionalEmailStore;
149
+ repository?: TransactionalEmailRepository;
150
+ now?: () => Date;
151
+ emailConfigured?: () => Promise<boolean>;
152
+ send?: (input: ClipsTransactionalEmailInput) => Promise<void>;
153
+ reconciliationBatchSize?: number;
154
+ deliveryBatchSize?: number;
155
+ leaseDurationMs?: number;
156
+ maxAttempts?: number;
157
+ retryBaseDelayMs?: number;
158
+ warn?: (message: string, details?: unknown) => void;
159
+ }
160
+
161
+ export interface TransactionalEmailWorkerResult {
162
+ enqueued: number;
163
+ cancelled: number;
164
+ retried: number;
165
+ failed: number;
166
+ sent: number;
167
+ }
168
+
169
+ function normalizedEmail(value: string | null | undefined): string | null {
170
+ const email = value?.trim().toLowerCase() ?? "";
171
+ const parsed = transactionalEmailRecipientSchema.safeParse(email);
172
+ return parsed.success ? parsed.data : null;
173
+ }
174
+
175
+ export function isSuppressedTransactionalRecipient(
176
+ value: string | null | undefined,
177
+ ): boolean {
178
+ const email = normalizedEmail(value);
179
+ // guard:allow-localhost-fallback — Suppress the retired dev identity; never use it as an owner.
180
+ if (!email || email === "local@localhost") return true;
181
+ const at = email.lastIndexOf("@");
182
+ const local = email.slice(0, at);
183
+ const domain = email.slice(at + 1);
184
+ return (
185
+ local.includes("+qa") &&
186
+ (domain === "example.test" ||
187
+ domain.endsWith(".test") ||
188
+ domain === "example.invalid" ||
189
+ domain.endsWith(".invalid"))
190
+ );
191
+ }
192
+
193
+ function normalizeShare(share: DirectShare): DirectShare | null {
194
+ const recipient = normalizedEmail(share.recipient);
195
+ if (!recipient || isSuppressedTransactionalRecipient(recipient)) return null;
196
+ return { ...share, recipient };
197
+ }
198
+
199
+ function isImportedRecording(recording: RecordingState): boolean {
200
+ return (
201
+ recording.sourceAppName === "Loom" ||
202
+ recording.sourceAppName === "Video link"
203
+ );
204
+ }
205
+
206
+ function isActiveReadyRecording(
207
+ recording: RecordingState | null,
208
+ ): recording is RecordingState {
209
+ return Boolean(
210
+ recording &&
211
+ recording.status === "ready" &&
212
+ recording.archivedAt === null &&
213
+ recording.trashedAt === null,
214
+ );
215
+ }
216
+
217
+ function defaultRepository(): TransactionalEmailRepository {
218
+ const db = getDb();
219
+
220
+ const selectShares = async (
221
+ enabledAt: string,
222
+ limit: number,
223
+ recipient?: string,
224
+ cursor?: ReconciliationCursor | null,
225
+ dueBefore?: string,
226
+ ): Promise<DirectShare[]> => {
227
+ const rows = await db
228
+ .select({
229
+ id: schema.recordingShares.id,
230
+ recordingId: schema.recordingShares.resourceId,
231
+ recipient: schema.recordingShares.principalId,
232
+ createdBy: schema.recordingShares.createdBy,
233
+ createdAt: schema.recordingShares.createdAt,
234
+ })
235
+ .from(schema.recordingShares)
236
+ .where(
237
+ and(
238
+ eq(schema.recordingShares.principalType, "user"),
239
+ recipient
240
+ ? ownerEmailMatches(schema.recordingShares.principalId, recipient)
241
+ : undefined,
242
+ gte(schema.recordingShares.createdAt, enabledAt),
243
+ dueBefore
244
+ ? lte(schema.recordingShares.createdAt, dueBefore)
245
+ : undefined,
246
+ cursor
247
+ ? or(
248
+ gt(schema.recordingShares.createdAt, cursor.createdAt),
249
+ and(
250
+ eq(schema.recordingShares.createdAt, cursor.createdAt),
251
+ gt(schema.recordingShares.id, cursor.id),
252
+ ),
253
+ )
254
+ : undefined,
255
+ ),
256
+ )
257
+ .orderBy(
258
+ asc(schema.recordingShares.createdAt),
259
+ asc(schema.recordingShares.id),
260
+ )
261
+ .limit(limit);
262
+ return rows;
263
+ };
264
+
265
+ return {
266
+ listDirectShares: (enabledAt, cursor, limit) =>
267
+ selectShares(enabledAt, limit, undefined, cursor),
268
+ listDueDirectShares: (enabledAt, dueBefore, cursor, limit) =>
269
+ selectShares(enabledAt, limit, undefined, cursor, dueBefore),
270
+ async listRecipientDistinctShares(recipient, enabledAt, limit) {
271
+ const distinctRecordings = await db
272
+ .select({
273
+ recordingId: schema.recordingShares.resourceId,
274
+ firstSharedAt: min(schema.recordingShares.createdAt),
275
+ })
276
+ .from(schema.recordingShares)
277
+ .where(
278
+ and(
279
+ eq(schema.recordingShares.principalType, "user"),
280
+ ownerEmailMatches(schema.recordingShares.principalId, recipient),
281
+ gte(schema.recordingShares.createdAt, enabledAt),
282
+ ),
283
+ )
284
+ .groupBy(schema.recordingShares.resourceId)
285
+ .orderBy(
286
+ asc(min(schema.recordingShares.createdAt)),
287
+ asc(schema.recordingShares.resourceId),
288
+ )
289
+ .limit(limit);
290
+ const shares: DirectShare[] = [];
291
+ for (const distinct of distinctRecordings) {
292
+ const [share] = await db
293
+ .select({
294
+ id: schema.recordingShares.id,
295
+ recordingId: schema.recordingShares.resourceId,
296
+ recipient: schema.recordingShares.principalId,
297
+ createdBy: schema.recordingShares.createdBy,
298
+ createdAt: schema.recordingShares.createdAt,
299
+ })
300
+ .from(schema.recordingShares)
301
+ .where(
302
+ and(
303
+ eq(schema.recordingShares.principalType, "user"),
304
+ ownerEmailMatches(schema.recordingShares.principalId, recipient),
305
+ eq(schema.recordingShares.resourceId, distinct.recordingId),
306
+ eq(schema.recordingShares.createdAt, distinct.firstSharedAt!),
307
+ ),
308
+ )
309
+ .orderBy(asc(schema.recordingShares.id))
310
+ .limit(1);
311
+ if (share) shares.push(share);
312
+ }
313
+ return shares;
314
+ },
315
+ async listCountedNonOwnerViews(enabledAt, cursor, limit) {
316
+ return db
317
+ .select({
318
+ id: schema.recordingViews.id,
319
+ viewedAt: schema.recordingViews.viewedAt,
320
+ viewerEmail: schema.recordingViews.viewerEmail,
321
+ recordingId: schema.recordingViews.recordingId,
322
+ ownerEmail: schema.recordings.ownerEmail,
323
+ })
324
+ .from(schema.recordingViews)
325
+ .innerJoin(
326
+ schema.recordings,
327
+ eq(schema.recordings.id, schema.recordingViews.recordingId),
328
+ )
329
+ .where(
330
+ and(
331
+ gte(schema.recordingViews.viewedAt, enabledAt),
332
+ or(
333
+ isNull(schema.recordingViews.viewerEmail),
334
+ sql`lower(${schema.recordingViews.viewerEmail}) <> lower(${schema.recordings.ownerEmail})`,
335
+ ),
336
+ cursor
337
+ ? or(
338
+ gt(schema.recordingViews.viewedAt, cursor.createdAt),
339
+ and(
340
+ eq(schema.recordingViews.viewedAt, cursor.createdAt),
341
+ gt(schema.recordingViews.id, cursor.id),
342
+ ),
343
+ )
344
+ : undefined,
345
+ ),
346
+ )
347
+ .orderBy(
348
+ asc(schema.recordingViews.viewedAt),
349
+ asc(schema.recordingViews.id),
350
+ )
351
+ .limit(limit);
352
+ },
353
+ async listReadyImports(enabledAt, cursor, limit) {
354
+ return db
355
+ .select({
356
+ id: schema.recordings.id,
357
+ organizationId: schema.recordings.organizationId,
358
+ ownerEmail: schema.recordings.ownerEmail,
359
+ title: schema.recordings.title,
360
+ titleSource: schema.recordings.titleSource,
361
+ sourceAppName: schema.recordings.sourceAppName,
362
+ createdAt: schema.recordings.createdAt,
363
+ status: schema.recordings.status,
364
+ archivedAt: schema.recordings.archivedAt,
365
+ trashedAt: schema.recordings.trashedAt,
366
+ })
367
+ .from(schema.recordings)
368
+ .where(
369
+ and(
370
+ eq(schema.recordings.status, "ready"),
371
+ gte(schema.recordings.createdAt, enabledAt),
372
+ or(
373
+ eq(schema.recordings.sourceAppName, "Loom"),
374
+ eq(schema.recordings.sourceAppName, "Video link"),
375
+ ),
376
+ cursor
377
+ ? or(
378
+ gt(schema.recordings.createdAt, cursor.createdAt),
379
+ and(
380
+ eq(schema.recordings.createdAt, cursor.createdAt),
381
+ gt(schema.recordings.id, cursor.id),
382
+ ),
383
+ )
384
+ : undefined,
385
+ ),
386
+ )
387
+ .orderBy(asc(schema.recordings.createdAt), asc(schema.recordings.id))
388
+ .limit(limit);
389
+ },
390
+ async getRecording(recordingId) {
391
+ const [recording] = await db
392
+ .select({
393
+ id: schema.recordings.id,
394
+ organizationId: schema.recordings.organizationId,
395
+ ownerEmail: schema.recordings.ownerEmail,
396
+ title: schema.recordings.title,
397
+ titleSource: schema.recordings.titleSource,
398
+ sourceAppName: schema.recordings.sourceAppName,
399
+ createdAt: schema.recordings.createdAt,
400
+ status: schema.recordings.status,
401
+ archivedAt: schema.recordings.archivedAt,
402
+ trashedAt: schema.recordings.trashedAt,
403
+ })
404
+ .from(schema.recordings)
405
+ .where(eq(schema.recordings.id, recordingId))
406
+ .limit(1);
407
+ return recording ?? null;
408
+ },
409
+ async getUserDisplayName(email) {
410
+ return (await getUserProfile(email)).name;
411
+ },
412
+ async getOrganizationBrandLogoUrl(organizationId) {
413
+ const [settings] = await db
414
+ .select({ brandLogoUrl: schema.organizationSettings.brandLogoUrl })
415
+ .from(schema.organizationSettings)
416
+ .where(eq(schema.organizationSettings.organizationId, organizationId))
417
+ .limit(1);
418
+ return settings?.brandLogoUrl?.trim() || null;
419
+ },
420
+ async recipientOwnsRecording(recipient) {
421
+ const [recording] = await db
422
+ .select({ id: schema.recordings.id })
423
+ .from(schema.recordings)
424
+ .where(ownerEmailMatches(schema.recordings.ownerEmail, recipient))
425
+ .limit(1);
426
+ return Boolean(recording);
427
+ },
428
+ async recipientHasShare(recipient, recordingId, shareId) {
429
+ const [share] = await db
430
+ .select({ recipient: schema.recordingShares.principalId })
431
+ .from(schema.recordingShares)
432
+ .where(
433
+ and(
434
+ eq(schema.recordingShares.id, shareId),
435
+ eq(schema.recordingShares.resourceId, recordingId),
436
+ eq(schema.recordingShares.principalType, "user"),
437
+ ),
438
+ )
439
+ .limit(1);
440
+ return normalizedEmail(share?.recipient) === recipient;
441
+ },
442
+ async recipientHasShares(recipient, recordingIds) {
443
+ const requiredIds = new Set(recordingIds);
444
+ if (requiredIds.size !== 2) return false;
445
+ const rows = await db
446
+ .select({ recordingId: schema.recordingShares.resourceId })
447
+ .from(schema.recordingShares)
448
+ .where(
449
+ and(
450
+ eq(schema.recordingShares.principalType, "user"),
451
+ ownerEmailMatches(schema.recordingShares.principalId, recipient),
452
+ inArray(schema.recordingShares.resourceId, [...requiredIds]),
453
+ ),
454
+ )
455
+ .groupBy(schema.recordingShares.resourceId);
456
+ const sharedIds = new Set(rows.map((share) => share.recordingId));
457
+ return (
458
+ sharedIds.size === requiredIds.size &&
459
+ [...requiredIds].every((recordingId) => sharedIds.has(recordingId))
460
+ );
461
+ },
462
+ async recipientHasCountedView(recipient, recordingId) {
463
+ const [view] = await db
464
+ .select({ id: schema.recordingViews.id })
465
+ .from(schema.recordingViews)
466
+ .where(
467
+ and(
468
+ eq(schema.recordingViews.recordingId, recordingId),
469
+ isNotNull(schema.recordingViews.viewerEmail),
470
+ ownerEmailMatches(schema.recordingViews.viewerEmail, recipient),
471
+ ),
472
+ )
473
+ .limit(1);
474
+ return Boolean(view);
475
+ },
476
+ async getFirstNonOwnerCountedView(recordingId, ownerEmail) {
477
+ const [view] = await db
478
+ .select({
479
+ id: schema.recordingViews.id,
480
+ viewedAt: schema.recordingViews.viewedAt,
481
+ viewerEmail: schema.recordingViews.viewerEmail,
482
+ })
483
+ .from(schema.recordingViews)
484
+ .where(
485
+ and(
486
+ eq(schema.recordingViews.recordingId, recordingId),
487
+ or(
488
+ isNull(schema.recordingViews.viewerEmail),
489
+ not(
490
+ ownerEmailMatches(
491
+ schema.recordingViews.viewerEmail,
492
+ ownerEmail,
493
+ ),
494
+ ),
495
+ ),
496
+ ),
497
+ )
498
+ .orderBy(
499
+ asc(schema.recordingViews.viewedAt),
500
+ asc(schema.recordingViews.id),
501
+ )
502
+ .limit(1);
503
+ return view ?? null;
504
+ },
505
+ async isFirstImport(recording, recipient, enabledAt) {
506
+ if (!isImportedRecording(recording)) return false;
507
+ const imports = await db
508
+ .select({
509
+ id: schema.recordings.id,
510
+ titleSource: schema.recordings.titleSource,
511
+ sourceAppName: schema.recordings.sourceAppName,
512
+ })
513
+ .from(schema.recordings)
514
+ .where(
515
+ and(
516
+ ownerEmailMatches(schema.recordings.ownerEmail, recipient),
517
+ eq(schema.recordings.status, "ready"),
518
+ gte(schema.recordings.createdAt, enabledAt),
519
+ lte(schema.recordings.createdAt, recording.createdAt),
520
+ or(
521
+ eq(schema.recordings.sourceAppName, "Loom"),
522
+ eq(schema.recordings.sourceAppName, "Video link"),
523
+ ),
524
+ ),
525
+ )
526
+ .orderBy(asc(schema.recordings.createdAt), asc(schema.recordings.id))
527
+ .limit(1);
528
+ return imports[0]?.id === recording.id;
529
+ },
530
+ };
531
+ }
532
+
533
+ async function reconcileShareDiscovery(
534
+ repository: TransactionalEmailRepository,
535
+ store: TransactionalEmailStore,
536
+ enabledAt: string,
537
+ cursor: ReconciliationCursor | null,
538
+ limit: number,
539
+ ): Promise<{ enqueued: number; nextCursor: ReconciliationCursor | null }> {
540
+ const page = await repository.listDirectShares(enabledAt, cursor, limit);
541
+ const shares = page
542
+ .map(normalizeShare)
543
+ .filter((share): share is DirectShare => share !== null);
544
+ let enqueued = 0;
545
+
546
+ for (const recipient of new Set(shares.map((share) => share.recipient))) {
547
+ if (await repository.recipientOwnsRecording(recipient)) continue;
548
+ const recipientShares = (
549
+ await repository.listRecipientDistinctShares(
550
+ recipient,
551
+ enabledAt,
552
+ RECIPIENT_SHARE_BATCH_SIZE,
553
+ )
554
+ )
555
+ .map(normalizeShare)
556
+ .filter((share): share is DirectShare => share !== null)
557
+ .sort(
558
+ (left, right) =>
559
+ left.createdAt.localeCompare(right.createdAt) ||
560
+ left.id.localeCompare(right.id),
561
+ );
562
+ const uniqueShares: DirectShare[] = [];
563
+ const recordingIds = new Set<string>();
564
+ for (const share of recipientShares) {
565
+ if (recordingIds.has(share.recordingId)) continue;
566
+ recordingIds.add(share.recordingId);
567
+ uniqueShares.push(share);
568
+ if (uniqueShares.length === 2) break;
569
+ }
570
+ if (uniqueShares.length !== 2) continue;
571
+ const secondShare = uniqueShares[1];
572
+ const result = await store.enqueue(
573
+ `two-clips:${recipient}`,
574
+ {
575
+ type: "two-clips",
576
+ recipient,
577
+ recordingIds: uniqueShares.map((share) => share.recordingId),
578
+ shareId: secondShare.id,
579
+ requestedBy: secondShare.createdBy,
580
+ },
581
+ "awaiting_ai",
582
+ );
583
+ if (result.created) enqueued += 1;
584
+ }
585
+
586
+ const lastShare = page[page.length - 1];
587
+ return {
588
+ enqueued,
589
+ nextCursor:
590
+ page.length >= limit && lastShare
591
+ ? { createdAt: lastShare.createdAt, id: lastShare.id }
592
+ : null,
593
+ };
594
+ }
595
+
596
+ async function reconcileDueReminders(
597
+ repository: TransactionalEmailRepository,
598
+ store: TransactionalEmailStore,
599
+ enabledAt: string,
600
+ cursor: ReconciliationCursor | null,
601
+ now: Date,
602
+ limit: number,
603
+ ): Promise<{ enqueued: number; nextCursor: ReconciliationCursor | null }> {
604
+ const dueBefore = new Date(now.getTime() - REMINDER_DELAY_MS).toISOString();
605
+ const page = await repository.listDueDirectShares(
606
+ enabledAt,
607
+ dueBefore,
608
+ cursor,
609
+ limit,
610
+ );
611
+ let enqueued = 0;
612
+ for (const rawShare of page) {
613
+ const share = normalizeShare(rawShare);
614
+ if (!share) continue;
615
+ const result = await store.enqueue(`unviewed-reminder:${share.id}`, {
616
+ type: "unviewed-reminder",
617
+ recipient: share.recipient,
618
+ recordingIds: [share.recordingId],
619
+ shareId: share.id,
620
+ requestedBy: share.createdBy,
621
+ });
622
+ if (result.created) enqueued += 1;
623
+ }
624
+ const lastShare = page[page.length - 1];
625
+ return {
626
+ enqueued,
627
+ nextCursor:
628
+ page.length >= limit && lastShare
629
+ ? { createdAt: lastShare.createdAt, id: lastShare.id }
630
+ : null,
631
+ };
632
+ }
633
+
634
+ async function reconcileFirstViews(
635
+ repository: TransactionalEmailRepository,
636
+ store: TransactionalEmailStore,
637
+ enabledAt: string,
638
+ cursor: ReconciliationCursor | null,
639
+ limit: number,
640
+ ): Promise<{ enqueued: number; nextCursor: ReconciliationCursor | null }> {
641
+ const page = await repository.listCountedNonOwnerViews(
642
+ enabledAt,
643
+ cursor,
644
+ limit,
645
+ );
646
+ let enqueued = 0;
647
+ for (const candidate of page) {
648
+ const ownerEmail = normalizedEmail(candidate.ownerEmail);
649
+ if (!ownerEmail || isSuppressedTransactionalRecipient(ownerEmail)) continue;
650
+ const firstView = await repository.getFirstNonOwnerCountedView(
651
+ candidate.recordingId,
652
+ ownerEmail,
653
+ );
654
+ if (
655
+ !firstView ||
656
+ firstView.id !== candidate.id ||
657
+ firstView.viewedAt < enabledAt
658
+ ) {
659
+ continue;
660
+ }
661
+ const result = await store.enqueue(`first-view:${candidate.recordingId}`, {
662
+ type: "first-view",
663
+ recipient: ownerEmail,
664
+ recordingIds: [candidate.recordingId],
665
+ requestedBy: ownerEmail,
666
+ });
667
+ if (result.created) enqueued += 1;
668
+ }
669
+ const lastView = page[page.length - 1];
670
+ return {
671
+ enqueued,
672
+ nextCursor:
673
+ page.length >= limit && lastView
674
+ ? { createdAt: lastView.viewedAt, id: lastView.id }
675
+ : null,
676
+ };
677
+ }
678
+
679
+ async function reconcileFirstImports(
680
+ repository: TransactionalEmailRepository,
681
+ store: TransactionalEmailStore,
682
+ enabledAt: string,
683
+ cursor: ReconciliationCursor | null,
684
+ limit: number,
685
+ ): Promise<{ enqueued: number; nextCursor: ReconciliationCursor | null }> {
686
+ const page = await repository.listReadyImports(enabledAt, cursor, limit);
687
+ let enqueued = 0;
688
+ for (const recording of page) {
689
+ const ownerEmail = normalizedEmail(recording.ownerEmail);
690
+ if (!ownerEmail || isSuppressedTransactionalRecipient(ownerEmail)) continue;
691
+ if (!(await repository.isFirstImport(recording, ownerEmail, enabledAt))) {
692
+ continue;
693
+ }
694
+ const result = await store.enqueueOrConvergeFirstImport(
695
+ ownerEmail,
696
+ recording.id,
697
+ ownerEmail,
698
+ );
699
+ if (result.created) enqueued += 1;
700
+ }
701
+ const lastImport = page[page.length - 1];
702
+ return {
703
+ enqueued,
704
+ nextCursor:
705
+ page.length >= limit && lastImport
706
+ ? { createdAt: lastImport.createdAt, id: lastImport.id }
707
+ : null,
708
+ };
709
+ }
710
+
711
+ async function makeSendInput(
712
+ job: TransactionalEmailJob,
713
+ repository: TransactionalEmailRepository,
714
+ enabledAt: string,
715
+ ): Promise<ClipsTransactionalEmailInput | null> {
716
+ const recipient = normalizedEmail(job.recipient);
717
+ if (!recipient || isSuppressedTransactionalRecipient(recipient)) return null;
718
+ const recordings = await Promise.all(
719
+ job.recordingIds.map((recordingId) => repository.getRecording(recordingId)),
720
+ );
721
+ if (!recordings.every(isActiveReadyRecording)) return null;
722
+
723
+ if (job.type === "unviewed-reminder") {
724
+ if (
725
+ !job.shareId ||
726
+ !(await repository.recipientHasShare(
727
+ recipient,
728
+ job.recordingIds[0],
729
+ job.shareId,
730
+ )) ||
731
+ (await repository.recipientHasCountedView(recipient, job.recordingIds[0]))
732
+ ) {
733
+ return null;
734
+ }
735
+ const senderEmail =
736
+ normalizedEmail(job.requestedBy) ??
737
+ normalizedEmail(recordings[0].ownerEmail);
738
+ const [senderName, brandLogoUrl] = await Promise.all([
739
+ senderEmail ? repository.getUserDisplayName(senderEmail) : null,
740
+ repository.getOrganizationBrandLogoUrl(recordings[0].organizationId),
741
+ ]);
742
+ return {
743
+ kind: "unviewed-reminder",
744
+ to: recipient,
745
+ recordingId: recordings[0].id,
746
+ title: recordings[0].title,
747
+ senderEmail,
748
+ senderName,
749
+ brandLogoUrl,
750
+ };
751
+ }
752
+
753
+ if (job.type === "two-clips") {
754
+ if (
755
+ (await repository.recipientOwnsRecording(recipient)) ||
756
+ !(await repository.recipientHasShares(recipient, job.recordingIds))
757
+ ) {
758
+ return null;
759
+ }
760
+ return {
761
+ kind: "two-clips",
762
+ to: recipient,
763
+ generatedSummary: job.generatedSummary,
764
+ };
765
+ }
766
+
767
+ if (job.type === "first-import") {
768
+ if (
769
+ normalizedEmail(recordings[0].ownerEmail) !== recipient ||
770
+ !(await repository.isFirstImport(recordings[0], recipient, enabledAt))
771
+ ) {
772
+ return null;
773
+ }
774
+ return {
775
+ kind: "first-import",
776
+ to: recipient,
777
+ recordingId: recordings[0].id,
778
+ title: recordings[0].title,
779
+ };
780
+ }
781
+
782
+ if (normalizedEmail(recordings[0].ownerEmail) !== recipient) return null;
783
+ const firstView = await repository.getFirstNonOwnerCountedView(
784
+ recordings[0].id,
785
+ recordings[0].ownerEmail,
786
+ );
787
+ if (!firstView) return null;
788
+ return {
789
+ kind: "first-view",
790
+ to: recipient,
791
+ recordingId: recordings[0].id,
792
+ title: recordings[0].title,
793
+ viewerEmail: firstView.viewerEmail,
794
+ };
795
+ }
796
+
797
+ function isRetryDue(
798
+ job: TransactionalEmailJob,
799
+ now: Date,
800
+ retryBaseDelayMs: number,
801
+ ): boolean {
802
+ if (job.state === "sending") {
803
+ return Boolean(
804
+ job.leaseUntil && Date.parse(job.leaseUntil) <= now.getTime(),
805
+ );
806
+ }
807
+ if (job.attempts === 0) return true;
808
+ const readyAt = job.readyAt ? Date.parse(job.readyAt) : 0;
809
+ const delay = retryBaseDelayMs * 2 ** Math.max(0, job.attempts - 1);
810
+ return readyAt + delay <= now.getTime();
811
+ }
812
+
813
+ export async function runTransactionalEmailsOnce(
814
+ dependencies: TransactionalEmailWorkerDependencies = {},
815
+ ): Promise<TransactionalEmailWorkerResult> {
816
+ return runWithRequestContext({}, async () => {
817
+ const store = dependencies.store ?? transactionalEmailStore;
818
+ const repository = dependencies.repository ?? defaultRepository();
819
+ const now = dependencies.now ?? (() => new Date());
820
+ const result: TransactionalEmailWorkerResult = {
821
+ enqueued: 0,
822
+ cancelled: 0,
823
+ retried: 0,
824
+ failed: 0,
825
+ sent: 0,
826
+ };
827
+ const config = await store.ensureEnabledAt();
828
+ const currentTime = now();
829
+ const reconciliationLimit =
830
+ dependencies.reconciliationBatchSize ?? RECONCILIATION_BATCH_SIZE;
831
+ const shareDiscovery = await reconcileShareDiscovery(
832
+ repository,
833
+ store,
834
+ config.enabledAt,
835
+ config.shareDiscoveryCursor ?? null,
836
+ reconciliationLimit,
837
+ );
838
+ result.enqueued += shareDiscovery.enqueued;
839
+ await store.updateReconciliationCursor(
840
+ "shareDiscoveryCursor",
841
+ shareDiscovery.nextCursor,
842
+ );
843
+ const reminders = await reconcileDueReminders(
844
+ repository,
845
+ store,
846
+ config.enabledAt,
847
+ config.reminderCursor ?? null,
848
+ currentTime,
849
+ reconciliationLimit,
850
+ );
851
+ result.enqueued += reminders.enqueued;
852
+ await store.updateReconciliationCursor(
853
+ "reminderCursor",
854
+ reminders.nextCursor,
855
+ );
856
+ const firstViews = await reconcileFirstViews(
857
+ repository,
858
+ store,
859
+ config.enabledAt,
860
+ config.firstViewCursor ?? null,
861
+ reconciliationLimit,
862
+ );
863
+ result.enqueued += firstViews.enqueued;
864
+ await store.updateReconciliationCursor(
865
+ "firstViewCursor",
866
+ firstViews.nextCursor,
867
+ );
868
+ const firstImports = await reconcileFirstImports(
869
+ repository,
870
+ store,
871
+ config.enabledAt,
872
+ config.firstImportCursor ?? null,
873
+ reconciliationLimit,
874
+ );
875
+ result.enqueued += firstImports.enqueued;
876
+ await store.updateReconciliationCursor(
877
+ "firstImportCursor",
878
+ firstImports.nextCursor,
879
+ );
880
+
881
+ const jobs = await store.listJobs();
882
+ const warn = dependencies.warn ?? console.warn;
883
+ for (const job of jobs) {
884
+ const dispatchedAt = job.aiDispatchedAt ?? job.updatedAt;
885
+ if (
886
+ job.state === "ai_dispatched" &&
887
+ currentTime.getTime() - Date.parse(dispatchedAt) >= AI_DISPATCH_STALE_MS
888
+ ) {
889
+ warn("[transactional-emails] AI dispatch remains unresolved", {
890
+ logicalKey: job.logicalKey,
891
+ aiDispatchedAt: dispatchedAt,
892
+ });
893
+ }
894
+ }
895
+ for (const job of jobs) {
896
+ if (
897
+ job.state === "pending" &&
898
+ (job.type === "first-view" ||
899
+ job.type === "first-import" ||
900
+ job.type === "unviewed-reminder")
901
+ ) {
902
+ await store.transition(job.logicalKey, ["pending"], "ready");
903
+ }
904
+ }
905
+
906
+ if (!(await (dependencies.emailConfigured ?? isEmailConfigured)())) {
907
+ return result;
908
+ }
909
+
910
+ const deliveryCandidates = (await store.listJobs())
911
+ .filter(
912
+ (job) =>
913
+ (job.state === "ready" || job.state === "sending") &&
914
+ isRetryDue(
915
+ job,
916
+ currentTime,
917
+ dependencies.retryBaseDelayMs ?? RETRY_BASE_DELAY_MS,
918
+ ),
919
+ )
920
+ .slice(0, dependencies.deliveryBatchSize ?? DELIVERY_BATCH_SIZE);
921
+
922
+ const maxAttempts = dependencies.maxAttempts ?? MAX_ATTEMPTS;
923
+ for (const candidate of deliveryCandidates) {
924
+ if (candidate.attempts >= maxAttempts) {
925
+ const lastError =
926
+ candidate.lastError ?? "Maximum delivery attempts reached";
927
+ const failed =
928
+ candidate.state === "sending"
929
+ ? await (async () => {
930
+ const reclaimed = await store.acquireSendingLease(
931
+ candidate.logicalKey,
932
+ dependencies.leaseDurationMs ?? SENDING_LEASE_MS,
933
+ );
934
+ return reclaimed?.leaseToken
935
+ ? store.transitionSending(
936
+ candidate.logicalKey,
937
+ reclaimed.leaseToken,
938
+ "failed",
939
+ { lastError },
940
+ )
941
+ : null;
942
+ })()
943
+ : await store.transition(
944
+ candidate.logicalKey,
945
+ [candidate.state],
946
+ "failed",
947
+ { lastError },
948
+ );
949
+ if (failed) result.failed += 1;
950
+ continue;
951
+ }
952
+ const leased = await store.acquireSendingLease(
953
+ candidate.logicalKey,
954
+ dependencies.leaseDurationMs ?? SENDING_LEASE_MS,
955
+ );
956
+ if (!leased) continue;
957
+
958
+ const leaseToken = leased.leaseToken;
959
+ if (!leaseToken) continue;
960
+ const input = await makeSendInput(leased, repository, config.enabledAt);
961
+ if (!input) {
962
+ if (
963
+ await store.transitionSending(
964
+ leased.logicalKey,
965
+ leaseToken,
966
+ "cancelled",
967
+ )
968
+ ) {
969
+ result.cancelled += 1;
970
+ }
971
+ continue;
972
+ }
973
+
974
+ try {
975
+ await (dependencies.send ?? sendClipsTransactionalEmail)(input);
976
+ if (
977
+ await store.transitionSending(leased.logicalKey, leaseToken, "sent")
978
+ ) {
979
+ result.sent += 1;
980
+ }
981
+ } catch (error) {
982
+ const message = (
983
+ error instanceof Error ? error.message : String(error)
984
+ ).slice(0, 4_000);
985
+ if (leased.attempts >= maxAttempts) {
986
+ if (
987
+ await store.transitionSending(
988
+ leased.logicalKey,
989
+ leaseToken,
990
+ "failed",
991
+ { lastError: message },
992
+ )
993
+ ) {
994
+ result.failed += 1;
995
+ }
996
+ } else if (
997
+ await store.transitionSending(
998
+ leased.logicalKey,
999
+ leaseToken,
1000
+ "ready",
1001
+ { lastError: message },
1002
+ )
1003
+ ) {
1004
+ result.retried += 1;
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ return result;
1010
+ });
1011
+ }
1012
+
1013
+ export default function registerTransactionalEmailsJob(): void {
1014
+ const isProd = process.env.NODE_ENV === "production";
1015
+ const flag = process.env.RUN_BACKGROUND_JOBS;
1016
+ const enabled = flag === "1" || (isProd && flag !== "0");
1017
+ if (!enabled) {
1018
+ if (process.env.DEBUG && !skippingLogged) {
1019
+ console.log(
1020
+ "[transactional-emails] Skipping background delivery (set RUN_BACKGROUND_JOBS=1 to enable in dev).",
1021
+ );
1022
+ skippingLogged = true;
1023
+ }
1024
+ return;
1025
+ }
1026
+ setInterval(() => {
1027
+ runTransactionalEmailsOnce().catch((error) =>
1028
+ console.error("[transactional-emails] interval failed:", error),
1029
+ );
1030
+ }, JOB_INTERVAL_MS);
1031
+ console.log(
1032
+ `[transactional-emails] Recurring reconciliation and delivery every ${JOB_INTERVAL_MS / 1000}s.`,
1033
+ );
1034
+ }