feat(bot): add reminders topic binding command

This commit is contained in:
2026-03-10 02:01:56 +04:00
parent 29563c24eb
commit 9c4fe5cb52
15 changed files with 321 additions and 121 deletions

View File

@@ -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)) {
await ctx.reply(t.setup.useBindFeedbackTopicInGroup)
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') { options.bot.command('bind_reminders_topic', async (ctx) => {
await ctx.reply(bindRejectionMessage(locale, result.reason)) await handleBindTopicCommand(ctx, 'reminders')
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) => {

View File

@@ -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>',

View File

@@ -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>',

View File

@@ -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

View File

@@ -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'
})
]
})
})
}) })

View File

@@ -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

View File

@@ -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[]

View File

@@ -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

View File

@@ -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`.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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" {

View File

@@ -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(

View File

@@ -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 {