diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index 763d786..20b8a8c 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -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( locale: BotLocale, reason: 'not_admin' | 'household_not_found' | 'pending_not_found' @@ -182,6 +216,63 @@ export function registerHouseholdSetupCommands(options: { miniAppUrl?: string logger?: Logger }): void { + async function handleBindTopicCommand( + ctx: Context, + role: 'purchase' | 'feedback' | 'reminders' + ): Promise { + 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) => { const fallbackLocale = await resolveReplyLocale({ ctx, @@ -353,103 +444,15 @@ export function registerHouseholdSetupCommands(options: { }) options.bot.command('bind_purchase_topic', async (ctx) => { - const locale = await resolveReplyLocale({ - 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) - ) + await handleBindTopicCommand(ctx, 'purchase') }) options.bot.command('bind_feedback_topic', async (ctx) => { - const locale = await resolveReplyLocale({ - ctx, - repository: options.householdConfigurationRepository - }) - const t = getBotTranslations(locale) + await handleBindTopicCommand(ctx, 'feedback') + }) - 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') { - 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('bind_reminders_topic', async (ctx) => { + await handleBindTopicCommand(ctx, 'reminders') }) options.bot.command('pending_members', async (ctx) => { diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index 02f7130..f3856b3 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -10,6 +10,7 @@ export const enBotTranslations: BotTranslationCatalog = { setup: 'Register this group as a household', bind_purchase_topic: 'Bind the current topic as purchases', 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', approve_member: 'Approve a pending household member' }, @@ -52,7 +53,7 @@ export const enBotTranslations: BotTranslationCatalog = { [ `Household ${created ? 'created' : 'already registered'}: ${householdName}`, `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.' ].join('\n'), 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.', feedbackTopicSaved: (householdName, 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.', useApproveMemberInGroup: 'Use /approve_member inside the household group.', approveMemberUsage: 'Usage: /approve_member ', diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index fbde037..6f840a2 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -10,6 +10,7 @@ export const ruBotTranslations: BotTranslationCatalog = { setup: 'Подключить эту группу как дом', bind_purchase_topic: 'Назначить текущий топик для покупок', bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений', + bind_reminders_topic: 'Назначить текущий топик для напоминаний', pending_members: 'Показать ожидающие заявки на вступление', approve_member: 'Подтвердить участника дома' }, @@ -54,7 +55,7 @@ export const ruBotTranslations: BotTranslationCatalog = { [ `${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`, `ID чата: ${telegramChatId}`, - 'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic.', + 'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельный топик для напоминаний, откройте его и выполните /bind_reminders_topic.', 'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.' ].join('\n'), useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.', @@ -63,6 +64,9 @@ export const ruBotTranslations: BotTranslationCatalog = { useBindFeedbackTopicInGroup: 'Используйте /bind_feedback_topic внутри топика группы дома.', feedbackTopicSaved: (householdName, threadId) => `Топик обратной связи сохранён для ${householdName} (тред ${threadId}).`, + useBindRemindersTopicInGroup: 'Используйте /bind_reminders_topic внутри топика группы дома.', + remindersTopicSaved: (householdName, threadId) => + `Топик напоминаний сохранён для ${householdName} (тред ${threadId}).`, usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.', useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.', approveMemberUsage: 'Использование: /approve_member ', diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 064c730..7367c2b 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -8,6 +8,7 @@ export type TelegramCommandName = | 'setup' | 'bind_purchase_topic' | 'bind_feedback_topic' + | 'bind_reminders_topic' | 'pending_members' | 'approve_member' @@ -19,6 +20,7 @@ export interface BotCommandDescriptions { setup: string bind_purchase_topic: string bind_feedback_topic: string + bind_reminders_topic: string pending_members: string approve_member: string } @@ -73,6 +75,8 @@ export interface BotTranslationCatalog { purchaseTopicSaved: (householdName: string, threadId: string) => string useBindFeedbackTopicInGroup: string feedbackTopicSaved: (householdName: string, threadId: string) => string + useBindRemindersTopicInGroup: string + remindersTopicSaved: (householdName: string, threadId: string) => string usePendingMembersInGroup: string useApproveMemberInGroup: string approveMemberUsage: string diff --git a/apps/bot/src/reminder-jobs.test.ts b/apps/bot/src/reminder-jobs.test.ts index 59e806a..46dfc85 100644 --- a/apps/bot/src/reminder-jobs.test.ts +++ b/apps/bot/src/reminder-jobs.test.ts @@ -1,6 +1,7 @@ import { describe, expect, mock, test } from 'bun:test' import type { ReminderJobResult, ReminderJobService } from '@household/application' +import { Temporal } from '@household/domain' import type { ReminderTarget } from '@household/ports' import { createReminderJobsHandler } from './reminder-jobs' @@ -10,9 +11,16 @@ const target: ReminderTarget = { householdName: 'Kojori House', telegramChatId: '-1001', 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', () => { test('returns per-household dispatch outcome with Telegram delivery metadata', async () => { const claimedResult: ReminderJobResult = { @@ -33,7 +41,8 @@ describe('createReminderJobsHandler', () => { listReminderTargets: async () => [target], releaseReminderDispatch: mock(async () => {}), sendReminderMessage, - reminderService + reminderService, + now: () => fixedNow }) const response = await handler.handle( @@ -73,6 +82,7 @@ describe('createReminderJobsHandler', () => { householdName: 'Kojori House', telegramChatId: '-1001', telegramThreadId: '12', + period: '2026-03', dedupeKey: '2026-03:utilities', outcome: 'claimed', messageText: 'Напоминание по коммунальным платежам за 2026-03' @@ -101,7 +111,8 @@ describe('createReminderJobsHandler', () => { releaseReminderDispatch: mock(async () => {}), sendReminderMessage, reminderService, - forceDryRun: true + forceDryRun: true, + now: () => fixedNow }) const response = await handler.handle( @@ -146,7 +157,8 @@ describe('createReminderJobsHandler', () => { sendReminderMessage: mock(async () => { throw new Error('Telegram unavailable') }), - reminderService + reminderService, + now: () => fixedNow }) const response = await handler.handle( @@ -185,7 +197,8 @@ describe('createReminderJobsHandler', () => { handleJob: mock(async () => { throw new Error('should not be called') }) - } + }, + now: () => fixedNow }) const response = await handler.handle( @@ -202,4 +215,94 @@ describe('createReminderJobsHandler', () => { 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' + }) + ] + }) + }) }) diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts index 321cefb..14d2ddf 100644 --- a/apps/bot/src/reminder-jobs.ts +++ b/apps/bot/src/reminder-jobs.ts @@ -1,5 +1,5 @@ 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 { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports' @@ -32,6 +32,34 @@ function currentPeriod(): string { 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 { const text = await request.text() @@ -56,6 +84,7 @@ export function createReminderJobsHandler(options: { sendReminderMessage: (target: ReminderTarget, text: string) => Promise reminderService: ReminderJobService forceDryRun?: boolean + now?: () => Temporal.Instant logger?: Logger }): { handle: (request: Request, rawReminderType: string) => Promise @@ -83,14 +112,19 @@ export function createReminderJobsHandler(options: { try { const body = await readBody(request) 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 currentInstant = options.now?.() ?? nowInstant() const targets = await options.listReminderTargets() const dispatches: Array<{ householdId: string householdName: string telegramChatId: string telegramThreadId: string | null + period: string dedupeKey: string outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed' messageText: string @@ -98,6 +132,11 @@ export function createReminderJobsHandler(options: { }> = [] for (const target of targets) { + if (!requestedPeriod && !isReminderDueToday(target, reminderType, currentInstant)) { + continue + } + + const period = requestedPeriod ?? targetPeriod(target, currentInstant) const result = await options.reminderService.handleJob({ householdId: target.householdId, period, @@ -148,6 +187,7 @@ export function createReminderJobsHandler(options: { householdName: target.householdName, telegramChatId: target.telegramChatId, telegramThreadId: target.telegramThreadId, + period, dedupeKey: result.dedupeKey, outcome, messageText: text, @@ -174,7 +214,7 @@ export function createReminderJobsHandler(options: { ok: true, jobId: body.jobId ?? schedulerJobName ?? null, reminderType, - period, + period: defaultPeriod, dryRun, totals, dispatches diff --git a/apps/bot/src/telegram-commands.ts b/apps/bot/src/telegram-commands.ts index 6bc3b0a..305605e 100644 --- a/apps/bot/src/telegram-commands.ts +++ b/apps/bot/src/telegram-commands.ts @@ -26,6 +26,7 @@ const GROUP_ADMIN_COMMAND_NAMES = [ 'setup', 'bind_purchase_topic', 'bind_feedback_topic', + 'bind_reminders_topic', 'pending_members', 'approve_member' ] as const satisfies readonly TelegramCommandName[] diff --git a/docs/roadmap.md b/docs/roadmap.md index db63367..222cb5b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -89,12 +89,13 @@ Goal: automate key payment reminders. Deliverables: - 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. Exit criteria: - Scheduled reminders fire reliably. +- Household billing settings control when reminders are delivered. - Duplicate sends are prevented. ## Phase 4 - Mini App V1 diff --git a/docs/runbooks/dev-setup.md b/docs/runbooks/dev-setup.md index f256b8c..cf33352 100644 --- a/docs/runbooks/dev-setup.md +++ b/docs/runbooks/dev-setup.md @@ -66,6 +66,7 @@ bun run review:coderabbit - 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` - 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` - Migration workflow is documented in `docs/runbooks/migrations.md`. - First deploy flow is documented in `docs/runbooks/first-deploy.md`. diff --git a/docs/specs/HOUSEBOT-004-reminders-and-scheduler.md b/docs/specs/HOUSEBOT-004-reminders-and-scheduler.md index 0244dc7..2ee61eb 100644 --- a/docs/specs/HOUSEBOT-004-reminders-and-scheduler.md +++ b/docs/specs/HOUSEBOT-004-reminders-and-scheduler.md @@ -17,7 +17,7 @@ Schedule and deliver household billing reminders to dedicated Telegram topics. ## Scope -- In: scheduler endpoints, reminder generation, send guards. +- In: scheduler endpoints, reminder generation, send guards, per-household reminder eligibility. - Out: full statement rendering details. ## Interfaces and Contracts @@ -30,10 +30,11 @@ Schedule and deliver household billing reminders to dedicated Telegram topics. ## Domain Rules -- Utilities reminder target: day 3 or 4 (configurable). -- Rent warning target: day 17. -- Rent due target: day 20. +- Utilities reminder target: household-configured utilities reminder day. +- Rent warning target: household-configured rent warning day. +- Rent due target: household-configured rent due day. - 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 diff --git a/docs/specs/HOUSEBOT-075-multi-household-reminder-delivery.md b/docs/specs/HOUSEBOT-075-multi-household-reminder-delivery.md index f462678..8e9547a 100644 --- a/docs/specs/HOUSEBOT-075-multi-household-reminder-delivery.md +++ b/docs/specs/HOUSEBOT-075-multi-household-reminder-delivery.md @@ -48,6 +48,6 @@ Reminder jobs must not require: ## Follow-ups -- per-household reminder settings +- daily scheduler cadence in infrastructure defaults - localized reminder copy using persisted household/member locale - scheduler fan-out observability metrics diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example index 433e6a3..684e765 100644 --- a/infra/terraform/terraform.tfvars.example +++ b/infra/terraform/terraform.tfvars.example @@ -23,9 +23,9 @@ bot_mini_app_allowed_origins = [ "https://household-dev-mini-app-abc123-ew.a.run.app" ] -scheduler_utilities_cron = "0 9 4 * *" -scheduler_rent_warning_cron = "0 9 17 * *" -scheduler_rent_due_cron = "0 9 20 * *" +scheduler_utilities_cron = "0 9 * * *" +scheduler_rent_warning_cron = "0 9 * * *" +scheduler_rent_due_cron = "0 9 * * *" scheduler_timezone = "Asia/Tbilisi" scheduler_paused = true scheduler_dry_run = true diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index f531b65..f6b18b7 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -138,21 +138,21 @@ variable "scheduler_timezone" { } 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 - default = "0 9 4 * *" + default = "0 9 * * *" } 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 - default = "0 9 17 * *" + default = "0 9 * * *" } 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 - default = "0 9 20 * *" + default = "0 9 * * *" } variable "scheduler_dry_run" { diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index 8e5ab23..fea2955 100644 --- a/packages/adapters-db/src/household-config-repository.ts +++ b/packages/adapters-db/src/household-config-repository.ts @@ -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 { @@ -139,6 +139,11 @@ function toReminderTarget(row: { telegramChatId: string reminderThreadId: string | null defaultLocale: string + timezone: string + rentDueDay: number + rentWarningDay: number + utilitiesDueDay: number + utilitiesReminderDay: number }): ReminderTarget { const locale = normalizeSupportedLocale(row.defaultLocale) if (!locale) { @@ -150,7 +155,12 @@ function toReminderTarget(row: { householdName: row.householdName, telegramChatId: row.telegramChatId, 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, telegramChatId: schema.householdTelegramChats.telegramChatId, reminderThreadId: schema.householdTopicBindings.telegramThreadId, - defaultLocale: schema.households.defaultLocale + defaultLocale: schema.households.defaultLocale, + timezone: + sql`coalesce(${schema.householdBillingSettings.timezone}, 'Asia/Tbilisi')`.as( + 'timezone' + ), + rentDueDay: sql`coalesce(${schema.householdBillingSettings.rentDueDay}, 20)`.as( + 'rent_due_day' + ), + rentWarningDay: + sql`coalesce(${schema.householdBillingSettings.rentWarningDay}, 17)`.as( + 'rent_warning_day' + ), + utilitiesDueDay: + sql`coalesce(${schema.householdBillingSettings.utilitiesDueDay}, 4)`.as( + 'utilities_due_day' + ), + utilitiesReminderDay: + sql`coalesce(${schema.householdBillingSettings.utilitiesReminderDay}, 3)`.as( + 'utilities_reminder_day' + ) }) .from(schema.householdTelegramChats) .innerJoin( schema.households, eq(schema.householdTelegramChats.householdId, schema.households.id) ) + .leftJoin( + schema.householdBillingSettings, + eq(schema.householdBillingSettings.householdId, schema.householdTelegramChats.householdId) + ) .leftJoin( schema.householdTopicBindings, and( diff --git a/packages/ports/src/reminders.ts b/packages/ports/src/reminders.ts index 2c4cf47..5dd2ec3 100644 --- a/packages/ports/src/reminders.ts +++ b/packages/ports/src/reminders.ts @@ -10,6 +10,11 @@ export interface ReminderTarget { telegramChatId: string telegramThreadId: string | null locale: SupportedLocale + timezone: string + rentDueDay: number + rentWarningDay: number + utilitiesDueDay: number + utilitiesReminderDay: number } export interface ClaimReminderDispatchInput {