feat(bot): add multi-household setup flow

This commit is contained in:
2026-03-09 03:40:20 +04:00
parent f3991fe7ce
commit e63d81cda2
21 changed files with 3337 additions and 9 deletions

View File

@@ -0,0 +1,102 @@
import { randomUUID } from 'node:crypto'
import { afterAll, describe, expect, test } from 'bun:test'
import { eq, inArray } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import { createDbHouseholdConfigurationRepository } from './household-config-repository'
const databaseUrl = process.env.DATABASE_URL
const testIfDatabase = databaseUrl ? test : test.skip
describe('createDbHouseholdConfigurationRepository', () => {
const createdHouseholdIds: string[] = []
afterAll(async () => {
if (!databaseUrl || createdHouseholdIds.length === 0) {
return
}
const { db, queryClient } = createDbClient(databaseUrl, {
max: 1,
prepare: false
})
await db.delete(schema.households).where(inArray(schema.households.id, createdHouseholdIds))
await queryClient.end({ timeout: 5 })
})
testIfDatabase('registers a Telegram household chat and binds topics', async () => {
const repositoryClient = createDbHouseholdConfigurationRepository(databaseUrl!)
const suffix = randomUUID()
const telegramChatId = `-100${Date.now()}`
const registered = await repositoryClient.repository.registerTelegramHouseholdChat({
householdName: `Integration Household ${suffix}`,
telegramChatId,
telegramChatType: 'supergroup',
title: 'Integration Household'
})
createdHouseholdIds.push(registered.household.householdId)
expect(registered.status).toBe('created')
expect(registered.household.telegramChatId).toBe(telegramChatId)
const existing = await repositoryClient.repository.registerTelegramHouseholdChat({
householdName: 'Ignored replacement title',
telegramChatId,
telegramChatType: 'supergroup',
title: 'Updated Integration Household'
})
expect(existing.status).toBe('existing')
expect(existing.household.householdId).toBe(registered.household.householdId)
expect(existing.household.title).toBe('Updated Integration Household')
const purchase = await repositoryClient.repository.bindHouseholdTopic({
householdId: registered.household.householdId,
role: 'purchase',
telegramThreadId: '7001',
topicName: 'Общие покупки'
})
const feedback = await repositoryClient.repository.bindHouseholdTopic({
householdId: registered.household.householdId,
role: 'feedback',
telegramThreadId: '7002',
topicName: 'Feedback'
})
expect(purchase.role).toBe('purchase')
expect(feedback.role).toBe('feedback')
const resolvedChat = await repositoryClient.repository.getTelegramHouseholdChat(telegramChatId)
const resolvedPurchase = await repositoryClient.repository.findHouseholdTopicByTelegramContext({
telegramChatId,
telegramThreadId: '7001'
})
const bindings = await repositoryClient.repository.listHouseholdTopicBindings(
registered.household.householdId
)
expect(resolvedChat?.householdId).toBe(registered.household.householdId)
expect(resolvedPurchase?.role).toBe('purchase')
expect(bindings).toHaveLength(2)
const verificationClient = createDbClient(databaseUrl!, {
max: 1,
prepare: false
})
const storedChatRows = await verificationClient.db
.select({ title: schema.householdTelegramChats.title })
.from(schema.householdTelegramChats)
.where(eq(schema.householdTelegramChats.telegramChatId, telegramChatId))
.limit(1)
expect(storedChatRows[0]?.title).toBe('Updated Integration Household')
await verificationClient.queryClient.end({ timeout: 5 })
await repositoryClient.close()
})
})

View File

@@ -0,0 +1,273 @@
import { and, eq } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import {
HOUSEHOLD_TOPIC_ROLES,
type HouseholdConfigurationRepository,
type HouseholdTelegramChatRecord,
type HouseholdTopicBindingRecord,
type HouseholdTopicRole,
type RegisterTelegramHouseholdChatResult
} from '@household/ports'
function normalizeTopicRole(role: string): HouseholdTopicRole {
const normalized = role.trim().toLowerCase()
if ((HOUSEHOLD_TOPIC_ROLES as readonly string[]).includes(normalized)) {
return normalized as HouseholdTopicRole
}
throw new Error(`Unsupported household topic role: ${role}`)
}
function toHouseholdTelegramChatRecord(row: {
householdId: string
householdName: string
telegramChatId: string
telegramChatType: string
title: string | null
}): HouseholdTelegramChatRecord {
return {
householdId: row.householdId,
householdName: row.householdName,
telegramChatId: row.telegramChatId,
telegramChatType: row.telegramChatType,
title: row.title
}
}
function toHouseholdTopicBindingRecord(row: {
householdId: string
role: string
telegramThreadId: string
topicName: string | null
}): HouseholdTopicBindingRecord {
return {
householdId: row.householdId,
role: normalizeTopicRole(row.role),
telegramThreadId: row.telegramThreadId,
topicName: row.topicName
}
}
export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
repository: HouseholdConfigurationRepository
close: () => Promise<void>
} {
const { db, queryClient } = createDbClient(databaseUrl, {
max: 5,
prepare: false
})
const repository: HouseholdConfigurationRepository = {
async registerTelegramHouseholdChat(input) {
return await db.transaction(async (tx): Promise<RegisterTelegramHouseholdChatResult> => {
const existingRows = await tx
.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.telegramChatId, input.telegramChatId))
.limit(1)
const existing = existingRows[0]
if (existing) {
const nextTitle = input.title?.trim() || existing.title
await tx
.update(schema.householdTelegramChats)
.set({
telegramChatType: input.telegramChatType,
title: nextTitle,
updatedAt: new Date()
})
.where(eq(schema.householdTelegramChats.telegramChatId, input.telegramChatId))
return {
status: 'existing',
household: toHouseholdTelegramChatRecord({
...existing,
telegramChatType: input.telegramChatType,
title: nextTitle
})
}
}
const insertedHouseholds = await tx
.insert(schema.households)
.values({
name: input.householdName
})
.returning({
id: schema.households.id,
name: schema.households.name
})
const household = insertedHouseholds[0]
if (!household) {
throw new Error('Failed to create household record')
}
const insertedChats = await tx
.insert(schema.householdTelegramChats)
.values({
householdId: household.id,
telegramChatId: input.telegramChatId,
telegramChatType: input.telegramChatType,
title: input.title?.trim() || null
})
.returning({
householdId: schema.householdTelegramChats.householdId,
telegramChatId: schema.householdTelegramChats.telegramChatId,
telegramChatType: schema.householdTelegramChats.telegramChatType,
title: schema.householdTelegramChats.title
})
const chat = insertedChats[0]
if (!chat) {
throw new Error('Failed to create Telegram household chat binding')
}
return {
status: 'created',
household: toHouseholdTelegramChatRecord({
householdId: chat.householdId,
householdName: household.name,
telegramChatId: chat.telegramChatId,
telegramChatType: chat.telegramChatType,
title: chat.title
})
}
})
},
async getTelegramHouseholdChat(telegramChatId) {
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.telegramChatId, telegramChatId))
.limit(1)
const row = rows[0]
return row ? toHouseholdTelegramChatRecord(row) : null
},
async bindHouseholdTopic(input) {
const rows = await db
.insert(schema.householdTopicBindings)
.values({
householdId: input.householdId,
role: input.role,
telegramThreadId: input.telegramThreadId,
topicName: input.topicName?.trim() || null
})
.onConflictDoUpdate({
target: [schema.householdTopicBindings.householdId, schema.householdTopicBindings.role],
set: {
telegramThreadId: input.telegramThreadId,
topicName: input.topicName?.trim() || null,
updatedAt: new Date()
}
})
.returning({
householdId: schema.householdTopicBindings.householdId,
role: schema.householdTopicBindings.role,
telegramThreadId: schema.householdTopicBindings.telegramThreadId,
topicName: schema.householdTopicBindings.topicName
})
const row = rows[0]
if (!row) {
throw new Error('Failed to bind household topic')
}
return toHouseholdTopicBindingRecord(row)
},
async getHouseholdTopicBinding(householdId, role) {
const rows = await db
.select({
householdId: schema.householdTopicBindings.householdId,
role: schema.householdTopicBindings.role,
telegramThreadId: schema.householdTopicBindings.telegramThreadId,
topicName: schema.householdTopicBindings.topicName
})
.from(schema.householdTopicBindings)
.where(
and(
eq(schema.householdTopicBindings.householdId, householdId),
eq(schema.householdTopicBindings.role, role)
)
)
.limit(1)
const row = rows[0]
return row ? toHouseholdTopicBindingRecord(row) : null
},
async findHouseholdTopicByTelegramContext(input) {
const rows = await db
.select({
householdId: schema.householdTopicBindings.householdId,
role: schema.householdTopicBindings.role,
telegramThreadId: schema.householdTopicBindings.telegramThreadId,
topicName: schema.householdTopicBindings.topicName
})
.from(schema.householdTopicBindings)
.innerJoin(
schema.householdTelegramChats,
eq(schema.householdTopicBindings.householdId, schema.householdTelegramChats.householdId)
)
.where(
and(
eq(schema.householdTelegramChats.telegramChatId, input.telegramChatId),
eq(schema.householdTopicBindings.telegramThreadId, input.telegramThreadId)
)
)
.limit(1)
const row = rows[0]
return row ? toHouseholdTopicBindingRecord(row) : null
},
async listHouseholdTopicBindings(householdId) {
const rows = await db
.select({
householdId: schema.householdTopicBindings.householdId,
role: schema.householdTopicBindings.role,
telegramThreadId: schema.householdTopicBindings.telegramThreadId,
topicName: schema.householdTopicBindings.topicName
})
.from(schema.householdTopicBindings)
.where(eq(schema.householdTopicBindings.householdId, householdId))
.orderBy(schema.householdTopicBindings.role)
return rows.map(toHouseholdTopicBindingRecord)
}
}
return {
repository,
close: async () => {
await queryClient.end({ timeout: 5 })
}
}
}

View File

@@ -1,3 +1,4 @@
export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository'
export { createDbFinanceRepository } from './finance-repository'
export { createDbHouseholdConfigurationRepository } from './household-config-repository'
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'