fix(bot): refine reminder scheduling and topic routing

This commit is contained in:
2026-03-24 15:04:42 +04:00
parent 63e299134d
commit a1acec5e60
9 changed files with 501 additions and 144 deletions

View File

@@ -11,6 +11,8 @@ describe('parseAdHocNotificationSchedule', () => {
resolvedLocalDate: '2026-03-24', resolvedLocalDate: '2026-03-24',
resolvedHour: 15, resolvedHour: 15,
resolvedMinute: 30, resolvedMinute: 30,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'exact', resolutionMode: 'exact',
now: Temporal.Instant.from('2026-03-23T09:00:00Z') now: Temporal.Instant.from('2026-03-23T09:00:00Z')
}) })
@@ -26,6 +28,8 @@ describe('parseAdHocNotificationSchedule', () => {
resolvedLocalDate: '2026-03-24', resolvedLocalDate: '2026-03-24',
resolvedHour: 12, resolvedHour: 12,
resolvedMinute: 0, resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'calendar',
resolutionMode: 'date_only', resolutionMode: 'date_only',
now: Temporal.Instant.from('2026-03-23T09:00:00Z') now: Temporal.Instant.from('2026-03-23T09:00:00Z')
}) })
@@ -41,6 +45,8 @@ describe('parseAdHocNotificationSchedule', () => {
resolvedLocalDate: '2026-03-24', resolvedLocalDate: '2026-03-24',
resolvedHour: 9, resolvedHour: 9,
resolvedMinute: 0, resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'fuzzy_window', resolutionMode: 'fuzzy_window',
now: Temporal.Instant.from('2026-03-23T09:00:00Z') now: Temporal.Instant.from('2026-03-23T09:00:00Z')
}) })
@@ -56,6 +62,8 @@ describe('parseAdHocNotificationSchedule', () => {
resolvedLocalDate: null, resolvedLocalDate: null,
resolvedHour: null, resolvedHour: null,
resolvedMinute: null, resolvedMinute: null,
relativeOffsetMinutes: null,
dateReferenceMode: null,
resolutionMode: 'ambiguous', resolutionMode: 'ambiguous',
now: Temporal.Instant.from('2026-03-23T09:00:00Z') now: Temporal.Instant.from('2026-03-23T09:00:00Z')
}) })
@@ -69,10 +77,45 @@ describe('parseAdHocNotificationSchedule', () => {
resolvedLocalDate: '2026-03-23', resolvedLocalDate: '2026-03-23',
resolvedHour: 10, resolvedHour: 10,
resolvedMinute: 0, resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'calendar',
resolutionMode: 'exact', resolutionMode: 'exact',
now: Temporal.Instant.from('2026-03-23T09:00:00Z') now: Temporal.Instant.from('2026-03-23T09:00:00Z')
}) })
expect(parsed.kind).toBe('invalid_past') expect(parsed.kind).toBe('invalid_past')
}) })
test('supports relative offsets like in 30 minutes', () => {
const parsed = parseAdHocNotificationSchedule({
timezone: 'Asia/Tbilisi',
resolvedLocalDate: null,
resolvedHour: null,
resolvedMinute: null,
relativeOffsetMinutes: 30,
dateReferenceMode: null,
resolutionMode: 'exact',
now: Temporal.Instant.from('2026-03-24T08:00:00Z')
})
expect(parsed.kind).toBe('parsed')
expect(parsed.timePrecision).toBe('exact')
expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T08:30:00Z')
})
test('reinterprets pre-dawn relative tomorrow as the upcoming same-calendar day', () => {
const parsed = parseAdHocNotificationSchedule({
timezone: 'Asia/Tbilisi',
resolvedLocalDate: '2026-03-25',
resolvedHour: 9,
resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'fuzzy_window',
now: Temporal.Instant.from('2026-03-24T00:39:00Z')
})
expect(parsed.kind).toBe('parsed')
expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T05:00:00Z')
})
}) })

View File

@@ -27,12 +27,14 @@ export function parseAdHocNotificationSchedule(input: {
resolvedLocalDate: string | null resolvedLocalDate: string | null
resolvedHour: number | null resolvedHour: number | null
resolvedMinute: number | null resolvedMinute: number | null
relativeOffsetMinutes?: number | null
dateReferenceMode?: 'relative' | 'calendar' | null
resolutionMode: AdHocNotificationResolutionMode | null resolutionMode: AdHocNotificationResolutionMode | null
now?: Instant now?: Instant
}): ParsedAdHocNotificationSchedule { }): ParsedAdHocNotificationSchedule {
const effectiveNow = input.now ?? nowInstant()
const timePrecision = precisionFromResolutionMode(input.resolutionMode) const timePrecision = precisionFromResolutionMode(input.resolutionMode)
if ( if (
!input.resolvedLocalDate ||
input.resolutionMode === null || input.resolutionMode === null ||
input.resolutionMode === 'ambiguous' || input.resolutionMode === 'ambiguous' ||
timePrecision === null timePrecision === null
@@ -44,6 +46,31 @@ export function parseAdHocNotificationSchedule(input: {
} }
} }
if (input.relativeOffsetMinutes !== null && input.relativeOffsetMinutes !== undefined) {
const scheduled = effectiveNow.add({ minutes: input.relativeOffsetMinutes })
if (scheduled.epochMilliseconds <= effectiveNow.epochMilliseconds) {
return {
kind: 'invalid_past',
scheduledFor: null,
timePrecision: null
}
}
return {
kind: 'parsed',
scheduledFor: scheduled,
timePrecision
}
}
if (!input.resolvedLocalDate) {
return {
kind: 'missing_schedule',
scheduledFor: null,
timePrecision: null
}
}
const hour = const hour =
input.resolutionMode === 'date_only' ? (input.resolvedHour ?? 12) : input.resolvedHour input.resolutionMode === 'date_only' ? (input.resolvedHour ?? 12) : input.resolvedHour
const minute = const minute =
@@ -58,7 +85,17 @@ export function parseAdHocNotificationSchedule(input: {
} }
try { try {
const date = Temporal.PlainDate.from(input.resolvedLocalDate) const nowZdt = effectiveNow.toZonedDateTimeISO(input.timezone)
let date = Temporal.PlainDate.from(input.resolvedLocalDate)
if (
input.dateReferenceMode === 'relative' &&
nowZdt.hour <= 4 &&
Temporal.PlainDate.compare(date, nowZdt.toPlainDate().add({ days: 1 })) === 0
) {
date = nowZdt.toPlainDate()
}
const scheduled = Temporal.ZonedDateTime.from({ const scheduled = Temporal.ZonedDateTime.from({
timeZone: input.timezone, timeZone: input.timezone,
year: date.year, year: date.year,
@@ -70,7 +107,6 @@ export function parseAdHocNotificationSchedule(input: {
millisecond: 0 millisecond: 0
}).toInstant() }).toInstant()
const effectiveNow = input.now ?? nowInstant()
if (scheduled.epochMilliseconds <= effectiveNow.epochMilliseconds) { if (scheduled.epochMilliseconds <= effectiveNow.epochMilliseconds) {
return { return {
kind: 'invalid_past', kind: 'invalid_past',

View File

@@ -16,34 +16,38 @@ import { formatReminderWhen, registerAdHocNotifications } from './ad-hoc-notific
import type { AdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter' import type { AdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter'
function createPromptRepository(): TelegramPendingActionRepository { function createPromptRepository(): TelegramPendingActionRepository {
let pending: TelegramPendingActionRecord | null = null const pending = new Map<string, TelegramPendingActionRecord>()
return { return {
async upsertPendingAction(input) { async upsertPendingAction(input) {
pending = input pending.set(`${input.telegramChatId}:${input.telegramUserId}`, input)
return input return input
}, },
async getPendingAction() { async getPendingAction(telegramChatId, telegramUserId) {
return pending return pending.get(`${telegramChatId}:${telegramUserId}`) ?? null
}, },
async clearPendingAction() { async clearPendingAction(telegramChatId, telegramUserId) {
pending = null pending.delete(`${telegramChatId}:${telegramUserId}`)
}, },
async clearPendingActionsForChat(telegramChatId, action) { async clearPendingActionsForChat(telegramChatId, action) {
if (!pending || pending.telegramChatId !== telegramChatId) { for (const [key, value] of pending.entries()) {
return if (value.telegramChatId !== telegramChatId) {
continue
} }
if (action && value.action !== action) {
if (action && pending.action !== action) { continue
return }
pending.delete(key)
} }
pending = null
} }
} }
} }
function reminderMessageUpdate(text: string, threadId = 777) { function reminderMessageUpdate(
text: string,
threadId = 777,
from: { id: number; firstName: string } = { id: 10002, firstName: 'Dima' }
) {
return { return {
update_id: 4001, update_id: 4001,
message: { message: {
@@ -56,9 +60,9 @@ function reminderMessageUpdate(text: string, threadId = 777) {
type: 'supergroup' type: 'supergroup'
}, },
from: { from: {
id: 10002, id: from.id,
is_bot: false, is_bot: false,
first_name: 'Dima' first_name: from.firstName
}, },
text text
} }
@@ -110,6 +114,7 @@ function member(
function createHouseholdRepository() { function createHouseholdRepository() {
const members = [ const members = [
member({ id: 'dima', telegramUserId: '10002', displayName: 'Дима' }), member({ id: 'dima', telegramUserId: '10002', displayName: 'Дима' }),
member({ id: 'stas', telegramUserId: '10003', displayName: 'Стас' }),
member({ id: 'georgiy', displayName: 'Георгий' }) member({ id: 'georgiy', displayName: 'Георгий' })
] ]
const settings: HouseholdBillingSettingsRecord = { const settings: HouseholdBillingSettingsRecord = {
@@ -238,6 +243,8 @@ describe('registerAdHocNotifications', () => {
resolvedLocalDate: tomorrow, resolvedLocalDate: tomorrow,
resolvedHour: 9, resolvedHour: 9,
resolvedMinute: 0, resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'fuzzy_window', resolutionMode: 'fuzzy_window',
clarificationQuestion: null, clarificationQuestion: null,
confidence: 90, confidence: 90,
@@ -250,6 +257,8 @@ describe('registerAdHocNotifications', () => {
resolvedLocalDate: tomorrow, resolvedLocalDate: tomorrow,
resolvedHour: 9, resolvedHour: 9,
resolvedMinute: 0, resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'fuzzy_window', resolutionMode: 'fuzzy_window',
clarificationQuestion: null, clarificationQuestion: null,
confidence: 90, confidence: 90,
@@ -267,6 +276,8 @@ describe('registerAdHocNotifications', () => {
resolvedLocalDate: null, resolvedLocalDate: null,
resolvedHour: null, resolvedHour: null,
resolvedMinute: null, resolvedMinute: null,
relativeOffsetMinutes: null,
dateReferenceMode: null,
resolutionMode: null, resolutionMode: null,
deliveryMode: null, deliveryMode: null,
dmRecipientMemberIds: null, dmRecipientMemberIds: null,
@@ -284,6 +295,8 @@ describe('registerAdHocNotifications', () => {
resolvedLocalDate: tomorrow, resolvedLocalDate: tomorrow,
resolvedHour: 10, resolvedHour: 10,
resolvedMinute: 0, resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'exact', resolutionMode: 'exact',
deliveryMode: null, deliveryMode: null,
dmRecipientMemberIds: null, dmRecipientMemberIds: null,
@@ -573,6 +586,8 @@ describe('registerAdHocNotifications', () => {
resolvedLocalDate: tomorrow, resolvedLocalDate: tomorrow,
resolvedHour: 9, resolvedHour: 9,
resolvedMinute: 0, resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'fuzzy_window', resolutionMode: 'fuzzy_window',
clarificationQuestion: null, clarificationQuestion: null,
confidence: 90, confidence: 90,
@@ -610,6 +625,148 @@ describe('registerAdHocNotifications', () => {
expect(expandedPayload.reply_markup?.inline_keyboard[0]?.[2]?.text).toBe('Скрыть') expect(expandedPayload.reply_markup?.inline_keyboard[0]?.[2]?.text).toBe('Скрыть')
expect(expandedPayload.reply_markup?.inline_keyboard[1]?.[0]?.text).toContain('В топик') expect(expandedPayload.reply_markup?.inline_keyboard[1]?.[0]?.text).toContain('В топик')
}) })
test('supports relative duration reminders like in 30 minutes', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
const scheduledRequests: Array<{ scheduledFor: string }> = []
const now = Temporal.Instant.from('2026-03-24T08:00:00Z')
const promptRepository = createPromptRepository()
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: false
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: -10012345,
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const originalNow = Temporal.Now.instant
Temporal.Now.instant = () => now
try {
registerAdHocNotifications({
bot,
householdConfigurationRepository: createHouseholdRepository() as never,
promptRepository,
notificationService: {
async scheduleNotification(input) {
scheduledRequests.push({
scheduledFor: input.scheduledFor.toString()
})
return {
status: 'scheduled',
notification: {
id: 'notif-1',
householdId: input.householdId,
creatorMemberId: input.creatorMemberId,
assigneeMemberId: input.assigneeMemberId ?? null,
originalRequestText: input.originalRequestText,
notificationText: input.notificationText,
timezone: input.timezone,
scheduledFor: input.scheduledFor,
timePrecision: input.timePrecision,
deliveryMode: input.deliveryMode,
dmRecipientMemberIds: input.dmRecipientMemberIds ?? [],
friendlyTagAssignee: false,
status: 'scheduled',
sourceTelegramChatId: input.sourceTelegramChatId ?? null,
sourceTelegramThreadId: input.sourceTelegramThreadId ?? null,
sentAt: null,
cancelledAt: null,
cancelledByMemberId: null,
createdAt: Temporal.Instant.from('2026-03-23T09:00:00Z'),
updatedAt: Temporal.Instant.from('2026-03-23T09:00:00Z')
}
}
},
async listUpcomingNotifications() {
return []
},
async cancelNotification() {
return { status: 'not_found' }
},
async updateNotification() {
return { status: 'not_found' }
},
async listDueNotifications() {
return []
},
async claimDueNotification() {
return false
},
async releaseDueNotification() {},
async markNotificationSent() {
return null
}
},
reminderInterpreter: {
async interpretRequest() {
return {
decision: 'notification',
notificationText: 'починить тебя',
assigneeMemberId: null,
resolvedLocalDate: null,
resolvedHour: null,
resolvedMinute: null,
relativeOffsetMinutes: 30,
dateReferenceMode: null,
resolutionMode: 'exact',
clarificationQuestion: null,
confidence: 94,
parserMode: 'llm'
}
},
async interpretSchedule() {
throw new Error('not used')
},
async interpretDraftEdit() {
throw new Error('not used')
},
async renderDeliveryText() {
return 'Пора чинить бота.'
}
}
})
await bot.handleUpdate(reminderMessageUpdate('Напомни починить тебя через 30 минут') as never)
const pending = await promptRepository.getPendingAction('-10012345', '10002')
const proposalId = (pending?.payload as { proposalId?: string } | null)?.proposalId
expect(proposalId).toBeTruthy()
await bot.handleUpdate(reminderCallbackUpdate(`adhocnotif:confirm:${proposalId}`) as never)
} finally {
Temporal.Now.instant = originalNow
}
expect((calls[0]?.payload as { text?: string })?.text).toContain('сегодня в 12:30')
expect(scheduledRequests).toEqual([
{
scheduledFor: '2026-03-24T08:30:00Z'
}
])
})
}) })
describe('formatReminderWhen', () => { describe('formatReminderWhen', () => {

View File

@@ -775,6 +775,8 @@ export function registerAdHocNotifications(options: {
resolvedLocalDate: interpretedSchedule.resolvedLocalDate, resolvedLocalDate: interpretedSchedule.resolvedLocalDate,
resolvedHour: interpretedSchedule.resolvedHour, resolvedHour: interpretedSchedule.resolvedHour,
resolvedMinute: interpretedSchedule.resolvedMinute, resolvedMinute: interpretedSchedule.resolvedMinute,
relativeOffsetMinutes: interpretedSchedule.relativeOffsetMinutes,
dateReferenceMode: interpretedSchedule.dateReferenceMode,
resolutionMode: interpretedSchedule.resolutionMode resolutionMode: interpretedSchedule.resolutionMode
}) })
@@ -822,6 +824,7 @@ export function registerAdHocNotifications(options: {
return return
} }
if (existingDraft.stage === 'confirm') {
if (!options.reminderInterpreter) { if (!options.reminderInterpreter) {
await replyInTopic(ctx, unavailableReply(reminderContext.locale)) await replyInTopic(ctx, unavailableReply(reminderContext.locale))
return return
@@ -875,6 +878,7 @@ export function registerAdHocNotifications(options: {
interpretedEdit.resolvedLocalDate !== null || interpretedEdit.resolvedLocalDate !== null ||
interpretedEdit.resolvedHour !== null || interpretedEdit.resolvedHour !== null ||
interpretedEdit.resolvedMinute !== null || interpretedEdit.resolvedMinute !== null ||
interpretedEdit.relativeOffsetMinutes !== null ||
interpretedEdit.resolutionMode !== null interpretedEdit.resolutionMode !== null
let nextSchedule = { let nextSchedule = {
@@ -888,6 +892,8 @@ export function registerAdHocNotifications(options: {
resolvedLocalDate: interpretedEdit.resolvedLocalDate ?? currentSchedule.date, resolvedLocalDate: interpretedEdit.resolvedLocalDate ?? currentSchedule.date,
resolvedHour: interpretedEdit.resolvedHour ?? currentSchedule.hour, resolvedHour: interpretedEdit.resolvedHour ?? currentSchedule.hour,
resolvedMinute: interpretedEdit.resolvedMinute ?? currentSchedule.minute, resolvedMinute: interpretedEdit.resolvedMinute ?? currentSchedule.minute,
relativeOffsetMinutes: interpretedEdit.relativeOffsetMinutes,
dateReferenceMode: interpretedEdit.dateReferenceMode,
resolutionMode: interpretedEdit.resolutionMode ?? 'exact' resolutionMode: interpretedEdit.resolutionMode ?? 'exact'
}) })
@@ -920,14 +926,18 @@ export function registerAdHocNotifications(options: {
const nextNormalizedNotificationText = const nextNormalizedNotificationText =
interpretedEdit.notificationText ?? existingDraft.normalizedNotificationText interpretedEdit.notificationText ?? existingDraft.normalizedNotificationText
const nextOriginalRequestText = const nextOriginalRequestText =
interpretedEdit.notificationText !== null ? messageText : existingDraft.originalRequestText interpretedEdit.notificationText !== null
? messageText
: existingDraft.originalRequestText
const nextAssigneeMemberId = interpretedEdit.assigneeChanged const nextAssigneeMemberId = interpretedEdit.assigneeChanged
? interpretedEdit.assigneeMemberId ? interpretedEdit.assigneeMemberId
: existingDraft.assigneeMemberId : existingDraft.assigneeMemberId
const nextDeliveryMode = interpretedEdit.deliveryMode ?? existingDraft.deliveryMode const nextDeliveryMode = interpretedEdit.deliveryMode ?? existingDraft.deliveryMode
const nextDmRecipientMemberIds = const nextDmRecipientMemberIds =
interpretedEdit.dmRecipientMemberIds ?? interpretedEdit.dmRecipientMemberIds ??
(nextDeliveryMode === existingDraft.deliveryMode ? existingDraft.dmRecipientMemberIds : []) (nextDeliveryMode === existingDraft.deliveryMode
? existingDraft.dmRecipientMemberIds
: [])
const renderedNotificationText = await renderNotificationText({ const renderedNotificationText = await renderNotificationText({
reminderContext, reminderContext,
@@ -956,6 +966,7 @@ export function registerAdHocNotifications(options: {
await showDraftConfirmation(ctx, nextPayload, existingDraft.confirmationMessageId) await showDraftConfirmation(ctx, nextPayload, existingDraft.confirmationMessageId)
return return
} }
}
if (!options.reminderInterpreter) { if (!options.reminderInterpreter) {
await replyInTopic(ctx, unavailableReply(reminderContext.locale)) await replyInTopic(ctx, unavailableReply(reminderContext.locale))
@@ -1025,6 +1036,8 @@ export function registerAdHocNotifications(options: {
resolvedLocalDate: interpretedRequest.resolvedLocalDate, resolvedLocalDate: interpretedRequest.resolvedLocalDate,
resolvedHour: interpretedRequest.resolvedHour, resolvedHour: interpretedRequest.resolvedHour,
resolvedMinute: interpretedRequest.resolvedMinute, resolvedMinute: interpretedRequest.resolvedMinute,
relativeOffsetMinutes: interpretedRequest.relativeOffsetMinutes,
dateReferenceMode: interpretedRequest.dateReferenceMode,
resolutionMode: interpretedRequest.resolutionMode resolutionMode: interpretedRequest.resolutionMode
}) })
@@ -1167,6 +1180,7 @@ export function registerAdHocNotifications(options: {
const payload = await loadDraft(options.promptRepository, ctx) const payload = await loadDraft(options.promptRepository, ctx)
if ( if (
!payload || !payload ||
payload.stage !== 'confirm' ||
payload.proposalId !== proposalId || payload.proposalId !== proposalId ||
!ctx.chat || !ctx.chat ||
!ctx.from || !ctx.from ||

View File

@@ -47,6 +47,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
resolvedLocalDate: '2026-03-24', resolvedLocalDate: '2026-03-24',
resolvedHour: 15, resolvedHour: 15,
resolvedMinute: 30, resolvedMinute: 30,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'exact', resolutionMode: 'exact',
confidence: 93, confidence: 93,
clarificationQuestion: null clarificationQuestion: null
@@ -72,6 +74,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
resolvedLocalDate: '2026-03-24', resolvedLocalDate: '2026-03-24',
resolvedHour: 15, resolvedHour: 15,
resolvedMinute: 30, resolvedMinute: 30,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'exact', resolutionMode: 'exact',
clarificationQuestion: null, clarificationQuestion: null,
confidence: 93, confidence: 93,
@@ -100,6 +104,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
resolvedLocalDate: '2026-03-24', resolvedLocalDate: '2026-03-24',
resolvedHour: 9, resolvedHour: 9,
resolvedMinute: 0, resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'fuzzy_window', resolutionMode: 'fuzzy_window',
confidence: 90, confidence: 90,
clarificationQuestion: null clarificationQuestion: null
@@ -141,6 +147,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
resolvedLocalDate: null, resolvedLocalDate: null,
resolvedHour: null, resolvedHour: null,
resolvedMinute: null, resolvedMinute: null,
relativeOffsetMinutes: null,
dateReferenceMode: null,
resolutionMode: 'ambiguous', resolutionMode: 'ambiguous',
confidence: 82, confidence: 82,
clarificationQuestion: 'Когда напомнить: завтра утром, днем или вечером?' clarificationQuestion: 'Когда напомнить: завтра утром, днем или вечером?'
@@ -185,6 +193,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
resolvedLocalDate: null, resolvedLocalDate: null,
resolvedHour: null, resolvedHour: null,
resolvedMinute: null, resolvedMinute: null,
relativeOffsetMinutes: null,
dateReferenceMode: null,
resolutionMode: null, resolutionMode: null,
confidence: 96, confidence: 96,
clarificationQuestion: null clarificationQuestion: null
@@ -225,6 +235,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
resolvedLocalDate: '2026-03-24', resolvedLocalDate: '2026-03-24',
resolvedHour: 10, resolvedHour: 10,
resolvedMinute: 0, resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'exact', resolutionMode: 'exact',
deliveryMode: null, deliveryMode: null,
dmRecipientMemberIds: null, dmRecipientMemberIds: null,
@@ -256,6 +268,8 @@ describe('createOpenAiAdHocNotificationInterpreter', () => {
decision: 'updated', decision: 'updated',
resolvedHour: 10, resolvedHour: 10,
resolvedMinute: 0, resolvedMinute: 0,
relativeOffsetMinutes: null,
dateReferenceMode: 'relative',
resolutionMode: 'exact', resolutionMode: 'exact',
notificationText: null, notificationText: null,
deliveryMode: null deliveryMode: null

View File

@@ -17,6 +17,8 @@ export interface AdHocNotificationInterpretation {
resolvedLocalDate: string | null resolvedLocalDate: string | null
resolvedHour: number | null resolvedHour: number | null
resolvedMinute: number | null resolvedMinute: number | null
relativeOffsetMinutes: number | null
dateReferenceMode: 'relative' | 'calendar' | null
resolutionMode: AdHocNotificationResolutionMode | null resolutionMode: AdHocNotificationResolutionMode | null
clarificationQuestion: string | null clarificationQuestion: string | null
confidence: number confidence: number
@@ -28,6 +30,8 @@ export interface AdHocNotificationScheduleInterpretation {
resolvedLocalDate: string | null resolvedLocalDate: string | null
resolvedHour: number | null resolvedHour: number | null
resolvedMinute: number | null resolvedMinute: number | null
relativeOffsetMinutes: number | null
dateReferenceMode: 'relative' | 'calendar' | null
resolutionMode: AdHocNotificationResolutionMode | null resolutionMode: AdHocNotificationResolutionMode | null
clarificationQuestion: string | null clarificationQuestion: string | null
confidence: number confidence: number
@@ -42,6 +46,8 @@ export interface AdHocNotificationDraftEditInterpretation {
resolvedLocalDate: string | null resolvedLocalDate: string | null
resolvedHour: number | null resolvedHour: number | null
resolvedMinute: number | null resolvedMinute: number | null
relativeOffsetMinutes: number | null
dateReferenceMode: 'relative' | 'calendar' | null
resolutionMode: AdHocNotificationResolutionMode | null resolutionMode: AdHocNotificationResolutionMode | null
deliveryMode: AdHocNotificationDeliveryMode | null deliveryMode: AdHocNotificationDeliveryMode | null
dmRecipientMemberIds: readonly string[] | null dmRecipientMemberIds: readonly string[] | null
@@ -57,6 +63,8 @@ interface ReminderInterpretationResult {
resolvedLocalDate: string | null resolvedLocalDate: string | null
resolvedHour: number | null resolvedHour: number | null
resolvedMinute: number | null resolvedMinute: number | null
relativeOffsetMinutes: number | null
dateReferenceMode: 'relative' | 'calendar' | null
resolutionMode: AdHocNotificationResolutionMode | null resolutionMode: AdHocNotificationResolutionMode | null
confidence: number confidence: number
clarificationQuestion: string | null clarificationQuestion: string | null
@@ -67,6 +75,8 @@ interface ReminderScheduleResult {
resolvedLocalDate: string | null resolvedLocalDate: string | null
resolvedHour: number | null resolvedHour: number | null
resolvedMinute: number | null resolvedMinute: number | null
relativeOffsetMinutes: number | null
dateReferenceMode: 'relative' | 'calendar' | null
resolutionMode: AdHocNotificationResolutionMode | null resolutionMode: AdHocNotificationResolutionMode | null
confidence: number confidence: number
clarificationQuestion: string | null clarificationQuestion: string | null
@@ -84,6 +94,8 @@ interface ReminderDraftEditResult {
resolvedLocalDate: string | null resolvedLocalDate: string | null
resolvedHour: number | null resolvedHour: number | null
resolvedMinute: number | null resolvedMinute: number | null
relativeOffsetMinutes: number | null
dateReferenceMode: 'relative' | 'calendar' | null
resolutionMode: AdHocNotificationResolutionMode | null resolutionMode: AdHocNotificationResolutionMode | null
deliveryMode: AdHocNotificationDeliveryMode | null deliveryMode: AdHocNotificationDeliveryMode | null
dmRecipientMemberIds: string[] | null dmRecipientMemberIds: string[] | null
@@ -204,6 +216,26 @@ function normalizeMinute(value: number | null | undefined): number | null {
return value return value
} }
function normalizeRelativeOffsetMinutes(value: number | null | undefined): number | null {
if (
value === null ||
value === undefined ||
!Number.isInteger(value) ||
value <= 0 ||
value > 7 * 24 * 60
) {
return null
}
return value
}
function normalizeDateReferenceMode(
value: string | null | undefined
): 'relative' | 'calendar' | null {
return value === 'relative' || value === 'calendar' ? value : null
}
function normalizeMemberId( function normalizeMemberId(
value: string | null | undefined, value: string | null | undefined,
members: readonly AdHocNotificationInterpreterMember[] members: readonly AdHocNotificationInterpreterMember[]
@@ -348,6 +380,18 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
resolvedMinute: { resolvedMinute: {
anyOf: [{ type: 'integer' }, { type: 'null' }] anyOf: [{ type: 'integer' }, { type: 'null' }]
}, },
relativeOffsetMinutes: {
anyOf: [{ type: 'integer' }, { type: 'null' }]
},
dateReferenceMode: {
anyOf: [
{
type: 'string',
enum: ['relative', 'calendar']
},
{ type: 'null' }
]
},
resolutionMode: { resolutionMode: {
anyOf: [ anyOf: [
{ {
@@ -373,6 +417,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
'resolvedLocalDate', 'resolvedLocalDate',
'resolvedHour', 'resolvedHour',
'resolvedMinute', 'resolvedMinute',
'relativeOffsetMinutes',
'dateReferenceMode',
'resolutionMode', 'resolutionMode',
'confidence', 'confidence',
'clarificationQuestion' 'clarificationQuestion'
@@ -385,9 +431,12 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
'Use the provided member ids when a reminder is clearly aimed at a specific household member.', 'Use the provided member ids when a reminder is clearly aimed at a specific household member.',
'resolvedLocalDate must be YYYY-MM-DD in the provided household timezone.', 'resolvedLocalDate must be YYYY-MM-DD in the provided household timezone.',
'resolvedHour and resolvedMinute must be in 24-hour local time when a reminder can be scheduled.', 'resolvedHour and resolvedMinute must be in 24-hour local time when a reminder can be scheduled.',
'If the user uses a relative duration like "через 30 минут" or "in 2 hours", prefer relativeOffsetMinutes over absolute date/time fields.',
'dateReferenceMode must be relative for words like today, tomorrow, the day after tomorrow, today evening, tomorrow morning; use calendar only for explicit calendar dates.',
'Use resolutionMode exact for explicit clock time, fuzzy_window for phrases like morning/evening, date_only for plain day/date without an explicit time, ambiguous when the request is still too vague to schedule.', 'Use resolutionMode exact for explicit clock time, fuzzy_window for phrases like morning/evening, date_only for plain day/date without an explicit time, ambiguous when the request is still too vague to schedule.',
'If schedule information is missing or ambiguous, return decision clarification and a short clarificationQuestion in the user language.', 'If schedule information is missing or ambiguous, return decision clarification and a short clarificationQuestion in the user language.',
'If the message is not a reminder request, return decision not_notification.', 'If the message is not a reminder request, return decision not_notification.',
'During local 00:00-04:59, vague tomorrow phrases like "завтра", "завтра утром", "tomorrow", "tomorrow morning" refer to the upcoming same-calendar-day reminder window unless the user gave an explicit calendar date.',
promptWindowRules(), promptWindowRules(),
options.assistantContext ? `Household context: ${options.assistantContext}` : null, options.assistantContext ? `Household context: ${options.assistantContext}` : null,
options.assistantTone ? `Preferred tone: ${options.assistantTone}` : null, options.assistantTone ? `Preferred tone: ${options.assistantTone}` : null,
@@ -419,6 +468,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate), resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate),
resolvedHour: normalizeHour(parsed.resolvedHour), resolvedHour: normalizeHour(parsed.resolvedHour),
resolvedMinute: normalizeMinute(parsed.resolvedMinute), resolvedMinute: normalizeMinute(parsed.resolvedMinute),
relativeOffsetMinutes: normalizeRelativeOffsetMinutes(parsed.relativeOffsetMinutes),
dateReferenceMode: normalizeDateReferenceMode(parsed.dateReferenceMode),
resolutionMode: normalizeResolutionMode(parsed.resolutionMode), resolutionMode: normalizeResolutionMode(parsed.resolutionMode),
clarificationQuestion: normalizeOptionalText(parsed.clarificationQuestion), clarificationQuestion: normalizeOptionalText(parsed.clarificationQuestion),
confidence: normalizeConfidence(parsed.confidence), confidence: normalizeConfidence(parsed.confidence),
@@ -448,6 +499,18 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
resolvedMinute: { resolvedMinute: {
anyOf: [{ type: 'integer' }, { type: 'null' }] anyOf: [{ type: 'integer' }, { type: 'null' }]
}, },
relativeOffsetMinutes: {
anyOf: [{ type: 'integer' }, { type: 'null' }]
},
dateReferenceMode: {
anyOf: [
{
type: 'string',
enum: ['relative', 'calendar']
},
{ type: 'null' }
]
},
resolutionMode: { resolutionMode: {
anyOf: [ anyOf: [
{ {
@@ -471,6 +534,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
'resolvedLocalDate', 'resolvedLocalDate',
'resolvedHour', 'resolvedHour',
'resolvedMinute', 'resolvedMinute',
'relativeOffsetMinutes',
'dateReferenceMode',
'resolutionMode', 'resolutionMode',
'confidence', 'confidence',
'clarificationQuestion' 'clarificationQuestion'
@@ -481,8 +546,11 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
'Decide whether the message contains enough schedule information to produce a local date/time or whether you need clarification.', 'Decide whether the message contains enough schedule information to produce a local date/time or whether you need clarification.',
'resolvedLocalDate must be YYYY-MM-DD in the provided household timezone.', 'resolvedLocalDate must be YYYY-MM-DD in the provided household timezone.',
'resolvedHour and resolvedMinute must be local 24-hour time when parsed.', 'resolvedHour and resolvedMinute must be local 24-hour time when parsed.',
'If the message uses a relative duration like "через 30 минут" or "in 2 hours", prefer relativeOffsetMinutes over absolute date/time fields.',
'dateReferenceMode must be relative for words like today, tomorrow, and the day after tomorrow; use calendar only for explicit calendar dates.',
'Use resolutionMode exact for explicit clock time, fuzzy_window for phrases like morning/evening, date_only for plain day/date without explicit time, ambiguous when still unclear.', 'Use resolutionMode exact for explicit clock time, fuzzy_window for phrases like morning/evening, date_only for plain day/date without explicit time, ambiguous when still unclear.',
'If the schedule is missing or ambiguous, return clarification and ask a short question in the user language.', 'If the schedule is missing or ambiguous, return clarification and ask a short question in the user language.',
'During local 00:00-04:59, vague tomorrow phrases like "завтра", "завтра утром", "tomorrow", "tomorrow morning" refer to the upcoming same-calendar-day reminder window unless the user gave an explicit calendar date.',
promptWindowRules(), promptWindowRules(),
`Household timezone: ${options.timezone}`, `Household timezone: ${options.timezone}`,
`Current local date/time in that timezone: ${options.localNow}`, `Current local date/time in that timezone: ${options.localNow}`,
@@ -502,6 +570,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate), resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate),
resolvedHour: normalizeHour(parsed.resolvedHour), resolvedHour: normalizeHour(parsed.resolvedHour),
resolvedMinute: normalizeMinute(parsed.resolvedMinute), resolvedMinute: normalizeMinute(parsed.resolvedMinute),
relativeOffsetMinutes: normalizeRelativeOffsetMinutes(parsed.relativeOffsetMinutes),
dateReferenceMode: normalizeDateReferenceMode(parsed.dateReferenceMode),
resolutionMode: normalizeResolutionMode(parsed.resolutionMode), resolutionMode: normalizeResolutionMode(parsed.resolutionMode),
clarificationQuestion: normalizeOptionalText(parsed.clarificationQuestion), clarificationQuestion: normalizeOptionalText(parsed.clarificationQuestion),
confidence: normalizeConfidence(parsed.confidence), confidence: normalizeConfidence(parsed.confidence),
@@ -540,6 +610,18 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
resolvedMinute: { resolvedMinute: {
anyOf: [{ type: 'integer' }, { type: 'null' }] anyOf: [{ type: 'integer' }, { type: 'null' }]
}, },
relativeOffsetMinutes: {
anyOf: [{ type: 'integer' }, { type: 'null' }]
},
dateReferenceMode: {
anyOf: [
{
type: 'string',
enum: ['relative', 'calendar']
},
{ type: 'null' }
]
},
resolutionMode: { resolutionMode: {
anyOf: [ anyOf: [
{ {
@@ -586,6 +668,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
'resolvedLocalDate', 'resolvedLocalDate',
'resolvedHour', 'resolvedHour',
'resolvedMinute', 'resolvedMinute',
'relativeOffsetMinutes',
'dateReferenceMode',
'resolutionMode', 'resolutionMode',
'deliveryMode', 'deliveryMode',
'dmRecipientMemberIds', 'dmRecipientMemberIds',
@@ -601,8 +685,11 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
'Use notificationText only when the user changes what should be reminded.', 'Use notificationText only when the user changes what should be reminded.',
'Use deliveryMode when the user changes where the reminder should be sent.', 'Use deliveryMode when the user changes where the reminder should be sent.',
'Use dmRecipientMemberIds only when the user clearly changes selected DM recipients.', 'Use dmRecipientMemberIds only when the user clearly changes selected DM recipients.',
'If the user uses a relative duration like "через 30 минут" or "in 2 hours", prefer relativeOffsetMinutes over absolute date/time fields.',
'dateReferenceMode must be relative for words like today, tomorrow, and the day after tomorrow; use calendar only for explicit calendar dates.',
'Use resolutionMode exact for explicit clock time, fuzzy_window for phrases like morning/evening, date_only for plain day/date without explicit time, ambiguous when still unclear.', 'Use resolutionMode exact for explicit clock time, fuzzy_window for phrases like morning/evening, date_only for plain day/date without explicit time, ambiguous when still unclear.',
'If the latest message is too ambiguous, return clarification and a short clarificationQuestion in the user language.', 'If the latest message is too ambiguous, return clarification and a short clarificationQuestion in the user language.',
'During local 00:00-04:59, vague tomorrow phrases like "завтра", "завтра утром", "tomorrow", "tomorrow morning" refer to the upcoming same-calendar-day reminder window unless the user gave an explicit calendar date.',
promptWindowRules(), promptWindowRules(),
options.assistantContext ? `Household context: ${options.assistantContext}` : null, options.assistantContext ? `Household context: ${options.assistantContext}` : null,
options.assistantTone ? `Preferred tone: ${options.assistantTone}` : null, options.assistantTone ? `Preferred tone: ${options.assistantTone}` : null,
@@ -643,6 +730,8 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate), resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate),
resolvedHour: normalizeHour(parsed.resolvedHour), resolvedHour: normalizeHour(parsed.resolvedHour),
resolvedMinute: normalizeMinute(parsed.resolvedMinute), resolvedMinute: normalizeMinute(parsed.resolvedMinute),
relativeOffsetMinutes: normalizeRelativeOffsetMinutes(parsed.relativeOffsetMinutes),
dateReferenceMode: normalizeDateReferenceMode(parsed.dateReferenceMode),
resolutionMode: normalizeResolutionMode(parsed.resolutionMode), resolutionMode: normalizeResolutionMode(parsed.resolutionMode),
deliveryMode: normalizeDeliveryMode(parsed.deliveryMode), deliveryMode: normalizeDeliveryMode(parsed.deliveryMode),
dmRecipientMemberIds: normalizeMemberIds(parsed.dmRecipientMemberIds, options.members), dmRecipientMemberIds: normalizeMemberIds(parsed.dmRecipientMemberIds, options.members),

View File

@@ -68,6 +68,9 @@ describe('createOpenAiChatAssistant', () => {
expect(capturedBody!.input[1]?.content).toContain( expect(capturedBody!.input[1]?.content).toContain(
'Members can ask the bot to schedule a future notification in this topic.' 'Members can ask the bot to schedule a future notification in this topic.'
) )
expect(capturedBody!.input[1]?.content).toContain(
'Never tell the user to set a reminder on their own device in this topic.'
)
} finally { } finally {
globalThis.fetch = originalFetch globalThis.fetch = originalFetch
} }

View File

@@ -64,7 +64,8 @@ function topicCapabilityNotes(topicRole: TopicMessageRole): string {
'- You can discuss existing household rent/utilities reminder timing, the supported utility-bill collection flow, and ad hoc household notifications.', '- You can discuss existing household rent/utilities reminder timing, the supported utility-bill collection flow, and ad hoc household notifications.',
'- Members can ask the bot to schedule a future notification in this topic.', '- Members can ask the bot to schedule a future notification in this topic.',
'- If the date or time is missing, ask a concise follow-up instead of pretending it was scheduled.', '- If the date or time is missing, ask a concise follow-up instead of pretending it was scheduled.',
'- Do not claim a notification was saved unless the system explicitly confirmed it.' '- Do not claim a notification was saved unless the system explicitly confirmed it.',
'- Never tell the user to set a reminder on their own device in this topic.'
].join('\n') ].join('\n')
case 'feedback': case 'feedback':
return [ return [
@@ -111,7 +112,7 @@ const ASSISTANT_SYSTEM_PROMPT = [
'Do not repeat the same clarification after the user declines, backs off, or says they are only thinking.', 'Do not repeat the same clarification after the user declines, backs off, or says they are only thinking.',
'Do not restate the full household context unless the user explicitly asks for details.', 'Do not restate the full household context unless the user explicitly asks for details.',
'Do not imply capabilities that are not explicitly provided in the system context.', 'Do not imply capabilities that are not explicitly provided in the system context.',
'There is no general feature for creating or scheduling arbitrary personal reminders unless the system explicitly says so.', 'There is no general feature for creating or scheduling arbitrary personal reminders unless the system explicitly says so in the current topic capability notes.',
'Avoid bullet lists unless the user asked for a list or several distinct items.', 'Avoid bullet lists unless the user asked for a list or several distinct items.',
'Reply in the user language inferred from the latest user message and locale context.' 'Reply in the user language inferred from the latest user message and locale context.'
].join(' ') ].join(' ')

View File

@@ -191,7 +191,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'Напомни Георгию завтра', originalRequestText: 'Напомни Георгию завтра',
notificationText: 'пошпынять Георгия о том, позвонил ли он', notificationText: 'пошпынять Георгия о том, позвонил ли он',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'), scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'topic' deliveryMode: 'topic'
}) })
@@ -222,7 +222,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'remind everyone tomorrow', originalRequestText: 'remind everyone tomorrow',
notificationText: 'pay rent', notificationText: 'pay rent',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'), scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'dm_all' deliveryMode: 'dm_all'
}) })
@@ -246,7 +246,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'remind tomorrow', originalRequestText: 'remind tomorrow',
notificationText: 'check rent', notificationText: 'check rent',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'), scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'topic', deliveryMode: 'topic',
friendlyTagAssignee: true friendlyTagAssignee: true
@@ -273,7 +273,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'remind tomorrow', originalRequestText: 'remind tomorrow',
notificationText: 'call landlord', notificationText: 'call landlord',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'), scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'topic', deliveryMode: 'topic',
friendlyTagAssignee: false friendlyTagAssignee: false
@@ -306,7 +306,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'remind tomorrow', originalRequestText: 'remind tomorrow',
notificationText: 'call landlord', notificationText: 'call landlord',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'), scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'topic', deliveryMode: 'topic',
friendlyTagAssignee: false friendlyTagAssignee: false
@@ -342,7 +342,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'remind tomorrow', originalRequestText: 'remind tomorrow',
notificationText: 'call landlord', notificationText: 'call landlord',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'), scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'topic', deliveryMode: 'topic',
friendlyTagAssignee: false friendlyTagAssignee: false
@@ -351,7 +351,7 @@ describe('createAdHocNotificationService', () => {
const result = await service.updateNotification({ const result = await service.updateNotification({
notificationId: created.id, notificationId: created.id,
viewerMemberId: 'creator', viewerMemberId: 'creator',
scheduledFor: Temporal.Instant.from('2026-03-24T09:00:00Z'), scheduledFor: Temporal.Instant.from('2026-03-25T09:00:00Z'),
timePrecision: 'exact', timePrecision: 'exact',
deliveryMode: 'dm_selected', deliveryMode: 'dm_selected',
dmRecipientMemberIds: ['alice', 'bob'], dmRecipientMemberIds: ['alice', 'bob'],
@@ -360,7 +360,7 @@ describe('createAdHocNotificationService', () => {
expect(result.status).toBe('updated') expect(result.status).toBe('updated')
if (result.status === 'updated') { if (result.status === 'updated') {
expect(result.notification.scheduledFor.toString()).toBe('2026-03-24T09:00:00Z') expect(result.notification.scheduledFor.toString()).toBe('2026-03-25T09:00:00Z')
expect(result.notification.deliveryMode).toBe('dm_selected') expect(result.notification.deliveryMode).toBe('dm_selected')
expect(result.notification.dmRecipientMemberIds).toEqual(['alice', 'bob']) expect(result.notification.dmRecipientMemberIds).toEqual(['alice', 'bob'])
} }