mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 22:44:03 +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 { describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
import type { AnonymousFeedbackService } from '@household/application'
|
import type { AnonymousFeedbackService } from '@household/application'
|
||||||
import type { TelegramPendingActionRepository } from '@household/ports'
|
import type {
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
TelegramPendingActionRepository
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
import { createTelegramBot } from './bot'
|
import { createTelegramBot } from './bot'
|
||||||
import { registerAnonymousFeedback } from './anonymous-feedback'
|
import { registerAnonymousFeedback } from './anonymous-feedback'
|
||||||
@@ -76,6 +79,89 @@ function createPromptRepository(): TelegramPendingActionRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createHouseholdConfigurationRepository(): HouseholdConfigurationRepository {
|
||||||
|
return {
|
||||||
|
registerTelegramHouseholdChat: async () => ({
|
||||||
|
status: 'existing',
|
||||||
|
household: {
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
telegramChatId: '-100222333',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
getTelegramHouseholdChat: async () => ({
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
telegramChatId: '-100222333',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House'
|
||||||
|
}),
|
||||||
|
getHouseholdChatByHouseholdId: async () => ({
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
telegramChatId: '-100222333',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House'
|
||||||
|
}),
|
||||||
|
bindHouseholdTopic: async (input) => ({
|
||||||
|
householdId: input.householdId,
|
||||||
|
role: input.role,
|
||||||
|
telegramThreadId: input.telegramThreadId,
|
||||||
|
topicName: input.topicName?.trim() || null
|
||||||
|
}),
|
||||||
|
getHouseholdTopicBinding: async (_householdId, role) =>
|
||||||
|
role === 'feedback'
|
||||||
|
? {
|
||||||
|
householdId: 'household-1',
|
||||||
|
role: 'feedback',
|
||||||
|
telegramThreadId: '77',
|
||||||
|
topicName: 'Feedback'
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
upsertHouseholdJoinToken: async () => ({
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
token: 'join-token',
|
||||||
|
createdByTelegramUserId: null
|
||||||
|
}),
|
||||||
|
getHouseholdJoinToken: async () => null,
|
||||||
|
getHouseholdByJoinToken: async () => null,
|
||||||
|
upsertPendingHouseholdMember: async (input) => ({
|
||||||
|
householdId: input.householdId,
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
telegramUserId: input.telegramUserId,
|
||||||
|
displayName: input.displayName,
|
||||||
|
username: input.username?.trim() || null,
|
||||||
|
languageCode: input.languageCode?.trim() || null
|
||||||
|
}),
|
||||||
|
getPendingHouseholdMember: async () => null,
|
||||||
|
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
||||||
|
ensureHouseholdMember: async (input) => ({
|
||||||
|
id: `member-${input.telegramUserId}`,
|
||||||
|
householdId: input.householdId,
|
||||||
|
telegramUserId: input.telegramUserId,
|
||||||
|
displayName: input.displayName,
|
||||||
|
isAdmin: input.isAdmin === true
|
||||||
|
}),
|
||||||
|
getHouseholdMember: async () => null,
|
||||||
|
listHouseholdMembersByTelegramUserId: async () => [
|
||||||
|
{
|
||||||
|
id: 'member-123456',
|
||||||
|
householdId: 'household-1',
|
||||||
|
telegramUserId: '123456',
|
||||||
|
displayName: 'Stan',
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
listPendingHouseholdMembers: async () => [],
|
||||||
|
approvePendingHouseholdMember: async () => null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('registerAnonymousFeedback', () => {
|
describe('registerAnonymousFeedback', () => {
|
||||||
test('posts accepted feedback into the configured topic', async () => {
|
test('posts accepted feedback into the configured topic', async () => {
|
||||||
const bot = createTelegramBot('000000:test-token')
|
const bot = createTelegramBot('000000:test-token')
|
||||||
@@ -124,10 +210,9 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
|
|
||||||
registerAnonymousFeedback({
|
registerAnonymousFeedback({
|
||||||
bot,
|
bot,
|
||||||
anonymousFeedbackService,
|
anonymousFeedbackServiceForHousehold: () => anonymousFeedbackService,
|
||||||
promptRepository: createPromptRepository(),
|
householdConfigurationRepository: createHouseholdConfigurationRepository(),
|
||||||
householdChatId: '-100222333',
|
promptRepository: createPromptRepository()
|
||||||
feedbackTopicId: 77
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await bot.handleUpdate(
|
await bot.handleUpdate(
|
||||||
@@ -187,7 +272,7 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
|
|
||||||
registerAnonymousFeedback({
|
registerAnonymousFeedback({
|
||||||
bot,
|
bot,
|
||||||
anonymousFeedbackService: {
|
anonymousFeedbackServiceForHousehold: () => ({
|
||||||
submit: mock(async () => ({
|
submit: mock(async () => ({
|
||||||
status: 'accepted' as const,
|
status: 'accepted' as const,
|
||||||
submissionId: 'submission-1',
|
submissionId: 'submission-1',
|
||||||
@@ -195,10 +280,9 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
})),
|
})),
|
||||||
markPosted: mock(async () => {}),
|
markPosted: mock(async () => {}),
|
||||||
markFailed: mock(async () => {})
|
markFailed: mock(async () => {})
|
||||||
},
|
}),
|
||||||
promptRepository: createPromptRepository(),
|
householdConfigurationRepository: createHouseholdConfigurationRepository(),
|
||||||
householdChatId: '-100222333',
|
promptRepository: createPromptRepository()
|
||||||
feedbackTopicId: 77
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await bot.handleUpdate(
|
await bot.handleUpdate(
|
||||||
@@ -258,14 +342,13 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
|
|
||||||
registerAnonymousFeedback({
|
registerAnonymousFeedback({
|
||||||
bot,
|
bot,
|
||||||
anonymousFeedbackService: {
|
anonymousFeedbackServiceForHousehold: () => ({
|
||||||
submit,
|
submit,
|
||||||
markPosted: mock(async () => {}),
|
markPosted: mock(async () => {}),
|
||||||
markFailed: mock(async () => {})
|
markFailed: mock(async () => {})
|
||||||
},
|
}),
|
||||||
promptRepository: createPromptRepository(),
|
householdConfigurationRepository: createHouseholdConfigurationRepository(),
|
||||||
householdChatId: '-100222333',
|
promptRepository: createPromptRepository()
|
||||||
feedbackTopicId: 77
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await bot.handleUpdate(
|
await bot.handleUpdate(
|
||||||
@@ -341,14 +424,13 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
|
|
||||||
registerAnonymousFeedback({
|
registerAnonymousFeedback({
|
||||||
bot,
|
bot,
|
||||||
anonymousFeedbackService: {
|
anonymousFeedbackServiceForHousehold: () => ({
|
||||||
submit,
|
submit,
|
||||||
markPosted: mock(async () => {}),
|
markPosted: mock(async () => {}),
|
||||||
markFailed: mock(async () => {})
|
markFailed: mock(async () => {})
|
||||||
},
|
}),
|
||||||
promptRepository: createPromptRepository(),
|
householdConfigurationRepository: createHouseholdConfigurationRepository(),
|
||||||
householdChatId: '-100222333',
|
promptRepository: createPromptRepository()
|
||||||
feedbackTopicId: 77
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await bot.handleUpdate(
|
await bot.handleUpdate(
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { AnonymousFeedbackService } from '@household/application'
|
import type { AnonymousFeedbackService } from '@household/application'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import type { TelegramPendingActionRepository } from '@household/ports'
|
import type {
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
TelegramPendingActionRepository
|
||||||
|
} from '@household/ports'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
|
|
||||||
const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const
|
const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const
|
||||||
@@ -98,10 +101,9 @@ async function startPendingAnonymousFeedbackPrompt(
|
|||||||
|
|
||||||
async function submitAnonymousFeedback(options: {
|
async function submitAnonymousFeedback(options: {
|
||||||
ctx: Context
|
ctx: Context
|
||||||
anonymousFeedbackService: AnonymousFeedbackService
|
anonymousFeedbackServiceForHousehold: (householdId: string) => AnonymousFeedbackService
|
||||||
|
householdConfigurationRepository: HouseholdConfigurationRepository
|
||||||
promptRepository: TelegramPendingActionRepository
|
promptRepository: TelegramPendingActionRepository
|
||||||
householdChatId: string
|
|
||||||
feedbackTopicId: number
|
|
||||||
logger?: Logger | undefined
|
logger?: Logger | undefined
|
||||||
rawText: string
|
rawText: string
|
||||||
keepPromptOnValidationFailure?: boolean
|
keepPromptOnValidationFailure?: boolean
|
||||||
@@ -117,7 +119,44 @@ async function submitAnonymousFeedback(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await options.anonymousFeedbackService.submit({
|
const memberships =
|
||||||
|
await options.householdConfigurationRepository.listHouseholdMembersByTelegramUserId(
|
||||||
|
telegramUserId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (memberships.length === 0) {
|
||||||
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
|
await options.ctx.reply('You are not a member of this household.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memberships.length > 1) {
|
||||||
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
|
await options.ctx.reply(
|
||||||
|
'You belong to multiple households. Open the target household from its group until household selection is added.'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = memberships[0]!
|
||||||
|
const householdChat =
|
||||||
|
await options.householdConfigurationRepository.getHouseholdChatByHouseholdId(member.householdId)
|
||||||
|
const feedbackTopic = await options.householdConfigurationRepository.getHouseholdTopicBinding(
|
||||||
|
member.householdId,
|
||||||
|
'feedback'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!householdChat || !feedbackTopic) {
|
||||||
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
|
await options.ctx.reply(
|
||||||
|
'Anonymous feedback is not configured for your household yet. Ask an admin to run /bind_feedback_topic.'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const anonymousFeedbackService = options.anonymousFeedbackServiceForHousehold(member.householdId)
|
||||||
|
|
||||||
|
const result = await anonymousFeedbackService.submit({
|
||||||
telegramUserId,
|
telegramUserId,
|
||||||
rawText: options.rawText,
|
rawText: options.rawText,
|
||||||
telegramChatId,
|
telegramChatId,
|
||||||
@@ -151,17 +190,17 @@ async function submitAnonymousFeedback(options: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const posted = await options.ctx.api.sendMessage(
|
const posted = await options.ctx.api.sendMessage(
|
||||||
options.householdChatId,
|
householdChat.telegramChatId,
|
||||||
feedbackText(result.sanitizedText),
|
feedbackText(result.sanitizedText),
|
||||||
{
|
{
|
||||||
message_thread_id: options.feedbackTopicId
|
message_thread_id: Number(feedbackTopic.telegramThreadId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await options.anonymousFeedbackService.markPosted({
|
await anonymousFeedbackService.markPosted({
|
||||||
submissionId: result.submissionId,
|
submissionId: result.submissionId,
|
||||||
postedChatId: options.householdChatId,
|
postedChatId: householdChat.telegramChatId,
|
||||||
postedThreadId: options.feedbackTopicId.toString(),
|
postedThreadId: feedbackTopic.telegramThreadId,
|
||||||
postedMessageId: posted.message_id.toString()
|
postedMessageId: posted.message_id.toString()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -173,23 +212,22 @@ async function submitAnonymousFeedback(options: {
|
|||||||
{
|
{
|
||||||
event: 'anonymous_feedback.post_failed',
|
event: 'anonymous_feedback.post_failed',
|
||||||
submissionId: result.submissionId,
|
submissionId: result.submissionId,
|
||||||
householdChatId: options.householdChatId,
|
householdChatId: householdChat.telegramChatId,
|
||||||
feedbackTopicId: options.feedbackTopicId,
|
feedbackTopicId: feedbackTopic.telegramThreadId,
|
||||||
error: message
|
error: message
|
||||||
},
|
},
|
||||||
'Anonymous feedback posting failed'
|
'Anonymous feedback posting failed'
|
||||||
)
|
)
|
||||||
await options.anonymousFeedbackService.markFailed(result.submissionId, message)
|
await anonymousFeedbackService.markFailed(result.submissionId, message)
|
||||||
await options.ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.')
|
await options.ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerAnonymousFeedback(options: {
|
export function registerAnonymousFeedback(options: {
|
||||||
bot: Bot
|
bot: Bot
|
||||||
anonymousFeedbackService: AnonymousFeedbackService
|
anonymousFeedbackServiceForHousehold: (householdId: string) => AnonymousFeedbackService
|
||||||
|
householdConfigurationRepository: HouseholdConfigurationRepository
|
||||||
promptRepository: TelegramPendingActionRepository
|
promptRepository: TelegramPendingActionRepository
|
||||||
householdChatId: string
|
|
||||||
feedbackTopicId: number
|
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): void {
|
}): void {
|
||||||
options.bot.command('cancel', async (ctx) => {
|
options.bot.command('cancel', async (ctx) => {
|
||||||
@@ -228,10 +266,9 @@ export function registerAnonymousFeedback(options: {
|
|||||||
|
|
||||||
await submitAnonymousFeedback({
|
await submitAnonymousFeedback({
|
||||||
ctx,
|
ctx,
|
||||||
anonymousFeedbackService: options.anonymousFeedbackService,
|
anonymousFeedbackServiceForHousehold: options.anonymousFeedbackServiceForHousehold,
|
||||||
|
householdConfigurationRepository: options.householdConfigurationRepository,
|
||||||
promptRepository: options.promptRepository,
|
promptRepository: options.promptRepository,
|
||||||
householdChatId: options.householdChatId,
|
|
||||||
feedbackTopicId: options.feedbackTopicId,
|
|
||||||
logger: options.logger,
|
logger: options.logger,
|
||||||
rawText
|
rawText
|
||||||
})
|
})
|
||||||
@@ -258,10 +295,9 @@ export function registerAnonymousFeedback(options: {
|
|||||||
|
|
||||||
await submitAnonymousFeedback({
|
await submitAnonymousFeedback({
|
||||||
ctx,
|
ctx,
|
||||||
anonymousFeedbackService: options.anonymousFeedbackService,
|
anonymousFeedbackServiceForHousehold: options.anonymousFeedbackServiceForHousehold,
|
||||||
|
householdConfigurationRepository: options.householdConfigurationRepository,
|
||||||
promptRepository: options.promptRepository,
|
promptRepository: options.promptRepository,
|
||||||
householdChatId: options.householdChatId,
|
|
||||||
feedbackTopicId: options.feedbackTopicId,
|
|
||||||
logger: options.logger,
|
logger: options.logger,
|
||||||
rawText: ctx.msg.text,
|
rawText: ctx.msg.text,
|
||||||
keepPromptOnValidationFailure: true
|
keepPromptOnValidationFailure: true
|
||||||
|
|||||||
@@ -102,18 +102,10 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
|
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
|
||||||
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
|
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
|
||||||
|
|
||||||
const purchaseTopicIngestionEnabled =
|
const purchaseTopicIngestionEnabled = databaseUrl !== undefined
|
||||||
databaseUrl !== undefined &&
|
|
||||||
householdId !== undefined &&
|
|
||||||
telegramHouseholdChatId !== undefined &&
|
|
||||||
telegramPurchaseTopicId !== undefined
|
|
||||||
|
|
||||||
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
|
const financeCommandsEnabled = databaseUrl !== undefined
|
||||||
const anonymousFeedbackEnabled =
|
const anonymousFeedbackEnabled = databaseUrl !== undefined
|
||||||
databaseUrl !== undefined &&
|
|
||||||
householdId !== undefined &&
|
|
||||||
telegramHouseholdChatId !== undefined &&
|
|
||||||
telegramFeedbackTopicId !== undefined
|
|
||||||
const miniAppAuthEnabled = databaseUrl !== undefined
|
const miniAppAuthEnabled = databaseUrl !== undefined
|
||||||
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
||||||
const reminderJobsEnabled =
|
const reminderJobsEnabled =
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FinanceCommandService } from '@household/application'
|
import type { FinanceCommandService } from '@household/application'
|
||||||
|
import type { HouseholdConfigurationRepository } from '@household/ports'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
|
|
||||||
function commandArgs(ctx: Context): string[] {
|
function commandArgs(ctx: Context): string[] {
|
||||||
@@ -10,9 +11,39 @@ function commandArgs(ctx: Context): string[] {
|
|||||||
return raw.split(/\s+/).filter(Boolean)
|
return raw.split(/\s+/).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFinanceCommandsService(financeService: FinanceCommandService): {
|
function isGroupChat(ctx: Context): boolean {
|
||||||
|
return ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFinanceCommandsService(options: {
|
||||||
|
householdConfigurationRepository: HouseholdConfigurationRepository
|
||||||
|
financeServiceForHousehold: (householdId: string) => FinanceCommandService
|
||||||
|
}): {
|
||||||
register: (bot: Bot) => void
|
register: (bot: Bot) => void
|
||||||
} {
|
} {
|
||||||
|
async function resolveGroupFinanceService(ctx: Context): Promise<{
|
||||||
|
service: FinanceCommandService
|
||||||
|
householdId: string
|
||||||
|
} | null> {
|
||||||
|
if (!isGroupChat(ctx)) {
|
||||||
|
await ctx.reply('Use this command inside a household group.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const household = await options.householdConfigurationRepository.getTelegramHouseholdChat(
|
||||||
|
ctx.chat!.id.toString()
|
||||||
|
)
|
||||||
|
if (!household) {
|
||||||
|
await ctx.reply('Household is not configured for this chat yet. Run /setup first.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: options.financeServiceForHousehold(household.householdId),
|
||||||
|
householdId: household.householdId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function requireMember(ctx: Context) {
|
async function requireMember(ctx: Context) {
|
||||||
const telegramUserId = ctx.from?.id?.toString()
|
const telegramUserId = ctx.from?.id?.toString()
|
||||||
if (!telegramUserId) {
|
if (!telegramUserId) {
|
||||||
@@ -20,33 +51,42 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await financeService.getMemberByTelegramUserId(telegramUserId)
|
const scoped = await resolveGroupFinanceService(ctx)
|
||||||
|
if (!scoped) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await scoped.service.getMemberByTelegramUserId(telegramUserId)
|
||||||
if (!member) {
|
if (!member) {
|
||||||
await ctx.reply('You are not a member of this household.')
|
await ctx.reply('You are not a member of this household.')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return member
|
return {
|
||||||
|
member,
|
||||||
|
service: scoped.service,
|
||||||
|
householdId: scoped.householdId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireAdmin(ctx: Context) {
|
async function requireAdmin(ctx: Context) {
|
||||||
const member = await requireMember(ctx)
|
const resolved = await requireMember(ctx)
|
||||||
if (!member) {
|
if (!resolved) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!member.isAdmin) {
|
if (!resolved.member.isAdmin) {
|
||||||
await ctx.reply('Only household admins can use this command.')
|
await ctx.reply('Only household admins can use this command.')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return member
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
function register(bot: Bot): void {
|
function register(bot: Bot): void {
|
||||||
bot.command('cycle_open', async (ctx) => {
|
bot.command('cycle_open', async (ctx) => {
|
||||||
const admin = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!admin) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +97,7 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cycle = await financeService.openCycle(args[0]!, args[1])
|
const cycle = await resolved.service.openCycle(args[0]!, args[1])
|
||||||
await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`)
|
await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to open cycle: ${(error as Error).message}`)
|
await ctx.reply(`Failed to open cycle: ${(error as Error).message}`)
|
||||||
@@ -65,13 +105,13 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot.command('cycle_close', async (ctx) => {
|
bot.command('cycle_close', async (ctx) => {
|
||||||
const admin = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!admin) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cycle = await financeService.closeCycle(commandArgs(ctx)[0])
|
const cycle = await resolved.service.closeCycle(commandArgs(ctx)[0])
|
||||||
if (!cycle) {
|
if (!cycle) {
|
||||||
await ctx.reply('No cycle found to close.')
|
await ctx.reply('No cycle found to close.')
|
||||||
return
|
return
|
||||||
@@ -84,8 +124,8 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot.command('rent_set', async (ctx) => {
|
bot.command('rent_set', async (ctx) => {
|
||||||
const admin = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!admin) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +136,7 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await financeService.setRent(args[0]!, args[1], args[2])
|
const result = await resolved.service.setRent(args[0]!, args[1], args[2])
|
||||||
if (!result) {
|
if (!result) {
|
||||||
await ctx.reply('No period provided and no open cycle found.')
|
await ctx.reply('No period provided and no open cycle found.')
|
||||||
return
|
return
|
||||||
@@ -111,8 +151,8 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot.command('utility_add', async (ctx) => {
|
bot.command('utility_add', async (ctx) => {
|
||||||
const admin = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!admin) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +163,12 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await financeService.addUtilityBill(args[0]!, args[1]!, admin.id, args[2])
|
const result = await resolved.service.addUtilityBill(
|
||||||
|
args[0]!,
|
||||||
|
args[1]!,
|
||||||
|
resolved.member.id,
|
||||||
|
args[2]
|
||||||
|
)
|
||||||
if (!result) {
|
if (!result) {
|
||||||
await ctx.reply('No open cycle found. Use /cycle_open first.')
|
await ctx.reply('No open cycle found. Use /cycle_open first.')
|
||||||
return
|
return
|
||||||
@@ -138,13 +183,13 @@ export function createFinanceCommandsService(financeService: FinanceCommandServi
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot.command('statement', async (ctx) => {
|
bot.command('statement', async (ctx) => {
|
||||||
const member = await requireMember(ctx)
|
const resolved = await requireMember(ctx)
|
||||||
if (!member) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const statement = await financeService.generateStatement(commandArgs(ctx)[0])
|
const statement = await resolved.service.generateStatement(commandArgs(ctx)[0])
|
||||||
if (!statement) {
|
if (!statement) {
|
||||||
await ctx.reply('No cycle found for statement.')
|
await ctx.reply('No cycle found for statement.')
|
||||||
return
|
return
|
||||||
|
|||||||
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
|
} as const
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildJoinMiniAppUrl(
|
||||||
|
miniAppUrl: string | undefined,
|
||||||
|
botUsername: string | undefined,
|
||||||
|
joinToken: string
|
||||||
|
): string | null {
|
||||||
|
const normalizedMiniAppUrl = miniAppUrl?.trim()
|
||||||
|
if (!normalizedMiniAppUrl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(normalizedMiniAppUrl)
|
||||||
|
url.searchParams.set('join', joinToken)
|
||||||
|
|
||||||
|
if (botUsername && botUsername.trim().length > 0) {
|
||||||
|
url.searchParams.set('bot', botUsername.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function miniAppReplyMarkup(
|
||||||
|
miniAppUrl: string | undefined,
|
||||||
|
botUsername: string | undefined,
|
||||||
|
joinToken: string
|
||||||
|
) {
|
||||||
|
const webAppUrl = buildJoinMiniAppUrl(miniAppUrl, botUsername, joinToken)
|
||||||
|
if (!webAppUrl) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Open mini app',
|
||||||
|
web_app: {
|
||||||
|
url: webAppUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerHouseholdSetupCommands(options: {
|
export function registerHouseholdSetupCommands(options: {
|
||||||
bot: Bot
|
bot: Bot
|
||||||
householdSetupService: HouseholdSetupService
|
householdSetupService: HouseholdSetupService
|
||||||
householdOnboardingService: HouseholdOnboardingService
|
householdOnboardingService: HouseholdOnboardingService
|
||||||
householdAdminService: HouseholdAdminService
|
householdAdminService: HouseholdAdminService
|
||||||
|
miniAppUrl?: string
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): void {
|
}): void {
|
||||||
options.bot.command('start', async (ctx) => {
|
options.bot.command('start', async (ctx) => {
|
||||||
@@ -171,13 +218,15 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
|
|
||||||
if (result.status === 'active') {
|
if (result.status === 'active') {
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`You are already an active member. Open the mini app to view ${result.member.displayName}.`
|
`You are already an active member. Open the mini app to view ${result.member.displayName}.`,
|
||||||
|
miniAppReplyMarkup(options.miniAppUrl, ctx.me.username, joinToken)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`Join request sent for ${result.household.name}. Wait for a household admin to confirm you.`
|
`Join request sent for ${result.household.name}. Wait for a household admin to confirm you.`,
|
||||||
|
miniAppReplyMarkup(options.miniAppUrl, ctx.me.username, joinToken)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -47,46 +47,60 @@ const shutdownTasks: Array<() => Promise<void>> = []
|
|||||||
const householdConfigurationRepositoryClient = runtime.databaseUrl
|
const householdConfigurationRepositoryClient = runtime.databaseUrl
|
||||||
? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
|
? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
|
||||||
: null
|
: null
|
||||||
const financeRepositoryClient =
|
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
|
||||||
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled
|
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
|
||||||
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
|
|
||||||
: null
|
|
||||||
const financeService = financeRepositoryClient
|
|
||||||
? createFinanceCommandService(financeRepositoryClient.repository)
|
|
||||||
: null
|
|
||||||
const householdOnboardingService = householdConfigurationRepositoryClient
|
const householdOnboardingService = householdConfigurationRepositoryClient
|
||||||
? createHouseholdOnboardingService({
|
? createHouseholdOnboardingService({
|
||||||
repository: householdConfigurationRepositoryClient.repository,
|
repository: householdConfigurationRepositoryClient.repository
|
||||||
...(financeRepositoryClient
|
|
||||||
? {
|
|
||||||
getMemberByTelegramUserId: financeRepositoryClient.repository.getMemberByTelegramUserId
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled
|
|
||||||
? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!)
|
|
||||||
: null
|
|
||||||
const telegramPendingActionRepositoryClient =
|
const telegramPendingActionRepositoryClient =
|
||||||
runtime.databaseUrl && runtime.anonymousFeedbackEnabled
|
runtime.databaseUrl && runtime.anonymousFeedbackEnabled
|
||||||
? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
|
? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
|
||||||
: null
|
: null
|
||||||
const anonymousFeedbackService = anonymousFeedbackRepositoryClient
|
const anonymousFeedbackRepositoryClients = new Map<
|
||||||
? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository)
|
string,
|
||||||
: null
|
ReturnType<typeof createDbAnonymousFeedbackRepository>
|
||||||
|
>()
|
||||||
|
const anonymousFeedbackServices = new Map<
|
||||||
|
string,
|
||||||
|
ReturnType<typeof createAnonymousFeedbackService>
|
||||||
|
>()
|
||||||
|
|
||||||
if (financeRepositoryClient) {
|
function financeServiceForHousehold(householdId: string) {
|
||||||
shutdownTasks.push(financeRepositoryClient.close)
|
const existing = financeServices.get(householdId)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId)
|
||||||
|
financeRepositoryClients.set(householdId, repositoryClient)
|
||||||
|
shutdownTasks.push(repositoryClient.close)
|
||||||
|
|
||||||
|
const service = createFinanceCommandService(repositoryClient.repository)
|
||||||
|
financeServices.set(householdId, service)
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
function anonymousFeedbackServiceForHousehold(householdId: string) {
|
||||||
|
const existing = anonymousFeedbackServices.get(householdId)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryClient = createDbAnonymousFeedbackRepository(runtime.databaseUrl!, householdId)
|
||||||
|
anonymousFeedbackRepositoryClients.set(householdId, repositoryClient)
|
||||||
|
shutdownTasks.push(repositoryClient.close)
|
||||||
|
|
||||||
|
const service = createAnonymousFeedbackService(repositoryClient.repository)
|
||||||
|
anonymousFeedbackServices.set(householdId, service)
|
||||||
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
if (householdConfigurationRepositoryClient) {
|
if (householdConfigurationRepositoryClient) {
|
||||||
shutdownTasks.push(householdConfigurationRepositoryClient.close)
|
shutdownTasks.push(householdConfigurationRepositoryClient.close)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anonymousFeedbackRepositoryClient) {
|
|
||||||
shutdownTasks.push(anonymousFeedbackRepositoryClient.close)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (telegramPendingActionRepositoryClient) {
|
if (telegramPendingActionRepositoryClient) {
|
||||||
shutdownTasks.push(telegramPendingActionRepositoryClient.close)
|
shutdownTasks.push(telegramPendingActionRepositoryClient.close)
|
||||||
}
|
}
|
||||||
@@ -120,7 +134,10 @@ if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (runtime.financeCommandsEnabled) {
|
if (runtime.financeCommandsEnabled) {
|
||||||
const financeCommands = createFinanceCommandsService(financeService!)
|
const financeCommands = createFinanceCommandsService({
|
||||||
|
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
|
||||||
|
financeServiceForHousehold
|
||||||
|
})
|
||||||
|
|
||||||
financeCommands.register(bot)
|
financeCommands.register(bot)
|
||||||
} else {
|
} else {
|
||||||
@@ -129,7 +146,7 @@ if (runtime.financeCommandsEnabled) {
|
|||||||
event: 'runtime.feature_disabled',
|
event: 'runtime.feature_disabled',
|
||||||
feature: 'finance-commands'
|
feature: 'finance-commands'
|
||||||
},
|
},
|
||||||
'Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.'
|
'Finance commands are disabled. Set DATABASE_URL to enable household lookups.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +160,11 @@ if (householdConfigurationRepositoryClient) {
|
|||||||
householdConfigurationRepositoryClient.repository
|
householdConfigurationRepositoryClient.repository
|
||||||
),
|
),
|
||||||
householdOnboardingService: householdOnboardingService!,
|
householdOnboardingService: householdOnboardingService!,
|
||||||
|
...(runtime.miniAppAllowedOrigins[0]
|
||||||
|
? {
|
||||||
|
miniAppUrl: runtime.miniAppAllowedOrigins[0]
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
logger: getLogger('household-setup')
|
logger: getLogger('household-setup')
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -180,13 +202,16 @@ if (!runtime.reminderJobsEnabled) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anonymousFeedbackService) {
|
if (
|
||||||
|
runtime.anonymousFeedbackEnabled &&
|
||||||
|
householdConfigurationRepositoryClient &&
|
||||||
|
telegramPendingActionRepositoryClient
|
||||||
|
) {
|
||||||
registerAnonymousFeedback({
|
registerAnonymousFeedback({
|
||||||
bot,
|
bot,
|
||||||
anonymousFeedbackService,
|
anonymousFeedbackServiceForHousehold,
|
||||||
|
householdConfigurationRepository: householdConfigurationRepositoryClient!.repository,
|
||||||
promptRepository: telegramPendingActionRepositoryClient!.repository,
|
promptRepository: telegramPendingActionRepositoryClient!.repository,
|
||||||
householdChatId: runtime.telegramHouseholdChatId!,
|
|
||||||
feedbackTopicId: runtime.telegramFeedbackTopicId!,
|
|
||||||
logger: getLogger('anonymous-feedback')
|
logger: getLogger('anonymous-feedback')
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -195,7 +220,7 @@ if (anonymousFeedbackService) {
|
|||||||
event: 'runtime.feature_disabled',
|
event: 'runtime.feature_disabled',
|
||||||
feature: 'anonymous-feedback'
|
feature: 'anonymous-feedback'
|
||||||
},
|
},
|
||||||
'Anonymous feedback is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_FEEDBACK_TOPIC_ID to enable.'
|
'Anonymous feedback is disabled. Set DATABASE_URL to enable household and topic lookups.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,11 +244,11 @@ const server = createBotWebhookServer({
|
|||||||
logger: getLogger('miniapp-auth')
|
logger: getLogger('miniapp-auth')
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
miniAppDashboard: financeService
|
miniAppDashboard: householdOnboardingService
|
||||||
? createMiniAppDashboardHandler({
|
? createMiniAppDashboardHandler({
|
||||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
botToken: runtime.telegramBotToken,
|
botToken: runtime.telegramBotToken,
|
||||||
financeService,
|
financeServiceForHousehold,
|
||||||
onboardingService: householdOnboardingService!,
|
onboardingService: householdOnboardingService!,
|
||||||
logger: getLogger('miniapp-dashboard')
|
logger: getLogger('miniapp-dashboard')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
title: 'Kojori House'
|
title: 'Kojori House'
|
||||||
}
|
}
|
||||||
let joinToken: string | null = 'join-token'
|
let joinToken: string | null = 'join-token'
|
||||||
|
const members = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string
|
||||||
|
householdId: string
|
||||||
|
telegramUserId: string
|
||||||
|
displayName: string
|
||||||
|
isAdmin: boolean
|
||||||
|
}
|
||||||
|
>()
|
||||||
let pending: {
|
let pending: {
|
||||||
householdId: string
|
householdId: string
|
||||||
householdName: string
|
householdName: string
|
||||||
@@ -33,6 +43,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
household
|
household
|
||||||
}),
|
}),
|
||||||
getTelegramHouseholdChat: async () => household,
|
getTelegramHouseholdChat: async () => household,
|
||||||
|
getHouseholdChatByHouseholdId: async () => household,
|
||||||
bindHouseholdTopic: async (input) =>
|
bindHouseholdTopic: async (input) =>
|
||||||
({
|
({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
@@ -72,13 +83,22 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
},
|
},
|
||||||
getPendingHouseholdMember: async () => pending,
|
getPendingHouseholdMember: async () => pending,
|
||||||
findPendingHouseholdMemberByTelegramUserId: async () => pending,
|
findPendingHouseholdMemberByTelegramUserId: async () => pending,
|
||||||
ensureHouseholdMember: async (input) => ({
|
ensureHouseholdMember: async (input) => {
|
||||||
householdId: household.householdId,
|
const member = {
|
||||||
telegramUserId: input.telegramUserId,
|
id: `member-${input.telegramUserId}`,
|
||||||
displayName: input.displayName,
|
householdId: household.householdId,
|
||||||
isAdmin: input.isAdmin === true
|
telegramUserId: input.telegramUserId,
|
||||||
}),
|
displayName: input.displayName,
|
||||||
getHouseholdMember: async () => null,
|
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] : []),
|
listPendingHouseholdMembers: async () => (pending ? [pending] : []),
|
||||||
approvePendingHouseholdMember: async (input) => {
|
approvePendingHouseholdMember: async (input) => {
|
||||||
if (!pending || pending.telegramUserId !== input.telegramUserId) {
|
if (!pending || pending.telegramUserId !== input.telegramUserId) {
|
||||||
@@ -86,11 +106,13 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const member = {
|
const member = {
|
||||||
|
id: `member-${pending.telegramUserId}`,
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
telegramUserId: pending.telegramUserId,
|
telegramUserId: pending.telegramUserId,
|
||||||
displayName: pending.displayName,
|
displayName: pending.displayName,
|
||||||
isAdmin: input.isAdmin === true
|
isAdmin: input.isAdmin === true
|
||||||
}
|
}
|
||||||
|
members.set(pending.telegramUserId, member)
|
||||||
pending = null
|
pending = null
|
||||||
return member
|
return member
|
||||||
}
|
}
|
||||||
@@ -100,17 +122,18 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
describe('createMiniAppAuthHandler', () => {
|
describe('createMiniAppAuthHandler', () => {
|
||||||
test('returns an authorized session for a household member', async () => {
|
test('returns an authorized session for a household member', async () => {
|
||||||
const authDate = Math.floor(Date.now() / 1000)
|
const authDate = Math.floor(Date.now() / 1000)
|
||||||
|
const repository = onboardingRepository()
|
||||||
|
await repository.ensureHouseholdMember({
|
||||||
|
householdId: 'household-1',
|
||||||
|
telegramUserId: '123456',
|
||||||
|
displayName: 'Stan',
|
||||||
|
isAdmin: true
|
||||||
|
})
|
||||||
const auth = createMiniAppAuthHandler({
|
const auth = createMiniAppAuthHandler({
|
||||||
allowedOrigins: ['http://localhost:5173'],
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
botToken: 'test-bot-token',
|
botToken: 'test-bot-token',
|
||||||
onboardingService: createHouseholdOnboardingService({
|
onboardingService: createHouseholdOnboardingService({
|
||||||
repository: onboardingRepository(),
|
repository
|
||||||
getMemberByTelegramUserId: async () => ({
|
|
||||||
id: 'member-1',
|
|
||||||
telegramUserId: '123456',
|
|
||||||
displayName: 'Stan',
|
|
||||||
isAdmin: true
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export interface MiniAppSessionResult {
|
|||||||
authorized: boolean
|
authorized: boolean
|
||||||
member?: {
|
member?: {
|
||||||
id: string
|
id: string
|
||||||
|
householdId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
household
|
household
|
||||||
}),
|
}),
|
||||||
getTelegramHouseholdChat: async () => household,
|
getTelegramHouseholdChat: async () => household,
|
||||||
|
getHouseholdChatByHouseholdId: async () => household,
|
||||||
bindHouseholdTopic: async (input) =>
|
bindHouseholdTopic: async (input) =>
|
||||||
({
|
({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
@@ -113,12 +114,14 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
getPendingHouseholdMember: async () => null,
|
getPendingHouseholdMember: async () => null,
|
||||||
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
||||||
ensureHouseholdMember: async (input) => ({
|
ensureHouseholdMember: async (input) => ({
|
||||||
|
id: `member-${input.telegramUserId}`,
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
isAdmin: input.isAdmin === true
|
isAdmin: input.isAdmin === true
|
||||||
}),
|
}),
|
||||||
getHouseholdMember: async () => null,
|
getHouseholdMember: async () => null,
|
||||||
|
listHouseholdMembersByTelegramUserId: async () => [],
|
||||||
listPendingHouseholdMembers: async () => [],
|
listPendingHouseholdMembers: async () => [],
|
||||||
approvePendingHouseholdMember: async () => null
|
approvePendingHouseholdMember: async () => null
|
||||||
}
|
}
|
||||||
@@ -135,14 +138,23 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
isAdmin: true
|
isAdmin: true
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
const householdRepository = onboardingRepository()
|
||||||
|
householdRepository.listHouseholdMembersByTelegramUserId = async () => [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
householdId: 'household-1',
|
||||||
|
telegramUserId: '123456',
|
||||||
|
displayName: 'Stan',
|
||||||
|
isAdmin: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const dashboard = createMiniAppDashboardHandler({
|
const dashboard = createMiniAppDashboardHandler({
|
||||||
allowedOrigins: ['http://localhost:5173'],
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
botToken: 'test-bot-token',
|
botToken: 'test-bot-token',
|
||||||
financeService,
|
financeServiceForHousehold: () => financeService,
|
||||||
onboardingService: createHouseholdOnboardingService({
|
onboardingService: createHouseholdOnboardingService({
|
||||||
repository: onboardingRepository(),
|
repository: householdRepository
|
||||||
getMemberByTelegramUserId: financeService.getMemberByTelegramUserId
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -202,14 +214,23 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
isAdmin: true
|
isAdmin: true
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
const householdRepository = onboardingRepository()
|
||||||
|
householdRepository.listHouseholdMembersByTelegramUserId = async () => [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
householdId: 'household-1',
|
||||||
|
telegramUserId: '123456',
|
||||||
|
displayName: 'Stan',
|
||||||
|
isAdmin: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const dashboard = createMiniAppDashboardHandler({
|
const dashboard = createMiniAppDashboardHandler({
|
||||||
allowedOrigins: ['http://localhost:5173'],
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
botToken: 'test-bot-token',
|
botToken: 'test-bot-token',
|
||||||
financeService,
|
financeServiceForHousehold: () => financeService,
|
||||||
onboardingService: createHouseholdOnboardingService({
|
onboardingService: createHouseholdOnboardingService({
|
||||||
repository: onboardingRepository(),
|
repository: householdRepository
|
||||||
getMemberByTelegramUserId: financeService.getMemberByTelegramUserId
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
export function createMiniAppDashboardHandler(options: {
|
export function createMiniAppDashboardHandler(options: {
|
||||||
allowedOrigins: readonly string[]
|
allowedOrigins: readonly string[]
|
||||||
botToken: string
|
botToken: string
|
||||||
financeService: FinanceCommandService
|
financeServiceForHousehold: (householdId: string) => FinanceCommandService
|
||||||
onboardingService: HouseholdOnboardingService
|
onboardingService: HouseholdOnboardingService
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): {
|
}): {
|
||||||
@@ -62,7 +62,17 @@ export function createMiniAppDashboardHandler(options: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboard = await options.financeService.generateDashboard()
|
if (!session.member) {
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{ ok: false, error: 'Authenticated session is missing member context' },
|
||||||
|
500,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = await options
|
||||||
|
.financeServiceForHousehold(session.member.householdId)
|
||||||
|
.generateDashboard()
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
return miniAppJsonResponse(
|
return miniAppJsonResponse(
|
||||||
{ ok: false, error: 'No billing cycle available' },
|
{ ok: false, error: 'No billing cycle available' },
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { createTelegramBot } from './bot'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildPurchaseAcknowledgement,
|
||||||
extractPurchaseTopicCandidate,
|
extractPurchaseTopicCandidate,
|
||||||
|
registerPurchaseTopicIngestion,
|
||||||
resolveConfiguredPurchaseTopicRecord,
|
resolveConfiguredPurchaseTopicRecord,
|
||||||
|
type PurchaseMessageIngestionRepository,
|
||||||
type PurchaseTopicCandidate
|
type PurchaseTopicCandidate
|
||||||
} from './purchase-topic-ingestion'
|
} from './purchase-topic-ingestion'
|
||||||
|
|
||||||
@@ -25,6 +30,39 @@ function candidate(overrides: Partial<PurchaseTopicCandidate> = {}): PurchaseTop
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function purchaseUpdate(text: string) {
|
||||||
|
const commandToken = text.split(' ')[0] ?? text
|
||||||
|
|
||||||
|
return {
|
||||||
|
update_id: 1001,
|
||||||
|
message: {
|
||||||
|
message_id: 55,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
message_thread_id: 777,
|
||||||
|
is_topic_message: true,
|
||||||
|
chat: {
|
||||||
|
id: Number(config.householdChatId),
|
||||||
|
type: 'supergroup'
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
id: 10002,
|
||||||
|
is_bot: false,
|
||||||
|
first_name: 'Mia'
|
||||||
|
},
|
||||||
|
text,
|
||||||
|
entities: text.startsWith('/')
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
length: commandToken.length,
|
||||||
|
type: 'bot_command'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('extractPurchaseTopicCandidate', () => {
|
describe('extractPurchaseTopicCandidate', () => {
|
||||||
test('returns record when message belongs to configured topic', () => {
|
test('returns record when message belongs to configured topic', () => {
|
||||||
const record = extractPurchaseTopicCandidate(candidate(), config)
|
const record = extractPurchaseTopicCandidate(candidate(), config)
|
||||||
@@ -86,3 +124,169 @@ describe('resolveConfiguredPurchaseTopicRecord', () => {
|
|||||||
expect(record).toBeNull()
|
expect(record).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('buildPurchaseAcknowledgement', () => {
|
||||||
|
test('returns parsed acknowledgement with amount summary', () => {
|
||||||
|
const result = buildPurchaseAcknowledgement({
|
||||||
|
status: 'created',
|
||||||
|
processingStatus: 'parsed',
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: 'GEL',
|
||||||
|
parsedItemDescription: 'toilet paper',
|
||||||
|
parserConfidence: 92,
|
||||||
|
parserMode: 'rules'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe('Recorded purchase: toilet paper - 30.00 GEL')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns review acknowledgement when parsing needs review', () => {
|
||||||
|
const result = buildPurchaseAcknowledgement({
|
||||||
|
status: 'created',
|
||||||
|
processingStatus: 'needs_review',
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: 'GEL',
|
||||||
|
parsedItemDescription: 'shared purchase',
|
||||||
|
parserConfidence: 78,
|
||||||
|
parserMode: 'rules'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe('Saved for review: shared purchase - 30.00 GEL')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns parse failure acknowledgement without guessed values', () => {
|
||||||
|
const result = buildPurchaseAcknowledgement({
|
||||||
|
status: 'created',
|
||||||
|
processingStatus: 'parse_failed',
|
||||||
|
parsedAmountMinor: null,
|
||||||
|
parsedCurrency: null,
|
||||||
|
parsedItemDescription: null,
|
||||||
|
parserConfidence: null,
|
||||||
|
parserMode: null
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe("Saved for review: I couldn't parse this purchase yet.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not acknowledge duplicates', () => {
|
||||||
|
expect(
|
||||||
|
buildPurchaseAcknowledgement({
|
||||||
|
status: 'duplicate'
|
||||||
|
})
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('registerPurchaseTopicIngestion', () => {
|
||||||
|
test('replies in-topic after a parsed purchase is recorded', async () => {
|
||||||
|
const bot = createTelegramBot('000000:test-token')
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
|
bot.botInfo = {
|
||||||
|
id: 999000,
|
||||||
|
is_bot: true,
|
||||||
|
first_name: 'Household Test Bot',
|
||||||
|
username: 'household_test_bot',
|
||||||
|
can_join_groups: true,
|
||||||
|
can_read_all_group_messages: false,
|
||||||
|
supports_inline_queries: false,
|
||||||
|
can_connect_to_business: false,
|
||||||
|
has_main_web_app: false,
|
||||||
|
has_topics_enabled: true,
|
||||||
|
allows_users_to_create_topics: false
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
message_id: calls.length,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: Number(config.householdChatId),
|
||||||
|
type: 'supergroup'
|
||||||
|
},
|
||||||
|
text: 'ok'
|
||||||
|
}
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
|
async save() {
|
||||||
|
return {
|
||||||
|
status: 'created',
|
||||||
|
processingStatus: 'parsed',
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: 'GEL',
|
||||||
|
parsedItemDescription: 'toilet paper',
|
||||||
|
parserConfidence: 92,
|
||||||
|
parserMode: 'rules'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0]?.method).toBe('sendMessage')
|
||||||
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
|
chat_id: Number(config.householdChatId),
|
||||||
|
reply_parameters: {
|
||||||
|
message_id: 55
|
||||||
|
},
|
||||||
|
text: 'Recorded purchase: toilet paper - 30.00 GEL'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not reply for duplicate deliveries', async () => {
|
||||||
|
const bot = createTelegramBot('000000:test-token')
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
|
bot.botInfo = {
|
||||||
|
id: 999000,
|
||||||
|
is_bot: true,
|
||||||
|
first_name: 'Household Test Bot',
|
||||||
|
username: 'household_test_bot',
|
||||||
|
can_join_groups: true,
|
||||||
|
can_read_all_group_messages: false,
|
||||||
|
supports_inline_queries: false,
|
||||||
|
can_connect_to_business: false,
|
||||||
|
has_main_web_app: false,
|
||||||
|
has_topics_enabled: true,
|
||||||
|
allows_users_to_create_topics: false
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
message_id: calls.length,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: Number(config.householdChatId),
|
||||||
|
type: 'supergroup'
|
||||||
|
},
|
||||||
|
text: 'ok'
|
||||||
|
}
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: PurchaseMessageIngestionRepository = {
|
||||||
|
async save() {
|
||||||
|
return {
|
||||||
|
status: 'duplicate'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPurchaseTopicIngestion(bot, config, repository)
|
||||||
|
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application'
|
import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application'
|
||||||
|
import { Money } from '@household/domain'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
@@ -30,11 +31,27 @@ export interface PurchaseTopicRecord extends PurchaseTopicCandidate {
|
|||||||
householdId: string
|
householdId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PurchaseMessageProcessingStatus = 'parsed' | 'needs_review' | 'parse_failed'
|
||||||
|
|
||||||
|
export type PurchaseMessageIngestionResult =
|
||||||
|
| {
|
||||||
|
status: 'duplicate'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'created'
|
||||||
|
processingStatus: PurchaseMessageProcessingStatus
|
||||||
|
parsedAmountMinor: bigint | null
|
||||||
|
parsedCurrency: 'GEL' | 'USD' | null
|
||||||
|
parsedItemDescription: string | null
|
||||||
|
parserConfidence: number | null
|
||||||
|
parserMode: 'rules' | 'llm' | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface PurchaseMessageIngestionRepository {
|
export interface PurchaseMessageIngestionRepository {
|
||||||
save(
|
save(
|
||||||
record: PurchaseTopicRecord,
|
record: PurchaseTopicRecord,
|
||||||
llmFallback?: PurchaseParserLlmFallback
|
llmFallback?: PurchaseParserLlmFallback
|
||||||
): Promise<'created' | 'duplicate'>
|
): Promise<PurchaseMessageIngestionResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractPurchaseTopicCandidate(
|
export function extractPurchaseTopicCandidate(
|
||||||
@@ -172,7 +189,21 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
})
|
})
|
||||||
.returning({ id: schema.purchaseMessages.id })
|
.returning({ id: schema.purchaseMessages.id })
|
||||||
|
|
||||||
return inserted.length > 0 ? 'created' : 'duplicate'
|
if (inserted.length === 0) {
|
||||||
|
return {
|
||||||
|
status: 'duplicate'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'created',
|
||||||
|
processingStatus,
|
||||||
|
parsedAmountMinor: parsed?.amountMinor ?? null,
|
||||||
|
parsedCurrency: parsed?.currency ?? null,
|
||||||
|
parsedItemDescription: parsed?.itemDescription ?? null,
|
||||||
|
parserConfidence: parsed?.confidence ?? null,
|
||||||
|
parserMode: parsed?.parserMode ?? null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +215,51 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPurchaseSummary(
|
||||||
|
result: Extract<PurchaseMessageIngestionResult, { status: 'created' }>
|
||||||
|
): string {
|
||||||
|
if (
|
||||||
|
result.parsedAmountMinor === null ||
|
||||||
|
result.parsedCurrency === null ||
|
||||||
|
result.parsedItemDescription === null
|
||||||
|
) {
|
||||||
|
return 'shared purchase'
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Money.fromMinor(result.parsedAmountMinor, result.parsedCurrency)
|
||||||
|
return `${result.parsedItemDescription} - ${amount.toMajorString()} ${result.parsedCurrency}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPurchaseAcknowledgement(
|
||||||
|
result: PurchaseMessageIngestionResult
|
||||||
|
): string | null {
|
||||||
|
if (result.status === 'duplicate') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (result.processingStatus) {
|
||||||
|
case 'parsed':
|
||||||
|
return `Recorded purchase: ${formatPurchaseSummary(result)}`
|
||||||
|
case 'needs_review':
|
||||||
|
return `Saved for review: ${formatPurchaseSummary(result)}`
|
||||||
|
case 'parse_failed':
|
||||||
|
return "Saved for review: I couldn't parse this purchase yet."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyToPurchaseMessage(ctx: Context, text: string): Promise<void> {
|
||||||
|
const message = ctx.msg
|
||||||
|
if (!message) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.reply(text, {
|
||||||
|
reply_parameters: {
|
||||||
|
message_id: message.message_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
|
function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
|
||||||
const message = ctx.message
|
const message = ctx.message
|
||||||
if (!message || !('text' in message)) {
|
if (!message || !('text' in message)) {
|
||||||
@@ -244,11 +320,13 @@ export function registerPurchaseTopicIngestion(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await repository.save(record, options.llmFallback)
|
const status = await repository.save(record, options.llmFallback)
|
||||||
|
const acknowledgement = buildPurchaseAcknowledgement(status)
|
||||||
|
|
||||||
if (status === 'created') {
|
if (status.status === 'created') {
|
||||||
options.logger?.info(
|
options.logger?.info(
|
||||||
{
|
{
|
||||||
event: 'purchase.ingested',
|
event: 'purchase.ingested',
|
||||||
|
processingStatus: status.processingStatus,
|
||||||
chatId: record.chatId,
|
chatId: record.chatId,
|
||||||
threadId: record.threadId,
|
threadId: record.threadId,
|
||||||
messageId: record.messageId,
|
messageId: record.messageId,
|
||||||
@@ -258,6 +336,10 @@ export function registerPurchaseTopicIngestion(
|
|||||||
'Purchase topic message ingested'
|
'Purchase topic message ingested'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (acknowledgement) {
|
||||||
|
await replyToPurchaseMessage(ctx, acknowledgement)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.logger?.error(
|
options.logger?.error(
|
||||||
{
|
{
|
||||||
@@ -308,12 +390,14 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await repository.save(record, options.llmFallback)
|
const status = await repository.save(record, options.llmFallback)
|
||||||
|
const acknowledgement = buildPurchaseAcknowledgement(status)
|
||||||
|
|
||||||
if (status === 'created') {
|
if (status.status === 'created') {
|
||||||
options.logger?.info(
|
options.logger?.info(
|
||||||
{
|
{
|
||||||
event: 'purchase.ingested',
|
event: 'purchase.ingested',
|
||||||
householdId: record.householdId,
|
householdId: record.householdId,
|
||||||
|
processingStatus: status.processingStatus,
|
||||||
chatId: record.chatId,
|
chatId: record.chatId,
|
||||||
threadId: record.threadId,
|
threadId: record.threadId,
|
||||||
messageId: record.messageId,
|
messageId: record.messageId,
|
||||||
@@ -323,6 +407,10 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
'Purchase topic message ingested'
|
'Purchase topic message ingested'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (acknowledgement) {
|
||||||
|
await replyToPurchaseMessage(ctx, acknowledgement)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.logger?.error(
|
options.logger?.error(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -86,12 +86,14 @@ function toHouseholdPendingMemberRecord(row: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toHouseholdMemberRecord(row: {
|
function toHouseholdMemberRecord(row: {
|
||||||
|
id: string
|
||||||
householdId: string
|
householdId: string
|
||||||
telegramUserId: string
|
telegramUserId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: number
|
isAdmin: number
|
||||||
}): HouseholdMemberRecord {
|
}): HouseholdMemberRecord {
|
||||||
return {
|
return {
|
||||||
|
id: row.id,
|
||||||
householdId: row.householdId,
|
householdId: row.householdId,
|
||||||
telegramUserId: row.telegramUserId,
|
telegramUserId: row.telegramUserId,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
@@ -219,6 +221,27 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
return row ? toHouseholdTelegramChatRecord(row) : null
|
return row ? toHouseholdTelegramChatRecord(row) : null
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getHouseholdChatByHouseholdId(householdId) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
householdId: schema.householdTelegramChats.householdId,
|
||||||
|
householdName: schema.households.name,
|
||||||
|
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
||||||
|
telegramChatType: schema.householdTelegramChats.telegramChatType,
|
||||||
|
title: schema.householdTelegramChats.title
|
||||||
|
})
|
||||||
|
.from(schema.householdTelegramChats)
|
||||||
|
.innerJoin(
|
||||||
|
schema.households,
|
||||||
|
eq(schema.householdTelegramChats.householdId, schema.households.id)
|
||||||
|
)
|
||||||
|
.where(eq(schema.householdTelegramChats.householdId, householdId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
return row ? toHouseholdTelegramChatRecord(row) : null
|
||||||
|
},
|
||||||
|
|
||||||
async bindHouseholdTopic(input) {
|
async bindHouseholdTopic(input) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.insert(schema.householdTopicBindings)
|
.insert(schema.householdTopicBindings)
|
||||||
@@ -535,6 +558,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
|
id: schema.members.id,
|
||||||
householdId: schema.members.householdId,
|
householdId: schema.members.householdId,
|
||||||
telegramUserId: schema.members.telegramUserId,
|
telegramUserId: schema.members.telegramUserId,
|
||||||
displayName: schema.members.displayName,
|
displayName: schema.members.displayName,
|
||||||
@@ -552,6 +576,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
async getHouseholdMember(householdId, telegramUserId) {
|
async getHouseholdMember(householdId, telegramUserId) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
|
id: schema.members.id,
|
||||||
householdId: schema.members.householdId,
|
householdId: schema.members.householdId,
|
||||||
telegramUserId: schema.members.telegramUserId,
|
telegramUserId: schema.members.telegramUserId,
|
||||||
displayName: schema.members.displayName,
|
displayName: schema.members.displayName,
|
||||||
@@ -570,6 +595,22 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
return row ? toHouseholdMemberRecord(row) : null
|
return row ? toHouseholdMemberRecord(row) : null
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listHouseholdMembersByTelegramUserId(telegramUserId) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: schema.members.id,
|
||||||
|
householdId: schema.members.householdId,
|
||||||
|
telegramUserId: schema.members.telegramUserId,
|
||||||
|
displayName: schema.members.displayName,
|
||||||
|
isAdmin: schema.members.isAdmin
|
||||||
|
})
|
||||||
|
.from(schema.members)
|
||||||
|
.where(eq(schema.members.telegramUserId, telegramUserId))
|
||||||
|
.orderBy(schema.members.householdId, schema.members.displayName)
|
||||||
|
|
||||||
|
return rows.map(toHouseholdMemberRecord)
|
||||||
|
},
|
||||||
|
|
||||||
async listPendingHouseholdMembers(householdId) {
|
async listPendingHouseholdMembers(householdId) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -640,6 +681,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
|
id: schema.members.id,
|
||||||
householdId: schema.members.householdId,
|
householdId: schema.members.householdId,
|
||||||
telegramUserId: schema.members.telegramUserId,
|
telegramUserId: schema.members.telegramUserId,
|
||||||
displayName: schema.members.displayName,
|
displayName: schema.members.displayName,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function createRepositoryStub() {
|
|||||||
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
|
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
|
||||||
|
|
||||||
members.set('1', {
|
members.set('1', {
|
||||||
|
id: 'member-1',
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
telegramUserId: '1',
|
telegramUserId: '1',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
@@ -43,6 +44,7 @@ function createRepositoryStub() {
|
|||||||
household
|
household
|
||||||
}),
|
}),
|
||||||
getTelegramHouseholdChat: async () => household,
|
getTelegramHouseholdChat: async () => household,
|
||||||
|
getHouseholdChatByHouseholdId: async () => household,
|
||||||
bindHouseholdTopic: async (input) =>
|
bindHouseholdTopic: async (input) =>
|
||||||
({
|
({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
@@ -80,6 +82,7 @@ function createRepositoryStub() {
|
|||||||
pendingMembers.get(telegramUserId) ?? null,
|
pendingMembers.get(telegramUserId) ?? null,
|
||||||
ensureHouseholdMember: async (input) => {
|
ensureHouseholdMember: async (input) => {
|
||||||
const record: HouseholdMemberRecord = {
|
const record: HouseholdMemberRecord = {
|
||||||
|
id: `member-${input.telegramUserId}`,
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
@@ -89,6 +92,8 @@ function createRepositoryStub() {
|
|||||||
return record
|
return record
|
||||||
},
|
},
|
||||||
getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null,
|
getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null,
|
||||||
|
listHouseholdMembersByTelegramUserId: async (telegramUserId) =>
|
||||||
|
[...members.values()].filter((member) => member.telegramUserId === telegramUserId),
|
||||||
listPendingHouseholdMembers: async () => [...pendingMembers.values()],
|
listPendingHouseholdMembers: async () => [...pendingMembers.values()],
|
||||||
approvePendingHouseholdMember: async (input) => {
|
approvePendingHouseholdMember: async (input) => {
|
||||||
const pending = pendingMembers.get(input.telegramUserId)
|
const pending = pendingMembers.get(input.telegramUserId)
|
||||||
@@ -99,6 +104,7 @@ function createRepositoryStub() {
|
|||||||
pendingMembers.delete(input.telegramUserId)
|
pendingMembers.delete(input.telegramUserId)
|
||||||
|
|
||||||
const member: HouseholdMemberRecord = {
|
const member: HouseholdMemberRecord = {
|
||||||
|
id: `member-${pending.telegramUserId}`,
|
||||||
householdId: pending.householdId,
|
householdId: pending.householdId,
|
||||||
telegramUserId: pending.telegramUserId,
|
telegramUserId: pending.telegramUserId,
|
||||||
displayName: pending.displayName,
|
displayName: pending.displayName,
|
||||||
@@ -170,6 +176,7 @@ describe('createHouseholdAdminService', () => {
|
|||||||
status: 'approved',
|
status: 'approved',
|
||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
member: {
|
member: {
|
||||||
|
id: 'member-2',
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
telegramUserId: '2',
|
telegramUserId: '2',
|
||||||
displayName: 'Alice',
|
displayName: 'Alice',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
FinanceMemberRecord,
|
|
||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
|
HouseholdMemberRecord,
|
||||||
HouseholdJoinTokenRecord,
|
HouseholdJoinTokenRecord,
|
||||||
HouseholdPendingMemberRecord,
|
HouseholdPendingMemberRecord,
|
||||||
HouseholdTelegramChatRecord,
|
HouseholdTelegramChatRecord,
|
||||||
@@ -21,6 +21,7 @@ function createRepositoryStub() {
|
|||||||
}
|
}
|
||||||
let joinToken: HouseholdJoinTokenRecord | null = null
|
let joinToken: HouseholdJoinTokenRecord | null = null
|
||||||
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
|
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
|
||||||
|
const members = new Map<string, HouseholdMemberRecord>()
|
||||||
|
|
||||||
const repository: HouseholdConfigurationRepository = {
|
const repository: HouseholdConfigurationRepository = {
|
||||||
async registerTelegramHouseholdChat() {
|
async registerTelegramHouseholdChat() {
|
||||||
@@ -32,6 +33,9 @@ function createRepositoryStub() {
|
|||||||
async getTelegramHouseholdChat() {
|
async getTelegramHouseholdChat() {
|
||||||
return household
|
return household
|
||||||
},
|
},
|
||||||
|
async getHouseholdChatByHouseholdId() {
|
||||||
|
return household
|
||||||
|
},
|
||||||
async bindHouseholdTopic(input) {
|
async bindHouseholdTopic(input) {
|
||||||
const binding: HouseholdTopicBindingRecord = {
|
const binding: HouseholdTopicBindingRecord = {
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
@@ -84,15 +88,22 @@ function createRepositoryStub() {
|
|||||||
return pendingMembers.get(telegramUserId) ?? null
|
return pendingMembers.get(telegramUserId) ?? null
|
||||||
},
|
},
|
||||||
async ensureHouseholdMember(input) {
|
async ensureHouseholdMember(input) {
|
||||||
return {
|
const member = {
|
||||||
|
id: `member-${input.telegramUserId}`,
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
isAdmin: input.isAdmin === true
|
isAdmin: input.isAdmin === true
|
||||||
}
|
}
|
||||||
|
members.set(input.telegramUserId, member)
|
||||||
|
return member
|
||||||
},
|
},
|
||||||
async getHouseholdMember() {
|
async getHouseholdMember(_householdId, telegramUserId) {
|
||||||
return null
|
return members.get(telegramUserId) ?? null
|
||||||
|
},
|
||||||
|
async listHouseholdMembersByTelegramUserId(telegramUserId) {
|
||||||
|
const member = members.get(telegramUserId)
|
||||||
|
return member ? [member] : []
|
||||||
},
|
},
|
||||||
async listPendingHouseholdMembers() {
|
async listPendingHouseholdMembers() {
|
||||||
return [...pendingMembers.values()]
|
return [...pendingMembers.values()]
|
||||||
@@ -106,6 +117,7 @@ function createRepositoryStub() {
|
|||||||
pendingMembers.delete(input.telegramUserId)
|
pendingMembers.delete(input.telegramUserId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id: `member-${pending.telegramUserId}`,
|
||||||
householdId: pending.householdId,
|
householdId: pending.householdId,
|
||||||
telegramUserId: pending.telegramUserId,
|
telegramUserId: pending.telegramUserId,
|
||||||
displayName: pending.displayName,
|
displayName: pending.displayName,
|
||||||
@@ -209,17 +221,16 @@ describe('createHouseholdOnboardingService', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns active when the user is already a finance member', async () => {
|
test('returns active when the user is already a household member', async () => {
|
||||||
const { repository } = createRepositoryStub()
|
const { repository } = createRepositoryStub()
|
||||||
const member: FinanceMemberRecord = {
|
await repository.ensureHouseholdMember({
|
||||||
id: 'member-1',
|
householdId: 'household-1',
|
||||||
telegramUserId: '42',
|
telegramUserId: '42',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
isAdmin: true
|
isAdmin: true
|
||||||
}
|
})
|
||||||
const service = createHouseholdOnboardingService({
|
const service = createHouseholdOnboardingService({
|
||||||
repository,
|
repository
|
||||||
getMemberByTelegramUserId: async () => member
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const access = await service.getMiniAppAccess({
|
const access = await service.getMiniAppAccess({
|
||||||
@@ -233,10 +244,49 @@ describe('createHouseholdOnboardingService', () => {
|
|||||||
expect(access).toEqual({
|
expect(access).toEqual({
|
||||||
status: 'active',
|
status: 'active',
|
||||||
member: {
|
member: {
|
||||||
id: 'member-1',
|
id: 'member-42',
|
||||||
|
householdId: 'household-1',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
isAdmin: true
|
isAdmin: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('returns open_from_group when user belongs to multiple households and no join token is provided', async () => {
|
||||||
|
const { repository } = createRepositoryStub()
|
||||||
|
const member: HouseholdMemberRecord = {
|
||||||
|
id: 'member-1',
|
||||||
|
householdId: 'household-1',
|
||||||
|
telegramUserId: '42',
|
||||||
|
displayName: 'Stan',
|
||||||
|
isAdmin: true
|
||||||
|
}
|
||||||
|
const service = createHouseholdOnboardingService({ repository })
|
||||||
|
const duplicateRepository = repository as HouseholdConfigurationRepository & {
|
||||||
|
listHouseholdMembersByTelegramUserId: (
|
||||||
|
telegramUserId: string
|
||||||
|
) => Promise<readonly HouseholdMemberRecord[]>
|
||||||
|
}
|
||||||
|
duplicateRepository.listHouseholdMembersByTelegramUserId = async () => [
|
||||||
|
member,
|
||||||
|
{
|
||||||
|
id: 'member-2',
|
||||||
|
householdId: 'household-2',
|
||||||
|
telegramUserId: '42',
|
||||||
|
displayName: 'Stan elsewhere',
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const access = await service.getMiniAppAccess({
|
||||||
|
identity: {
|
||||||
|
telegramUserId: '42',
|
||||||
|
displayName: 'Stan'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(access).toEqual({
|
||||||
|
status: 'open_from_group'
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
import type { FinanceMemberRecord, HouseholdConfigurationRepository } from '@household/ports'
|
import type { HouseholdConfigurationRepository, HouseholdMemberRecord } from '@household/ports'
|
||||||
|
|
||||||
export interface HouseholdOnboardingIdentity {
|
export interface HouseholdOnboardingIdentity {
|
||||||
telegramUserId: string
|
telegramUserId: string
|
||||||
@@ -14,6 +14,7 @@ export type HouseholdMiniAppAccess =
|
|||||||
status: 'active'
|
status: 'active'
|
||||||
member: {
|
member: {
|
||||||
id: string
|
id: string
|
||||||
|
householdId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,7 @@ export interface HouseholdOnboardingService {
|
|||||||
status: 'active'
|
status: 'active'
|
||||||
member: {
|
member: {
|
||||||
id: string
|
id: string
|
||||||
|
householdId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
@@ -68,13 +70,15 @@ export interface HouseholdOnboardingService {
|
|||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMember(member: FinanceMemberRecord): {
|
function toMember(member: HouseholdMemberRecord): {
|
||||||
id: string
|
id: string
|
||||||
|
householdId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
id: member.id,
|
id: member.id,
|
||||||
|
householdId: member.householdId,
|
||||||
displayName: member.displayName,
|
displayName: member.displayName,
|
||||||
isAdmin: member.isAdmin
|
isAdmin: member.isAdmin
|
||||||
}
|
}
|
||||||
@@ -86,7 +90,6 @@ function generateJoinToken(): string {
|
|||||||
|
|
||||||
export function createHouseholdOnboardingService(options: {
|
export function createHouseholdOnboardingService(options: {
|
||||||
repository: HouseholdConfigurationRepository
|
repository: HouseholdConfigurationRepository
|
||||||
getMemberByTelegramUserId?: (telegramUserId: string) => Promise<FinanceMemberRecord | null>
|
|
||||||
tokenFactory?: () => string
|
tokenFactory?: () => string
|
||||||
}): HouseholdOnboardingService {
|
}): HouseholdOnboardingService {
|
||||||
const createToken = options.tokenFactory ?? generateJoinToken
|
const createToken = options.tokenFactory ?? generateJoinToken
|
||||||
@@ -121,14 +124,26 @@ export function createHouseholdOnboardingService(options: {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getMiniAppAccess(input) {
|
async getMiniAppAccess(input) {
|
||||||
const activeMember = options.getMemberByTelegramUserId
|
const activeMemberships = await options.repository.listHouseholdMembersByTelegramUserId(
|
||||||
? await options.getMemberByTelegramUserId(input.identity.telegramUserId)
|
input.identity.telegramUserId
|
||||||
: null
|
)
|
||||||
|
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 {
|
return {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
member: toMember(activeMember)
|
member: toMember(matchingActiveMember)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +166,7 @@ export function createHouseholdOnboardingService(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const household = await options.repository.getHouseholdByJoinToken(input.joinToken)
|
const household = requestedHousehold
|
||||||
if (!household) {
|
if (!household) {
|
||||||
return {
|
return {
|
||||||
status: 'open_from_group'
|
status: 'open_from_group'
|
||||||
@@ -189,9 +204,9 @@ export function createHouseholdOnboardingService(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeMember = options.getMemberByTelegramUserId
|
const activeMember = (
|
||||||
? await options.getMemberByTelegramUserId(input.identity.telegramUserId)
|
await options.repository.listHouseholdMembersByTelegramUserId(input.identity.telegramUserId)
|
||||||
: null
|
).find((member) => member.householdId === household.householdId)
|
||||||
|
|
||||||
if (activeMember) {
|
if (activeMember) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ function createRepositoryStub() {
|
|||||||
return households.get(telegramChatId) ?? null
|
return households.get(telegramChatId) ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getHouseholdChatByHouseholdId(householdId) {
|
||||||
|
return (
|
||||||
|
[...households.values()].find((household) => household.householdId === householdId) ?? null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
async bindHouseholdTopic(input) {
|
async bindHouseholdTopic(input) {
|
||||||
const next: HouseholdTopicBindingRecord = {
|
const next: HouseholdTopicBindingRecord = {
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
@@ -156,6 +162,7 @@ function createRepositoryStub() {
|
|||||||
const key = `${input.householdId}:${input.telegramUserId}`
|
const key = `${input.householdId}:${input.telegramUserId}`
|
||||||
const existing = members.get(key)
|
const existing = members.get(key)
|
||||||
const next: HouseholdMemberRecord = {
|
const next: HouseholdMemberRecord = {
|
||||||
|
id: existing?.id ?? `member-${input.telegramUserId}`,
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
@@ -169,6 +176,10 @@ function createRepositoryStub() {
|
|||||||
return members.get(`${householdId}:${telegramUserId}`) ?? null
|
return members.get(`${householdId}:${telegramUserId}`) ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listHouseholdMembersByTelegramUserId(telegramUserId) {
|
||||||
|
return [...members.values()].filter((member) => member.telegramUserId === telegramUserId)
|
||||||
|
},
|
||||||
|
|
||||||
async listPendingHouseholdMembers(householdId) {
|
async listPendingHouseholdMembers(householdId) {
|
||||||
return [...pendingMembers.values()].filter((entry) => entry.householdId === householdId)
|
return [...pendingMembers.values()].filter((entry) => entry.householdId === householdId)
|
||||||
},
|
},
|
||||||
@@ -183,6 +194,7 @@ function createRepositoryStub() {
|
|||||||
pendingMembers.delete(key)
|
pendingMembers.delete(key)
|
||||||
|
|
||||||
const member: HouseholdMemberRecord = {
|
const member: HouseholdMemberRecord = {
|
||||||
|
id: `member-${pending.telegramUserId}`,
|
||||||
householdId: pending.householdId,
|
householdId: pending.householdId,
|
||||||
telegramUserId: pending.telegramUserId,
|
telegramUserId: pending.telegramUserId,
|
||||||
displayName: pending.displayName,
|
displayName: pending.displayName,
|
||||||
@@ -220,6 +232,7 @@ describe('createHouseholdSetupService', () => {
|
|||||||
expect(result.household.telegramChatId).toBe('-100123')
|
expect(result.household.telegramChatId).toBe('-100123')
|
||||||
const admin = await repository.getHouseholdMember(result.household.householdId, '42')
|
const admin = await repository.getHouseholdMember(result.household.householdId, '42')
|
||||||
expect(admin).toEqual({
|
expect(admin).toEqual({
|
||||||
|
id: 'member-42',
|
||||||
householdId: result.household.householdId,
|
householdId: result.household.householdId,
|
||||||
telegramUserId: '42',
|
telegramUserId: '42',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface HouseholdPendingMemberRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HouseholdMemberRecord {
|
export interface HouseholdMemberRecord {
|
||||||
|
id: string
|
||||||
householdId: string
|
householdId: string
|
||||||
telegramUserId: string
|
telegramUserId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -57,6 +58,7 @@ export interface HouseholdConfigurationRepository {
|
|||||||
input: RegisterTelegramHouseholdChatInput
|
input: RegisterTelegramHouseholdChatInput
|
||||||
): Promise<RegisterTelegramHouseholdChatResult>
|
): Promise<RegisterTelegramHouseholdChatResult>
|
||||||
getTelegramHouseholdChat(telegramChatId: string): Promise<HouseholdTelegramChatRecord | null>
|
getTelegramHouseholdChat(telegramChatId: string): Promise<HouseholdTelegramChatRecord | null>
|
||||||
|
getHouseholdChatByHouseholdId(householdId: string): Promise<HouseholdTelegramChatRecord | null>
|
||||||
bindHouseholdTopic(input: {
|
bindHouseholdTopic(input: {
|
||||||
householdId: string
|
householdId: string
|
||||||
role: HouseholdTopicRole
|
role: HouseholdTopicRole
|
||||||
@@ -103,6 +105,9 @@ export interface HouseholdConfigurationRepository {
|
|||||||
householdId: string,
|
householdId: string,
|
||||||
telegramUserId: string
|
telegramUserId: string
|
||||||
): Promise<HouseholdMemberRecord | null>
|
): Promise<HouseholdMemberRecord | null>
|
||||||
|
listHouseholdMembersByTelegramUserId(
|
||||||
|
telegramUserId: string
|
||||||
|
): Promise<readonly HouseholdMemberRecord[]>
|
||||||
listPendingHouseholdMembers(householdId: string): Promise<readonly HouseholdPendingMemberRecord[]>
|
listPendingHouseholdMembers(householdId: string): Promise<readonly HouseholdPendingMemberRecord[]>
|
||||||
approvePendingHouseholdMember(input: {
|
approvePendingHouseholdMember(input: {
|
||||||
householdId: string
|
householdId: string
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { randomUUID } from 'node:crypto'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
|
|
||||||
import { createFinanceCommandService } from '@household/application'
|
import { createFinanceCommandService } from '@household/application'
|
||||||
import { createDbFinanceRepository } from '@household/adapters-db'
|
import {
|
||||||
|
createDbFinanceRepository,
|
||||||
|
createDbHouseholdConfigurationRepository
|
||||||
|
} from '@household/adapters-db'
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
|
|
||||||
import { createTelegramBot } from '../../apps/bot/src/bot'
|
import { createTelegramBot } from '../../apps/bot/src/bot'
|
||||||
@@ -132,6 +135,9 @@ async function run(): Promise<void> {
|
|||||||
let coreClient: ReturnType<typeof createDbClient> | undefined
|
let coreClient: ReturnType<typeof createDbClient> | undefined
|
||||||
let ingestionClient: ReturnType<typeof createPurchaseMessageRepository> | undefined
|
let ingestionClient: ReturnType<typeof createPurchaseMessageRepository> | undefined
|
||||||
let financeRepositoryClient: ReturnType<typeof createDbFinanceRepository> | undefined
|
let financeRepositoryClient: ReturnType<typeof createDbFinanceRepository> | undefined
|
||||||
|
let householdConfigurationRepositoryClient:
|
||||||
|
| ReturnType<typeof createDbHouseholdConfigurationRepository>
|
||||||
|
| undefined
|
||||||
|
|
||||||
const bot = createTelegramBot('000000:test-token')
|
const bot = createTelegramBot('000000:test-token')
|
||||||
const replies: string[] = []
|
const replies: string[] = []
|
||||||
@@ -181,8 +187,12 @@ async function run(): Promise<void> {
|
|||||||
|
|
||||||
ingestionClient = createPurchaseMessageRepository(databaseUrl)
|
ingestionClient = createPurchaseMessageRepository(databaseUrl)
|
||||||
financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household)
|
financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household)
|
||||||
|
householdConfigurationRepositoryClient = createDbHouseholdConfigurationRepository(databaseUrl)
|
||||||
const financeService = createFinanceCommandService(financeRepositoryClient.repository)
|
const financeService = createFinanceCommandService(financeRepositoryClient.repository)
|
||||||
const financeCommands = createFinanceCommandsService(financeService)
|
const financeCommands = createFinanceCommandsService({
|
||||||
|
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
||||||
|
financeServiceForHousehold: () => financeService
|
||||||
|
})
|
||||||
|
|
||||||
registerPurchaseTopicIngestion(
|
registerPurchaseTopicIngestion(
|
||||||
bot,
|
bot,
|
||||||
@@ -200,6 +210,12 @@ async function run(): Promise<void> {
|
|||||||
id: ids.household,
|
id: ids.household,
|
||||||
name: 'E2E Smoke Household'
|
name: 'E2E Smoke Household'
|
||||||
})
|
})
|
||||||
|
await coreClient.db.insert(schema.householdTelegramChats).values({
|
||||||
|
householdId: ids.household,
|
||||||
|
telegramChatId: chatId,
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'E2E Smoke Household'
|
||||||
|
})
|
||||||
|
|
||||||
await coreClient.db.insert(schema.members).values([
|
await coreClient.db.insert(schema.members).values([
|
||||||
{
|
{
|
||||||
@@ -338,7 +354,8 @@ async function run(): Promise<void> {
|
|||||||
: undefined,
|
: undefined,
|
||||||
coreClient?.queryClient.end({ timeout: 5 }),
|
coreClient?.queryClient.end({ timeout: 5 }),
|
||||||
ingestionClient?.close(),
|
ingestionClient?.close(),
|
||||||
financeRepositoryClient?.close()
|
financeRepositoryClient?.close(),
|
||||||
|
householdConfigurationRepositoryClient?.close()
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user