mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +00:00
feat(bot): cut over multi-household member flows
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
180
apps/bot/src/household-setup.test.ts
Normal file
180
apps/bot/src/household-setup.test.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -47,46 +47,60 @@ const shutdownTasks: Array<() => Promise<void>> = []
|
||||
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<string, ReturnType<typeof createDbFinanceRepository>>()
|
||||
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
|
||||
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<typeof createDbAnonymousFeedbackRepository>
|
||||
>()
|
||||
const anonymousFeedbackServices = new Map<
|
||||
string,
|
||||
ReturnType<typeof createAnonymousFeedbackService>
|
||||
>()
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface MiniAppSessionResult {
|
||||
authorized: boolean
|
||||
member?: {
|
||||
id: string
|
||||
householdId: string
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<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', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<PurchaseMessageIngestionResult>
|
||||
}
|
||||
|
||||
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<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 {
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ function createRepositoryStub() {
|
||||
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
|
||||
|
||||
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',
|
||||
|
||||
@@ -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<string, HouseholdPendingMemberRecord>()
|
||||
const members = new Map<string, HouseholdMemberRecord>()
|
||||
|
||||
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<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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<FinanceMemberRecord | null>
|
||||
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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<RegisterTelegramHouseholdChatResult>
|
||||
getTelegramHouseholdChat(telegramChatId: string): Promise<HouseholdTelegramChatRecord | null>
|
||||
getHouseholdChatByHouseholdId(householdId: string): Promise<HouseholdTelegramChatRecord | null>
|
||||
bindHouseholdTopic(input: {
|
||||
householdId: string
|
||||
role: HouseholdTopicRole
|
||||
@@ -103,6 +105,9 @@ export interface HouseholdConfigurationRepository {
|
||||
householdId: string,
|
||||
telegramUserId: string
|
||||
): Promise<HouseholdMemberRecord | null>
|
||||
listHouseholdMembersByTelegramUserId(
|
||||
telegramUserId: string
|
||||
): Promise<readonly HouseholdMemberRecord[]>
|
||||
listPendingHouseholdMembers(householdId: string): Promise<readonly HouseholdPendingMemberRecord[]>
|
||||
approvePendingHouseholdMember(input: {
|
||||
householdId: string
|
||||
|
||||
@@ -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<void> {
|
||||
let coreClient: ReturnType<typeof createDbClient> | undefined
|
||||
let ingestionClient: ReturnType<typeof createPurchaseMessageRepository> | undefined
|
||||
let financeRepositoryClient: ReturnType<typeof createDbFinanceRepository> | undefined
|
||||
let householdConfigurationRepositoryClient:
|
||||
| ReturnType<typeof createDbHouseholdConfigurationRepository>
|
||||
| undefined
|
||||
|
||||
const bot = createTelegramBot('000000:test-token')
|
||||
const replies: string[] = []
|
||||
@@ -181,8 +187,12 @@ async function run(): Promise<void> {
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
: undefined,
|
||||
coreClient?.queryClient.end({ timeout: 5 }),
|
||||
ingestionClient?.close(),
|
||||
financeRepositoryClient?.close()
|
||||
financeRepositoryClient?.close(),
|
||||
householdConfigurationRepositoryClient?.close()
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user