mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(bot): add multi-household reminder delivery
This commit is contained in:
@@ -15,12 +15,6 @@ SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key
|
|||||||
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
|
||||||
TELEGRAM_WEBHOOK_SECRET=your-webhook-secret
|
TELEGRAM_WEBHOOK_SECRET=your-webhook-secret
|
||||||
TELEGRAM_WEBHOOK_PATH=/webhook/telegram
|
TELEGRAM_WEBHOOK_PATH=/webhook/telegram
|
||||||
TELEGRAM_HOUSEHOLD_CHAT_ID=-1001234567890
|
|
||||||
TELEGRAM_PURCHASE_TOPIC_ID=777
|
|
||||||
TELEGRAM_FEEDBACK_TOPIC_ID=888
|
|
||||||
|
|
||||||
# Household
|
|
||||||
HOUSEHOLD_ID=11111111-1111-4111-8111-111111111111
|
|
||||||
|
|
||||||
# Mini app
|
# Mini app
|
||||||
MINI_APP_ALLOWED_ORIGINS=http://localhost:5173
|
MINI_APP_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
: null,
|
: null,
|
||||||
findHouseholdTopicByTelegramContext: async () => null,
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
listHouseholdTopicBindings: async () => [],
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
listReminderTargets: async () => [],
|
||||||
upsertHouseholdJoinToken: async () => ({
|
upsertHouseholdJoinToken: async () => ({
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export interface BotRuntimeConfig {
|
|||||||
telegramWebhookSecret: string
|
telegramWebhookSecret: string
|
||||||
telegramWebhookPath: string
|
telegramWebhookPath: string
|
||||||
databaseUrl?: string
|
databaseUrl?: string
|
||||||
householdId?: string
|
|
||||||
telegramHouseholdChatId?: string
|
telegramHouseholdChatId?: string
|
||||||
telegramPurchaseTopicId?: number
|
telegramPurchaseTopicId?: number
|
||||||
telegramFeedbackTopicId?: number
|
telegramFeedbackTopicId?: number
|
||||||
@@ -94,7 +93,6 @@ function parseOptionalCsv(value: string | undefined): readonly string[] {
|
|||||||
|
|
||||||
export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig {
|
export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig {
|
||||||
const databaseUrl = parseOptionalValue(env.DATABASE_URL)
|
const databaseUrl = parseOptionalValue(env.DATABASE_URL)
|
||||||
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 telegramFeedbackTopicId = parseOptionalTopicId(env.TELEGRAM_FEEDBACK_TOPIC_ID)
|
const telegramFeedbackTopicId = parseOptionalTopicId(env.TELEGRAM_FEEDBACK_TOPIC_ID)
|
||||||
@@ -109,9 +107,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
const miniAppAuthEnabled = databaseUrl !== undefined
|
const miniAppAuthEnabled = databaseUrl !== undefined
|
||||||
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
||||||
const reminderJobsEnabled =
|
const reminderJobsEnabled =
|
||||||
databaseUrl !== undefined &&
|
databaseUrl !== undefined && (schedulerSharedSecret !== undefined || hasSchedulerOidcConfig)
|
||||||
householdId !== undefined &&
|
|
||||||
(schedulerSharedSecret !== undefined || hasSchedulerOidcConfig)
|
|
||||||
|
|
||||||
const runtime: BotRuntimeConfig = {
|
const runtime: BotRuntimeConfig = {
|
||||||
port: parsePort(env.PORT),
|
port: parsePort(env.PORT),
|
||||||
@@ -132,9 +128,6 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
if (databaseUrl !== undefined) {
|
if (databaseUrl !== undefined) {
|
||||||
runtime.databaseUrl = databaseUrl
|
runtime.databaseUrl = databaseUrl
|
||||||
}
|
}
|
||||||
if (householdId !== undefined) {
|
|
||||||
runtime.householdId = householdId
|
|
||||||
}
|
|
||||||
if (telegramHouseholdChatId !== undefined) {
|
if (telegramHouseholdChatId !== undefined) {
|
||||||
runtime.telegramHouseholdChatId = telegramHouseholdChatId
|
runtime.telegramHouseholdChatId = telegramHouseholdChatId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ export const enBotTranslations: BotTranslationCatalog = {
|
|||||||
statementTotal: (amount, currency) => `Total: ${amount} ${currency}`,
|
statementTotal: (amount, currency) => `Total: ${amount} ${currency}`,
|
||||||
statementFailed: (message) => `Failed to generate statement: ${message}`
|
statementFailed: (message) => `Failed to generate statement: ${message}`
|
||||||
},
|
},
|
||||||
|
reminders: {
|
||||||
|
utilities: (period) => `Utilities reminder for ${period}`,
|
||||||
|
rentWarning: (period) => `Rent reminder for ${period}: payment is coming up soon.`,
|
||||||
|
rentDue: (period) => `Rent due reminder for ${period}: please settle payment today.`
|
||||||
|
},
|
||||||
purchase: {
|
purchase: {
|
||||||
sharedPurchaseFallback: 'shared purchase',
|
sharedPurchaseFallback: 'shared purchase',
|
||||||
recorded: (summary) => `Recorded purchase: ${summary}`,
|
recorded: (summary) => `Recorded purchase: ${summary}`,
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
statementTotal: (amount, currency) => `Итого: ${amount} ${currency}`,
|
statementTotal: (amount, currency) => `Итого: ${amount} ${currency}`,
|
||||||
statementFailed: (message) => `Не удалось построить выписку: ${message}`
|
statementFailed: (message) => `Не удалось построить выписку: ${message}`
|
||||||
},
|
},
|
||||||
|
reminders: {
|
||||||
|
utilities: (period) => `Напоминание по коммунальным платежам за ${period}`,
|
||||||
|
rentWarning: (period) => `Напоминание по аренде за ${period}: срок оплаты скоро наступит.`,
|
||||||
|
rentDue: (period) => `Напоминание по аренде за ${period}: пожалуйста, оплатите сегодня.`
|
||||||
|
},
|
||||||
purchase: {
|
purchase: {
|
||||||
sharedPurchaseFallback: 'общая покупка',
|
sharedPurchaseFallback: 'общая покупка',
|
||||||
recorded: (summary) => `Покупка сохранена: ${summary}`,
|
recorded: (summary) => `Покупка сохранена: ${summary}`,
|
||||||
|
|||||||
@@ -138,6 +138,11 @@ export interface BotTranslationCatalog {
|
|||||||
statementTotal: (amount: string, currency: string) => string
|
statementTotal: (amount: string, currency: string) => string
|
||||||
statementFailed: (message: string) => string
|
statementFailed: (message: string) => string
|
||||||
}
|
}
|
||||||
|
reminders: {
|
||||||
|
utilities: (period: string) => string
|
||||||
|
rentWarning: (period: string) => string
|
||||||
|
rentDue: (period: string) => string
|
||||||
|
}
|
||||||
purchase: {
|
purchase: {
|
||||||
sharedPurchaseFallback: string
|
sharedPurchaseFallback: string
|
||||||
recorded: (summary: string) => string
|
recorded: (summary: string) => string
|
||||||
|
|||||||
@@ -202,7 +202,30 @@ const reminderJobs = runtime.reminderJobsEnabled
|
|||||||
shutdownTasks.push(reminderRepositoryClient.close)
|
shutdownTasks.push(reminderRepositoryClient.close)
|
||||||
|
|
||||||
return createReminderJobsHandler({
|
return createReminderJobsHandler({
|
||||||
householdId: runtime.householdId!,
|
listReminderTargets: () =>
|
||||||
|
householdConfigurationRepositoryClient!.repository.listReminderTargets(),
|
||||||
|
releaseReminderDispatch: (input) =>
|
||||||
|
reminderRepositoryClient.repository.releaseReminderDispatch(input),
|
||||||
|
sendReminderMessage: async (target, text) => {
|
||||||
|
const threadId =
|
||||||
|
target.telegramThreadId !== null ? Number(target.telegramThreadId) : undefined
|
||||||
|
|
||||||
|
if (target.telegramThreadId !== null && (!threadId || !Number.isInteger(threadId))) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid reminder thread id for household ${target.householdId}: ${target.telegramThreadId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.api.sendMessage(
|
||||||
|
target.telegramChatId,
|
||||||
|
text,
|
||||||
|
threadId
|
||||||
|
? {
|
||||||
|
message_thread_id: threadId
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
},
|
||||||
reminderService,
|
reminderService,
|
||||||
logger: getLogger('scheduler')
|
logger: getLogger('scheduler')
|
||||||
})
|
})
|
||||||
@@ -215,7 +238,7 @@ if (!runtime.reminderJobsEnabled) {
|
|||||||
event: 'runtime.feature_disabled',
|
event: 'runtime.feature_disabled',
|
||||||
feature: 'reminder-jobs'
|
feature: 'reminder-jobs'
|
||||||
},
|
},
|
||||||
'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.'
|
'Reminder jobs are disabled. Set DATABASE_URL and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
getHouseholdTopicBinding: async () => null,
|
getHouseholdTopicBinding: async () => null,
|
||||||
findHouseholdTopicByTelegramContext: async () => null,
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
listHouseholdTopicBindings: async () => [],
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
listReminderTargets: async () => [],
|
||||||
upsertHouseholdJoinToken: async (input) => ({
|
upsertHouseholdJoinToken: async (input) => ({
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
householdName: household.householdName,
|
householdName: household.householdName,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
getHouseholdTopicBinding: async () => null,
|
getHouseholdTopicBinding: async () => null,
|
||||||
findHouseholdTopicByTelegramContext: async () => null,
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
listHouseholdTopicBindings: async () => [],
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
listReminderTargets: async () => [],
|
||||||
upsertHouseholdJoinToken: async (input) => ({
|
upsertHouseholdJoinToken: async (input) => ({
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
householdName: household.householdName,
|
householdName: household.householdName,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
getHouseholdTopicBinding: async () => null,
|
getHouseholdTopicBinding: async () => null,
|
||||||
findHouseholdTopicByTelegramContext: async () => null,
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
listHouseholdTopicBindings: async () => [],
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
listReminderTargets: async () => [],
|
||||||
upsertHouseholdJoinToken: async (input) => ({
|
upsertHouseholdJoinToken: async (input) => ({
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
householdName: household.householdName,
|
householdName: household.householdName,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ function repository(): HouseholdConfigurationRepository {
|
|||||||
getHouseholdTopicBinding: async () => null,
|
getHouseholdTopicBinding: async () => null,
|
||||||
findHouseholdTopicByTelegramContext: async () => null,
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
listHouseholdTopicBindings: async () => [],
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
listReminderTargets: async () => [],
|
||||||
upsertHouseholdJoinToken: async () => ({
|
upsertHouseholdJoinToken: async () => ({
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
householdName: household.householdName,
|
householdName: household.householdName,
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test'
|
import { describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
import type { ReminderJobResult, ReminderJobService } from '@household/application'
|
import type { ReminderJobResult, ReminderJobService } from '@household/application'
|
||||||
|
import type { ReminderTarget } from '@household/ports'
|
||||||
|
|
||||||
import { createReminderJobsHandler } from './reminder-jobs'
|
import { createReminderJobsHandler } from './reminder-jobs'
|
||||||
|
|
||||||
|
const target: ReminderTarget = {
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
telegramChatId: '-1001',
|
||||||
|
telegramThreadId: '12',
|
||||||
|
locale: 'ru'
|
||||||
|
}
|
||||||
|
|
||||||
describe('createReminderJobsHandler', () => {
|
describe('createReminderJobsHandler', () => {
|
||||||
test('returns job outcome with dedupe metadata', async () => {
|
test('returns per-household dispatch outcome with Telegram delivery metadata', async () => {
|
||||||
const claimedResult: ReminderJobResult = {
|
const claimedResult: ReminderJobResult = {
|
||||||
status: 'claimed',
|
status: 'claimed',
|
||||||
dedupeKey: '2026-03:utilities',
|
dedupeKey: '2026-03:utilities',
|
||||||
@@ -18,9 +27,12 @@ describe('createReminderJobsHandler', () => {
|
|||||||
const reminderService: ReminderJobService = {
|
const reminderService: ReminderJobService = {
|
||||||
handleJob: mock(async () => claimedResult)
|
handleJob: mock(async () => claimedResult)
|
||||||
}
|
}
|
||||||
|
const sendReminderMessage = mock(async () => {})
|
||||||
|
|
||||||
const handler = createReminderJobsHandler({
|
const handler = createReminderJobsHandler({
|
||||||
householdId: 'household-1',
|
listReminderTargets: async () => [target],
|
||||||
|
releaseReminderDispatch: mock(async () => {}),
|
||||||
|
sendReminderMessage,
|
||||||
reminderService
|
reminderService
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -35,20 +47,41 @@ describe('createReminderJobsHandler', () => {
|
|||||||
'utilities'
|
'utilities'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
expect(sendReminderMessage).toHaveBeenCalledTimes(1)
|
||||||
|
expect(sendReminderMessage).toHaveBeenCalledWith(
|
||||||
|
target,
|
||||||
|
'Напоминание по коммунальным платежам за 2026-03'
|
||||||
|
)
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(await response.json()).toEqual({
|
expect(await response.json()).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
jobId: 'job-1',
|
jobId: 'job-1',
|
||||||
reminderType: 'utilities',
|
reminderType: 'utilities',
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
dedupeKey: '2026-03:utilities',
|
|
||||||
outcome: 'claimed',
|
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
messageText: 'Utilities reminder for 2026-03'
|
totals: {
|
||||||
|
targets: 1,
|
||||||
|
claimed: 1,
|
||||||
|
duplicate: 0,
|
||||||
|
'dry-run': 0,
|
||||||
|
failed: 0
|
||||||
|
},
|
||||||
|
dispatches: [
|
||||||
|
{
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
telegramChatId: '-1001',
|
||||||
|
telegramThreadId: '12',
|
||||||
|
dedupeKey: '2026-03:utilities',
|
||||||
|
outcome: 'claimed',
|
||||||
|
messageText: 'Напоминание по коммунальным платежам за 2026-03'
|
||||||
|
}
|
||||||
|
]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('supports forced dry-run mode', async () => {
|
test('supports forced dry-run mode without posting to Telegram', async () => {
|
||||||
const dryRunResult: ReminderJobResult = {
|
const dryRunResult: ReminderJobResult = {
|
||||||
status: 'dry-run',
|
status: 'dry-run',
|
||||||
dedupeKey: '2026-03:rent-warning',
|
dedupeKey: '2026-03:rent-warning',
|
||||||
@@ -61,9 +94,12 @@ describe('createReminderJobsHandler', () => {
|
|||||||
const reminderService: ReminderJobService = {
|
const reminderService: ReminderJobService = {
|
||||||
handleJob: mock(async () => dryRunResult)
|
handleJob: mock(async () => dryRunResult)
|
||||||
}
|
}
|
||||||
|
const sendReminderMessage = mock(async () => {})
|
||||||
|
|
||||||
const handler = createReminderJobsHandler({
|
const handler = createReminderJobsHandler({
|
||||||
householdId: 'household-1',
|
listReminderTargets: async () => [target],
|
||||||
|
releaseReminderDispatch: mock(async () => {}),
|
||||||
|
sendReminderMessage,
|
||||||
reminderService,
|
reminderService,
|
||||||
forceDryRun: true
|
forceDryRun: true
|
||||||
})
|
})
|
||||||
@@ -76,16 +112,75 @@ describe('createReminderJobsHandler', () => {
|
|||||||
'rent-warning'
|
'rent-warning'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
expect(sendReminderMessage).toHaveBeenCalledTimes(0)
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(await response.json()).toMatchObject({
|
expect(await response.json()).toMatchObject({
|
||||||
outcome: 'dry-run',
|
dryRun: true,
|
||||||
dryRun: true
|
totals: {
|
||||||
|
targets: 1,
|
||||||
|
claimed: 0,
|
||||||
|
duplicate: 0,
|
||||||
|
'dry-run': 1,
|
||||||
|
failed: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('releases a dispatch claim when Telegram delivery fails', async () => {
|
||||||
|
const failedResult: ReminderJobResult = {
|
||||||
|
status: 'claimed',
|
||||||
|
dedupeKey: '2026-03:rent-due',
|
||||||
|
payloadHash: 'hash',
|
||||||
|
reminderType: 'rent-due',
|
||||||
|
period: '2026-03',
|
||||||
|
messageText: 'Rent due reminder for 2026-03: please settle payment today.'
|
||||||
|
}
|
||||||
|
const reminderService: ReminderJobService = {
|
||||||
|
handleJob: mock(async () => failedResult)
|
||||||
|
}
|
||||||
|
const releaseReminderDispatch = mock(async () => {})
|
||||||
|
|
||||||
|
const handler = createReminderJobsHandler({
|
||||||
|
listReminderTargets: async () => [target],
|
||||||
|
releaseReminderDispatch,
|
||||||
|
sendReminderMessage: mock(async () => {
|
||||||
|
throw new Error('Telegram unavailable')
|
||||||
|
}),
|
||||||
|
reminderService
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handle(
|
||||||
|
new Request('http://localhost/jobs/reminder/rent-due', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ period: '2026-03' })
|
||||||
|
}),
|
||||||
|
'rent-due'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(releaseReminderDispatch).toHaveBeenCalledWith({
|
||||||
|
householdId: 'household-1',
|
||||||
|
period: '2026-03',
|
||||||
|
reminderType: 'rent-due'
|
||||||
|
})
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
totals: {
|
||||||
|
failed: 1
|
||||||
|
},
|
||||||
|
dispatches: [
|
||||||
|
expect.objectContaining({
|
||||||
|
outcome: 'failed',
|
||||||
|
error: 'Telegram unavailable'
|
||||||
|
})
|
||||||
|
]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('rejects unsupported reminder type', async () => {
|
test('rejects unsupported reminder type', async () => {
|
||||||
const handler = createReminderJobsHandler({
|
const handler = createReminderJobsHandler({
|
||||||
householdId: 'household-1',
|
listReminderTargets: async () => [target],
|
||||||
|
releaseReminderDispatch: mock(async () => {}),
|
||||||
|
sendReminderMessage: mock(async () => {}),
|
||||||
reminderService: {
|
reminderService: {
|
||||||
handleJob: mock(async () => {
|
handleJob: mock(async () => {
|
||||||
throw new Error('should not be called')
|
throw new Error('should not be called')
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { ReminderJobService } from '@household/application'
|
import type { ReminderJobService } from '@household/application'
|
||||||
import { BillingPeriod, nowInstant } from '@household/domain'
|
import { BillingPeriod, nowInstant } from '@household/domain'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import { REMINDER_TYPES, type ReminderType } from '@household/ports'
|
import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports'
|
||||||
|
|
||||||
|
import { getBotTranslations } from './i18n'
|
||||||
|
|
||||||
interface ReminderJobRequestBody {
|
interface ReminderJobRequestBody {
|
||||||
period?: string
|
period?: string
|
||||||
@@ -45,13 +47,32 @@ async function readBody(request: Request): Promise<ReminderJobRequestBody> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createReminderJobsHandler(options: {
|
export function createReminderJobsHandler(options: {
|
||||||
householdId: string
|
listReminderTargets: () => Promise<readonly ReminderTarget[]>
|
||||||
|
releaseReminderDispatch: (input: {
|
||||||
|
householdId: string
|
||||||
|
period: string
|
||||||
|
reminderType: ReminderType
|
||||||
|
}) => Promise<void>
|
||||||
|
sendReminderMessage: (target: ReminderTarget, text: string) => Promise<void>
|
||||||
reminderService: ReminderJobService
|
reminderService: ReminderJobService
|
||||||
forceDryRun?: boolean
|
forceDryRun?: boolean
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): {
|
}): {
|
||||||
handle: (request: Request, rawReminderType: string) => Promise<Response>
|
handle: (request: Request, rawReminderType: string) => Promise<Response>
|
||||||
} {
|
} {
|
||||||
|
function messageText(target: ReminderTarget, reminderType: ReminderType, period: string): string {
|
||||||
|
const t = getBotTranslations(target.locale).reminders
|
||||||
|
|
||||||
|
switch (reminderType) {
|
||||||
|
case 'utilities':
|
||||||
|
return t.utilities(period)
|
||||||
|
case 'rent-warning':
|
||||||
|
return t.rentWarning(period)
|
||||||
|
case 'rent-due':
|
||||||
|
return t.rentDue(period)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handle: async (request, rawReminderType) => {
|
handle: async (request, rawReminderType) => {
|
||||||
const reminderType = parseReminderType(rawReminderType)
|
const reminderType = parseReminderType(rawReminderType)
|
||||||
@@ -64,34 +85,99 @@ export function createReminderJobsHandler(options: {
|
|||||||
const schedulerJobName = request.headers.get('x-cloudscheduler-jobname')
|
const schedulerJobName = request.headers.get('x-cloudscheduler-jobname')
|
||||||
const period = BillingPeriod.fromString(body.period ?? currentPeriod()).toString()
|
const period = BillingPeriod.fromString(body.period ?? currentPeriod()).toString()
|
||||||
const dryRun = options.forceDryRun === true || body.dryRun === true
|
const dryRun = options.forceDryRun === true || body.dryRun === true
|
||||||
const result = await options.reminderService.handleJob({
|
const targets = await options.listReminderTargets()
|
||||||
householdId: options.householdId,
|
const dispatches: Array<{
|
||||||
period,
|
householdId: string
|
||||||
reminderType,
|
householdName: string
|
||||||
dryRun
|
telegramChatId: string
|
||||||
})
|
telegramThreadId: string | null
|
||||||
|
dedupeKey: string
|
||||||
|
outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed'
|
||||||
|
messageText: string
|
||||||
|
error?: string
|
||||||
|
}> = []
|
||||||
|
|
||||||
const logPayload = {
|
for (const target of targets) {
|
||||||
event: 'scheduler.reminder.dispatch',
|
const result = await options.reminderService.handleJob({
|
||||||
reminderType,
|
householdId: target.householdId,
|
||||||
period,
|
period,
|
||||||
jobId: body.jobId ?? schedulerJobName ?? null,
|
reminderType,
|
||||||
dedupeKey: result.dedupeKey,
|
dryRun
|
||||||
outcome: result.status,
|
})
|
||||||
dryRun
|
const text = messageText(target, reminderType, period)
|
||||||
|
|
||||||
|
let outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed' = result.status
|
||||||
|
let error: string | undefined
|
||||||
|
|
||||||
|
if (result.status === 'claimed') {
|
||||||
|
try {
|
||||||
|
await options.sendReminderMessage(target, text)
|
||||||
|
} catch (dispatchError) {
|
||||||
|
await options.releaseReminderDispatch({
|
||||||
|
householdId: target.householdId,
|
||||||
|
period,
|
||||||
|
reminderType
|
||||||
|
})
|
||||||
|
|
||||||
|
outcome = 'failed'
|
||||||
|
error =
|
||||||
|
dispatchError instanceof Error
|
||||||
|
? dispatchError.message
|
||||||
|
: 'Unknown reminder delivery error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options.logger?.info(
|
||||||
|
{
|
||||||
|
event: 'scheduler.reminder.dispatch',
|
||||||
|
reminderType,
|
||||||
|
period,
|
||||||
|
jobId: body.jobId ?? schedulerJobName ?? null,
|
||||||
|
householdId: target.householdId,
|
||||||
|
householdName: target.householdName,
|
||||||
|
dedupeKey: result.dedupeKey,
|
||||||
|
outcome,
|
||||||
|
dryRun,
|
||||||
|
...(error ? { error } : {})
|
||||||
|
},
|
||||||
|
'Reminder job processed'
|
||||||
|
)
|
||||||
|
|
||||||
|
dispatches.push({
|
||||||
|
householdId: target.householdId,
|
||||||
|
householdName: target.householdName,
|
||||||
|
telegramChatId: target.telegramChatId,
|
||||||
|
telegramThreadId: target.telegramThreadId,
|
||||||
|
dedupeKey: result.dedupeKey,
|
||||||
|
outcome,
|
||||||
|
messageText: text,
|
||||||
|
...(error ? { error } : {})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
options.logger?.info(logPayload, 'Reminder job processed')
|
const totals = dispatches.reduce(
|
||||||
|
(summary, dispatch) => {
|
||||||
|
summary.targets += 1
|
||||||
|
summary[dispatch.outcome] += 1
|
||||||
|
return summary
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targets: 0,
|
||||||
|
claimed: 0,
|
||||||
|
duplicate: 0,
|
||||||
|
'dry-run': 0,
|
||||||
|
failed: 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
ok: true,
|
ok: true,
|
||||||
jobId: body.jobId ?? schedulerJobName ?? null,
|
jobId: body.jobId ?? schedulerJobName ?? null,
|
||||||
reminderType,
|
reminderType,
|
||||||
period,
|
period,
|
||||||
dedupeKey: result.dedupeKey,
|
|
||||||
outcome: result.status,
|
|
||||||
dryRun,
|
dryRun,
|
||||||
messageText: result.messageText
|
totals,
|
||||||
|
dispatches
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown reminder job error'
|
const message = error instanceof Error ? error.message : 'Unknown reminder job error'
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ bun run review:coderabbit
|
|||||||
- Typed environment validation lives in `packages/config/src/env.ts`.
|
- Typed environment validation lives in `packages/config/src/env.ts`.
|
||||||
- Copy `.env.example` to `.env` before running app/database commands.
|
- Copy `.env.example` to `.env` before running app/database commands.
|
||||||
- Local bot feature flags come from env presence:
|
- Local bot feature flags come from env presence:
|
||||||
- finance commands require `DATABASE_URL` and `HOUSEHOLD_ID`
|
- finance commands require `DATABASE_URL` plus household setup in Telegram via `/setup`
|
||||||
- purchase ingestion also requires `TELEGRAM_HOUSEHOLD_CHAT_ID` and `TELEGRAM_PURCHASE_TOPIC_ID`
|
- purchase ingestion requires `DATABASE_URL` plus a bound purchase topic via `/bind_purchase_topic`
|
||||||
- anonymous feedback also requires `TELEGRAM_FEEDBACK_TOPIC_ID`
|
- anonymous feedback requires `DATABASE_URL` plus a bound feedback topic via `/bind_feedback_topic`
|
||||||
- reminders require `SCHEDULER_SHARED_SECRET` or `SCHEDULER_OIDC_ALLOWED_EMAILS`
|
- reminders require `DATABASE_URL` plus `SCHEDULER_SHARED_SECRET` or `SCHEDULER_OIDC_ALLOWED_EMAILS`
|
||||||
- mini app CORS can be constrained with `MINI_APP_ALLOWED_ORIGINS`
|
- mini app CORS can be constrained with `MINI_APP_ALLOWED_ORIGINS`
|
||||||
- Migration workflow is documented in `docs/runbooks/migrations.md`.
|
- Migration workflow is documented in `docs/runbooks/migrations.md`.
|
||||||
- First deploy flow is documented in `docs/runbooks/first-deploy.md`.
|
- First deploy flow is documented in `docs/runbooks/first-deploy.md`.
|
||||||
|
|||||||
53
docs/specs/HOUSEBOT-075-multi-household-reminder-delivery.md
Normal file
53
docs/specs/HOUSEBOT-075-multi-household-reminder-delivery.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# HOUSEBOT-075 Multi-Household Reminder Delivery
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the current reminder placeholder path with a real multi-household reminder dispatcher.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Current reminder jobs only claim a dedupe key and return `messageText`. They do not send any Telegram message. They also require a single global `HOUSEHOLD_ID`, which is incompatible with the bot's DB-backed multi-household model.
|
||||||
|
|
||||||
|
## Target behavior
|
||||||
|
|
||||||
|
- Scheduler endpoint accepts `utilities`, `rent-warning`, or `rent-due`
|
||||||
|
- For the target billing period, the bot resolves all configured household reminder targets from the database
|
||||||
|
- A household reminder target uses:
|
||||||
|
- bound `reminders` topic if present
|
||||||
|
- otherwise the household chat itself
|
||||||
|
- For each target household, the bot:
|
||||||
|
- builds deterministic reminder text
|
||||||
|
- claims dedupe for `(householdId, period, reminderType)`
|
||||||
|
- posts the message to Telegram only when the claim succeeds
|
||||||
|
- Dry-run returns the planned dispatches without posting
|
||||||
|
|
||||||
|
## Delivery model
|
||||||
|
|
||||||
|
- Scheduler route remains a single endpoint per reminder type
|
||||||
|
- One request fan-outs across all reminder-enabled households
|
||||||
|
- Logs include an entry per household outcome
|
||||||
|
|
||||||
|
## Data source
|
||||||
|
|
||||||
|
Household config comes from `HouseholdConfigurationRepository`:
|
||||||
|
|
||||||
|
- household chat binding is required
|
||||||
|
- reminder topic binding is optional
|
||||||
|
|
||||||
|
## Runtime contract
|
||||||
|
|
||||||
|
Reminder jobs require:
|
||||||
|
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- one scheduler auth mechanism (`SCHEDULER_SHARED_SECRET` or allowed OIDC service accounts)
|
||||||
|
|
||||||
|
Reminder jobs must not require:
|
||||||
|
|
||||||
|
- `HOUSEHOLD_ID`
|
||||||
|
- group/topic reminder env vars
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- per-household reminder settings
|
||||||
|
- localized reminder copy using persisted household/member locale
|
||||||
|
- scheduler fan-out observability metrics
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, asc, eq } from 'drizzle-orm'
|
||||||
|
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
import { instantToDate, normalizeSupportedLocale, nowInstant } from '@household/domain'
|
import { instantToDate, normalizeSupportedLocale, nowInstant } from '@household/domain'
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type HouseholdTelegramChatRecord,
|
type HouseholdTelegramChatRecord,
|
||||||
type HouseholdTopicBindingRecord,
|
type HouseholdTopicBindingRecord,
|
||||||
type HouseholdTopicRole,
|
type HouseholdTopicRole,
|
||||||
|
type ReminderTarget,
|
||||||
type RegisterTelegramHouseholdChatResult
|
type RegisterTelegramHouseholdChatResult
|
||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
|
|
||||||
@@ -125,6 +126,27 @@ function toHouseholdMemberRecord(row: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toReminderTarget(row: {
|
||||||
|
householdId: string
|
||||||
|
householdName: string
|
||||||
|
telegramChatId: string
|
||||||
|
reminderThreadId: string | null
|
||||||
|
defaultLocale: string
|
||||||
|
}): ReminderTarget {
|
||||||
|
const locale = normalizeSupportedLocale(row.defaultLocale)
|
||||||
|
if (!locale) {
|
||||||
|
throw new Error(`Unsupported household default locale: ${row.defaultLocale}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
householdId: row.householdId,
|
||||||
|
householdName: row.householdName,
|
||||||
|
telegramChatId: row.telegramChatId,
|
||||||
|
telegramThreadId: row.reminderThreadId,
|
||||||
|
locale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||||
repository: HouseholdConfigurationRepository
|
repository: HouseholdConfigurationRepository
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
@@ -364,6 +386,35 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
return rows.map(toHouseholdTopicBindingRecord)
|
return rows.map(toHouseholdTopicBindingRecord)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listReminderTargets() {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
householdId: schema.householdTelegramChats.householdId,
|
||||||
|
householdName: schema.households.name,
|
||||||
|
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
||||||
|
reminderThreadId: schema.householdTopicBindings.telegramThreadId,
|
||||||
|
defaultLocale: schema.households.defaultLocale
|
||||||
|
})
|
||||||
|
.from(schema.householdTelegramChats)
|
||||||
|
.innerJoin(
|
||||||
|
schema.households,
|
||||||
|
eq(schema.householdTelegramChats.householdId, schema.households.id)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
schema.householdTopicBindings,
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
schema.householdTopicBindings.householdId,
|
||||||
|
schema.householdTelegramChats.householdId
|
||||||
|
),
|
||||||
|
eq(schema.householdTopicBindings.role, 'reminders')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(schema.householdTelegramChats.telegramChatId), asc(schema.households.name))
|
||||||
|
|
||||||
|
return rows.map(toReminderTarget)
|
||||||
|
},
|
||||||
|
|
||||||
async upsertHouseholdJoinToken(input) {
|
async upsertHouseholdJoinToken(input) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.insert(schema.householdJoinTokens)
|
.insert(schema.householdJoinTokens)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
import type { ReminderDispatchRepository } from '@household/ports'
|
import type { ReminderDispatchRepository } from '@household/ports'
|
||||||
|
|
||||||
@@ -34,6 +36,20 @@ export function createDbReminderDispatchRepository(databaseUrl: string): {
|
|||||||
dedupeKey,
|
dedupeKey,
|
||||||
claimed: rows.length > 0
|
claimed: rows.length > 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async releaseReminderDispatch(input) {
|
||||||
|
const dedupeKey = `${input.period}:${input.reminderType}`
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(schema.processedBotMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.processedBotMessages.householdId, input.householdId),
|
||||||
|
eq(schema.processedBotMessages.source, 'scheduler-reminder'),
|
||||||
|
eq(schema.processedBotMessages.sourceMessageKey, dedupeKey)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ function createRepositoryStub() {
|
|||||||
getHouseholdTopicBinding: async () => null,
|
getHouseholdTopicBinding: async () => null,
|
||||||
findHouseholdTopicByTelegramContext: async () => null,
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
listHouseholdTopicBindings: async () => [],
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
listReminderTargets: async () => [],
|
||||||
upsertHouseholdJoinToken: async (input) =>
|
upsertHouseholdJoinToken: async (input) =>
|
||||||
({
|
({
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ function createRepositoryStub() {
|
|||||||
async listHouseholdTopicBindings() {
|
async listHouseholdTopicBindings() {
|
||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
|
async listReminderTargets() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
async upsertHouseholdJoinToken(input) {
|
async upsertHouseholdJoinToken(input) {
|
||||||
joinToken = {
|
joinToken = {
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ function createRepositoryStub() {
|
|||||||
async listHouseholdTopicBindings(householdId) {
|
async listHouseholdTopicBindings(householdId) {
|
||||||
return bindings.get(householdId) ?? []
|
return bindings.get(householdId) ?? []
|
||||||
},
|
},
|
||||||
|
async listReminderTargets() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
|
||||||
async upsertHouseholdJoinToken(input) {
|
async upsertHouseholdJoinToken(input) {
|
||||||
const household = [...households.values()].find(
|
const household = [...households.values()].find(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function createRepository(): HouseholdConfigurationRepository {
|
|||||||
getHouseholdTopicBinding: async () => null,
|
getHouseholdTopicBinding: async () => null,
|
||||||
findHouseholdTopicByTelegramContext: async () => null,
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
listHouseholdTopicBindings: async () => [],
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
listReminderTargets: async () => [],
|
||||||
upsertHouseholdJoinToken: async () => ({
|
upsertHouseholdJoinToken: async () => ({
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
householdName: household.householdName,
|
householdName: household.householdName,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function repository(): HouseholdConfigurationRepository {
|
|||||||
getHouseholdTopicBinding: async () => null,
|
getHouseholdTopicBinding: async () => null,
|
||||||
findHouseholdTopicByTelegramContext: async () => null,
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
listHouseholdTopicBindings: async () => [],
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
listReminderTargets: async () => [],
|
||||||
upsertHouseholdJoinToken: async () => ({
|
upsertHouseholdJoinToken: async () => ({
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class ReminderDispatchRepositoryStub implements ReminderDispatchRepository {
|
|||||||
this.lastClaim = input
|
this.lastClaim = input
|
||||||
return this.nextResult
|
return this.nextResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async releaseReminderDispatch(): Promise<void> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('createReminderJobService', () => {
|
describe('createReminderJobService', () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const server = {
|
|||||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
HOUSEHOLD_ID: z.string().uuid(),
|
HOUSEHOLD_ID: z.string().uuid().optional(),
|
||||||
SUPABASE_URL: z.string().url().optional(),
|
SUPABASE_URL: z.string().url().optional(),
|
||||||
SUPABASE_PUBLISHABLE_KEY: z.string().min(1).optional(),
|
SUPABASE_PUBLISHABLE_KEY: z.string().min(1).optional(),
|
||||||
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1).optional(),
|
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1).optional(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SupportedLocale } from '@household/domain'
|
import type { SupportedLocale } from '@household/domain'
|
||||||
|
import type { ReminderTarget } from './reminders'
|
||||||
|
|
||||||
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const
|
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ export interface HouseholdConfigurationRepository {
|
|||||||
telegramThreadId: string
|
telegramThreadId: string
|
||||||
}): Promise<HouseholdTopicBindingRecord | null>
|
}): Promise<HouseholdTopicBindingRecord | null>
|
||||||
listHouseholdTopicBindings(householdId: string): Promise<readonly HouseholdTopicBindingRecord[]>
|
listHouseholdTopicBindings(householdId: string): Promise<readonly HouseholdTopicBindingRecord[]>
|
||||||
|
listReminderTargets(): Promise<readonly ReminderTarget[]>
|
||||||
upsertHouseholdJoinToken(input: {
|
upsertHouseholdJoinToken(input: {
|
||||||
householdId: string
|
householdId: string
|
||||||
token: string
|
token: string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export {
|
|||||||
type ClaimReminderDispatchInput,
|
type ClaimReminderDispatchInput,
|
||||||
type ClaimReminderDispatchResult,
|
type ClaimReminderDispatchResult,
|
||||||
type ReminderDispatchRepository,
|
type ReminderDispatchRepository,
|
||||||
|
type ReminderTarget,
|
||||||
type ReminderType
|
type ReminderType
|
||||||
} from './reminders'
|
} from './reminders'
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
import type { SupportedLocale } from '@household/domain'
|
||||||
|
|
||||||
export const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const
|
export const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const
|
||||||
|
|
||||||
export type ReminderType = (typeof REMINDER_TYPES)[number]
|
export type ReminderType = (typeof REMINDER_TYPES)[number]
|
||||||
|
|
||||||
|
export interface ReminderTarget {
|
||||||
|
householdId: string
|
||||||
|
householdName: string
|
||||||
|
telegramChatId: string
|
||||||
|
telegramThreadId: string | null
|
||||||
|
locale: SupportedLocale
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClaimReminderDispatchInput {
|
export interface ClaimReminderDispatchInput {
|
||||||
householdId: string
|
householdId: string
|
||||||
period: string
|
period: string
|
||||||
@@ -16,4 +26,9 @@ export interface ClaimReminderDispatchResult {
|
|||||||
|
|
||||||
export interface ReminderDispatchRepository {
|
export interface ReminderDispatchRepository {
|
||||||
claimReminderDispatch(input: ClaimReminderDispatchInput): Promise<ClaimReminderDispatchResult>
|
claimReminderDispatch(input: ClaimReminderDispatchInput): Promise<ClaimReminderDispatchResult>
|
||||||
|
releaseReminderDispatch(input: {
|
||||||
|
householdId: string
|
||||||
|
period: string
|
||||||
|
reminderType: ReminderType
|
||||||
|
}): Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user