mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:54:02 +00:00
245 lines
7.2 KiB
TypeScript
245 lines
7.2 KiB
TypeScript
import type { ReminderJobService } from '@household/application'
|
|
import { BillingPeriod, Temporal, nowInstant } from '@household/domain'
|
|
import type { Logger } from '@household/observability'
|
|
import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports'
|
|
|
|
import { getBotTranslations } from './i18n'
|
|
|
|
interface ReminderJobRequestBody {
|
|
period?: string
|
|
jobId?: string
|
|
dryRun?: boolean
|
|
}
|
|
|
|
function json(body: object, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: {
|
|
'content-type': 'application/json; charset=utf-8'
|
|
}
|
|
})
|
|
}
|
|
|
|
function parseReminderType(raw: string): ReminderType | null {
|
|
if ((REMINDER_TYPES as readonly string[]).includes(raw)) {
|
|
return raw as ReminderType
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
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()
|
|
|
|
if (text.trim().length === 0) {
|
|
return {}
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(text) as ReminderJobRequestBody
|
|
} catch {
|
|
throw new Error('Invalid JSON body')
|
|
}
|
|
}
|
|
|
|
export function createReminderJobsHandler(options: {
|
|
listReminderTargets: () => Promise<readonly ReminderTarget[]>
|
|
ensureBillingCycle?: (input: { householdId: string; at: Temporal.Instant }) => Promise<void>
|
|
releaseReminderDispatch: (input: {
|
|
householdId: string
|
|
period: string
|
|
reminderType: ReminderType
|
|
}) => Promise<void>
|
|
sendReminderMessage: (target: ReminderTarget, text: string) => Promise<void>
|
|
reminderService: ReminderJobService
|
|
forceDryRun?: boolean
|
|
now?: () => Temporal.Instant
|
|
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)
|
|
if (!reminderType) {
|
|
return json({ ok: false, error: 'Invalid reminder type' }, 400)
|
|
}
|
|
|
|
try {
|
|
const body = await readBody(request)
|
|
const schedulerJobName = request.headers.get('x-cloudscheduler-jobname')
|
|
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
|
|
error?: string
|
|
}> = []
|
|
|
|
for (const target of targets) {
|
|
await options.ensureBillingCycle?.({
|
|
householdId: target.householdId,
|
|
at: currentInstant
|
|
})
|
|
|
|
if (!requestedPeriod && !isReminderDueToday(target, reminderType, currentInstant)) {
|
|
continue
|
|
}
|
|
|
|
const period = requestedPeriod ?? targetPeriod(target, currentInstant)
|
|
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,
|
|
period,
|
|
dedupeKey: result.dedupeKey,
|
|
outcome,
|
|
messageText: text,
|
|
...(error ? { error } : {})
|
|
})
|
|
}
|
|
|
|
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: defaultPeriod,
|
|
dryRun,
|
|
totals,
|
|
dispatches
|
|
})
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown reminder job error'
|
|
|
|
options.logger?.error(
|
|
{
|
|
event: 'scheduler.reminder.dispatch_failed',
|
|
reminderType: rawReminderType,
|
|
error: message
|
|
},
|
|
'Reminder job failed'
|
|
)
|
|
|
|
return json({ ok: false, error: message }, 400)
|
|
}
|
|
}
|
|
}
|
|
}
|