feat(bot): add multi-household reminder delivery

This commit is contained in:
2026-03-09 16:50:57 +04:00
parent 12f33e7aea
commit 16f9981fee
27 changed files with 412 additions and 52 deletions

View File

@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm'
import { and, asc, eq } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import { instantToDate, normalizeSupportedLocale, nowInstant } from '@household/domain'
@@ -11,6 +11,7 @@ import {
type HouseholdTelegramChatRecord,
type HouseholdTopicBindingRecord,
type HouseholdTopicRole,
type ReminderTarget,
type RegisterTelegramHouseholdChatResult
} 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): {
repository: HouseholdConfigurationRepository
close: () => Promise<void>
@@ -364,6 +386,35 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
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) {
const rows = await db
.insert(schema.householdJoinTokens)

View File

@@ -1,3 +1,5 @@
import { and, eq } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import type { ReminderDispatchRepository } from '@household/ports'
@@ -34,6 +36,20 @@ export function createDbReminderDispatchRepository(databaseUrl: string): {
dedupeKey,
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)
)
)
}
}

View File

@@ -59,6 +59,7 @@ function createRepositoryStub() {
getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [],
listReminderTargets: async () => [],
upsertHouseholdJoinToken: async (input) =>
({
householdId: household.householdId,

View File

@@ -55,6 +55,9 @@ function createRepositoryStub() {
async listHouseholdTopicBindings() {
return []
},
async listReminderTargets() {
return []
},
async upsertHouseholdJoinToken(input) {
joinToken = {
householdId: household.householdId,

View File

@@ -93,6 +93,9 @@ function createRepositoryStub() {
async listHouseholdTopicBindings(householdId) {
return bindings.get(householdId) ?? []
},
async listReminderTargets() {
return []
},
async upsertHouseholdJoinToken(input) {
const household = [...households.values()].find(

View File

@@ -37,6 +37,7 @@ function createRepository(): HouseholdConfigurationRepository {
getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [],
listReminderTargets: async () => [],
upsertHouseholdJoinToken: async () => ({
householdId: household.householdId,
householdName: household.householdName,

View File

@@ -28,6 +28,7 @@ function repository(): HouseholdConfigurationRepository {
getHouseholdTopicBinding: async () => null,
findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [],
listReminderTargets: async () => [],
upsertHouseholdJoinToken: async () => ({
householdId: 'household-1',
householdName: 'Kojori House',

View File

@@ -22,6 +22,8 @@ class ReminderDispatchRepositoryStub implements ReminderDispatchRepository {
this.lastClaim = input
return this.nextResult
}
async releaseReminderDispatch(): Promise<void> {}
}
describe('createReminderJobService', () => {

View File

@@ -19,7 +19,7 @@ const server = {
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
HOUSEHOLD_ID: z.string().uuid(),
HOUSEHOLD_ID: z.string().uuid().optional(),
SUPABASE_URL: z.string().url().optional(),
SUPABASE_PUBLISHABLE_KEY: z.string().min(1).optional(),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1).optional(),

View File

@@ -1,4 +1,5 @@
import type { SupportedLocale } from '@household/domain'
import type { ReminderTarget } from './reminders'
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const
@@ -80,6 +81,7 @@ export interface HouseholdConfigurationRepository {
telegramThreadId: string
}): Promise<HouseholdTopicBindingRecord | null>
listHouseholdTopicBindings(householdId: string): Promise<readonly HouseholdTopicBindingRecord[]>
listReminderTargets(): Promise<readonly ReminderTarget[]>
upsertHouseholdJoinToken(input: {
householdId: string
token: string

View File

@@ -3,6 +3,7 @@ export {
type ClaimReminderDispatchInput,
type ClaimReminderDispatchResult,
type ReminderDispatchRepository,
type ReminderTarget,
type ReminderType
} from './reminders'
export {

View File

@@ -1,7 +1,17 @@
import type { SupportedLocale } from '@household/domain'
export const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const
export type ReminderType = (typeof REMINDER_TYPES)[number]
export interface ReminderTarget {
householdId: string
householdName: string
telegramChatId: string
telegramThreadId: string | null
locale: SupportedLocale
}
export interface ClaimReminderDispatchInput {
householdId: string
period: string
@@ -16,4 +26,9 @@ export interface ClaimReminderDispatchResult {
export interface ReminderDispatchRepository {
claimReminderDispatch(input: ClaimReminderDispatchInput): Promise<ClaimReminderDispatchResult>
releaseReminderDispatch(input: {
householdId: string
period: string
reminderType: ReminderType
}): Promise<void>
}