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(
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<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) => {
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) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ReminderJobRequestBody> {
const text = await request.text()
@@ -56,6 +84,7 @@ export function createReminderJobsHandler(options: {
sendReminderMessage: (target: ReminderTarget, text: string) => Promise<void>
reminderService: ReminderJobService
forceDryRun?: boolean
now?: () => Temporal.Instant
logger?: Logger
}): {
handle: (request: Request, rawReminderType: string) => Promise<Response>
@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 {
@@ -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<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)
.innerJoin(
schema.households,
eq(schema.householdTelegramChats.householdId, schema.households.id)
)
.leftJoin(
schema.householdBillingSettings,
eq(schema.householdBillingSettings.householdId, schema.householdTelegramChats.householdId)
)
.leftJoin(
schema.householdTopicBindings,
and(

View File

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