From 16f9981fee79b52021cd8cec039283f974262b34 Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 9 Mar 2026 16:50:57 +0400 Subject: [PATCH] feat(bot): add multi-household reminder delivery --- .env.example | 6 - apps/bot/src/anonymous-feedback.test.ts | 1 + apps/bot/src/config.ts | 9 +- apps/bot/src/i18n/locales/en.ts | 5 + apps/bot/src/i18n/locales/ru.ts | 5 + apps/bot/src/i18n/types.ts | 5 + apps/bot/src/index.ts | 27 +++- apps/bot/src/miniapp-admin.test.ts | 1 + apps/bot/src/miniapp-auth.test.ts | 1 + apps/bot/src/miniapp-dashboard.test.ts | 1 + apps/bot/src/miniapp-locale.test.ts | 1 + apps/bot/src/reminder-jobs.test.ts | 115 ++++++++++++++-- apps/bot/src/reminder-jobs.ts | 126 +++++++++++++++--- docs/runbooks/dev-setup.md | 8 +- ...T-075-multi-household-reminder-delivery.md | 53 ++++++++ .../src/household-config-repository.ts | 53 +++++++- .../src/reminder-dispatch-repository.ts | 16 +++ .../src/household-admin-service.test.ts | 1 + .../src/household-onboarding-service.test.ts | 3 + .../src/household-setup-service.test.ts | 3 + .../src/locale-preference-service.test.ts | 1 + .../src/miniapp-admin-service.test.ts | 1 + .../src/reminder-job-service.test.ts | 2 + packages/config/src/env.ts | 2 +- packages/ports/src/household-config.ts | 2 + packages/ports/src/index.ts | 1 + packages/ports/src/reminders.ts | 15 +++ 27 files changed, 412 insertions(+), 52 deletions(-) create mode 100644 docs/specs/HOUSEBOT-075-multi-household-reminder-delivery.md diff --git a/.env.example b/.env.example index 207038a..3d65365 100644 --- a/.env.example +++ b/.env.example @@ -15,12 +15,6 @@ SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key TELEGRAM_BOT_TOKEN=your-telegram-bot-token TELEGRAM_WEBHOOK_SECRET=your-webhook-secret TELEGRAM_WEBHOOK_PATH=/webhook/telegram -TELEGRAM_HOUSEHOLD_CHAT_ID=-1001234567890 -TELEGRAM_PURCHASE_TOPIC_ID=777 -TELEGRAM_FEEDBACK_TOPIC_ID=888 - -# Household -HOUSEHOLD_ID=11111111-1111-4111-8111-111111111111 # Mini app MINI_APP_ALLOWED_ORIGINS=http://localhost:5173 diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index dca2b72..6a5c9e1 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -132,6 +132,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit : null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], upsertHouseholdJoinToken: async () => ({ householdId: 'household-1', householdName: 'Kojori House', diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index 302ad7d..45c5046 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -5,7 +5,6 @@ export interface BotRuntimeConfig { telegramWebhookSecret: string telegramWebhookPath: string databaseUrl?: string - householdId?: string telegramHouseholdChatId?: string telegramPurchaseTopicId?: number telegramFeedbackTopicId?: number @@ -94,7 +93,6 @@ function parseOptionalCsv(value: string | undefined): readonly string[] { export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig { const databaseUrl = parseOptionalValue(env.DATABASE_URL) - const householdId = parseOptionalValue(env.HOUSEHOLD_ID) const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID) const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID) const telegramFeedbackTopicId = parseOptionalTopicId(env.TELEGRAM_FEEDBACK_TOPIC_ID) @@ -109,9 +107,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu const miniAppAuthEnabled = databaseUrl !== undefined const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 const reminderJobsEnabled = - databaseUrl !== undefined && - householdId !== undefined && - (schedulerSharedSecret !== undefined || hasSchedulerOidcConfig) + databaseUrl !== undefined && (schedulerSharedSecret !== undefined || hasSchedulerOidcConfig) const runtime: BotRuntimeConfig = { port: parsePort(env.PORT), @@ -132,9 +128,6 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu if (databaseUrl !== undefined) { runtime.databaseUrl = databaseUrl } - if (householdId !== undefined) { - runtime.householdId = householdId - } if (telegramHouseholdChatId !== undefined) { runtime.telegramHouseholdChatId = telegramHouseholdChatId } diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index 358fe44..02f7130 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -133,6 +133,11 @@ export const enBotTranslations: BotTranslationCatalog = { statementTotal: (amount, currency) => `Total: ${amount} ${currency}`, statementFailed: (message) => `Failed to generate statement: ${message}` }, + reminders: { + utilities: (period) => `Utilities reminder for ${period}`, + rentWarning: (period) => `Rent reminder for ${period}: payment is coming up soon.`, + rentDue: (period) => `Rent due reminder for ${period}: please settle payment today.` + }, purchase: { sharedPurchaseFallback: 'shared purchase', recorded: (summary) => `Recorded purchase: ${summary}`, diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index 5637207..fbde037 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -136,6 +136,11 @@ export const ruBotTranslations: BotTranslationCatalog = { statementTotal: (amount, currency) => `Итого: ${amount} ${currency}`, statementFailed: (message) => `Не удалось построить выписку: ${message}` }, + reminders: { + utilities: (period) => `Напоминание по коммунальным платежам за ${period}`, + rentWarning: (period) => `Напоминание по аренде за ${period}: срок оплаты скоро наступит.`, + rentDue: (period) => `Напоминание по аренде за ${period}: пожалуйста, оплатите сегодня.` + }, purchase: { sharedPurchaseFallback: 'общая покупка', recorded: (summary) => `Покупка сохранена: ${summary}`, diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 37d6f39..064c730 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -138,6 +138,11 @@ export interface BotTranslationCatalog { statementTotal: (amount: string, currency: string) => string statementFailed: (message: string) => string } + reminders: { + utilities: (period: string) => string + rentWarning: (period: string) => string + rentDue: (period: string) => string + } purchase: { sharedPurchaseFallback: string recorded: (summary: string) => string diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 3a4c902..e6e71a3 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -202,7 +202,30 @@ const reminderJobs = runtime.reminderJobsEnabled shutdownTasks.push(reminderRepositoryClient.close) return createReminderJobsHandler({ - householdId: runtime.householdId!, + listReminderTargets: () => + householdConfigurationRepositoryClient!.repository.listReminderTargets(), + releaseReminderDispatch: (input) => + reminderRepositoryClient.repository.releaseReminderDispatch(input), + sendReminderMessage: async (target, text) => { + const threadId = + target.telegramThreadId !== null ? Number(target.telegramThreadId) : undefined + + if (target.telegramThreadId !== null && (!threadId || !Number.isInteger(threadId))) { + throw new Error( + `Invalid reminder thread id for household ${target.householdId}: ${target.telegramThreadId}` + ) + } + + await bot.api.sendMessage( + target.telegramChatId, + text, + threadId + ? { + message_thread_id: threadId + } + : undefined + ) + }, reminderService, logger: getLogger('scheduler') }) @@ -215,7 +238,7 @@ if (!runtime.reminderJobsEnabled) { event: 'runtime.feature_disabled', feature: 'reminder-jobs' }, - 'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.' + 'Reminder jobs are disabled. Set DATABASE_URL and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.' ) } diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index ff98f88..d6fbe9b 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -39,6 +39,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], upsertHouseholdJoinToken: async (input) => ({ householdId: household.householdId, householdName: household.householdName, diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index 4ab567f..d0f38b6 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -58,6 +58,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], upsertHouseholdJoinToken: async (input) => ({ householdId: household.householdId, householdName: household.householdName, diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 08503c4..cca9dd7 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -97,6 +97,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], upsertHouseholdJoinToken: async (input) => ({ householdId: household.householdId, householdName: household.householdName, diff --git a/apps/bot/src/miniapp-locale.test.ts b/apps/bot/src/miniapp-locale.test.ts index 2710725..98dfac9 100644 --- a/apps/bot/src/miniapp-locale.test.ts +++ b/apps/bot/src/miniapp-locale.test.ts @@ -64,6 +64,7 @@ function repository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], upsertHouseholdJoinToken: async () => ({ householdId: household.householdId, householdName: household.householdName, diff --git a/apps/bot/src/reminder-jobs.test.ts b/apps/bot/src/reminder-jobs.test.ts index a38b732..59e806a 100644 --- a/apps/bot/src/reminder-jobs.test.ts +++ b/apps/bot/src/reminder-jobs.test.ts @@ -1,11 +1,20 @@ import { describe, expect, mock, test } from 'bun:test' import type { ReminderJobResult, ReminderJobService } from '@household/application' +import type { ReminderTarget } from '@household/ports' import { createReminderJobsHandler } from './reminder-jobs' +const target: ReminderTarget = { + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-1001', + telegramThreadId: '12', + locale: 'ru' +} + describe('createReminderJobsHandler', () => { - test('returns job outcome with dedupe metadata', async () => { + test('returns per-household dispatch outcome with Telegram delivery metadata', async () => { const claimedResult: ReminderJobResult = { status: 'claimed', dedupeKey: '2026-03:utilities', @@ -18,9 +27,12 @@ describe('createReminderJobsHandler', () => { const reminderService: ReminderJobService = { handleJob: mock(async () => claimedResult) } + const sendReminderMessage = mock(async () => {}) const handler = createReminderJobsHandler({ - householdId: 'household-1', + listReminderTargets: async () => [target], + releaseReminderDispatch: mock(async () => {}), + sendReminderMessage, reminderService }) @@ -35,20 +47,41 @@ describe('createReminderJobsHandler', () => { 'utilities' ) + expect(sendReminderMessage).toHaveBeenCalledTimes(1) + expect(sendReminderMessage).toHaveBeenCalledWith( + target, + 'Напоминание по коммунальным платежам за 2026-03' + ) + expect(response.status).toBe(200) expect(await response.json()).toEqual({ ok: true, jobId: 'job-1', reminderType: 'utilities', period: '2026-03', - dedupeKey: '2026-03:utilities', - outcome: 'claimed', dryRun: false, - messageText: 'Utilities reminder for 2026-03' + totals: { + targets: 1, + claimed: 1, + duplicate: 0, + 'dry-run': 0, + failed: 0 + }, + dispatches: [ + { + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-1001', + telegramThreadId: '12', + dedupeKey: '2026-03:utilities', + outcome: 'claimed', + messageText: 'Напоминание по коммунальным платежам за 2026-03' + } + ] }) }) - test('supports forced dry-run mode', async () => { + test('supports forced dry-run mode without posting to Telegram', async () => { const dryRunResult: ReminderJobResult = { status: 'dry-run', dedupeKey: '2026-03:rent-warning', @@ -61,9 +94,12 @@ describe('createReminderJobsHandler', () => { const reminderService: ReminderJobService = { handleJob: mock(async () => dryRunResult) } + const sendReminderMessage = mock(async () => {}) const handler = createReminderJobsHandler({ - householdId: 'household-1', + listReminderTargets: async () => [target], + releaseReminderDispatch: mock(async () => {}), + sendReminderMessage, reminderService, forceDryRun: true }) @@ -76,16 +112,75 @@ describe('createReminderJobsHandler', () => { 'rent-warning' ) + expect(sendReminderMessage).toHaveBeenCalledTimes(0) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ - outcome: 'dry-run', - dryRun: true + dryRun: true, + totals: { + targets: 1, + claimed: 0, + duplicate: 0, + 'dry-run': 1, + failed: 0 + } + }) + }) + + test('releases a dispatch claim when Telegram delivery fails', async () => { + const failedResult: ReminderJobResult = { + status: 'claimed', + 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 () => failedResult) + } + const releaseReminderDispatch = mock(async () => {}) + + const handler = createReminderJobsHandler({ + listReminderTargets: async () => [target], + releaseReminderDispatch, + sendReminderMessage: mock(async () => { + throw new Error('Telegram unavailable') + }), + reminderService + }) + + const response = await handler.handle( + new Request('http://localhost/jobs/reminder/rent-due', { + method: 'POST', + body: JSON.stringify({ period: '2026-03' }) + }), + 'rent-due' + ) + + expect(releaseReminderDispatch).toHaveBeenCalledWith({ + householdId: 'household-1', + period: '2026-03', + reminderType: 'rent-due' + }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + totals: { + failed: 1 + }, + dispatches: [ + expect.objectContaining({ + outcome: 'failed', + error: 'Telegram unavailable' + }) + ] }) }) test('rejects unsupported reminder type', async () => { const handler = createReminderJobsHandler({ - householdId: 'household-1', + listReminderTargets: async () => [target], + releaseReminderDispatch: mock(async () => {}), + sendReminderMessage: mock(async () => {}), reminderService: { handleJob: mock(async () => { throw new Error('should not be called') diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts index ac7f700..321cefb 100644 --- a/apps/bot/src/reminder-jobs.ts +++ b/apps/bot/src/reminder-jobs.ts @@ -1,7 +1,9 @@ import type { ReminderJobService } from '@household/application' import { BillingPeriod, nowInstant } from '@household/domain' import type { Logger } from '@household/observability' -import { REMINDER_TYPES, type ReminderType } from '@household/ports' +import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports' + +import { getBotTranslations } from './i18n' interface ReminderJobRequestBody { period?: string @@ -45,13 +47,32 @@ async function readBody(request: Request): Promise { } export function createReminderJobsHandler(options: { - householdId: string + listReminderTargets: () => Promise + releaseReminderDispatch: (input: { + householdId: string + period: string + reminderType: ReminderType + }) => Promise + sendReminderMessage: (target: ReminderTarget, text: string) => Promise reminderService: ReminderJobService forceDryRun?: boolean logger?: Logger }): { handle: (request: Request, rawReminderType: string) => Promise } { + function messageText(target: ReminderTarget, reminderType: ReminderType, period: string): string { + const t = getBotTranslations(target.locale).reminders + + switch (reminderType) { + case 'utilities': + return t.utilities(period) + case 'rent-warning': + return t.rentWarning(period) + case 'rent-due': + return t.rentDue(period) + } + } + return { handle: async (request, rawReminderType) => { const reminderType = parseReminderType(rawReminderType) @@ -64,34 +85,99 @@ export function createReminderJobsHandler(options: { const schedulerJobName = request.headers.get('x-cloudscheduler-jobname') const period = BillingPeriod.fromString(body.period ?? currentPeriod()).toString() const dryRun = options.forceDryRun === true || body.dryRun === true - const result = await options.reminderService.handleJob({ - householdId: options.householdId, - period, - reminderType, - dryRun - }) + const targets = await options.listReminderTargets() + const dispatches: Array<{ + householdId: string + householdName: string + telegramChatId: string + telegramThreadId: string | null + dedupeKey: string + outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed' + messageText: string + error?: string + }> = [] - const logPayload = { - event: 'scheduler.reminder.dispatch', - reminderType, - period, - jobId: body.jobId ?? schedulerJobName ?? null, - dedupeKey: result.dedupeKey, - outcome: result.status, - dryRun + for (const target of targets) { + const result = await options.reminderService.handleJob({ + householdId: target.householdId, + period, + reminderType, + dryRun + }) + const text = messageText(target, reminderType, period) + + let outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed' = result.status + let error: string | undefined + + if (result.status === 'claimed') { + try { + await options.sendReminderMessage(target, text) + } catch (dispatchError) { + await options.releaseReminderDispatch({ + householdId: target.householdId, + period, + reminderType + }) + + outcome = 'failed' + error = + dispatchError instanceof Error + ? dispatchError.message + : 'Unknown reminder delivery error' + } + } + + options.logger?.info( + { + event: 'scheduler.reminder.dispatch', + reminderType, + period, + jobId: body.jobId ?? schedulerJobName ?? null, + householdId: target.householdId, + householdName: target.householdName, + dedupeKey: result.dedupeKey, + outcome, + dryRun, + ...(error ? { error } : {}) + }, + 'Reminder job processed' + ) + + dispatches.push({ + householdId: target.householdId, + householdName: target.householdName, + telegramChatId: target.telegramChatId, + telegramThreadId: target.telegramThreadId, + dedupeKey: result.dedupeKey, + outcome, + messageText: text, + ...(error ? { error } : {}) + }) } - options.logger?.info(logPayload, 'Reminder job processed') + const totals = dispatches.reduce( + (summary, dispatch) => { + summary.targets += 1 + summary[dispatch.outcome] += 1 + return summary + }, + { + targets: 0, + claimed: 0, + duplicate: 0, + 'dry-run': 0, + failed: 0 + } + ) return json({ ok: true, jobId: body.jobId ?? schedulerJobName ?? null, reminderType, period, - dedupeKey: result.dedupeKey, - outcome: result.status, dryRun, - messageText: result.messageText + totals, + dispatches }) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown reminder job error' diff --git a/docs/runbooks/dev-setup.md b/docs/runbooks/dev-setup.md index 9385330..f256b8c 100644 --- a/docs/runbooks/dev-setup.md +++ b/docs/runbooks/dev-setup.md @@ -62,10 +62,10 @@ bun run review:coderabbit - Typed environment validation lives in `packages/config/src/env.ts`. - Copy `.env.example` to `.env` before running app/database commands. - Local bot feature flags come from env presence: - - finance commands require `DATABASE_URL` and `HOUSEHOLD_ID` - - purchase ingestion also requires `TELEGRAM_HOUSEHOLD_CHAT_ID` and `TELEGRAM_PURCHASE_TOPIC_ID` - - anonymous feedback also requires `TELEGRAM_FEEDBACK_TOPIC_ID` - - reminders require `SCHEDULER_SHARED_SECRET` or `SCHEDULER_OIDC_ALLOWED_EMAILS` + - finance commands require `DATABASE_URL` plus household setup in Telegram via `/setup` + - 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` - 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-075-multi-household-reminder-delivery.md b/docs/specs/HOUSEBOT-075-multi-household-reminder-delivery.md new file mode 100644 index 0000000..f462678 --- /dev/null +++ b/docs/specs/HOUSEBOT-075-multi-household-reminder-delivery.md @@ -0,0 +1,53 @@ +# HOUSEBOT-075 Multi-Household Reminder Delivery + +## Goal + +Replace the current reminder placeholder path with a real multi-household reminder dispatcher. + +## Problem + +Current reminder jobs only claim a dedupe key and return `messageText`. They do not send any Telegram message. They also require a single global `HOUSEHOLD_ID`, which is incompatible with the bot's DB-backed multi-household model. + +## Target behavior + +- Scheduler endpoint accepts `utilities`, `rent-warning`, or `rent-due` +- For the target billing period, the bot resolves all configured household reminder targets from the database +- A household reminder target uses: + - bound `reminders` topic if present + - otherwise the household chat itself +- For each target household, the bot: + - builds deterministic reminder text + - claims dedupe for `(householdId, period, reminderType)` + - posts the message to Telegram only when the claim succeeds +- Dry-run returns the planned dispatches without posting + +## Delivery model + +- Scheduler route remains a single endpoint per reminder type +- One request fan-outs across all reminder-enabled households +- Logs include an entry per household outcome + +## Data source + +Household config comes from `HouseholdConfigurationRepository`: + +- household chat binding is required +- reminder topic binding is optional + +## Runtime contract + +Reminder jobs require: + +- `DATABASE_URL` +- one scheduler auth mechanism (`SCHEDULER_SHARED_SECRET` or allowed OIDC service accounts) + +Reminder jobs must not require: + +- `HOUSEHOLD_ID` +- group/topic reminder env vars + +## Follow-ups + +- per-household reminder settings +- localized reminder copy using persisted household/member locale +- scheduler fan-out observability metrics diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index 9311f1d..f6421f8 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, eq } from 'drizzle-orm' +import { and, asc, eq } from 'drizzle-orm' import { createDbClient, schema } from '@household/db' import { instantToDate, normalizeSupportedLocale, nowInstant } from '@household/domain' @@ -11,6 +11,7 @@ import { type HouseholdTelegramChatRecord, type HouseholdTopicBindingRecord, type HouseholdTopicRole, + type ReminderTarget, type RegisterTelegramHouseholdChatResult } from '@household/ports' @@ -125,6 +126,27 @@ function toHouseholdMemberRecord(row: { } } +function toReminderTarget(row: { + householdId: string + householdName: string + telegramChatId: string + reminderThreadId: string | null + defaultLocale: string +}): ReminderTarget { + const locale = normalizeSupportedLocale(row.defaultLocale) + if (!locale) { + throw new Error(`Unsupported household default locale: ${row.defaultLocale}`) + } + + return { + householdId: row.householdId, + householdName: row.householdName, + telegramChatId: row.telegramChatId, + telegramThreadId: row.reminderThreadId, + locale + } +} + export function createDbHouseholdConfigurationRepository(databaseUrl: string): { repository: HouseholdConfigurationRepository close: () => Promise @@ -364,6 +386,35 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { return rows.map(toHouseholdTopicBindingRecord) }, + async listReminderTargets() { + const rows = await db + .select({ + householdId: schema.householdTelegramChats.householdId, + householdName: schema.households.name, + telegramChatId: schema.householdTelegramChats.telegramChatId, + reminderThreadId: schema.householdTopicBindings.telegramThreadId, + defaultLocale: schema.households.defaultLocale + }) + .from(schema.householdTelegramChats) + .innerJoin( + schema.households, + eq(schema.householdTelegramChats.householdId, schema.households.id) + ) + .leftJoin( + schema.householdTopicBindings, + and( + eq( + schema.householdTopicBindings.householdId, + schema.householdTelegramChats.householdId + ), + eq(schema.householdTopicBindings.role, 'reminders') + ) + ) + .orderBy(asc(schema.householdTelegramChats.telegramChatId), asc(schema.households.name)) + + return rows.map(toReminderTarget) + }, + async upsertHouseholdJoinToken(input) { const rows = await db .insert(schema.householdJoinTokens) diff --git a/packages/adapters-db/src/reminder-dispatch-repository.ts b/packages/adapters-db/src/reminder-dispatch-repository.ts index 14064dc..c74d764 100644 --- a/packages/adapters-db/src/reminder-dispatch-repository.ts +++ b/packages/adapters-db/src/reminder-dispatch-repository.ts @@ -1,3 +1,5 @@ +import { and, eq } from 'drizzle-orm' + import { createDbClient, schema } from '@household/db' import type { ReminderDispatchRepository } from '@household/ports' @@ -34,6 +36,20 @@ export function createDbReminderDispatchRepository(databaseUrl: string): { dedupeKey, claimed: rows.length > 0 } + }, + + async releaseReminderDispatch(input) { + const dedupeKey = `${input.period}:${input.reminderType}` + + await db + .delete(schema.processedBotMessages) + .where( + and( + eq(schema.processedBotMessages.householdId, input.householdId), + eq(schema.processedBotMessages.source, 'scheduler-reminder'), + eq(schema.processedBotMessages.sourceMessageKey, dedupeKey) + ) + ) } } diff --git a/packages/application/src/household-admin-service.test.ts b/packages/application/src/household-admin-service.test.ts index 3d31d5a..c5ad96b 100644 --- a/packages/application/src/household-admin-service.test.ts +++ b/packages/application/src/household-admin-service.test.ts @@ -59,6 +59,7 @@ function createRepositoryStub() { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], upsertHouseholdJoinToken: async (input) => ({ householdId: household.householdId, diff --git a/packages/application/src/household-onboarding-service.test.ts b/packages/application/src/household-onboarding-service.test.ts index c4bfbfb..cc91a14 100644 --- a/packages/application/src/household-onboarding-service.test.ts +++ b/packages/application/src/household-onboarding-service.test.ts @@ -55,6 +55,9 @@ function createRepositoryStub() { async listHouseholdTopicBindings() { return [] }, + async listReminderTargets() { + return [] + }, async upsertHouseholdJoinToken(input) { joinToken = { householdId: household.householdId, diff --git a/packages/application/src/household-setup-service.test.ts b/packages/application/src/household-setup-service.test.ts index 5215ad1..d956da3 100644 --- a/packages/application/src/household-setup-service.test.ts +++ b/packages/application/src/household-setup-service.test.ts @@ -93,6 +93,9 @@ function createRepositoryStub() { async listHouseholdTopicBindings(householdId) { return bindings.get(householdId) ?? [] }, + async listReminderTargets() { + return [] + }, async upsertHouseholdJoinToken(input) { const household = [...households.values()].find( diff --git a/packages/application/src/locale-preference-service.test.ts b/packages/application/src/locale-preference-service.test.ts index d695ef4..1fe8a06 100644 --- a/packages/application/src/locale-preference-service.test.ts +++ b/packages/application/src/locale-preference-service.test.ts @@ -37,6 +37,7 @@ function createRepository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], upsertHouseholdJoinToken: async () => ({ householdId: household.householdId, householdName: household.householdName, diff --git a/packages/application/src/miniapp-admin-service.test.ts b/packages/application/src/miniapp-admin-service.test.ts index 654e655..650ba97 100644 --- a/packages/application/src/miniapp-admin-service.test.ts +++ b/packages/application/src/miniapp-admin-service.test.ts @@ -28,6 +28,7 @@ function repository(): HouseholdConfigurationRepository { getHouseholdTopicBinding: async () => null, findHouseholdTopicByTelegramContext: async () => null, listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], upsertHouseholdJoinToken: async () => ({ householdId: 'household-1', householdName: 'Kojori House', diff --git a/packages/application/src/reminder-job-service.test.ts b/packages/application/src/reminder-job-service.test.ts index e3d05d5..f9e34eb 100644 --- a/packages/application/src/reminder-job-service.test.ts +++ b/packages/application/src/reminder-job-service.test.ts @@ -22,6 +22,8 @@ class ReminderDispatchRepositoryStub implements ReminderDispatchRepository { this.lastClaim = input return this.nextResult } + + async releaseReminderDispatch(): Promise {} } describe('createReminderJobService', () => { diff --git a/packages/config/src/env.ts b/packages/config/src/env.ts index 295a4f7..4c3fe7b 100644 --- a/packages/config/src/env.ts +++ b/packages/config/src/env.ts @@ -19,7 +19,7 @@ const server = { LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), PORT: z.coerce.number().int().min(1).max(65535).default(3000), DATABASE_URL: z.string().url(), - HOUSEHOLD_ID: z.string().uuid(), + HOUSEHOLD_ID: z.string().uuid().optional(), SUPABASE_URL: z.string().url().optional(), SUPABASE_PUBLISHABLE_KEY: z.string().min(1).optional(), SUPABASE_SERVICE_ROLE_KEY: z.string().min(1).optional(), diff --git a/packages/ports/src/household-config.ts b/packages/ports/src/household-config.ts index beb37d2..15eeb4c 100644 --- a/packages/ports/src/household-config.ts +++ b/packages/ports/src/household-config.ts @@ -1,4 +1,5 @@ import type { SupportedLocale } from '@household/domain' +import type { ReminderTarget } from './reminders' export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const @@ -80,6 +81,7 @@ export interface HouseholdConfigurationRepository { telegramThreadId: string }): Promise listHouseholdTopicBindings(householdId: string): Promise + listReminderTargets(): Promise upsertHouseholdJoinToken(input: { householdId: string token: string diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index af9af86..30798a8 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -3,6 +3,7 @@ export { type ClaimReminderDispatchInput, type ClaimReminderDispatchResult, type ReminderDispatchRepository, + type ReminderTarget, type ReminderType } from './reminders' export { diff --git a/packages/ports/src/reminders.ts b/packages/ports/src/reminders.ts index cc6f1da..2c4cf47 100644 --- a/packages/ports/src/reminders.ts +++ b/packages/ports/src/reminders.ts @@ -1,7 +1,17 @@ +import type { SupportedLocale } from '@household/domain' + export const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const export type ReminderType = (typeof REMINDER_TYPES)[number] +export interface ReminderTarget { + householdId: string + householdName: string + telegramChatId: string + telegramThreadId: string | null + locale: SupportedLocale +} + export interface ClaimReminderDispatchInput { householdId: string period: string @@ -16,4 +26,9 @@ export interface ClaimReminderDispatchResult { export interface ReminderDispatchRepository { claimReminderDispatch(input: ClaimReminderDispatchInput): Promise + releaseReminderDispatch(input: { + householdId: string + period: string + reminderType: ReminderType + }): Promise }