@agent-native/core 0.128.1 → 0.128.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +2 -1
- package/corpus/core/src/server/email-template.ts +33 -0
- package/corpus/templates/chat/_gitignore +2 -0
- package/corpus/templates/clips/AGENTS.md +6 -1
- package/corpus/templates/clips/actions/complete-transactional-email-summary.ts +56 -0
- package/corpus/templates/clips/actions/import-loom-recording.ts +258 -76
- package/corpus/templates/clips/actions/lib/direct-video.ts +96 -0
- package/corpus/templates/clips/actions/lib/loom-import-job.ts +53 -2
- package/corpus/templates/clips/actions/lib/loom-video.ts +2 -62
- package/corpus/templates/clips/actions/lib/video-download-limits.ts +62 -0
- package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +249 -0
- package/corpus/templates/clips/app/components/library/library-grid.tsx +65 -0
- package/corpus/templates/clips/app/components/library/library-layout.tsx +55 -0
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +28 -126
- package/corpus/templates/clips/app/global.css +23 -0
- package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +98 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +20 -0
- package/corpus/templates/clips/app/routes/_app.tsx +2 -0
- package/corpus/templates/clips/app/routes/import.tsx +396 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +8 -6
- package/corpus/templates/clips/app/routes/record.tsx +13 -71
- package/corpus/templates/clips/changelog/2026-07-25-two-clip-emails-now-include-a-concise-summary-generated-secu.md +6 -0
- package/corpus/templates/clips/data/clips-transactional-emails/config.json +3 -0
- package/corpus/templates/clips/server/jobs/transactional-emails.ts +1034 -0
- package/corpus/templates/clips/server/lib/transactional-email-store.ts +865 -0
- package/corpus/templates/clips/server/lib/transactional-email-templates.ts +270 -0
- package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
- package/corpus/templates/clips/server/routes/api/view-event.post.ts +59 -2
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
- package/corpus/templates/content/AGENTS.md +8 -0
- package/corpus/templates/content/actions/_content-database-personal-view.ts +28 -3
- package/corpus/templates/content/actions/_property-utils.ts +32 -2
- package/corpus/templates/content/actions/configure-document-property.ts +12 -2
- package/corpus/templates/content/actions/delete-document-property.ts +13 -3
- package/corpus/templates/content/actions/duplicate-document-property.ts +13 -3
- package/corpus/templates/content/actions/list-content-spaces.ts +5 -2
- package/corpus/templates/content/actions/list-document-properties.ts +12 -3
- package/corpus/templates/content/actions/move-database-item.ts +74 -55
- package/corpus/templates/content/actions/reorder-document-property.ts +20 -4
- package/corpus/templates/content/actions/set-document-property.ts +10 -1
- package/corpus/templates/content/actions/update-content-database-personal-view.ts +7 -0
- package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +31 -21
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +30 -3
- package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +8 -1
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +72 -20
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +79 -10
- package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +8 -1
- package/corpus/templates/content/app/components/editor/database/settings.tsx +6 -0
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +246 -26
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +491 -197
- package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +3 -3
- package/corpus/templates/content/app/components/sidebar/sidebar-reorder.tsx +409 -0
- package/corpus/templates/content/app/hooks/use-content-database.ts +117 -3
- package/corpus/templates/content/app/hooks/use-content-spaces.ts +1 -0
- package/corpus/templates/content/app/hooks/use-document-properties.ts +62 -18
- package/corpus/templates/content/app/hooks/use-documents.ts +13 -5
- package/corpus/templates/content/app/i18n-data.ts +261 -3
- package/corpus/templates/content/app/routes/_app.page.$id.tsx +9 -2
- package/corpus/templates/content/changelog/2026-07-24-database-page-properties-keep-their-context.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-sidebar-references-can-now-be-personally-reordered.md +6 -0
- package/corpus/templates/content/docs/solutions/2026-07-24-database-property-context-switch-diagnosis.md +249 -0
- package/corpus/templates/content/docs/solutions/content-sidebar-order-shape.md +389 -0
- package/corpus/templates/content/shared/api.ts +28 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/editor/DragHandle.ts +63 -9
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/email-template.d.ts +7 -0
- package/dist/server/email-template.d.ts.map +1 -1
- package/dist/server/email-template.js +24 -0
- package/dist/server/email-template.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/chat/_gitignore +2 -0
- package/package.json +4 -3
- package/src/server/email-template.ts +33 -0
- package/src/templates/chat/_gitignore +2 -0
|
@@ -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
|
+
}
|