mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
feat(bot): add secure reminder job runtime
This commit is contained in:
@@ -9,6 +9,8 @@ export interface BotRuntimeConfig {
|
|||||||
telegramPurchaseTopicId?: number
|
telegramPurchaseTopicId?: number
|
||||||
purchaseTopicIngestionEnabled: boolean
|
purchaseTopicIngestionEnabled: boolean
|
||||||
financeCommandsEnabled: boolean
|
financeCommandsEnabled: boolean
|
||||||
|
schedulerSharedSecret?: string
|
||||||
|
reminderJobsEnabled: boolean
|
||||||
openaiApiKey?: string
|
openaiApiKey?: string
|
||||||
parserModel: string
|
parserModel: string
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
const householdId = parseOptionalValue(env.HOUSEHOLD_ID)
|
const householdId = parseOptionalValue(env.HOUSEHOLD_ID)
|
||||||
const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID)
|
const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID)
|
||||||
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID)
|
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID)
|
||||||
|
const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET)
|
||||||
|
|
||||||
const purchaseTopicIngestionEnabled =
|
const purchaseTopicIngestionEnabled =
|
||||||
databaseUrl !== undefined &&
|
databaseUrl !== undefined &&
|
||||||
@@ -65,6 +68,8 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
telegramPurchaseTopicId !== undefined
|
telegramPurchaseTopicId !== undefined
|
||||||
|
|
||||||
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
|
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
|
||||||
|
const reminderJobsEnabled =
|
||||||
|
databaseUrl !== undefined && householdId !== undefined && schedulerSharedSecret !== undefined
|
||||||
|
|
||||||
const runtime: BotRuntimeConfig = {
|
const runtime: BotRuntimeConfig = {
|
||||||
port: parsePort(env.PORT),
|
port: parsePort(env.PORT),
|
||||||
@@ -73,6 +78,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
|
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
|
||||||
purchaseTopicIngestionEnabled,
|
purchaseTopicIngestionEnabled,
|
||||||
financeCommandsEnabled,
|
financeCommandsEnabled,
|
||||||
|
reminderJobsEnabled,
|
||||||
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini'
|
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) {
|
if (telegramPurchaseTopicId !== undefined) {
|
||||||
runtime.telegramPurchaseTopicId = telegramPurchaseTopicId
|
runtime.telegramPurchaseTopicId = telegramPurchaseTopicId
|
||||||
}
|
}
|
||||||
|
if (schedulerSharedSecret !== undefined) {
|
||||||
|
runtime.schedulerSharedSecret = schedulerSharedSecret
|
||||||
|
}
|
||||||
const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY)
|
const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY)
|
||||||
if (openaiApiKey !== undefined) {
|
if (openaiApiKey !== undefined) {
|
||||||
runtime.openaiApiKey = openaiApiKey
|
runtime.openaiApiKey = openaiApiKey
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { webhookCallback } from 'grammy'
|
import { webhookCallback } from 'grammy'
|
||||||
|
|
||||||
import { createFinanceCommandService } from '@household/application'
|
import { createFinanceCommandService, createReminderJobService } from '@household/application'
|
||||||
import { createDbFinanceRepository } from '@household/adapters-db'
|
import {
|
||||||
|
createDbFinanceRepository,
|
||||||
|
createDbReminderDispatchRepository
|
||||||
|
} from '@household/adapters-db'
|
||||||
|
|
||||||
import { createFinanceCommandsService } from './finance-commands'
|
import { createFinanceCommandsService } from './finance-commands'
|
||||||
import { createTelegramBot } from './bot'
|
import { createTelegramBot } from './bot'
|
||||||
@@ -11,6 +14,7 @@ import {
|
|||||||
createPurchaseMessageRepository,
|
createPurchaseMessageRepository,
|
||||||
registerPurchaseTopicIngestion
|
registerPurchaseTopicIngestion
|
||||||
} from './purchase-topic-ingestion'
|
} from './purchase-topic-ingestion'
|
||||||
|
import { createReminderJobsHandler } from './reminder-jobs'
|
||||||
import { createBotWebhookServer } from './server'
|
import { createBotWebhookServer } from './server'
|
||||||
|
|
||||||
const runtime = getBotRuntimeConfig()
|
const runtime = getBotRuntimeConfig()
|
||||||
@@ -58,10 +62,37 @@ if (runtime.financeCommandsEnabled) {
|
|||||||
console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.')
|
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({
|
const server = createBotWebhookServer({
|
||||||
webhookPath: runtime.telegramWebhookPath,
|
webhookPath: runtime.telegramWebhookPath,
|
||||||
webhookSecret: runtime.telegramWebhookSecret,
|
webhookSecret: runtime.telegramWebhookSecret,
|
||||||
webhookHandler
|
webhookHandler,
|
||||||
|
scheduler:
|
||||||
|
reminderJobs && runtime.schedulerSharedSecret
|
||||||
|
? {
|
||||||
|
sharedSecret: runtime.schedulerSharedSecret,
|
||||||
|
handler: reminderJobs.handle
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
if (import.meta.main) {
|
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({
|
const server = createBotWebhookServer({
|
||||||
webhookPath: '/webhook/telegram',
|
webhookPath: '/webhook/telegram',
|
||||||
webhookSecret: 'secret-token',
|
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 () => {
|
test('returns health payload', async () => {
|
||||||
@@ -59,4 +69,46 @@ describe('createBotWebhookServer', () => {
|
|||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(await response.text()).toBe('ok')
|
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
|
webhookPath: string
|
||||||
webhookSecret: string
|
webhookSecret: string
|
||||||
webhookHandler: (request: Request) => Promise<Response> | Response
|
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 {
|
function json(body: object, status = 200): Response {
|
||||||
@@ -19,12 +26,22 @@ function isAuthorized(request: Request, expectedSecret: string): boolean {
|
|||||||
return secretHeader === expectedSecret
|
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): {
|
export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||||
fetch: (request: Request) => Promise<Response>
|
fetch: (request: Request) => Promise<Response>
|
||||||
} {
|
} {
|
||||||
const normalizedWebhookPath = options.webhookPath.startsWith('/')
|
const normalizedWebhookPath = options.webhookPath.startsWith('/')
|
||||||
? options.webhookPath
|
? options.webhookPath
|
||||||
: `/${options.webhookPath}`
|
: `/${options.webhookPath}`
|
||||||
|
const schedulerPathPrefix = options.scheduler
|
||||||
|
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
||||||
|
: null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetch: async (request: Request) => {
|
fetch: async (request: Request) => {
|
||||||
@@ -35,6 +52,19 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname !== normalizedWebhookPath) {
|
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 })
|
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 { 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 { calculateMonthlySettlement } from './settlement-engine'
|
||||||
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
||||||
|
export {
|
||||||
|
createReminderJobService,
|
||||||
|
type ReminderJobResult,
|
||||||
|
type ReminderJobService
|
||||||
|
} from './reminder-job-service'
|
||||||
export {
|
export {
|
||||||
parsePurchaseMessage,
|
parsePurchaseMessage,
|
||||||
type ParsedPurchaseResult,
|
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 {
|
export type {
|
||||||
FinanceCycleRecord,
|
FinanceCycleRecord,
|
||||||
FinanceMemberRecord,
|
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