mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 22:34:03 +00:00
refactor(bot): replace reminder polling with scheduled dispatches
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
HouseholdConfigurationRepository,
|
||||
HouseholdMemberRecord
|
||||
} from '@household/ports'
|
||||
import type { ScheduledDispatchService } from './scheduled-dispatch-service'
|
||||
|
||||
interface NotificationActor {
|
||||
memberId: string
|
||||
@@ -57,6 +58,7 @@ export type ScheduleAdHocNotificationResult =
|
||||
| 'delivery_mode_invalid'
|
||||
| 'friendly_assignee_missing'
|
||||
| 'scheduled_for_past'
|
||||
| 'dispatch_schedule_failed'
|
||||
}
|
||||
|
||||
export type CancelAdHocNotificationResult =
|
||||
@@ -78,7 +80,11 @@ export type UpdateAdHocNotificationResult =
|
||||
}
|
||||
| {
|
||||
status: 'invalid'
|
||||
reason: 'delivery_mode_invalid' | 'dm_recipients_missing' | 'scheduled_for_past'
|
||||
reason:
|
||||
| 'delivery_mode_invalid'
|
||||
| 'dm_recipients_missing'
|
||||
| 'scheduled_for_past'
|
||||
| 'dispatch_schedule_failed'
|
||||
}
|
||||
|
||||
export interface AdHocNotificationService {
|
||||
@@ -165,6 +171,7 @@ export function createAdHocNotificationService(input: {
|
||||
HouseholdConfigurationRepository,
|
||||
'getHouseholdMember' | 'listHouseholdMembers'
|
||||
>
|
||||
scheduledDispatchService?: ScheduledDispatchService
|
||||
}): AdHocNotificationService {
|
||||
async function resolveActor(
|
||||
householdId: string,
|
||||
@@ -272,6 +279,28 @@ export function createAdHocNotificationService(input: {
|
||||
sourceTelegramThreadId: notificationInput.sourceTelegramThreadId ?? null
|
||||
})
|
||||
|
||||
if (input.scheduledDispatchService) {
|
||||
try {
|
||||
await input.scheduledDispatchService.scheduleAdHocNotification({
|
||||
householdId: notification.householdId,
|
||||
notificationId: notification.id,
|
||||
dueAt: notification.scheduledFor,
|
||||
timezone: notification.timezone
|
||||
})
|
||||
} catch {
|
||||
await input.repository.cancelNotification({
|
||||
notificationId: notification.id,
|
||||
cancelledByMemberId: notification.creatorMemberId,
|
||||
cancelledAt: nowInstant()
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'invalid',
|
||||
reason: 'dispatch_schedule_failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'scheduled',
|
||||
notification
|
||||
@@ -352,6 +381,10 @@ export function createAdHocNotificationService(input: {
|
||||
}
|
||||
}
|
||||
|
||||
if (input.scheduledDispatchService) {
|
||||
await input.scheduledDispatchService.cancelAdHocNotification(notificationId, asOf)
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'cancelled',
|
||||
notification: cancelled
|
||||
@@ -397,6 +430,10 @@ export function createAdHocNotificationService(input: {
|
||||
input.householdConfigurationRepository,
|
||||
notification.householdId
|
||||
)
|
||||
const previousScheduledFor = notification.scheduledFor
|
||||
const previousTimePrecision = notification.timePrecision
|
||||
const previousDeliveryMode = notification.deliveryMode
|
||||
const previousDmRecipientMemberIds = notification.dmRecipientMemberIds
|
||||
|
||||
if (scheduledFor && scheduledFor.epochMilliseconds <= asOf.epochMilliseconds) {
|
||||
return {
|
||||
@@ -455,6 +492,31 @@ export function createAdHocNotificationService(input: {
|
||||
}
|
||||
}
|
||||
|
||||
if (input.scheduledDispatchService) {
|
||||
try {
|
||||
await input.scheduledDispatchService.scheduleAdHocNotification({
|
||||
householdId: updated.householdId,
|
||||
notificationId: updated.id,
|
||||
dueAt: updated.scheduledFor,
|
||||
timezone: updated.timezone
|
||||
})
|
||||
} catch {
|
||||
await input.repository.updateNotification({
|
||||
notificationId,
|
||||
scheduledFor: previousScheduledFor,
|
||||
timePrecision: previousTimePrecision,
|
||||
deliveryMode: previousDeliveryMode,
|
||||
dmRecipientMemberIds: previousDmRecipientMemberIds,
|
||||
updatedAt: nowInstant()
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'invalid',
|
||||
reason: 'dispatch_schedule_failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'updated',
|
||||
notification: updated
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
HouseholdTopicBindingRecord,
|
||||
HouseholdTopicRole
|
||||
} from '@household/ports'
|
||||
import type { ScheduledDispatchService } from './scheduled-dispatch-service'
|
||||
|
||||
export interface HouseholdSetupService {
|
||||
setupGroupChat(input: {
|
||||
@@ -72,7 +73,8 @@ function defaultHouseholdName(title: string | undefined, telegramChatId: string)
|
||||
}
|
||||
|
||||
export function createHouseholdSetupService(
|
||||
repository: HouseholdConfigurationRepository
|
||||
repository: HouseholdConfigurationRepository,
|
||||
scheduledDispatchService?: ScheduledDispatchService
|
||||
): HouseholdSetupService {
|
||||
return {
|
||||
async setupGroupChat(input) {
|
||||
@@ -118,6 +120,12 @@ export function createHouseholdSetupService(
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduledDispatchService) {
|
||||
await scheduledDispatchService.reconcileHouseholdBuiltInDispatches(
|
||||
registered.household.householdId
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
status: registered.status,
|
||||
household: registered.household
|
||||
|
||||
@@ -25,10 +25,9 @@ export {
|
||||
type HouseholdOnboardingService
|
||||
} from './household-onboarding-service'
|
||||
export {
|
||||
createReminderJobService,
|
||||
type ReminderJobResult,
|
||||
type ReminderJobService
|
||||
} from './reminder-job-service'
|
||||
createScheduledDispatchService,
|
||||
type ScheduledDispatchService
|
||||
} from './scheduled-dispatch-service'
|
||||
export {
|
||||
createLocalePreferenceService,
|
||||
type LocalePreferenceService
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
HouseholdUtilityCategoryRecord
|
||||
} from '@household/ports'
|
||||
import { Money, Temporal, type CurrencyCode } from '@household/domain'
|
||||
import type { ScheduledDispatchService } from './scheduled-dispatch-service'
|
||||
|
||||
function isValidDay(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= 1 && value <= 31
|
||||
@@ -339,7 +340,8 @@ function normalizeAssistantText(
|
||||
}
|
||||
|
||||
export function createMiniAppAdminService(
|
||||
repository: HouseholdConfigurationRepository
|
||||
repository: HouseholdConfigurationRepository,
|
||||
scheduledDispatchService?: ScheduledDispatchService
|
||||
): MiniAppAdminService {
|
||||
return {
|
||||
async getSettings(input) {
|
||||
@@ -531,6 +533,10 @@ export function createMiniAppAdminService(
|
||||
throw new Error('Failed to resolve household chat after settings update')
|
||||
}
|
||||
|
||||
if (scheduledDispatchService) {
|
||||
await scheduledDispatchService.reconcileHouseholdBuiltInDispatches(input.householdId)
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
householdName: household.householdName,
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import type {
|
||||
ClaimReminderDispatchInput,
|
||||
ClaimReminderDispatchResult,
|
||||
ReminderDispatchRepository
|
||||
} from '@household/ports'
|
||||
|
||||
import { createReminderJobService } from './reminder-job-service'
|
||||
|
||||
class ReminderDispatchRepositoryStub implements ReminderDispatchRepository {
|
||||
nextResult: ClaimReminderDispatchResult = {
|
||||
dedupeKey: '2026-03:utilities',
|
||||
claimed: true
|
||||
}
|
||||
|
||||
lastClaim: ClaimReminderDispatchInput | null = null
|
||||
|
||||
async claimReminderDispatch(
|
||||
input: ClaimReminderDispatchInput
|
||||
): Promise<ClaimReminderDispatchResult> {
|
||||
this.lastClaim = input
|
||||
return this.nextResult
|
||||
}
|
||||
|
||||
async releaseReminderDispatch(): Promise<void> {}
|
||||
}
|
||||
|
||||
describe('createReminderJobService', () => {
|
||||
test('returns dry-run result without touching the repository', async () => {
|
||||
const repository = new ReminderDispatchRepositoryStub()
|
||||
const service = createReminderJobService(repository)
|
||||
|
||||
const result = await service.handleJob({
|
||||
householdId: 'household-1',
|
||||
period: '2026-03',
|
||||
reminderType: 'utilities',
|
||||
dryRun: true
|
||||
})
|
||||
|
||||
expect(result.status).toBe('dry-run')
|
||||
expect(result.dedupeKey).toBe('2026-03:utilities')
|
||||
expect(result.messageText).toBe('Utilities reminder for 2026-03')
|
||||
expect(repository.lastClaim).toBeNull()
|
||||
})
|
||||
|
||||
test('claims a dispatch once and returns the dedupe key', async () => {
|
||||
const repository = new ReminderDispatchRepositoryStub()
|
||||
repository.nextResult = {
|
||||
dedupeKey: '2026-03:rent-due',
|
||||
claimed: true
|
||||
}
|
||||
const service = createReminderJobService(repository)
|
||||
|
||||
const result = await service.handleJob({
|
||||
householdId: 'household-1',
|
||||
period: '2026-03',
|
||||
reminderType: 'rent-due'
|
||||
})
|
||||
|
||||
expect(result.status).toBe('claimed')
|
||||
expect(result.dedupeKey).toBe('2026-03:rent-due')
|
||||
expect(repository.lastClaim).toMatchObject({
|
||||
householdId: 'household-1',
|
||||
period: '2026-03',
|
||||
reminderType: 'rent-due'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns duplicate when the repository rejects a replay', async () => {
|
||||
const repository = new ReminderDispatchRepositoryStub()
|
||||
repository.nextResult = {
|
||||
dedupeKey: '2026-03:rent-warning',
|
||||
claimed: false
|
||||
}
|
||||
|
||||
const service = createReminderJobService(repository)
|
||||
const result = await service.handleJob({
|
||||
householdId: 'household-1',
|
||||
period: '2026-03',
|
||||
reminderType: 'rent-warning'
|
||||
})
|
||||
|
||||
expect(result.status).toBe('duplicate')
|
||||
expect(result.dedupeKey).toBe('2026-03:rent-warning')
|
||||
})
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { BillingPeriod } from '@household/domain'
|
||||
import type {
|
||||
ClaimReminderDispatchResult,
|
||||
ReminderDispatchRepository,
|
||||
ReminderType
|
||||
} from '@household/ports'
|
||||
|
||||
function computePayloadHash(payload: object): string {
|
||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
|
||||
}
|
||||
|
||||
function buildReminderDedupeKey(period: string, reminderType: ReminderType): string {
|
||||
return `${period}:${reminderType}`
|
||||
}
|
||||
|
||||
function createReminderMessage(reminderType: ReminderType, period: string): string {
|
||||
switch (reminderType) {
|
||||
case 'utilities':
|
||||
return `Utilities reminder for ${period}`
|
||||
case 'rent-warning':
|
||||
return `Rent reminder for ${period}: payment is coming up soon.`
|
||||
case 'rent-due':
|
||||
return `Rent due reminder for ${period}: please settle payment today.`
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReminderJobResult {
|
||||
status: 'dry-run' | 'claimed' | 'duplicate'
|
||||
dedupeKey: string
|
||||
payloadHash: string
|
||||
reminderType: ReminderType
|
||||
period: string
|
||||
messageText: string
|
||||
}
|
||||
|
||||
export interface ReminderJobService {
|
||||
handleJob(input: {
|
||||
householdId: string
|
||||
period: string
|
||||
reminderType: ReminderType
|
||||
dryRun?: boolean
|
||||
}): Promise<ReminderJobResult>
|
||||
}
|
||||
|
||||
export function createReminderJobService(
|
||||
repository: ReminderDispatchRepository
|
||||
): ReminderJobService {
|
||||
return {
|
||||
async handleJob(input) {
|
||||
const period = BillingPeriod.fromString(input.period).toString()
|
||||
const payloadHash = computePayloadHash({
|
||||
householdId: input.householdId,
|
||||
period,
|
||||
reminderType: input.reminderType
|
||||
})
|
||||
const messageText = createReminderMessage(input.reminderType, period)
|
||||
|
||||
if (input.dryRun === true) {
|
||||
return {
|
||||
status: 'dry-run',
|
||||
dedupeKey: buildReminderDedupeKey(period, input.reminderType),
|
||||
payloadHash,
|
||||
reminderType: input.reminderType,
|
||||
period,
|
||||
messageText
|
||||
}
|
||||
}
|
||||
|
||||
const result: ClaimReminderDispatchResult = await repository.claimReminderDispatch({
|
||||
householdId: input.householdId,
|
||||
period,
|
||||
reminderType: input.reminderType,
|
||||
payloadHash
|
||||
})
|
||||
|
||||
return {
|
||||
status: result.claimed ? 'claimed' : 'duplicate',
|
||||
dedupeKey: result.dedupeKey,
|
||||
payloadHash,
|
||||
reminderType: input.reminderType,
|
||||
period,
|
||||
messageText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
294
packages/application/src/scheduled-dispatch-service.test.ts
Normal file
294
packages/application/src/scheduled-dispatch-service.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { Temporal } from '@household/domain'
|
||||
import type {
|
||||
HouseholdBillingSettingsRecord,
|
||||
HouseholdTelegramChatRecord,
|
||||
ReminderTarget,
|
||||
ScheduledDispatchRecord,
|
||||
ScheduledDispatchRepository,
|
||||
ScheduledDispatchScheduler
|
||||
} from '@household/ports'
|
||||
|
||||
import { createScheduledDispatchService } from './scheduled-dispatch-service'
|
||||
|
||||
class ScheduledDispatchRepositoryStub implements ScheduledDispatchRepository {
|
||||
dispatches = new Map<string, ScheduledDispatchRecord>()
|
||||
nextId = 1
|
||||
claims = new Set<string>()
|
||||
|
||||
async createScheduledDispatch(input: {
|
||||
householdId: string
|
||||
kind: ScheduledDispatchRecord['kind']
|
||||
dueAt: Temporal.Instant
|
||||
timezone: string
|
||||
provider: ScheduledDispatchRecord['provider']
|
||||
providerDispatchId?: string | null
|
||||
adHocNotificationId?: string | null
|
||||
period?: string | null
|
||||
}): Promise<ScheduledDispatchRecord> {
|
||||
const id = `dispatch-${this.nextId++}`
|
||||
const record: ScheduledDispatchRecord = {
|
||||
id,
|
||||
householdId: input.householdId,
|
||||
kind: input.kind,
|
||||
dueAt: input.dueAt,
|
||||
timezone: input.timezone,
|
||||
status: 'scheduled',
|
||||
provider: input.provider,
|
||||
providerDispatchId: input.providerDispatchId ?? null,
|
||||
adHocNotificationId: input.adHocNotificationId ?? null,
|
||||
period: input.period ?? null,
|
||||
sentAt: null,
|
||||
cancelledAt: null,
|
||||
createdAt: Temporal.Instant.from('2026-03-24T00:00:00Z'),
|
||||
updatedAt: Temporal.Instant.from('2026-03-24T00:00:00Z')
|
||||
}
|
||||
this.dispatches.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
async getScheduledDispatchById(dispatchId: string): Promise<ScheduledDispatchRecord | null> {
|
||||
return this.dispatches.get(dispatchId) ?? null
|
||||
}
|
||||
|
||||
async getScheduledDispatchByAdHocNotificationId(
|
||||
notificationId: string
|
||||
): Promise<ScheduledDispatchRecord | null> {
|
||||
return (
|
||||
[...this.dispatches.values()].find(
|
||||
(dispatch) => dispatch.adHocNotificationId === notificationId
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
async listScheduledDispatchesForHousehold(
|
||||
householdId: string
|
||||
): Promise<readonly ScheduledDispatchRecord[]> {
|
||||
return [...this.dispatches.values()].filter((dispatch) => dispatch.householdId === householdId)
|
||||
}
|
||||
|
||||
async updateScheduledDispatch(input: {
|
||||
dispatchId: string
|
||||
dueAt?: Temporal.Instant
|
||||
timezone?: string
|
||||
providerDispatchId?: string | null
|
||||
period?: string | null
|
||||
updatedAt: Temporal.Instant
|
||||
}): Promise<ScheduledDispatchRecord | null> {
|
||||
const current = this.dispatches.get(input.dispatchId)
|
||||
if (!current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const next: ScheduledDispatchRecord = {
|
||||
...current,
|
||||
dueAt: input.dueAt ?? current.dueAt,
|
||||
timezone: input.timezone ?? current.timezone,
|
||||
providerDispatchId:
|
||||
input.providerDispatchId === undefined
|
||||
? current.providerDispatchId
|
||||
: input.providerDispatchId,
|
||||
period: input.period === undefined ? current.period : input.period,
|
||||
updatedAt: input.updatedAt
|
||||
}
|
||||
this.dispatches.set(input.dispatchId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
async cancelScheduledDispatch(
|
||||
dispatchId: string,
|
||||
cancelledAt: Temporal.Instant
|
||||
): Promise<ScheduledDispatchRecord | null> {
|
||||
const current = this.dispatches.get(dispatchId)
|
||||
if (!current || current.status !== 'scheduled') {
|
||||
return null
|
||||
}
|
||||
|
||||
const next: ScheduledDispatchRecord = {
|
||||
...current,
|
||||
status: 'cancelled',
|
||||
cancelledAt
|
||||
}
|
||||
this.dispatches.set(dispatchId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
async markScheduledDispatchSent(
|
||||
dispatchId: string,
|
||||
sentAt: Temporal.Instant
|
||||
): Promise<ScheduledDispatchRecord | null> {
|
||||
const current = this.dispatches.get(dispatchId)
|
||||
if (!current || current.status !== 'scheduled') {
|
||||
return null
|
||||
}
|
||||
|
||||
const next: ScheduledDispatchRecord = {
|
||||
...current,
|
||||
status: 'sent',
|
||||
sentAt
|
||||
}
|
||||
this.dispatches.set(dispatchId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
async claimScheduledDispatchDelivery(dispatchId: string) {
|
||||
if (this.claims.has(dispatchId)) {
|
||||
return { dispatchId, claimed: false }
|
||||
}
|
||||
this.claims.add(dispatchId)
|
||||
return { dispatchId, claimed: true }
|
||||
}
|
||||
|
||||
async releaseScheduledDispatchDelivery(dispatchId: string) {
|
||||
this.claims.delete(dispatchId)
|
||||
}
|
||||
}
|
||||
|
||||
function createSchedulerStub(): ScheduledDispatchScheduler & {
|
||||
scheduled: Array<{ dispatchId: string; dueAt: string }>
|
||||
cancelled: string[]
|
||||
} {
|
||||
let nextId = 1
|
||||
|
||||
return {
|
||||
provider: 'gcp-cloud-tasks',
|
||||
scheduled: [],
|
||||
cancelled: [],
|
||||
async scheduleOneShotDispatch(input) {
|
||||
this.scheduled.push({
|
||||
dispatchId: input.dispatchId,
|
||||
dueAt: input.dueAt.toString()
|
||||
})
|
||||
return {
|
||||
providerDispatchId: `provider-${nextId++}`
|
||||
}
|
||||
},
|
||||
async cancelDispatch(providerDispatchId) {
|
||||
this.cancelled.push(providerDispatchId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function billingSettings(
|
||||
timezone = 'Asia/Tbilisi'
|
||||
): HouseholdBillingSettingsRecord & { householdId: string } {
|
||||
return {
|
||||
householdId: 'household-1',
|
||||
settlementCurrency: 'GEL',
|
||||
timezone,
|
||||
rentDueDay: 5,
|
||||
rentWarningDay: 3,
|
||||
utilitiesReminderDay: 12,
|
||||
utilitiesDueDay: 15,
|
||||
rentAmountMinor: null,
|
||||
rentCurrency: 'GEL',
|
||||
rentPaymentDestinations: null
|
||||
}
|
||||
}
|
||||
|
||||
function householdChat(): HouseholdTelegramChatRecord {
|
||||
return {
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori',
|
||||
telegramChatId: 'chat-1',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori',
|
||||
defaultLocale: 'ru'
|
||||
}
|
||||
}
|
||||
|
||||
describe('createScheduledDispatchService', () => {
|
||||
test('schedules and reschedules ad hoc notifications via provider task', async () => {
|
||||
const repository = new ScheduledDispatchRepositoryStub()
|
||||
const scheduler = createSchedulerStub()
|
||||
const service = createScheduledDispatchService({
|
||||
repository,
|
||||
scheduler,
|
||||
householdConfigurationRepository: {
|
||||
async getHouseholdBillingSettings() {
|
||||
return billingSettings()
|
||||
},
|
||||
async getHouseholdChatByHouseholdId() {
|
||||
return householdChat()
|
||||
},
|
||||
async listReminderTargets(): Promise<readonly ReminderTarget[]> {
|
||||
return []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const firstDueAt = Temporal.Instant.from('2026-03-25T08:00:00Z')
|
||||
const secondDueAt = Temporal.Instant.from('2026-03-25T09:00:00Z')
|
||||
|
||||
const first = await service.scheduleAdHocNotification({
|
||||
householdId: 'household-1',
|
||||
notificationId: 'notif-1',
|
||||
dueAt: firstDueAt,
|
||||
timezone: 'Asia/Tbilisi'
|
||||
})
|
||||
const second = await service.scheduleAdHocNotification({
|
||||
householdId: 'household-1',
|
||||
notificationId: 'notif-1',
|
||||
dueAt: secondDueAt,
|
||||
timezone: 'Asia/Tbilisi'
|
||||
})
|
||||
|
||||
expect(first.providerDispatchId).toBe('provider-1')
|
||||
expect(second.providerDispatchId).toBe('provider-2')
|
||||
expect(scheduler.cancelled).toEqual(['provider-1'])
|
||||
|
||||
await service.cancelAdHocNotification('notif-1', Temporal.Instant.from('2026-03-24T11:00:00Z'))
|
||||
|
||||
expect(scheduler.cancelled).toEqual(['provider-1', 'provider-2'])
|
||||
expect((await repository.getScheduledDispatchByAdHocNotificationId('notif-1'))?.status).toBe(
|
||||
'cancelled'
|
||||
)
|
||||
})
|
||||
|
||||
test('reconciles one future built-in dispatch per reminder kind', async () => {
|
||||
const repository = new ScheduledDispatchRepositoryStub()
|
||||
const scheduler = createSchedulerStub()
|
||||
const service = createScheduledDispatchService({
|
||||
repository,
|
||||
scheduler,
|
||||
householdConfigurationRepository: {
|
||||
async getHouseholdBillingSettings() {
|
||||
return billingSettings()
|
||||
},
|
||||
async getHouseholdChatByHouseholdId() {
|
||||
return householdChat()
|
||||
},
|
||||
async listReminderTargets(): Promise<readonly ReminderTarget[]> {
|
||||
return [
|
||||
{
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori',
|
||||
telegramChatId: 'chat-1',
|
||||
telegramThreadId: '103',
|
||||
locale: 'ru',
|
||||
timezone: 'Asia/Tbilisi',
|
||||
utilitiesReminderDay: 12,
|
||||
utilitiesDueDay: 15,
|
||||
rentWarningDay: 3,
|
||||
rentDueDay: 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await service.reconcileAllBuiltInDispatches(Temporal.Instant.from('2026-03-24T00:00:00Z'))
|
||||
|
||||
const scheduled = [...repository.dispatches.values()].filter(
|
||||
(dispatch) => dispatch.status === 'scheduled'
|
||||
)
|
||||
expect(scheduled.map((dispatch) => dispatch.kind).sort()).toEqual([
|
||||
'rent_due',
|
||||
'rent_warning',
|
||||
'utilities'
|
||||
])
|
||||
expect(scheduler.scheduled).toHaveLength(3)
|
||||
expect(scheduled.every((dispatch) => dispatch.period === '2026-04')).toBe(true)
|
||||
})
|
||||
})
|
||||
327
packages/application/src/scheduled-dispatch-service.ts
Normal file
327
packages/application/src/scheduled-dispatch-service.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
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
|
||||
|
||||
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>
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user