feat(bot): add secure reminder job runtime

This commit is contained in:
2026-03-08 22:15:01 +04:00
parent f6d1f34acf
commit 6c0dbfc48e
14 changed files with 670 additions and 4 deletions

View File

@@ -1 +1,2 @@
export { createDbFinanceRepository } from './finance-repository'
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'

View File

@@ -0,0 +1,46 @@
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
}
}
}
return {
repository,
close: async () => {
await queryClient.end({ timeout: 5 })
}
}
}

View File

@@ -1,5 +1,10 @@
export { calculateMonthlySettlement } from './settlement-engine'
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
export {
createReminderJobService,
type ReminderJobResult,
type ReminderJobService
} from './reminder-job-service'
export {
parsePurchaseMessage,
type ParsedPurchaseResult,

View File

@@ -0,0 +1,80 @@
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
}
}
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()
const service = createReminderJobService(repository)
const result = await service.handleJob({
householdId: 'household-1',
period: '2026-03',
reminderType: 'rent-due'
})
expect(result.status).toBe('claimed')
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

@@ -0,0 +1,84 @@
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 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: `${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

@@ -1,3 +1,10 @@
export {
REMINDER_TYPES,
type ClaimReminderDispatchInput,
type ClaimReminderDispatchResult,
type ReminderDispatchRepository,
type ReminderType
} from './reminders'
export type {
FinanceCycleRecord,
FinanceMemberRecord,

View File

@@ -0,0 +1,19 @@
export const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const
export type ReminderType = (typeof REMINDER_TYPES)[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>
}