import type { NotificationSettings, NotificationSettingsPatch } from "@rakazo/contracts"; import { clockTimeToMinutes, minutesToClockTime, NUDGES_PER_DAY, UpdateNotificationSettingsInput, } from "@rakazo/contracts"; import { isQuietMinute, localDateAndMinute } from "@rakazo/core"; import type { PrismaClient } from "./client.js"; const settingsSelect = { quietHoursStart: true, quietHoursEnd: true, nudges: true } as const; type SettingsRow = { quietHoursStart: number | null; quietHoursEnd: number | null; nudges: boolean; }; function quietRange(row: SettingsRow | null) { return row && row.quietHoursStart !== null && row.quietHoursEnd !== null ? { start: row.quietHoursStart, end: row.quietHoursEnd } : null; } function toSettings(row: SettingsRow | null): NotificationSettings { const range = quietRange(row); return { quietHours: range ? { start: minutesToClockTime(range.start), end: minutesToClockTime(range.end) } : null, nudges: row?.nudges ?? true, }; } /** The user's notification settings; without a stored row: no quiet hours, nudges on. */ export async function getNotificationSettings( prisma: PrismaClient, userId: string, ): Promise { return toSettings( await prisma.userNotificationSettings.findUnique({ where: { userId }, select: settingsSelect }), ); } /** Applies the fields present in `patch`; `quietHours: null` turns quiet hours off. */ export async function updateNotificationSettings( prisma: PrismaClient, userId: string, patch: NotificationSettingsPatch, ): Promise { const valid = UpdateNotificationSettingsInput.parse(patch); const data: { quietHoursStart?: number | null; quietHoursEnd?: number | null; nudges?: boolean } = {}; if (valid.quietHours !== undefined) { data.quietHoursStart = valid.quietHours ? clockTimeToMinutes(valid.quietHours.start) : null; data.quietHoursEnd = valid.quietHours ? clockTimeToMinutes(valid.quietHours.end) : null; } if (valid.nudges !== undefined) data.nudges = valid.nudges; return toSettings( await prisma.userNotificationSettings.upsert({ where: { userId }, create: { userId, ...data }, update: data, select: settingsSelect, }), ); } export interface NotificationPolicy { /** True during the user's quiet hours: no push may go out. */ quiet: boolean; nudges: boolean; /** The user's local date ("YYYY-MM-DD"), the day the nudge cap counts against. */ localDate: string; } /** What may reach the user at `now`, judged in their profile time zone (UTC when unknown). */ export async function loadNotificationPolicy( prisma: PrismaClient, userId: string, now: Date = new Date(), ): Promise { const [row, profile] = await Promise.all([ prisma.userNotificationSettings.findUnique({ where: { userId }, select: settingsSelect }), prisma.userProfile.findUnique({ where: { userId }, select: { timeZone: true } }), ]); const local = localDateAndMinute(now, profile?.timeZone); return { quiet: isQuietMinute(quietRange(row), local.minute), nudges: row?.nudges ?? true, localDate: local.date, }; } /** * Counts one nudge against the user's daily cap and returns whether it may go out. Each step is * a single conditional UPDATE, so concurrent nudges cannot exceed the cap. */ export async function claimDailyNudge( prisma: PrismaClient, userId: string, localDate: string, limit: number = NUDGES_PER_DAY, ): Promise { const day = new Date(`${localDate}T00:00:00.000Z`); await prisma.userNotificationSettings.createMany({ data: [{ userId }], skipDuplicates: true }); // Two rounds: a concurrent first nudge of the day can move the date between the statements. for (let round = 0; round < 2; round += 1) { const sameDay = await prisma.userNotificationSettings.updateMany({ where: { userId, nudgeDate: day, nudgeCount: { lt: limit } }, data: { nudgeCount: { increment: 1 } }, }); if (sameDay.count === 1) return true; const newDay = await prisma.userNotificationSettings.updateMany({ where: { userId, OR: [{ nudgeDate: null }, { nudgeDate: { not: day } }] }, data: { nudgeDate: day, nudgeCount: 1 }, }); if (newDay.count === 1) return true; } return false; }