fix(coach): move check-ins when the user's time zone changes
The coach check-ins are main-bot routines scheduled in the profile time zone, but a later zone change (About you, onboarding, the main bot's update_user_profile tool, or forgetting the whole profile) left them in the old zone until the rhythm was saved again, so they fired at the wrong local time. syncCoachTimeZone, next to the rhythm code, moves the main bot's coach routines that are not in the current profile zone into it, recomputes the next run of armed ones and re-arms their wakeup. Paused check-ins move but stay paused, routines the user made are never touched, users without check-ins get none, and a save that leaves the zone as it is changes nothing. The profile update and forget RPCs and the profile tool call it whenever they write the zone. Quiet hours, the daily nudge cap and coach dates are evaluated in the profile zone when they are used, so they need no migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
aea604f7ca
commit
68f9dfb34b
@ -619,10 +619,10 @@ export function createRouter(deps: RouterDeps) {
|
||||
profile: {
|
||||
get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)),
|
||||
update: authed.profile.update.handler(({ context, input }) =>
|
||||
updateProfile(deps.prisma, context.actor, input),
|
||||
updateProfile(deps, context.actor, input),
|
||||
),
|
||||
forget: authed.profile.forget.handler(({ context, input }) =>
|
||||
forgetProfile(deps.prisma, context.actor, input),
|
||||
forgetProfile(deps, context.actor, input),
|
||||
),
|
||||
},
|
||||
coach: {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { CoachRhythmDeps } from "@rakazo/adapters";
|
||||
import { syncCoachTimeZone } from "@rakazo/adapters";
|
||||
import type {
|
||||
Actor,
|
||||
ForgetUserFacts,
|
||||
@ -29,32 +31,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise<UserProf
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
prisma: PrismaClient,
|
||||
deps: CoachRhythmDeps,
|
||||
actor: Actor,
|
||||
input: UpdateUserProfile,
|
||||
): Promise<UserProfileState> {
|
||||
try {
|
||||
await updateUserProfile(prisma, actor.userId, input);
|
||||
await updateUserProfile(deps.prisma, actor.userId, input);
|
||||
} catch (error) {
|
||||
throw toRpcError(error);
|
||||
}
|
||||
return getUserProfileState(prisma, actor.userId);
|
||||
if (input.timeZone !== undefined) await syncCoachTimeZone(deps, actor.userId);
|
||||
return getUserProfileState(deps.prisma, actor.userId);
|
||||
}
|
||||
|
||||
/** Unknown ids are ignored, so a caller cannot probe another user's facts. */
|
||||
export async function forgetProfile(
|
||||
prisma: PrismaClient,
|
||||
deps: CoachRhythmDeps,
|
||||
actor: Actor,
|
||||
input: ForgetUserFacts,
|
||||
): Promise<UserProfileState> {
|
||||
await forgetUserFacts(
|
||||
prisma,
|
||||
actor.userId,
|
||||
input.factIds
|
||||
const target = input.factIds
|
||||
? { factIds: input.factIds }
|
||||
: input.category
|
||||
? { category: input.category }
|
||||
: { all: true },
|
||||
);
|
||||
return getUserProfileState(prisma, actor.userId);
|
||||
: { all: true as const };
|
||||
await forgetUserFacts(deps.prisma, actor.userId, target);
|
||||
// Forgetting everything clears the time zone too.
|
||||
if ("all" in target) await syncCoachTimeZone(deps, actor.userId);
|
||||
return getUserProfileState(deps.prisma, actor.userId);
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { routineJobKey } from "@rakazo/adapter-kit";
|
||||
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
|
||||
import { nextCronDate } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { COACH_ROUTINES, getCoachRhythm, setCoachRhythm } from "./coach-rhythm.js";
|
||||
import { updateUserProfileFromTool } from "./user-profile-tools.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const describePostgres =
|
||||
@ -179,4 +181,114 @@ describePostgres("coach rhythm (PostgreSQL)", () => {
|
||||
expect(await coachRoutines(userId)).toEqual([]);
|
||||
expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(routine!.id));
|
||||
});
|
||||
|
||||
it("moves the check-ins, and only them, when the profile time zone changes", async () => {
|
||||
const userId = await newUser("Europe/Vienna");
|
||||
const jobs = fakeJobs();
|
||||
const deps = { prisma, jobs };
|
||||
await setCoachRhythm(deps, {
|
||||
userId,
|
||||
rhythm: { morning: "07:30", evening: "21:00", weekly: { day: 0, time: "18:00" } },
|
||||
});
|
||||
const conversation = await ensureMainConversation(prisma, userId);
|
||||
const evening = await prisma.routine.findFirstOrThrow({
|
||||
where: { userId, coachKey: "evening" },
|
||||
});
|
||||
await prisma.routine.update({
|
||||
where: { id: evening.id },
|
||||
data: { active: false, nextRunAt: null },
|
||||
});
|
||||
const own = await prisma.routine.create({
|
||||
data: {
|
||||
botId: conversation.leadBotId,
|
||||
userId,
|
||||
spaceId: conversation.spaceId,
|
||||
name: "Water the plants",
|
||||
prompt: "Remind me to water the plants.",
|
||||
crons: ["0 9 * * *"],
|
||||
timezone: "Europe/Vienna",
|
||||
active: true,
|
||||
nextRunAt: nextCronDate("0 9 * * *", new Date(), "Europe/Vienna"),
|
||||
},
|
||||
});
|
||||
const setZone = (time_zone: string) =>
|
||||
updateUserProfileFromTool(
|
||||
deps,
|
||||
{
|
||||
userId,
|
||||
bot: { id: conversation.leadBotId, kind: "main" },
|
||||
threadId: conversation.threadId,
|
||||
sourceMessageId: null,
|
||||
},
|
||||
{ time_zone },
|
||||
);
|
||||
const before = await coachRoutines(userId);
|
||||
|
||||
// Saving the zone the check-ins already use changes nothing.
|
||||
jobs.enqueue.mockClear();
|
||||
expect(await setZone("Europe/Vienna")).toMatchObject({ ok: true });
|
||||
expect(await coachRoutines(userId)).toEqual(before);
|
||||
expect(jobs.enqueue).not.toHaveBeenCalled();
|
||||
|
||||
expect(await setZone("America/New_York")).toMatchObject({ ok: true });
|
||||
const moved = await coachRoutines(userId);
|
||||
expect(moved.map((routine) => [routine.coachKey, routine.timezone])).toEqual([
|
||||
["evening", "America/New_York"],
|
||||
["morning", "America/New_York"],
|
||||
["weekly", "America/New_York"],
|
||||
]);
|
||||
const localTime = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "America/New_York",
|
||||
weekday: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).format(date);
|
||||
const [pausedEvening, morning, weekly] = moved;
|
||||
expect(localTime(morning!.nextRunAt!)).toMatch(/ 07:30$/);
|
||||
expect(localTime(weekly!.nextRunAt!)).toBe("Sun 18:00");
|
||||
for (const routine of [morning!, weekly!]) {
|
||||
expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(routine.nextRunAt).not.toEqual(before.find((old) => old.id === routine.id)?.nextRunAt);
|
||||
}
|
||||
expect(jobs.enqueue).toHaveBeenCalledTimes(2);
|
||||
expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(morning!.id, morning!.nextRunAt!));
|
||||
expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(weekly!.id, weekly!.nextRunAt!));
|
||||
// A paused check-in moves but stays paused; the user's own routine keeps its zone.
|
||||
expect(pausedEvening).toMatchObject({ active: false, nextRunAt: null });
|
||||
expect(await prisma.routine.findUniqueOrThrow({ where: { id: own.id } })).toEqual(own);
|
||||
expect(await getCoachRhythm(prisma, userId)).toEqual({
|
||||
morning: "07:30",
|
||||
evening: null,
|
||||
weekly: { day: 0, time: "18:00" },
|
||||
});
|
||||
|
||||
// Saving the new zone again changes nothing.
|
||||
jobs.enqueue.mockClear();
|
||||
await setZone("America/New_York");
|
||||
expect(await coachRoutines(userId)).toEqual(moved);
|
||||
expect(jobs.enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates no check-ins for a user without any when the time zone changes", async () => {
|
||||
const userId = await newUser("Europe/Vienna");
|
||||
const jobs = fakeJobs();
|
||||
const main = await ensureMainConversation(prisma, userId);
|
||||
await updateUserProfileFromTool(
|
||||
{ prisma, jobs },
|
||||
{
|
||||
userId,
|
||||
bot: { id: main.leadBotId, kind: "main" },
|
||||
threadId: main.threadId,
|
||||
sourceMessageId: null,
|
||||
},
|
||||
{ time_zone: "Asia/Tokyo" },
|
||||
);
|
||||
expect(await prisma.userProfile.findUnique({ where: { userId } })).toMatchObject({
|
||||
timeZone: "Asia/Tokyo",
|
||||
});
|
||||
expect(await prisma.routine.count({ where: { userId } })).toBe(0);
|
||||
expect(jobs.enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
clockTimeToMinutes,
|
||||
minutesToClockTime,
|
||||
} from "@rakazo/contracts";
|
||||
import { nextCronDate } from "@rakazo/core";
|
||||
import { nextCronDate, nextCronDateAcross } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db";
|
||||
|
||||
@ -146,3 +146,32 @@ export async function setCoachRhythm(
|
||||
}
|
||||
return getCoachRhythm(prisma, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the main bot's check-ins into the user's current profile time zone and re-arms each
|
||||
* armed one for its next local run. Check-ins already in that zone and routines the user made
|
||||
* stay as they are, so calling it after every time zone save is safe.
|
||||
*/
|
||||
export async function syncCoachTimeZone(deps: CoachRhythmDeps, userId: string): Promise<void> {
|
||||
const { prisma, jobs } = deps;
|
||||
const botId = await findMainBotId(prisma, userId);
|
||||
if (!botId) return;
|
||||
const timezone = await coachTimeZone(prisma, userId);
|
||||
const moved = await prisma.routine.findMany({
|
||||
where: { botId, userId, coachKey: { not: null }, timezone: { not: timezone } },
|
||||
});
|
||||
for (const routine of moved) {
|
||||
const armed = routine.active && routine.nextRunAt !== null;
|
||||
const nextRunAt = armed
|
||||
? (nextCronDateAcross(routine.crons, new Date(), timezone) ?? routine.nextRunAt)
|
||||
: routine.nextRunAt;
|
||||
// Skipped when another save moved it meanwhile.
|
||||
const { count } = await prisma.routine.updateMany({
|
||||
where: { id: routine.id, timezone: routine.timezone },
|
||||
data: { timezone, nextRunAt },
|
||||
});
|
||||
if (count === 1 && armed && nextRunAt) {
|
||||
await jobs.enqueue(routineWakeupJob(routine.id, nextRunAt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2607,7 +2607,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
if (name === "update_user_profile") {
|
||||
return finish(
|
||||
await updateUserProfileFromTool(
|
||||
deps.prisma,
|
||||
deps,
|
||||
{
|
||||
userId: run.userId,
|
||||
bot,
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type * as CoachRhythmModule from "./coach-rhythm.js";
|
||||
import { syncCoachTimeZone } from "./coach-rhythm.js";
|
||||
import { selectBuiltinToolsForRun } from "./executor-tools.js";
|
||||
import { updateUserProfileFromTool } from "./user-profile-tools.js";
|
||||
|
||||
vi.mock("./coach-rhythm.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CoachRhythmModule>()),
|
||||
syncCoachTimeZone: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function toolNames(options: {
|
||||
mainBot?: boolean;
|
||||
messagingChannelRun?: boolean;
|
||||
@ -23,15 +31,16 @@ function toolNames(options: {
|
||||
}).map((tool) => tool.name);
|
||||
}
|
||||
|
||||
/** Any database access fails the test: refusals and invalid input must not reach it. */
|
||||
const untouchedPrisma = new Proxy(
|
||||
/** Any database or job access fails the test: refusals and invalid input must not reach it. */
|
||||
const untouched = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, property) {
|
||||
throw new Error(`unexpected database access: ${String(property)}`);
|
||||
throw new Error(`unexpected access: ${String(property)}`);
|
||||
},
|
||||
},
|
||||
) as PrismaClient;
|
||||
);
|
||||
const untouchedDeps = { prisma: untouched as PrismaClient, jobs: untouched as JobPublisher };
|
||||
|
||||
const context = (kind: string) => ({
|
||||
userId: "user-1",
|
||||
@ -58,24 +67,24 @@ describe("update_user_profile", () => {
|
||||
|
||||
it("refuses specialists even when they call the tool anyway", async () => {
|
||||
await expect(
|
||||
updateUserProfileFromTool(untouchedPrisma, context("specialist"), {
|
||||
updateUserProfileFromTool(untouchedDeps, context("specialist"), {
|
||||
facts: [{ category: "work", value: "Nurse" }],
|
||||
}),
|
||||
).resolves.toEqual({ error: "Only the user's main bot can update the user profile." });
|
||||
});
|
||||
|
||||
it("rejects invalid or empty updates before writing", async () => {
|
||||
const invalidCategory = await updateUserProfileFromTool(untouchedPrisma, context("main"), {
|
||||
const invalidCategory = await updateUserProfileFromTool(untouchedDeps, context("main"), {
|
||||
facts: [{ category: "secrets", value: "x" }],
|
||||
});
|
||||
expect(invalidCategory).toEqual({ error: expect.stringContaining("facts.0.category") });
|
||||
|
||||
const unknownZone = await updateUserProfileFromTool(untouchedPrisma, context("main"), {
|
||||
const unknownZone = await updateUserProfileFromTool(untouchedDeps, context("main"), {
|
||||
time_zone: "Vienna",
|
||||
});
|
||||
expect(unknownZone).toEqual({ error: expect.stringContaining("Unknown time zone") });
|
||||
|
||||
await expect(updateUserProfileFromTool(untouchedPrisma, context("main"), {})).resolves.toEqual({
|
||||
await expect(updateUserProfileFromTool(untouchedDeps, context("main"), {})).resolves.toEqual({
|
||||
error: "Nothing to save. Pass facts or a profile field.",
|
||||
});
|
||||
});
|
||||
@ -96,8 +105,9 @@ describe("update_user_profile", () => {
|
||||
const prisma = {
|
||||
$transaction: (run: (client: typeof tx) => Promise<unknown>) => run(tx),
|
||||
} as unknown as PrismaClient;
|
||||
const jobs = { enqueue: vi.fn(), cancel: vi.fn() };
|
||||
|
||||
const result = await updateUserProfileFromTool(prisma, context("main"), {
|
||||
const result = await updateUserProfileFromTool({ prisma, jobs }, context("main"), {
|
||||
facts: [
|
||||
{ category: "work", value: "Nurse" },
|
||||
{ category: "health", value: "Asthma" },
|
||||
@ -121,5 +131,12 @@ describe("update_user_profile", () => {
|
||||
}),
|
||||
expect.objectContaining({ category: "health", status: "pending", sensitive: true }),
|
||||
]);
|
||||
// A new time zone moves the check-ins; facts alone leave them be.
|
||||
expect(syncCoachTimeZone).toHaveBeenCalledExactlyOnceWith({ prisma, jobs }, "user-1");
|
||||
vi.mocked(syncCoachTimeZone).mockClear();
|
||||
await updateUserProfileFromTool({ prisma, jobs }, context("main"), {
|
||||
facts: [{ category: "work", value: "Nurse" }],
|
||||
});
|
||||
expect(syncCoachTimeZone).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,9 +5,10 @@ import {
|
||||
UserFactCategorySchema,
|
||||
UserFactValueSchema,
|
||||
} from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { InvalidUserFactError, updateUserProfile } from "@rakazo/db";
|
||||
import { z } from "zod";
|
||||
import type { CoachRhythmDeps } from "./coach-rhythm.js";
|
||||
import { syncCoachTimeZone } from "./coach-rhythm.js";
|
||||
|
||||
const UpdateUserProfileArgs = z.object({
|
||||
facts: z
|
||||
@ -29,7 +30,7 @@ export interface UserProfileToolContext {
|
||||
|
||||
/** Records what the main bot learned about the user, attributed to the conversation. */
|
||||
export async function updateUserProfileFromTool(
|
||||
prisma: PrismaClient,
|
||||
deps: CoachRhythmDeps,
|
||||
context: UserProfileToolContext,
|
||||
rawArgs: Record<string, unknown>,
|
||||
) {
|
||||
@ -54,7 +55,7 @@ export async function updateUserProfileFromTool(
|
||||
return { error: "Nothing to save. Pass facts or a profile field." };
|
||||
}
|
||||
try {
|
||||
const added = await updateUserProfile(prisma, context.userId, {
|
||||
const added = await updateUserProfile(deps.prisma, context.userId, {
|
||||
timeZone: args.time_zone,
|
||||
language: args.language,
|
||||
communicationStyle: args.communication_style,
|
||||
@ -66,6 +67,7 @@ export async function updateUserProfileFromTool(
|
||||
sourceMessageId: context.sourceMessageId,
|
||||
})),
|
||||
});
|
||||
if (args.time_zone !== undefined) await syncCoachTimeZone(deps, context.userId);
|
||||
const pending = added.filter((fact) => fact.status === "pending").length;
|
||||
return pending > 0
|
||||
? {
|
||||
|
||||
@ -247,6 +247,32 @@ describeWithDatabase("coach through the API and the main bot", () => {
|
||||
expect(rearmed.active).toBe(true);
|
||||
expect(rearmed.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("moves the check-ins when the user's time zone changes or is forgotten", async () => {
|
||||
const cookie = await signup(app, `coach-zone-${stamp}@rakazo.test`, "Coach Zone");
|
||||
const me = await rpc<{ userId: string }>(app, cookie, "me");
|
||||
await rpc(app, cookie, "coach/rhythm/set", { morning: "07:00", evening: null, weekly: null });
|
||||
const morning = () =>
|
||||
handles.prisma.routine.findFirstOrThrow({
|
||||
where: { userId: me.userId, coachKey: "morning" },
|
||||
});
|
||||
expect(await morning()).toMatchObject({ timezone: "UTC" });
|
||||
|
||||
await rpc(app, cookie, "profile/update", { timeZone: "Asia/Tokyo" });
|
||||
const moved = await morning();
|
||||
expect(moved.timezone).toBe("Asia/Tokyo");
|
||||
expect(
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Asia/Tokyo",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).format(moved.nextRunAt!),
|
||||
).toBe("07:00");
|
||||
|
||||
await rpc(app, cookie, "profile/forget", { all: true });
|
||||
expect(await morning()).toMatchObject({ timezone: "UTC" });
|
||||
});
|
||||
});
|
||||
|
||||
async function signup(app: App, email: string, name: string) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user