feat(bot): cut over multi-household member flows

This commit is contained in:
2026-03-09 06:14:57 +04:00
parent de86706f4f
commit 7c602900ee
20 changed files with 1068 additions and 163 deletions

View File

@@ -1,7 +1,10 @@
import { describe, expect, mock, test } from 'bun:test' import { describe, expect, mock, test } from 'bun:test'
import type { AnonymousFeedbackService } from '@household/application' import type { AnonymousFeedbackService } from '@household/application'
import type { TelegramPendingActionRepository } from '@household/ports' import type {
HouseholdConfigurationRepository,
TelegramPendingActionRepository
} from '@household/ports'
import { createTelegramBot } from './bot' import { createTelegramBot } from './bot'
import { registerAnonymousFeedback } from './anonymous-feedback' import { registerAnonymousFeedback } from './anonymous-feedback'
@@ -76,6 +79,89 @@ function createPromptRepository(): TelegramPendingActionRepository {
} }
} }
function createHouseholdConfigurationRepository(): HouseholdConfigurationRepository {
return {
registerTelegramHouseholdChat: async () => ({
status: 'existing',
household: {
householdId: 'household-1',
householdName: 'Kojori House',
telegramChatId: '-100222333',
telegramChatType: 'supergroup',
title: 'Kojori House'
}
}),
getTelegramHouseholdChat: async () => ({
householdId: 'household-1',
householdName: 'Kojori House',
telegramChatId: '-100222333',
telegramChatType: 'supergroup',
title: 'Kojori House'
}),
getHouseholdChatByHouseholdId: async () => ({
householdId: 'household-1',
householdName: 'Kojori House',
telegramChatId: '-100222333',
telegramChatType: 'supergroup',
title: 'Kojori House'
}),
bindHouseholdTopic: async (input) => ({
householdId: input.householdId,
role: input.role,
telegramThreadId: input.telegramThreadId,
topicName: input.topicName?.trim() || null
}),
getHouseholdTopicBinding: async (_householdId, role) =>
role === 'feedback'
? {
householdId: 'household-1',
role: 'feedback',
telegramThreadId: '77',
topicName: 'Feedback'
}
: null,
findHouseholdTopicByTelegramContext: async () => null,
listHouseholdTopicBindings: async () => [],
upsertHouseholdJoinToken: async () => ({
householdId: 'household-1',
householdName: 'Kojori House',
token: 'join-token',
createdByTelegramUserId: null
}),
getHouseholdJoinToken: async () => null,
getHouseholdByJoinToken: async () => null,
upsertPendingHouseholdMember: async (input) => ({
householdId: input.householdId,
householdName: 'Kojori House',
telegramUserId: input.telegramUserId,
displayName: input.displayName,
username: input.username?.trim() || null,
languageCode: input.languageCode?.trim() || null
}),
getPendingHouseholdMember: async () => null,
findPendingHouseholdMemberByTelegramUserId: async () => null,
ensureHouseholdMember: async (input) => ({
id: `member-${input.telegramUserId}`,
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
isAdmin: input.isAdmin === true
}),
getHouseholdMember: async () => null,
listHouseholdMembersByTelegramUserId: async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
isAdmin: false
}
],
listPendingHouseholdMembers: async () => [],
approvePendingHouseholdMember: async () => null
}
}
describe('registerAnonymousFeedback', () => { describe('registerAnonymousFeedback', () => {
test('posts accepted feedback into the configured topic', async () => { test('posts accepted feedback into the configured topic', async () => {
const bot = createTelegramBot('000000:test-token') const bot = createTelegramBot('000000:test-token')
@@ -124,10 +210,9 @@ describe('registerAnonymousFeedback', () => {
registerAnonymousFeedback({ registerAnonymousFeedback({
bot, bot,
anonymousFeedbackService, anonymousFeedbackServiceForHousehold: () => anonymousFeedbackService,
promptRepository: createPromptRepository(), householdConfigurationRepository: createHouseholdConfigurationRepository(),
householdChatId: '-100222333', promptRepository: createPromptRepository()
feedbackTopicId: 77
}) })
await bot.handleUpdate( await bot.handleUpdate(
@@ -187,7 +272,7 @@ describe('registerAnonymousFeedback', () => {
registerAnonymousFeedback({ registerAnonymousFeedback({
bot, bot,
anonymousFeedbackService: { anonymousFeedbackServiceForHousehold: () => ({
submit: mock(async () => ({ submit: mock(async () => ({
status: 'accepted' as const, status: 'accepted' as const,
submissionId: 'submission-1', submissionId: 'submission-1',
@@ -195,10 +280,9 @@ describe('registerAnonymousFeedback', () => {
})), })),
markPosted: mock(async () => {}), markPosted: mock(async () => {}),
markFailed: mock(async () => {}) markFailed: mock(async () => {})
}, }),
promptRepository: createPromptRepository(), householdConfigurationRepository: createHouseholdConfigurationRepository(),
householdChatId: '-100222333', promptRepository: createPromptRepository()
feedbackTopicId: 77
}) })
await bot.handleUpdate( await bot.handleUpdate(
@@ -258,14 +342,13 @@ describe('registerAnonymousFeedback', () => {
registerAnonymousFeedback({ registerAnonymousFeedback({
bot, bot,
anonymousFeedbackService: { anonymousFeedbackServiceForHousehold: () => ({
submit, submit,
markPosted: mock(async () => {}), markPosted: mock(async () => {}),
markFailed: mock(async () => {}) markFailed: mock(async () => {})
}, }),
promptRepository: createPromptRepository(), householdConfigurationRepository: createHouseholdConfigurationRepository(),
householdChatId: '-100222333', promptRepository: createPromptRepository()
feedbackTopicId: 77
}) })
await bot.handleUpdate( await bot.handleUpdate(
@@ -341,14 +424,13 @@ describe('registerAnonymousFeedback', () => {
registerAnonymousFeedback({ registerAnonymousFeedback({
bot, bot,
anonymousFeedbackService: { anonymousFeedbackServiceForHousehold: () => ({
submit, submit,
markPosted: mock(async () => {}), markPosted: mock(async () => {}),
markFailed: mock(async () => {}) markFailed: mock(async () => {})
}, }),
promptRepository: createPromptRepository(), householdConfigurationRepository: createHouseholdConfigurationRepository(),
householdChatId: '-100222333', promptRepository: createPromptRepository()
feedbackTopicId: 77
}) })
await bot.handleUpdate( await bot.handleUpdate(

View File

@@ -1,6 +1,9 @@
import type { AnonymousFeedbackService } from '@household/application' import type { AnonymousFeedbackService } from '@household/application'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import type { TelegramPendingActionRepository } from '@household/ports' import type {
HouseholdConfigurationRepository,
TelegramPendingActionRepository
} from '@household/ports'
import type { Bot, Context } from 'grammy' import type { Bot, Context } from 'grammy'
const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const
@@ -98,10 +101,9 @@ async function startPendingAnonymousFeedbackPrompt(
async function submitAnonymousFeedback(options: { async function submitAnonymousFeedback(options: {
ctx: Context ctx: Context
anonymousFeedbackService: AnonymousFeedbackService anonymousFeedbackServiceForHousehold: (householdId: string) => AnonymousFeedbackService
householdConfigurationRepository: HouseholdConfigurationRepository
promptRepository: TelegramPendingActionRepository promptRepository: TelegramPendingActionRepository
householdChatId: string
feedbackTopicId: number
logger?: Logger | undefined logger?: Logger | undefined
rawText: string rawText: string
keepPromptOnValidationFailure?: boolean keepPromptOnValidationFailure?: boolean
@@ -117,7 +119,44 @@ async function submitAnonymousFeedback(options: {
return return
} }
const result = await options.anonymousFeedbackService.submit({ const memberships =
await options.householdConfigurationRepository.listHouseholdMembersByTelegramUserId(
telegramUserId
)
if (memberships.length === 0) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply('You are not a member of this household.')
return
}
if (memberships.length > 1) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply(
'You belong to multiple households. Open the target household from its group until household selection is added.'
)
return
}
const member = memberships[0]!
const householdChat =
await options.householdConfigurationRepository.getHouseholdChatByHouseholdId(member.householdId)
const feedbackTopic = await options.householdConfigurationRepository.getHouseholdTopicBinding(
member.householdId,
'feedback'
)
if (!householdChat || !feedbackTopic) {
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
await options.ctx.reply(
'Anonymous feedback is not configured for your household yet. Ask an admin to run /bind_feedback_topic.'
)
return
}
const anonymousFeedbackService = options.anonymousFeedbackServiceForHousehold(member.householdId)
const result = await anonymousFeedbackService.submit({
telegramUserId, telegramUserId,
rawText: options.rawText, rawText: options.rawText,
telegramChatId, telegramChatId,
@@ -151,17 +190,17 @@ async function submitAnonymousFeedback(options: {
try { try {
const posted = await options.ctx.api.sendMessage( const posted = await options.ctx.api.sendMessage(
options.householdChatId, householdChat.telegramChatId,
feedbackText(result.sanitizedText), feedbackText(result.sanitizedText),
{ {
message_thread_id: options.feedbackTopicId message_thread_id: Number(feedbackTopic.telegramThreadId)
} }
) )
await options.anonymousFeedbackService.markPosted({ await anonymousFeedbackService.markPosted({
submissionId: result.submissionId, submissionId: result.submissionId,
postedChatId: options.householdChatId, postedChatId: householdChat.telegramChatId,
postedThreadId: options.feedbackTopicId.toString(), postedThreadId: feedbackTopic.telegramThreadId,
postedMessageId: posted.message_id.toString() postedMessageId: posted.message_id.toString()
}) })
@@ -173,23 +212,22 @@ async function submitAnonymousFeedback(options: {
{ {
event: 'anonymous_feedback.post_failed', event: 'anonymous_feedback.post_failed',
submissionId: result.submissionId, submissionId: result.submissionId,
householdChatId: options.householdChatId, householdChatId: householdChat.telegramChatId,
feedbackTopicId: options.feedbackTopicId, feedbackTopicId: feedbackTopic.telegramThreadId,
error: message error: message
}, },
'Anonymous feedback posting failed' 'Anonymous feedback posting failed'
) )
await options.anonymousFeedbackService.markFailed(result.submissionId, message) await anonymousFeedbackService.markFailed(result.submissionId, message)
await options.ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.') await options.ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.')
} }
} }
export function registerAnonymousFeedback(options: { export function registerAnonymousFeedback(options: {
bot: Bot bot: Bot
anonymousFeedbackService: AnonymousFeedbackService anonymousFeedbackServiceForHousehold: (householdId: string) => AnonymousFeedbackService
householdConfigurationRepository: HouseholdConfigurationRepository
promptRepository: TelegramPendingActionRepository promptRepository: TelegramPendingActionRepository
householdChatId: string
feedbackTopicId: number
logger?: Logger logger?: Logger
}): void { }): void {
options.bot.command('cancel', async (ctx) => { options.bot.command('cancel', async (ctx) => {
@@ -228,10 +266,9 @@ export function registerAnonymousFeedback(options: {
await submitAnonymousFeedback({ await submitAnonymousFeedback({
ctx, ctx,
anonymousFeedbackService: options.anonymousFeedbackService, anonymousFeedbackServiceForHousehold: options.anonymousFeedbackServiceForHousehold,
householdConfigurationRepository: options.householdConfigurationRepository,
promptRepository: options.promptRepository, promptRepository: options.promptRepository,
householdChatId: options.householdChatId,
feedbackTopicId: options.feedbackTopicId,
logger: options.logger, logger: options.logger,
rawText rawText
}) })
@@ -258,10 +295,9 @@ export function registerAnonymousFeedback(options: {
await submitAnonymousFeedback({ await submitAnonymousFeedback({
ctx, ctx,
anonymousFeedbackService: options.anonymousFeedbackService, anonymousFeedbackServiceForHousehold: options.anonymousFeedbackServiceForHousehold,
householdConfigurationRepository: options.householdConfigurationRepository,
promptRepository: options.promptRepository, promptRepository: options.promptRepository,
householdChatId: options.householdChatId,
feedbackTopicId: options.feedbackTopicId,
logger: options.logger, logger: options.logger,
rawText: ctx.msg.text, rawText: ctx.msg.text,
keepPromptOnValidationFailure: true keepPromptOnValidationFailure: true

View File

@@ -102,18 +102,10 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS) const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS) const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
const purchaseTopicIngestionEnabled = const purchaseTopicIngestionEnabled = databaseUrl !== undefined
databaseUrl !== undefined &&
householdId !== undefined &&
telegramHouseholdChatId !== undefined &&
telegramPurchaseTopicId !== undefined
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined const financeCommandsEnabled = databaseUrl !== undefined
const anonymousFeedbackEnabled = const anonymousFeedbackEnabled = databaseUrl !== undefined
databaseUrl !== undefined &&
householdId !== undefined &&
telegramHouseholdChatId !== undefined &&
telegramFeedbackTopicId !== undefined
const miniAppAuthEnabled = databaseUrl !== undefined const miniAppAuthEnabled = databaseUrl !== undefined
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
const reminderJobsEnabled = const reminderJobsEnabled =

View File

@@ -1,4 +1,5 @@
import type { FinanceCommandService } from '@household/application' import type { FinanceCommandService } from '@household/application'
import type { HouseholdConfigurationRepository } from '@household/ports'
import type { Bot, Context } from 'grammy' import type { Bot, Context } from 'grammy'
function commandArgs(ctx: Context): string[] { function commandArgs(ctx: Context): string[] {
@@ -10,9 +11,39 @@ function commandArgs(ctx: Context): string[] {
return raw.split(/\s+/).filter(Boolean) return raw.split(/\s+/).filter(Boolean)
} }
export function createFinanceCommandsService(financeService: FinanceCommandService): { function isGroupChat(ctx: Context): boolean {
return ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
}
export function createFinanceCommandsService(options: {
householdConfigurationRepository: HouseholdConfigurationRepository
financeServiceForHousehold: (householdId: string) => FinanceCommandService
}): {
register: (bot: Bot) => void register: (bot: Bot) => void
} { } {
async function resolveGroupFinanceService(ctx: Context): Promise<{
service: FinanceCommandService
householdId: string
} | null> {
if (!isGroupChat(ctx)) {
await ctx.reply('Use this command inside a household group.')
return null
}
const household = await options.householdConfigurationRepository.getTelegramHouseholdChat(
ctx.chat!.id.toString()
)
if (!household) {
await ctx.reply('Household is not configured for this chat yet. Run /setup first.')
return null
}
return {
service: options.financeServiceForHousehold(household.householdId),
householdId: household.householdId
}
}
async function requireMember(ctx: Context) { async function requireMember(ctx: Context) {
const telegramUserId = ctx.from?.id?.toString() const telegramUserId = ctx.from?.id?.toString()
if (!telegramUserId) { if (!telegramUserId) {
@@ -20,33 +51,42 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
return null return null
} }
const member = await financeService.getMemberByTelegramUserId(telegramUserId) const scoped = await resolveGroupFinanceService(ctx)
if (!scoped) {
return null
}
const member = await scoped.service.getMemberByTelegramUserId(telegramUserId)
if (!member) { if (!member) {
await ctx.reply('You are not a member of this household.') await ctx.reply('You are not a member of this household.')
return null return null
} }
return member return {
member,
service: scoped.service,
householdId: scoped.householdId
}
} }
async function requireAdmin(ctx: Context) { async function requireAdmin(ctx: Context) {
const member = await requireMember(ctx) const resolved = await requireMember(ctx)
if (!member) { if (!resolved) {
return null return null
} }
if (!member.isAdmin) { if (!resolved.member.isAdmin) {
await ctx.reply('Only household admins can use this command.') await ctx.reply('Only household admins can use this command.')
return null return null
} }
return member return resolved
} }
function register(bot: Bot): void { function register(bot: Bot): void {
bot.command('cycle_open', async (ctx) => { bot.command('cycle_open', async (ctx) => {
const admin = await requireAdmin(ctx) const resolved = await requireAdmin(ctx)
if (!admin) { if (!resolved) {
return return
} }
@@ -57,7 +97,7 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
} }
try { try {
const cycle = await financeService.openCycle(args[0]!, args[1]) const cycle = await resolved.service.openCycle(args[0]!, args[1])
await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`) await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`)
} catch (error) { } catch (error) {
await ctx.reply(`Failed to open cycle: ${(error as Error).message}`) await ctx.reply(`Failed to open cycle: ${(error as Error).message}`)
@@ -65,13 +105,13 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
}) })
bot.command('cycle_close', async (ctx) => { bot.command('cycle_close', async (ctx) => {
const admin = await requireAdmin(ctx) const resolved = await requireAdmin(ctx)
if (!admin) { if (!resolved) {
return return
} }
try { try {
const cycle = await financeService.closeCycle(commandArgs(ctx)[0]) const cycle = await resolved.service.closeCycle(commandArgs(ctx)[0])
if (!cycle) { if (!cycle) {
await ctx.reply('No cycle found to close.') await ctx.reply('No cycle found to close.')
return return
@@ -84,8 +124,8 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
}) })
bot.command('rent_set', async (ctx) => { bot.command('rent_set', async (ctx) => {
const admin = await requireAdmin(ctx) const resolved = await requireAdmin(ctx)
if (!admin) { if (!resolved) {
return return
} }
@@ -96,7 +136,7 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
} }
try { try {
const result = await financeService.setRent(args[0]!, args[1], args[2]) const result = await resolved.service.setRent(args[0]!, args[1], args[2])
if (!result) { if (!result) {
await ctx.reply('No period provided and no open cycle found.') await ctx.reply('No period provided and no open cycle found.')
return return
@@ -111,8 +151,8 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
}) })
bot.command('utility_add', async (ctx) => { bot.command('utility_add', async (ctx) => {
const admin = await requireAdmin(ctx) const resolved = await requireAdmin(ctx)
if (!admin) { if (!resolved) {
return return
} }
@@ -123,7 +163,12 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
} }
try { try {
const result = await financeService.addUtilityBill(args[0]!, args[1]!, admin.id, args[2]) const result = await resolved.service.addUtilityBill(
args[0]!,
args[1]!,
resolved.member.id,
args[2]
)
if (!result) { if (!result) {
await ctx.reply('No open cycle found. Use /cycle_open first.') await ctx.reply('No open cycle found. Use /cycle_open first.')
return return
@@ -138,13 +183,13 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
}) })
bot.command('statement', async (ctx) => { bot.command('statement', async (ctx) => {
const member = await requireMember(ctx) const resolved = await requireMember(ctx)
if (!member) { if (!resolved) {
return return
} }
try { try {
const statement = await financeService.generateStatement(commandArgs(ctx)[0]) const statement = await resolved.service.generateStatement(commandArgs(ctx)[0])
if (!statement) { if (!statement) {
await ctx.reply('No cycle found for statement.') await ctx.reply('No cycle found for statement.')
return return

View File

@@ -0,0 +1,180 @@
import { describe, expect, test } from 'bun:test'
import type {
HouseholdAdminService,
HouseholdOnboardingService,
HouseholdSetupService
} from '@household/application'
import { createTelegramBot } from './bot'
import { buildJoinMiniAppUrl, registerHouseholdSetupCommands } from './household-setup'
function startUpdate(text: string) {
const commandToken = text.split(' ')[0] ?? text
return {
update_id: 2001,
message: {
message_id: 71,
date: Math.floor(Date.now() / 1000),
chat: {
id: 123456,
type: 'private'
},
from: {
id: 123456,
is_bot: false,
first_name: 'Stan'
},
text,
entities: [
{
offset: 0,
length: commandToken.length,
type: 'bot_command'
}
]
}
}
}
function createHouseholdSetupService(): HouseholdSetupService {
return {
async setupGroupChat() {
return {
status: 'rejected',
reason: 'invalid_chat_type'
}
},
async bindTopic() {
return {
status: 'rejected',
reason: 'household_not_found'
}
}
}
}
function createHouseholdAdminService(): HouseholdAdminService {
return {
async listPendingMembers() {
return {
status: 'rejected',
reason: 'household_not_found'
}
},
async approvePendingMember() {
return {
status: 'rejected',
reason: 'pending_not_found'
}
}
}
}
describe('buildJoinMiniAppUrl', () => {
test('adds join token and bot username query parameters', () => {
const url = buildJoinMiniAppUrl(
'https://household-dev-mini-app.example.app',
'kojori_bot',
'join-token'
)
expect(url).toBe('https://household-dev-mini-app.example.app/?join=join-token&bot=kojori_bot')
})
test('returns null when no mini app url is configured', () => {
expect(buildJoinMiniAppUrl(undefined, 'kojori_bot', 'join-token')).toBeNull()
})
})
describe('registerHouseholdSetupCommands', () => {
test('offers an Open mini app button after a DM join request', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: false
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: 123456,
type: 'private'
},
text: 'ok'
}
} as never
})
const householdOnboardingService: HouseholdOnboardingService = {
async ensureHouseholdJoinToken() {
return {
householdId: 'household-1',
householdName: 'Kojori House',
token: 'join-token'
}
},
async getMiniAppAccess() {
return {
status: 'open_from_group'
}
},
async joinHousehold() {
return {
status: 'pending',
household: {
id: 'household-1',
name: 'Kojori House'
}
}
}
}
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(),
householdOnboardingService,
householdAdminService: createHouseholdAdminService(),
miniAppUrl: 'https://miniapp.example.app'
})
await bot.handleUpdate(startUpdate('/start join_join-token') as never)
expect(calls).toHaveLength(1)
expect(calls[0]?.method).toBe('sendMessage')
expect(calls[0]?.payload).toMatchObject({
chat_id: 123456,
text: 'Join request sent for Kojori House. Wait for a household admin to confirm you.',
reply_markup: {
inline_keyboard: [
[
{
text: 'Open mini app',
web_app: {
url: 'https://miniapp.example.app/?join=join-token&bot=household_test_bot'
}
}
]
]
}
})
})
})

View File

@@ -112,11 +112,58 @@ function pendingMembersReply(result: {
} as const } as const
} }
export function buildJoinMiniAppUrl(
miniAppUrl: string | undefined,
botUsername: string | undefined,
joinToken: string
): string | null {
const normalizedMiniAppUrl = miniAppUrl?.trim()
if (!normalizedMiniAppUrl) {
return null
}
const url = new URL(normalizedMiniAppUrl)
url.searchParams.set('join', joinToken)
if (botUsername && botUsername.trim().length > 0) {
url.searchParams.set('bot', botUsername.trim())
}
return url.toString()
}
function miniAppReplyMarkup(
miniAppUrl: string | undefined,
botUsername: string | undefined,
joinToken: string
) {
const webAppUrl = buildJoinMiniAppUrl(miniAppUrl, botUsername, joinToken)
if (!webAppUrl) {
return {}
}
return {
reply_markup: {
inline_keyboard: [
[
{
text: 'Open mini app',
web_app: {
url: webAppUrl
}
}
]
]
}
}
}
export function registerHouseholdSetupCommands(options: { export function registerHouseholdSetupCommands(options: {
bot: Bot bot: Bot
householdSetupService: HouseholdSetupService householdSetupService: HouseholdSetupService
householdOnboardingService: HouseholdOnboardingService householdOnboardingService: HouseholdOnboardingService
householdAdminService: HouseholdAdminService householdAdminService: HouseholdAdminService
miniAppUrl?: string
logger?: Logger logger?: Logger
}): void { }): void {
options.bot.command('start', async (ctx) => { options.bot.command('start', async (ctx) => {
@@ -171,13 +218,15 @@ export function registerHouseholdSetupCommands(options: {
if (result.status === 'active') { if (result.status === 'active') {
await ctx.reply( await ctx.reply(
`You are already an active member. Open the mini app to view ${result.member.displayName}.` `You are already an active member. Open the mini app to view ${result.member.displayName}.`,
miniAppReplyMarkup(options.miniAppUrl, ctx.me.username, joinToken)
) )
return return
} }
await ctx.reply( await ctx.reply(
`Join request sent for ${result.household.name}. Wait for a household admin to confirm you.` `Join request sent for ${result.household.name}. Wait for a household admin to confirm you.`,
miniAppReplyMarkup(options.miniAppUrl, ctx.me.username, joinToken)
) )
}) })

View File

@@ -47,46 +47,60 @@ const shutdownTasks: Array<() => Promise<void>> = []
const householdConfigurationRepositoryClient = runtime.databaseUrl const householdConfigurationRepositoryClient = runtime.databaseUrl
? createDbHouseholdConfigurationRepository(runtime.databaseUrl) ? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
: null : null
const financeRepositoryClient = const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
: null
const financeService = financeRepositoryClient
? createFinanceCommandService(financeRepositoryClient.repository)
: null
const householdOnboardingService = householdConfigurationRepositoryClient const householdOnboardingService = householdConfigurationRepositoryClient
? createHouseholdOnboardingService({ ? createHouseholdOnboardingService({
repository: householdConfigurationRepositoryClient.repository, repository: householdConfigurationRepositoryClient.repository
...(financeRepositoryClient
? {
getMemberByTelegramUserId: financeRepositoryClient.repository.getMemberByTelegramUserId
}
: {})
}) })
: null : null
const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled
? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!)
: null
const telegramPendingActionRepositoryClient = const telegramPendingActionRepositoryClient =
runtime.databaseUrl && runtime.anonymousFeedbackEnabled runtime.databaseUrl && runtime.anonymousFeedbackEnabled
? createDbTelegramPendingActionRepository(runtime.databaseUrl!) ? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
: null : null
const anonymousFeedbackService = anonymousFeedbackRepositoryClient const anonymousFeedbackRepositoryClients = new Map<
? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository) string,
: null ReturnType<typeof createDbAnonymousFeedbackRepository>
>()
const anonymousFeedbackServices = new Map<
string,
ReturnType<typeof createAnonymousFeedbackService>
>()
if (financeRepositoryClient) { function financeServiceForHousehold(householdId: string) {
shutdownTasks.push(financeRepositoryClient.close) const existing = financeServices.get(householdId)
if (existing) {
return existing
}
const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId)
financeRepositoryClients.set(householdId, repositoryClient)
shutdownTasks.push(repositoryClient.close)
const service = createFinanceCommandService(repositoryClient.repository)
financeServices.set(householdId, service)
return service
}
function anonymousFeedbackServiceForHousehold(householdId: string) {
const existing = anonymousFeedbackServices.get(householdId)
if (existing) {
return existing
}
const repositoryClient = createDbAnonymousFeedbackRepository(runtime.databaseUrl!, householdId)
anonymousFeedbackRepositoryClients.set(householdId, repositoryClient)
shutdownTasks.push(repositoryClient.close)
const service = createAnonymousFeedbackService(repositoryClient.repository)
anonymousFeedbackServices.set(householdId, service)
return service
} }
if (householdConfigurationRepositoryClient) { if (householdConfigurationRepositoryClient) {
shutdownTasks.push(householdConfigurationRepositoryClient.close) shutdownTasks.push(householdConfigurationRepositoryClient.close)
} }
if (anonymousFeedbackRepositoryClient) {
shutdownTasks.push(anonymousFeedbackRepositoryClient.close)
}
if (telegramPendingActionRepositoryClient) { if (telegramPendingActionRepositoryClient) {
shutdownTasks.push(telegramPendingActionRepositoryClient.close) shutdownTasks.push(telegramPendingActionRepositoryClient.close)
} }
@@ -120,7 +134,10 @@ if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
} }
if (runtime.financeCommandsEnabled) { if (runtime.financeCommandsEnabled) {
const financeCommands = createFinanceCommandsService(financeService!) const financeCommands = createFinanceCommandsService({
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
financeServiceForHousehold
})
financeCommands.register(bot) financeCommands.register(bot)
} else { } else {
@@ -129,7 +146,7 @@ if (runtime.financeCommandsEnabled) {
event: 'runtime.feature_disabled', event: 'runtime.feature_disabled',
feature: 'finance-commands' feature: 'finance-commands'
}, },
'Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.' 'Finance commands are disabled. Set DATABASE_URL to enable household lookups.'
) )
} }
@@ -143,6 +160,11 @@ if (householdConfigurationRepositoryClient) {
householdConfigurationRepositoryClient.repository householdConfigurationRepositoryClient.repository
), ),
householdOnboardingService: householdOnboardingService!, householdOnboardingService: householdOnboardingService!,
...(runtime.miniAppAllowedOrigins[0]
? {
miniAppUrl: runtime.miniAppAllowedOrigins[0]
}
: {}),
logger: getLogger('household-setup') logger: getLogger('household-setup')
}) })
} else { } else {
@@ -180,13 +202,16 @@ if (!runtime.reminderJobsEnabled) {
) )
} }
if (anonymousFeedbackService) { if (
runtime.anonymousFeedbackEnabled &&
householdConfigurationRepositoryClient &&
telegramPendingActionRepositoryClient
) {
registerAnonymousFeedback({ registerAnonymousFeedback({
bot, bot,
anonymousFeedbackService, anonymousFeedbackServiceForHousehold,
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
promptRepository: telegramPendingActionRepositoryClient!.repository, promptRepository: telegramPendingActionRepositoryClient!.repository,
householdChatId: runtime.telegramHouseholdChatId!,
feedbackTopicId: runtime.telegramFeedbackTopicId!,
logger: getLogger('anonymous-feedback') logger: getLogger('anonymous-feedback')
}) })
} else { } else {
@@ -195,7 +220,7 @@ if (anonymousFeedbackService) {
event: 'runtime.feature_disabled', event: 'runtime.feature_disabled',
feature: 'anonymous-feedback' feature: 'anonymous-feedback'
}, },
'Anonymous feedback is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_FEEDBACK_TOPIC_ID to enable.' 'Anonymous feedback is disabled. Set DATABASE_URL to enable household and topic lookups.'
) )
} }
@@ -219,11 +244,11 @@ const server = createBotWebhookServer({
logger: getLogger('miniapp-auth') logger: getLogger('miniapp-auth')
}) })
: undefined, : undefined,
miniAppDashboard: financeService miniAppDashboard: householdOnboardingService
? createMiniAppDashboardHandler({ ? createMiniAppDashboardHandler({
allowedOrigins: runtime.miniAppAllowedOrigins, allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken, botToken: runtime.telegramBotToken,
financeService, financeServiceForHousehold,
onboardingService: householdOnboardingService!, onboardingService: householdOnboardingService!,
logger: getLogger('miniapp-dashboard') logger: getLogger('miniapp-dashboard')
}) })

View File

@@ -18,6 +18,16 @@ function onboardingRepository(): HouseholdConfigurationRepository {
title: 'Kojori House' title: 'Kojori House'
} }
let joinToken: string | null = 'join-token' let joinToken: string | null = 'join-token'
const members = new Map<
string,
{
id: string
householdId: string
telegramUserId: string
displayName: string
isAdmin: boolean
}
>()
let pending: { let pending: {
householdId: string householdId: string
householdName: string householdName: string
@@ -33,6 +43,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
household household
}), }),
getTelegramHouseholdChat: async () => household, getTelegramHouseholdChat: async () => household,
getHouseholdChatByHouseholdId: async () => household,
bindHouseholdTopic: async (input) => bindHouseholdTopic: async (input) =>
({ ({
householdId: input.householdId, householdId: input.householdId,
@@ -72,13 +83,22 @@ function onboardingRepository(): HouseholdConfigurationRepository {
}, },
getPendingHouseholdMember: async () => pending, getPendingHouseholdMember: async () => pending,
findPendingHouseholdMemberByTelegramUserId: async () => pending, findPendingHouseholdMemberByTelegramUserId: async () => pending,
ensureHouseholdMember: async (input) => ({ ensureHouseholdMember: async (input) => {
const member = {
id: `member-${input.telegramUserId}`,
householdId: household.householdId, householdId: household.householdId,
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}), }
getHouseholdMember: async () => null, members.set(input.telegramUserId, member)
return member
},
getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null,
listHouseholdMembersByTelegramUserId: async (telegramUserId) => {
const member = members.get(telegramUserId)
return member ? [member] : []
},
listPendingHouseholdMembers: async () => (pending ? [pending] : []), listPendingHouseholdMembers: async () => (pending ? [pending] : []),
approvePendingHouseholdMember: async (input) => { approvePendingHouseholdMember: async (input) => {
if (!pending || pending.telegramUserId !== input.telegramUserId) { if (!pending || pending.telegramUserId !== input.telegramUserId) {
@@ -86,11 +106,13 @@ function onboardingRepository(): HouseholdConfigurationRepository {
} }
const member = { const member = {
id: `member-${pending.telegramUserId}`,
householdId: household.householdId, householdId: household.householdId,
telegramUserId: pending.telegramUserId, telegramUserId: pending.telegramUserId,
displayName: pending.displayName, displayName: pending.displayName,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
} }
members.set(pending.telegramUserId, member)
pending = null pending = null
return member return member
} }
@@ -100,17 +122,18 @@ function onboardingRepository(): HouseholdConfigurationRepository {
describe('createMiniAppAuthHandler', () => { describe('createMiniAppAuthHandler', () => {
test('returns an authorized session for a household member', async () => { test('returns an authorized session for a household member', async () => {
const authDate = Math.floor(Date.now() / 1000) const authDate = Math.floor(Date.now() / 1000)
const auth = createMiniAppAuthHandler({ const repository = onboardingRepository()
allowedOrigins: ['http://localhost:5173'], await repository.ensureHouseholdMember({
botToken: 'test-bot-token', householdId: 'household-1',
onboardingService: createHouseholdOnboardingService({
repository: onboardingRepository(),
getMemberByTelegramUserId: async () => ({
id: 'member-1',
telegramUserId: '123456', telegramUserId: '123456',
displayName: 'Stan', displayName: 'Stan',
isAdmin: true isAdmin: true
}) })
const auth = createMiniAppAuthHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
onboardingService: createHouseholdOnboardingService({
repository
}) })
}) })

View File

@@ -98,6 +98,7 @@ export interface MiniAppSessionResult {
authorized: boolean authorized: boolean
member?: { member?: {
id: string id: string
householdId: string
displayName: string displayName: string
isAdmin: boolean isAdmin: boolean
} }

View File

@@ -84,6 +84,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
household household
}), }),
getTelegramHouseholdChat: async () => household, getTelegramHouseholdChat: async () => household,
getHouseholdChatByHouseholdId: async () => household,
bindHouseholdTopic: async (input) => bindHouseholdTopic: async (input) =>
({ ({
householdId: input.householdId, householdId: input.householdId,
@@ -113,12 +114,14 @@ function onboardingRepository(): HouseholdConfigurationRepository {
getPendingHouseholdMember: async () => null, getPendingHouseholdMember: async () => null,
findPendingHouseholdMemberByTelegramUserId: async () => null, findPendingHouseholdMemberByTelegramUserId: async () => null,
ensureHouseholdMember: async (input) => ({ ensureHouseholdMember: async (input) => ({
id: `member-${input.telegramUserId}`,
householdId: household.householdId, householdId: household.householdId,
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
}), }),
getHouseholdMember: async () => null, getHouseholdMember: async () => null,
listHouseholdMembersByTelegramUserId: async () => [],
listPendingHouseholdMembers: async () => [], listPendingHouseholdMembers: async () => [],
approvePendingHouseholdMember: async () => null approvePendingHouseholdMember: async () => null
} }
@@ -135,14 +138,23 @@ describe('createMiniAppDashboardHandler', () => {
isAdmin: true isAdmin: true
}) })
) )
const householdRepository = onboardingRepository()
householdRepository.listHouseholdMembersByTelegramUserId = async () => [
{
id: 'member-1',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
isAdmin: true
}
]
const dashboard = createMiniAppDashboardHandler({ const dashboard = createMiniAppDashboardHandler({
allowedOrigins: ['http://localhost:5173'], allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token', botToken: 'test-bot-token',
financeService, financeServiceForHousehold: () => financeService,
onboardingService: createHouseholdOnboardingService({ onboardingService: createHouseholdOnboardingService({
repository: onboardingRepository(), repository: householdRepository
getMemberByTelegramUserId: financeService.getMemberByTelegramUserId
}) })
}) })
@@ -202,14 +214,23 @@ describe('createMiniAppDashboardHandler', () => {
isAdmin: true isAdmin: true
}) })
) )
const householdRepository = onboardingRepository()
householdRepository.listHouseholdMembersByTelegramUserId = async () => [
{
id: 'member-1',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
isAdmin: true
}
]
const dashboard = createMiniAppDashboardHandler({ const dashboard = createMiniAppDashboardHandler({
allowedOrigins: ['http://localhost:5173'], allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token', botToken: 'test-bot-token',
financeService, financeServiceForHousehold: () => financeService,
onboardingService: createHouseholdOnboardingService({ onboardingService: createHouseholdOnboardingService({
repository: onboardingRepository(), repository: householdRepository
getMemberByTelegramUserId: financeService.getMemberByTelegramUserId
}) })
}) })

View File

@@ -12,7 +12,7 @@ import {
export function createMiniAppDashboardHandler(options: { export function createMiniAppDashboardHandler(options: {
allowedOrigins: readonly string[] allowedOrigins: readonly string[]
botToken: string botToken: string
financeService: FinanceCommandService financeServiceForHousehold: (householdId: string) => FinanceCommandService
onboardingService: HouseholdOnboardingService onboardingService: HouseholdOnboardingService
logger?: Logger logger?: Logger
}): { }): {
@@ -62,7 +62,17 @@ export function createMiniAppDashboardHandler(options: {
) )
} }
const dashboard = await options.financeService.generateDashboard() if (!session.member) {
return miniAppJsonResponse(
{ ok: false, error: 'Authenticated session is missing member context' },
500,
origin
)
}
const dashboard = await options
.financeServiceForHousehold(session.member.householdId)
.generateDashboard()
if (!dashboard) { if (!dashboard) {
return miniAppJsonResponse( return miniAppJsonResponse(
{ ok: false, error: 'No billing cycle available' }, { ok: false, error: 'No billing cycle available' },

View File

@@ -1,8 +1,13 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { createTelegramBot } from './bot'
import { import {
buildPurchaseAcknowledgement,
extractPurchaseTopicCandidate, extractPurchaseTopicCandidate,
registerPurchaseTopicIngestion,
resolveConfiguredPurchaseTopicRecord, resolveConfiguredPurchaseTopicRecord,
type PurchaseMessageIngestionRepository,
type PurchaseTopicCandidate type PurchaseTopicCandidate
} from './purchase-topic-ingestion' } from './purchase-topic-ingestion'
@@ -25,6 +30,39 @@ function candidate(overrides: Partial<PurchaseTopicCandidate> = {}): PurchaseTop
} }
} }
function purchaseUpdate(text: string) {
const commandToken = text.split(' ')[0] ?? text
return {
update_id: 1001,
message: {
message_id: 55,
date: Math.floor(Date.now() / 1000),
message_thread_id: 777,
is_topic_message: true,
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
from: {
id: 10002,
is_bot: false,
first_name: 'Mia'
},
text,
entities: text.startsWith('/')
? [
{
offset: 0,
length: commandToken.length,
type: 'bot_command'
}
]
: []
}
}
}
describe('extractPurchaseTopicCandidate', () => { describe('extractPurchaseTopicCandidate', () => {
test('returns record when message belongs to configured topic', () => { test('returns record when message belongs to configured topic', () => {
const record = extractPurchaseTopicCandidate(candidate(), config) const record = extractPurchaseTopicCandidate(candidate(), config)
@@ -86,3 +124,169 @@ describe('resolveConfiguredPurchaseTopicRecord', () => {
expect(record).toBeNull() expect(record).toBeNull()
}) })
}) })
describe('buildPurchaseAcknowledgement', () => {
test('returns parsed acknowledgement with amount summary', () => {
const result = buildPurchaseAcknowledgement({
status: 'created',
processingStatus: 'parsed',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'rules'
})
expect(result).toBe('Recorded purchase: toilet paper - 30.00 GEL')
})
test('returns review acknowledgement when parsing needs review', () => {
const result = buildPurchaseAcknowledgement({
status: 'created',
processingStatus: 'needs_review',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'shared purchase',
parserConfidence: 78,
parserMode: 'rules'
})
expect(result).toBe('Saved for review: shared purchase - 30.00 GEL')
})
test('returns parse failure acknowledgement without guessed values', () => {
const result = buildPurchaseAcknowledgement({
status: 'created',
processingStatus: 'parse_failed',
parsedAmountMinor: null,
parsedCurrency: null,
parsedItemDescription: null,
parserConfidence: null,
parserMode: null
})
expect(result).toBe("Saved for review: I couldn't parse this purchase yet.")
})
test('does not acknowledge duplicates', () => {
expect(
buildPurchaseAcknowledgement({
status: 'duplicate'
})
).toBeNull()
})
})
describe('registerPurchaseTopicIngestion', () => {
test('replies in-topic after a parsed purchase is recorded', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: false
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async save() {
return {
status: 'created',
processingStatus: 'parsed',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'rules'
}
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
expect(calls).toHaveLength(1)
expect(calls[0]?.method).toBe('sendMessage')
expect(calls[0]?.payload).toMatchObject({
chat_id: Number(config.householdChatId),
reply_parameters: {
message_id: 55
},
text: 'Recorded purchase: toilet paper - 30.00 GEL'
})
})
test('does not reply for duplicate deliveries', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: false
}
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async save() {
return {
status: 'duplicate'
}
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
expect(calls).toHaveLength(0)
})
})

View File

@@ -1,4 +1,5 @@
import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application' import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application'
import { Money } from '@household/domain'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import type { Bot, Context } from 'grammy' import type { Bot, Context } from 'grammy'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
@@ -30,11 +31,27 @@ export interface PurchaseTopicRecord extends PurchaseTopicCandidate {
householdId: string householdId: string
} }
export type PurchaseMessageProcessingStatus = 'parsed' | 'needs_review' | 'parse_failed'
export type PurchaseMessageIngestionResult =
| {
status: 'duplicate'
}
| {
status: 'created'
processingStatus: PurchaseMessageProcessingStatus
parsedAmountMinor: bigint | null
parsedCurrency: 'GEL' | 'USD' | null
parsedItemDescription: string | null
parserConfidence: number | null
parserMode: 'rules' | 'llm' | null
}
export interface PurchaseMessageIngestionRepository { export interface PurchaseMessageIngestionRepository {
save( save(
record: PurchaseTopicRecord, record: PurchaseTopicRecord,
llmFallback?: PurchaseParserLlmFallback llmFallback?: PurchaseParserLlmFallback
): Promise<'created' | 'duplicate'> ): Promise<PurchaseMessageIngestionResult>
} }
export function extractPurchaseTopicCandidate( export function extractPurchaseTopicCandidate(
@@ -172,7 +189,21 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
}) })
.returning({ id: schema.purchaseMessages.id }) .returning({ id: schema.purchaseMessages.id })
return inserted.length > 0 ? 'created' : 'duplicate' if (inserted.length === 0) {
return {
status: 'duplicate'
}
}
return {
status: 'created',
processingStatus,
parsedAmountMinor: parsed?.amountMinor ?? null,
parsedCurrency: parsed?.currency ?? null,
parsedItemDescription: parsed?.itemDescription ?? null,
parserConfidence: parsed?.confidence ?? null,
parserMode: parsed?.parserMode ?? null
}
} }
} }
@@ -184,6 +215,51 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
} }
} }
function formatPurchaseSummary(
result: Extract<PurchaseMessageIngestionResult, { status: 'created' }>
): string {
if (
result.parsedAmountMinor === null ||
result.parsedCurrency === null ||
result.parsedItemDescription === null
) {
return 'shared purchase'
}
const amount = Money.fromMinor(result.parsedAmountMinor, result.parsedCurrency)
return `${result.parsedItemDescription} - ${amount.toMajorString()} ${result.parsedCurrency}`
}
export function buildPurchaseAcknowledgement(
result: PurchaseMessageIngestionResult
): string | null {
if (result.status === 'duplicate') {
return null
}
switch (result.processingStatus) {
case 'parsed':
return `Recorded purchase: ${formatPurchaseSummary(result)}`
case 'needs_review':
return `Saved for review: ${formatPurchaseSummary(result)}`
case 'parse_failed':
return "Saved for review: I couldn't parse this purchase yet."
}
}
async function replyToPurchaseMessage(ctx: Context, text: string): Promise<void> {
const message = ctx.msg
if (!message) {
return
}
await ctx.reply(text, {
reply_parameters: {
message_id: message.message_id
}
})
}
function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null { function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
const message = ctx.message const message = ctx.message
if (!message || !('text' in message)) { if (!message || !('text' in message)) {
@@ -244,11 +320,13 @@ export function registerPurchaseTopicIngestion(
try { try {
const status = await repository.save(record, options.llmFallback) const status = await repository.save(record, options.llmFallback)
const acknowledgement = buildPurchaseAcknowledgement(status)
if (status === 'created') { if (status.status === 'created') {
options.logger?.info( options.logger?.info(
{ {
event: 'purchase.ingested', event: 'purchase.ingested',
processingStatus: status.processingStatus,
chatId: record.chatId, chatId: record.chatId,
threadId: record.threadId, threadId: record.threadId,
messageId: record.messageId, messageId: record.messageId,
@@ -258,6 +336,10 @@ export function registerPurchaseTopicIngestion(
'Purchase topic message ingested' 'Purchase topic message ingested'
) )
} }
if (acknowledgement) {
await replyToPurchaseMessage(ctx, acknowledgement)
}
} catch (error) { } catch (error) {
options.logger?.error( options.logger?.error(
{ {
@@ -308,12 +390,14 @@ export function registerConfiguredPurchaseTopicIngestion(
try { try {
const status = await repository.save(record, options.llmFallback) const status = await repository.save(record, options.llmFallback)
const acknowledgement = buildPurchaseAcknowledgement(status)
if (status === 'created') { if (status.status === 'created') {
options.logger?.info( options.logger?.info(
{ {
event: 'purchase.ingested', event: 'purchase.ingested',
householdId: record.householdId, householdId: record.householdId,
processingStatus: status.processingStatus,
chatId: record.chatId, chatId: record.chatId,
threadId: record.threadId, threadId: record.threadId,
messageId: record.messageId, messageId: record.messageId,
@@ -323,6 +407,10 @@ export function registerConfiguredPurchaseTopicIngestion(
'Purchase topic message ingested' 'Purchase topic message ingested'
) )
} }
if (acknowledgement) {
await replyToPurchaseMessage(ctx, acknowledgement)
}
} catch (error) { } catch (error) {
options.logger?.error( options.logger?.error(
{ {

View File

@@ -86,12 +86,14 @@ function toHouseholdPendingMemberRecord(row: {
} }
function toHouseholdMemberRecord(row: { function toHouseholdMemberRecord(row: {
id: string
householdId: string householdId: string
telegramUserId: string telegramUserId: string
displayName: string displayName: string
isAdmin: number isAdmin: number
}): HouseholdMemberRecord { }): HouseholdMemberRecord {
return { return {
id: row.id,
householdId: row.householdId, householdId: row.householdId,
telegramUserId: row.telegramUserId, telegramUserId: row.telegramUserId,
displayName: row.displayName, displayName: row.displayName,
@@ -219,6 +221,27 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
return row ? toHouseholdTelegramChatRecord(row) : null return row ? toHouseholdTelegramChatRecord(row) : null
}, },
async getHouseholdChatByHouseholdId(householdId) {
const rows = await db
.select({
householdId: schema.householdTelegramChats.householdId,
householdName: schema.households.name,
telegramChatId: schema.householdTelegramChats.telegramChatId,
telegramChatType: schema.householdTelegramChats.telegramChatType,
title: schema.householdTelegramChats.title
})
.from(schema.householdTelegramChats)
.innerJoin(
schema.households,
eq(schema.householdTelegramChats.householdId, schema.households.id)
)
.where(eq(schema.householdTelegramChats.householdId, householdId))
.limit(1)
const row = rows[0]
return row ? toHouseholdTelegramChatRecord(row) : null
},
async bindHouseholdTopic(input) { async bindHouseholdTopic(input) {
const rows = await db const rows = await db
.insert(schema.householdTopicBindings) .insert(schema.householdTopicBindings)
@@ -535,6 +558,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
} }
}) })
.returning({ .returning({
id: schema.members.id,
householdId: schema.members.householdId, householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,
@@ -552,6 +576,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
async getHouseholdMember(householdId, telegramUserId) { async getHouseholdMember(householdId, telegramUserId) {
const rows = await db const rows = await db
.select({ .select({
id: schema.members.id,
householdId: schema.members.householdId, householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,
@@ -570,6 +595,22 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
return row ? toHouseholdMemberRecord(row) : null return row ? toHouseholdMemberRecord(row) : null
}, },
async listHouseholdMembersByTelegramUserId(telegramUserId) {
const rows = await db
.select({
id: schema.members.id,
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
isAdmin: schema.members.isAdmin
})
.from(schema.members)
.where(eq(schema.members.telegramUserId, telegramUserId))
.orderBy(schema.members.householdId, schema.members.displayName)
return rows.map(toHouseholdMemberRecord)
},
async listPendingHouseholdMembers(householdId) { async listPendingHouseholdMembers(householdId) {
const rows = await db const rows = await db
.select({ .select({
@@ -640,6 +681,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
} }
}) })
.returning({ .returning({
id: schema.members.id,
householdId: schema.members.householdId, householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId, telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName, displayName: schema.members.displayName,

View File

@@ -23,6 +23,7 @@ function createRepositoryStub() {
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>() const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
members.set('1', { members.set('1', {
id: 'member-1',
householdId: household.householdId, householdId: household.householdId,
telegramUserId: '1', telegramUserId: '1',
displayName: 'Stan', displayName: 'Stan',
@@ -43,6 +44,7 @@ function createRepositoryStub() {
household household
}), }),
getTelegramHouseholdChat: async () => household, getTelegramHouseholdChat: async () => household,
getHouseholdChatByHouseholdId: async () => household,
bindHouseholdTopic: async (input) => bindHouseholdTopic: async (input) =>
({ ({
householdId: input.householdId, householdId: input.householdId,
@@ -80,6 +82,7 @@ function createRepositoryStub() {
pendingMembers.get(telegramUserId) ?? null, pendingMembers.get(telegramUserId) ?? null,
ensureHouseholdMember: async (input) => { ensureHouseholdMember: async (input) => {
const record: HouseholdMemberRecord = { const record: HouseholdMemberRecord = {
id: `member-${input.telegramUserId}`,
householdId: input.householdId, householdId: input.householdId,
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
@@ -89,6 +92,8 @@ function createRepositoryStub() {
return record return record
}, },
getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null, getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null,
listHouseholdMembersByTelegramUserId: async (telegramUserId) =>
[...members.values()].filter((member) => member.telegramUserId === telegramUserId),
listPendingHouseholdMembers: async () => [...pendingMembers.values()], listPendingHouseholdMembers: async () => [...pendingMembers.values()],
approvePendingHouseholdMember: async (input) => { approvePendingHouseholdMember: async (input) => {
const pending = pendingMembers.get(input.telegramUserId) const pending = pendingMembers.get(input.telegramUserId)
@@ -99,6 +104,7 @@ function createRepositoryStub() {
pendingMembers.delete(input.telegramUserId) pendingMembers.delete(input.telegramUserId)
const member: HouseholdMemberRecord = { const member: HouseholdMemberRecord = {
id: `member-${pending.telegramUserId}`,
householdId: pending.householdId, householdId: pending.householdId,
telegramUserId: pending.telegramUserId, telegramUserId: pending.telegramUserId,
displayName: pending.displayName, displayName: pending.displayName,
@@ -170,6 +176,7 @@ describe('createHouseholdAdminService', () => {
status: 'approved', status: 'approved',
householdName: 'Kojori House', householdName: 'Kojori House',
member: { member: {
id: 'member-2',
householdId: 'household-1', householdId: 'household-1',
telegramUserId: '2', telegramUserId: '2',
displayName: 'Alice', displayName: 'Alice',

View File

@@ -1,8 +1,8 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { import type {
FinanceMemberRecord,
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
HouseholdMemberRecord,
HouseholdJoinTokenRecord, HouseholdJoinTokenRecord,
HouseholdPendingMemberRecord, HouseholdPendingMemberRecord,
HouseholdTelegramChatRecord, HouseholdTelegramChatRecord,
@@ -21,6 +21,7 @@ function createRepositoryStub() {
} }
let joinToken: HouseholdJoinTokenRecord | null = null let joinToken: HouseholdJoinTokenRecord | null = null
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>() const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
const members = new Map<string, HouseholdMemberRecord>()
const repository: HouseholdConfigurationRepository = { const repository: HouseholdConfigurationRepository = {
async registerTelegramHouseholdChat() { async registerTelegramHouseholdChat() {
@@ -32,6 +33,9 @@ function createRepositoryStub() {
async getTelegramHouseholdChat() { async getTelegramHouseholdChat() {
return household return household
}, },
async getHouseholdChatByHouseholdId() {
return household
},
async bindHouseholdTopic(input) { async bindHouseholdTopic(input) {
const binding: HouseholdTopicBindingRecord = { const binding: HouseholdTopicBindingRecord = {
householdId: input.householdId, householdId: input.householdId,
@@ -84,15 +88,22 @@ function createRepositoryStub() {
return pendingMembers.get(telegramUserId) ?? null return pendingMembers.get(telegramUserId) ?? null
}, },
async ensureHouseholdMember(input) { async ensureHouseholdMember(input) {
return { const member = {
id: `member-${input.telegramUserId}`,
householdId: input.householdId, householdId: input.householdId,
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
isAdmin: input.isAdmin === true isAdmin: input.isAdmin === true
} }
members.set(input.telegramUserId, member)
return member
}, },
async getHouseholdMember() { async getHouseholdMember(_householdId, telegramUserId) {
return null return members.get(telegramUserId) ?? null
},
async listHouseholdMembersByTelegramUserId(telegramUserId) {
const member = members.get(telegramUserId)
return member ? [member] : []
}, },
async listPendingHouseholdMembers() { async listPendingHouseholdMembers() {
return [...pendingMembers.values()] return [...pendingMembers.values()]
@@ -106,6 +117,7 @@ function createRepositoryStub() {
pendingMembers.delete(input.telegramUserId) pendingMembers.delete(input.telegramUserId)
return { return {
id: `member-${pending.telegramUserId}`,
householdId: pending.householdId, householdId: pending.householdId,
telegramUserId: pending.telegramUserId, telegramUserId: pending.telegramUserId,
displayName: pending.displayName, displayName: pending.displayName,
@@ -209,17 +221,16 @@ describe('createHouseholdOnboardingService', () => {
}) })
}) })
test('returns active when the user is already a finance member', async () => { test('returns active when the user is already a household member', async () => {
const { repository } = createRepositoryStub() const { repository } = createRepositoryStub()
const member: FinanceMemberRecord = { await repository.ensureHouseholdMember({
id: 'member-1', householdId: 'household-1',
telegramUserId: '42', telegramUserId: '42',
displayName: 'Stan', displayName: 'Stan',
isAdmin: true isAdmin: true
} })
const service = createHouseholdOnboardingService({ const service = createHouseholdOnboardingService({
repository, repository
getMemberByTelegramUserId: async () => member
}) })
const access = await service.getMiniAppAccess({ const access = await service.getMiniAppAccess({
@@ -233,10 +244,49 @@ describe('createHouseholdOnboardingService', () => {
expect(access).toEqual({ expect(access).toEqual({
status: 'active', status: 'active',
member: { member: {
id: 'member-1', id: 'member-42',
householdId: 'household-1',
displayName: 'Stan', displayName: 'Stan',
isAdmin: true isAdmin: true
} }
}) })
}) })
test('returns open_from_group when user belongs to multiple households and no join token is provided', async () => {
const { repository } = createRepositoryStub()
const member: HouseholdMemberRecord = {
id: 'member-1',
householdId: 'household-1',
telegramUserId: '42',
displayName: 'Stan',
isAdmin: true
}
const service = createHouseholdOnboardingService({ repository })
const duplicateRepository = repository as HouseholdConfigurationRepository & {
listHouseholdMembersByTelegramUserId: (
telegramUserId: string
) => Promise<readonly HouseholdMemberRecord[]>
}
duplicateRepository.listHouseholdMembersByTelegramUserId = async () => [
member,
{
id: 'member-2',
householdId: 'household-2',
telegramUserId: '42',
displayName: 'Stan elsewhere',
isAdmin: false
}
]
const access = await service.getMiniAppAccess({
identity: {
telegramUserId: '42',
displayName: 'Stan'
}
})
expect(access).toEqual({
status: 'open_from_group'
})
})
}) })

View File

@@ -1,6 +1,6 @@
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import type { FinanceMemberRecord, HouseholdConfigurationRepository } from '@household/ports' import type { HouseholdConfigurationRepository, HouseholdMemberRecord } from '@household/ports'
export interface HouseholdOnboardingIdentity { export interface HouseholdOnboardingIdentity {
telegramUserId: string telegramUserId: string
@@ -14,6 +14,7 @@ export type HouseholdMiniAppAccess =
status: 'active' status: 'active'
member: { member: {
id: string id: string
householdId: string
displayName: string displayName: string
isAdmin: boolean isAdmin: boolean
} }
@@ -58,6 +59,7 @@ export interface HouseholdOnboardingService {
status: 'active' status: 'active'
member: { member: {
id: string id: string
householdId: string
displayName: string displayName: string
isAdmin: boolean isAdmin: boolean
} }
@@ -68,13 +70,15 @@ export interface HouseholdOnboardingService {
> >
} }
function toMember(member: FinanceMemberRecord): { function toMember(member: HouseholdMemberRecord): {
id: string id: string
householdId: string
displayName: string displayName: string
isAdmin: boolean isAdmin: boolean
} { } {
return { return {
id: member.id, id: member.id,
householdId: member.householdId,
displayName: member.displayName, displayName: member.displayName,
isAdmin: member.isAdmin isAdmin: member.isAdmin
} }
@@ -86,7 +90,6 @@ function generateJoinToken(): string {
export function createHouseholdOnboardingService(options: { export function createHouseholdOnboardingService(options: {
repository: HouseholdConfigurationRepository repository: HouseholdConfigurationRepository
getMemberByTelegramUserId?: (telegramUserId: string) => Promise<FinanceMemberRecord | null>
tokenFactory?: () => string tokenFactory?: () => string
}): HouseholdOnboardingService { }): HouseholdOnboardingService {
const createToken = options.tokenFactory ?? generateJoinToken const createToken = options.tokenFactory ?? generateJoinToken
@@ -121,14 +124,26 @@ export function createHouseholdOnboardingService(options: {
}, },
async getMiniAppAccess(input) { async getMiniAppAccess(input) {
const activeMember = options.getMemberByTelegramUserId const activeMemberships = await options.repository.listHouseholdMembersByTelegramUserId(
? await options.getMemberByTelegramUserId(input.identity.telegramUserId) input.identity.telegramUserId
)
const requestedHousehold =
input.joinToken !== undefined
? await options.repository.getHouseholdByJoinToken(input.joinToken)
: null : null
const matchingActiveMember =
requestedHousehold === null
? activeMemberships.length === 1
? activeMemberships[0]!
: null
: (activeMemberships.find(
(member) => member.householdId === requestedHousehold.householdId
) ?? null)
if (activeMember) { if (matchingActiveMember) {
return { return {
status: 'active', status: 'active',
member: toMember(activeMember) member: toMember(matchingActiveMember)
} }
} }
@@ -151,7 +166,7 @@ export function createHouseholdOnboardingService(options: {
} }
} }
const household = await options.repository.getHouseholdByJoinToken(input.joinToken) const household = requestedHousehold
if (!household) { if (!household) {
return { return {
status: 'open_from_group' status: 'open_from_group'
@@ -189,9 +204,9 @@ export function createHouseholdOnboardingService(options: {
} }
} }
const activeMember = options.getMemberByTelegramUserId const activeMember = (
? await options.getMemberByTelegramUserId(input.identity.telegramUserId) await options.repository.listHouseholdMembersByTelegramUserId(input.identity.telegramUserId)
: null ).find((member) => member.householdId === household.householdId)
if (activeMember) { if (activeMember) {
return { return {

View File

@@ -53,6 +53,12 @@ function createRepositoryStub() {
return households.get(telegramChatId) ?? null return households.get(telegramChatId) ?? null
}, },
async getHouseholdChatByHouseholdId(householdId) {
return (
[...households.values()].find((household) => household.householdId === householdId) ?? null
)
},
async bindHouseholdTopic(input) { async bindHouseholdTopic(input) {
const next: HouseholdTopicBindingRecord = { const next: HouseholdTopicBindingRecord = {
householdId: input.householdId, householdId: input.householdId,
@@ -156,6 +162,7 @@ function createRepositoryStub() {
const key = `${input.householdId}:${input.telegramUserId}` const key = `${input.householdId}:${input.telegramUserId}`
const existing = members.get(key) const existing = members.get(key)
const next: HouseholdMemberRecord = { const next: HouseholdMemberRecord = {
id: existing?.id ?? `member-${input.telegramUserId}`,
householdId: input.householdId, householdId: input.householdId,
telegramUserId: input.telegramUserId, telegramUserId: input.telegramUserId,
displayName: input.displayName, displayName: input.displayName,
@@ -169,6 +176,10 @@ function createRepositoryStub() {
return members.get(`${householdId}:${telegramUserId}`) ?? null return members.get(`${householdId}:${telegramUserId}`) ?? null
}, },
async listHouseholdMembersByTelegramUserId(telegramUserId) {
return [...members.values()].filter((member) => member.telegramUserId === telegramUserId)
},
async listPendingHouseholdMembers(householdId) { async listPendingHouseholdMembers(householdId) {
return [...pendingMembers.values()].filter((entry) => entry.householdId === householdId) return [...pendingMembers.values()].filter((entry) => entry.householdId === householdId)
}, },
@@ -183,6 +194,7 @@ function createRepositoryStub() {
pendingMembers.delete(key) pendingMembers.delete(key)
const member: HouseholdMemberRecord = { const member: HouseholdMemberRecord = {
id: `member-${pending.telegramUserId}`,
householdId: pending.householdId, householdId: pending.householdId,
telegramUserId: pending.telegramUserId, telegramUserId: pending.telegramUserId,
displayName: pending.displayName, displayName: pending.displayName,
@@ -220,6 +232,7 @@ describe('createHouseholdSetupService', () => {
expect(result.household.telegramChatId).toBe('-100123') expect(result.household.telegramChatId).toBe('-100123')
const admin = await repository.getHouseholdMember(result.household.householdId, '42') const admin = await repository.getHouseholdMember(result.household.householdId, '42')
expect(admin).toEqual({ expect(admin).toEqual({
id: 'member-42',
householdId: result.household.householdId, householdId: result.household.householdId,
telegramUserId: '42', telegramUserId: '42',
displayName: 'Stan', displayName: 'Stan',

View File

@@ -34,6 +34,7 @@ export interface HouseholdPendingMemberRecord {
} }
export interface HouseholdMemberRecord { export interface HouseholdMemberRecord {
id: string
householdId: string householdId: string
telegramUserId: string telegramUserId: string
displayName: string displayName: string
@@ -57,6 +58,7 @@ export interface HouseholdConfigurationRepository {
input: RegisterTelegramHouseholdChatInput input: RegisterTelegramHouseholdChatInput
): Promise<RegisterTelegramHouseholdChatResult> ): Promise<RegisterTelegramHouseholdChatResult>
getTelegramHouseholdChat(telegramChatId: string): Promise<HouseholdTelegramChatRecord | null> getTelegramHouseholdChat(telegramChatId: string): Promise<HouseholdTelegramChatRecord | null>
getHouseholdChatByHouseholdId(householdId: string): Promise<HouseholdTelegramChatRecord | null>
bindHouseholdTopic(input: { bindHouseholdTopic(input: {
householdId: string householdId: string
role: HouseholdTopicRole role: HouseholdTopicRole
@@ -103,6 +105,9 @@ export interface HouseholdConfigurationRepository {
householdId: string, householdId: string,
telegramUserId: string telegramUserId: string
): Promise<HouseholdMemberRecord | null> ): Promise<HouseholdMemberRecord | null>
listHouseholdMembersByTelegramUserId(
telegramUserId: string
): Promise<readonly HouseholdMemberRecord[]>
listPendingHouseholdMembers(householdId: string): Promise<readonly HouseholdPendingMemberRecord[]> listPendingHouseholdMembers(householdId: string): Promise<readonly HouseholdPendingMemberRecord[]>
approvePendingHouseholdMember(input: { approvePendingHouseholdMember(input: {
householdId: string householdId: string

View File

@@ -4,7 +4,10 @@ import { randomUUID } from 'node:crypto'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { createFinanceCommandService } from '@household/application' import { createFinanceCommandService } from '@household/application'
import { createDbFinanceRepository } from '@household/adapters-db' import {
createDbFinanceRepository,
createDbHouseholdConfigurationRepository
} from '@household/adapters-db'
import { createDbClient, schema } from '@household/db' import { createDbClient, schema } from '@household/db'
import { createTelegramBot } from '../../apps/bot/src/bot' import { createTelegramBot } from '../../apps/bot/src/bot'
@@ -132,6 +135,9 @@ async function run(): Promise<void> {
let coreClient: ReturnType<typeof createDbClient> | undefined let coreClient: ReturnType<typeof createDbClient> | undefined
let ingestionClient: ReturnType<typeof createPurchaseMessageRepository> | undefined let ingestionClient: ReturnType<typeof createPurchaseMessageRepository> | undefined
let financeRepositoryClient: ReturnType<typeof createDbFinanceRepository> | undefined let financeRepositoryClient: ReturnType<typeof createDbFinanceRepository> | undefined
let householdConfigurationRepositoryClient:
| ReturnType<typeof createDbHouseholdConfigurationRepository>
| undefined
const bot = createTelegramBot('000000:test-token') const bot = createTelegramBot('000000:test-token')
const replies: string[] = [] const replies: string[] = []
@@ -181,8 +187,12 @@ async function run(): Promise<void> {
ingestionClient = createPurchaseMessageRepository(databaseUrl) ingestionClient = createPurchaseMessageRepository(databaseUrl)
financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household) financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household)
householdConfigurationRepositoryClient = createDbHouseholdConfigurationRepository(databaseUrl)
const financeService = createFinanceCommandService(financeRepositoryClient.repository) const financeService = createFinanceCommandService(financeRepositoryClient.repository)
const financeCommands = createFinanceCommandsService(financeService) const financeCommands = createFinanceCommandsService({
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
financeServiceForHousehold: () => financeService
})
registerPurchaseTopicIngestion( registerPurchaseTopicIngestion(
bot, bot,
@@ -200,6 +210,12 @@ async function run(): Promise<void> {
id: ids.household, id: ids.household,
name: 'E2E Smoke Household' name: 'E2E Smoke Household'
}) })
await coreClient.db.insert(schema.householdTelegramChats).values({
householdId: ids.household,
telegramChatId: chatId,
telegramChatType: 'supergroup',
title: 'E2E Smoke Household'
})
await coreClient.db.insert(schema.members).values([ await coreClient.db.insert(schema.members).values([
{ {
@@ -338,7 +354,8 @@ async function run(): Promise<void> {
: undefined, : undefined,
coreClient?.queryClient.end({ timeout: 5 }), coreClient?.queryClient.end({ timeout: 5 }),
ingestionClient?.close(), ingestionClient?.close(),
financeRepositoryClient?.close() financeRepositoryClient?.close(),
householdConfigurationRepositoryClient?.close()
]) ])
} }
} }