feat(bot): add multi-household reminder delivery

This commit is contained in:
2026-03-09 16:50:57 +04:00
parent 12f33e7aea
commit 16f9981fee
27 changed files with 412 additions and 52 deletions

View File

@@ -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<ReminderJobRequestBody> {
}
export function createReminderJobsHandler(options: {
householdId: string
listReminderTargets: () => Promise<readonly ReminderTarget[]>
releaseReminderDispatch: (input: {
householdId: string
period: string
reminderType: ReminderType
}) => Promise<void>
sendReminderMessage: (target: ReminderTarget, text: string) => Promise<void>
reminderService: ReminderJobService
forceDryRun?: boolean
logger?: Logger
}): {
handle: (request: Request, rawReminderType: string) => Promise<Response>
} {
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'