import type { CheckIn, CheckInKind, CoachSource, CoachToday, DailyBrief, Goal, GoalStatus, Habit, HabitCadence, HabitToday, } from "@rakazo/contracts"; import { CHECK_IN_NOTE_MAX_LENGTH, COACH_ACTIVE_GOAL_LIMIT, COACH_ACTIVE_HABIT_LIMIT, COACH_TITLE_MAX_LENGTH, DAILY_BRIEF_MAX_LENGTH, GOAL_DETAIL_MAX_LENGTH, LocalDateSchema, } from "@rakazo/contracts"; import { addDays, habitDoneInPeriod, habitStreak, knownTimeZone, localDateIn, weekStartOf, } from "@rakazo/core"; import type { PrismaClient } from "./client.js"; import { Prisma } from "./client.js"; /** How far back a check-in may be logged, and how far back streaks are counted. */ export const CHECK_IN_MAX_AGE_DAYS = 366; export class CoachNotFoundError extends Error { constructor(what: "Goal" | "Habit") { super(`${what} not found`); this.name = "CoachNotFoundError"; } } export class InvalidCoachInputError extends Error { constructor(message: string) { super(message); this.name = "InvalidCoachInputError"; } } export interface NewGoal { title: string; detail?: string; /** The user's local date, "YYYY-MM-DD". */ targetDate?: string | null; spaceId?: string | null; source: CoachSource; createdByBotId?: string | null; } export interface GoalPatch { title?: string; detail?: string; status?: GoalStatus; targetDate?: string | null; spaceId?: string | null; } export interface NewHabit { title: string; cadence?: HabitCadence; goalId?: string | null; source: CoachSource; createdByBotId?: string | null; } export interface HabitPatch { title?: string; cadence?: HabitCadence; goalId?: string | null; active?: boolean; } export interface CheckInEntry { kind: CheckInKind; habitId?: string; goalId?: string; /** The user's local date; defaults to today in their time zone. */ date?: string; done?: boolean | null; mood?: number | null; energy?: number | null; note?: string; createdByBotId?: string | null; } export interface CheckInFilter { from?: string; to?: string; kind?: CheckInKind; habitId?: string; goalId?: string; } export interface DailyBriefEntry { text: string; /** The user's local date; defaults to today in their time zone. */ date?: string; botId?: string | null; } /** Active goals and habits as the main bot sees them in its prompt. */ export interface CoachSummary { date: string; timeZone: string; goals: Goal[]; habits: HabitToday[]; } const CHECK_IN_LIST_LIMIT = 500; type Tx = Prisma.TransactionClient; type GoalRow = Prisma.UserGoalGetPayload; type HabitRow = Prisma.HabitGetPayload; type CheckInRow = Prisma.CheckInGetPayload; type BriefRow = Prisma.DailyBriefGetPayload; function dateString(value: Date): string { return value.toISOString().slice(0, 10); } function dateValue(value: string): Date { return new Date(`${value}T00:00:00Z`); } function goalDto(row: GoalRow): Goal { return { id: row.id, title: row.title, detail: row.detail, status: row.status as GoalStatus, targetDate: row.targetDate ? dateString(row.targetDate) : null, spaceId: row.spaceId, source: row.source as CoachSource, createdByBotId: row.createdByBotId, completedAt: row.completedAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } function habitDto(row: HabitRow): Habit { return { id: row.id, title: row.title, cadence: row.cadence as HabitCadence, goalId: row.goalId, active: row.active, source: row.source as CoachSource, createdByBotId: row.createdByBotId, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } function checkInDto(row: CheckInRow): CheckIn { return { id: row.id, kind: row.kind as CheckInKind, habitId: row.habitId, goalId: row.goalId, date: dateString(row.date), done: row.done, mood: row.mood, energy: row.energy, note: row.note, createdByBotId: row.createdByBotId, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } function briefDto(row: BriefRow): DailyBrief { return { id: row.id, date: dateString(row.date), text: row.text, botId: row.botId, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } function title(value: string): string { const text = value.trim(); if (!text) throw new InvalidCoachInputError("Title is required"); if (text.length > COACH_TITLE_MAX_LENGTH) { throw new InvalidCoachInputError(`Title is longer than ${COACH_TITLE_MAX_LENGTH} characters`); } return text; } function boundedText(value: string, max: number, label: string): string { const text = value.trim(); if (text.length > max) throw new InvalidCoachInputError(`${label} is longer than ${max} characters`); return text; } function localDate(value: string): string { if (!LocalDateSchema.safeParse(value).success) { throw new InvalidCoachInputError("Dates use YYYY-MM-DD"); } return value; } function rating(value: number | null | undefined, label: string): number | null | undefined { if (value === null || value === undefined) return value; if (!Number.isInteger(value) || value < 1 || value > 5) { throw new InvalidCoachInputError(`${label} is a whole number from 1 to 5`); } return value; } /** * Serializes one user's coach writes so the active limits and the one-check-in-per-day rule hold * under concurrency. */ async function lockCoach(tx: Tx, userId: string): Promise { const key = `coach:${userId}`; await tx.$queryRaw(Prisma.sql` SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))::text AS "lock" `); } /** The user's time zone from their profile, UTC when unknown. Every coach date uses it. */ export async function coachTimeZone( prisma: Pick, userId: string, ): Promise { const profile = await prisma.userProfile.findUnique({ where: { userId }, select: { timeZone: true }, }); return knownTimeZone(profile?.timeZone) ?? "UTC"; } async function assertMemberSpace(tx: Tx, userId: string, spaceId: string | null | undefined) { if (!spaceId) return; const member = await tx.spaceMember.findFirst({ where: { userId, spaceId }, select: { id: true }, }); if (!member) throw new InvalidCoachInputError("Space not found"); } async function ownedGoal(tx: Tx, userId: string, goalId: string): Promise { const goal = await tx.userGoal.findFirst({ where: { id: goalId, userId } }); if (!goal) throw new CoachNotFoundError("Goal"); return goal; } async function ownedHabit(tx: Tx, userId: string, habitId: string): Promise { const habit = await tx.habit.findFirst({ where: { id: habitId, userId } }); if (!habit) throw new CoachNotFoundError("Habit"); return habit; } async function assertActiveGoalRoom(tx: Tx, userId: string): Promise { const active = await tx.userGoal.count({ where: { userId, status: "active" } }); if (active >= COACH_ACTIVE_GOAL_LIMIT) { throw new InvalidCoachInputError(`Keep at most ${COACH_ACTIVE_GOAL_LIMIT} active goals`); } } async function assertActiveHabitRoom(tx: Tx, userId: string): Promise { const active = await tx.habit.count({ where: { userId, active: true } }); if (active >= COACH_ACTIVE_HABIT_LIMIT) { throw new InvalidCoachInputError(`Keep at most ${COACH_ACTIVE_HABIT_LIMIT} active habits`); } } export async function createGoal( prisma: PrismaClient, userId: string, input: NewGoal, ): Promise { const data = { title: title(input.title), detail: boundedText(input.detail ?? "", GOAL_DETAIL_MAX_LENGTH, "Detail"), targetDate: input.targetDate ? dateValue(localDate(input.targetDate)) : null, }; return prisma.$transaction(async (tx) => { await lockCoach(tx, userId); await assertActiveGoalRoom(tx, userId); await assertMemberSpace(tx, userId, input.spaceId); const row = await tx.userGoal.create({ data: { ...data, userId, spaceId: input.spaceId ?? null, source: input.source, createdByBotId: input.createdByBotId ?? null, }, }); return goalDto(row); }); } /** Changes a goal; marking it done stamps completedAt, any other status clears it. */ export async function updateGoal( prisma: PrismaClient, userId: string, goalId: string, patch: GoalPatch, ): Promise { const data: Prisma.UserGoalUncheckedUpdateInput = {}; if (patch.title !== undefined) data.title = title(patch.title); if (patch.detail !== undefined) { data.detail = boundedText(patch.detail, GOAL_DETAIL_MAX_LENGTH, "Detail"); } if (patch.targetDate !== undefined) { data.targetDate = patch.targetDate ? dateValue(localDate(patch.targetDate)) : null; } if (patch.spaceId !== undefined) data.spaceId = patch.spaceId; return prisma.$transaction(async (tx) => { await lockCoach(tx, userId); const goal = await ownedGoal(tx, userId, goalId); if (patch.spaceId !== undefined) await assertMemberSpace(tx, userId, patch.spaceId); if (patch.status !== undefined && patch.status !== goal.status) { if (patch.status === "active") await assertActiveGoalRoom(tx, userId); data.status = patch.status; data.completedAt = patch.status === "done" ? new Date() : null; } const row = await tx.userGoal.update({ where: { id: goal.id }, data }); return goalDto(row); }); } /** Deletes a goal with its check-ins; habits that served it stay, unlinked. */ export async function removeGoal(prisma: PrismaClient, userId: string, goalId: string) { const { count } = await prisma.userGoal.deleteMany({ where: { id: goalId, userId } }); if (count === 0) throw new CoachNotFoundError("Goal"); } export async function listGoals( prisma: PrismaClient, userId: string, filter: { status?: GoalStatus } = {}, ): Promise { const rows = await prisma.userGoal.findMany({ where: { userId, ...(filter.status ? { status: filter.status } : {}) }, orderBy: [{ createdAt: "desc" }, { id: "asc" }], }); return rows.map(goalDto); } export async function createHabit( prisma: PrismaClient, userId: string, input: NewHabit, ): Promise { const habitTitle = title(input.title); return prisma.$transaction(async (tx) => { await lockCoach(tx, userId); await assertActiveHabitRoom(tx, userId); if (input.goalId) await ownedGoal(tx, userId, input.goalId); const row = await tx.habit.create({ data: { userId, title: habitTitle, cadence: input.cadence ?? "daily", goalId: input.goalId ?? null, source: input.source, createdByBotId: input.createdByBotId ?? null, }, }); return habitDto(row); }); } /** Changes a habit; `active: false` stops tracking it but keeps its history. */ export async function updateHabit( prisma: PrismaClient, userId: string, habitId: string, patch: HabitPatch, ): Promise { const data: Prisma.HabitUncheckedUpdateInput = {}; if (patch.title !== undefined) data.title = title(patch.title); if (patch.cadence !== undefined) data.cadence = patch.cadence; if (patch.goalId !== undefined) data.goalId = patch.goalId; return prisma.$transaction(async (tx) => { await lockCoach(tx, userId); const habit = await ownedHabit(tx, userId, habitId); if (patch.goalId) await ownedGoal(tx, userId, patch.goalId); if (patch.active !== undefined && patch.active !== habit.active) { if (patch.active) await assertActiveHabitRoom(tx, userId); data.active = patch.active; } const row = await tx.habit.update({ where: { id: habit.id }, data }); return habitDto(row); }); } export async function removeHabit(prisma: PrismaClient, userId: string, habitId: string) { const { count } = await prisma.habit.deleteMany({ where: { id: habitId, userId } }); if (count === 0) throw new CoachNotFoundError("Habit"); } export async function listHabits( prisma: PrismaClient, userId: string, filter: { includeInactive?: boolean } = {}, ): Promise { const rows = await prisma.habit.findMany({ where: { userId, ...(filter.includeInactive ? {} : { active: true }) }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], }); return rows.map(habitDto); } /** * Logs or updates one day's check-in: one per habit, one per goal and one daily check-in per * date. Fields left out keep their stored value. Unchecking a weekly habit clears the whole * week, so the habit reads as not done this week. */ export async function logCheckIn( prisma: PrismaClient, userId: string, entry: CheckInEntry, now: Date = new Date(), ): Promise { if ((entry.kind === "habit") !== Boolean(entry.habitId)) { throw new InvalidCoachInputError("A habit check-in needs habitId, and only it"); } if ((entry.kind === "goal") !== Boolean(entry.goalId)) { throw new InvalidCoachInputError("A goal check-in needs goalId, and only it"); } const fields: Prisma.CheckInUncheckedUpdateInput = {}; if (entry.done !== undefined) fields.done = entry.done; const mood = rating(entry.mood, "Mood"); if (mood !== undefined) fields.mood = mood; const energy = rating(entry.energy, "Energy"); if (energy !== undefined) fields.energy = energy; if (entry.note !== undefined) { fields.note = boundedText(entry.note, CHECK_IN_NOTE_MAX_LENGTH, "Note"); } return prisma.$transaction(async (tx) => { await lockCoach(tx, userId); const today = localDateIn(await coachTimeZone(tx, userId), now); const date = entry.date ? localDate(entry.date) : today; if (date > today) throw new InvalidCoachInputError("Check-ins cannot be in the future"); if (date < addDays(today, -CHECK_IN_MAX_AGE_DAYS)) { throw new InvalidCoachInputError(`Check-ins go back at most ${CHECK_IN_MAX_AGE_DAYS} days`); } const habit = entry.habitId ? await ownedHabit(tx, userId, entry.habitId) : null; if (entry.goalId) await ownedGoal(tx, userId, entry.goalId); const day = dateValue(date); const existing = habit ? await tx.checkIn.findUnique({ where: { habitId_date: { habitId: habit.id, date: day } } }) : entry.goalId ? await tx.checkIn.findUnique({ where: { goalId_date: { goalId: entry.goalId, date: day } }, }) : await tx.checkIn.findFirst({ where: { userId, kind: "daily", date: day } }); const row = existing ? await tx.checkIn.update({ where: { id: existing.id }, data: fields }) : await tx.checkIn.create({ data: { userId, kind: entry.kind, habitId: habit?.id ?? null, goalId: entry.goalId ?? null, date: day, done: entry.done ?? null, mood: mood ?? null, energy: energy ?? null, note: typeof fields.note === "string" ? fields.note : "", createdByBotId: entry.createdByBotId ?? null, }, }); if (habit && entry.done === false && habit.cadence === "weekly") { await tx.checkIn.updateMany({ where: { habitId: habit.id, done: true, date: { gte: dateValue(weekStartOf(date)), lte: dateValue(addDays(weekStartOf(date), 6)), }, }, data: { done: false }, }); } return checkInDto(row); }); } export async function listCheckIns( prisma: PrismaClient, userId: string, filter: CheckInFilter = {}, ): Promise { const rows = await prisma.checkIn.findMany({ where: { userId, ...(filter.kind ? { kind: filter.kind } : {}), ...(filter.habitId ? { habitId: filter.habitId } : {}), ...(filter.goalId ? { goalId: filter.goalId } : {}), ...(filter.from || filter.to ? { date: { ...(filter.from ? { gte: dateValue(localDate(filter.from)) } : {}), ...(filter.to ? { lte: dateValue(localDate(filter.to)) } : {}), }, } : {}), }, orderBy: [{ date: "desc" }, { createdAt: "desc" }, { id: "asc" }], take: CHECK_IN_LIST_LIMIT, }); return rows.map(checkInDto); } /** Saves the brief for one of the user's days, replacing an earlier one for that date. */ export async function upsertDailyBrief( prisma: PrismaClient, userId: string, entry: DailyBriefEntry, now: Date = new Date(), ): Promise { const text = boundedText(entry.text, DAILY_BRIEF_MAX_LENGTH, "Brief"); if (!text) throw new InvalidCoachInputError("Brief is empty"); return prisma.$transaction(async (tx) => { await lockCoach(tx, userId); const today = localDateIn(await coachTimeZone(tx, userId), now); const date = entry.date ? localDate(entry.date) : today; if (date > addDays(today, 1)) { throw new InvalidCoachInputError("A brief is for today or tomorrow at the latest"); } const row = await tx.dailyBrief.upsert({ where: { userId_date: { userId, date: dateValue(date) } }, create: { userId, date: dateValue(date), text, botId: entry.botId ?? null }, update: { text, botId: entry.botId ?? null }, }); return briefDto(row); }); } async function activeGoals(prisma: PrismaClient, userId: string): Promise { const rows = await prisma.userGoal.findMany({ where: { userId, status: "active" }, orderBy: [{ targetDate: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }, { id: "asc" }], }); return rows.map(goalDto); } async function activeHabitsOn( prisma: PrismaClient, userId: string, today: string, ): Promise { const habits = await prisma.habit.findMany({ where: { userId, active: true }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], }); if (habits.length === 0) return []; const done = await prisma.checkIn.findMany({ where: { userId, kind: "habit", done: true, habitId: { in: habits.map((habit) => habit.id) }, date: { gte: dateValue(addDays(today, -CHECK_IN_MAX_AGE_DAYS)), lte: dateValue(today) }, }, select: { habitId: true, date: true }, }); const doneDates = new Map>(); for (const row of done) { if (!row.habitId) continue; const dates = doneDates.get(row.habitId) ?? new Set(); dates.add(dateString(row.date)); doneDates.set(row.habitId, dates); } return habits.map((row) => { const habit = habitDto(row); const dates = doneDates.get(row.id) ?? new Set(); return { ...habit, doneToday: habitDoneInPeriod(habit.cadence, dates, today), streak: habitStreak(habit.cadence, dates, today), }; }); } /** Active goals and habits with streaks, for the main bot's prompt. */ export async function loadCoachSummary( prisma: PrismaClient, userId: string, now: Date = new Date(), ): Promise { const timeZone = await coachTimeZone(prisma, userId); const date = localDateIn(timeZone, now); const [goals, habits] = await Promise.all([ activeGoals(prisma, userId), activeHabitsOn(prisma, userId, date), ]); return { date, timeZone, goals, habits }; } /** The user's day: today's brief, active goals, habits with streaks and the daily check-in. */ export async function loadCoachToday( prisma: PrismaClient, userId: string, now: Date = new Date(), ): Promise { const timeZone = await coachTimeZone(prisma, userId); const date = localDateIn(timeZone, now); const day = dateValue(date); const [brief, goals, habits, checkIn] = await Promise.all([ prisma.dailyBrief.findUnique({ where: { userId_date: { userId, date: day } } }), activeGoals(prisma, userId), activeHabitsOn(prisma, userId, date), prisma.checkIn.findFirst({ where: { userId, kind: "daily", date: day } }), ]); return { date, timeZone, brief: brief ? briefDto(brief) : null, goals, habits, checkIn: checkIn ? checkInDto(checkIn) : null, }; }