diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index a196f22..9d0e90f 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -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: { diff --git a/apps/api/src/user-profile.ts b/apps/api/src/user-profile.ts index 4307f1a..5192c06 100644 --- a/apps/api/src/user-profile.ts +++ b/apps/api/src/user-profile.ts @@ -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 { 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 { - 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); } diff --git a/packages/adapters/src/coach-rhythm.postgres.test.ts b/packages/adapters/src/coach-rhythm.postgres.test.ts index 73136e2..e55c5f6 100644 --- a/packages/adapters/src/coach-rhythm.postgres.test.ts +++ b/packages/adapters/src/coach-rhythm.postgres.test.ts @@ -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(); + }); }); diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts index 06ae758..c385eec 100644 --- a/packages/adapters/src/coach-rhythm.ts +++ b/packages/adapters/src/coach-rhythm.ts @@ -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 { + 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)); + } + } +} diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index eb1df2a..49dfd97 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -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, diff --git a/packages/adapters/src/user-profile-tools.test.ts b/packages/adapters/src/user-profile-tools.test.ts index b6200cf..0b118a3 100644 --- a/packages/adapters/src/user-profile-tools.test.ts +++ b/packages/adapters/src/user-profile-tools.test.ts @@ -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()), + 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) => 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(); }); }); diff --git a/packages/adapters/src/user-profile-tools.ts b/packages/adapters/src/user-profile-tools.ts index 19255cb..9ba0991 100644 --- a/packages/adapters/src/user-profile-tools.ts +++ b/packages/adapters/src/user-profile-tools.ts @@ -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, ) { @@ -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 ? { diff --git a/packages/testkit/src/coach.postgres.test.ts b/packages/testkit/src/coach.postgres.test.ts index 7f86a1a..386250d 100644 --- a/packages/testkit/src/coach.postgres.test.ts +++ b/packages/testkit/src/coach.postgres.test.ts @@ -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) {