feat(bot): add ad hoc reminder notifications

This commit is contained in:
2026-03-24 01:28:26 +04:00
parent dc499214d9
commit 7218b55b1f
21 changed files with 6746 additions and 8 deletions

View File

@@ -0,0 +1,114 @@
import { describe, expect, test } from 'bun:test'
import { Temporal } from '@household/domain'
import type { AdHocNotificationService, DeliverableAdHocNotification } from '@household/application'
import { createAdHocNotificationJobsHandler } from './ad-hoc-notification-jobs'
function dueNotification(
input: Partial<DeliverableAdHocNotification['notification']> = {}
): DeliverableAdHocNotification {
return {
notification: {
id: input.id ?? 'notif-1',
householdId: input.householdId ?? 'household-1',
creatorMemberId: input.creatorMemberId ?? 'creator',
assigneeMemberId: input.assigneeMemberId ?? 'assignee',
originalRequestText: 'raw',
notificationText: input.notificationText ?? 'Ping Georgiy',
timezone: input.timezone ?? 'Asia/Tbilisi',
scheduledFor: input.scheduledFor ?? Temporal.Instant.from('2026-03-23T09:00:00Z'),
timePrecision: input.timePrecision ?? 'exact',
deliveryMode: input.deliveryMode ?? 'topic',
dmRecipientMemberIds: input.dmRecipientMemberIds ?? [],
friendlyTagAssignee: input.friendlyTagAssignee ?? true,
status: input.status ?? 'scheduled',
sourceTelegramChatId: null,
sourceTelegramThreadId: null,
sentAt: null,
cancelledAt: null,
cancelledByMemberId: null,
createdAt: Temporal.Instant.from('2026-03-22T09:00:00Z'),
updatedAt: Temporal.Instant.from('2026-03-22T09:00:00Z')
},
creator: {
memberId: 'creator',
telegramUserId: '111',
displayName: 'Dima'
},
assignee: {
memberId: 'assignee',
telegramUserId: '222',
displayName: 'Georgiy'
},
dmRecipients: [
{
memberId: 'recipient',
telegramUserId: '333',
displayName: 'Alice'
}
]
}
}
describe('createAdHocNotificationJobsHandler', () => {
test('delivers topic notifications and marks them sent', async () => {
const sentTopicMessages: string[] = []
const sentNotifications: string[] = []
const service: AdHocNotificationService = {
scheduleNotification: async () => {
throw new Error('not used')
},
listUpcomingNotifications: async () => [],
cancelNotification: async () => ({ status: 'not_found' }),
listDueNotifications: async () => [dueNotification()],
claimDueNotification: async () => true,
releaseDueNotification: async () => {},
markNotificationSent: async (notificationId) => {
sentNotifications.push(notificationId)
return null
}
}
const handler = createAdHocNotificationJobsHandler({
notificationService: service,
householdConfigurationRepository: {
async getHouseholdChatByHouseholdId() {
return {
householdId: 'household-1',
householdName: 'Kojori',
telegramChatId: '777',
telegramChatType: 'supergroup',
title: 'Kojori',
defaultLocale: 'ru'
}
},
async getHouseholdTopicBinding() {
return {
householdId: 'household-1',
role: 'reminders',
telegramThreadId: '103',
topicName: 'Reminders'
}
}
},
sendTopicMessage: async (input) => {
sentTopicMessages.push(`${input.chatId}:${input.threadId}:${input.text}`)
},
sendDirectMessage: async () => {}
})
const response = await handler.handle(
new Request('http://localhost/jobs/notifications/due', {
method: 'POST'
})
)
const payload = (await response.json()) as { ok: boolean; notifications: { outcome: string }[] }
expect(payload.ok).toBe(true)
expect(payload.notifications[0]?.outcome).toBe('sent')
expect(sentTopicMessages[0]).toContain('tg://user?id=222')
expect(sentNotifications).toEqual(['notif-1'])
})
})

View File

@@ -0,0 +1,194 @@
import type { AdHocNotificationService, DeliverableAdHocNotification } from '@household/application'
import { nowInstant } from '@household/domain'
import type { Logger } from '@household/observability'
import type { HouseholdConfigurationRepository } from '@household/ports'
import { buildTopicNotificationText } from './ad-hoc-notifications'
interface DueNotificationJobRequestBody {
dryRun?: boolean
jobId?: string
}
function json(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
'content-type': 'application/json; charset=utf-8'
}
})
}
async function readBody(request: Request): Promise<DueNotificationJobRequestBody> {
const text = await request.text()
if (text.trim().length === 0) {
return {}
}
try {
return JSON.parse(text) as DueNotificationJobRequestBody
} catch {
throw new Error('Invalid JSON body')
}
}
export function createAdHocNotificationJobsHandler(options: {
notificationService: AdHocNotificationService
householdConfigurationRepository: Pick<
HouseholdConfigurationRepository,
'getHouseholdChatByHouseholdId' | 'getHouseholdTopicBinding'
>
sendTopicMessage: (input: {
householdId: string
chatId: string
threadId: string | null
text: string
parseMode?: 'HTML'
}) => Promise<void>
sendDirectMessage: (input: { telegramUserId: string; text: string }) => Promise<void>
logger?: Logger
}): {
handle: (request: Request) => Promise<Response>
} {
async function deliver(notification: DeliverableAdHocNotification) {
switch (notification.notification.deliveryMode) {
case 'topic': {
const [chat, reminderTopic] = await Promise.all([
options.householdConfigurationRepository.getHouseholdChatByHouseholdId(
notification.notification.householdId
),
options.householdConfigurationRepository.getHouseholdTopicBinding(
notification.notification.householdId,
'reminders'
)
])
if (!chat) {
throw new Error(
`Household chat not configured for ${notification.notification.householdId}`
)
}
const content = buildTopicNotificationText({
notificationText: notification.notification.notificationText,
assignee: notification.assignee,
friendlyTagAssignee: notification.notification.friendlyTagAssignee
})
await options.sendTopicMessage({
householdId: notification.notification.householdId,
chatId: chat.telegramChatId,
threadId: reminderTopic?.telegramThreadId ?? null,
text: content.text,
parseMode: content.parseMode
})
return
}
case 'dm_all':
case 'dm_selected': {
for (const recipient of notification.dmRecipients) {
await options.sendDirectMessage({
telegramUserId: recipient.telegramUserId,
text: notification.notification.notificationText
})
}
return
}
}
}
return {
handle: async (request) => {
if (request.method !== 'POST') {
return json({ ok: false, error: 'Method Not Allowed' }, 405)
}
try {
const body = await readBody(request)
const now = nowInstant()
const due = await options.notificationService.listDueNotifications(now)
const dispatches: Array<{
notificationId: string
householdId: string
outcome: 'dry-run' | 'sent' | 'duplicate' | 'failed'
error?: string
}> = []
for (const notification of due) {
if (body.dryRun === true) {
dispatches.push({
notificationId: notification.notification.id,
householdId: notification.notification.householdId,
outcome: 'dry-run'
})
continue
}
const claimed = await options.notificationService.claimDueNotification(
notification.notification.id
)
if (!claimed) {
dispatches.push({
notificationId: notification.notification.id,
householdId: notification.notification.householdId,
outcome: 'duplicate'
})
continue
}
try {
await deliver(notification)
await options.notificationService.markNotificationSent(
notification.notification.id,
now
)
dispatches.push({
notificationId: notification.notification.id,
householdId: notification.notification.householdId,
outcome: 'sent'
})
} catch (error) {
await options.notificationService.releaseDueNotification(notification.notification.id)
dispatches.push({
notificationId: notification.notification.id,
householdId: notification.notification.householdId,
outcome: 'failed',
error: error instanceof Error ? error.message : 'Unknown delivery error'
})
}
}
options.logger?.info(
{
event: 'scheduler.ad_hoc_notifications.dispatch',
notificationCount: dispatches.length,
jobId: body.jobId ?? request.headers.get('x-cloudscheduler-jobname') ?? null,
dryRun: body.dryRun === true
},
'Ad hoc notification job completed'
)
return json({
ok: true,
dryRun: body.dryRun === true,
notifications: dispatches
})
} catch (error) {
options.logger?.error(
{
event: 'scheduler.ad_hoc_notifications.failed',
error: error instanceof Error ? error.message : String(error)
},
'Ad hoc notification job failed'
)
return json(
{
ok: false,
error: error instanceof Error ? error.message : 'Unknown error'
},
500
)
}
}
}
}

View File

@@ -0,0 +1,90 @@
import { describe, expect, test } from 'bun:test'
import { Temporal } from '@household/domain'
import type { HouseholdMemberRecord } from '@household/ports'
import {
parseAdHocNotificationRequest,
parseAdHocNotificationSchedule
} from './ad-hoc-notification-parser'
function member(
input: Partial<HouseholdMemberRecord> & Pick<HouseholdMemberRecord, 'id'>
): HouseholdMemberRecord {
return {
id: input.id,
householdId: input.householdId ?? 'household-1',
telegramUserId: input.telegramUserId ?? `${input.id}-tg`,
displayName: input.displayName ?? input.id,
status: input.status ?? 'active',
preferredLocale: input.preferredLocale ?? 'ru',
householdDefaultLocale: input.householdDefaultLocale ?? 'ru',
rentShareWeight: input.rentShareWeight ?? 1,
isAdmin: input.isAdmin ?? false
}
}
describe('parseAdHocNotificationRequest', () => {
const members = [
member({ id: 'dima', displayName: 'Дима' }),
member({ id: 'georgiy', displayName: 'Георгий' })
]
test('parses exact datetime and assignee from russian request', () => {
const parsed = parseAdHocNotificationRequest({
text: 'Железяка, напомни пошпынять Георгия завтра в 15:30',
timezone: 'Asia/Tbilisi',
locale: 'ru',
members,
senderMemberId: 'dima',
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
})
expect(parsed.kind).toBe('parsed')
expect(parsed.notificationText).toContain('пошпынять Георгия')
expect(parsed.assigneeMemberId).toBe('georgiy')
expect(parsed.timePrecision).toBe('exact')
expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T11:30:00Z')
})
test('defaults vague tomorrow to daytime slot', () => {
const parsed = parseAdHocNotificationRequest({
text: 'напомни Георгию завтра про звонок',
timezone: 'Asia/Tbilisi',
locale: 'ru',
members,
senderMemberId: 'dima',
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
})
expect(parsed.kind).toBe('parsed')
expect(parsed.timePrecision).toBe('date_only_defaulted')
expect(parsed.scheduledFor?.toString()).toBe('2026-03-24T08:00:00Z')
})
test('requests follow-up when schedule is missing', () => {
const parsed = parseAdHocNotificationRequest({
text: 'напомни пошпынять Георгия',
timezone: 'Asia/Tbilisi',
locale: 'ru',
members,
senderMemberId: 'dima',
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
})
expect(parsed.kind).toBe('missing_schedule')
expect(parsed.notificationText).toContain('пошпынять Георгия')
})
})
describe('parseAdHocNotificationSchedule', () => {
test('rejects past schedule', () => {
const parsed = parseAdHocNotificationSchedule({
text: 'сегодня в 10:00',
timezone: 'Asia/Tbilisi',
now: Temporal.Instant.from('2026-03-23T09:00:00Z')
})
expect(parsed.kind).toBe('invalid_past')
})
})

View File

@@ -0,0 +1,370 @@
import { Temporal, nowInstant, type Instant } from '@household/domain'
import type { HouseholdMemberRecord } from '@household/ports'
type SupportedLocale = 'en' | 'ru'
export interface ParsedAdHocNotificationRequest {
kind: 'parsed' | 'missing_schedule' | 'invalid_past' | 'not_intent'
originalRequestText: string
notificationText: string | null
assigneeMemberId: string | null
scheduledFor: Instant | null
timePrecision: 'exact' | 'date_only_defaulted' | null
}
export interface ParsedAdHocNotificationSchedule {
kind: 'parsed' | 'missing_schedule' | 'invalid_past'
scheduledFor: Instant | null
timePrecision: 'exact' | 'date_only_defaulted' | null
}
const INTENT_PATTERNS = [
/\bremind(?: me)?(?: to)?\b/i,
/\bping me\b/i,
/\bnotification\b/i,
/(?:^|[^\p{L}])напомни(?:ть)?(?=$|[^\p{L}])/iu,
/(?:^|[^\p{L}])напоминани[ея](?=$|[^\p{L}])/iu,
/(?:^|[^\p{L}])пни(?=$|[^\p{L}])/iu,
/(?:^|[^\p{L}])толкни(?=$|[^\p{L}])/iu
] as const
const DAYTIME_DEFAULT_HOUR = 12
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/gu, ' ').trim()
}
function stripLeadingBotAddress(value: string): string {
const match = value.match(/^([^,\n:]{1,40})([:,])\s+/u)
if (!match) {
return value
}
return value.slice(match[0].length)
}
function hasIntent(value: string): boolean {
return INTENT_PATTERNS.some((pattern) => pattern.test(value))
}
function removeIntentPreamble(value: string): string {
const normalized = stripLeadingBotAddress(value)
const patterns = [
/\bremind(?: me)?(?: to)?\b/iu,
/\bping me to\b/iu,
/(?:^|[^\p{L}])напомни(?:ть)?(?=$|[^\p{L}])/iu,
/(?:^|[^\p{L}])пни(?=$|[^\p{L}])/iu,
/(?:^|[^\p{L}])толкни(?=$|[^\p{L}])/iu
]
for (const pattern of patterns) {
const match = pattern.exec(normalized)
if (!match) {
continue
}
return normalizeWhitespace(normalized.slice(match.index + match[0].length))
}
return normalizeWhitespace(normalized)
}
function aliasVariants(token: string): string[] {
const aliases = new Set<string>([token])
if (token.endsWith('а') && token.length > 2) {
aliases.add(`${token.slice(0, -1)}ы`)
aliases.add(`${token.slice(0, -1)}е`)
aliases.add(`${token.slice(0, -1)}у`)
aliases.add(`${token.slice(0, -1)}ой`)
}
if (token.endsWith('я') && token.length > 2) {
aliases.add(`${token.slice(0, -1)}и`)
aliases.add(`${token.slice(0, -1)}ю`)
aliases.add(`${token.slice(0, -1)}ей`)
}
if (token.endsWith('й') && token.length > 2) {
aliases.add(`${token.slice(0, -1)}я`)
aliases.add(`${token.slice(0, -1)}ю`)
}
return [...aliases]
}
function normalizeText(value: string): string {
return value
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/gu, ' ')
.trim()
}
function memberAliases(member: HouseholdMemberRecord): string[] {
const normalized = normalizeText(member.displayName)
const tokens = normalized.split(' ').filter((token) => token.length >= 2)
const aliases = new Set<string>([normalized, ...tokens])
for (const token of tokens) {
for (const alias of aliasVariants(token)) {
aliases.add(alias)
}
}
return [...aliases]
}
function detectAssignee(
text: string,
members: readonly HouseholdMemberRecord[],
senderMemberId: string
): string | null {
const normalizedText = ` ${normalizeText(text)} `
const candidates = members
.filter((member) => member.status === 'active' && member.id !== senderMemberId)
.map((member) => ({
memberId: member.id,
score: memberAliases(member).reduce((best, alias) => {
const normalizedAlias = alias.trim()
if (normalizedAlias.length < 2) {
return best
}
if (normalizedText.includes(` ${normalizedAlias} `)) {
return Math.max(best, normalizedAlias.length + 10)
}
return best
}, 0)
}))
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score)
return candidates[0]?.memberId ?? null
}
function parseTime(text: string): {
hour: number
minute: number
matchedText: string | null
} | null {
const explicit = /(?:^|\s)(?:at|в)\s*(\d{1,2})(?::(\d{2}))?(?=$|[^\d])/iu.exec(text)
const standalone = explicit ? explicit : /(?:^|\s)(\d{1,2}):(\d{2})(?=$|[^\d])/u.exec(text)
const match = standalone
if (!match) {
return null
}
const hour = Number(match[1])
const minute = Number(match[2] ?? '0')
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour > 23 || minute > 59) {
return null
}
return {
hour,
minute,
matchedText: match[0]
}
}
function parseDate(
text: string,
timezone: string,
referenceInstant: Instant
): {
date: Temporal.PlainDate
matchedText: string | null
precision: 'exact' | 'date_only_defaulted'
} | null {
const localNow = referenceInstant.toZonedDateTimeISO(timezone)
const relativePatterns: Array<{
pattern: RegExp
days: number
}> = [
{ pattern: /\bday after tomorrow\b/iu, days: 2 },
{ pattern: /(?:^|[^\p{L}])послезавтра(?=$|[^\p{L}])/iu, days: 2 },
{ pattern: /\btomorrow\b/iu, days: 1 },
{ pattern: /(?:^|[^\p{L}])завтра(?=$|[^\p{L}])/iu, days: 1 },
{ pattern: /\btoday\b/iu, days: 0 },
{ pattern: /(?:^|[^\p{L}])сегодня(?=$|[^\p{L}])/iu, days: 0 }
]
for (const entry of relativePatterns) {
const match = entry.pattern.exec(text)
if (!match) {
continue
}
return {
date: localNow.toPlainDate().add({ days: entry.days }),
matchedText: match[0],
precision: 'date_only_defaulted'
}
}
const isoMatch = /\b(\d{4})-(\d{2})-(\d{2})\b/u.exec(text)
if (isoMatch) {
return {
date: Temporal.PlainDate.from({
year: Number(isoMatch[1]),
month: Number(isoMatch[2]),
day: Number(isoMatch[3])
}),
matchedText: isoMatch[0],
precision: 'date_only_defaulted'
}
}
const dottedMatch = /\b(\d{1,2})[./](\d{1,2})(?:[./](\d{4}))?\b/u.exec(text)
if (dottedMatch) {
return {
date: Temporal.PlainDate.from({
year: Number(dottedMatch[3] ?? String(localNow.year)),
month: Number(dottedMatch[2]),
day: Number(dottedMatch[1])
}),
matchedText: dottedMatch[0],
precision: 'date_only_defaulted'
}
}
return null
}
export function parseAdHocNotificationSchedule(input: {
text: string
timezone: string
now?: Instant
}): ParsedAdHocNotificationSchedule {
const rawText = normalizeWhitespace(input.text)
const referenceInstant = input.now ?? nowInstant()
const date = parseDate(rawText, input.timezone, referenceInstant)
const time = parseTime(rawText)
if (!date) {
return {
kind: 'missing_schedule',
scheduledFor: null,
timePrecision: null
}
}
const scheduledDateTime = Temporal.ZonedDateTime.from({
timeZone: input.timezone,
year: date.date.year,
month: date.date.month,
day: date.date.day,
hour: time?.hour ?? DAYTIME_DEFAULT_HOUR,
minute: time?.minute ?? 0,
second: 0,
millisecond: 0
}).toInstant()
if (scheduledDateTime.epochMilliseconds <= referenceInstant.epochMilliseconds) {
return {
kind: 'invalid_past',
scheduledFor: scheduledDateTime,
timePrecision: time ? 'exact' : 'date_only_defaulted'
}
}
return {
kind: 'parsed',
scheduledFor: scheduledDateTime,
timePrecision: time ? 'exact' : 'date_only_defaulted'
}
}
function stripScheduleFragments(text: string, fragments: readonly (string | null)[]): string {
let next = text
for (const fragment of fragments) {
if (!fragment || fragment.trim().length === 0) {
continue
}
next = next.replace(fragment, ' ')
}
next = next
.replace(/\b(?:on|at|в)\b/giu, ' ')
.replace(/\s+/gu, ' ')
.trim()
.replace(/^[,.\-:;]+/u, '')
.replace(/[,\-:;]+$/u, '')
.trim()
return next
}
export function parseAdHocNotificationRequest(input: {
text: string
timezone: string
locale: SupportedLocale
members: readonly HouseholdMemberRecord[]
senderMemberId: string
now?: Instant
}): ParsedAdHocNotificationRequest {
const rawText = normalizeWhitespace(input.text)
if (!hasIntent(rawText)) {
return {
kind: 'not_intent',
originalRequestText: rawText,
notificationText: null,
assigneeMemberId: null,
scheduledFor: null,
timePrecision: null
}
}
const body = removeIntentPreamble(rawText)
const referenceInstant = input.now ?? nowInstant()
const date = parseDate(body, input.timezone, referenceInstant)
const time = parseTime(body)
const notificationText = stripScheduleFragments(body, [
date?.matchedText ?? null,
time?.matchedText ?? null
])
const assigneeMemberId = detectAssignee(notificationText, input.members, input.senderMemberId)
if (!date) {
return {
kind: 'missing_schedule',
originalRequestText: rawText,
notificationText: notificationText.length > 0 ? notificationText : body,
assigneeMemberId,
scheduledFor: null,
timePrecision: null
}
}
const schedule = parseAdHocNotificationSchedule({
text: [date.matchedText, time?.matchedText].filter(Boolean).join(' '),
timezone: input.timezone,
now: referenceInstant
})
if (schedule.kind === 'invalid_past') {
return {
kind: 'invalid_past',
originalRequestText: rawText,
notificationText: notificationText.length > 0 ? notificationText : body,
assigneeMemberId,
scheduledFor: schedule.scheduledFor,
timePrecision: schedule.timePrecision
}
}
return {
kind: 'parsed',
originalRequestText: rawText,
notificationText: notificationText.length > 0 ? notificationText : body,
assigneeMemberId,
scheduledFor: schedule.scheduledFor,
timePrecision: schedule.timePrecision
}
}

View File

@@ -0,0 +1,888 @@
import type { AdHocNotificationService } from '@household/application'
import { Temporal, nowInstant } from '@household/domain'
import type { Logger } from '@household/observability'
import type {
AdHocNotificationDeliveryMode,
HouseholdConfigurationRepository,
HouseholdMemberRecord,
TelegramPendingActionRepository
} from '@household/ports'
import type { Bot, Context } from 'grammy'
import type { InlineKeyboardMarkup } from 'grammy/types'
import {
parseAdHocNotificationRequest,
parseAdHocNotificationSchedule
} from './ad-hoc-notification-parser'
import { resolveReplyLocale } from './bot-locale'
import type { BotLocale } from './i18n'
const AD_HOC_NOTIFICATION_ACTION = 'ad_hoc_notification' as const
const AD_HOC_NOTIFICATION_ACTION_TTL_MS = 30 * 60_000
const AD_HOC_NOTIFICATION_CONFIRM_PREFIX = 'adhocnotif:confirm:'
const AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX = 'adhocnotif:canceldraft:'
const AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX = 'adhocnotif:cancel:'
const AD_HOC_NOTIFICATION_MODE_PREFIX = 'adhocnotif:mode:'
const AD_HOC_NOTIFICATION_FRIENDLY_PREFIX = 'adhocnotif:friendly:'
const AD_HOC_NOTIFICATION_MEMBER_PREFIX = 'adhocnotif:member:'
type NotificationDraftPayload =
| {
stage: 'await_schedule'
proposalId: string
householdId: string
threadId: string
creatorMemberId: string
timezone: string
originalRequestText: string
notificationText: string
assigneeMemberId: string | null
deliveryMode: AdHocNotificationDeliveryMode
dmRecipientMemberIds: readonly string[]
friendlyTagAssignee: boolean
}
| {
stage: 'confirm'
proposalId: string
householdId: string
threadId: string
creatorMemberId: string
timezone: string
originalRequestText: string
notificationText: string
assigneeMemberId: string | null
scheduledForIso: string
timePrecision: 'exact' | 'date_only_defaulted'
deliveryMode: AdHocNotificationDeliveryMode
dmRecipientMemberIds: readonly string[]
friendlyTagAssignee: boolean
}
interface ReminderTopicContext {
locale: BotLocale
householdId: string
threadId: string
member: HouseholdMemberRecord
members: readonly HouseholdMemberRecord[]
timezone: string
}
function createProposalId(): string {
return crypto.randomUUID().slice(0, 8)
}
function getMessageThreadId(ctx: Context): string | null {
const message =
ctx.msg ??
(ctx.callbackQuery && 'message' in ctx.callbackQuery ? ctx.callbackQuery.message : null)
if (!message || !('message_thread_id' in message) || message.message_thread_id === undefined) {
return null
}
return message.message_thread_id.toString()
}
function readMessageText(ctx: Context): string | null {
const message = ctx.message
if (!message) {
return null
}
if ('text' in message && typeof message.text === 'string') {
return message.text.trim()
}
if ('caption' in message && typeof message.caption === 'string') {
return message.caption.trim()
}
return null
}
function escapeHtml(raw: string): string {
return raw.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
function formatScheduledFor(locale: BotLocale, scheduledForIso: string, timezone: string): string {
const zdt = Temporal.Instant.from(scheduledForIso).toZonedDateTimeISO(timezone)
const date =
locale === 'ru'
? `${String(zdt.day).padStart(2, '0')}.${String(zdt.month).padStart(2, '0')}.${zdt.year}`
: `${zdt.year}-${String(zdt.month).padStart(2, '0')}-${String(zdt.day).padStart(2, '0')}`
const time = `${String(zdt.hour).padStart(2, '0')}:${String(zdt.minute).padStart(2, '0')}`
return `${date} ${time} (${timezone})`
}
function deliveryModeLabel(locale: BotLocale, mode: AdHocNotificationDeliveryMode): string {
if (locale === 'ru') {
switch (mode) {
case 'topic':
return 'в этот топик'
case 'dm_all':
return 'всем в личку'
case 'dm_selected':
return 'выбранным в личку'
}
}
switch (mode) {
case 'topic':
return 'this topic'
case 'dm_all':
return 'DM all members'
case 'dm_selected':
return 'DM selected members'
}
}
function notificationSummaryText(input: {
locale: BotLocale
payload: Extract<NotificationDraftPayload, { stage: 'confirm' }>
members: readonly HouseholdMemberRecord[]
}): string {
const assignee = input.payload.assigneeMemberId
? input.members.find((member) => member.id === input.payload.assigneeMemberId)
: null
const selectedRecipients =
input.payload.deliveryMode === 'dm_selected'
? input.members.filter((member) => input.payload.dmRecipientMemberIds.includes(member.id))
: []
if (input.locale === 'ru') {
return [
'Запланировать напоминание?',
'',
`Текст: ${input.payload.notificationText}`,
`Когда: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`,
`Точность: ${input.payload.timePrecision === 'date_only_defaulted' ? 'время по умолчанию 12:00' : 'точное время'}`,
`Куда: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`,
assignee ? `Ответственный: ${assignee.displayName}` : null,
input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0
? `Получатели: ${selectedRecipients.map((member) => member.displayName).join(', ')}`
: null,
assignee ? `Дружелюбный тег: ${input.payload.friendlyTagAssignee ? 'вкл' : 'выкл'}` : null,
'',
'Подтвердите или измените настройки ниже.'
]
.filter(Boolean)
.join('\n')
}
return [
'Schedule this notification?',
'',
`Text: ${input.payload.notificationText}`,
`When: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`,
`Precision: ${input.payload.timePrecision === 'date_only_defaulted' ? 'defaulted to 12:00' : 'exact time'}`,
`Delivery: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`,
assignee ? `Assignee: ${assignee.displayName}` : null,
input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0
? `Recipients: ${selectedRecipients.map((member) => member.displayName).join(', ')}`
: null,
assignee ? `Friendly tag: ${input.payload.friendlyTagAssignee ? 'on' : 'off'}` : null,
'',
'Confirm or adjust below.'
]
.filter(Boolean)
.join('\n')
}
function notificationDraftReplyMarkup(
locale: BotLocale,
payload: Extract<NotificationDraftPayload, { stage: 'confirm' }>,
members: readonly HouseholdMemberRecord[]
): InlineKeyboardMarkup {
const deliveryButtons = [
{
text: `${payload.deliveryMode === 'topic' ? '• ' : ''}${locale === 'ru' ? 'В топик' : 'Topic'}`,
callback_data: `${AD_HOC_NOTIFICATION_MODE_PREFIX}${payload.proposalId}:topic`
},
{
text: `${payload.deliveryMode === 'dm_all' ? '• ' : ''}${locale === 'ru' ? 'Всем ЛС' : 'DM all'}`,
callback_data: `${AD_HOC_NOTIFICATION_MODE_PREFIX}${payload.proposalId}:dm_all`
}
]
const rows: InlineKeyboardMarkup['inline_keyboard'] = [
[
{
text: locale === 'ru' ? 'Подтвердить' : 'Confirm',
callback_data: `${AD_HOC_NOTIFICATION_CONFIRM_PREFIX}${payload.proposalId}`
},
{
text: locale === 'ru' ? 'Отменить' : 'Cancel',
callback_data: `${AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX}${payload.proposalId}`
}
],
deliveryButtons,
[
{
text: `${payload.deliveryMode === 'dm_selected' ? '• ' : ''}${locale === 'ru' ? 'Выбрать ЛС' : 'DM selected'}`,
callback_data: `${AD_HOC_NOTIFICATION_MODE_PREFIX}${payload.proposalId}:dm_selected`
}
]
]
if (payload.assigneeMemberId) {
rows.push([
{
text: `${payload.friendlyTagAssignee ? '✅ ' : ''}${locale === 'ru' ? 'Тегнуть ответственного' : 'Friendly tag assignee'}`,
callback_data: `${AD_HOC_NOTIFICATION_FRIENDLY_PREFIX}${payload.proposalId}`
}
])
}
if (payload.deliveryMode === 'dm_selected') {
const eligibleMembers = members.filter((member) => member.status === 'active')
for (const member of eligibleMembers) {
rows.push([
{
text: `${payload.dmRecipientMemberIds.includes(member.id) ? '✅ ' : ''}${member.displayName}`,
callback_data: `${AD_HOC_NOTIFICATION_MEMBER_PREFIX}${payload.proposalId}:${member.id}`
}
])
}
}
return {
inline_keyboard: rows
}
}
function buildSavedNotificationReplyMarkup(
locale: BotLocale,
notificationId: string
): InlineKeyboardMarkup {
return {
inline_keyboard: [
[
{
text: locale === 'ru' ? 'Отменить напоминание' : 'Cancel notification',
callback_data: `${AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX}${notificationId}`
}
]
]
}
}
async function replyInTopic(
ctx: Context,
text: string,
replyMarkup?: InlineKeyboardMarkup,
options?: {
parseMode?: 'HTML'
}
): Promise<void> {
const message = ctx.msg
if (!ctx.chat || !message) {
return
}
const threadId =
'message_thread_id' in message && message.message_thread_id !== undefined
? message.message_thread_id
: undefined
await ctx.api.sendMessage(ctx.chat.id, text, {
...(threadId !== undefined
? {
message_thread_id: threadId
}
: {}),
reply_parameters: {
message_id: message.message_id
},
...(replyMarkup
? {
reply_markup: replyMarkup as InlineKeyboardMarkup
}
: {}),
...(options?.parseMode
? {
parse_mode: options.parseMode
}
: {})
})
}
async function resolveReminderTopicContext(
ctx: Context,
repository: HouseholdConfigurationRepository
): Promise<ReminderTopicContext | null> {
if (ctx.chat?.type !== 'group' && ctx.chat?.type !== 'supergroup') {
return null
}
const threadId = getMessageThreadId(ctx)
if (!ctx.chat || !threadId) {
return null
}
const binding = await repository.findHouseholdTopicByTelegramContext({
telegramChatId: ctx.chat.id.toString(),
telegramThreadId: threadId
})
if (!binding || binding.role !== 'reminders') {
return null
}
const telegramUserId = ctx.from?.id?.toString()
if (!telegramUserId) {
return null
}
const [locale, member, members, settings] = await Promise.all([
resolveReplyLocale({
ctx,
repository,
householdId: binding.householdId
}),
repository.getHouseholdMember(binding.householdId, telegramUserId),
repository.listHouseholdMembers(binding.householdId),
repository.getHouseholdBillingSettings(binding.householdId)
])
if (!member) {
return null
}
return {
locale,
householdId: binding.householdId,
threadId,
member,
members,
timezone: settings.timezone
}
}
async function saveDraft(
repository: TelegramPendingActionRepository,
ctx: Context,
payload: NotificationDraftPayload
): Promise<void> {
const telegramUserId = ctx.from?.id?.toString()
const chatId = ctx.chat?.id?.toString()
if (!telegramUserId || !chatId) {
return
}
await repository.upsertPendingAction({
telegramUserId,
telegramChatId: chatId,
action: AD_HOC_NOTIFICATION_ACTION,
payload,
expiresAt: nowInstant().add({ milliseconds: AD_HOC_NOTIFICATION_ACTION_TTL_MS })
})
}
async function loadDraft(
repository: TelegramPendingActionRepository,
ctx: Context
): Promise<NotificationDraftPayload | null> {
const telegramUserId = ctx.from?.id?.toString()
const chatId = ctx.chat?.id?.toString()
if (!telegramUserId || !chatId) {
return null
}
const pending = await repository.getPendingAction(chatId, telegramUserId)
return pending?.action === AD_HOC_NOTIFICATION_ACTION
? (pending.payload as NotificationDraftPayload)
: null
}
export function registerAdHocNotifications(options: {
bot: Bot
householdConfigurationRepository: HouseholdConfigurationRepository
promptRepository: TelegramPendingActionRepository
notificationService: AdHocNotificationService
logger?: Logger
}): void {
async function showDraftConfirmation(
ctx: Context,
draft: Extract<NotificationDraftPayload, { stage: 'confirm' }>
) {
const reminderContext = await resolveReminderTopicContext(
ctx,
options.householdConfigurationRepository
)
if (!reminderContext) {
return
}
await replyInTopic(
ctx,
notificationSummaryText({
locale: reminderContext.locale,
payload: draft,
members: reminderContext.members
}),
notificationDraftReplyMarkup(reminderContext.locale, draft, reminderContext.members)
)
}
async function refreshConfirmationMessage(
ctx: Context,
payload: Extract<NotificationDraftPayload, { stage: 'confirm' }>
) {
const reminderContext = await resolveReminderTopicContext(
ctx,
options.householdConfigurationRepository
)
if (!reminderContext || !ctx.callbackQuery || !('message' in ctx.callbackQuery)) {
return
}
await saveDraft(options.promptRepository, ctx, payload)
await ctx.editMessageText(
notificationSummaryText({
locale: reminderContext.locale,
payload,
members: reminderContext.members
}),
{
reply_markup: notificationDraftReplyMarkup(
reminderContext.locale,
payload,
reminderContext.members
)
}
)
}
options.bot.command('notifications', async (ctx, next) => {
const reminderContext = await resolveReminderTopicContext(
ctx,
options.householdConfigurationRepository
)
if (!reminderContext) {
await next()
return
}
const items = await options.notificationService.listUpcomingNotifications({
householdId: reminderContext.householdId,
viewerMemberId: reminderContext.member.id
})
const locale = reminderContext.locale
if (items.length === 0) {
await replyInTopic(
ctx,
locale === 'ru'
? 'Пока нет будущих напоминаний, которые вы можете отменить.'
: 'There are no upcoming notifications you can cancel yet.'
)
return
}
const lines = items.slice(0, 10).map((item, index) => {
const when = formatScheduledFor(
locale,
item.scheduledFor.toString(),
reminderContext.timezone
)
return `${index + 1}. ${item.notificationText}\n${when}\n${deliveryModeLabel(locale, item.deliveryMode)}`
})
const keyboard: InlineKeyboardMarkup = {
inline_keyboard: items.slice(0, 10).map((item, index) => [
{
text: locale === 'ru' ? `Отменить ${index + 1}` : `Cancel ${index + 1}`,
callback_data: `${AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX}${item.id}`
}
])
}
await replyInTopic(
ctx,
[locale === 'ru' ? 'Ближайшие напоминания:' : 'Upcoming notifications:', '', ...lines].join(
'\n'
),
keyboard
)
})
options.bot.on('message', async (ctx, next) => {
const messageText = readMessageText(ctx)
if (!messageText || messageText.startsWith('/')) {
await next()
return
}
const reminderContext = await resolveReminderTopicContext(
ctx,
options.householdConfigurationRepository
)
if (!reminderContext) {
await next()
return
}
const existingDraft = await loadDraft(options.promptRepository, ctx)
if (existingDraft && existingDraft.threadId === reminderContext.threadId) {
if (existingDraft.stage === 'await_schedule') {
const schedule = parseAdHocNotificationSchedule({
text: messageText,
timezone: existingDraft.timezone
})
if (schedule.kind === 'missing_schedule') {
await replyInTopic(
ctx,
reminderContext.locale === 'ru'
? 'Нужны хотя бы день или дата. Например: «завтра», «24.03», «2026-03-24 18:30».'
: 'I still need at least a day or date. For example: "tomorrow", "2026-03-24", or "2026-03-24 18:30".'
)
return
}
if (schedule.kind === 'invalid_past') {
await replyInTopic(
ctx,
reminderContext.locale === 'ru'
? 'Это время уже в прошлом. Пришлите будущую дату или время.'
: 'That time is already in the past. Send a future date or time.'
)
return
}
const confirmPayload: Extract<NotificationDraftPayload, { stage: 'confirm' }> = {
...existingDraft,
stage: 'confirm',
scheduledForIso: schedule.scheduledFor!.toString(),
timePrecision: schedule.timePrecision!
}
await saveDraft(options.promptRepository, ctx, confirmPayload)
await showDraftConfirmation(ctx, confirmPayload)
return
}
await next()
return
}
const parsed = parseAdHocNotificationRequest({
text: messageText,
timezone: reminderContext.timezone,
locale: reminderContext.locale,
members: reminderContext.members,
senderMemberId: reminderContext.member.id
})
if (parsed.kind === 'not_intent') {
await next()
return
}
if (!parsed.notificationText || parsed.notificationText.length === 0) {
await replyInTopic(
ctx,
reminderContext.locale === 'ru'
? 'Не понял текст напоминания. Сформулируйте, что именно нужно напомнить.'
: 'I could not extract the notification text. Please restate what should be reminded.'
)
return
}
if (parsed.kind === 'missing_schedule') {
await saveDraft(options.promptRepository, ctx, {
stage: 'await_schedule',
proposalId: createProposalId(),
householdId: reminderContext.householdId,
threadId: reminderContext.threadId,
creatorMemberId: reminderContext.member.id,
timezone: reminderContext.timezone,
originalRequestText: parsed.originalRequestText,
notificationText: parsed.notificationText,
assigneeMemberId: parsed.assigneeMemberId,
deliveryMode: 'topic',
dmRecipientMemberIds: [],
friendlyTagAssignee: false
})
await replyInTopic(
ctx,
reminderContext.locale === 'ru'
? 'Когда напомнить? Подойдёт свободная форма, например: «завтра», «завтра в 15:00», «24.03 18:30».'
: 'When should I remind? Free-form is fine, for example: "tomorrow", "tomorrow 15:00", or "2026-03-24 18:30".'
)
return
}
if (parsed.kind === 'invalid_past') {
await replyInTopic(
ctx,
reminderContext.locale === 'ru'
? 'Это время уже в прошлом. Пришлите будущую дату или время.'
: 'That time is already in the past. Send a future date or time.'
)
return
}
const draft: Extract<NotificationDraftPayload, { stage: 'confirm' }> = {
stage: 'confirm',
proposalId: createProposalId(),
householdId: reminderContext.householdId,
threadId: reminderContext.threadId,
creatorMemberId: reminderContext.member.id,
timezone: reminderContext.timezone,
originalRequestText: parsed.originalRequestText,
notificationText: parsed.notificationText,
assigneeMemberId: parsed.assigneeMemberId,
scheduledForIso: parsed.scheduledFor!.toString(),
timePrecision: parsed.timePrecision!,
deliveryMode: 'topic',
dmRecipientMemberIds: [],
friendlyTagAssignee: false
}
await saveDraft(options.promptRepository, ctx, draft)
await showDraftConfirmation(ctx, draft)
})
options.bot.on('callback_query:data', async (ctx, next) => {
const data = typeof ctx.callbackQuery?.data === 'string' ? ctx.callbackQuery.data : null
if (!data) {
await next()
return
}
if (data.startsWith(AD_HOC_NOTIFICATION_CONFIRM_PREFIX)) {
const proposalId = data.slice(AD_HOC_NOTIFICATION_CONFIRM_PREFIX.length)
const reminderContext = await resolveReminderTopicContext(
ctx,
options.householdConfigurationRepository
)
const payload = await loadDraft(options.promptRepository, ctx)
if (
!reminderContext ||
!payload ||
payload.stage !== 'confirm' ||
payload.proposalId !== proposalId
) {
await next()
return
}
const result = await options.notificationService.scheduleNotification({
householdId: payload.householdId,
creatorMemberId: payload.creatorMemberId,
originalRequestText: payload.originalRequestText,
notificationText: payload.notificationText,
timezone: payload.timezone,
scheduledFor: Temporal.Instant.from(payload.scheduledForIso),
timePrecision: payload.timePrecision,
deliveryMode: payload.deliveryMode,
assigneeMemberId: payload.assigneeMemberId,
dmRecipientMemberIds: payload.dmRecipientMemberIds,
friendlyTagAssignee: payload.friendlyTagAssignee,
sourceTelegramChatId: ctx.chat?.id?.toString() ?? null,
sourceTelegramThreadId: payload.threadId
})
if (result.status !== 'scheduled') {
await ctx.answerCallbackQuery({
text:
reminderContext.locale === 'ru'
? 'Не удалось сохранить напоминание.'
: 'Failed to save notification.',
show_alert: true
})
return
}
await options.promptRepository.clearPendingAction(
ctx.chat!.id.toString(),
ctx.from!.id.toString()
)
await ctx.answerCallbackQuery({
text:
reminderContext.locale === 'ru' ? 'Напоминание запланировано.' : 'Notification scheduled.'
})
await ctx.editMessageText(
[
reminderContext.locale === 'ru'
? `Напоминание запланировано: ${result.notification.notificationText}`
: `Notification scheduled: ${result.notification.notificationText}`,
formatScheduledFor(
reminderContext.locale,
result.notification.scheduledFor.toString(),
result.notification.timezone
)
].join('\n'),
{
reply_markup: buildSavedNotificationReplyMarkup(
reminderContext.locale,
result.notification.id
)
}
)
return
}
if (data.startsWith(AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX)) {
const proposalId = data.slice(AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX.length)
const payload = await loadDraft(options.promptRepository, ctx)
if (!payload || payload.proposalId !== proposalId || !ctx.chat || !ctx.from) {
await next()
return
}
await options.promptRepository.clearPendingAction(
ctx.chat.id.toString(),
ctx.from.id.toString()
)
await ctx.answerCallbackQuery({
text: 'Cancelled'
})
await ctx.editMessageText('Cancelled', {
reply_markup: {
inline_keyboard: []
}
})
return
}
if (data.startsWith(AD_HOC_NOTIFICATION_MODE_PREFIX)) {
const [proposalId, mode] = data.slice(AD_HOC_NOTIFICATION_MODE_PREFIX.length).split(':')
const payload = await loadDraft(options.promptRepository, ctx)
if (
!payload ||
payload.stage !== 'confirm' ||
payload.proposalId !== proposalId ||
(mode !== 'topic' && mode !== 'dm_all' && mode !== 'dm_selected')
) {
await next()
return
}
const nextPayload: Extract<NotificationDraftPayload, { stage: 'confirm' }> = {
...payload,
deliveryMode: mode,
dmRecipientMemberIds: mode === 'dm_selected' ? payload.dmRecipientMemberIds : []
}
await refreshConfirmationMessage(ctx, nextPayload)
await ctx.answerCallbackQuery()
return
}
if (data.startsWith(AD_HOC_NOTIFICATION_FRIENDLY_PREFIX)) {
const proposalId = data.slice(AD_HOC_NOTIFICATION_FRIENDLY_PREFIX.length)
const payload = await loadDraft(options.promptRepository, ctx)
if (!payload || payload.stage !== 'confirm' || payload.proposalId !== proposalId) {
await next()
return
}
await refreshConfirmationMessage(ctx, {
...payload,
friendlyTagAssignee: !payload.friendlyTagAssignee
})
await ctx.answerCallbackQuery()
return
}
if (data.startsWith(AD_HOC_NOTIFICATION_MEMBER_PREFIX)) {
const rest = data.slice(AD_HOC_NOTIFICATION_MEMBER_PREFIX.length)
const separatorIndex = rest.indexOf(':')
const proposalId = separatorIndex >= 0 ? rest.slice(0, separatorIndex) : ''
const memberId = separatorIndex >= 0 ? rest.slice(separatorIndex + 1) : ''
const payload = await loadDraft(options.promptRepository, ctx)
if (
!payload ||
payload.stage !== 'confirm' ||
payload.proposalId !== proposalId ||
payload.deliveryMode !== 'dm_selected'
) {
await next()
return
}
const selected = new Set(payload.dmRecipientMemberIds)
if (selected.has(memberId)) {
selected.delete(memberId)
} else {
selected.add(memberId)
}
await refreshConfirmationMessage(ctx, {
...payload,
dmRecipientMemberIds: [...selected]
})
await ctx.answerCallbackQuery()
return
}
if (data.startsWith(AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX)) {
const notificationId = data.slice(AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX.length)
const reminderContext = await resolveReminderTopicContext(
ctx,
options.householdConfigurationRepository
)
if (!reminderContext) {
await next()
return
}
const result = await options.notificationService.cancelNotification({
notificationId,
viewerMemberId: reminderContext.member.id
})
if (result.status !== 'cancelled') {
await ctx.answerCallbackQuery({
text:
reminderContext.locale === 'ru'
? 'Не удалось отменить напоминание.'
: 'Could not cancel this notification.',
show_alert: true
})
return
}
await ctx.answerCallbackQuery({
text: reminderContext.locale === 'ru' ? 'Напоминание отменено.' : 'Notification cancelled.'
})
await ctx.editMessageText(
reminderContext.locale === 'ru'
? `Напоминание отменено: ${result.notification.notificationText}`
: `Notification cancelled: ${result.notification.notificationText}`,
{
reply_markup: {
inline_keyboard: []
}
}
)
return
}
await next()
})
}
export function buildTopicNotificationText(input: {
notificationText: string
assignee?: {
displayName: string
telegramUserId: string
} | null
friendlyTagAssignee: boolean
}): {
text: string
parseMode: 'HTML'
} {
if (input.friendlyTagAssignee && input.assignee) {
return {
text: `<a href="tg://user?id=${escapeHtml(input.assignee.telegramUserId)}">${escapeHtml(input.assignee.displayName)}</a>, ${escapeHtml(input.notificationText)}`,
parseMode: 'HTML'
}
}
return {
text: escapeHtml(input.notificationText),
parseMode: 'HTML'
}
}

View File

@@ -2,6 +2,7 @@ import { webhookCallback } from 'grammy'
import type { InlineKeyboardMarkup } from 'grammy/types'
import {
createAdHocNotificationService,
createAnonymousFeedbackService,
createFinanceCommandService,
createHouseholdAdminService,
@@ -13,6 +14,7 @@ import {
createReminderJobService
} from '@household/application'
import {
createDbAdHocNotificationRepository,
createDbAnonymousFeedbackRepository,
createDbFinanceRepository,
createDbHouseholdConfigurationRepository,
@@ -23,6 +25,8 @@ import {
} from '@household/adapters-db'
import { configureLogger, getLogger } from '@household/observability'
import { createAdHocNotificationJobsHandler } from './ad-hoc-notification-jobs'
import { registerAdHocNotifications } from './ad-hoc-notifications'
import { registerAnonymousFeedback } from './anonymous-feedback'
import {
createInMemoryAssistantConversationMemoryStore,
@@ -178,6 +182,16 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
string,
ReturnType<typeof createAnonymousFeedbackService>
>()
const adHocNotificationRepositoryClient = runtime.databaseUrl
? createDbAdHocNotificationRepository(runtime.databaseUrl)
: null
const adHocNotificationService =
adHocNotificationRepositoryClient && householdConfigurationRepositoryClient
? createAdHocNotificationService({
repository: adHocNotificationRepositoryClient.repository,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository
})
: null
function financeServiceForHousehold(householdId: string) {
const existing = financeServices.get(householdId)
@@ -260,6 +274,10 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
shutdownTasks.push(topicMessageHistoryRepositoryClient.close)
}
if (adHocNotificationRepositoryClient) {
shutdownTasks.push(adHocNotificationRepositoryClient.close)
}
if (purchaseRepositoryClient && householdConfigurationRepositoryClient) {
registerConfiguredPurchaseTopicIngestion(
bot,
@@ -375,6 +393,20 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
)
}
if (
householdConfigurationRepositoryClient &&
telegramPendingActionRepositoryClient &&
adHocNotificationService
) {
registerAdHocNotifications({
bot,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
promptRepository: telegramPendingActionRepositoryClient.repository,
notificationService: adHocNotificationService,
logger: getLogger('ad-hoc-notifications')
})
}
const reminderJobs = runtime.reminderJobsEnabled
? (() => {
const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!)
@@ -428,6 +460,34 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
})
})()
: null
const adHocNotificationJobs =
runtime.reminderJobsEnabled &&
adHocNotificationService &&
householdConfigurationRepositoryClient
? createAdHocNotificationJobsHandler({
notificationService: adHocNotificationService,
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
sendTopicMessage: async (input) => {
const threadId = input.threadId ? Number(input.threadId) : undefined
await bot.api.sendMessage(input.chatId, input.text, {
...(threadId && Number.isInteger(threadId)
? {
message_thread_id: threadId
}
: {}),
...(input.parseMode
? {
parse_mode: input.parseMode
}
: {})
})
},
sendDirectMessage: async (input) => {
await bot.api.sendMessage(input.telegramUserId, input.text)
},
logger: getLogger('scheduler')
})
: null
if (!runtime.reminderJobsEnabled) {
logger.warn(
@@ -825,20 +885,50 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
})
: undefined,
scheduler:
reminderJobs && runtime.schedulerSharedSecret
(reminderJobs || adHocNotificationJobs) && runtime.schedulerSharedSecret
? {
pathPrefix: '/jobs',
authorize: createSchedulerRequestAuthorizer({
sharedSecret: runtime.schedulerSharedSecret,
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
}).authorize,
handler: reminderJobs.handle
handler: async (request, jobPath) => {
if (jobPath.startsWith('reminder/')) {
return reminderJobs
? reminderJobs.handle(request, jobPath.slice('reminder/'.length))
: new Response('Not Found', { status: 404 })
}
if (jobPath === 'notifications/due') {
return adHocNotificationJobs
? adHocNotificationJobs.handle(request)
: new Response('Not Found', { status: 404 })
}
return new Response('Not Found', { status: 404 })
}
}
: reminderJobs
: reminderJobs || adHocNotificationJobs
? {
pathPrefix: '/jobs',
authorize: createSchedulerRequestAuthorizer({
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
}).authorize,
handler: reminderJobs.handle
handler: async (request, jobPath) => {
if (jobPath.startsWith('reminder/')) {
return reminderJobs
? reminderJobs.handle(request, jobPath.slice('reminder/'.length))
: new Response('Not Found', { status: 404 })
}
if (jobPath === 'notifications/due') {
return adHocNotificationJobs
? adHocNotificationJobs.handle(request)
: new Response('Not Found', { status: 404 })
}
return new Response('Not Found', { status: 404 })
}
}
: undefined
})

View File

@@ -66,7 +66,7 @@ describe('createOpenAiChatAssistant', () => {
expect(capturedBody!.input[1]?.role).toBe('system')
expect(capturedBody!.input[1]?.content).toContain('Topic role: reminders')
expect(capturedBody!.input[1]?.content).toContain(
'You cannot create, schedule, snooze, or manage arbitrary personal reminders.'
'Members can ask the bot to schedule a future notification in this topic.'
)
} finally {
globalThis.fetch = originalFetch

View File

@@ -61,9 +61,10 @@ function topicCapabilityNotes(topicRole: TopicMessageRole): string {
case 'reminders':
return [
'Reminders topic capabilities:',
'- You can discuss existing household rent/utilities reminder timing and the supported utility-bill collection flow.',
'- You cannot create, schedule, snooze, or manage arbitrary personal reminders.',
'- You cannot promise future reminder setup. If asked, say that this feature is not supported.'
'- 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.',
'- 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.'
].join('\n')
case 'feedback':
return [

View File

@@ -0,0 +1,276 @@
import { and, asc, eq, lte } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import { instantFromDatabaseValue, instantToDate, nowInstant } from '@household/domain'
import type {
AdHocNotificationRecord,
AdHocNotificationRepository,
ClaimAdHocNotificationDeliveryResult
} from '@household/ports'
const DELIVERY_CLAIM_SOURCE = 'ad-hoc-notification'
function parseMemberIds(raw: unknown): readonly string[] {
if (!Array.isArray(raw)) {
return []
}
return raw.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
}
function mapNotification(row: {
id: string
householdId: string
creatorMemberId: string
assigneeMemberId: string | null
originalRequestText: string
notificationText: string
timezone: string
scheduledFor: Date | string
timePrecision: string
deliveryMode: string
dmRecipientMemberIds: unknown
friendlyTagAssignee: number
status: string
sourceTelegramChatId: string | null
sourceTelegramThreadId: string | null
sentAt: Date | string | null
cancelledAt: Date | string | null
cancelledByMemberId: string | null
createdAt: Date | string
updatedAt: Date | string
}): AdHocNotificationRecord {
return {
id: row.id,
householdId: row.householdId,
creatorMemberId: row.creatorMemberId,
assigneeMemberId: row.assigneeMemberId,
originalRequestText: row.originalRequestText,
notificationText: row.notificationText,
timezone: row.timezone,
scheduledFor: instantFromDatabaseValue(row.scheduledFor)!,
timePrecision: row.timePrecision as AdHocNotificationRecord['timePrecision'],
deliveryMode: row.deliveryMode as AdHocNotificationRecord['deliveryMode'],
dmRecipientMemberIds: parseMemberIds(row.dmRecipientMemberIds),
friendlyTagAssignee: row.friendlyTagAssignee === 1,
status: row.status as AdHocNotificationRecord['status'],
sourceTelegramChatId: row.sourceTelegramChatId,
sourceTelegramThreadId: row.sourceTelegramThreadId,
sentAt: instantFromDatabaseValue(row.sentAt),
cancelledAt: instantFromDatabaseValue(row.cancelledAt),
cancelledByMemberId: row.cancelledByMemberId,
createdAt: instantFromDatabaseValue(row.createdAt)!,
updatedAt: instantFromDatabaseValue(row.updatedAt)!
}
}
function notificationSelect() {
return {
id: schema.adHocNotifications.id,
householdId: schema.adHocNotifications.householdId,
creatorMemberId: schema.adHocNotifications.creatorMemberId,
assigneeMemberId: schema.adHocNotifications.assigneeMemberId,
originalRequestText: schema.adHocNotifications.originalRequestText,
notificationText: schema.adHocNotifications.notificationText,
timezone: schema.adHocNotifications.timezone,
scheduledFor: schema.adHocNotifications.scheduledFor,
timePrecision: schema.adHocNotifications.timePrecision,
deliveryMode: schema.adHocNotifications.deliveryMode,
dmRecipientMemberIds: schema.adHocNotifications.dmRecipientMemberIds,
friendlyTagAssignee: schema.adHocNotifications.friendlyTagAssignee,
status: schema.adHocNotifications.status,
sourceTelegramChatId: schema.adHocNotifications.sourceTelegramChatId,
sourceTelegramThreadId: schema.adHocNotifications.sourceTelegramThreadId,
sentAt: schema.adHocNotifications.sentAt,
cancelledAt: schema.adHocNotifications.cancelledAt,
cancelledByMemberId: schema.adHocNotifications.cancelledByMemberId,
createdAt: schema.adHocNotifications.createdAt,
updatedAt: schema.adHocNotifications.updatedAt
}
}
export function createDbAdHocNotificationRepository(databaseUrl: string): {
repository: AdHocNotificationRepository
close: () => Promise<void>
} {
const { db, queryClient } = createDbClient(databaseUrl, {
max: 3,
prepare: false
})
const repository: AdHocNotificationRepository = {
async createNotification(input) {
const timestamp = instantToDate(nowInstant())
const rows = await db
.insert(schema.adHocNotifications)
.values({
householdId: input.householdId,
creatorMemberId: input.creatorMemberId,
assigneeMemberId: input.assigneeMemberId ?? null,
originalRequestText: input.originalRequestText,
notificationText: input.notificationText,
timezone: input.timezone,
scheduledFor: instantToDate(input.scheduledFor),
timePrecision: input.timePrecision,
deliveryMode: input.deliveryMode,
dmRecipientMemberIds: input.dmRecipientMemberIds ?? [],
friendlyTagAssignee: input.friendlyTagAssignee ? 1 : 0,
status: 'scheduled',
sourceTelegramChatId: input.sourceTelegramChatId ?? null,
sourceTelegramThreadId: input.sourceTelegramThreadId ?? null,
updatedAt: timestamp
})
.returning(notificationSelect())
const row = rows[0]
if (!row) {
throw new Error('Notification insert did not return a row')
}
return mapNotification(row)
},
async getNotificationById(notificationId) {
const rows = await db
.select(notificationSelect())
.from(schema.adHocNotifications)
.where(eq(schema.adHocNotifications.id, notificationId))
.limit(1)
return rows[0] ? mapNotification(rows[0]) : null
},
async listUpcomingNotificationsForHousehold(householdId, asOf) {
const rows = await db
.select(notificationSelect())
.from(schema.adHocNotifications)
.where(
and(
eq(schema.adHocNotifications.householdId, householdId),
eq(schema.adHocNotifications.status, 'scheduled'),
lte(schema.adHocNotifications.createdAt, instantToDate(asOf))
)
)
.orderBy(
asc(schema.adHocNotifications.scheduledFor),
asc(schema.adHocNotifications.createdAt)
)
return rows
.map(mapNotification)
.filter((record) => record.scheduledFor.epochMilliseconds >= asOf.epochMilliseconds)
},
async cancelNotification(input) {
const rows = await db
.update(schema.adHocNotifications)
.set({
status: 'cancelled',
cancelledAt: instantToDate(input.cancelledAt),
cancelledByMemberId: input.cancelledByMemberId,
updatedAt: instantToDate(nowInstant())
})
.where(
and(
eq(schema.adHocNotifications.id, input.notificationId),
eq(schema.adHocNotifications.status, 'scheduled')
)
)
.returning(notificationSelect())
return rows[0] ? mapNotification(rows[0]) : null
},
async listDueNotifications(asOf) {
const rows = await db
.select(notificationSelect())
.from(schema.adHocNotifications)
.where(
and(
eq(schema.adHocNotifications.status, 'scheduled'),
lte(schema.adHocNotifications.scheduledFor, instantToDate(asOf))
)
)
.orderBy(
asc(schema.adHocNotifications.scheduledFor),
asc(schema.adHocNotifications.createdAt)
)
return rows.map(mapNotification)
},
async markNotificationSent(notificationId, sentAt) {
const rows = await db
.update(schema.adHocNotifications)
.set({
status: 'sent',
sentAt: instantToDate(sentAt),
updatedAt: instantToDate(nowInstant())
})
.where(
and(
eq(schema.adHocNotifications.id, notificationId),
eq(schema.adHocNotifications.status, 'scheduled')
)
)
.returning(notificationSelect())
return rows[0] ? mapNotification(rows[0]) : null
},
async claimNotificationDelivery(notificationId) {
const notification = await repository.getNotificationById(notificationId)
if (!notification) {
return {
notificationId,
claimed: false
} satisfies ClaimAdHocNotificationDeliveryResult
}
const rows = await db
.insert(schema.processedBotMessages)
.values({
householdId: notification.householdId,
source: DELIVERY_CLAIM_SOURCE,
sourceMessageKey: notificationId
})
.onConflictDoNothing({
target: [
schema.processedBotMessages.householdId,
schema.processedBotMessages.source,
schema.processedBotMessages.sourceMessageKey
]
})
.returning({ id: schema.processedBotMessages.id })
return {
notificationId,
claimed: rows.length > 0
}
},
async releaseNotificationDelivery(notificationId) {
const notification = await repository.getNotificationById(notificationId)
if (!notification) {
return
}
await db
.delete(schema.processedBotMessages)
.where(
and(
eq(schema.processedBotMessages.householdId, notification.householdId),
eq(schema.processedBotMessages.source, DELIVERY_CLAIM_SOURCE),
eq(schema.processedBotMessages.sourceMessageKey, notificationId)
)
)
}
}
return {
repository,
close: async () => {
await queryClient.end({ timeout: 5 })
}
}
}

View File

@@ -1,3 +1,4 @@
export { createDbAdHocNotificationRepository } from './ad-hoc-notification-repository'
export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository'
export { createDbFinanceRepository } from './finance-repository'
export { createDbHouseholdConfigurationRepository } from './household-config-repository'

View File

@@ -9,6 +9,10 @@ import type {
} from '@household/ports'
function parsePendingActionType(raw: string): TelegramPendingActionType {
if (raw === 'ad_hoc_notification') {
return raw
}
if (raw === 'anonymous_feedback') {
return raw
}

View File

@@ -0,0 +1,268 @@
import { describe, expect, test } from 'bun:test'
import { Temporal } from '@household/domain'
import type {
AdHocNotificationRecord,
AdHocNotificationRepository,
CancelAdHocNotificationInput,
ClaimAdHocNotificationDeliveryResult,
CreateAdHocNotificationInput,
HouseholdConfigurationRepository,
HouseholdMemberRecord
} from '@household/ports'
import { createAdHocNotificationService } from './ad-hoc-notification-service'
class NotificationRepositoryStub implements AdHocNotificationRepository {
notifications = new Map<string, AdHocNotificationRecord>()
nextId = 1
async createNotification(input: CreateAdHocNotificationInput): Promise<AdHocNotificationRecord> {
const id = `notif-${this.nextId++}`
const record: AdHocNotificationRecord = {
id,
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: input.friendlyTagAssignee,
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')
}
this.notifications.set(id, record)
return record
}
async getNotificationById(notificationId: string): Promise<AdHocNotificationRecord | null> {
return this.notifications.get(notificationId) ?? null
}
async listUpcomingNotificationsForHousehold(
householdId: string,
asOf: Temporal.Instant
): Promise<readonly AdHocNotificationRecord[]> {
return [...this.notifications.values()].filter(
(notification) =>
notification.householdId === householdId &&
notification.status === 'scheduled' &&
notification.scheduledFor.epochMilliseconds > asOf.epochMilliseconds
)
}
async cancelNotification(
input: CancelAdHocNotificationInput
): Promise<AdHocNotificationRecord | null> {
const record = this.notifications.get(input.notificationId)
if (!record || record.status !== 'scheduled') {
return null
}
const next = {
...record,
status: 'cancelled' as const,
cancelledAt: input.cancelledAt,
cancelledByMemberId: input.cancelledByMemberId
}
this.notifications.set(input.notificationId, next)
return next
}
async listDueNotifications(asOf: Temporal.Instant): Promise<readonly AdHocNotificationRecord[]> {
return [...this.notifications.values()].filter(
(notification) =>
notification.status === 'scheduled' &&
notification.scheduledFor.epochMilliseconds <= asOf.epochMilliseconds
)
}
async markNotificationSent(
notificationId: string,
sentAt: Temporal.Instant
): Promise<AdHocNotificationRecord | null> {
const record = this.notifications.get(notificationId)
if (!record || record.status !== 'scheduled') {
return null
}
const next = {
...record,
status: 'sent' as const,
sentAt
}
this.notifications.set(notificationId, next)
return next
}
async claimNotificationDelivery(
notificationId: string
): Promise<ClaimAdHocNotificationDeliveryResult> {
return {
notificationId,
claimed: true
}
}
async releaseNotificationDelivery(): Promise<void> {}
}
function member(
input: Partial<HouseholdMemberRecord> & Pick<HouseholdMemberRecord, 'id'>
): HouseholdMemberRecord {
return {
id: input.id,
householdId: input.householdId ?? 'household-1',
telegramUserId: input.telegramUserId ?? `${input.id}-tg`,
displayName: input.displayName ?? input.id,
status: input.status ?? 'active',
preferredLocale: input.preferredLocale ?? 'ru',
householdDefaultLocale: input.householdDefaultLocale ?? 'ru',
rentShareWeight: input.rentShareWeight ?? 1,
isAdmin: input.isAdmin ?? false
}
}
function createHouseholdRepository(
members: readonly HouseholdMemberRecord[]
): Pick<HouseholdConfigurationRepository, 'getHouseholdMember' | 'listHouseholdMembers'> {
return {
async getHouseholdMember(householdId, telegramUserId) {
return (
members.find(
(member) => member.householdId === householdId && member.telegramUserId === telegramUserId
) ?? null
)
},
async listHouseholdMembers(householdId) {
return members.filter((member) => member.householdId === householdId)
}
}
}
describe('createAdHocNotificationService', () => {
test('defaults date-only reminder to scheduled notification with topic delivery', async () => {
const repository = new NotificationRepositoryStub()
const members = [member({ id: 'creator' }), member({ id: 'assignee', displayName: 'Georgiy' })]
const service = createAdHocNotificationService({
repository,
householdConfigurationRepository: createHouseholdRepository(members)
})
const result = await service.scheduleNotification({
householdId: 'household-1',
creatorMemberId: 'creator',
assigneeMemberId: 'assignee',
originalRequestText: 'Напомни Георгию завтра',
notificationText: 'пошпынять Георгия о том, позвонил ли он',
timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
timePrecision: 'date_only_defaulted',
deliveryMode: 'topic'
})
expect(result.status).toBe('scheduled')
if (result.status === 'scheduled') {
expect(result.notification.deliveryMode).toBe('topic')
expect(result.notification.assigneeMemberId).toBe('assignee')
}
})
test('expands dm_all to all active members', async () => {
const repository = new NotificationRepositoryStub()
const members = [
member({ id: 'creator' }),
member({ id: 'alice' }),
member({ id: 'bob', status: 'away' }),
member({ id: 'carol' })
]
const service = createAdHocNotificationService({
repository,
householdConfigurationRepository: createHouseholdRepository(members)
})
const result = await service.scheduleNotification({
householdId: 'household-1',
creatorMemberId: 'creator',
originalRequestText: 'remind everyone tomorrow',
notificationText: 'pay rent',
timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
timePrecision: 'date_only_defaulted',
deliveryMode: 'dm_all'
})
expect(result.status).toBe('scheduled')
if (result.status === 'scheduled') {
expect(result.notification.dmRecipientMemberIds).toEqual(['creator', 'alice', 'carol'])
}
})
test('rejects friendly mode without assignee', async () => {
const repository = new NotificationRepositoryStub()
const service = createAdHocNotificationService({
repository,
householdConfigurationRepository: createHouseholdRepository([member({ id: 'creator' })])
})
const result = await service.scheduleNotification({
householdId: 'household-1',
creatorMemberId: 'creator',
originalRequestText: 'remind tomorrow',
notificationText: 'check rent',
timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
timePrecision: 'date_only_defaulted',
deliveryMode: 'topic',
friendlyTagAssignee: true
})
expect(result).toEqual({
status: 'invalid',
reason: 'friendly_assignee_missing'
})
})
test('allows admin to cancel someone else notification', async () => {
const repository = new NotificationRepositoryStub()
const creator = member({ id: 'creator', telegramUserId: 'creator-tg' })
const admin = member({ id: 'admin', telegramUserId: 'admin-tg', isAdmin: true })
const service = createAdHocNotificationService({
repository,
householdConfigurationRepository: createHouseholdRepository([creator, admin])
})
const created = await repository.createNotification({
householdId: 'household-1',
creatorMemberId: 'creator',
originalRequestText: 'remind tomorrow',
notificationText: 'call landlord',
timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
timePrecision: 'date_only_defaulted',
deliveryMode: 'topic',
friendlyTagAssignee: false
})
const result = await service.cancelNotification({
notificationId: created.id,
viewerMemberId: 'admin',
asOf: Temporal.Instant.from('2026-03-23T09:00:00Z')
})
expect(result.status).toBe('cancelled')
if (result.status === 'cancelled') {
expect(result.notification.cancelledByMemberId).toBe('admin')
}
})
})

View File

@@ -0,0 +1,377 @@
import { nowInstant, type Instant } from '@household/domain'
import type {
AdHocNotificationDeliveryMode,
AdHocNotificationRecord,
AdHocNotificationRepository,
AdHocNotificationTimePrecision,
HouseholdConfigurationRepository,
HouseholdMemberRecord
} from '@household/ports'
interface NotificationActor {
memberId: string
householdId: string
isAdmin: boolean
}
export interface AdHocNotificationMemberSummary {
memberId: string
telegramUserId: string
displayName: string
}
export interface AdHocNotificationSummary {
id: string
notificationText: string
scheduledFor: Instant
deliveryMode: AdHocNotificationDeliveryMode
friendlyTagAssignee: boolean
creatorDisplayName: string
assigneeDisplayName: string | null
canCancel: boolean
}
export interface DeliverableAdHocNotification {
notification: AdHocNotificationRecord
creator: AdHocNotificationMemberSummary
assignee: AdHocNotificationMemberSummary | null
dmRecipients: readonly AdHocNotificationMemberSummary[]
}
export type ScheduleAdHocNotificationResult =
| {
status: 'scheduled'
notification: AdHocNotificationRecord
}
| {
status: 'invalid'
reason:
| 'creator_not_found'
| 'assignee_not_found'
| 'dm_recipients_missing'
| 'delivery_mode_invalid'
| 'friendly_assignee_missing'
| 'scheduled_for_past'
}
export type CancelAdHocNotificationResult =
| {
status: 'cancelled'
notification: AdHocNotificationRecord
}
| {
status: 'not_found' | 'forbidden' | 'already_handled' | 'past_due'
}
export interface AdHocNotificationService {
scheduleNotification(input: {
householdId: string
creatorMemberId: string
originalRequestText: string
notificationText: string
timezone: string
scheduledFor: Instant
timePrecision: AdHocNotificationTimePrecision
deliveryMode: AdHocNotificationDeliveryMode
assigneeMemberId?: string | null
dmRecipientMemberIds?: readonly string[]
friendlyTagAssignee?: boolean
sourceTelegramChatId?: string | null
sourceTelegramThreadId?: string | null
}): Promise<ScheduleAdHocNotificationResult>
listUpcomingNotifications(input: {
householdId: string
viewerMemberId: string
asOf?: Instant
}): Promise<readonly AdHocNotificationSummary[]>
cancelNotification(input: {
notificationId: string
viewerMemberId: string
asOf?: Instant
}): Promise<CancelAdHocNotificationResult>
listDueNotifications(asOf?: Instant): Promise<readonly DeliverableAdHocNotification[]>
claimDueNotification(notificationId: string): Promise<boolean>
releaseDueNotification(notificationId: string): Promise<void>
markNotificationSent(
notificationId: string,
sentAt?: Instant
): Promise<AdHocNotificationRecord | null>
}
function summarizeMember(member: HouseholdMemberRecord): AdHocNotificationMemberSummary {
return {
memberId: member.id,
telegramUserId: member.telegramUserId,
displayName: member.displayName
}
}
function isActiveMember(member: HouseholdMemberRecord): boolean {
return member.status === 'active'
}
async function listMemberMap(
repository: Pick<HouseholdConfigurationRepository, 'listHouseholdMembers'>,
householdId: string
): Promise<Map<string, HouseholdMemberRecord>> {
const members = await repository.listHouseholdMembers(householdId)
return new Map(members.map((member) => [member.id, member]))
}
function canCancelNotification(
notification: AdHocNotificationRecord,
actor: NotificationActor
): boolean {
return actor.isAdmin || notification.creatorMemberId === actor.memberId
}
export function createAdHocNotificationService(input: {
repository: AdHocNotificationRepository
householdConfigurationRepository: Pick<
HouseholdConfigurationRepository,
'getHouseholdMember' | 'listHouseholdMembers'
>
}): AdHocNotificationService {
async function resolveActor(
householdId: string,
memberId: string
): Promise<NotificationActor | null> {
const members = await input.householdConfigurationRepository.listHouseholdMembers(householdId)
const member = members.find((entry) => entry.id === memberId)
if (!member) {
return null
}
return {
memberId: member.id,
householdId: member.householdId,
isAdmin: member.isAdmin
}
}
return {
async scheduleNotification(notificationInput) {
const memberMap = await listMemberMap(
input.householdConfigurationRepository,
notificationInput.householdId
)
const creator = memberMap.get(notificationInput.creatorMemberId)
if (!creator) {
return {
status: 'invalid',
reason: 'creator_not_found'
}
}
const assignee = notificationInput.assigneeMemberId
? memberMap.get(notificationInput.assigneeMemberId)
: null
if (notificationInput.assigneeMemberId && !assignee) {
return {
status: 'invalid',
reason: 'assignee_not_found'
}
}
const effectiveNow = nowInstant()
if (notificationInput.scheduledFor.epochMilliseconds <= effectiveNow.epochMilliseconds) {
return {
status: 'invalid',
reason: 'scheduled_for_past'
}
}
const friendlyTagAssignee = notificationInput.friendlyTagAssignee === true
if (friendlyTagAssignee && !assignee) {
return {
status: 'invalid',
reason: 'friendly_assignee_missing'
}
}
let dmRecipientMemberIds: readonly string[] = []
switch (notificationInput.deliveryMode) {
case 'topic':
dmRecipientMemberIds = []
break
case 'dm_all':
dmRecipientMemberIds = [...memberMap.values()]
.filter(isActiveMember)
.map((member) => member.id)
break
case 'dm_selected': {
const selected = (notificationInput.dmRecipientMemberIds ?? [])
.map((memberId) => memberMap.get(memberId))
.filter((member): member is HouseholdMemberRecord => Boolean(member))
.filter(isActiveMember)
if (selected.length === 0) {
return {
status: 'invalid',
reason: 'dm_recipients_missing'
}
}
dmRecipientMemberIds = selected.map((member) => member.id)
break
}
default:
return {
status: 'invalid',
reason: 'delivery_mode_invalid'
}
}
const notification = await input.repository.createNotification({
householdId: notificationInput.householdId,
creatorMemberId: notificationInput.creatorMemberId,
assigneeMemberId: assignee?.id ?? null,
originalRequestText: notificationInput.originalRequestText.trim(),
notificationText: notificationInput.notificationText.trim(),
timezone: notificationInput.timezone,
scheduledFor: notificationInput.scheduledFor,
timePrecision: notificationInput.timePrecision,
deliveryMode: notificationInput.deliveryMode,
dmRecipientMemberIds,
friendlyTagAssignee,
sourceTelegramChatId: notificationInput.sourceTelegramChatId ?? null,
sourceTelegramThreadId: notificationInput.sourceTelegramThreadId ?? null
})
return {
status: 'scheduled',
notification
}
},
async listUpcomingNotifications({ householdId, viewerMemberId, asOf = nowInstant() }) {
const actor = await resolveActor(householdId, viewerMemberId)
if (!actor) {
return []
}
const memberMap = await listMemberMap(input.householdConfigurationRepository, householdId)
const notifications = await input.repository.listUpcomingNotificationsForHousehold(
householdId,
asOf
)
return notifications
.filter((notification) => actor.isAdmin || notification.creatorMemberId === actor.memberId)
.map((notification) => ({
id: notification.id,
notificationText: notification.notificationText,
scheduledFor: notification.scheduledFor,
deliveryMode: notification.deliveryMode,
friendlyTagAssignee: notification.friendlyTagAssignee,
creatorDisplayName:
memberMap.get(notification.creatorMemberId)?.displayName ??
notification.creatorMemberId,
assigneeDisplayName: notification.assigneeMemberId
? (memberMap.get(notification.assigneeMemberId)?.displayName ??
notification.assigneeMemberId)
: null,
canCancel: canCancelNotification(notification, actor)
}))
},
async cancelNotification({ notificationId, viewerMemberId, asOf = nowInstant() }) {
const notification = await input.repository.getNotificationById(notificationId)
if (!notification) {
return {
status: 'not_found'
}
}
if (notification.status !== 'scheduled') {
return {
status: 'already_handled'
}
}
if (notification.scheduledFor.epochMilliseconds <= asOf.epochMilliseconds) {
return {
status: 'past_due'
}
}
const actor = await resolveActor(notification.householdId, viewerMemberId)
if (!actor || !canCancelNotification(notification, actor)) {
return {
status: 'forbidden'
}
}
const cancelled = await input.repository.cancelNotification({
notificationId,
cancelledByMemberId: actor.memberId,
cancelledAt: asOf
})
if (!cancelled) {
return {
status: 'already_handled'
}
}
return {
status: 'cancelled',
notification: cancelled
}
},
async listDueNotifications(asOf = nowInstant()) {
const due = await input.repository.listDueNotifications(asOf)
const groupedMembers = new Map<string, Map<string, HouseholdMemberRecord>>()
async function membersForHousehold(householdId: string) {
const existing = groupedMembers.get(householdId)
if (existing) {
return existing
}
const memberMap = await listMemberMap(input.householdConfigurationRepository, householdId)
groupedMembers.set(householdId, memberMap)
return memberMap
}
const results: DeliverableAdHocNotification[] = []
for (const notification of due) {
const memberMap = await membersForHousehold(notification.householdId)
const creator = memberMap.get(notification.creatorMemberId)
if (!creator) {
continue
}
const assignee = notification.assigneeMemberId
? (memberMap.get(notification.assigneeMemberId) ?? null)
: null
const dmRecipients = notification.dmRecipientMemberIds
.map((memberId) => memberMap.get(memberId))
.filter((member): member is HouseholdMemberRecord => Boolean(member))
results.push({
notification,
creator: summarizeMember(creator),
assignee: assignee ? summarizeMember(assignee) : null,
dmRecipients: dmRecipients.map(summarizeMember)
})
}
return results
},
async claimDueNotification(notificationId) {
const result = await input.repository.claimNotificationDelivery(notificationId)
return result.claimed
},
releaseDueNotification(notificationId) {
return input.repository.releaseNotificationDelivery(notificationId)
},
markNotificationSent(notificationId, sentAt = nowInstant()) {
return input.repository.markNotificationSent(notificationId, sentAt)
}
}
}

View File

@@ -1,4 +1,13 @@
export { calculateMonthlySettlement } from './settlement-engine'
export {
createAdHocNotificationService,
type AdHocNotificationMemberSummary,
type AdHocNotificationService,
type AdHocNotificationSummary,
type CancelAdHocNotificationResult,
type DeliverableAdHocNotification,
type ScheduleAdHocNotificationResult
} from './ad-hoc-notification-service'
export {
createAnonymousFeedbackService,
type AnonymousFeedbackService,

View File

@@ -0,0 +1,50 @@
CREATE TABLE "ad_hoc_notifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"creator_member_id" uuid NOT NULL,
"assignee_member_id" uuid,
"original_request_text" text NOT NULL,
"notification_text" text NOT NULL,
"timezone" text NOT NULL,
"scheduled_for" timestamp with time zone NOT NULL,
"time_precision" text NOT NULL,
"delivery_mode" text NOT NULL,
"dm_recipient_member_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
"friendly_tag_assignee" integer DEFAULT 0 NOT NULL,
"status" text DEFAULT 'scheduled' NOT NULL,
"source_telegram_chat_id" text,
"source_telegram_thread_id" text,
"sent_at" timestamp with time zone,
"cancelled_at" timestamp with time zone,
"cancelled_by_member_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "payment_purchase_allocations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"payment_record_id" uuid NOT NULL,
"purchase_id" uuid NOT NULL,
"member_id" uuid NOT NULL,
"amount_minor" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "purchase_messages" ADD COLUMN "cycle_id" uuid;--> statement-breakpoint
ALTER TABLE "purchase_messages" ADD COLUMN "payer_member_id" uuid;--> statement-breakpoint
ALTER TABLE "ad_hoc_notifications" ADD CONSTRAINT "ad_hoc_notifications_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ad_hoc_notifications" ADD CONSTRAINT "ad_hoc_notifications_creator_member_id_members_id_fk" FOREIGN KEY ("creator_member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ad_hoc_notifications" ADD CONSTRAINT "ad_hoc_notifications_assignee_member_id_members_id_fk" FOREIGN KEY ("assignee_member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ad_hoc_notifications" ADD CONSTRAINT "ad_hoc_notifications_cancelled_by_member_id_members_id_fk" FOREIGN KEY ("cancelled_by_member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payment_purchase_allocations" ADD CONSTRAINT "payment_purchase_allocations_payment_record_id_payment_records_id_fk" FOREIGN KEY ("payment_record_id") REFERENCES "public"."payment_records"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payment_purchase_allocations" ADD CONSTRAINT "payment_purchase_allocations_purchase_id_purchase_messages_id_fk" FOREIGN KEY ("purchase_id") REFERENCES "public"."purchase_messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payment_purchase_allocations" ADD CONSTRAINT "payment_purchase_allocations_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ad_hoc_notifications_due_idx" ON "ad_hoc_notifications" USING btree ("status","scheduled_for");--> statement-breakpoint
CREATE INDEX "ad_hoc_notifications_household_status_idx" ON "ad_hoc_notifications" USING btree ("household_id","status","scheduled_for");--> statement-breakpoint
CREATE INDEX "ad_hoc_notifications_creator_idx" ON "ad_hoc_notifications" USING btree ("creator_member_id");--> statement-breakpoint
CREATE INDEX "ad_hoc_notifications_assignee_idx" ON "ad_hoc_notifications" USING btree ("assignee_member_id");--> statement-breakpoint
CREATE INDEX "payment_purchase_allocations_payment_idx" ON "payment_purchase_allocations" USING btree ("payment_record_id");--> statement-breakpoint
CREATE INDEX "payment_purchase_allocations_purchase_member_idx" ON "payment_purchase_allocations" USING btree ("purchase_id","member_id");--> statement-breakpoint
ALTER TABLE "purchase_messages" ADD CONSTRAINT "purchase_messages_cycle_id_billing_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "public"."billing_cycles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "purchase_messages" ADD CONSTRAINT "purchase_messages_payer_member_id_members_id_fk" FOREIGN KEY ("payer_member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "purchase_messages_cycle_idx" ON "purchase_messages" USING btree ("cycle_id");

File diff suppressed because it is too large Load Diff

View File

@@ -162,6 +162,13 @@
"when": 1774205000000,
"tag": "0022_carry_purchase_history",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1774294611532,
"tag": "0023_huge_vision",
"breakpoints": true
}
]
}

View File

@@ -507,6 +507,52 @@ export const processedBotMessages = pgTable(
})
)
export const adHocNotifications = pgTable(
'ad_hoc_notifications',
{
id: uuid('id').defaultRandom().primaryKey(),
householdId: uuid('household_id')
.notNull()
.references(() => households.id, { onDelete: 'cascade' }),
creatorMemberId: uuid('creator_member_id')
.notNull()
.references(() => members.id, { onDelete: 'restrict' }),
assigneeMemberId: uuid('assignee_member_id').references(() => members.id, {
onDelete: 'set null'
}),
originalRequestText: text('original_request_text').notNull(),
notificationText: text('notification_text').notNull(),
timezone: text('timezone').notNull(),
scheduledFor: timestamp('scheduled_for', { withTimezone: true }).notNull(),
timePrecision: text('time_precision').notNull(),
deliveryMode: text('delivery_mode').notNull(),
dmRecipientMemberIds: jsonb('dm_recipient_member_ids')
.default(sql`'[]'::jsonb`)
.notNull(),
friendlyTagAssignee: integer('friendly_tag_assignee').default(0).notNull(),
status: text('status').default('scheduled').notNull(),
sourceTelegramChatId: text('source_telegram_chat_id'),
sourceTelegramThreadId: text('source_telegram_thread_id'),
sentAt: timestamp('sent_at', { withTimezone: true }),
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
cancelledByMemberId: uuid('cancelled_by_member_id').references(() => members.id, {
onDelete: 'set null'
}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
dueIdx: index('ad_hoc_notifications_due_idx').on(table.status, table.scheduledFor),
householdStatusIdx: index('ad_hoc_notifications_household_status_idx').on(
table.householdId,
table.status,
table.scheduledFor
),
creatorIdx: index('ad_hoc_notifications_creator_idx').on(table.creatorMemberId),
assigneeIdx: index('ad_hoc_notifications_assignee_idx').on(table.assigneeMemberId)
})
)
export const topicMessages = pgTable(
'topic_messages',
{

View File

@@ -6,6 +6,19 @@ export {
type ReminderTarget,
type ReminderType
} from './reminders'
export {
AD_HOC_NOTIFICATION_DELIVERY_MODES,
AD_HOC_NOTIFICATION_STATUSES,
AD_HOC_NOTIFICATION_TIME_PRECISIONS,
type AdHocNotificationDeliveryMode,
type AdHocNotificationRecord,
type AdHocNotificationRepository,
type AdHocNotificationStatus,
type AdHocNotificationTimePrecision,
type CancelAdHocNotificationInput,
type ClaimAdHocNotificationDeliveryResult,
type CreateAdHocNotificationInput
} from './notifications'
export type {
ClaimProcessedBotMessageInput,
ClaimProcessedBotMessageResult,

View File

@@ -0,0 +1,76 @@
import type { Instant } from '@household/domain'
export const AD_HOC_NOTIFICATION_TIME_PRECISIONS = ['exact', 'date_only_defaulted'] as const
export const AD_HOC_NOTIFICATION_DELIVERY_MODES = ['topic', 'dm_all', 'dm_selected'] as const
export const AD_HOC_NOTIFICATION_STATUSES = ['scheduled', 'sent', 'cancelled'] as const
export type AdHocNotificationTimePrecision = (typeof AD_HOC_NOTIFICATION_TIME_PRECISIONS)[number]
export type AdHocNotificationDeliveryMode = (typeof AD_HOC_NOTIFICATION_DELIVERY_MODES)[number]
export type AdHocNotificationStatus = (typeof AD_HOC_NOTIFICATION_STATUSES)[number]
export interface AdHocNotificationRecord {
id: string
householdId: string
creatorMemberId: string
assigneeMemberId: string | null
originalRequestText: string
notificationText: string
timezone: string
scheduledFor: Instant
timePrecision: AdHocNotificationTimePrecision
deliveryMode: AdHocNotificationDeliveryMode
dmRecipientMemberIds: readonly string[]
friendlyTagAssignee: boolean
status: AdHocNotificationStatus
sourceTelegramChatId: string | null
sourceTelegramThreadId: string | null
sentAt: Instant | null
cancelledAt: Instant | null
cancelledByMemberId: string | null
createdAt: Instant
updatedAt: Instant
}
export interface CreateAdHocNotificationInput {
householdId: string
creatorMemberId: string
assigneeMemberId?: string | null
originalRequestText: string
notificationText: string
timezone: string
scheduledFor: Instant
timePrecision: AdHocNotificationTimePrecision
deliveryMode: AdHocNotificationDeliveryMode
dmRecipientMemberIds?: readonly string[]
friendlyTagAssignee: boolean
sourceTelegramChatId?: string | null
sourceTelegramThreadId?: string | null
}
export interface CancelAdHocNotificationInput {
notificationId: string
cancelledByMemberId: string
cancelledAt: Instant
}
export interface ClaimAdHocNotificationDeliveryResult {
notificationId: string
claimed: boolean
}
export interface AdHocNotificationRepository {
createNotification(input: CreateAdHocNotificationInput): Promise<AdHocNotificationRecord>
getNotificationById(notificationId: string): Promise<AdHocNotificationRecord | null>
listUpcomingNotificationsForHousehold(
householdId: string,
asOf: Instant
): Promise<readonly AdHocNotificationRecord[]>
cancelNotification(input: CancelAdHocNotificationInput): Promise<AdHocNotificationRecord | null>
listDueNotifications(asOf: Instant): Promise<readonly AdHocNotificationRecord[]>
markNotificationSent(
notificationId: string,
sentAt: Instant
): Promise<AdHocNotificationRecord | null>
claimNotificationDelivery(notificationId: string): Promise<ClaimAdHocNotificationDeliveryResult>
releaseNotificationDelivery(notificationId: string): Promise<void>
}

View File

@@ -1,6 +1,7 @@
import type { Instant } from '@household/domain'
export const TELEGRAM_PENDING_ACTION_TYPES = [
'ad_hoc_notification',
'anonymous_feedback',
'assistant_payment_confirmation',
'household_group_invite',