mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +00:00
refactor(bot): replace reminder polling with scheduled dispatches
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
252
packages/adapters-db/src/scheduled-dispatch-repository.ts
Normal file
252
packages/adapters-db/src/scheduled-dispatch-repository.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
22
packages/db/drizzle/0024_lush_lucky_pierre.sql
Normal file
22
packages/db/drizzle/0024_lush_lucky_pierre.sql
Normal 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");
|
||||
4047
packages/db/drizzle/meta/0024_snapshot.json
Normal file
4047
packages/db/drizzle/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
97
packages/ports/src/scheduled-dispatches.ts
Normal file
97
packages/ports/src/scheduled-dispatches.ts
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user