Skip to content

Commit c4b9e62

Browse files
committed
Fix follow-up cron wiring
1 parent 938d6cf commit c4b9e62

7 files changed

Lines changed: 140 additions & 14 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ For local-only test credentials, prefer `apps/web/.env.test.local`, which is git
9292
- `GOOGLE_OAUTH_REDIRECT_URI`: OAuth callback URL for Google Calendar linking
9393
- `GOOGLE_LINK_TOKEN_SECRET`: secret used only for one-time Telegram-to-browser Google link handoff tokens
9494
- `GOOGLE_CALENDAR_TOKEN_ENCRYPTION_KEY`: base64-encoded 32-byte key used to encrypt stored Google access and refresh tokens
95-
- `CRON_SECRET`: bearer token required for protected cron routes such as Google Calendar reconciliation
95+
- `CRON_SECRET`: bearer token required for protected cron routes such as Google Calendar reconciliation and follow-up dispatch
9696

9797
To register the production Telegram webhook once those values are set, also export `ATLAS_WEBHOOK_URL` as the full deployed route URL and run `pnpm telegram:webhook:set`.
9898

@@ -106,6 +106,8 @@ Atlas no longer exposes public planner/debug mutation routes. The intended exter
106106
- `GET /api/google-calendar/oauth/callback`
107107
- protected cron routes that require `Authorization: Bearer $CRON_SECRET`
108108

109+
The production deployment schedules follow-up dispatch on Vercel every 10 minutes via `GET /api/cron/send-followups`.
110+
109111
## How We Work
110112

111113
This repo is designed for human-plus-agent collaboration.

apps/web/src/app/api/cron/send-followups/route.test.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

3-
import { POST } from "./route";
3+
import { GET, POST } from "./route";
44

55
const { runBundledFollowUpsMock } = vi.hoisted(() => ({
66
runBundledFollowUpsMock: vi.fn()
@@ -24,7 +24,7 @@ describe("send followups cron route", () => {
2424
it("rejects unauthenticated cron requests", async () => {
2525
process.env.CRON_SECRET = "cron-secret";
2626

27-
const response = await POST(new Request("http://localhost/api/cron/send-followups", { method: "POST" }));
27+
const response = await GET(new Request("http://localhost/api/cron/send-followups"));
2828

2929
expect(response.status).toBe(401);
3030
await expect(response.json()).resolves.toMatchObject({
@@ -34,7 +34,7 @@ describe("send followups cron route", () => {
3434
});
3535

3636
it("fails closed when the cron secret is not configured", async () => {
37-
const response = await POST(new Request("http://localhost/api/cron/send-followups", { method: "POST" }));
37+
const response = await GET(new Request("http://localhost/api/cron/send-followups"));
3838

3939
expect(response.status).toBe(503);
4040
await expect(response.json()).resolves.toMatchObject({
@@ -43,7 +43,24 @@ describe("send followups cron route", () => {
4343
});
4444
});
4545

46-
it("runs followups for authorized cron requests", async () => {
46+
it("runs followups for authorized GET cron requests", async () => {
47+
process.env.CRON_SECRET = "cron-secret";
48+
49+
const response = await GET(
50+
new Request("http://localhost/api/cron/send-followups", {
51+
headers: { authorization: "Bearer cron-secret" }
52+
})
53+
);
54+
55+
expect(response.status).toBe(200);
56+
await expect(response.json()).resolves.toMatchObject({
57+
accepted: true,
58+
sentBundles: 0
59+
});
60+
expect(runBundledFollowUpsMock).toHaveBeenCalledTimes(1);
61+
});
62+
63+
it("also accepts authorized POST requests", async () => {
4764
process.env.CRON_SECRET = "cron-secret";
4865

4966
const response = await POST(

apps/web/src/app/api/cron/send-followups/route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function isAuthorized(request: Request) {
3131
return { ok: true as const };
3232
}
3333

34-
export async function POST(request: Request) {
34+
async function handleRequest(request: Request) {
3535
const auth = isAuthorized(request);
3636

3737
if (!auth.ok) {
@@ -40,3 +40,11 @@ export async function POST(request: Request) {
4040

4141
return jsonOk(await runBundledFollowUps());
4242
}
43+
44+
export async function GET(request: Request) {
45+
return handleRequest(request);
46+
}
47+
48+
export async function POST(request: Request) {
49+
return handleRequest(request);
50+
}

apps/web/src/lib/server/follow-up.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
listTasksForTests,
77
resetInboxProcessingStoreForTests,
88
resetIncomingTelegramIngressStoreForTests,
9-
seedInboxItemForProcessingTests
9+
seedInboxItemForProcessingTests,
10+
type FollowUpRuntimeStore
1011
} from "@atlas/db";
1112

1213
import { runBundledFollowUps } from "./follow-up";
@@ -187,4 +188,87 @@ describe("runBundledFollowUps", () => {
187188
taskIds: [...initialTaskIds, ...laterTaskIds]
188189
});
189190
});
191+
192+
it("deduplicates identical follow-up bundles across repeated runner attempts", async () => {
193+
const reservedKeys = new Set<string>();
194+
const deliveryStore = {
195+
reserveOutgoingIfAbsent: vi.fn(async (event) => {
196+
if (reservedKeys.has(event.idempotencyKey)) {
197+
return { status: "duplicate" as const };
198+
}
199+
200+
reservedKeys.add(event.idempotencyKey);
201+
return { status: "reserved" as const, eventId: "event-1" };
202+
}),
203+
updateOutgoing: vi.fn(async () => undefined)
204+
};
205+
const sender = vi.fn().mockResolvedValue({
206+
ok: true,
207+
result: {
208+
message_id: 88,
209+
date: 1_700_000_000,
210+
chat: {
211+
id: "123",
212+
type: "private"
213+
},
214+
text: "sent"
215+
}
216+
});
217+
const dueTask = {
218+
id: "task-1",
219+
userId: "123",
220+
sourceInboxItemId: "inbox-a",
221+
lastInboxItemId: "inbox-a",
222+
title: "Review launch checklist",
223+
lifecycleState: "scheduled" as const,
224+
externalCalendarEventId: null,
225+
externalCalendarId: null,
226+
scheduledStartAt: "2026-03-20T16:00:00.000Z",
227+
scheduledEndAt: "2026-03-20T17:00:00.000Z",
228+
calendarSyncStatus: "in_sync" as const,
229+
calendarSyncUpdatedAt: null,
230+
rescheduleCount: 0,
231+
lastFollowupAt: null,
232+
followupReminderSentAt: null,
233+
completedAt: null,
234+
archivedAt: null,
235+
priority: "medium" as const,
236+
urgency: "medium" as const,
237+
createdAt: "2026-03-20T16:00:00.000Z",
238+
dueType: "initial" as const
239+
};
240+
const store: FollowUpRuntimeStore = {
241+
listDueFollowUpTasks: vi.fn(async () => [dueTask]),
242+
listOutstandingFollowUpTasks: vi.fn(async () => []),
243+
hasInFlightInboxItem: vi.fn(async () => false),
244+
markFollowUpSent: vi.fn(async () => undefined),
245+
markFollowUpReminderSent: vi.fn(async () => undefined)
246+
};
247+
248+
await runBundledFollowUps("2026-03-20T19:00:00.000Z", {
249+
store,
250+
deliveryStore,
251+
sender
252+
});
253+
await runBundledFollowUps("2026-03-20T19:00:00.000Z", {
254+
store,
255+
deliveryStore,
256+
sender
257+
});
258+
259+
expect(sender).toHaveBeenCalledTimes(1);
260+
expect(deliveryStore.reserveOutgoingIfAbsent).toHaveBeenCalledTimes(2);
261+
expect(deliveryStore.reserveOutgoingIfAbsent).toHaveBeenNthCalledWith(
262+
1,
263+
expect.objectContaining({
264+
idempotencyKey: "telegram:followup:inbox-item:initial:task-1"
265+
})
266+
);
267+
expect(deliveryStore.reserveOutgoingIfAbsent).toHaveBeenNthCalledWith(
268+
2,
269+
expect.objectContaining({
270+
idempotencyKey: "telegram:followup:inbox-item:initial:task-1"
271+
})
272+
);
273+
});
190274
});

apps/web/src/lib/server/follow-up.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,14 @@ export async function runBundledFollowUps(
5151
const outstanding = await store.listOutstandingFollowUpTasks(userId);
5252
const initialTaskIds = new Set(initialTasks.map((task) => task.id));
5353
const reminderTaskIds = new Set(reminderTasks.map((task) => task.id));
54-
const bundleTasks = mergeFollowUpBundleTasks(outstanding, initialTasks);
54+
const bundleTasks = mergeFollowUpBundleTasks(outstanding, initialTasks, reminderTasks);
5555

5656
if (bundleTasks.length === 0) {
5757
continue;
5858
}
5959

6060
const bundle = buildFollowUpBundle(bundleTasks, initialTasks.length > 0 ? "initial" : "reminder");
61-
const delivery = await sendFollowUpBundle(userId, bundle, now, dependencies);
61+
const delivery = await sendFollowUpBundle(userId, bundle, dependencies);
6262

6363
if (delivery.status === "failed") {
6464
continue;
@@ -84,7 +84,8 @@ export async function runBundledFollowUps(
8484

8585
function mergeFollowUpBundleTasks(
8686
outstanding: Task[],
87-
initialTasks: FollowUpDueTask[]
87+
initialTasks: FollowUpDueTask[],
88+
reminderTasks: FollowUpDueTask[]
8889
) {
8990
const tasksById = new Map<string, Pick<Task, "id" | "title" | "scheduledEndAt">>();
9091

@@ -98,6 +99,12 @@ function mergeFollowUpBundleTasks(
9899
}
99100
}
100101

102+
for (const task of reminderTasks) {
103+
if (!tasksById.has(task.id)) {
104+
tasksById.set(task.id, task);
105+
}
106+
}
107+
101108
return Array.from(tasksById.values());
102109
}
103110

@@ -138,7 +145,6 @@ export function buildFollowUpBundle(tasks: Pick<Task, "id" | "title" | "schedule
138145
async function sendFollowUpBundle(
139146
userId: string,
140147
bundle: RenderedFollowUpBundle,
141-
now: string,
142148
dependencies: FollowUpRunnerDependencies
143149
) {
144150
const chatId = userId;
@@ -148,7 +154,7 @@ async function sendFollowUpBundle(
148154
userId,
149155
chatId,
150156
text: bundle.text,
151-
idempotencyKey: `${buildTelegramFollowUpIdempotencyKey(`${bundle.kind}:${bundle.taskIds.join(",")}:${now}`)}:${bundle.taskIds.length}`,
157+
idempotencyKey: buildTelegramFollowUpIdempotencyKey(`${bundle.kind}:${bundle.taskIds.join(",")}`),
152158
bundle
153159
},
154160
{

docs/workflows/production-deploy-checklist.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,11 @@ curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo"
109109

110110
### Cron protection
111111

112-
1. Call the reconcile route without `Authorization: Bearer $CRON_SECRET` and confirm it is rejected.
113-
2. Call the same route with the correct bearer token and confirm it succeeds.
112+
1. Confirm Vercel Cron Jobs includes `/api/cron/send-followups` on the production deployment.
113+
2. Call the follow-up cron route without `Authorization: Bearer $CRON_SECRET` and confirm it is rejected.
114+
3. Call the follow-up cron route with the correct bearer token and confirm it succeeds.
115+
4. Call the reconcile route without `Authorization: Bearer $CRON_SECRET` and confirm it is rejected.
116+
5. Call the reconcile route with the correct bearer token and confirm it succeeds.
114117

115118
### Basic safety checks
116119

vercel.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
{
22
"$schema": "https://openapi.vercel.sh/vercel.json",
3+
"crons": [
4+
{
5+
"path": "/api/cron/send-followups",
6+
"schedule": "*/10 * * * *"
7+
}
8+
],
39
"buildCommand": "pnpm build",
410
"installCommand": "pnpm install --frozen-lockfile"
511
}

0 commit comments

Comments
 (0)