mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
feat(bot): add reminders topic binding command
This commit is contained in:
@@ -65,6 +65,40 @@ function bindRejectionMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindTopicUsageMessage(
|
||||||
|
locale: BotLocale,
|
||||||
|
role: 'purchase' | 'feedback' | 'reminders'
|
||||||
|
): string {
|
||||||
|
const t = getBotTranslations(locale).setup
|
||||||
|
|
||||||
|
switch (role) {
|
||||||
|
case 'purchase':
|
||||||
|
return t.useBindPurchaseTopicInGroup
|
||||||
|
case 'feedback':
|
||||||
|
return t.useBindFeedbackTopicInGroup
|
||||||
|
case 'reminders':
|
||||||
|
return t.useBindRemindersTopicInGroup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTopicSuccessMessage(
|
||||||
|
locale: BotLocale,
|
||||||
|
role: 'purchase' | 'feedback' | 'reminders',
|
||||||
|
householdName: string,
|
||||||
|
threadId: string
|
||||||
|
): string {
|
||||||
|
const t = getBotTranslations(locale).setup
|
||||||
|
|
||||||
|
switch (role) {
|
||||||
|
case 'purchase':
|
||||||
|
return t.purchaseTopicSaved(householdName, threadId)
|
||||||
|
case 'feedback':
|
||||||
|
return t.feedbackTopicSaved(householdName, threadId)
|
||||||
|
case 'reminders':
|
||||||
|
return t.remindersTopicSaved(householdName, threadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function adminRejectionMessage(
|
function adminRejectionMessage(
|
||||||
locale: BotLocale,
|
locale: BotLocale,
|
||||||
reason: 'not_admin' | 'household_not_found' | 'pending_not_found'
|
reason: 'not_admin' | 'household_not_found' | 'pending_not_found'
|
||||||
@@ -182,6 +216,63 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
miniAppUrl?: string
|
miniAppUrl?: string
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): void {
|
}): void {
|
||||||
|
async function handleBindTopicCommand(
|
||||||
|
ctx: Context,
|
||||||
|
role: 'purchase' | 'feedback' | 'reminders'
|
||||||
|
): Promise<void> {
|
||||||
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isGroupChat(ctx)) {
|
||||||
|
await ctx.reply(bindTopicUsageMessage(locale, role))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorIsAdmin = await isGroupAdmin(ctx)
|
||||||
|
const telegramThreadId =
|
||||||
|
isTopicMessage(ctx) && ctx.msg && 'message_thread_id' in ctx.msg
|
||||||
|
? ctx.msg.message_thread_id?.toString()
|
||||||
|
: undefined
|
||||||
|
const result = await options.householdSetupService.bindTopic({
|
||||||
|
actorIsAdmin,
|
||||||
|
telegramChatId: ctx.chat.id.toString(),
|
||||||
|
role,
|
||||||
|
...(telegramThreadId
|
||||||
|
? {
|
||||||
|
telegramThreadId
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
await ctx.reply(bindRejectionMessage(locale, result.reason))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.logger?.info(
|
||||||
|
{
|
||||||
|
event: 'household_setup.topic_bound',
|
||||||
|
role: result.binding.role,
|
||||||
|
telegramChatId: result.household.telegramChatId,
|
||||||
|
telegramThreadId: result.binding.telegramThreadId,
|
||||||
|
householdId: result.household.householdId,
|
||||||
|
actorTelegramUserId: ctx.from?.id?.toString()
|
||||||
|
},
|
||||||
|
'Household topic bound'
|
||||||
|
)
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
bindTopicSuccessMessage(
|
||||||
|
locale,
|
||||||
|
role,
|
||||||
|
result.household.householdName,
|
||||||
|
result.binding.telegramThreadId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
options.bot.command('start', async (ctx) => {
|
options.bot.command('start', async (ctx) => {
|
||||||
const fallbackLocale = await resolveReplyLocale({
|
const fallbackLocale = await resolveReplyLocale({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -353,103 +444,15 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('bind_purchase_topic', async (ctx) => {
|
options.bot.command('bind_purchase_topic', async (ctx) => {
|
||||||
const locale = await resolveReplyLocale({
|
await handleBindTopicCommand(ctx, 'purchase')
|
||||||
ctx,
|
|
||||||
repository: options.householdConfigurationRepository
|
|
||||||
})
|
|
||||||
const t = getBotTranslations(locale)
|
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
|
||||||
await ctx.reply(t.setup.useBindPurchaseTopicInGroup)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorIsAdmin = await isGroupAdmin(ctx)
|
|
||||||
const telegramThreadId =
|
|
||||||
isTopicMessage(ctx) && ctx.msg && 'message_thread_id' in ctx.msg
|
|
||||||
? ctx.msg.message_thread_id?.toString()
|
|
||||||
: undefined
|
|
||||||
const result = await options.householdSetupService.bindTopic({
|
|
||||||
actorIsAdmin,
|
|
||||||
telegramChatId: ctx.chat.id.toString(),
|
|
||||||
role: 'purchase',
|
|
||||||
...(telegramThreadId
|
|
||||||
? {
|
|
||||||
telegramThreadId
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.status === 'rejected') {
|
|
||||||
await ctx.reply(bindRejectionMessage(locale, result.reason))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
options.logger?.info(
|
|
||||||
{
|
|
||||||
event: 'household_setup.topic_bound',
|
|
||||||
role: result.binding.role,
|
|
||||||
telegramChatId: result.household.telegramChatId,
|
|
||||||
telegramThreadId: result.binding.telegramThreadId,
|
|
||||||
householdId: result.household.householdId,
|
|
||||||
actorTelegramUserId: ctx.from?.id?.toString()
|
|
||||||
},
|
|
||||||
'Household topic bound'
|
|
||||||
)
|
|
||||||
|
|
||||||
await ctx.reply(
|
|
||||||
t.setup.purchaseTopicSaved(result.household.householdName, result.binding.telegramThreadId)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('bind_feedback_topic', async (ctx) => {
|
options.bot.command('bind_feedback_topic', async (ctx) => {
|
||||||
const locale = await resolveReplyLocale({
|
await handleBindTopicCommand(ctx, 'feedback')
|
||||||
ctx,
|
})
|
||||||
repository: options.householdConfigurationRepository
|
|
||||||
})
|
|
||||||
const t = getBotTranslations(locale)
|
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
options.bot.command('bind_reminders_topic', async (ctx) => {
|
||||||
await ctx.reply(t.setup.useBindFeedbackTopicInGroup)
|
await handleBindTopicCommand(ctx, 'reminders')
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorIsAdmin = await isGroupAdmin(ctx)
|
|
||||||
const telegramThreadId =
|
|
||||||
isTopicMessage(ctx) && ctx.msg && 'message_thread_id' in ctx.msg
|
|
||||||
? ctx.msg.message_thread_id?.toString()
|
|
||||||
: undefined
|
|
||||||
const result = await options.householdSetupService.bindTopic({
|
|
||||||
actorIsAdmin,
|
|
||||||
telegramChatId: ctx.chat.id.toString(),
|
|
||||||
role: 'feedback',
|
|
||||||
...(telegramThreadId
|
|
||||||
? {
|
|
||||||
telegramThreadId
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.status === 'rejected') {
|
|
||||||
await ctx.reply(bindRejectionMessage(locale, result.reason))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
options.logger?.info(
|
|
||||||
{
|
|
||||||
event: 'household_setup.topic_bound',
|
|
||||||
role: result.binding.role,
|
|
||||||
telegramChatId: result.household.telegramChatId,
|
|
||||||
telegramThreadId: result.binding.telegramThreadId,
|
|
||||||
householdId: result.household.householdId,
|
|
||||||
actorTelegramUserId: ctx.from?.id?.toString()
|
|
||||||
},
|
|
||||||
'Household topic bound'
|
|
||||||
)
|
|
||||||
|
|
||||||
await ctx.reply(
|
|
||||||
t.setup.feedbackTopicSaved(result.household.householdName, result.binding.telegramThreadId)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('pending_members', async (ctx) => {
|
options.bot.command('pending_members', async (ctx) => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
setup: 'Register this group as a household',
|
setup: 'Register this group as a household',
|
||||||
bind_purchase_topic: 'Bind the current topic as purchases',
|
bind_purchase_topic: 'Bind the current topic as purchases',
|
||||||
bind_feedback_topic: 'Bind the current topic as feedback',
|
bind_feedback_topic: 'Bind the current topic as feedback',
|
||||||
|
bind_reminders_topic: 'Bind the current topic as reminders',
|
||||||
pending_members: 'List pending household join requests',
|
pending_members: 'List pending household join requests',
|
||||||
approve_member: 'Approve a pending household member'
|
approve_member: 'Approve a pending household member'
|
||||||
},
|
},
|
||||||
@@ -52,7 +53,7 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
[
|
[
|
||||||
`Household ${created ? 'created' : 'already registered'}: ${householdName}`,
|
`Household ${created ? 'created' : 'already registered'}: ${householdName}`,
|
||||||
`Chat ID: ${telegramChatId}`,
|
`Chat ID: ${telegramChatId}`,
|
||||||
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.',
|
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic. If you want a dedicated reminders topic, open it and run /bind_reminders_topic.',
|
||||||
'Members should open the bot chat from the button below and confirm the join request there.'
|
'Members should open the bot chat from the button below and confirm the join request there.'
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.',
|
useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.',
|
||||||
@@ -61,6 +62,9 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
useBindFeedbackTopicInGroup: 'Use /bind_feedback_topic inside the household group topic.',
|
useBindFeedbackTopicInGroup: 'Use /bind_feedback_topic inside the household group topic.',
|
||||||
feedbackTopicSaved: (householdName, threadId) =>
|
feedbackTopicSaved: (householdName, threadId) =>
|
||||||
`Feedback topic saved for ${householdName} (thread ${threadId}).`,
|
`Feedback topic saved for ${householdName} (thread ${threadId}).`,
|
||||||
|
useBindRemindersTopicInGroup: 'Use /bind_reminders_topic inside the household group topic.',
|
||||||
|
remindersTopicSaved: (householdName, threadId) =>
|
||||||
|
`Reminders topic saved for ${householdName} (thread ${threadId}).`,
|
||||||
usePendingMembersInGroup: 'Use /pending_members inside the household group.',
|
usePendingMembersInGroup: 'Use /pending_members inside the household group.',
|
||||||
useApproveMemberInGroup: 'Use /approve_member inside the household group.',
|
useApproveMemberInGroup: 'Use /approve_member inside the household group.',
|
||||||
approveMemberUsage: 'Usage: /approve_member <telegram_user_id>',
|
approveMemberUsage: 'Usage: /approve_member <telegram_user_id>',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
setup: 'Подключить эту группу как дом',
|
setup: 'Подключить эту группу как дом',
|
||||||
bind_purchase_topic: 'Назначить текущий топик для покупок',
|
bind_purchase_topic: 'Назначить текущий топик для покупок',
|
||||||
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
|
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
|
||||||
|
bind_reminders_topic: 'Назначить текущий топик для напоминаний',
|
||||||
pending_members: 'Показать ожидающие заявки на вступление',
|
pending_members: 'Показать ожидающие заявки на вступление',
|
||||||
approve_member: 'Подтвердить участника дома'
|
approve_member: 'Подтвердить участника дома'
|
||||||
},
|
},
|
||||||
@@ -54,7 +55,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
[
|
[
|
||||||
`${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`,
|
`${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`,
|
||||||
`ID чата: ${telegramChatId}`,
|
`ID чата: ${telegramChatId}`,
|
||||||
'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic.',
|
'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельный топик для напоминаний, откройте его и выполните /bind_reminders_topic.',
|
||||||
'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.'
|
'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.'
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.',
|
useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.',
|
||||||
@@ -63,6 +64,9 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
useBindFeedbackTopicInGroup: 'Используйте /bind_feedback_topic внутри топика группы дома.',
|
useBindFeedbackTopicInGroup: 'Используйте /bind_feedback_topic внутри топика группы дома.',
|
||||||
feedbackTopicSaved: (householdName, threadId) =>
|
feedbackTopicSaved: (householdName, threadId) =>
|
||||||
`Топик обратной связи сохранён для ${householdName} (тред ${threadId}).`,
|
`Топик обратной связи сохранён для ${householdName} (тред ${threadId}).`,
|
||||||
|
useBindRemindersTopicInGroup: 'Используйте /bind_reminders_topic внутри топика группы дома.',
|
||||||
|
remindersTopicSaved: (householdName, threadId) =>
|
||||||
|
`Топик напоминаний сохранён для ${householdName} (тред ${threadId}).`,
|
||||||
usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.',
|
usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.',
|
||||||
useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.',
|
useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.',
|
||||||
approveMemberUsage: 'Использование: /approve_member <telegram_user_id>',
|
approveMemberUsage: 'Использование: /approve_member <telegram_user_id>',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type TelegramCommandName =
|
|||||||
| 'setup'
|
| 'setup'
|
||||||
| 'bind_purchase_topic'
|
| 'bind_purchase_topic'
|
||||||
| 'bind_feedback_topic'
|
| 'bind_feedback_topic'
|
||||||
|
| 'bind_reminders_topic'
|
||||||
| 'pending_members'
|
| 'pending_members'
|
||||||
| 'approve_member'
|
| 'approve_member'
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export interface BotCommandDescriptions {
|
|||||||
setup: string
|
setup: string
|
||||||
bind_purchase_topic: string
|
bind_purchase_topic: string
|
||||||
bind_feedback_topic: string
|
bind_feedback_topic: string
|
||||||
|
bind_reminders_topic: string
|
||||||
pending_members: string
|
pending_members: string
|
||||||
approve_member: string
|
approve_member: string
|
||||||
}
|
}
|
||||||
@@ -73,6 +75,8 @@ export interface BotTranslationCatalog {
|
|||||||
purchaseTopicSaved: (householdName: string, threadId: string) => string
|
purchaseTopicSaved: (householdName: string, threadId: string) => string
|
||||||
useBindFeedbackTopicInGroup: string
|
useBindFeedbackTopicInGroup: string
|
||||||
feedbackTopicSaved: (householdName: string, threadId: string) => string
|
feedbackTopicSaved: (householdName: string, threadId: string) => string
|
||||||
|
useBindRemindersTopicInGroup: string
|
||||||
|
remindersTopicSaved: (householdName: string, threadId: string) => string
|
||||||
usePendingMembersInGroup: string
|
usePendingMembersInGroup: string
|
||||||
useApproveMemberInGroup: string
|
useApproveMemberInGroup: string
|
||||||
approveMemberUsage: string
|
approveMemberUsage: string
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test'
|
import { describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
import type { ReminderJobResult, ReminderJobService } from '@household/application'
|
import type { ReminderJobResult, ReminderJobService } from '@household/application'
|
||||||
|
import { Temporal } from '@household/domain'
|
||||||
import type { ReminderTarget } from '@household/ports'
|
import type { ReminderTarget } from '@household/ports'
|
||||||
|
|
||||||
import { createReminderJobsHandler } from './reminder-jobs'
|
import { createReminderJobsHandler } from './reminder-jobs'
|
||||||
@@ -10,9 +11,16 @@ const target: ReminderTarget = {
|
|||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
telegramChatId: '-1001',
|
telegramChatId: '-1001',
|
||||||
telegramThreadId: '12',
|
telegramThreadId: '12',
|
||||||
locale: 'ru'
|
locale: 'ru',
|
||||||
|
timezone: 'Asia/Tbilisi',
|
||||||
|
rentDueDay: 20,
|
||||||
|
rentWarningDay: 17,
|
||||||
|
utilitiesDueDay: 4,
|
||||||
|
utilitiesReminderDay: 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fixedNow = Temporal.Instant.from('2026-03-03T09:00:00Z')
|
||||||
|
|
||||||
describe('createReminderJobsHandler', () => {
|
describe('createReminderJobsHandler', () => {
|
||||||
test('returns per-household dispatch outcome with Telegram delivery metadata', async () => {
|
test('returns per-household dispatch outcome with Telegram delivery metadata', async () => {
|
||||||
const claimedResult: ReminderJobResult = {
|
const claimedResult: ReminderJobResult = {
|
||||||
@@ -33,7 +41,8 @@ describe('createReminderJobsHandler', () => {
|
|||||||
listReminderTargets: async () => [target],
|
listReminderTargets: async () => [target],
|
||||||
releaseReminderDispatch: mock(async () => {}),
|
releaseReminderDispatch: mock(async () => {}),
|
||||||
sendReminderMessage,
|
sendReminderMessage,
|
||||||
reminderService
|
reminderService,
|
||||||
|
now: () => fixedNow
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await handler.handle(
|
const response = await handler.handle(
|
||||||
@@ -73,6 +82,7 @@ describe('createReminderJobsHandler', () => {
|
|||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
telegramChatId: '-1001',
|
telegramChatId: '-1001',
|
||||||
telegramThreadId: '12',
|
telegramThreadId: '12',
|
||||||
|
period: '2026-03',
|
||||||
dedupeKey: '2026-03:utilities',
|
dedupeKey: '2026-03:utilities',
|
||||||
outcome: 'claimed',
|
outcome: 'claimed',
|
||||||
messageText: 'Напоминание по коммунальным платежам за 2026-03'
|
messageText: 'Напоминание по коммунальным платежам за 2026-03'
|
||||||
@@ -101,7 +111,8 @@ describe('createReminderJobsHandler', () => {
|
|||||||
releaseReminderDispatch: mock(async () => {}),
|
releaseReminderDispatch: mock(async () => {}),
|
||||||
sendReminderMessage,
|
sendReminderMessage,
|
||||||
reminderService,
|
reminderService,
|
||||||
forceDryRun: true
|
forceDryRun: true,
|
||||||
|
now: () => fixedNow
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await handler.handle(
|
const response = await handler.handle(
|
||||||
@@ -146,7 +157,8 @@ describe('createReminderJobsHandler', () => {
|
|||||||
sendReminderMessage: mock(async () => {
|
sendReminderMessage: mock(async () => {
|
||||||
throw new Error('Telegram unavailable')
|
throw new Error('Telegram unavailable')
|
||||||
}),
|
}),
|
||||||
reminderService
|
reminderService,
|
||||||
|
now: () => fixedNow
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await handler.handle(
|
const response = await handler.handle(
|
||||||
@@ -185,7 +197,8 @@ describe('createReminderJobsHandler', () => {
|
|||||||
handleJob: mock(async () => {
|
handleJob: mock(async () => {
|
||||||
throw new Error('should not be called')
|
throw new Error('should not be called')
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
now: () => fixedNow
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await handler.handle(
|
const response = await handler.handle(
|
||||||
@@ -202,4 +215,94 @@ describe('createReminderJobsHandler', () => {
|
|||||||
error: 'Invalid reminder type'
|
error: 'Invalid reminder type'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('skips households whose configured reminder day does not match today when no period override is supplied', async () => {
|
||||||
|
const reminderService: ReminderJobService = {
|
||||||
|
handleJob: mock(async () => {
|
||||||
|
throw new Error('should not be called')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = createReminderJobsHandler({
|
||||||
|
listReminderTargets: async () => [
|
||||||
|
{
|
||||||
|
...target,
|
||||||
|
utilitiesReminderDay: 31
|
||||||
|
}
|
||||||
|
],
|
||||||
|
releaseReminderDispatch: mock(async () => {}),
|
||||||
|
sendReminderMessage: mock(async () => {}),
|
||||||
|
reminderService,
|
||||||
|
now: () => fixedNow
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handle(
|
||||||
|
new Request('http://localhost/jobs/reminder/utilities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ jobId: 'job-3' })
|
||||||
|
}),
|
||||||
|
'utilities'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
totals: {
|
||||||
|
targets: 0
|
||||||
|
},
|
||||||
|
dispatches: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('honors explicit period overrides even when today is not the configured reminder day', async () => {
|
||||||
|
const dryRunResult: ReminderJobResult = {
|
||||||
|
status: 'dry-run',
|
||||||
|
dedupeKey: '2026-03:rent-due',
|
||||||
|
payloadHash: 'hash',
|
||||||
|
reminderType: 'rent-due',
|
||||||
|
period: '2026-03',
|
||||||
|
messageText: 'Rent due reminder for 2026-03: please settle payment today.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const reminderService: ReminderJobService = {
|
||||||
|
handleJob: mock(async () => dryRunResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = createReminderJobsHandler({
|
||||||
|
listReminderTargets: async () => [
|
||||||
|
{
|
||||||
|
...target,
|
||||||
|
rentDueDay: 20
|
||||||
|
}
|
||||||
|
],
|
||||||
|
releaseReminderDispatch: mock(async () => {}),
|
||||||
|
sendReminderMessage: mock(async () => {}),
|
||||||
|
reminderService,
|
||||||
|
now: () => fixedNow
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handle(
|
||||||
|
new Request('http://localhost/jobs/reminder/rent-due', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ period: '2026-03', dryRun: true })
|
||||||
|
}),
|
||||||
|
'rent-due'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
totals: {
|
||||||
|
targets: 1,
|
||||||
|
'dry-run': 1
|
||||||
|
},
|
||||||
|
dispatches: [
|
||||||
|
expect.objectContaining({
|
||||||
|
householdId: 'household-1',
|
||||||
|
period: '2026-03',
|
||||||
|
outcome: 'dry-run'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReminderJobService } from '@household/application'
|
import type { ReminderJobService } from '@household/application'
|
||||||
import { BillingPeriod, nowInstant } from '@household/domain'
|
import { BillingPeriod, Temporal, nowInstant } from '@household/domain'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports'
|
import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports'
|
||||||
|
|
||||||
@@ -32,6 +32,34 @@ function currentPeriod(): string {
|
|||||||
return BillingPeriod.fromInstant(nowInstant()).toString()
|
return BillingPeriod.fromInstant(nowInstant()).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function targetLocalDate(target: ReminderTarget, instant: Temporal.Instant) {
|
||||||
|
return instant.toZonedDateTimeISO(target.timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReminderDueToday(
|
||||||
|
target: ReminderTarget,
|
||||||
|
reminderType: ReminderType,
|
||||||
|
instant: Temporal.Instant
|
||||||
|
): boolean {
|
||||||
|
const currentDay = targetLocalDate(target, instant).day
|
||||||
|
|
||||||
|
switch (reminderType) {
|
||||||
|
case 'utilities':
|
||||||
|
return currentDay === target.utilitiesReminderDay
|
||||||
|
case 'rent-warning':
|
||||||
|
return currentDay === target.rentWarningDay
|
||||||
|
case 'rent-due':
|
||||||
|
return currentDay === target.rentDueDay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetPeriod(target: ReminderTarget, instant: Temporal.Instant): string {
|
||||||
|
const localDate = targetLocalDate(target, instant)
|
||||||
|
return BillingPeriod.fromString(
|
||||||
|
`${localDate.year}-${String(localDate.month).padStart(2, '0')}`
|
||||||
|
).toString()
|
||||||
|
}
|
||||||
|
|
||||||
async function readBody(request: Request): Promise<ReminderJobRequestBody> {
|
async function readBody(request: Request): Promise<ReminderJobRequestBody> {
|
||||||
const text = await request.text()
|
const text = await request.text()
|
||||||
|
|
||||||
@@ -56,6 +84,7 @@ export function createReminderJobsHandler(options: {
|
|||||||
sendReminderMessage: (target: ReminderTarget, text: string) => Promise<void>
|
sendReminderMessage: (target: ReminderTarget, text: string) => Promise<void>
|
||||||
reminderService: ReminderJobService
|
reminderService: ReminderJobService
|
||||||
forceDryRun?: boolean
|
forceDryRun?: boolean
|
||||||
|
now?: () => Temporal.Instant
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): {
|
}): {
|
||||||
handle: (request: Request, rawReminderType: string) => Promise<Response>
|
handle: (request: Request, rawReminderType: string) => Promise<Response>
|
||||||
@@ -83,14 +112,19 @@ export function createReminderJobsHandler(options: {
|
|||||||
try {
|
try {
|
||||||
const body = await readBody(request)
|
const body = await readBody(request)
|
||||||
const schedulerJobName = request.headers.get('x-cloudscheduler-jobname')
|
const schedulerJobName = request.headers.get('x-cloudscheduler-jobname')
|
||||||
const period = BillingPeriod.fromString(body.period ?? currentPeriod()).toString()
|
const requestedPeriod = body.period
|
||||||
|
? BillingPeriod.fromString(body.period).toString()
|
||||||
|
: null
|
||||||
|
const defaultPeriod = requestedPeriod ?? currentPeriod()
|
||||||
const dryRun = options.forceDryRun === true || body.dryRun === true
|
const dryRun = options.forceDryRun === true || body.dryRun === true
|
||||||
|
const currentInstant = options.now?.() ?? nowInstant()
|
||||||
const targets = await options.listReminderTargets()
|
const targets = await options.listReminderTargets()
|
||||||
const dispatches: Array<{
|
const dispatches: Array<{
|
||||||
householdId: string
|
householdId: string
|
||||||
householdName: string
|
householdName: string
|
||||||
telegramChatId: string
|
telegramChatId: string
|
||||||
telegramThreadId: string | null
|
telegramThreadId: string | null
|
||||||
|
period: string
|
||||||
dedupeKey: string
|
dedupeKey: string
|
||||||
outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed'
|
outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed'
|
||||||
messageText: string
|
messageText: string
|
||||||
@@ -98,6 +132,11 @@ export function createReminderJobsHandler(options: {
|
|||||||
}> = []
|
}> = []
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
|
if (!requestedPeriod && !isReminderDueToday(target, reminderType, currentInstant)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = requestedPeriod ?? targetPeriod(target, currentInstant)
|
||||||
const result = await options.reminderService.handleJob({
|
const result = await options.reminderService.handleJob({
|
||||||
householdId: target.householdId,
|
householdId: target.householdId,
|
||||||
period,
|
period,
|
||||||
@@ -148,6 +187,7 @@ export function createReminderJobsHandler(options: {
|
|||||||
householdName: target.householdName,
|
householdName: target.householdName,
|
||||||
telegramChatId: target.telegramChatId,
|
telegramChatId: target.telegramChatId,
|
||||||
telegramThreadId: target.telegramThreadId,
|
telegramThreadId: target.telegramThreadId,
|
||||||
|
period,
|
||||||
dedupeKey: result.dedupeKey,
|
dedupeKey: result.dedupeKey,
|
||||||
outcome,
|
outcome,
|
||||||
messageText: text,
|
messageText: text,
|
||||||
@@ -174,7 +214,7 @@ export function createReminderJobsHandler(options: {
|
|||||||
ok: true,
|
ok: true,
|
||||||
jobId: body.jobId ?? schedulerJobName ?? null,
|
jobId: body.jobId ?? schedulerJobName ?? null,
|
||||||
reminderType,
|
reminderType,
|
||||||
period,
|
period: defaultPeriod,
|
||||||
dryRun,
|
dryRun,
|
||||||
totals,
|
totals,
|
||||||
dispatches
|
dispatches
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const GROUP_ADMIN_COMMAND_NAMES = [
|
|||||||
'setup',
|
'setup',
|
||||||
'bind_purchase_topic',
|
'bind_purchase_topic',
|
||||||
'bind_feedback_topic',
|
'bind_feedback_topic',
|
||||||
|
'bind_reminders_topic',
|
||||||
'pending_members',
|
'pending_members',
|
||||||
'approve_member'
|
'approve_member'
|
||||||
] as const satisfies readonly TelegramCommandName[]
|
] as const satisfies readonly TelegramCommandName[]
|
||||||
|
|||||||
@@ -89,12 +89,13 @@ Goal: automate key payment reminders.
|
|||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
||||||
- Cloud Scheduler jobs.
|
- Cloud Scheduler jobs.
|
||||||
- Reminder handlers for day 3/4 utilities, day 17 rent notice, day 20 due date.
|
- Reminder handlers that evaluate per-household utility and rent reminder dates.
|
||||||
- Dedicated topic posting for reminders.
|
- Dedicated topic posting for reminders.
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
|
|
||||||
- Scheduled reminders fire reliably.
|
- Scheduled reminders fire reliably.
|
||||||
|
- Household billing settings control when reminders are delivered.
|
||||||
- Duplicate sends are prevented.
|
- Duplicate sends are prevented.
|
||||||
|
|
||||||
## Phase 4 - Mini App V1
|
## Phase 4 - Mini App V1
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ bun run review:coderabbit
|
|||||||
- purchase ingestion requires `DATABASE_URL` plus a bound purchase topic via `/bind_purchase_topic`
|
- purchase ingestion requires `DATABASE_URL` plus a bound purchase topic via `/bind_purchase_topic`
|
||||||
- anonymous feedback requires `DATABASE_URL` plus a bound feedback topic via `/bind_feedback_topic`
|
- anonymous feedback requires `DATABASE_URL` plus a bound feedback topic via `/bind_feedback_topic`
|
||||||
- reminders require `DATABASE_URL` plus `SCHEDULER_SHARED_SECRET` or `SCHEDULER_OIDC_ALLOWED_EMAILS`
|
- reminders require `DATABASE_URL` plus `SCHEDULER_SHARED_SECRET` or `SCHEDULER_OIDC_ALLOWED_EMAILS`
|
||||||
|
and optionally use a dedicated reminders topic via `/bind_reminders_topic`
|
||||||
- mini app CORS can be constrained with `MINI_APP_ALLOWED_ORIGINS`
|
- mini app CORS can be constrained with `MINI_APP_ALLOWED_ORIGINS`
|
||||||
- Migration workflow is documented in `docs/runbooks/migrations.md`.
|
- Migration workflow is documented in `docs/runbooks/migrations.md`.
|
||||||
- First deploy flow is documented in `docs/runbooks/first-deploy.md`.
|
- First deploy flow is documented in `docs/runbooks/first-deploy.md`.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Schedule and deliver household billing reminders to dedicated Telegram topics.
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
- In: scheduler endpoints, reminder generation, send guards.
|
- In: scheduler endpoints, reminder generation, send guards, per-household reminder eligibility.
|
||||||
- Out: full statement rendering details.
|
- Out: full statement rendering details.
|
||||||
|
|
||||||
## Interfaces and Contracts
|
## Interfaces and Contracts
|
||||||
@@ -30,10 +30,11 @@ Schedule and deliver household billing reminders to dedicated Telegram topics.
|
|||||||
|
|
||||||
## Domain Rules
|
## Domain Rules
|
||||||
|
|
||||||
- Utilities reminder target: day 3 or 4 (configurable).
|
- Utilities reminder target: household-configured utilities reminder day.
|
||||||
- Rent warning target: day 17.
|
- Rent warning target: household-configured rent warning day.
|
||||||
- Rent due target: day 20.
|
- Rent due target: household-configured rent due day.
|
||||||
- Duplicate-send guard keyed by household + cycle + reminder type.
|
- Duplicate-send guard keyed by household + cycle + reminder type.
|
||||||
|
- Scheduler should run on a daily cadence and let the application decide which households are due today.
|
||||||
|
|
||||||
## Data Model Changes
|
## Data Model Changes
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,6 @@ Reminder jobs must not require:
|
|||||||
|
|
||||||
## Follow-ups
|
## Follow-ups
|
||||||
|
|
||||||
- per-household reminder settings
|
- daily scheduler cadence in infrastructure defaults
|
||||||
- localized reminder copy using persisted household/member locale
|
- localized reminder copy using persisted household/member locale
|
||||||
- scheduler fan-out observability metrics
|
- scheduler fan-out observability metrics
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ bot_mini_app_allowed_origins = [
|
|||||||
"https://household-dev-mini-app-abc123-ew.a.run.app"
|
"https://household-dev-mini-app-abc123-ew.a.run.app"
|
||||||
]
|
]
|
||||||
|
|
||||||
scheduler_utilities_cron = "0 9 4 * *"
|
scheduler_utilities_cron = "0 9 * * *"
|
||||||
scheduler_rent_warning_cron = "0 9 17 * *"
|
scheduler_rent_warning_cron = "0 9 * * *"
|
||||||
scheduler_rent_due_cron = "0 9 20 * *"
|
scheduler_rent_due_cron = "0 9 * * *"
|
||||||
scheduler_timezone = "Asia/Tbilisi"
|
scheduler_timezone = "Asia/Tbilisi"
|
||||||
scheduler_paused = true
|
scheduler_paused = true
|
||||||
scheduler_dry_run = true
|
scheduler_dry_run = true
|
||||||
|
|||||||
@@ -138,21 +138,21 @@ variable "scheduler_timezone" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
variable "scheduler_utilities_cron" {
|
variable "scheduler_utilities_cron" {
|
||||||
description = "Cron expression for the utilities reminder scheduler job"
|
description = "Cron expression for the utilities reminder scheduler job. Daily cadence is recommended because the app filters per household."
|
||||||
type = string
|
type = string
|
||||||
default = "0 9 4 * *"
|
default = "0 9 * * *"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "scheduler_rent_warning_cron" {
|
variable "scheduler_rent_warning_cron" {
|
||||||
description = "Cron expression for the rent warning scheduler job"
|
description = "Cron expression for the rent warning scheduler job. Daily cadence is recommended because the app filters per household."
|
||||||
type = string
|
type = string
|
||||||
default = "0 9 17 * *"
|
default = "0 9 * * *"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "scheduler_rent_due_cron" {
|
variable "scheduler_rent_due_cron" {
|
||||||
description = "Cron expression for the rent due scheduler job"
|
description = "Cron expression for the rent due scheduler job. Daily cadence is recommended because the app filters per household."
|
||||||
type = string
|
type = string
|
||||||
default = "0 9 20 * *"
|
default = "0 9 * * *"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "scheduler_dry_run" {
|
variable "scheduler_dry_run" {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, asc, eq } from 'drizzle-orm'
|
import { and, asc, eq, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
import {
|
import {
|
||||||
@@ -139,6 +139,11 @@ function toReminderTarget(row: {
|
|||||||
telegramChatId: string
|
telegramChatId: string
|
||||||
reminderThreadId: string | null
|
reminderThreadId: string | null
|
||||||
defaultLocale: string
|
defaultLocale: string
|
||||||
|
timezone: string
|
||||||
|
rentDueDay: number
|
||||||
|
rentWarningDay: number
|
||||||
|
utilitiesDueDay: number
|
||||||
|
utilitiesReminderDay: number
|
||||||
}): ReminderTarget {
|
}): ReminderTarget {
|
||||||
const locale = normalizeSupportedLocale(row.defaultLocale)
|
const locale = normalizeSupportedLocale(row.defaultLocale)
|
||||||
if (!locale) {
|
if (!locale) {
|
||||||
@@ -150,7 +155,12 @@ function toReminderTarget(row: {
|
|||||||
householdName: row.householdName,
|
householdName: row.householdName,
|
||||||
telegramChatId: row.telegramChatId,
|
telegramChatId: row.telegramChatId,
|
||||||
telegramThreadId: row.reminderThreadId,
|
telegramThreadId: row.reminderThreadId,
|
||||||
locale
|
locale,
|
||||||
|
timezone: row.timezone,
|
||||||
|
rentDueDay: row.rentDueDay,
|
||||||
|
rentWarningDay: row.rentWarningDay,
|
||||||
|
utilitiesDueDay: row.utilitiesDueDay,
|
||||||
|
utilitiesReminderDay: row.utilitiesReminderDay
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,13 +506,36 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
householdName: schema.households.name,
|
householdName: schema.households.name,
|
||||||
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
||||||
reminderThreadId: schema.householdTopicBindings.telegramThreadId,
|
reminderThreadId: schema.householdTopicBindings.telegramThreadId,
|
||||||
defaultLocale: schema.households.defaultLocale
|
defaultLocale: schema.households.defaultLocale,
|
||||||
|
timezone:
|
||||||
|
sql<string>`coalesce(${schema.householdBillingSettings.timezone}, 'Asia/Tbilisi')`.as(
|
||||||
|
'timezone'
|
||||||
|
),
|
||||||
|
rentDueDay: sql<number>`coalesce(${schema.householdBillingSettings.rentDueDay}, 20)`.as(
|
||||||
|
'rent_due_day'
|
||||||
|
),
|
||||||
|
rentWarningDay:
|
||||||
|
sql<number>`coalesce(${schema.householdBillingSettings.rentWarningDay}, 17)`.as(
|
||||||
|
'rent_warning_day'
|
||||||
|
),
|
||||||
|
utilitiesDueDay:
|
||||||
|
sql<number>`coalesce(${schema.householdBillingSettings.utilitiesDueDay}, 4)`.as(
|
||||||
|
'utilities_due_day'
|
||||||
|
),
|
||||||
|
utilitiesReminderDay:
|
||||||
|
sql<number>`coalesce(${schema.householdBillingSettings.utilitiesReminderDay}, 3)`.as(
|
||||||
|
'utilities_reminder_day'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.from(schema.householdTelegramChats)
|
.from(schema.householdTelegramChats)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
schema.households,
|
schema.households,
|
||||||
eq(schema.householdTelegramChats.householdId, schema.households.id)
|
eq(schema.householdTelegramChats.householdId, schema.households.id)
|
||||||
)
|
)
|
||||||
|
.leftJoin(
|
||||||
|
schema.householdBillingSettings,
|
||||||
|
eq(schema.householdBillingSettings.householdId, schema.householdTelegramChats.householdId)
|
||||||
|
)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
schema.householdTopicBindings,
|
schema.householdTopicBindings,
|
||||||
and(
|
and(
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export interface ReminderTarget {
|
|||||||
telegramChatId: string
|
telegramChatId: string
|
||||||
telegramThreadId: string | null
|
telegramThreadId: string | null
|
||||||
locale: SupportedLocale
|
locale: SupportedLocale
|
||||||
|
timezone: string
|
||||||
|
rentDueDay: number
|
||||||
|
rentWarningDay: number
|
||||||
|
utilitiesDueDay: number
|
||||||
|
utilitiesReminderDay: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaimReminderDispatchInput {
|
export interface ClaimReminderDispatchInput {
|
||||||
|
|||||||
Reference in New Issue
Block a user