refactor(bot): replace reminder polling with scheduled dispatches

This commit is contained in:
2026-03-24 20:51:54 +04:00
parent a1acec5e60
commit 7f836eeee2
48 changed files with 6425 additions and 1557 deletions

View File

@@ -3,6 +3,6 @@ export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-reposi
export { createDbFinanceRepository } from './finance-repository'
export { createDbHouseholdConfigurationRepository } from './household-config-repository'
export { createDbProcessedBotMessageRepository } from './processed-bot-message-repository'
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
export { createDbScheduledDispatchRepository } from './scheduled-dispatch-repository'
export { createDbTelegramPendingActionRepository } from './telegram-pending-action-repository'
export { createDbTopicMessageHistoryRepository } from './topic-message-history-repository'

View File

@@ -1,62 +0,0 @@
import { and, eq } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import type { ReminderDispatchRepository } from '@household/ports'
export function createDbReminderDispatchRepository(databaseUrl: string): {
repository: ReminderDispatchRepository
close: () => Promise<void>
} {
const { db, queryClient } = createDbClient(databaseUrl, {
max: 3,
prepare: false
})
const repository: ReminderDispatchRepository = {
async claimReminderDispatch(input) {
const dedupeKey = `${input.period}:${input.reminderType}`
const rows = await db
.insert(schema.processedBotMessages)
.values({
householdId: input.householdId,
source: 'scheduler-reminder',
sourceMessageKey: dedupeKey,
payloadHash: input.payloadHash
})
.onConflictDoNothing({
target: [
schema.processedBotMessages.householdId,
schema.processedBotMessages.source,
schema.processedBotMessages.sourceMessageKey
]
})
.returning({ id: schema.processedBotMessages.id })
return {
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)
)
)
}
}
return {
repository,
close: async () => {
await queryClient.end({ timeout: 5 })
}
}
}

View File

@@ -0,0 +1,252 @@
import { and, asc, eq } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import { instantFromDatabaseValue, instantToDate, nowInstant } from '@household/domain'
import type {
ClaimScheduledDispatchDeliveryResult,
ScheduledDispatchRecord,
ScheduledDispatchRepository
} from '@household/ports'
const DELIVERY_CLAIM_SOURCE = 'scheduled-dispatch'
function scheduledDispatchSelect() {
return {
id: schema.scheduledDispatches.id,
householdId: schema.scheduledDispatches.householdId,
kind: schema.scheduledDispatches.kind,
dueAt: schema.scheduledDispatches.dueAt,
timezone: schema.scheduledDispatches.timezone,
status: schema.scheduledDispatches.status,
provider: schema.scheduledDispatches.provider,
providerDispatchId: schema.scheduledDispatches.providerDispatchId,
adHocNotificationId: schema.scheduledDispatches.adHocNotificationId,
period: schema.scheduledDispatches.period,
sentAt: schema.scheduledDispatches.sentAt,
cancelledAt: schema.scheduledDispatches.cancelledAt,
createdAt: schema.scheduledDispatches.createdAt,
updatedAt: schema.scheduledDispatches.updatedAt
}
}
function mapScheduledDispatch(row: {
id: string
householdId: string
kind: string
dueAt: Date | string
timezone: string
status: string
provider: string
providerDispatchId: string | null
adHocNotificationId: string | null
period: string | null
sentAt: Date | string | null
cancelledAt: Date | string | null
createdAt: Date | string
updatedAt: Date | string
}): ScheduledDispatchRecord {
return {
id: row.id,
householdId: row.householdId,
kind: row.kind as ScheduledDispatchRecord['kind'],
dueAt: instantFromDatabaseValue(row.dueAt)!,
timezone: row.timezone,
status: row.status as ScheduledDispatchRecord['status'],
provider: row.provider as ScheduledDispatchRecord['provider'],
providerDispatchId: row.providerDispatchId,
adHocNotificationId: row.adHocNotificationId,
period: row.period,
sentAt: instantFromDatabaseValue(row.sentAt),
cancelledAt: instantFromDatabaseValue(row.cancelledAt),
createdAt: instantFromDatabaseValue(row.createdAt)!,
updatedAt: instantFromDatabaseValue(row.updatedAt)!
}
}
export function createDbScheduledDispatchRepository(databaseUrl: string): {
repository: ScheduledDispatchRepository
close: () => Promise<void>
} {
const { db, queryClient } = createDbClient(databaseUrl, {
max: 3,
prepare: false
})
const repository: ScheduledDispatchRepository = {
async createScheduledDispatch(input) {
const timestamp = instantToDate(nowInstant())
const rows = await db
.insert(schema.scheduledDispatches)
.values({
householdId: input.householdId,
kind: input.kind,
dueAt: instantToDate(input.dueAt),
timezone: input.timezone,
status: 'scheduled',
provider: input.provider,
providerDispatchId: input.providerDispatchId ?? null,
adHocNotificationId: input.adHocNotificationId ?? null,
period: input.period ?? null,
updatedAt: timestamp
})
.returning(scheduledDispatchSelect())
const row = rows[0]
if (!row) {
throw new Error('Scheduled dispatch insert did not return a row')
}
return mapScheduledDispatch(row)
},
async getScheduledDispatchById(dispatchId) {
const rows = await db
.select(scheduledDispatchSelect())
.from(schema.scheduledDispatches)
.where(eq(schema.scheduledDispatches.id, dispatchId))
.limit(1)
return rows[0] ? mapScheduledDispatch(rows[0]) : null
},
async getScheduledDispatchByAdHocNotificationId(notificationId) {
const rows = await db
.select(scheduledDispatchSelect())
.from(schema.scheduledDispatches)
.where(eq(schema.scheduledDispatches.adHocNotificationId, notificationId))
.limit(1)
return rows[0] ? mapScheduledDispatch(rows[0]) : null
},
async listScheduledDispatchesForHousehold(householdId) {
const rows = await db
.select(scheduledDispatchSelect())
.from(schema.scheduledDispatches)
.where(eq(schema.scheduledDispatches.householdId, householdId))
.orderBy(asc(schema.scheduledDispatches.dueAt), asc(schema.scheduledDispatches.createdAt))
return rows.map(mapScheduledDispatch)
},
async updateScheduledDispatch(input) {
const updates: Record<string, unknown> = {
updatedAt: instantToDate(input.updatedAt)
}
if (input.dueAt) {
updates.dueAt = instantToDate(input.dueAt)
}
if (input.timezone) {
updates.timezone = input.timezone
}
if (input.providerDispatchId !== undefined) {
updates.providerDispatchId = input.providerDispatchId
}
if (input.period !== undefined) {
updates.period = input.period
}
const rows = await db
.update(schema.scheduledDispatches)
.set(updates)
.where(eq(schema.scheduledDispatches.id, input.dispatchId))
.returning(scheduledDispatchSelect())
return rows[0] ? mapScheduledDispatch(rows[0]) : null
},
async cancelScheduledDispatch(dispatchId, cancelledAt) {
const rows = await db
.update(schema.scheduledDispatches)
.set({
status: 'cancelled',
cancelledAt: instantToDate(cancelledAt),
updatedAt: instantToDate(nowInstant())
})
.where(
and(
eq(schema.scheduledDispatches.id, dispatchId),
eq(schema.scheduledDispatches.status, 'scheduled')
)
)
.returning(scheduledDispatchSelect())
return rows[0] ? mapScheduledDispatch(rows[0]) : null
},
async markScheduledDispatchSent(dispatchId, sentAt) {
const rows = await db
.update(schema.scheduledDispatches)
.set({
status: 'sent',
sentAt: instantToDate(sentAt),
updatedAt: instantToDate(nowInstant())
})
.where(
and(
eq(schema.scheduledDispatches.id, dispatchId),
eq(schema.scheduledDispatches.status, 'scheduled')
)
)
.returning(scheduledDispatchSelect())
return rows[0] ? mapScheduledDispatch(rows[0]) : null
},
async claimScheduledDispatchDelivery(dispatchId) {
const dispatch = await repository.getScheduledDispatchById(dispatchId)
if (!dispatch) {
return {
dispatchId,
claimed: false
} satisfies ClaimScheduledDispatchDeliveryResult
}
const rows = await db
.insert(schema.processedBotMessages)
.values({
householdId: dispatch.householdId,
source: DELIVERY_CLAIM_SOURCE,
sourceMessageKey: dispatchId
})
.onConflictDoNothing({
target: [
schema.processedBotMessages.householdId,
schema.processedBotMessages.source,
schema.processedBotMessages.sourceMessageKey
]
})
.returning({ id: schema.processedBotMessages.id })
return {
dispatchId,
claimed: rows.length > 0
}
},
async releaseScheduledDispatchDelivery(dispatchId) {
const dispatch = await repository.getScheduledDispatchById(dispatchId)
if (!dispatch) {
return
}
await db
.delete(schema.processedBotMessages)
.where(
and(
eq(schema.processedBotMessages.householdId, dispatch.householdId),
eq(schema.processedBotMessages.source, DELIVERY_CLAIM_SOURCE),
eq(schema.processedBotMessages.sourceMessageKey, dispatchId)
)
)
}
}
return {
repository,
close: async () => {
await queryClient.end({ timeout: 5 })
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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')
})
})

View File

@@ -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
}
}
}
}

View 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)
})
})

View 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)
}
}
}

View File

@@ -25,6 +25,7 @@
"0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad",
"0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1",
"0022_carry_purchase_history.sql": "f031c9736e43e71eec3263a323332c29de9324c6409db034b0760051c8a9f074",
"0023_huge_vision.sql": "9a682e8b62fc6c54711ccd7bb912dd7192e278f546d5853670bea6a0a4585c1c"
"0023_huge_vision.sql": "9a682e8b62fc6c54711ccd7bb912dd7192e278f546d5853670bea6a0a4585c1c",
"0024_lush_lucky_pierre.sql": "35d111486df774fde5add5cc98f2bf8bcb16d5bae8c4dd4df01fedb661a297d6"
}
}

View File

@@ -0,0 +1,22 @@
CREATE TABLE "scheduled_dispatches" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"kind" text NOT NULL,
"due_at" timestamp with time zone NOT NULL,
"timezone" text NOT NULL,
"status" text DEFAULT 'scheduled' NOT NULL,
"provider" text NOT NULL,
"provider_dispatch_id" text,
"ad_hoc_notification_id" uuid,
"period" text,
"sent_at" timestamp with time zone,
"cancelled_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "scheduled_dispatches" ADD CONSTRAINT "scheduled_dispatches_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "scheduled_dispatches" ADD CONSTRAINT "scheduled_dispatches_ad_hoc_notification_id_ad_hoc_notifications_id_fk" FOREIGN KEY ("ad_hoc_notification_id") REFERENCES "public"."ad_hoc_notifications"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "scheduled_dispatches_due_idx" ON "scheduled_dispatches" USING btree ("status","due_at");--> statement-breakpoint
CREATE INDEX "scheduled_dispatches_household_kind_idx" ON "scheduled_dispatches" USING btree ("household_id","kind","status");--> statement-breakpoint
CREATE UNIQUE INDEX "scheduled_dispatches_ad_hoc_notification_unique" ON "scheduled_dispatches" USING btree ("ad_hoc_notification_id");

File diff suppressed because it is too large Load Diff

View File

@@ -169,6 +169,13 @@
"when": 1774294611532,
"tag": "0023_huge_vision",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774367260609,
"tag": "0024_lush_lucky_pierre",
"breakpoints": true
}
]
}

View File

@@ -553,6 +553,41 @@ export const adHocNotifications = pgTable(
})
)
export const scheduledDispatches = pgTable(
'scheduled_dispatches',
{
id: uuid('id').defaultRandom().primaryKey(),
householdId: uuid('household_id')
.notNull()
.references(() => households.id, { onDelete: 'cascade' }),
kind: text('kind').notNull(),
dueAt: timestamp('due_at', { withTimezone: true }).notNull(),
timezone: text('timezone').notNull(),
status: text('status').default('scheduled').notNull(),
provider: text('provider').notNull(),
providerDispatchId: text('provider_dispatch_id'),
adHocNotificationId: uuid('ad_hoc_notification_id').references(() => adHocNotifications.id, {
onDelete: 'cascade'
}),
period: text('period'),
sentAt: timestamp('sent_at', { withTimezone: true }),
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
dueIdx: index('scheduled_dispatches_due_idx').on(table.status, table.dueAt),
householdKindIdx: index('scheduled_dispatches_household_kind_idx').on(
table.householdId,
table.kind,
table.status
),
adHocNotificationUnique: uniqueIndex('scheduled_dispatches_ad_hoc_notification_unique').on(
table.adHocNotificationId
)
})
)
export const topicMessages = pgTable(
'topic_messages',
{

View File

@@ -1,11 +1,4 @@
export {
REMINDER_TYPES,
type ClaimReminderDispatchInput,
type ClaimReminderDispatchResult,
type ReminderDispatchRepository,
type ReminderTarget,
type ReminderType
} from './reminders'
export { REMINDER_TYPES, type ReminderTarget, type ReminderType } from './reminders'
export {
AD_HOC_NOTIFICATION_DELIVERY_MODES,
AD_HOC_NOTIFICATION_STATUSES,
@@ -20,6 +13,22 @@ export {
type CreateAdHocNotificationInput,
type UpdateAdHocNotificationInput
} from './notifications'
export {
SCHEDULED_DISPATCH_KINDS,
SCHEDULED_DISPATCH_PROVIDERS,
SCHEDULED_DISPATCH_STATUSES,
type ClaimScheduledDispatchDeliveryResult,
type CreateScheduledDispatchInput,
type ScheduleOneShotDispatchInput,
type ScheduleOneShotDispatchResult,
type ScheduledDispatchKind,
type ScheduledDispatchProvider,
type ScheduledDispatchRecord,
type ScheduledDispatchRepository,
type ScheduledDispatchScheduler,
type ScheduledDispatchStatus,
type UpdateScheduledDispatchInput
} from './scheduled-dispatches'
export type {
ClaimProcessedBotMessageInput,
ClaimProcessedBotMessageResult,

View File

@@ -16,24 +16,3 @@ export interface ReminderTarget {
utilitiesDueDay: number
utilitiesReminderDay: number
}
export interface ClaimReminderDispatchInput {
householdId: string
period: string
reminderType: ReminderType
payloadHash: string
}
export interface ClaimReminderDispatchResult {
dedupeKey: string
claimed: boolean
}
export interface ReminderDispatchRepository {
claimReminderDispatch(input: ClaimReminderDispatchInput): Promise<ClaimReminderDispatchResult>
releaseReminderDispatch(input: {
householdId: string
period: string
reminderType: ReminderType
}): Promise<void>
}

View File

@@ -0,0 +1,97 @@
import type { Instant } from '@household/domain'
export const SCHEDULED_DISPATCH_KINDS = [
'ad_hoc_notification',
'utilities',
'rent_warning',
'rent_due'
] as const
export const SCHEDULED_DISPATCH_STATUSES = ['scheduled', 'sent', 'cancelled'] as const
export const SCHEDULED_DISPATCH_PROVIDERS = ['gcp-cloud-tasks', 'aws-eventbridge'] as const
export type ScheduledDispatchKind = (typeof SCHEDULED_DISPATCH_KINDS)[number]
export type ScheduledDispatchStatus = (typeof SCHEDULED_DISPATCH_STATUSES)[number]
export type ScheduledDispatchProvider = (typeof SCHEDULED_DISPATCH_PROVIDERS)[number]
export interface ScheduledDispatchRecord {
id: string
householdId: string
kind: ScheduledDispatchKind
dueAt: Instant
timezone: string
status: ScheduledDispatchStatus
provider: ScheduledDispatchProvider
providerDispatchId: string | null
adHocNotificationId: string | null
period: string | null
sentAt: Instant | null
cancelledAt: Instant | null
createdAt: Instant
updatedAt: Instant
}
export interface CreateScheduledDispatchInput {
householdId: string
kind: ScheduledDispatchKind
dueAt: Instant
timezone: string
provider: ScheduledDispatchProvider
providerDispatchId?: string | null
adHocNotificationId?: string | null
period?: string | null
}
export interface UpdateScheduledDispatchInput {
dispatchId: string
dueAt?: Instant
timezone?: string
providerDispatchId?: string | null
period?: string | null
updatedAt: Instant
}
export interface ClaimScheduledDispatchDeliveryResult {
dispatchId: string
claimed: boolean
}
export interface ScheduledDispatchRepository {
createScheduledDispatch(input: CreateScheduledDispatchInput): Promise<ScheduledDispatchRecord>
getScheduledDispatchById(dispatchId: string): Promise<ScheduledDispatchRecord | null>
getScheduledDispatchByAdHocNotificationId(
notificationId: string
): Promise<ScheduledDispatchRecord | null>
listScheduledDispatchesForHousehold(
householdId: string
): Promise<readonly ScheduledDispatchRecord[]>
updateScheduledDispatch(
input: UpdateScheduledDispatchInput
): Promise<ScheduledDispatchRecord | null>
cancelScheduledDispatch(
dispatchId: string,
cancelledAt: Instant
): Promise<ScheduledDispatchRecord | null>
markScheduledDispatchSent(
dispatchId: string,
sentAt: Instant
): Promise<ScheduledDispatchRecord | null>
claimScheduledDispatchDelivery(dispatchId: string): Promise<ClaimScheduledDispatchDeliveryResult>
releaseScheduledDispatchDelivery(dispatchId: string): Promise<void>
}
export interface ScheduleOneShotDispatchInput {
dispatchId: string
dueAt: Instant
}
export interface ScheduleOneShotDispatchResult {
providerDispatchId: string
}
export interface ScheduledDispatchScheduler {
readonly provider: ScheduledDispatchProvider
scheduleOneShotDispatch(
input: ScheduleOneShotDispatchInput
): Promise<ScheduleOneShotDispatchResult>
cancelDispatch(providerDispatchId: string): Promise<void>
}