mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:34:03 +00:00
351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
import { BillingPeriod, Temporal, nowInstant, type Instant } from '@household/domain'
|
|
import type {
|
|
HouseholdConfigurationRepository,
|
|
ScheduledDispatchKind,
|
|
ScheduledDispatchRecord,
|
|
ScheduledDispatchRepository,
|
|
ScheduledDispatchScheduler
|
|
} from '@household/ports'
|
|
|
|
const BUILT_IN_DISPATCH_KINDS = ['utilities', 'rent_warning', 'rent_due'] as const
|
|
const DEFAULT_DUE_DISPATCH_SCAN_LIMIT = 25
|
|
const MAX_DUE_DISPATCH_SCAN_LIMIT = 100
|
|
|
|
function normalizeDueDispatchLimit(limit: number | undefined): number {
|
|
const value = limit ?? DEFAULT_DUE_DISPATCH_SCAN_LIMIT
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
return DEFAULT_DUE_DISPATCH_SCAN_LIMIT
|
|
}
|
|
|
|
return Math.min(value, MAX_DUE_DISPATCH_SCAN_LIMIT)
|
|
}
|
|
|
|
function builtInDispatchDay(
|
|
kind: (typeof BUILT_IN_DISPATCH_KINDS)[number],
|
|
settings: Awaited<ReturnType<HouseholdConfigurationRepository['getHouseholdBillingSettings']>>
|
|
): number {
|
|
switch (kind) {
|
|
case 'utilities':
|
|
return settings.utilitiesReminderDay
|
|
case 'rent_warning':
|
|
return settings.rentWarningDay
|
|
case 'rent_due':
|
|
return settings.rentDueDay
|
|
}
|
|
}
|
|
|
|
function builtInDispatchHour(): number {
|
|
return 9
|
|
}
|
|
|
|
function clampDay(year: number, month: number, day: number): number {
|
|
const yearMonth = new Temporal.PlainYearMonth(year, month)
|
|
return Math.min(day, yearMonth.daysInMonth)
|
|
}
|
|
|
|
function nextBuiltInDispatch(input: {
|
|
kind: (typeof BUILT_IN_DISPATCH_KINDS)[number]
|
|
timezone: string
|
|
day: number
|
|
asOf: Instant
|
|
}): {
|
|
dueAt: Instant
|
|
period: string
|
|
} {
|
|
const localNow = input.asOf.toZonedDateTimeISO(input.timezone)
|
|
let year = localNow.year
|
|
let month = localNow.month
|
|
|
|
const buildCandidate = (candidateYear: number, candidateMonth: number) => {
|
|
const candidateDay = clampDay(candidateYear, candidateMonth, input.day)
|
|
return new Temporal.PlainDateTime(
|
|
candidateYear,
|
|
candidateMonth,
|
|
candidateDay,
|
|
builtInDispatchHour(),
|
|
0,
|
|
0,
|
|
0
|
|
).toZonedDateTime(input.timezone)
|
|
}
|
|
|
|
let candidate = buildCandidate(year, month)
|
|
if (candidate.epochMilliseconds <= localNow.epochMilliseconds) {
|
|
const nextMonth = new Temporal.PlainYearMonth(localNow.year, localNow.month).add({
|
|
months: 1
|
|
})
|
|
year = nextMonth.year
|
|
month = nextMonth.month
|
|
candidate = buildCandidate(year, month)
|
|
}
|
|
|
|
return {
|
|
dueAt: candidate.toInstant(),
|
|
period: BillingPeriod.fromString(
|
|
`${candidate.year}-${String(candidate.month).padStart(2, '0')}`
|
|
).toString()
|
|
}
|
|
}
|
|
|
|
export interface ScheduledDispatchService {
|
|
scheduleAdHocNotification(input: {
|
|
householdId: string
|
|
notificationId: string
|
|
dueAt: Instant
|
|
timezone: string
|
|
}): Promise<ScheduledDispatchRecord>
|
|
cancelAdHocNotification(notificationId: string, cancelledAt?: Instant): Promise<void>
|
|
reconcileHouseholdBuiltInDispatches(householdId: string, asOf?: Instant): Promise<void>
|
|
reconcileAllBuiltInDispatches(asOf?: Instant): Promise<void>
|
|
listDueDispatches(input?: {
|
|
asOf?: Instant
|
|
limit?: number
|
|
}): Promise<readonly ScheduledDispatchRecord[]>
|
|
getDispatchById(dispatchId: string): Promise<ScheduledDispatchRecord | null>
|
|
claimDispatch(dispatchId: string): Promise<boolean>
|
|
releaseDispatch(dispatchId: string): Promise<void>
|
|
markDispatchSent(dispatchId: string, sentAt?: Instant): Promise<ScheduledDispatchRecord | null>
|
|
}
|
|
|
|
export function createScheduledDispatchService(input: {
|
|
repository: ScheduledDispatchRepository
|
|
scheduler: ScheduledDispatchScheduler
|
|
householdConfigurationRepository: Pick<
|
|
HouseholdConfigurationRepository,
|
|
'getHouseholdBillingSettings' | 'getHouseholdChatByHouseholdId' | 'listReminderTargets'
|
|
>
|
|
}): ScheduledDispatchService {
|
|
async function createDispatchRecord(record: {
|
|
householdId: string
|
|
kind: ScheduledDispatchKind
|
|
dueAt: Instant
|
|
timezone: string
|
|
adHocNotificationId?: string | null
|
|
period?: string | null
|
|
}) {
|
|
return input.repository.createScheduledDispatch({
|
|
householdId: record.householdId,
|
|
kind: record.kind,
|
|
dueAt: record.dueAt,
|
|
timezone: record.timezone,
|
|
provider: input.scheduler.provider,
|
|
providerDispatchId: null,
|
|
adHocNotificationId: record.adHocNotificationId ?? null,
|
|
period: record.period ?? null
|
|
})
|
|
}
|
|
|
|
async function activateDispatch(
|
|
dispatch: ScheduledDispatchRecord,
|
|
dueAt: Instant,
|
|
timezone: string,
|
|
period?: string | null
|
|
) {
|
|
const result = await input.scheduler.scheduleOneShotDispatch({
|
|
dispatchId: dispatch.id,
|
|
dueAt
|
|
})
|
|
|
|
const updated = await input.repository.updateScheduledDispatch({
|
|
dispatchId: dispatch.id,
|
|
dueAt,
|
|
timezone,
|
|
providerDispatchId: result.providerDispatchId,
|
|
period: period ?? null,
|
|
updatedAt: nowInstant()
|
|
})
|
|
if (!updated) {
|
|
await input.scheduler.cancelDispatch(result.providerDispatchId)
|
|
throw new Error(`Failed to update scheduled dispatch ${dispatch.id}`)
|
|
}
|
|
return updated
|
|
}
|
|
|
|
async function ensureBuiltInDispatch(inputValue: {
|
|
householdId: string
|
|
kind: (typeof BUILT_IN_DISPATCH_KINDS)[number]
|
|
dueAt: Instant
|
|
timezone: string
|
|
period: string
|
|
existing: ScheduledDispatchRecord | null
|
|
}) {
|
|
if (
|
|
inputValue.existing &&
|
|
inputValue.existing.status === 'scheduled' &&
|
|
inputValue.existing.dueAt.epochMilliseconds === inputValue.dueAt.epochMilliseconds &&
|
|
inputValue.existing.period === inputValue.period &&
|
|
inputValue.existing.provider === input.scheduler.provider &&
|
|
inputValue.existing.providerDispatchId
|
|
) {
|
|
return
|
|
}
|
|
|
|
if (!inputValue.existing) {
|
|
const created = await createDispatchRecord({
|
|
householdId: inputValue.householdId,
|
|
kind: inputValue.kind,
|
|
dueAt: inputValue.dueAt,
|
|
timezone: inputValue.timezone,
|
|
period: inputValue.period
|
|
})
|
|
|
|
try {
|
|
await activateDispatch(created, inputValue.dueAt, inputValue.timezone, inputValue.period)
|
|
} catch (error) {
|
|
await input.repository.cancelScheduledDispatch(created.id, nowInstant())
|
|
throw error
|
|
}
|
|
return
|
|
}
|
|
|
|
const previousProviderDispatchId = inputValue.existing.providerDispatchId
|
|
const updated = await activateDispatch(
|
|
inputValue.existing,
|
|
inputValue.dueAt,
|
|
inputValue.timezone,
|
|
inputValue.period
|
|
)
|
|
|
|
if (previousProviderDispatchId && previousProviderDispatchId !== updated.providerDispatchId) {
|
|
await input.scheduler.cancelDispatch(previousProviderDispatchId)
|
|
}
|
|
}
|
|
|
|
async function reconcileHouseholdBuiltInDispatches(householdId: string, asOf = nowInstant()) {
|
|
const [chat, settings, existingDispatches] = await Promise.all([
|
|
input.householdConfigurationRepository.getHouseholdChatByHouseholdId(householdId),
|
|
input.householdConfigurationRepository.getHouseholdBillingSettings(householdId),
|
|
input.repository.listScheduledDispatchesForHousehold(householdId)
|
|
])
|
|
|
|
const existingByKind = new Map(
|
|
existingDispatches
|
|
.filter((dispatch) =>
|
|
BUILT_IN_DISPATCH_KINDS.includes(
|
|
dispatch.kind as (typeof BUILT_IN_DISPATCH_KINDS)[number]
|
|
)
|
|
)
|
|
.map((dispatch) => [dispatch.kind, dispatch])
|
|
)
|
|
|
|
if (!chat) {
|
|
for (const dispatch of existingByKind.values()) {
|
|
if (dispatch.status !== 'scheduled') {
|
|
continue
|
|
}
|
|
|
|
if (dispatch.providerDispatchId) {
|
|
await input.scheduler.cancelDispatch(dispatch.providerDispatchId)
|
|
}
|
|
await input.repository.cancelScheduledDispatch(dispatch.id, asOf)
|
|
}
|
|
return
|
|
}
|
|
|
|
for (const kind of BUILT_IN_DISPATCH_KINDS) {
|
|
const next = nextBuiltInDispatch({
|
|
kind,
|
|
timezone: settings.timezone,
|
|
day: builtInDispatchDay(kind, settings),
|
|
asOf
|
|
})
|
|
|
|
await ensureBuiltInDispatch({
|
|
householdId,
|
|
kind,
|
|
dueAt: next.dueAt,
|
|
timezone: settings.timezone,
|
|
period: next.period,
|
|
existing: existingByKind.get(kind) ?? null
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
async scheduleAdHocNotification(dispatchInput) {
|
|
const existing = await input.repository.getScheduledDispatchByAdHocNotificationId(
|
|
dispatchInput.notificationId
|
|
)
|
|
if (!existing) {
|
|
const created = await createDispatchRecord({
|
|
householdId: dispatchInput.householdId,
|
|
kind: 'ad_hoc_notification',
|
|
dueAt: dispatchInput.dueAt,
|
|
timezone: dispatchInput.timezone,
|
|
adHocNotificationId: dispatchInput.notificationId
|
|
})
|
|
|
|
try {
|
|
return await activateDispatch(created, dispatchInput.dueAt, dispatchInput.timezone, null)
|
|
} catch (error) {
|
|
await input.repository.cancelScheduledDispatch(created.id, nowInstant())
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const previousProviderDispatchId = existing.providerDispatchId
|
|
const updated = await activateDispatch(
|
|
existing,
|
|
dispatchInput.dueAt,
|
|
dispatchInput.timezone,
|
|
null
|
|
)
|
|
|
|
if (previousProviderDispatchId && previousProviderDispatchId !== updated.providerDispatchId) {
|
|
await input.scheduler.cancelDispatch(previousProviderDispatchId)
|
|
}
|
|
|
|
return updated
|
|
},
|
|
|
|
async cancelAdHocNotification(notificationId, cancelledAt = nowInstant()) {
|
|
const existing =
|
|
await input.repository.getScheduledDispatchByAdHocNotificationId(notificationId)
|
|
if (!existing || existing.status !== 'scheduled') {
|
|
return
|
|
}
|
|
|
|
if (existing.providerDispatchId) {
|
|
await input.scheduler.cancelDispatch(existing.providerDispatchId)
|
|
}
|
|
await input.repository.cancelScheduledDispatch(existing.id, cancelledAt)
|
|
},
|
|
|
|
reconcileHouseholdBuiltInDispatches,
|
|
|
|
async reconcileAllBuiltInDispatches(asOf = nowInstant()) {
|
|
const targets = await input.householdConfigurationRepository.listReminderTargets()
|
|
const householdIds = [...new Set(targets.map((target) => target.householdId))]
|
|
|
|
for (const householdId of householdIds) {
|
|
await reconcileHouseholdBuiltInDispatches(householdId, asOf)
|
|
}
|
|
},
|
|
|
|
listDueDispatches(inputValue) {
|
|
return input.repository.listDueScheduledDispatches({
|
|
dueBefore: inputValue?.asOf ?? nowInstant(),
|
|
provider: input.scheduler.provider,
|
|
limit: normalizeDueDispatchLimit(inputValue?.limit)
|
|
})
|
|
},
|
|
|
|
getDispatchById(dispatchId) {
|
|
return input.repository.getScheduledDispatchById(dispatchId)
|
|
},
|
|
|
|
async claimDispatch(dispatchId) {
|
|
const result = await input.repository.claimScheduledDispatchDelivery(dispatchId)
|
|
return result.claimed
|
|
},
|
|
|
|
releaseDispatch(dispatchId) {
|
|
return input.repository.releaseScheduledDispatchDelivery(dispatchId)
|
|
},
|
|
|
|
markDispatchSent(dispatchId, sentAt = nowInstant()) {
|
|
return input.repository.markScheduledDispatchSent(dispatchId, sentAt)
|
|
}
|
|
}
|
|
}
|