mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 08:54:04 +00:00
feat(bot): add secure reminder job runtime
This commit is contained in:
@@ -9,6 +9,8 @@ export interface BotRuntimeConfig {
|
||||
telegramPurchaseTopicId?: number
|
||||
purchaseTopicIngestionEnabled: boolean
|
||||
financeCommandsEnabled: boolean
|
||||
schedulerSharedSecret?: string
|
||||
reminderJobsEnabled: boolean
|
||||
openaiApiKey?: string
|
||||
parserModel: string
|
||||
}
|
||||
@@ -57,6 +59,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
||||
const householdId = parseOptionalValue(env.HOUSEHOLD_ID)
|
||||
const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID)
|
||||
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID)
|
||||
const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET)
|
||||
|
||||
const purchaseTopicIngestionEnabled =
|
||||
databaseUrl !== undefined &&
|
||||
@@ -65,6 +68,8 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
||||
telegramPurchaseTopicId !== undefined
|
||||
|
||||
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
|
||||
const reminderJobsEnabled =
|
||||
databaseUrl !== undefined && householdId !== undefined && schedulerSharedSecret !== undefined
|
||||
|
||||
const runtime: BotRuntimeConfig = {
|
||||
port: parsePort(env.PORT),
|
||||
@@ -73,6 +78,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
||||
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
|
||||
purchaseTopicIngestionEnabled,
|
||||
financeCommandsEnabled,
|
||||
reminderJobsEnabled,
|
||||
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini'
|
||||
}
|
||||
|
||||
@@ -88,6 +94,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
||||
if (telegramPurchaseTopicId !== undefined) {
|
||||
runtime.telegramPurchaseTopicId = telegramPurchaseTopicId
|
||||
}
|
||||
if (schedulerSharedSecret !== undefined) {
|
||||
runtime.schedulerSharedSecret = schedulerSharedSecret
|
||||
}
|
||||
const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY)
|
||||
if (openaiApiKey !== undefined) {
|
||||
runtime.openaiApiKey = openaiApiKey
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { webhookCallback } from 'grammy'
|
||||
|
||||
import { createFinanceCommandService } from '@household/application'
|
||||
import { createDbFinanceRepository } from '@household/adapters-db'
|
||||
import { createFinanceCommandService, createReminderJobService } from '@household/application'
|
||||
import {
|
||||
createDbFinanceRepository,
|
||||
createDbReminderDispatchRepository
|
||||
} from '@household/adapters-db'
|
||||
|
||||
import { createFinanceCommandsService } from './finance-commands'
|
||||
import { createTelegramBot } from './bot'
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
createPurchaseMessageRepository,
|
||||
registerPurchaseTopicIngestion
|
||||
} from './purchase-topic-ingestion'
|
||||
import { createReminderJobsHandler } from './reminder-jobs'
|
||||
import { createBotWebhookServer } from './server'
|
||||
|
||||
const runtime = getBotRuntimeConfig()
|
||||
@@ -58,10 +62,37 @@ if (runtime.financeCommandsEnabled) {
|
||||
console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.')
|
||||
}
|
||||
|
||||
const reminderJobs = runtime.reminderJobsEnabled
|
||||
? (() => {
|
||||
const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!)
|
||||
const reminderService = createReminderJobService(reminderRepositoryClient.repository)
|
||||
|
||||
shutdownTasks.push(reminderRepositoryClient.close)
|
||||
|
||||
return createReminderJobsHandler({
|
||||
householdId: runtime.householdId!,
|
||||
reminderService
|
||||
})
|
||||
})()
|
||||
: null
|
||||
|
||||
if (!runtime.reminderJobsEnabled) {
|
||||
console.warn(
|
||||
'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and SCHEDULER_SHARED_SECRET to enable.'
|
||||
)
|
||||
}
|
||||
|
||||
const server = createBotWebhookServer({
|
||||
webhookPath: runtime.telegramWebhookPath,
|
||||
webhookSecret: runtime.telegramWebhookSecret,
|
||||
webhookHandler
|
||||
webhookHandler,
|
||||
scheduler:
|
||||
reminderJobs && runtime.schedulerSharedSecret
|
||||
? {
|
||||
sharedSecret: runtime.schedulerSharedSecret,
|
||||
handler: reminderJobs.handle
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
|
||||
if (import.meta.main) {
|
||||
|
||||
110
apps/bot/src/reminder-jobs.test.ts
Normal file
110
apps/bot/src/reminder-jobs.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
import type { ReminderJobResult, ReminderJobService } from '@household/application'
|
||||
|
||||
import { createReminderJobsHandler } from './reminder-jobs'
|
||||
|
||||
describe('createReminderJobsHandler', () => {
|
||||
test('returns job outcome with dedupe metadata', async () => {
|
||||
const claimedResult: ReminderJobResult = {
|
||||
status: 'claimed',
|
||||
dedupeKey: '2026-03:utilities',
|
||||
payloadHash: 'hash',
|
||||
reminderType: 'utilities',
|
||||
period: '2026-03',
|
||||
messageText: 'Utilities reminder for 2026-03'
|
||||
}
|
||||
|
||||
const reminderService: ReminderJobService = {
|
||||
handleJob: mock(async () => claimedResult)
|
||||
}
|
||||
|
||||
const handler = createReminderJobsHandler({
|
||||
householdId: 'household-1',
|
||||
reminderService
|
||||
})
|
||||
|
||||
const response = await handler.handle(
|
||||
new Request('http://localhost/jobs/reminder/utilities', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
period: '2026-03',
|
||||
jobId: 'job-1'
|
||||
})
|
||||
}),
|
||||
'utilities'
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
ok: true,
|
||||
jobId: 'job-1',
|
||||
reminderType: 'utilities',
|
||||
period: '2026-03',
|
||||
dedupeKey: '2026-03:utilities',
|
||||
outcome: 'claimed',
|
||||
dryRun: false,
|
||||
messageText: 'Utilities reminder for 2026-03'
|
||||
})
|
||||
})
|
||||
|
||||
test('supports forced dry-run mode', async () => {
|
||||
const dryRunResult: ReminderJobResult = {
|
||||
status: 'dry-run',
|
||||
dedupeKey: '2026-03:rent-warning',
|
||||
payloadHash: 'hash',
|
||||
reminderType: 'rent-warning',
|
||||
period: '2026-03',
|
||||
messageText: 'Rent reminder for 2026-03: payment is coming up soon.'
|
||||
}
|
||||
|
||||
const reminderService: ReminderJobService = {
|
||||
handleJob: mock(async () => dryRunResult)
|
||||
}
|
||||
|
||||
const handler = createReminderJobsHandler({
|
||||
householdId: 'household-1',
|
||||
reminderService,
|
||||
forceDryRun: true
|
||||
})
|
||||
|
||||
const response = await handler.handle(
|
||||
new Request('http://localhost/jobs/reminder/rent-warning', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ period: '2026-03', jobId: 'job-2' })
|
||||
}),
|
||||
'rent-warning'
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
outcome: 'dry-run',
|
||||
dryRun: true
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects unsupported reminder type', async () => {
|
||||
const handler = createReminderJobsHandler({
|
||||
householdId: 'household-1',
|
||||
reminderService: {
|
||||
handleJob: mock(async () => {
|
||||
throw new Error('should not be called')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const response = await handler.handle(
|
||||
new Request('http://localhost/jobs/reminder/unknown', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ period: '2026-03' })
|
||||
}),
|
||||
'unknown'
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
ok: false,
|
||||
error: 'Invalid reminder type'
|
||||
})
|
||||
})
|
||||
})
|
||||
111
apps/bot/src/reminder-jobs.ts
Normal file
111
apps/bot/src/reminder-jobs.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { BillingPeriod } from '@household/domain'
|
||||
import type { ReminderJobService } from '@household/application'
|
||||
|
||||
const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const
|
||||
type ReminderType = (typeof REMINDER_TYPES)[number]
|
||||
|
||||
interface ReminderJobRequestBody {
|
||||
period?: string
|
||||
jobId?: string
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
function json(body: object, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseReminderType(raw: string): ReminderType | null {
|
||||
if ((REMINDER_TYPES as readonly string[]).includes(raw)) {
|
||||
return raw as ReminderType
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function currentPeriod(): string {
|
||||
const now = new Date()
|
||||
const year = now.getUTCFullYear()
|
||||
const month = `${now.getUTCMonth() + 1}`.padStart(2, '0')
|
||||
|
||||
return `${year}-${month}`
|
||||
}
|
||||
|
||||
async function readBody(request: Request): Promise<ReminderJobRequestBody> {
|
||||
const text = await request.text()
|
||||
|
||||
if (text.trim().length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text) as ReminderJobRequestBody
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function createReminderJobsHandler(options: {
|
||||
householdId: string
|
||||
reminderService: ReminderJobService
|
||||
forceDryRun?: boolean
|
||||
}): {
|
||||
handle: (request: Request, rawReminderType: string) => Promise<Response>
|
||||
} {
|
||||
return {
|
||||
handle: async (request, rawReminderType) => {
|
||||
const reminderType = parseReminderType(rawReminderType)
|
||||
if (!reminderType) {
|
||||
return json({ ok: false, error: 'Invalid reminder type' }, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(request)
|
||||
const period = BillingPeriod.fromString(body.period ?? currentPeriod()).toString()
|
||||
const dryRun = options.forceDryRun === true || body.dryRun === true
|
||||
const result = await options.reminderService.handleJob({
|
||||
householdId: options.householdId,
|
||||
period,
|
||||
reminderType,
|
||||
dryRun
|
||||
})
|
||||
|
||||
const logPayload = {
|
||||
event: 'scheduler.reminder.dispatch',
|
||||
reminderType,
|
||||
period,
|
||||
jobId: body.jobId ?? null,
|
||||
dedupeKey: result.dedupeKey,
|
||||
outcome: result.status,
|
||||
dryRun
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(logPayload))
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
jobId: body.jobId ?? null,
|
||||
reminderType,
|
||||
period,
|
||||
dedupeKey: result.dedupeKey,
|
||||
outcome: result.status,
|
||||
dryRun,
|
||||
messageText: result.messageText
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown reminder job error'
|
||||
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: 'scheduler.reminder.dispatch_failed',
|
||||
reminderType: rawReminderType,
|
||||
error: message
|
||||
})
|
||||
)
|
||||
|
||||
return json({ ok: false, error: message }, 400)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,17 @@ describe('createBotWebhookServer', () => {
|
||||
const server = createBotWebhookServer({
|
||||
webhookPath: '/webhook/telegram',
|
||||
webhookSecret: 'secret-token',
|
||||
webhookHandler: async () => new Response('ok', { status: 200 })
|
||||
webhookHandler: async () => new Response('ok', { status: 200 }),
|
||||
scheduler: {
|
||||
sharedSecret: 'scheduler-secret',
|
||||
handler: async (_request, reminderType) =>
|
||||
new Response(JSON.stringify({ ok: true, reminderType }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('returns health payload', async () => {
|
||||
@@ -59,4 +69,46 @@ describe('createBotWebhookServer', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toBe('ok')
|
||||
})
|
||||
|
||||
test('rejects scheduler request with missing secret', async () => {
|
||||
const response = await server.fetch(
|
||||
new Request('http://localhost/jobs/reminder/utilities', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ period: '2026-03' })
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
test('rejects non-post method for scheduler endpoint', async () => {
|
||||
const response = await server.fetch(
|
||||
new Request('http://localhost/jobs/reminder/utilities', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-household-scheduler-secret': 'scheduler-secret'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(405)
|
||||
})
|
||||
|
||||
test('accepts authorized scheduler request', async () => {
|
||||
const response = await server.fetch(
|
||||
new Request('http://localhost/jobs/reminder/rent-due', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-household-scheduler-secret': 'scheduler-secret'
|
||||
},
|
||||
body: JSON.stringify({ period: '2026-03' })
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
ok: true,
|
||||
reminderType: 'rent-due'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,13 @@ export interface BotWebhookServerOptions {
|
||||
webhookPath: string
|
||||
webhookSecret: string
|
||||
webhookHandler: (request: Request) => Promise<Response> | Response
|
||||
scheduler?:
|
||||
| {
|
||||
pathPrefix?: string
|
||||
sharedSecret: string
|
||||
handler: (request: Request, reminderType: string) => Promise<Response>
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
function json(body: object, status = 200): Response {
|
||||
@@ -19,12 +26,22 @@ function isAuthorized(request: Request, expectedSecret: string): boolean {
|
||||
return secretHeader === expectedSecret
|
||||
}
|
||||
|
||||
function isSchedulerAuthorized(request: Request, expectedSecret: string): boolean {
|
||||
const customHeader = request.headers.get('x-household-scheduler-secret')
|
||||
const authorizationHeader = request.headers.get('authorization')
|
||||
|
||||
return customHeader === expectedSecret || authorizationHeader === `Bearer ${expectedSecret}`
|
||||
}
|
||||
|
||||
export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
fetch: (request: Request) => Promise<Response>
|
||||
} {
|
||||
const normalizedWebhookPath = options.webhookPath.startsWith('/')
|
||||
? options.webhookPath
|
||||
: `/${options.webhookPath}`
|
||||
const schedulerPathPrefix = options.scheduler
|
||||
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
||||
: null
|
||||
|
||||
return {
|
||||
fetch: async (request: Request) => {
|
||||
@@ -35,6 +52,19 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
}
|
||||
|
||||
if (url.pathname !== normalizedWebhookPath) {
|
||||
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Method Not Allowed', { status: 405 })
|
||||
}
|
||||
|
||||
if (!isSchedulerAuthorized(request, options.scheduler!.sharedSecret)) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const reminderType = url.pathname.slice(`${schedulerPathPrefix}/`.length)
|
||||
return await options.scheduler!.handler(request, reminderType)
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
}
|
||||
|
||||
|
||||
81
docs/specs/HOUSEBOT-031-secure-scheduler-endpoint.md
Normal file
81
docs/specs/HOUSEBOT-031-secure-scheduler-endpoint.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# HOUSEBOT-031: Secure Scheduler Endpoint and Idempotent Reminder Dispatch
|
||||
|
||||
## Summary
|
||||
|
||||
Add authenticated reminder job endpoints to the bot runtime with deterministic deduplication and dry-run support.
|
||||
|
||||
## Goals
|
||||
|
||||
- Accept reminder job calls through dedicated HTTP endpoints.
|
||||
- Reject unauthorized or malformed scheduler requests.
|
||||
- Prevent duplicate reminder dispatch for the same household, period, and reminder type.
|
||||
- Emit structured outcomes for local validation and future monitoring.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Full Cloud Scheduler IaC setup.
|
||||
- Final Telegram reminder copy or topic routing.
|
||||
- OIDC verification in v1 of this runtime slice.
|
||||
|
||||
## Scope
|
||||
|
||||
- In: shared-secret auth, request validation, dry-run mode, dedupe persistence, structured logs.
|
||||
- Out: live Telegram send integration and scheduler provisioning.
|
||||
|
||||
## Interfaces and Contracts
|
||||
|
||||
- Endpoint family: `/jobs/reminder/<type>`
|
||||
- Allowed types:
|
||||
- `utilities`
|
||||
- `rent-warning`
|
||||
- `rent-due`
|
||||
- Request body:
|
||||
- `period?: YYYY-MM`
|
||||
- `jobId?: string`
|
||||
- `dryRun?: boolean`
|
||||
- Auth:
|
||||
- `x-household-scheduler-secret: <secret>` or `Authorization: Bearer <secret>`
|
||||
|
||||
## Domain Rules
|
||||
|
||||
- Dedupe key format: `<period>:<reminderType>`
|
||||
- Persistence uniqueness remains household-scoped.
|
||||
- `dryRun=true` never persists a dispatch claim.
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
- None. Reuse `processed_bot_messages` as the idempotency ledger for scheduler reminder claims.
|
||||
|
||||
## Security and Privacy
|
||||
|
||||
- Scheduler routes are unavailable unless `SCHEDULER_SHARED_SECRET` is configured.
|
||||
- Unauthorized callers receive `401`.
|
||||
- Request errors return `400` without leaking secrets.
|
||||
|
||||
## Observability
|
||||
|
||||
- Successful and failed job handling emits structured JSON logs.
|
||||
- Log payload includes:
|
||||
- `jobId`
|
||||
- `dedupeKey`
|
||||
- `outcome`
|
||||
- `reminderType`
|
||||
- `period`
|
||||
|
||||
## Edge Cases and Failure Modes
|
||||
|
||||
- Empty body defaults period to the current UTC billing month.
|
||||
- Invalid period format is rejected.
|
||||
- Replayed jobs return `duplicate` without a second dispatch claim.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: reminder job service dry-run and dedupe results.
|
||||
- Integration-ish: HTTP handler auth, route validation, and response payloads.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Unauthorized scheduler requests are rejected.
|
||||
- [ ] Duplicate scheduler calls return a deterministic duplicate outcome.
|
||||
- [ ] Dry-run mode skips persistence and still returns a structured payload.
|
||||
- [ ] Logs include `jobId`, `dedupeKey`, and outcome.
|
||||
@@ -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