Merge f/tz-rhythm: move check-ins when the time zone changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
a7ae619b28
@ -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
|
||||
? { factIds: input.factIds }
|
||||
: input.category
|
||||
? { category: input.category }
|
||||
: { all: true },
|
||||
);
|
||||
return getUserProfileState(prisma, actor.userId);
|
||||
const target = input.factIds
|
||||
? { factIds: input.factIds }
|
||||
: input.category
|
||||
? { category: input.category }
|
||||
: { 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