mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 21:04:03 +00:00
feat(bot): add secure reminder job runtime
This commit is contained in:
@@ -1 +1,2 @@
|
||||
export { createDbFinanceRepository } from './finance-repository'
|
||||
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
|
||||
|
||||
46
packages/adapters-db/src/reminder-dispatch-repository.ts
Normal file
46
packages/adapters-db/src/reminder-dispatch-repository.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
80
packages/application/src/reminder-job-service.test.ts
Normal file
80
packages/application/src/reminder-job-service.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
84
packages/application/src/reminder-job-service.ts
Normal file
84
packages/application/src/reminder-job-service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
export {
|
||||
REMINDER_TYPES,
|
||||
type ClaimReminderDispatchInput,
|
||||
type ClaimReminderDispatchResult,
|
||||
type ReminderDispatchRepository,
|
||||
type ReminderType
|
||||
} from './reminders'
|
||||
export type {
|
||||
FinanceCycleRecord,
|
||||
FinanceMemberRecord,
|
||||
|
||||
19
packages/ports/src/reminders.ts
Normal file
19
packages/ports/src/reminders.ts
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user