diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index 1f57c13..6f79697 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -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 diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 044510f..6ee0820 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -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) { diff --git a/apps/bot/src/reminder-jobs.test.ts b/apps/bot/src/reminder-jobs.test.ts new file mode 100644 index 0000000..a38b732 --- /dev/null +++ b/apps/bot/src/reminder-jobs.test.ts @@ -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' + }) + }) +}) diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts new file mode 100644 index 0000000..a4cbb0a --- /dev/null +++ b/apps/bot/src/reminder-jobs.ts @@ -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 { + 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 +} { + 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) + } + } + } +} diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index ed771b3..f820a34 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -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' + }) + }) }) diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index d9fc210..b08a670 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -2,6 +2,13 @@ export interface BotWebhookServerOptions { webhookPath: string webhookSecret: string webhookHandler: (request: Request) => Promise | Response + scheduler?: + | { + pathPrefix?: string + sharedSecret: string + handler: (request: Request, reminderType: string) => Promise + } + | 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 } { 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 }) } diff --git a/docs/specs/HOUSEBOT-031-secure-scheduler-endpoint.md b/docs/specs/HOUSEBOT-031-secure-scheduler-endpoint.md new file mode 100644 index 0000000..69d2f8a --- /dev/null +++ b/docs/specs/HOUSEBOT-031-secure-scheduler-endpoint.md @@ -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/` +- Allowed types: + - `utilities` + - `rent-warning` + - `rent-due` +- Request body: + - `period?: YYYY-MM` + - `jobId?: string` + - `dryRun?: boolean` +- Auth: + - `x-household-scheduler-secret: ` or `Authorization: Bearer ` + +## Domain Rules + +- Dedupe key format: `:` +- 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. diff --git a/packages/adapters-db/src/index.ts b/packages/adapters-db/src/index.ts index aa2d2ef..bc08a9b 100644 --- a/packages/adapters-db/src/index.ts +++ b/packages/adapters-db/src/index.ts @@ -1 +1,2 @@ export { createDbFinanceRepository } from './finance-repository' +export { createDbReminderDispatchRepository } from './reminder-dispatch-repository' diff --git a/packages/adapters-db/src/reminder-dispatch-repository.ts b/packages/adapters-db/src/reminder-dispatch-repository.ts new file mode 100644 index 0000000..14064dc --- /dev/null +++ b/packages/adapters-db/src/reminder-dispatch-repository.ts @@ -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 +} { + 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 }) + } + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index ce8c1fc..ffb204b 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -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, diff --git a/packages/application/src/reminder-job-service.test.ts b/packages/application/src/reminder-job-service.test.ts new file mode 100644 index 0000000..2b1b620 --- /dev/null +++ b/packages/application/src/reminder-job-service.test.ts @@ -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 { + 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') + }) +}) diff --git a/packages/application/src/reminder-job-service.ts b/packages/application/src/reminder-job-service.ts new file mode 100644 index 0000000..dcdd121 --- /dev/null +++ b/packages/application/src/reminder-job-service.ts @@ -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 +} + +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 + } + } + } +} diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index a9ba74a..6a8bff2 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -1,3 +1,10 @@ +export { + REMINDER_TYPES, + type ClaimReminderDispatchInput, + type ClaimReminderDispatchResult, + type ReminderDispatchRepository, + type ReminderType +} from './reminders' export type { FinanceCycleRecord, FinanceMemberRecord, diff --git a/packages/ports/src/reminders.ts b/packages/ports/src/reminders.ts new file mode 100644 index 0000000..cc6f1da --- /dev/null +++ b/packages/ports/src/reminders.ts @@ -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 +}