@agent-native/core 0.136.0 → 0.136.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/templates/analytics/.agents/skills/analysis-workspace/SKILL.md +31 -5
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +7 -0
- package/corpus/templates/analytics/.agents/skills/gong/SKILL.md +52 -19
- package/corpus/templates/analytics/.agents/skills/provider-api/SKILL.md +25 -3
- package/corpus/templates/analytics/AGENTS.md +6 -7
- package/corpus/templates/analytics/actions/gong-calls.ts +3 -3
- package/corpus/templates/analytics/actions/provider-api-catalog.ts +1 -1
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
- package/corpus/templates/analytics/app/i18n-data.ts +46 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +67 -1
- package/corpus/templates/analytics/app/pages/settings/settings-search.ts +6 -0
- package/corpus/templates/analytics/changelog/2026-08-03-dashboard-builds-now-finish-without-an-extra-confirmation.md +6 -0
- package/corpus/templates/analytics/changelog/2026-08-03-error-alert-emails-are-opt-in.md +6 -0
- package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +10 -9
- package/corpus/templates/analytics/server/lib/error-capture.ts +38 -10
- package/corpus/templates/analytics/server/lib/gong.ts +27 -4
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +64 -4
- package/corpus/templates/analytics/shared/analytics-user-prefs.ts +7 -0
- package/corpus/templates/clips/.agents/skills/video-sharing/SKILL.md +15 -6
- package/corpus/templates/clips/actions/apply-rewind-extension.ts +9 -0
- package/corpus/templates/clips/actions/cleanup-dictation.ts +2 -2
- package/corpus/templates/clips/actions/cleanup-transcript.ts +8 -11
- package/corpus/templates/clips/actions/clear-edits.ts +15 -2
- package/corpus/templates/clips/actions/create-recording-agent-link.ts +10 -0
- package/corpus/templates/clips/actions/finalize-meeting.ts +6 -6
- package/corpus/templates/clips/actions/finalize-recording.ts +33 -8
- package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +1 -3
- package/corpus/templates/clips/actions/list-recordings.ts +39 -24
- package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +4 -1
- package/corpus/templates/clips/actions/regenerate-title.ts +2 -2
- package/corpus/templates/clips/actions/stitch-recordings.ts +36 -7
- package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +1 -4
- package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +3 -1
- package/corpus/templates/clips/app/components/library/recording-card.tsx +9 -0
- package/corpus/templates/clips/app/components/player/recording-views-badge.tsx +52 -7
- package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
- package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +17 -10
- package/corpus/templates/clips/app/i18n/en-US.ts +3 -0
- package/corpus/templates/clips/app/lib/timestamp-mapping.ts +5 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -0
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
- package/corpus/templates/clips/changelog/2026-07-31-agent-views-now-show-the-agent-s-name-from-the-link-it-was-g.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-31-clip-cards-and-the-watch-share-page-header-now-show-agent-vi.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-01-recordings-stored-in-private-s3-compatible-buckets-finalize-.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-03-clip-cleanup-titles-and-meeting-summaries-now-use-luna-by-de.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-03-get-a-monthly-recap-email-showing-how-many-people-and-ai-age.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-03-get-an-email-the-first-time-an-ai-agent-reads-one-of-your-cl.md +6 -0
- package/corpus/templates/clips/learnings.defaults.md +2 -2
- package/corpus/templates/clips/package.json +1 -1
- package/corpus/templates/clips/scripts/send-real-recap.ts +165 -0
- package/corpus/templates/clips/scripts/send-test-emails.ts +60 -0
- package/corpus/templates/clips/server/db/schema.ts +4 -0
- package/corpus/templates/clips/server/jobs/transactional-emails.ts +256 -0
- package/corpus/templates/clips/server/lib/agent-views.ts +38 -11
- package/corpus/templates/clips/server/lib/media-storage-provenance.ts +16 -0
- package/corpus/templates/clips/server/lib/public-agent-context.ts +3 -1
- package/corpus/templates/clips/server/lib/recap-metrics.ts +373 -0
- package/corpus/templates/clips/server/lib/s3-upload-provider.ts +84 -4
- package/corpus/templates/clips/server/lib/transactional-email-store.ts +41 -2
- package/corpus/templates/clips/server/lib/transactional-email-templates.ts +245 -3
- package/corpus/templates/clips/server/plugins/db.ts +16 -0
- package/corpus/templates/clips/server/register-secrets.ts +2 -2
- package/corpus/templates/clips/server/routes/api/public-recording.get.ts +7 -2
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +42 -1
- package/corpus/templates/design/actions/take-design-screenshot.ts +5 -3
- package/corpus/templates/design/package.json +1 -1
- package/corpus/templates/design/server/lib/playwright-runtime.ts +13 -5
- package/corpus/templates/mail/actions/get-automation-settings.ts +4 -12
- package/corpus/templates/mail/app/pages/SettingsPage.tsx +1 -1
- package/corpus/templates/mail/changelog/2026-08-03-inbox-automations-now-prefer-the-lowest-cost-luna-model-when.md +6 -0
- package/corpus/templates/mail/server/lib/automation-engine.ts +19 -14
- package/corpus/templates/mail/server/lib/automation-model.ts +110 -0
- package/corpus/templates/mail/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/slides/actions/generate-slides-ai.ts +92 -24
- package/corpus/templates/slides/changelog/2026-08-03-legacy-slide-outlines-now-prefer-the-lowest-cost-luna-model-.md +6 -0
- package/corpus/templates/tasks/package.json +2 -2
- package/dist/agent/production-agent.d.ts +2 -2
- package/dist/agent/production-agent.js +1 -1
- package/dist/catalog.json +2 -0
- package/dist/client/AgentTaskCard.js +4 -2
- package/dist/client/i18n.js +3 -0
- package/dist/client/settings/VoiceTranscriptionSection.js +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/localization/default-messages.d.ts +5 -0
- package/dist/localization/default-messages.js +5 -0
- package/dist/mcp/oauth-client-metadata.d.ts +7 -0
- package/dist/mcp/oauth-client-metadata.js +47 -0
- package/dist/mcp/oauth-route.js +7 -3
- package/dist/mcp/oauth-store.d.ts +2 -0
- package/dist/mcp/oauth-store.js +25 -3
- package/dist/observability/traces.d.ts +1 -1
- package/dist/observability/traces.js +2 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/index.js +29 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-access.d.ts +3 -1
- package/dist/server/agent-access.js +2 -1
- package/dist/server/agent-chat/browser-team-tools.d.ts +1 -0
- package/dist/server/agent-chat/browser-team-tools.js +5 -4
- package/dist/server/agent-chat/context-tools.js +2 -2
- package/dist/server/agent-chat-plugin.js +13 -6
- package/dist/server/agent-teams-run-queue.d.ts +2 -0
- package/dist/server/agent-teams.d.ts +2 -0
- package/dist/server/agent-teams.js +45 -2
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/short-lived-token.d.ts +7 -0
- package/dist/server/short-lived-token.js +7 -1
- package/dist/server/ssr-handler.js +19 -1
- package/dist/server/transcribe-voice.js +24 -16
- package/dist/vite/client.d.ts +37 -1
- package/dist/vite/client.js +110 -1
- package/docs/content/template-assets.mdx +14 -4
- package/package.json +3 -4
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Send the monthly recap for a real account using live Clips data.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the database directly with a read-only driver instead of booting
|
|
5
|
+
* the app, so it cannot run a migration against a shared database. Copy is
|
|
6
|
+
* composed exactly as the worker composes it, so this sends the real thing.
|
|
7
|
+
*
|
|
8
|
+
* npx tsx scripts/send-real-recap.ts --owner a@b.com --to a@b.com --month 2026-07
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import postgres from "postgres";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
composeRecapCopy,
|
|
15
|
+
sendClipsTransactionalEmail,
|
|
16
|
+
} from "../server/lib/transactional-email-templates.js";
|
|
17
|
+
|
|
18
|
+
function arg(args: string[], name: string): string | undefined {
|
|
19
|
+
const index = args.indexOf(`--${name}`);
|
|
20
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function required(args: string[], name: string): string {
|
|
24
|
+
const value = arg(args, name);
|
|
25
|
+
if (!value) throw new Error(`--${name} is required`);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main(args: string[]): Promise<void> {
|
|
30
|
+
const owner = required(args, "owner");
|
|
31
|
+
const to = required(args, "to");
|
|
32
|
+
const month = required(args, "month");
|
|
33
|
+
const databaseUrl = process.env.CLIPS_DATABASE_URL;
|
|
34
|
+
if (!databaseUrl) throw new Error("CLIPS_DATABASE_URL is not set");
|
|
35
|
+
|
|
36
|
+
const [year, monthNumber] = month.split("-").map(Number);
|
|
37
|
+
const startAt = new Date(Date.UTC(year, monthNumber - 1, 1)).toISOString();
|
|
38
|
+
const endAt = new Date(Date.UTC(year, monthNumber, 1)).toISOString();
|
|
39
|
+
|
|
40
|
+
const sql = postgres(databaseUrl, { ssl: "require", max: 1 });
|
|
41
|
+
try {
|
|
42
|
+
const owned = await sql<
|
|
43
|
+
{
|
|
44
|
+
id: string;
|
|
45
|
+
title: string;
|
|
46
|
+
thumbnail_url: string | null;
|
|
47
|
+
duration_ms: number;
|
|
48
|
+
created_at: string;
|
|
49
|
+
}[]
|
|
50
|
+
>`select id, title, thumbnail_url, duration_ms, created_at
|
|
51
|
+
from recordings where lower(owner_email) = lower(${owner})`;
|
|
52
|
+
if (owned.length === 0) throw new Error(`${owner} owns no recordings`);
|
|
53
|
+
const ids = owned.map((row) => row.id);
|
|
54
|
+
|
|
55
|
+
const humans = await sql<{ recording_id: string; n: number }[]>`
|
|
56
|
+
select recording_id, count(*)::int n
|
|
57
|
+
from recording_views
|
|
58
|
+
where recording_id in ${sql(ids)}
|
|
59
|
+
and viewed_at >= ${startAt} and viewed_at < ${endAt}
|
|
60
|
+
group by recording_id`;
|
|
61
|
+
const agents = await sql<{ recording_id: string; n: number }[]>`
|
|
62
|
+
select recording_id, count(*)::int n
|
|
63
|
+
from recording_agent_views
|
|
64
|
+
where recording_id in ${sql(ids)}
|
|
65
|
+
and first_seen_at >= ${startAt} and first_seen_at < ${endAt}
|
|
66
|
+
group by recording_id`;
|
|
67
|
+
|
|
68
|
+
const humanByClip = new Map(humans.map((row) => [row.recording_id, row.n]));
|
|
69
|
+
const agentByClip = new Map(agents.map((row) => [row.recording_id, row.n]));
|
|
70
|
+
const humanViews = [...humanByClip.values()].reduce((a, b) => a + b, 0);
|
|
71
|
+
const agentSessions = [...agentByClip.values()].reduce((a, b) => a + b, 0);
|
|
72
|
+
if (humanViews === 0 && agentSessions === 0) {
|
|
73
|
+
throw new Error(`${owner} had no audience in ${month}; no recap is due`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const top = owned
|
|
77
|
+
.filter((row) => humanByClip.has(row.id) || agentByClip.has(row.id))
|
|
78
|
+
.map((row) => ({
|
|
79
|
+
row,
|
|
80
|
+
audience:
|
|
81
|
+
(humanByClip.get(row.id) ?? 0) + (agentByClip.get(row.id) ?? 0),
|
|
82
|
+
}))
|
|
83
|
+
.sort(
|
|
84
|
+
(left, right) =>
|
|
85
|
+
right.audience - left.audience ||
|
|
86
|
+
right.row.created_at.localeCompare(left.row.created_at) ||
|
|
87
|
+
left.row.id.localeCompare(right.row.id),
|
|
88
|
+
)[0].row;
|
|
89
|
+
|
|
90
|
+
const monthViewerIds = (
|
|
91
|
+
await sql<{ viewer_id: string }[]>`
|
|
92
|
+
select distinct viewer_id from recording_views
|
|
93
|
+
where recording_id = ${top.id}
|
|
94
|
+
and viewed_at >= ${startAt} and viewed_at < ${endAt}`
|
|
95
|
+
).map((row) => row.viewer_id);
|
|
96
|
+
let completedPct = 0;
|
|
97
|
+
let dropOffMs: number | null = null;
|
|
98
|
+
if (monthViewerIds.length > 0) {
|
|
99
|
+
const [completion] = await sql<{ mean: string | null }[]>`
|
|
100
|
+
select avg(completed_pct) mean from recording_viewers
|
|
101
|
+
where recording_id = ${top.id} and id in ${sql(monthViewerIds)}`;
|
|
102
|
+
completedPct = Math.round(Number(completion?.mean ?? 0));
|
|
103
|
+
const [progress] = await sql<{ timestamp_ms: number }[]>`
|
|
104
|
+
select timestamp_ms from recording_events
|
|
105
|
+
where recording_id = ${top.id} and viewer_id in ${sql(monthViewerIds)}
|
|
106
|
+
and kind in ('watch-progress', 'pause')
|
|
107
|
+
and created_at >= ${startAt} and created_at < ${endAt}
|
|
108
|
+
order by timestamp_ms desc limit 1`;
|
|
109
|
+
dropOffMs = progress ? Number(progress.timestamp_ms) : null;
|
|
110
|
+
}
|
|
111
|
+
const agentBreakdown = (
|
|
112
|
+
await sql<{ agent_label: string | null; n: number }[]>`
|
|
113
|
+
select agent_label, count(*)::int n from recording_agent_views
|
|
114
|
+
where recording_id = ${top.id}
|
|
115
|
+
and first_seen_at >= ${startAt} and first_seen_at < ${endAt}
|
|
116
|
+
group by agent_label order by n desc, agent_label asc`
|
|
117
|
+
).map((row) => ({
|
|
118
|
+
// "Agent" is the agent-views fallback label, not a product name.
|
|
119
|
+
agentLabel: row.agent_label === "Agent" ? null : row.agent_label,
|
|
120
|
+
sessions: row.n,
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
console.log(
|
|
124
|
+
`Account totals for ${month}: ${humanViews} human views, ${agentSessions} agent sessions`,
|
|
125
|
+
);
|
|
126
|
+
console.log(
|
|
127
|
+
`Top clip: ${top.title} (${humanByClip.get(top.id) ?? 0} human / ${agentByClip.get(top.id) ?? 0} agent)`,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await sendClipsTransactionalEmail({
|
|
131
|
+
kind: "monthly-recap",
|
|
132
|
+
to,
|
|
133
|
+
month,
|
|
134
|
+
humanViews,
|
|
135
|
+
agentSessions,
|
|
136
|
+
topClip: {
|
|
137
|
+
recordingId: top.id,
|
|
138
|
+
title: top.title,
|
|
139
|
+
thumbnailUrl: top.thumbnail_url,
|
|
140
|
+
durationMs: Number(top.duration_ms),
|
|
141
|
+
recordedAt: top.created_at,
|
|
142
|
+
humanViews: humanByClip.get(top.id) ?? 0,
|
|
143
|
+
agentSessions: agentByClip.get(top.id) ?? 0,
|
|
144
|
+
},
|
|
145
|
+
copy: composeRecapCopy({
|
|
146
|
+
humanViews,
|
|
147
|
+
agentSessions,
|
|
148
|
+
topClip: {
|
|
149
|
+
humanViews: humanByClip.get(top.id) ?? 0,
|
|
150
|
+
completedPct,
|
|
151
|
+
dropOffMs,
|
|
152
|
+
agentBreakdown,
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
console.log(`Sent monthly-recap for ${owner} to ${to}`);
|
|
157
|
+
} finally {
|
|
158
|
+
await sql.end();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
163
|
+
console.error(error instanceof Error ? error.message : error);
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-off: send the two new Clips transactional emails to a real inbox so the
|
|
3
|
+
* rendering can be eyeballed in a mail client. Sample data only — this never
|
|
4
|
+
* touches the job queue or real recordings.
|
|
5
|
+
*
|
|
6
|
+
* pnpm script send-test-emails --to someone@example.com
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getEmailProvider, isEmailConfigured } from "@agent-native/core/server";
|
|
10
|
+
|
|
11
|
+
import { sendClipsTransactionalEmail } from "../server/lib/transactional-email-templates.js";
|
|
12
|
+
|
|
13
|
+
function argValue(args: string[], name: string): string | undefined {
|
|
14
|
+
const index = args.indexOf(`--${name}`);
|
|
15
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default async function main(args: string[]): Promise<void> {
|
|
19
|
+
const to = argValue(args, "to");
|
|
20
|
+
if (!to) throw new Error("--to <email> is required");
|
|
21
|
+
|
|
22
|
+
if (!(await isEmailConfigured())) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"No email provider configured; set SENDGRID_API_KEY or RESEND_API_KEY.",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
console.log(`Provider: ${await getEmailProvider()}`);
|
|
28
|
+
|
|
29
|
+
await sendClipsTransactionalEmail({
|
|
30
|
+
kind: "first-agent-view",
|
|
31
|
+
to,
|
|
32
|
+
recordingId: "demo-agent-view",
|
|
33
|
+
title: "Deploy walkthrough",
|
|
34
|
+
agentName: "Claude",
|
|
35
|
+
});
|
|
36
|
+
console.log(`Sent first-agent-view to ${to}`);
|
|
37
|
+
|
|
38
|
+
await sendClipsTransactionalEmail({
|
|
39
|
+
kind: "monthly-recap",
|
|
40
|
+
to,
|
|
41
|
+
month: "2026-07",
|
|
42
|
+
humanViews: 9,
|
|
43
|
+
agentSessions: 4,
|
|
44
|
+
topClip: {
|
|
45
|
+
recordingId: "demo-recap-top",
|
|
46
|
+
title: "Deploy walkthrough",
|
|
47
|
+
thumbnailUrl: null,
|
|
48
|
+
durationMs: 252_000,
|
|
49
|
+
recordedAt: "2026-07-12T00:00:00.000Z",
|
|
50
|
+
humanViews: 9,
|
|
51
|
+
agentSessions: 4,
|
|
52
|
+
},
|
|
53
|
+
copy: {
|
|
54
|
+
heroLine: "9 people watched your clip. 4 agents read it.",
|
|
55
|
+
agentBreakdown: "3 from Claude · 1 from ChatGPT",
|
|
56
|
+
completionNote: "71% average completion · most stopped at 4:12",
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
console.log(`Sent monthly-recap to ${to}`);
|
|
60
|
+
}
|
|
@@ -414,7 +414,11 @@ export const recordingAgentViews = table(
|
|
|
414
414
|
recordingId: text("recording_id").notNull(),
|
|
415
415
|
// sha256 of user-agent + request IP. Never stores the raw IP.
|
|
416
416
|
agentKey: text("agent_key").notNull(),
|
|
417
|
+
// Null when nothing named the agent — distinct from a self-declared name.
|
|
417
418
|
agentLabel: text("agent_label"),
|
|
419
|
+
// Raw (truncated) user-agent, kept so an unnamed agent stays identifiable
|
|
420
|
+
// and new AGENT_LABELS patterns come from real traffic, not guesses.
|
|
421
|
+
userAgent: text("user_agent"),
|
|
418
422
|
// Time bucket that collapses one agent's burst of context/transcript/frame
|
|
419
423
|
// polls into a single view.
|
|
420
424
|
viewSessionId: text("view_session_id").notNull(),
|
|
@@ -18,6 +18,13 @@ import {
|
|
|
18
18
|
} from "drizzle-orm";
|
|
19
19
|
|
|
20
20
|
import { getDb, schema } from "../db/index.js";
|
|
21
|
+
import {
|
|
22
|
+
computeMonthlyRecap,
|
|
23
|
+
listOwnersWithMonthlyAudience,
|
|
24
|
+
previousRecapMonth,
|
|
25
|
+
recapMonthRange,
|
|
26
|
+
type MonthlyRecap,
|
|
27
|
+
} from "../lib/recap-metrics.js";
|
|
21
28
|
import { ownerEmailMatches } from "../lib/recordings.js";
|
|
22
29
|
import {
|
|
23
30
|
AI_DISPATCH_STALE_MS,
|
|
@@ -26,6 +33,7 @@ import {
|
|
|
26
33
|
type TransactionalEmailJob,
|
|
27
34
|
} from "../lib/transactional-email-store.js";
|
|
28
35
|
import {
|
|
36
|
+
composeRecapCopy,
|
|
29
37
|
sendClipsTransactionalEmail,
|
|
30
38
|
type ClipsTransactionalEmailInput,
|
|
31
39
|
} from "../lib/transactional-email-templates.js";
|
|
@@ -36,6 +44,7 @@ const RECONCILIATION_WRITE_CONCURRENCY = 8;
|
|
|
36
44
|
const RECIPIENT_SHARE_BATCH_SIZE = 2;
|
|
37
45
|
const DELIVERY_BATCH_SIZE = 25;
|
|
38
46
|
const REMINDER_DELAY_MS = 48 * 60 * 60 * 1000;
|
|
47
|
+
const RECAP_SEND_HOUR_UTC = 14;
|
|
39
48
|
const SENDING_LEASE_MS = 2 * 60 * 1000;
|
|
40
49
|
const MAX_ATTEMPTS = 3;
|
|
41
50
|
const RETRY_BASE_DELAY_MS = 60_000;
|
|
@@ -73,6 +82,17 @@ type FirstViewCandidate = CountedView & {
|
|
|
73
82
|
ownerEmail: string;
|
|
74
83
|
};
|
|
75
84
|
|
|
85
|
+
type AgentView = {
|
|
86
|
+
id: string;
|
|
87
|
+
recordingId: string;
|
|
88
|
+
agentLabel: string | null;
|
|
89
|
+
firstSeenAt: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type AgentViewCandidate = AgentView & {
|
|
93
|
+
ownerEmail: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
76
96
|
type ReconciliationCursor = {
|
|
77
97
|
createdAt: string;
|
|
78
98
|
id: string;
|
|
@@ -109,6 +129,7 @@ export type TransactionalEmailStore = Pick<
|
|
|
109
129
|
| "transitionSending"
|
|
110
130
|
| "updateReconciliationCursor"
|
|
111
131
|
| "enqueueOrConvergeFirstImport"
|
|
132
|
+
| "readJob"
|
|
112
133
|
>;
|
|
113
134
|
|
|
114
135
|
export interface TransactionalEmailRepository {
|
|
@@ -133,6 +154,15 @@ export interface TransactionalEmailRepository {
|
|
|
133
154
|
cursor: ReconciliationCursor | null,
|
|
134
155
|
limit: number,
|
|
135
156
|
): Promise<FirstViewCandidate[]>;
|
|
157
|
+
listAgentViews(
|
|
158
|
+
enabledAt: string,
|
|
159
|
+
cursor: ReconciliationCursor | null,
|
|
160
|
+
limit: number,
|
|
161
|
+
): Promise<AgentViewCandidate[]>;
|
|
162
|
+
getFirstOwnerAgentView(
|
|
163
|
+
ownerEmail: string,
|
|
164
|
+
enabledAt: string,
|
|
165
|
+
): Promise<AgentView | null>;
|
|
136
166
|
listReadyImports(
|
|
137
167
|
enabledAt: string,
|
|
138
168
|
cursor: ReconciliationCursor | null,
|
|
@@ -164,6 +194,11 @@ export interface TransactionalEmailRepository {
|
|
|
164
194
|
recipient: string,
|
|
165
195
|
enabledAt: string,
|
|
166
196
|
): Promise<boolean>;
|
|
197
|
+
listOwnersWithMonthlyAudience(month: string): Promise<string[]>;
|
|
198
|
+
computeMonthlyRecap(
|
|
199
|
+
ownerEmail: string,
|
|
200
|
+
month: string,
|
|
201
|
+
): Promise<MonthlyRecap | null>;
|
|
167
202
|
}
|
|
168
203
|
|
|
169
204
|
export interface TransactionalEmailWorkerDependencies {
|
|
@@ -372,6 +407,69 @@ function defaultRepository(): TransactionalEmailRepository {
|
|
|
372
407
|
)
|
|
373
408
|
.limit(limit);
|
|
374
409
|
},
|
|
410
|
+
async listAgentViews(enabledAt, cursor, limit) {
|
|
411
|
+
return db
|
|
412
|
+
.select({
|
|
413
|
+
id: schema.recordingAgentViews.id,
|
|
414
|
+
recordingId: schema.recordingAgentViews.recordingId,
|
|
415
|
+
agentLabel: schema.recordingAgentViews.agentLabel,
|
|
416
|
+
firstSeenAt: schema.recordingAgentViews.firstSeenAt,
|
|
417
|
+
ownerEmail: schema.recordings.ownerEmail,
|
|
418
|
+
})
|
|
419
|
+
.from(schema.recordingAgentViews)
|
|
420
|
+
.innerJoin(
|
|
421
|
+
schema.recordings,
|
|
422
|
+
eq(schema.recordings.id, schema.recordingAgentViews.recordingId),
|
|
423
|
+
)
|
|
424
|
+
.where(
|
|
425
|
+
and(
|
|
426
|
+
gte(schema.recordingAgentViews.firstSeenAt, enabledAt),
|
|
427
|
+
cursor
|
|
428
|
+
? or(
|
|
429
|
+
gt(schema.recordingAgentViews.firstSeenAt, cursor.createdAt),
|
|
430
|
+
and(
|
|
431
|
+
eq(
|
|
432
|
+
schema.recordingAgentViews.firstSeenAt,
|
|
433
|
+
cursor.createdAt,
|
|
434
|
+
),
|
|
435
|
+
gt(schema.recordingAgentViews.id, cursor.id),
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
: undefined,
|
|
439
|
+
),
|
|
440
|
+
)
|
|
441
|
+
.orderBy(
|
|
442
|
+
asc(schema.recordingAgentViews.firstSeenAt),
|
|
443
|
+
asc(schema.recordingAgentViews.id),
|
|
444
|
+
)
|
|
445
|
+
.limit(limit);
|
|
446
|
+
},
|
|
447
|
+
async getFirstOwnerAgentView(ownerEmail, enabledAt) {
|
|
448
|
+
const [view] = await db
|
|
449
|
+
.select({
|
|
450
|
+
id: schema.recordingAgentViews.id,
|
|
451
|
+
recordingId: schema.recordingAgentViews.recordingId,
|
|
452
|
+
agentLabel: schema.recordingAgentViews.agentLabel,
|
|
453
|
+
firstSeenAt: schema.recordingAgentViews.firstSeenAt,
|
|
454
|
+
})
|
|
455
|
+
.from(schema.recordingAgentViews)
|
|
456
|
+
.innerJoin(
|
|
457
|
+
schema.recordings,
|
|
458
|
+
eq(schema.recordings.id, schema.recordingAgentViews.recordingId),
|
|
459
|
+
)
|
|
460
|
+
.where(
|
|
461
|
+
and(
|
|
462
|
+
ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
|
|
463
|
+
gte(schema.recordingAgentViews.firstSeenAt, enabledAt),
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
.orderBy(
|
|
467
|
+
asc(schema.recordingAgentViews.firstSeenAt),
|
|
468
|
+
asc(schema.recordingAgentViews.id),
|
|
469
|
+
)
|
|
470
|
+
.limit(1);
|
|
471
|
+
return view ?? null;
|
|
472
|
+
},
|
|
375
473
|
async listReadyImports(enabledAt, cursor, limit) {
|
|
376
474
|
return db
|
|
377
475
|
.select({
|
|
@@ -524,6 +622,8 @@ function defaultRepository(): TransactionalEmailRepository {
|
|
|
524
622
|
.limit(1);
|
|
525
623
|
return view ?? null;
|
|
526
624
|
},
|
|
625
|
+
listOwnersWithMonthlyAudience,
|
|
626
|
+
computeMonthlyRecap,
|
|
527
627
|
async isFirstImport(recording, recipient, enabledAt) {
|
|
528
628
|
if (!isImportedRecording(recording)) return false;
|
|
529
629
|
const imports = await db
|
|
@@ -700,6 +800,41 @@ async function reconcileFirstViews(
|
|
|
700
800
|
};
|
|
701
801
|
}
|
|
702
802
|
|
|
803
|
+
async function reconcileFirstAgentViews(
|
|
804
|
+
repository: TransactionalEmailRepository,
|
|
805
|
+
store: TransactionalEmailStore,
|
|
806
|
+
enabledAt: string,
|
|
807
|
+
cursor: ReconciliationCursor | null,
|
|
808
|
+
limit: number,
|
|
809
|
+
): Promise<{ enqueued: number; nextCursor: ReconciliationCursor | null }> {
|
|
810
|
+
const page = await repository.listAgentViews(enabledAt, cursor, limit);
|
|
811
|
+
let enqueued = 0;
|
|
812
|
+
for (const candidate of page) {
|
|
813
|
+
const ownerEmail = normalizedEmail(candidate.ownerEmail);
|
|
814
|
+
if (!ownerEmail || isSuppressedTransactionalRecipient(ownerEmail)) continue;
|
|
815
|
+
const firstAgentView = await repository.getFirstOwnerAgentView(
|
|
816
|
+
ownerEmail,
|
|
817
|
+
enabledAt,
|
|
818
|
+
);
|
|
819
|
+
if (!firstAgentView || firstAgentView.id !== candidate.id) continue;
|
|
820
|
+
const result = await store.enqueue(`first-agent-view:${ownerEmail}`, {
|
|
821
|
+
type: "first-agent-view",
|
|
822
|
+
recipient: ownerEmail,
|
|
823
|
+
recordingIds: [candidate.recordingId],
|
|
824
|
+
requestedBy: ownerEmail,
|
|
825
|
+
});
|
|
826
|
+
if (result.created) enqueued += 1;
|
|
827
|
+
}
|
|
828
|
+
const lastView = page[page.length - 1];
|
|
829
|
+
return {
|
|
830
|
+
enqueued,
|
|
831
|
+
nextCursor:
|
|
832
|
+
page.length >= limit && lastView
|
|
833
|
+
? { createdAt: lastView.firstSeenAt, id: lastView.id }
|
|
834
|
+
: null,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
703
838
|
async function reconcileFirstImports(
|
|
704
839
|
repository: TransactionalEmailRepository,
|
|
705
840
|
store: TransactionalEmailStore,
|
|
@@ -732,6 +867,49 @@ async function reconcileFirstImports(
|
|
|
732
867
|
};
|
|
733
868
|
}
|
|
734
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Recaps close on the UTC month boundary but wait until `RECAP_SEND_HOUR_UTC`
|
|
872
|
+
* on the 1st so they land mid-morning in the Americas rather than at midnight.
|
|
873
|
+
* A month is only ever enqueued once per owner, so a late first run of the day
|
|
874
|
+
* still sends rather than skipping the month.
|
|
875
|
+
*/
|
|
876
|
+
async function reconcileMonthlyRecaps(
|
|
877
|
+
repository: TransactionalEmailRepository,
|
|
878
|
+
store: TransactionalEmailStore,
|
|
879
|
+
enabledAt: string,
|
|
880
|
+
now: Date,
|
|
881
|
+
): Promise<number> {
|
|
882
|
+
if (now.getUTCHours() < RECAP_SEND_HOUR_UTC) return 0;
|
|
883
|
+
const month = previousRecapMonth(now);
|
|
884
|
+
// Never recap a month that closed before transactional email was switched
|
|
885
|
+
// on. A month still open at that point does get a recap: the audience it
|
|
886
|
+
// reports is the owner's own, and skipping it would cost them a full month.
|
|
887
|
+
if (recapMonthRange(month).endAt <= enabledAt) return 0;
|
|
888
|
+
|
|
889
|
+
let enqueued = 0;
|
|
890
|
+
for (const ownerEmail of await repository.listOwnersWithMonthlyAudience(
|
|
891
|
+
month,
|
|
892
|
+
)) {
|
|
893
|
+
const recipient = normalizedEmail(ownerEmail);
|
|
894
|
+
if (!recipient || isSuppressedTransactionalRecipient(recipient)) continue;
|
|
895
|
+
const logicalKey = `monthly-recap:${recipient}:${month}`;
|
|
896
|
+
// Checked before the analytics work: this pass reruns every minute for the
|
|
897
|
+
// rest of the month, and recomputing a recap already queued is pure waste.
|
|
898
|
+
if (await store.readJob(logicalKey)) continue;
|
|
899
|
+
const recap = await repository.computeMonthlyRecap(recipient, month);
|
|
900
|
+
if (!recap) continue;
|
|
901
|
+
const result = await store.enqueue(logicalKey, {
|
|
902
|
+
type: "monthly-recap",
|
|
903
|
+
recipient,
|
|
904
|
+
recordingIds: [recap.topClip.recordingId],
|
|
905
|
+
requestedBy: recipient,
|
|
906
|
+
month,
|
|
907
|
+
});
|
|
908
|
+
if (result.created) enqueued += 1;
|
|
909
|
+
}
|
|
910
|
+
return enqueued;
|
|
911
|
+
}
|
|
912
|
+
|
|
735
913
|
async function makeSendInput(
|
|
736
914
|
job: TransactionalEmailJob,
|
|
737
915
|
repository: TransactionalEmailRepository,
|
|
@@ -739,6 +917,45 @@ async function makeSendInput(
|
|
|
739
917
|
): Promise<ClipsTransactionalEmailInput | null> {
|
|
740
918
|
const recipient = normalizedEmail(job.recipient);
|
|
741
919
|
if (!recipient || isSuppressedTransactionalRecipient(recipient)) return null;
|
|
920
|
+
|
|
921
|
+
if (job.type === "monthly-recap") {
|
|
922
|
+
// Ranked again at send time instead of trusting the queued clip: a month
|
|
923
|
+
// whose top clip was trashed or overtaken still deserves its recap, and
|
|
924
|
+
// the analytics already exclude clips the owner can no longer open.
|
|
925
|
+
if (!job.month) return null;
|
|
926
|
+
const recap = await repository.computeMonthlyRecap(recipient, job.month);
|
|
927
|
+
if (!recap) return null;
|
|
928
|
+
return {
|
|
929
|
+
kind: "monthly-recap",
|
|
930
|
+
to: recipient,
|
|
931
|
+
month: job.month,
|
|
932
|
+
humanViews: recap.humanViews,
|
|
933
|
+
agentSessions: recap.agentSessions,
|
|
934
|
+
topClip: {
|
|
935
|
+
recordingId: recap.topClip.recordingId,
|
|
936
|
+
title: recap.topClip.title,
|
|
937
|
+
thumbnailUrl: recap.topClip.thumbnailUrl,
|
|
938
|
+
durationMs: recap.topClip.durationMs,
|
|
939
|
+
recordedAt: recap.topClip.recordedAt,
|
|
940
|
+
humanViews: recap.topClip.humanViews,
|
|
941
|
+
agentSessions: recap.topClip.agentSessions,
|
|
942
|
+
},
|
|
943
|
+
copy: composeRecapCopy({
|
|
944
|
+
humanViews: recap.humanViews,
|
|
945
|
+
agentSessions: recap.agentSessions,
|
|
946
|
+
topClip: {
|
|
947
|
+
humanViews: recap.topClip.humanViews,
|
|
948
|
+
completedPct: recap.topClip.completedPct,
|
|
949
|
+
dropOffMs: recap.topClip.dropOffMs,
|
|
950
|
+
agentBreakdown: recap.topClip.agentBreakdown.map((entry) => ({
|
|
951
|
+
agentLabel: entry.agentLabel,
|
|
952
|
+
sessions: entry.sessions,
|
|
953
|
+
})),
|
|
954
|
+
},
|
|
955
|
+
}),
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
742
959
|
const recordings = await Promise.all(
|
|
743
960
|
job.recordingIds.map((recordingId) => repository.getRecording(recordingId)),
|
|
744
961
|
);
|
|
@@ -788,6 +1005,24 @@ async function makeSendInput(
|
|
|
788
1005
|
};
|
|
789
1006
|
}
|
|
790
1007
|
|
|
1008
|
+
if (job.type === "first-agent-view") {
|
|
1009
|
+
if (normalizedEmail(recordings[0].ownerEmail) !== recipient) return null;
|
|
1010
|
+
const firstAgentView = await repository.getFirstOwnerAgentView(
|
|
1011
|
+
recipient,
|
|
1012
|
+
enabledAt,
|
|
1013
|
+
);
|
|
1014
|
+
if (!firstAgentView || firstAgentView.recordingId !== recordings[0].id) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
return {
|
|
1018
|
+
kind: "first-agent-view",
|
|
1019
|
+
to: recipient,
|
|
1020
|
+
recordingId: recordings[0].id,
|
|
1021
|
+
title: recordings[0].title,
|
|
1022
|
+
agentName: firstAgentView.agentLabel,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
791
1026
|
if (job.type === "first-import") {
|
|
792
1027
|
if (
|
|
793
1028
|
normalizedEmail(recordings[0].ownerEmail) !== recipient ||
|
|
@@ -889,6 +1124,18 @@ export async function runTransactionalEmailsOnce(
|
|
|
889
1124
|
"firstViewCursor",
|
|
890
1125
|
firstViews.nextCursor,
|
|
891
1126
|
);
|
|
1127
|
+
const firstAgentViews = await reconcileFirstAgentViews(
|
|
1128
|
+
repository,
|
|
1129
|
+
store,
|
|
1130
|
+
config.enabledAt,
|
|
1131
|
+
config.firstAgentViewCursor ?? null,
|
|
1132
|
+
reconciliationLimit,
|
|
1133
|
+
);
|
|
1134
|
+
result.enqueued += firstAgentViews.enqueued;
|
|
1135
|
+
await store.updateReconciliationCursor(
|
|
1136
|
+
"firstAgentViewCursor",
|
|
1137
|
+
firstAgentViews.nextCursor,
|
|
1138
|
+
);
|
|
892
1139
|
const firstImports = await reconcileFirstImports(
|
|
893
1140
|
repository,
|
|
894
1141
|
store,
|
|
@@ -902,6 +1149,13 @@ export async function runTransactionalEmailsOnce(
|
|
|
902
1149
|
firstImports.nextCursor,
|
|
903
1150
|
);
|
|
904
1151
|
|
|
1152
|
+
result.enqueued += await reconcileMonthlyRecaps(
|
|
1153
|
+
repository,
|
|
1154
|
+
store,
|
|
1155
|
+
config.enabledAt,
|
|
1156
|
+
currentTime,
|
|
1157
|
+
);
|
|
1158
|
+
|
|
905
1159
|
const jobs = await store.listJobs();
|
|
906
1160
|
const warn = dependencies.warn ?? console.warn;
|
|
907
1161
|
for (const job of jobs) {
|
|
@@ -921,7 +1175,9 @@ export async function runTransactionalEmailsOnce(
|
|
|
921
1175
|
(job) =>
|
|
922
1176
|
job.state === "pending" &&
|
|
923
1177
|
(job.type === "first-view" ||
|
|
1178
|
+
job.type === "first-agent-view" ||
|
|
924
1179
|
job.type === "first-import" ||
|
|
1180
|
+
job.type === "monthly-recap" ||
|
|
925
1181
|
job.type === "unviewed-reminder"),
|
|
926
1182
|
),
|
|
927
1183
|
(job) => store.transition(job.logicalKey, ["pending"], "ready"),
|