diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index 3f7ada6..6e96194 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -1,7 +1,10 @@ import { describe, expect, mock, test } from 'bun:test' import type { AnonymousFeedbackService } from '@household/application' -import type { TelegramPendingActionRepository } from '@household/ports' +import type { + HouseholdConfigurationRepository, + TelegramPendingActionRepository +} from '@household/ports' import { createTelegramBot } from './bot' 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', () => { test('posts accepted feedback into the configured topic', async () => { const bot = createTelegramBot('000000:test-token') @@ -124,10 +210,9 @@ describe('registerAnonymousFeedback', () => { registerAnonymousFeedback({ bot, - anonymousFeedbackService, - promptRepository: createPromptRepository(), - householdChatId: '-100222333', - feedbackTopicId: 77 + anonymousFeedbackServiceForHousehold: () => anonymousFeedbackService, + householdConfigurationRepository: createHouseholdConfigurationRepository(), + promptRepository: createPromptRepository() }) await bot.handleUpdate( @@ -187,7 +272,7 @@ describe('registerAnonymousFeedback', () => { registerAnonymousFeedback({ bot, - anonymousFeedbackService: { + anonymousFeedbackServiceForHousehold: () => ({ submit: mock(async () => ({ status: 'accepted' as const, submissionId: 'submission-1', @@ -195,10 +280,9 @@ describe('registerAnonymousFeedback', () => { })), markPosted: mock(async () => {}), markFailed: mock(async () => {}) - }, - promptRepository: createPromptRepository(), - householdChatId: '-100222333', - feedbackTopicId: 77 + }), + householdConfigurationRepository: createHouseholdConfigurationRepository(), + promptRepository: createPromptRepository() }) await bot.handleUpdate( @@ -258,14 +342,13 @@ describe('registerAnonymousFeedback', () => { registerAnonymousFeedback({ bot, - anonymousFeedbackService: { + anonymousFeedbackServiceForHousehold: () => ({ submit, markPosted: mock(async () => {}), markFailed: mock(async () => {}) - }, - promptRepository: createPromptRepository(), - householdChatId: '-100222333', - feedbackTopicId: 77 + }), + householdConfigurationRepository: createHouseholdConfigurationRepository(), + promptRepository: createPromptRepository() }) await bot.handleUpdate( @@ -341,14 +424,13 @@ describe('registerAnonymousFeedback', () => { registerAnonymousFeedback({ bot, - anonymousFeedbackService: { + anonymousFeedbackServiceForHousehold: () => ({ submit, markPosted: mock(async () => {}), markFailed: mock(async () => {}) - }, - promptRepository: createPromptRepository(), - householdChatId: '-100222333', - feedbackTopicId: 77 + }), + householdConfigurationRepository: createHouseholdConfigurationRepository(), + promptRepository: createPromptRepository() }) await bot.handleUpdate( diff --git a/apps/bot/src/anonymous-feedback.ts b/apps/bot/src/anonymous-feedback.ts index c6e631c..779b39d 100644 --- a/apps/bot/src/anonymous-feedback.ts +++ b/apps/bot/src/anonymous-feedback.ts @@ -1,6 +1,9 @@ import type { AnonymousFeedbackService } from '@household/application' 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' const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const @@ -98,10 +101,9 @@ async function startPendingAnonymousFeedbackPrompt( async function submitAnonymousFeedback(options: { ctx: Context - anonymousFeedbackService: AnonymousFeedbackService + anonymousFeedbackServiceForHousehold: (householdId: string) => AnonymousFeedbackService + householdConfigurationRepository: HouseholdConfigurationRepository promptRepository: TelegramPendingActionRepository - householdChatId: string - feedbackTopicId: number logger?: Logger | undefined rawText: string keepPromptOnValidationFailure?: boolean @@ -117,7 +119,44 @@ async function submitAnonymousFeedback(options: { 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, rawText: options.rawText, telegramChatId, @@ -151,17 +190,17 @@ async function submitAnonymousFeedback(options: { try { const posted = await options.ctx.api.sendMessage( - options.householdChatId, + householdChat.telegramChatId, feedbackText(result.sanitizedText), { - message_thread_id: options.feedbackTopicId + message_thread_id: Number(feedbackTopic.telegramThreadId) } ) - await options.anonymousFeedbackService.markPosted({ + await anonymousFeedbackService.markPosted({ submissionId: result.submissionId, - postedChatId: options.householdChatId, - postedThreadId: options.feedbackTopicId.toString(), + postedChatId: householdChat.telegramChatId, + postedThreadId: feedbackTopic.telegramThreadId, postedMessageId: posted.message_id.toString() }) @@ -173,23 +212,22 @@ async function submitAnonymousFeedback(options: { { event: 'anonymous_feedback.post_failed', submissionId: result.submissionId, - householdChatId: options.householdChatId, - feedbackTopicId: options.feedbackTopicId, + householdChatId: householdChat.telegramChatId, + feedbackTopicId: feedbackTopic.telegramThreadId, error: message }, '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.') } } export function registerAnonymousFeedback(options: { bot: Bot - anonymousFeedbackService: AnonymousFeedbackService + anonymousFeedbackServiceForHousehold: (householdId: string) => AnonymousFeedbackService + householdConfigurationRepository: HouseholdConfigurationRepository promptRepository: TelegramPendingActionRepository - householdChatId: string - feedbackTopicId: number logger?: Logger }): void { options.bot.command('cancel', async (ctx) => { @@ -228,10 +266,9 @@ export function registerAnonymousFeedback(options: { await submitAnonymousFeedback({ ctx, - anonymousFeedbackService: options.anonymousFeedbackService, + anonymousFeedbackServiceForHousehold: options.anonymousFeedbackServiceForHousehold, + householdConfigurationRepository: options.householdConfigurationRepository, promptRepository: options.promptRepository, - householdChatId: options.householdChatId, - feedbackTopicId: options.feedbackTopicId, logger: options.logger, rawText }) @@ -258,10 +295,9 @@ export function registerAnonymousFeedback(options: { await submitAnonymousFeedback({ ctx, - anonymousFeedbackService: options.anonymousFeedbackService, + anonymousFeedbackServiceForHousehold: options.anonymousFeedbackServiceForHousehold, + householdConfigurationRepository: options.householdConfigurationRepository, promptRepository: options.promptRepository, - householdChatId: options.householdChatId, - feedbackTopicId: options.feedbackTopicId, logger: options.logger, rawText: ctx.msg.text, keepPromptOnValidationFailure: true diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index 00fbf20..302ad7d 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -102,18 +102,10 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS) const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS) - const purchaseTopicIngestionEnabled = - databaseUrl !== undefined && - householdId !== undefined && - telegramHouseholdChatId !== undefined && - telegramPurchaseTopicId !== undefined + const purchaseTopicIngestionEnabled = databaseUrl !== undefined - const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined - const anonymousFeedbackEnabled = - databaseUrl !== undefined && - householdId !== undefined && - telegramHouseholdChatId !== undefined && - telegramFeedbackTopicId !== undefined + const financeCommandsEnabled = databaseUrl !== undefined + const anonymousFeedbackEnabled = databaseUrl !== undefined const miniAppAuthEnabled = databaseUrl !== undefined const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 const reminderJobsEnabled = diff --git a/apps/bot/src/finance-commands.ts b/apps/bot/src/finance-commands.ts index 7731bd7..dedf523 100644 --- a/apps/bot/src/finance-commands.ts +++ b/apps/bot/src/finance-commands.ts @@ -1,4 +1,5 @@ import type { FinanceCommandService } from '@household/application' +import type { HouseholdConfigurationRepository } from '@household/ports' import type { Bot, Context } from 'grammy' function commandArgs(ctx: Context): string[] { @@ -10,9 +11,39 @@ function commandArgs(ctx: Context): string[] { 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 } { + 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) { const telegramUserId = ctx.from?.id?.toString() if (!telegramUserId) { @@ -20,33 +51,42 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi 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) { await ctx.reply('You are not a member of this household.') return null } - return member + return { + member, + service: scoped.service, + householdId: scoped.householdId + } } async function requireAdmin(ctx: Context) { - const member = await requireMember(ctx) - if (!member) { + const resolved = await requireMember(ctx) + if (!resolved) { return null } - if (!member.isAdmin) { + if (!resolved.member.isAdmin) { await ctx.reply('Only household admins can use this command.') return null } - return member + return resolved } function register(bot: Bot): void { bot.command('cycle_open', async (ctx) => { - const admin = await requireAdmin(ctx) - if (!admin) { + const resolved = await requireAdmin(ctx) + if (!resolved) { return } @@ -57,7 +97,7 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi } 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})`) } catch (error) { 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) => { - const admin = await requireAdmin(ctx) - if (!admin) { + const resolved = await requireAdmin(ctx) + if (!resolved) { return } try { - const cycle = await financeService.closeCycle(commandArgs(ctx)[0]) + const cycle = await resolved.service.closeCycle(commandArgs(ctx)[0]) if (!cycle) { await ctx.reply('No cycle found to close.') return @@ -84,8 +124,8 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi }) bot.command('rent_set', async (ctx) => { - const admin = await requireAdmin(ctx) - if (!admin) { + const resolved = await requireAdmin(ctx) + if (!resolved) { return } @@ -96,7 +136,7 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi } 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) { await ctx.reply('No period provided and no open cycle found.') return @@ -111,8 +151,8 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi }) bot.command('utility_add', async (ctx) => { - const admin = await requireAdmin(ctx) - if (!admin) { + const resolved = await requireAdmin(ctx) + if (!resolved) { return } @@ -123,7 +163,12 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi } 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) { await ctx.reply('No open cycle found. Use /cycle_open first.') return @@ -138,13 +183,13 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi }) bot.command('statement', async (ctx) => { - const member = await requireMember(ctx) - if (!member) { + const resolved = await requireMember(ctx) + if (!resolved) { return } try { - const statement = await financeService.generateStatement(commandArgs(ctx)[0]) + const statement = await resolved.service.generateStatement(commandArgs(ctx)[0]) if (!statement) { await ctx.reply('No cycle found for statement.') return diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts new file mode 100644 index 0000000..689b7ee --- /dev/null +++ b/apps/bot/src/household-setup.test.ts @@ -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' + } + } + ] + ] + } + }) + }) +}) diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index 40dd246..7db0d88 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -112,11 +112,58 @@ function pendingMembersReply(result: { } 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: { bot: Bot householdSetupService: HouseholdSetupService householdOnboardingService: HouseholdOnboardingService householdAdminService: HouseholdAdminService + miniAppUrl?: string logger?: Logger }): void { options.bot.command('start', async (ctx) => { @@ -171,13 +218,15 @@ export function registerHouseholdSetupCommands(options: { if (result.status === 'active') { 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 } 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) ) }) diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index fb18820..eb46aa1 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -47,46 +47,60 @@ const shutdownTasks: Array<() => Promise> = [] const householdConfigurationRepositoryClient = runtime.databaseUrl ? createDbHouseholdConfigurationRepository(runtime.databaseUrl) : null -const financeRepositoryClient = - runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled - ? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!) - : null -const financeService = financeRepositoryClient - ? createFinanceCommandService(financeRepositoryClient.repository) - : null +const financeRepositoryClients = new Map>() +const financeServices = new Map>() const householdOnboardingService = householdConfigurationRepositoryClient ? createHouseholdOnboardingService({ - repository: householdConfigurationRepositoryClient.repository, - ...(financeRepositoryClient - ? { - getMemberByTelegramUserId: financeRepositoryClient.repository.getMemberByTelegramUserId - } - : {}) + repository: householdConfigurationRepositoryClient.repository }) : null -const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled - ? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!) - : null const telegramPendingActionRepositoryClient = runtime.databaseUrl && runtime.anonymousFeedbackEnabled ? createDbTelegramPendingActionRepository(runtime.databaseUrl!) : null -const anonymousFeedbackService = anonymousFeedbackRepositoryClient - ? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository) - : null +const anonymousFeedbackRepositoryClients = new Map< + string, + ReturnType +>() +const anonymousFeedbackServices = new Map< + string, + ReturnType +>() -if (financeRepositoryClient) { - shutdownTasks.push(financeRepositoryClient.close) +function financeServiceForHousehold(householdId: string) { + 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) { shutdownTasks.push(householdConfigurationRepositoryClient.close) } -if (anonymousFeedbackRepositoryClient) { - shutdownTasks.push(anonymousFeedbackRepositoryClient.close) -} - if (telegramPendingActionRepositoryClient) { shutdownTasks.push(telegramPendingActionRepositoryClient.close) } @@ -120,7 +134,10 @@ if (runtime.databaseUrl && householdConfigurationRepositoryClient) { } if (runtime.financeCommandsEnabled) { - const financeCommands = createFinanceCommandsService(financeService!) + const financeCommands = createFinanceCommandsService({ + householdConfigurationRepository: householdConfigurationRepositoryClient!.repository, + financeServiceForHousehold + }) financeCommands.register(bot) } else { @@ -129,7 +146,7 @@ if (runtime.financeCommandsEnabled) { event: 'runtime.feature_disabled', 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 ), householdOnboardingService: householdOnboardingService!, + ...(runtime.miniAppAllowedOrigins[0] + ? { + miniAppUrl: runtime.miniAppAllowedOrigins[0] + } + : {}), logger: getLogger('household-setup') }) } else { @@ -180,13 +202,16 @@ if (!runtime.reminderJobsEnabled) { ) } -if (anonymousFeedbackService) { +if ( + runtime.anonymousFeedbackEnabled && + householdConfigurationRepositoryClient && + telegramPendingActionRepositoryClient +) { registerAnonymousFeedback({ bot, - anonymousFeedbackService, + anonymousFeedbackServiceForHousehold, + householdConfigurationRepository: householdConfigurationRepositoryClient!.repository, promptRepository: telegramPendingActionRepositoryClient!.repository, - householdChatId: runtime.telegramHouseholdChatId!, - feedbackTopicId: runtime.telegramFeedbackTopicId!, logger: getLogger('anonymous-feedback') }) } else { @@ -195,7 +220,7 @@ if (anonymousFeedbackService) { event: 'runtime.feature_disabled', 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') }) : undefined, - miniAppDashboard: financeService + miniAppDashboard: householdOnboardingService ? createMiniAppDashboardHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - financeService, + financeServiceForHousehold, onboardingService: householdOnboardingService!, logger: getLogger('miniapp-dashboard') }) diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index 376f429..0672c42 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -18,6 +18,16 @@ function onboardingRepository(): HouseholdConfigurationRepository { title: 'Kojori House' } let joinToken: string | null = 'join-token' + const members = new Map< + string, + { + id: string + householdId: string + telegramUserId: string + displayName: string + isAdmin: boolean + } + >() let pending: { householdId: string householdName: string @@ -33,6 +43,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { household }), getTelegramHouseholdChat: async () => household, + getHouseholdChatByHouseholdId: async () => household, bindHouseholdTopic: async (input) => ({ householdId: input.householdId, @@ -72,13 +83,22 @@ function onboardingRepository(): HouseholdConfigurationRepository { }, getPendingHouseholdMember: async () => pending, findPendingHouseholdMemberByTelegramUserId: async () => pending, - ensureHouseholdMember: async (input) => ({ - householdId: household.householdId, - telegramUserId: input.telegramUserId, - displayName: input.displayName, - isAdmin: input.isAdmin === true - }), - getHouseholdMember: async () => null, + ensureHouseholdMember: async (input) => { + const member = { + id: `member-${input.telegramUserId}`, + householdId: household.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + isAdmin: input.isAdmin === true + } + 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] : []), approvePendingHouseholdMember: async (input) => { if (!pending || pending.telegramUserId !== input.telegramUserId) { @@ -86,11 +106,13 @@ function onboardingRepository(): HouseholdConfigurationRepository { } const member = { + id: `member-${pending.telegramUserId}`, householdId: household.householdId, telegramUserId: pending.telegramUserId, displayName: pending.displayName, isAdmin: input.isAdmin === true } + members.set(pending.telegramUserId, member) pending = null return member } @@ -100,17 +122,18 @@ function onboardingRepository(): HouseholdConfigurationRepository { describe('createMiniAppAuthHandler', () => { test('returns an authorized session for a household member', async () => { const authDate = Math.floor(Date.now() / 1000) + const repository = onboardingRepository() + await repository.ensureHouseholdMember({ + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + }) const auth = createMiniAppAuthHandler({ allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', onboardingService: createHouseholdOnboardingService({ - repository: onboardingRepository(), - getMemberByTelegramUserId: async () => ({ - id: 'member-1', - telegramUserId: '123456', - displayName: 'Stan', - isAdmin: true - }) + repository }) }) diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts index 7caf65c..ef8e80c 100644 --- a/apps/bot/src/miniapp-auth.ts +++ b/apps/bot/src/miniapp-auth.ts @@ -98,6 +98,7 @@ export interface MiniAppSessionResult { authorized: boolean member?: { id: string + householdId: string displayName: string isAdmin: boolean } diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 133321e..d5d9195 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -84,6 +84,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { household }), getTelegramHouseholdChat: async () => household, + getHouseholdChatByHouseholdId: async () => household, bindHouseholdTopic: async (input) => ({ householdId: input.householdId, @@ -113,12 +114,14 @@ function onboardingRepository(): HouseholdConfigurationRepository { getPendingHouseholdMember: async () => null, findPendingHouseholdMemberByTelegramUserId: async () => null, ensureHouseholdMember: async (input) => ({ + id: `member-${input.telegramUserId}`, householdId: household.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, isAdmin: input.isAdmin === true }), getHouseholdMember: async () => null, + listHouseholdMembersByTelegramUserId: async () => [], listPendingHouseholdMembers: async () => [], approvePendingHouseholdMember: async () => null } @@ -135,14 +138,23 @@ describe('createMiniAppDashboardHandler', () => { isAdmin: true }) ) + const householdRepository = onboardingRepository() + householdRepository.listHouseholdMembersByTelegramUserId = async () => [ + { + id: 'member-1', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + } + ] const dashboard = createMiniAppDashboardHandler({ allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', - financeService, + financeServiceForHousehold: () => financeService, onboardingService: createHouseholdOnboardingService({ - repository: onboardingRepository(), - getMemberByTelegramUserId: financeService.getMemberByTelegramUserId + repository: householdRepository }) }) @@ -202,14 +214,23 @@ describe('createMiniAppDashboardHandler', () => { isAdmin: true }) ) + const householdRepository = onboardingRepository() + householdRepository.listHouseholdMembersByTelegramUserId = async () => [ + { + id: 'member-1', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + } + ] const dashboard = createMiniAppDashboardHandler({ allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', - financeService, + financeServiceForHousehold: () => financeService, onboardingService: createHouseholdOnboardingService({ - repository: onboardingRepository(), - getMemberByTelegramUserId: financeService.getMemberByTelegramUserId + repository: householdRepository }) }) diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index 0df494d..a00ce12 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -12,7 +12,7 @@ import { export function createMiniAppDashboardHandler(options: { allowedOrigins: readonly string[] botToken: string - financeService: FinanceCommandService + financeServiceForHousehold: (householdId: string) => FinanceCommandService onboardingService: HouseholdOnboardingService 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) { return miniAppJsonResponse( { ok: false, error: 'No billing cycle available' }, diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index 5f0a812..9851316 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -1,8 +1,13 @@ import { describe, expect, test } from 'bun:test' +import { createTelegramBot } from './bot' + import { + buildPurchaseAcknowledgement, extractPurchaseTopicCandidate, + registerPurchaseTopicIngestion, resolveConfiguredPurchaseTopicRecord, + type PurchaseMessageIngestionRepository, type PurchaseTopicCandidate } from './purchase-topic-ingestion' @@ -25,6 +30,39 @@ function candidate(overrides: Partial = {}): 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', () => { test('returns record when message belongs to configured topic', () => { const record = extractPurchaseTopicCandidate(candidate(), config) @@ -86,3 +124,169 @@ describe('resolveConfiguredPurchaseTopicRecord', () => { 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) + }) +}) diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index c084276..4ec53a9 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -1,4 +1,5 @@ import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application' +import { Money } from '@household/domain' import { and, eq } from 'drizzle-orm' import type { Bot, Context } from 'grammy' import type { Logger } from '@household/observability' @@ -30,11 +31,27 @@ export interface PurchaseTopicRecord extends PurchaseTopicCandidate { 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 { save( record: PurchaseTopicRecord, llmFallback?: PurchaseParserLlmFallback - ): Promise<'created' | 'duplicate'> + ): Promise } export function extractPurchaseTopicCandidate( @@ -172,7 +189,21 @@ export function createPurchaseMessageRepository(databaseUrl: string): { }) .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 +): 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 { + const message = ctx.msg + if (!message) { + return + } + + await ctx.reply(text, { + reply_parameters: { + message_id: message.message_id + } + }) +} + function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null { const message = ctx.message if (!message || !('text' in message)) { @@ -244,11 +320,13 @@ export function registerPurchaseTopicIngestion( try { const status = await repository.save(record, options.llmFallback) + const acknowledgement = buildPurchaseAcknowledgement(status) - if (status === 'created') { + if (status.status === 'created') { options.logger?.info( { event: 'purchase.ingested', + processingStatus: status.processingStatus, chatId: record.chatId, threadId: record.threadId, messageId: record.messageId, @@ -258,6 +336,10 @@ export function registerPurchaseTopicIngestion( 'Purchase topic message ingested' ) } + + if (acknowledgement) { + await replyToPurchaseMessage(ctx, acknowledgement) + } } catch (error) { options.logger?.error( { @@ -308,12 +390,14 @@ export function registerConfiguredPurchaseTopicIngestion( try { const status = await repository.save(record, options.llmFallback) + const acknowledgement = buildPurchaseAcknowledgement(status) - if (status === 'created') { + if (status.status === 'created') { options.logger?.info( { event: 'purchase.ingested', householdId: record.householdId, + processingStatus: status.processingStatus, chatId: record.chatId, threadId: record.threadId, messageId: record.messageId, @@ -323,6 +407,10 @@ export function registerConfiguredPurchaseTopicIngestion( 'Purchase topic message ingested' ) } + + if (acknowledgement) { + await replyToPurchaseMessage(ctx, acknowledgement) + } } catch (error) { options.logger?.error( { diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index 47c8d0e..15184d4 100644 --- a/packages/adapters-db/src/household-config-repository.ts +++ b/packages/adapters-db/src/household-config-repository.ts @@ -86,12 +86,14 @@ function toHouseholdPendingMemberRecord(row: { } function toHouseholdMemberRecord(row: { + id: string householdId: string telegramUserId: string displayName: string isAdmin: number }): HouseholdMemberRecord { return { + id: row.id, householdId: row.householdId, telegramUserId: row.telegramUserId, displayName: row.displayName, @@ -219,6 +221,27 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { 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) { const rows = await db .insert(schema.householdTopicBindings) @@ -535,6 +558,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { } }) .returning({ + id: schema.members.id, householdId: schema.members.householdId, telegramUserId: schema.members.telegramUserId, displayName: schema.members.displayName, @@ -552,6 +576,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { async getHouseholdMember(householdId, telegramUserId) { const rows = await db .select({ + id: schema.members.id, householdId: schema.members.householdId, telegramUserId: schema.members.telegramUserId, displayName: schema.members.displayName, @@ -570,6 +595,22 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { 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) { const rows = await db .select({ @@ -640,6 +681,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { } }) .returning({ + id: schema.members.id, householdId: schema.members.householdId, telegramUserId: schema.members.telegramUserId, displayName: schema.members.displayName, diff --git a/packages/application/src/household-admin-service.test.ts b/packages/application/src/household-admin-service.test.ts index 73612a0..d0d38e2 100644 --- a/packages/application/src/household-admin-service.test.ts +++ b/packages/application/src/household-admin-service.test.ts @@ -23,6 +23,7 @@ function createRepositoryStub() { const pendingMembers = new Map() members.set('1', { + id: 'member-1', householdId: household.householdId, telegramUserId: '1', displayName: 'Stan', @@ -43,6 +44,7 @@ function createRepositoryStub() { household }), getTelegramHouseholdChat: async () => household, + getHouseholdChatByHouseholdId: async () => household, bindHouseholdTopic: async (input) => ({ householdId: input.householdId, @@ -80,6 +82,7 @@ function createRepositoryStub() { pendingMembers.get(telegramUserId) ?? null, ensureHouseholdMember: async (input) => { const record: HouseholdMemberRecord = { + id: `member-${input.telegramUserId}`, householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, @@ -89,6 +92,8 @@ function createRepositoryStub() { return record }, getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null, + listHouseholdMembersByTelegramUserId: async (telegramUserId) => + [...members.values()].filter((member) => member.telegramUserId === telegramUserId), listPendingHouseholdMembers: async () => [...pendingMembers.values()], approvePendingHouseholdMember: async (input) => { const pending = pendingMembers.get(input.telegramUserId) @@ -99,6 +104,7 @@ function createRepositoryStub() { pendingMembers.delete(input.telegramUserId) const member: HouseholdMemberRecord = { + id: `member-${pending.telegramUserId}`, householdId: pending.householdId, telegramUserId: pending.telegramUserId, displayName: pending.displayName, @@ -170,6 +176,7 @@ describe('createHouseholdAdminService', () => { status: 'approved', householdName: 'Kojori House', member: { + id: 'member-2', householdId: 'household-1', telegramUserId: '2', displayName: 'Alice', diff --git a/packages/application/src/household-onboarding-service.test.ts b/packages/application/src/household-onboarding-service.test.ts index b67d1c6..80610e2 100644 --- a/packages/application/src/household-onboarding-service.test.ts +++ b/packages/application/src/household-onboarding-service.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test' import type { - FinanceMemberRecord, HouseholdConfigurationRepository, + HouseholdMemberRecord, HouseholdJoinTokenRecord, HouseholdPendingMemberRecord, HouseholdTelegramChatRecord, @@ -21,6 +21,7 @@ function createRepositoryStub() { } let joinToken: HouseholdJoinTokenRecord | null = null const pendingMembers = new Map() + const members = new Map() const repository: HouseholdConfigurationRepository = { async registerTelegramHouseholdChat() { @@ -32,6 +33,9 @@ function createRepositoryStub() { async getTelegramHouseholdChat() { return household }, + async getHouseholdChatByHouseholdId() { + return household + }, async bindHouseholdTopic(input) { const binding: HouseholdTopicBindingRecord = { householdId: input.householdId, @@ -84,15 +88,22 @@ function createRepositoryStub() { return pendingMembers.get(telegramUserId) ?? null }, async ensureHouseholdMember(input) { - return { + const member = { + id: `member-${input.telegramUserId}`, householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, isAdmin: input.isAdmin === true } + members.set(input.telegramUserId, member) + return member }, - async getHouseholdMember() { - return null + async getHouseholdMember(_householdId, telegramUserId) { + return members.get(telegramUserId) ?? null + }, + async listHouseholdMembersByTelegramUserId(telegramUserId) { + const member = members.get(telegramUserId) + return member ? [member] : [] }, async listPendingHouseholdMembers() { return [...pendingMembers.values()] @@ -106,6 +117,7 @@ function createRepositoryStub() { pendingMembers.delete(input.telegramUserId) return { + id: `member-${pending.telegramUserId}`, householdId: pending.householdId, telegramUserId: pending.telegramUserId, 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 member: FinanceMemberRecord = { - id: 'member-1', + await repository.ensureHouseholdMember({ + householdId: 'household-1', telegramUserId: '42', displayName: 'Stan', isAdmin: true - } + }) const service = createHouseholdOnboardingService({ - repository, - getMemberByTelegramUserId: async () => member + repository }) const access = await service.getMiniAppAccess({ @@ -233,10 +244,49 @@ describe('createHouseholdOnboardingService', () => { expect(access).toEqual({ status: 'active', member: { - id: 'member-1', + id: 'member-42', + householdId: 'household-1', displayName: 'Stan', 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 + } + 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' + }) + }) }) diff --git a/packages/application/src/household-onboarding-service.ts b/packages/application/src/household-onboarding-service.ts index e002df9..1373654 100644 --- a/packages/application/src/household-onboarding-service.ts +++ b/packages/application/src/household-onboarding-service.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto' -import type { FinanceMemberRecord, HouseholdConfigurationRepository } from '@household/ports' +import type { HouseholdConfigurationRepository, HouseholdMemberRecord } from '@household/ports' export interface HouseholdOnboardingIdentity { telegramUserId: string @@ -14,6 +14,7 @@ export type HouseholdMiniAppAccess = status: 'active' member: { id: string + householdId: string displayName: string isAdmin: boolean } @@ -58,6 +59,7 @@ export interface HouseholdOnboardingService { status: 'active' member: { id: string + householdId: string displayName: string isAdmin: boolean } @@ -68,13 +70,15 @@ export interface HouseholdOnboardingService { > } -function toMember(member: FinanceMemberRecord): { +function toMember(member: HouseholdMemberRecord): { id: string + householdId: string displayName: string isAdmin: boolean } { return { id: member.id, + householdId: member.householdId, displayName: member.displayName, isAdmin: member.isAdmin } @@ -86,7 +90,6 @@ function generateJoinToken(): string { export function createHouseholdOnboardingService(options: { repository: HouseholdConfigurationRepository - getMemberByTelegramUserId?: (telegramUserId: string) => Promise tokenFactory?: () => string }): HouseholdOnboardingService { const createToken = options.tokenFactory ?? generateJoinToken @@ -121,14 +124,26 @@ export function createHouseholdOnboardingService(options: { }, async getMiniAppAccess(input) { - const activeMember = options.getMemberByTelegramUserId - ? await options.getMemberByTelegramUserId(input.identity.telegramUserId) - : null + const activeMemberships = await options.repository.listHouseholdMembersByTelegramUserId( + input.identity.telegramUserId + ) + const requestedHousehold = + input.joinToken !== undefined + ? await options.repository.getHouseholdByJoinToken(input.joinToken) + : null + const matchingActiveMember = + requestedHousehold === null + ? activeMemberships.length === 1 + ? activeMemberships[0]! + : null + : (activeMemberships.find( + (member) => member.householdId === requestedHousehold.householdId + ) ?? null) - if (activeMember) { + if (matchingActiveMember) { return { 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) { return { status: 'open_from_group' @@ -189,9 +204,9 @@ export function createHouseholdOnboardingService(options: { } } - const activeMember = options.getMemberByTelegramUserId - ? await options.getMemberByTelegramUserId(input.identity.telegramUserId) - : null + const activeMember = ( + await options.repository.listHouseholdMembersByTelegramUserId(input.identity.telegramUserId) + ).find((member) => member.householdId === household.householdId) if (activeMember) { return { diff --git a/packages/application/src/household-setup-service.test.ts b/packages/application/src/household-setup-service.test.ts index 23f7be1..3780fe7 100644 --- a/packages/application/src/household-setup-service.test.ts +++ b/packages/application/src/household-setup-service.test.ts @@ -53,6 +53,12 @@ function createRepositoryStub() { return households.get(telegramChatId) ?? null }, + async getHouseholdChatByHouseholdId(householdId) { + return ( + [...households.values()].find((household) => household.householdId === householdId) ?? null + ) + }, + async bindHouseholdTopic(input) { const next: HouseholdTopicBindingRecord = { householdId: input.householdId, @@ -156,6 +162,7 @@ function createRepositoryStub() { const key = `${input.householdId}:${input.telegramUserId}` const existing = members.get(key) const next: HouseholdMemberRecord = { + id: existing?.id ?? `member-${input.telegramUserId}`, householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, @@ -169,6 +176,10 @@ function createRepositoryStub() { return members.get(`${householdId}:${telegramUserId}`) ?? null }, + async listHouseholdMembersByTelegramUserId(telegramUserId) { + return [...members.values()].filter((member) => member.telegramUserId === telegramUserId) + }, + async listPendingHouseholdMembers(householdId) { return [...pendingMembers.values()].filter((entry) => entry.householdId === householdId) }, @@ -183,6 +194,7 @@ function createRepositoryStub() { pendingMembers.delete(key) const member: HouseholdMemberRecord = { + id: `member-${pending.telegramUserId}`, householdId: pending.householdId, telegramUserId: pending.telegramUserId, displayName: pending.displayName, @@ -220,6 +232,7 @@ describe('createHouseholdSetupService', () => { expect(result.household.telegramChatId).toBe('-100123') const admin = await repository.getHouseholdMember(result.household.householdId, '42') expect(admin).toEqual({ + id: 'member-42', householdId: result.household.householdId, telegramUserId: '42', displayName: 'Stan', diff --git a/packages/ports/src/household-config.ts b/packages/ports/src/household-config.ts index eb06989..a059257 100644 --- a/packages/ports/src/household-config.ts +++ b/packages/ports/src/household-config.ts @@ -34,6 +34,7 @@ export interface HouseholdPendingMemberRecord { } export interface HouseholdMemberRecord { + id: string householdId: string telegramUserId: string displayName: string @@ -57,6 +58,7 @@ export interface HouseholdConfigurationRepository { input: RegisterTelegramHouseholdChatInput ): Promise getTelegramHouseholdChat(telegramChatId: string): Promise + getHouseholdChatByHouseholdId(householdId: string): Promise bindHouseholdTopic(input: { householdId: string role: HouseholdTopicRole @@ -103,6 +105,9 @@ export interface HouseholdConfigurationRepository { householdId: string, telegramUserId: string ): Promise + listHouseholdMembersByTelegramUserId( + telegramUserId: string + ): Promise listPendingHouseholdMembers(householdId: string): Promise approvePendingHouseholdMember(input: { householdId: string diff --git a/scripts/e2e/billing-flow.ts b/scripts/e2e/billing-flow.ts index 3951a13..d86f57b 100644 --- a/scripts/e2e/billing-flow.ts +++ b/scripts/e2e/billing-flow.ts @@ -4,7 +4,10 @@ import { randomUUID } from 'node:crypto' import { eq } from 'drizzle-orm' 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 { createTelegramBot } from '../../apps/bot/src/bot' @@ -132,6 +135,9 @@ async function run(): Promise { let coreClient: ReturnType | undefined let ingestionClient: ReturnType | undefined let financeRepositoryClient: ReturnType | undefined + let householdConfigurationRepositoryClient: + | ReturnType + | undefined const bot = createTelegramBot('000000:test-token') const replies: string[] = [] @@ -181,8 +187,12 @@ async function run(): Promise { ingestionClient = createPurchaseMessageRepository(databaseUrl) financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household) + householdConfigurationRepositoryClient = createDbHouseholdConfigurationRepository(databaseUrl) const financeService = createFinanceCommandService(financeRepositoryClient.repository) - const financeCommands = createFinanceCommandsService(financeService) + const financeCommands = createFinanceCommandsService({ + householdConfigurationRepository: householdConfigurationRepositoryClient.repository, + financeServiceForHousehold: () => financeService + }) registerPurchaseTopicIngestion( bot, @@ -200,6 +210,12 @@ async function run(): Promise { id: ids.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([ { @@ -338,7 +354,8 @@ async function run(): Promise { : undefined, coreClient?.queryClient.end({ timeout: 5 }), ingestionClient?.close(), - financeRepositoryClient?.close() + financeRepositoryClient?.close(), + householdConfigurationRepositoryClient?.close() ]) } }