mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
feat(bot): add multi-household setup flow
This commit is contained in:
@@ -11,6 +11,9 @@ export function createTelegramBot(token: string, logger?: Logger): Bot {
|
|||||||
'Available commands:',
|
'Available commands:',
|
||||||
'/help - Show command list',
|
'/help - Show command list',
|
||||||
'/household_status - Show placeholder household status',
|
'/household_status - Show placeholder household status',
|
||||||
|
'/setup [household name] - Register this group as a household',
|
||||||
|
'/bind_purchase_topic - Bind the current topic as the purchase topic',
|
||||||
|
'/bind_feedback_topic - Bind the current topic as the feedback topic',
|
||||||
'/anon <message> - Send anonymous household feedback in a private chat'
|
'/anon <message> - Send anonymous household feedback in a private chat'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
)
|
)
|
||||||
|
|||||||
184
apps/bot/src/household-setup.ts
Normal file
184
apps/bot/src/household-setup.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import type { HouseholdSetupService } from '@household/application'
|
||||||
|
import type { Logger } from '@household/observability'
|
||||||
|
import type { Bot, Context } from 'grammy'
|
||||||
|
|
||||||
|
function commandArgText(ctx: Context): string {
|
||||||
|
return typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupChat(ctx: Context): ctx is Context & {
|
||||||
|
chat: NonNullable<Context['chat']> & { type: 'group' | 'supergroup'; title?: string }
|
||||||
|
} {
|
||||||
|
return ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTopicMessage(ctx: Context): boolean {
|
||||||
|
const message = ctx.msg
|
||||||
|
return !!message && 'is_topic_message' in message && message.is_topic_message === true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isGroupAdmin(ctx: Context): Promise<boolean> {
|
||||||
|
if (!ctx.chat || !ctx.from) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await ctx.api.getChatMember(ctx.chat.id, ctx.from.id)
|
||||||
|
return member.status === 'creator' || member.status === 'administrator'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRejectionMessage(reason: 'not_admin' | 'invalid_chat_type'): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'not_admin':
|
||||||
|
return 'Only Telegram group admins can run /setup.'
|
||||||
|
case 'invalid_chat_type':
|
||||||
|
return 'Use /setup inside a group or supergroup.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRejectionMessage(
|
||||||
|
reason: 'not_admin' | 'household_not_found' | 'not_topic_message'
|
||||||
|
): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'not_admin':
|
||||||
|
return 'Only Telegram group admins can bind household topics.'
|
||||||
|
case 'household_not_found':
|
||||||
|
return 'Household is not configured for this chat yet. Run /setup first.'
|
||||||
|
case 'not_topic_message':
|
||||||
|
return 'Run this command inside the target topic thread.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerHouseholdSetupCommands(options: {
|
||||||
|
bot: Bot
|
||||||
|
householdSetupService: HouseholdSetupService
|
||||||
|
logger?: Logger
|
||||||
|
}): void {
|
||||||
|
options.bot.command('setup', async (ctx) => {
|
||||||
|
if (!isGroupChat(ctx)) {
|
||||||
|
await ctx.reply('Use /setup inside the household group.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorIsAdmin = await isGroupAdmin(ctx)
|
||||||
|
const result = await options.householdSetupService.setupGroupChat({
|
||||||
|
actorIsAdmin,
|
||||||
|
telegramChatId: ctx.chat.id.toString(),
|
||||||
|
telegramChatType: ctx.chat.type,
|
||||||
|
title: ctx.chat.title,
|
||||||
|
householdName: commandArgText(ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
await ctx.reply(setupRejectionMessage(result.reason))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.logger?.info(
|
||||||
|
{
|
||||||
|
event: 'household_setup.chat_registered',
|
||||||
|
telegramChatId: result.household.telegramChatId,
|
||||||
|
householdId: result.household.householdId,
|
||||||
|
actorTelegramUserId: ctx.from?.id?.toString(),
|
||||||
|
status: result.status
|
||||||
|
},
|
||||||
|
'Household group registered'
|
||||||
|
)
|
||||||
|
|
||||||
|
const action = result.status === 'created' ? 'created' : 'already registered'
|
||||||
|
await ctx.reply(
|
||||||
|
[
|
||||||
|
`Household ${action}: ${result.household.householdName}`,
|
||||||
|
`Chat ID: ${result.household.telegramChatId}`,
|
||||||
|
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.'
|
||||||
|
].join('\n')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
options.bot.command('bind_purchase_topic', async (ctx) => {
|
||||||
|
if (!isGroupChat(ctx)) {
|
||||||
|
await ctx.reply('Use /bind_purchase_topic inside the household group topic.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorIsAdmin = await isGroupAdmin(ctx)
|
||||||
|
const telegramThreadId =
|
||||||
|
isTopicMessage(ctx) && ctx.msg && 'message_thread_id' in ctx.msg
|
||||||
|
? ctx.msg.message_thread_id?.toString()
|
||||||
|
: undefined
|
||||||
|
const result = await options.householdSetupService.bindTopic({
|
||||||
|
actorIsAdmin,
|
||||||
|
telegramChatId: ctx.chat.id.toString(),
|
||||||
|
role: 'purchase',
|
||||||
|
...(telegramThreadId
|
||||||
|
? {
|
||||||
|
telegramThreadId
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
await ctx.reply(bindRejectionMessage(result.reason))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.logger?.info(
|
||||||
|
{
|
||||||
|
event: 'household_setup.topic_bound',
|
||||||
|
role: result.binding.role,
|
||||||
|
telegramChatId: result.household.telegramChatId,
|
||||||
|
telegramThreadId: result.binding.telegramThreadId,
|
||||||
|
householdId: result.household.householdId,
|
||||||
|
actorTelegramUserId: ctx.from?.id?.toString()
|
||||||
|
},
|
||||||
|
'Household topic bound'
|
||||||
|
)
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`Purchase topic saved for ${result.household.householdName} (thread ${result.binding.telegramThreadId}).`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
options.bot.command('bind_feedback_topic', async (ctx) => {
|
||||||
|
if (!isGroupChat(ctx)) {
|
||||||
|
await ctx.reply('Use /bind_feedback_topic inside the household group topic.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorIsAdmin = await isGroupAdmin(ctx)
|
||||||
|
const telegramThreadId =
|
||||||
|
isTopicMessage(ctx) && ctx.msg && 'message_thread_id' in ctx.msg
|
||||||
|
? ctx.msg.message_thread_id?.toString()
|
||||||
|
: undefined
|
||||||
|
const result = await options.householdSetupService.bindTopic({
|
||||||
|
actorIsAdmin,
|
||||||
|
telegramChatId: ctx.chat.id.toString(),
|
||||||
|
role: 'feedback',
|
||||||
|
...(telegramThreadId
|
||||||
|
? {
|
||||||
|
telegramThreadId
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
await ctx.reply(bindRejectionMessage(result.reason))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.logger?.info(
|
||||||
|
{
|
||||||
|
event: 'household_setup.topic_bound',
|
||||||
|
role: result.binding.role,
|
||||||
|
telegramChatId: result.household.telegramChatId,
|
||||||
|
telegramThreadId: result.binding.telegramThreadId,
|
||||||
|
householdId: result.household.householdId,
|
||||||
|
actorTelegramUserId: ctx.from?.id?.toString()
|
||||||
|
},
|
||||||
|
'Household topic bound'
|
||||||
|
)
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`Feedback topic saved for ${result.household.householdName} (thread ${result.binding.telegramThreadId}).`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ import { webhookCallback } from 'grammy'
|
|||||||
import {
|
import {
|
||||||
createAnonymousFeedbackService,
|
createAnonymousFeedbackService,
|
||||||
createFinanceCommandService,
|
createFinanceCommandService,
|
||||||
|
createHouseholdSetupService,
|
||||||
createReminderJobService
|
createReminderJobService
|
||||||
} from '@household/application'
|
} from '@household/application'
|
||||||
import {
|
import {
|
||||||
createDbAnonymousFeedbackRepository,
|
createDbAnonymousFeedbackRepository,
|
||||||
createDbFinanceRepository,
|
createDbFinanceRepository,
|
||||||
|
createDbHouseholdConfigurationRepository,
|
||||||
createDbReminderDispatchRepository
|
createDbReminderDispatchRepository
|
||||||
} from '@household/adapters-db'
|
} from '@household/adapters-db'
|
||||||
import { configureLogger, getLogger } from '@household/observability'
|
import { configureLogger, getLogger } from '@household/observability'
|
||||||
@@ -16,10 +18,11 @@ import { registerAnonymousFeedback } from './anonymous-feedback'
|
|||||||
import { createFinanceCommandsService } from './finance-commands'
|
import { createFinanceCommandsService } from './finance-commands'
|
||||||
import { createTelegramBot } from './bot'
|
import { createTelegramBot } from './bot'
|
||||||
import { getBotRuntimeConfig } from './config'
|
import { getBotRuntimeConfig } from './config'
|
||||||
|
import { registerHouseholdSetupCommands } from './household-setup'
|
||||||
import { createOpenAiParserFallback } from './openai-parser-fallback'
|
import { createOpenAiParserFallback } from './openai-parser-fallback'
|
||||||
import {
|
import {
|
||||||
createPurchaseMessageRepository,
|
createPurchaseMessageRepository,
|
||||||
registerPurchaseTopicIngestion
|
registerConfiguredPurchaseTopicIngestion
|
||||||
} from './purchase-topic-ingestion'
|
} from './purchase-topic-ingestion'
|
||||||
import { createReminderJobsHandler } from './reminder-jobs'
|
import { createReminderJobsHandler } from './reminder-jobs'
|
||||||
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
||||||
@@ -38,6 +41,9 @@ const bot = createTelegramBot(runtime.telegramBotToken, getLogger('telegram'))
|
|||||||
const webhookHandler = webhookCallback(bot, 'std/http')
|
const webhookHandler = webhookCallback(bot, 'std/http')
|
||||||
|
|
||||||
const shutdownTasks: Array<() => Promise<void>> = []
|
const shutdownTasks: Array<() => Promise<void>> = []
|
||||||
|
const householdConfigurationRepositoryClient = runtime.databaseUrl
|
||||||
|
? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
|
||||||
|
: null
|
||||||
const financeRepositoryClient =
|
const financeRepositoryClient =
|
||||||
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled
|
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled
|
||||||
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
|
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
|
||||||
@@ -56,22 +62,22 @@ if (financeRepositoryClient) {
|
|||||||
shutdownTasks.push(financeRepositoryClient.close)
|
shutdownTasks.push(financeRepositoryClient.close)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (householdConfigurationRepositoryClient) {
|
||||||
|
shutdownTasks.push(householdConfigurationRepositoryClient.close)
|
||||||
|
}
|
||||||
|
|
||||||
if (anonymousFeedbackRepositoryClient) {
|
if (anonymousFeedbackRepositoryClient) {
|
||||||
shutdownTasks.push(anonymousFeedbackRepositoryClient.close)
|
shutdownTasks.push(anonymousFeedbackRepositoryClient.close)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtime.purchaseTopicIngestionEnabled) {
|
if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
|
||||||
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
||||||
shutdownTasks.push(purchaseRepositoryClient.close)
|
shutdownTasks.push(purchaseRepositoryClient.close)
|
||||||
const llmFallback = createOpenAiParserFallback(runtime.openaiApiKey, runtime.parserModel)
|
const llmFallback = createOpenAiParserFallback(runtime.openaiApiKey, runtime.parserModel)
|
||||||
|
|
||||||
registerPurchaseTopicIngestion(
|
registerConfiguredPurchaseTopicIngestion(
|
||||||
bot,
|
bot,
|
||||||
{
|
householdConfigurationRepositoryClient.repository,
|
||||||
householdId: runtime.householdId!,
|
|
||||||
householdChatId: runtime.telegramHouseholdChatId!,
|
|
||||||
purchaseTopicId: runtime.telegramPurchaseTopicId!
|
|
||||||
},
|
|
||||||
purchaseRepositoryClient.repository,
|
purchaseRepositoryClient.repository,
|
||||||
{
|
{
|
||||||
...(llmFallback
|
...(llmFallback
|
||||||
@@ -88,7 +94,7 @@ if (runtime.purchaseTopicIngestionEnabled) {
|
|||||||
event: 'runtime.feature_disabled',
|
event: 'runtime.feature_disabled',
|
||||||
feature: 'purchase-topic-ingestion'
|
feature: 'purchase-topic-ingestion'
|
||||||
},
|
},
|
||||||
'Purchase topic ingestion is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_PURCHASE_TOPIC_ID to enable.'
|
'Purchase topic ingestion is disabled. Set DATABASE_URL to enable Telegram topic lookups.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +112,24 @@ if (runtime.financeCommandsEnabled) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (householdConfigurationRepositoryClient) {
|
||||||
|
registerHouseholdSetupCommands({
|
||||||
|
bot,
|
||||||
|
householdSetupService: createHouseholdSetupService(
|
||||||
|
householdConfigurationRepositoryClient.repository
|
||||||
|
),
|
||||||
|
logger: getLogger('household-setup')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
event: 'runtime.feature_disabled',
|
||||||
|
feature: 'household-setup'
|
||||||
|
},
|
||||||
|
'Household setup commands are disabled. Set DATABASE_URL to enable.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const reminderJobs = runtime.reminderJobsEnabled
|
const reminderJobs = runtime.reminderJobsEnabled
|
||||||
? (() => {
|
? (() => {
|
||||||
const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!)
|
const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
extractPurchaseTopicCandidate,
|
extractPurchaseTopicCandidate,
|
||||||
|
resolveConfiguredPurchaseTopicRecord,
|
||||||
type PurchaseTopicCandidate
|
type PurchaseTopicCandidate
|
||||||
} from './purchase-topic-ingestion'
|
} from './purchase-topic-ingestion'
|
||||||
|
|
||||||
@@ -60,3 +61,28 @@ describe('extractPurchaseTopicCandidate', () => {
|
|||||||
expect(record).toBeNull()
|
expect(record).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('resolveConfiguredPurchaseTopicRecord', () => {
|
||||||
|
test('returns record when the configured topic role is purchase', () => {
|
||||||
|
const record = resolveConfiguredPurchaseTopicRecord(candidate(), {
|
||||||
|
householdId: 'household-1',
|
||||||
|
role: 'purchase',
|
||||||
|
telegramThreadId: '777',
|
||||||
|
topicName: 'Общие покупки'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(record).not.toBeNull()
|
||||||
|
expect(record?.householdId).toBe('household-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('skips non-purchase topic bindings', () => {
|
||||||
|
const record = resolveConfiguredPurchaseTopicRecord(candidate(), {
|
||||||
|
householdId: 'household-1',
|
||||||
|
role: 'feedback',
|
||||||
|
telegramThreadId: '777',
|
||||||
|
topicName: 'Feedback'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(record).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household
|
|||||||
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'
|
||||||
|
import type {
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
HouseholdTopicBindingRecord
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
|
|
||||||
@@ -61,6 +65,30 @@ export function extractPurchaseTopicCandidate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveConfiguredPurchaseTopicRecord(
|
||||||
|
value: PurchaseTopicCandidate,
|
||||||
|
binding: HouseholdTopicBindingRecord
|
||||||
|
): PurchaseTopicRecord | null {
|
||||||
|
if (value.rawText.trim().startsWith('/')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.role !== 'purchase') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = value.rawText.trim()
|
||||||
|
if (normalizedText.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
rawText: normalizedText,
|
||||||
|
householdId: binding.householdId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function needsReviewAsInt(value: boolean): number {
|
function needsReviewAsInt(value: boolean): number {
|
||||||
return value ? 1 : 0
|
return value ? 1 : 0
|
||||||
}
|
}
|
||||||
@@ -245,3 +273,69 @@ export function registerPurchaseTopicIngestion(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function registerConfiguredPurchaseTopicIngestion(
|
||||||
|
bot: Bot,
|
||||||
|
householdConfigurationRepository: HouseholdConfigurationRepository,
|
||||||
|
repository: PurchaseMessageIngestionRepository,
|
||||||
|
options: {
|
||||||
|
llmFallback?: PurchaseParserLlmFallback
|
||||||
|
logger?: Logger
|
||||||
|
} = {}
|
||||||
|
): void {
|
||||||
|
bot.on('message:text', async (ctx, next) => {
|
||||||
|
const candidate = toCandidateFromContext(ctx)
|
||||||
|
if (!candidate) {
|
||||||
|
await next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = await householdConfigurationRepository.findHouseholdTopicByTelegramContext({
|
||||||
|
telegramChatId: candidate.chatId,
|
||||||
|
telegramThreadId: candidate.threadId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!binding) {
|
||||||
|
await next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = resolveConfiguredPurchaseTopicRecord(candidate, binding)
|
||||||
|
if (!record) {
|
||||||
|
await next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await repository.save(record, options.llmFallback)
|
||||||
|
|
||||||
|
if (status === 'created') {
|
||||||
|
options.logger?.info(
|
||||||
|
{
|
||||||
|
event: 'purchase.ingested',
|
||||||
|
householdId: record.householdId,
|
||||||
|
chatId: record.chatId,
|
||||||
|
threadId: record.threadId,
|
||||||
|
messageId: record.messageId,
|
||||||
|
updateId: record.updateId,
|
||||||
|
senderTelegramUserId: record.senderTelegramUserId
|
||||||
|
},
|
||||||
|
'Purchase topic message ingested'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
options.logger?.error(
|
||||||
|
{
|
||||||
|
event: 'purchase.ingest_failed',
|
||||||
|
householdId: record.householdId,
|
||||||
|
chatId: record.chatId,
|
||||||
|
threadId: record.threadId,
|
||||||
|
messageId: record.messageId,
|
||||||
|
updateId: record.updateId,
|
||||||
|
error
|
||||||
|
},
|
||||||
|
'Failed to ingest purchase topic message'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
118
docs/decisions/ADR-003-multi-household-runtime-configuration.md
Normal file
118
docs/decisions/ADR-003-multi-household-runtime-configuration.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# ADR-003: Database-Backed Multi-Household Telegram Configuration
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
Decision Date: 2026-03-09
|
||||||
|
Owners: Stanislav Kalishin
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current runtime assumes one household per deployment. `HOUSEHOLD_ID`,
|
||||||
|
`TELEGRAM_HOUSEHOLD_CHAT_ID`, `TELEGRAM_PURCHASE_TOPIC_ID`, and
|
||||||
|
`TELEGRAM_FEEDBACK_TOPIC_ID` are injected as environment variables and then used
|
||||||
|
globally by the bot.
|
||||||
|
|
||||||
|
That model is not viable for the intended product:
|
||||||
|
|
||||||
|
- one bot should support multiple Telegram groups
|
||||||
|
- onboarding should not require Terraform edits or redeploys
|
||||||
|
- topic ids should be captured from real Telegram updates, not copied manually
|
||||||
|
- mini app and bot features must resolve household context per chat/member
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Move household Telegram configuration out of environment variables and into the
|
||||||
|
application database.
|
||||||
|
|
||||||
|
Keep environment variables only for deployment-global concerns:
|
||||||
|
|
||||||
|
- `TELEGRAM_BOT_TOKEN`
|
||||||
|
- `TELEGRAM_WEBHOOK_SECRET`
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- scheduler auth settings
|
||||||
|
- logging and infrastructure settings
|
||||||
|
|
||||||
|
Persist household-specific Telegram configuration as data:
|
||||||
|
|
||||||
|
- Telegram group/supergroup chat id
|
||||||
|
- topic bindings by role (`purchase`, `feedback`, later `reminders`)
|
||||||
|
- household state and setup status
|
||||||
|
- member-to-household Telegram identity linkage
|
||||||
|
|
||||||
|
Bootstrap household setup through bot interactions:
|
||||||
|
|
||||||
|
- bot added to a Telegram group
|
||||||
|
- admin runs `/setup`
|
||||||
|
- admin runs topic binding commands inside target topics
|
||||||
|
- members DM `/start` to link their Telegram identity
|
||||||
|
|
||||||
|
## Boundary Rules
|
||||||
|
|
||||||
|
- Domain stays unaware of Telegram ids and setup flows.
|
||||||
|
- Application owns setup use-cases and authorization rules.
|
||||||
|
- Ports expose household configuration and membership repositories.
|
||||||
|
- Telegram-specific extraction of chat ids, topic ids, and admin checks remains
|
||||||
|
in adapters/app entrypoints.
|
||||||
|
|
||||||
|
## Data Model Direction
|
||||||
|
|
||||||
|
Add database-backed configuration entities around the existing `households`
|
||||||
|
table, likely including:
|
||||||
|
|
||||||
|
- `telegram_households` or equivalent mapping from Telegram chat to household
|
||||||
|
- `household_topics` keyed by household + role
|
||||||
|
- richer `members` onboarding/link status fields if needed
|
||||||
|
|
||||||
|
Exact table shapes remain implementation work, but the model must support:
|
||||||
|
|
||||||
|
- many Telegram groups per deployment
|
||||||
|
- unique chat id ownership
|
||||||
|
- unique topic role per household
|
||||||
|
- idempotent setup commands
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
- Removes deploy-time coupling between infrastructure and household onboarding.
|
||||||
|
- Matches real Telegram behavior, where chat and topic identifiers are only
|
||||||
|
known after bot interaction.
|
||||||
|
- Keeps the production architecture aligned with a multi-tenant SaaS model.
|
||||||
|
- Simplifies future mini app settings screens because configuration is already
|
||||||
|
stored as data.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Positive:
|
||||||
|
|
||||||
|
- one deployed bot can support many households
|
||||||
|
- onboarding is self-serve through Telegram commands and later mini app UI
|
||||||
|
- infrastructure configuration becomes smaller and safer
|
||||||
|
|
||||||
|
Negative:
|
||||||
|
|
||||||
|
- requires schema changes and migration from the current single-household model
|
||||||
|
- setup authorization rules become a real application concern
|
||||||
|
- finance/reminder flows must resolve household context dynamically
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- Mixing old env-based household logic with new DB-backed logic creates an
|
||||||
|
inconsistent runtime.
|
||||||
|
|
||||||
|
Mitigation:
|
||||||
|
|
||||||
|
- Introduce an explicit migration phase with fallback rules documented in the
|
||||||
|
spec.
|
||||||
|
- Remove household-specific env usage after setup flows are shipped.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- Unauthorized users could bind topics or hijack setup.
|
||||||
|
|
||||||
|
Mitigation:
|
||||||
|
|
||||||
|
- Require Telegram group admin privileges for setup and topic binding commands.
|
||||||
|
- Persist an owning admin/member relation and audit setup actions.
|
||||||
@@ -63,6 +63,25 @@ Exit criteria:
|
|||||||
- Purchase messages are ingested and persisted.
|
- Purchase messages are ingested and persisted.
|
||||||
- Monthly statement can be produced via command.
|
- Monthly statement can be produced via command.
|
||||||
|
|
||||||
|
## Phase 2.5 - Multi-Household Setup
|
||||||
|
|
||||||
|
Goal: remove single-household deploy assumptions and support real Telegram group
|
||||||
|
onboarding.
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
|
||||||
|
- Database-backed Telegram group and topic configuration.
|
||||||
|
- Group bootstrap command (`/setup`).
|
||||||
|
- Topic binding commands executed inside the target topic.
|
||||||
|
- Member DM onboarding and household linkage flow.
|
||||||
|
- Removal of household-specific runtime env requirements.
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- One bot deployment can support multiple household groups.
|
||||||
|
- New households can be configured without Terraform edits or redeploy.
|
||||||
|
- Purchase and anonymous feedback topics are bound from real Telegram updates.
|
||||||
|
|
||||||
## Phase 3 - Reminders and Scheduling
|
## Phase 3 - Reminders and Scheduling
|
||||||
|
|
||||||
Goal: automate key payment reminders.
|
Goal: automate key payment reminders.
|
||||||
|
|||||||
154
docs/specs/HOUSEBOT-070-multi-household-telegram-setup.md
Normal file
154
docs/specs/HOUSEBOT-070-multi-household-telegram-setup.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# HOUSEBOT-070: Multi-Household Telegram Setup and Configuration
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the current single-household env configuration with database-backed
|
||||||
|
household registration, topic binding, and member onboarding so one deployed bot
|
||||||
|
can serve multiple Telegram groups.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Register a household from a real Telegram group without redeploying
|
||||||
|
- Bind purchase and feedback topics from in-topic admin commands
|
||||||
|
- Link real Telegram users to household members through DM onboarding
|
||||||
|
- Resolve household context dynamically in bot and mini app flows
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Full settings UI in the mini app
|
||||||
|
- Multi-household reminders customization beyond topic role binding
|
||||||
|
- Cross-household user accounts beyond Telegram identity linkage
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- In:
|
||||||
|
- group bootstrap command
|
||||||
|
- topic binding commands
|
||||||
|
- persistent Telegram chat/topic configuration
|
||||||
|
- member DM onboarding and pending linkage flow
|
||||||
|
- migration away from global household/topic env vars
|
||||||
|
- Out:
|
||||||
|
- advanced role management UI
|
||||||
|
- invite links and QR onboarding
|
||||||
|
- reminders topic configuration UI
|
||||||
|
|
||||||
|
## Interfaces and Contracts
|
||||||
|
|
||||||
|
Telegram commands:
|
||||||
|
|
||||||
|
- `/setup`
|
||||||
|
- `/bind_purchase_topic`
|
||||||
|
- `/bind_feedback_topic`
|
||||||
|
- `/status`
|
||||||
|
- `/start`
|
||||||
|
|
||||||
|
Expected command behavior:
|
||||||
|
|
||||||
|
- `/setup` in a group:
|
||||||
|
- creates or reuses a household bound to `chat.id`
|
||||||
|
- records setup initiator
|
||||||
|
- returns current setup state
|
||||||
|
- `/bind_purchase_topic` in a topic:
|
||||||
|
- stores `message.chat.id` + `message.message_thread_id` as purchase topic
|
||||||
|
- `/bind_feedback_topic` in a topic:
|
||||||
|
- stores `message.chat.id` + `message.message_thread_id` as feedback topic
|
||||||
|
- `/start` in DM:
|
||||||
|
- records Telegram identity
|
||||||
|
- lists pending household memberships or onboarding status
|
||||||
|
|
||||||
|
Mini app/API implications:
|
||||||
|
|
||||||
|
- session resolution must locate the caller’s household membership from stored
|
||||||
|
membership data, not a deployment-global `HOUSEHOLD_ID`
|
||||||
|
- household dashboard endpoints must accept or derive household context safely
|
||||||
|
|
||||||
|
## Domain Rules
|
||||||
|
|
||||||
|
- One Telegram group chat maps to exactly one household
|
||||||
|
- Topic role binding is unique per household and role
|
||||||
|
- Only Telegram group admins can run setup and topic binding commands
|
||||||
|
- Topic binding commands must be executed inside a topic message thread
|
||||||
|
- Setup commands are idempotent
|
||||||
|
- Member linkage must use the caller’s actual Telegram user id
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
Add tables or equivalent structures for:
|
||||||
|
|
||||||
|
- household-to-Telegram chat mapping
|
||||||
|
- topic bindings by household and role
|
||||||
|
- member onboarding/link status if current `members` shape is insufficient
|
||||||
|
|
||||||
|
Indexes/constraints should cover:
|
||||||
|
|
||||||
|
- unique Telegram chat id
|
||||||
|
- unique `(household_id, role)` for topic bindings
|
||||||
|
- unique `(household_id, telegram_user_id)` for members
|
||||||
|
|
||||||
|
Migration direction:
|
||||||
|
|
||||||
|
- keep current `households` and `members`
|
||||||
|
- backfill the existing demo household into the new config model if useful for
|
||||||
|
development
|
||||||
|
- remove runtime dependency on household/topic env vars after cutover
|
||||||
|
|
||||||
|
## Security and Privacy
|
||||||
|
|
||||||
|
- Verify group admin status before setup mutations
|
||||||
|
- Reject topic binding attempts outside the target group/topic
|
||||||
|
- Log setup and binding actions with actor Telegram id
|
||||||
|
- Do not expose household membership data across households
|
||||||
|
- Keep anonymous feedback sender privacy unchanged
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Required logs:
|
||||||
|
|
||||||
|
- household setup started/completed
|
||||||
|
- topic binding created/updated
|
||||||
|
- member onboarding started/linked
|
||||||
|
- rejected setup attempts with reason
|
||||||
|
|
||||||
|
Useful metrics:
|
||||||
|
|
||||||
|
- setup command success/failure count
|
||||||
|
- topic binding count by role
|
||||||
|
- unlinked member count
|
||||||
|
|
||||||
|
## Edge Cases and Failure Modes
|
||||||
|
|
||||||
|
- Bot added to a group without admin permissions
|
||||||
|
- Bot present in a group with privacy mode still enabled
|
||||||
|
- Topic binding command sent in main chat instead of a topic
|
||||||
|
- Duplicate `/setup` on an already configured household
|
||||||
|
- Member DMs `/start` before the household exists
|
||||||
|
- Same Telegram user belongs to multiple households
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- Unit:
|
||||||
|
- setup authorization rules
|
||||||
|
- topic binding validation
|
||||||
|
- member link resolution rules
|
||||||
|
- Integration:
|
||||||
|
- repository operations for chat/topic binding persistence
|
||||||
|
- Telegram command handlers with realistic update payloads
|
||||||
|
- E2E:
|
||||||
|
- group setup -> topic binding -> member DM onboarding -> command success
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Bot can create or load household config from a group `/setup` command
|
||||||
|
- [ ] Admin can bind the purchase topic without manual id lookup
|
||||||
|
- [ ] Admin can bind the feedback topic without manual id lookup
|
||||||
|
- [ ] Member DM onboarding stores real Telegram user identity for later linkage
|
||||||
|
- [ ] Existing finance and feedback flows can resolve household context from DB
|
||||||
|
- [ ] Household-specific env vars are no longer required in deployed runtime
|
||||||
|
|
||||||
|
## Rollout Plan
|
||||||
|
|
||||||
|
- Add new schema and repositories first
|
||||||
|
- Ship setup and topic binding commands behind the existing bot deployment
|
||||||
|
- Migrate bot runtime reads from env vars to DB-backed config
|
||||||
|
- Remove household/topic env vars from Terraform examples and runbooks after
|
||||||
|
cutover
|
||||||
102
packages/adapters-db/src/household-config-repository.test.ts
Normal file
102
packages/adapters-db/src/household-config-repository.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
273
packages/adapters-db/src/household-config-repository.ts
Normal file
273
packages/adapters-db/src/household-config-repository.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository'
|
export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository'
|
||||||
export { createDbFinanceRepository } from './finance-repository'
|
export { createDbFinanceRepository } from './finance-repository'
|
||||||
|
export { createDbHouseholdConfigurationRepository } from './household-config-repository'
|
||||||
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
|
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
|
||||||
|
|||||||
193
packages/application/src/household-setup-service.test.ts
Normal file
193
packages/application/src/household-setup-service.test.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
HouseholdTelegramChatRecord,
|
||||||
|
HouseholdTopicBindingRecord
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
import { createHouseholdSetupService } from './household-setup-service'
|
||||||
|
|
||||||
|
function createRepositoryStub() {
|
||||||
|
const households = new Map<string, HouseholdTelegramChatRecord>()
|
||||||
|
const bindings = new Map<string, HouseholdTopicBindingRecord[]>()
|
||||||
|
|
||||||
|
const repository: HouseholdConfigurationRepository = {
|
||||||
|
async registerTelegramHouseholdChat(input) {
|
||||||
|
const existing = households.get(input.telegramChatId)
|
||||||
|
if (existing) {
|
||||||
|
const next = {
|
||||||
|
...existing,
|
||||||
|
telegramChatType: input.telegramChatType,
|
||||||
|
title: input.title?.trim() || existing.title
|
||||||
|
}
|
||||||
|
households.set(input.telegramChatId, next)
|
||||||
|
return {
|
||||||
|
status: 'existing',
|
||||||
|
household: next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: HouseholdTelegramChatRecord = {
|
||||||
|
householdId: `household-${households.size + 1}`,
|
||||||
|
householdName: input.householdName,
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
telegramChatType: input.telegramChatType,
|
||||||
|
title: input.title?.trim() || null
|
||||||
|
}
|
||||||
|
households.set(input.telegramChatId, created)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'created',
|
||||||
|
household: created
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTelegramHouseholdChat(telegramChatId) {
|
||||||
|
return households.get(telegramChatId) ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async bindHouseholdTopic(input) {
|
||||||
|
const next: HouseholdTopicBindingRecord = {
|
||||||
|
householdId: input.householdId,
|
||||||
|
role: input.role,
|
||||||
|
telegramThreadId: input.telegramThreadId,
|
||||||
|
topicName: input.topicName?.trim() || null
|
||||||
|
}
|
||||||
|
const existing = bindings.get(input.householdId) ?? []
|
||||||
|
const filtered = existing.filter((entry) => entry.role !== input.role)
|
||||||
|
bindings.set(input.householdId, [...filtered, next])
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHouseholdTopicBinding(householdId, role) {
|
||||||
|
return bindings.get(householdId)?.find((entry) => entry.role === role) ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async findHouseholdTopicByTelegramContext(input) {
|
||||||
|
const household = households.get(input.telegramChatId)
|
||||||
|
if (!household) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
bindings
|
||||||
|
.get(household.householdId)
|
||||||
|
?.find((entry) => entry.telegramThreadId === input.telegramThreadId) ?? null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
async listHouseholdTopicBindings(householdId) {
|
||||||
|
return bindings.get(householdId) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repository
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createHouseholdSetupService', () => {
|
||||||
|
test('creates a new household chat binding for a group admin', async () => {
|
||||||
|
const { repository } = createRepositoryStub()
|
||||||
|
const service = createHouseholdSetupService(repository)
|
||||||
|
|
||||||
|
const result = await service.setupGroupChat({
|
||||||
|
actorIsAdmin: true,
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.status).toBe('created')
|
||||||
|
if (result.status !== 'created') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expect(result.household.householdName).toBe('Kojori House')
|
||||||
|
expect(result.household.telegramChatId).toBe('-100123')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects setup when the actor is not a group admin', async () => {
|
||||||
|
const { repository } = createRepositoryStub()
|
||||||
|
const service = createHouseholdSetupService(repository)
|
||||||
|
|
||||||
|
const result = await service.setupGroupChat({
|
||||||
|
actorIsAdmin: false,
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'not_admin'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('binds a purchase topic for an existing household', async () => {
|
||||||
|
const { repository } = createRepositoryStub()
|
||||||
|
const service = createHouseholdSetupService(repository)
|
||||||
|
const setup = await service.setupGroupChat({
|
||||||
|
actorIsAdmin: true,
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(setup.status).toBe('created')
|
||||||
|
|
||||||
|
const result = await service.bindTopic({
|
||||||
|
actorIsAdmin: true,
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
role: 'purchase',
|
||||||
|
telegramThreadId: '777',
|
||||||
|
topicName: 'Общие покупки'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.status).toBe('bound')
|
||||||
|
if (result.status !== 'bound') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expect(result.binding.role).toBe('purchase')
|
||||||
|
expect(result.binding.telegramThreadId).toBe('777')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects topic binding when the household is not set up yet', async () => {
|
||||||
|
const { repository } = createRepositoryStub()
|
||||||
|
const service = createHouseholdSetupService(repository)
|
||||||
|
|
||||||
|
const result = await service.bindTopic({
|
||||||
|
actorIsAdmin: true,
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
role: 'feedback',
|
||||||
|
telegramThreadId: '778'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'household_not_found'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects topic binding outside a topic thread', async () => {
|
||||||
|
const { repository } = createRepositoryStub()
|
||||||
|
const service = createHouseholdSetupService(repository)
|
||||||
|
await service.setupGroupChat({
|
||||||
|
actorIsAdmin: true,
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.bindTopic({
|
||||||
|
actorIsAdmin: true,
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
role: 'feedback'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'not_topic_message'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
133
packages/application/src/household-setup-service.ts
Normal file
133
packages/application/src/household-setup-service.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import type {
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
HouseholdTelegramChatRecord,
|
||||||
|
HouseholdTopicBindingRecord,
|
||||||
|
HouseholdTopicRole
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
export interface HouseholdSetupService {
|
||||||
|
setupGroupChat(input: {
|
||||||
|
actorIsAdmin: boolean
|
||||||
|
telegramChatId: string
|
||||||
|
telegramChatType: string
|
||||||
|
title?: string
|
||||||
|
householdName?: string
|
||||||
|
}): Promise<
|
||||||
|
| {
|
||||||
|
status: 'created' | 'existing'
|
||||||
|
household: HouseholdTelegramChatRecord
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'rejected'
|
||||||
|
reason: 'not_admin' | 'invalid_chat_type'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
bindTopic(input: {
|
||||||
|
actorIsAdmin: boolean
|
||||||
|
telegramChatId: string
|
||||||
|
role: HouseholdTopicRole
|
||||||
|
telegramThreadId?: string
|
||||||
|
topicName?: string
|
||||||
|
}): Promise<
|
||||||
|
| {
|
||||||
|
status: 'bound'
|
||||||
|
household: HouseholdTelegramChatRecord
|
||||||
|
binding: HouseholdTopicBindingRecord
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'rejected'
|
||||||
|
reason: 'not_admin' | 'household_not_found' | 'not_topic_message'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedGroupChat(chatType: string): boolean {
|
||||||
|
return chatType === 'group' || chatType === 'supergroup'
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultHouseholdName(title: string | undefined, telegramChatId: string): string {
|
||||||
|
const normalizedTitle = title?.trim()
|
||||||
|
return normalizedTitle && normalizedTitle.length > 0
|
||||||
|
? normalizedTitle
|
||||||
|
: `Household ${telegramChatId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHouseholdSetupService(
|
||||||
|
repository: HouseholdConfigurationRepository
|
||||||
|
): HouseholdSetupService {
|
||||||
|
return {
|
||||||
|
async setupGroupChat(input) {
|
||||||
|
if (!input.actorIsAdmin) {
|
||||||
|
return {
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'not_admin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupportedGroupChat(input.telegramChatType)) {
|
||||||
|
return {
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'invalid_chat_type'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const registered = await repository.registerTelegramHouseholdChat({
|
||||||
|
householdName:
|
||||||
|
input.householdName?.trim() || defaultHouseholdName(input.title, input.telegramChatId),
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
telegramChatType: input.telegramChatType,
|
||||||
|
...(input.title?.trim()
|
||||||
|
? {
|
||||||
|
title: input.title.trim()
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: registered.status,
|
||||||
|
household: registered.household
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async bindTopic(input) {
|
||||||
|
if (!input.actorIsAdmin) {
|
||||||
|
return {
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'not_admin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.telegramThreadId) {
|
||||||
|
return {
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'not_topic_message'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const household = await repository.getTelegramHouseholdChat(input.telegramChatId)
|
||||||
|
if (!household) {
|
||||||
|
return {
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'household_not_found'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = await repository.bindHouseholdTopic({
|
||||||
|
householdId: household.householdId,
|
||||||
|
role: input.role,
|
||||||
|
telegramThreadId: input.telegramThreadId,
|
||||||
|
...(input.topicName?.trim()
|
||||||
|
? {
|
||||||
|
topicName: input.topicName.trim()
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'bound',
|
||||||
|
household,
|
||||||
|
binding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
type AnonymousFeedbackSubmitResult
|
type AnonymousFeedbackSubmitResult
|
||||||
} from './anonymous-feedback-service'
|
} from './anonymous-feedback-service'
|
||||||
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
||||||
|
export { createHouseholdSetupService, type HouseholdSetupService } from './household-setup-service'
|
||||||
export {
|
export {
|
||||||
createReminderJobService,
|
createReminderJobService,
|
||||||
type ReminderJobResult,
|
type ReminderJobResult,
|
||||||
|
|||||||
27
packages/db/drizzle/0005_free_kang.sql
Normal file
27
packages/db/drizzle/0005_free_kang.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TABLE "household_telegram_chats" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"telegram_chat_id" text NOT NULL,
|
||||||
|
"telegram_chat_type" text NOT NULL,
|
||||||
|
"title" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "household_topic_bindings" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"role" text NOT NULL,
|
||||||
|
"telegram_thread_id" text NOT NULL,
|
||||||
|
"topic_name" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "household_telegram_chats" ADD CONSTRAINT "household_telegram_chats_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "household_topic_bindings" ADD CONSTRAINT "household_topic_bindings_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "household_telegram_chats_household_unique" ON "household_telegram_chats" USING btree ("household_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "household_telegram_chats_chat_unique" ON "household_telegram_chats" USING btree ("telegram_chat_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "household_topic_bindings_household_role_unique" ON "household_topic_bindings" USING btree ("household_id","role");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "household_topic_bindings_household_thread_unique" ON "household_topic_bindings" USING btree ("household_id","telegram_thread_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "household_topic_bindings_household_role_idx" ON "household_topic_bindings" USING btree ("household_id","role");
|
||||||
1818
packages/db/drizzle/meta/0005_snapshot.json
Normal file
1818
packages/db/drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
|||||||
"when": 1772995779819,
|
"when": 1772995779819,
|
||||||
"tag": "0004_big_ultimatum",
|
"tag": "0004_big_ultimatum",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773012360748,
|
||||||
|
"tag": "0005_free_kang",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,54 @@ export const households = pgTable('households', {
|
|||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const householdTelegramChats = pgTable(
|
||||||
|
'household_telegram_chats',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
householdId: uuid('household_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: 'cascade' }),
|
||||||
|
telegramChatId: text('telegram_chat_id').notNull(),
|
||||||
|
telegramChatType: text('telegram_chat_type').notNull(),
|
||||||
|
title: text('title'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
householdUnique: uniqueIndex('household_telegram_chats_household_unique').on(table.householdId),
|
||||||
|
chatUnique: uniqueIndex('household_telegram_chats_chat_unique').on(table.telegramChatId)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const householdTopicBindings = pgTable(
|
||||||
|
'household_topic_bindings',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
householdId: uuid('household_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: 'cascade' }),
|
||||||
|
role: text('role').notNull(),
|
||||||
|
telegramThreadId: text('telegram_thread_id').notNull(),
|
||||||
|
topicName: text('topic_name'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
householdRoleUnique: uniqueIndex('household_topic_bindings_household_role_unique').on(
|
||||||
|
table.householdId,
|
||||||
|
table.role
|
||||||
|
),
|
||||||
|
householdThreadUnique: uniqueIndex('household_topic_bindings_household_thread_unique').on(
|
||||||
|
table.householdId,
|
||||||
|
table.telegramThreadId
|
||||||
|
),
|
||||||
|
householdRoleIdx: index('household_topic_bindings_household_role_idx').on(
|
||||||
|
table.householdId,
|
||||||
|
table.role
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const members = pgTable(
|
export const members = pgTable(
|
||||||
'members',
|
'members',
|
||||||
{
|
{
|
||||||
@@ -343,6 +391,8 @@ export const settlementLines = pgTable(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export type Household = typeof households.$inferSelect
|
export type Household = typeof households.$inferSelect
|
||||||
|
export type HouseholdTelegramChat = typeof householdTelegramChats.$inferSelect
|
||||||
|
export type HouseholdTopicBinding = typeof householdTopicBindings.$inferSelect
|
||||||
export type Member = typeof members.$inferSelect
|
export type Member = typeof members.$inferSelect
|
||||||
export type BillingCycle = typeof billingCycles.$inferSelect
|
export type BillingCycle = typeof billingCycles.$inferSelect
|
||||||
export type UtilityBill = typeof utilityBills.$inferSelect
|
export type UtilityBill = typeof utilityBills.$inferSelect
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { and, eq } from 'drizzle-orm'
|
|||||||
import { createDbClient } from './client'
|
import { createDbClient } from './client'
|
||||||
import {
|
import {
|
||||||
billingCycles,
|
billingCycles,
|
||||||
|
householdTelegramChats,
|
||||||
|
householdTopicBindings,
|
||||||
households,
|
households,
|
||||||
members,
|
members,
|
||||||
presenceOverrides,
|
presenceOverrides,
|
||||||
@@ -68,6 +70,34 @@ async function seed(): Promise<void> {
|
|||||||
])
|
])
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(householdTelegramChats)
|
||||||
|
.values({
|
||||||
|
householdId: FIXTURE_IDS.household,
|
||||||
|
telegramChatId: '-1001234567890',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori Demo Household'
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(householdTopicBindings)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
householdId: FIXTURE_IDS.household,
|
||||||
|
role: 'purchase',
|
||||||
|
telegramThreadId: '777',
|
||||||
|
topicName: 'Общие покупки'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
householdId: FIXTURE_IDS.household,
|
||||||
|
role: 'feedback',
|
||||||
|
telegramThreadId: '778',
|
||||||
|
topicName: 'Anonymous feedback'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
.onConflictDoNothing()
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(billingCycles)
|
.insert(billingCycles)
|
||||||
.values({
|
.values({
|
||||||
@@ -212,6 +242,16 @@ async function seed(): Promise<void> {
|
|||||||
if (seededCycle.length === 0) {
|
if (seededCycle.length === 0) {
|
||||||
throw new Error('Seed verification failed: billing cycle not found')
|
throw new Error('Seed verification failed: billing cycle not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seededChat = await db
|
||||||
|
.select({ telegramChatId: householdTelegramChats.telegramChatId })
|
||||||
|
.from(householdTelegramChats)
|
||||||
|
.where(eq(householdTelegramChats.householdId, FIXTURE_IDS.household))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (seededChat.length === 0) {
|
||||||
|
throw new Error('Seed verification failed: Telegram household chat not found')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
52
packages/ports/src/household-config.ts
Normal file
52
packages/ports/src/household-config.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const
|
||||||
|
|
||||||
|
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
|
||||||
|
|
||||||
|
export interface HouseholdTelegramChatRecord {
|
||||||
|
householdId: string
|
||||||
|
householdName: string
|
||||||
|
telegramChatId: string
|
||||||
|
telegramChatType: string
|
||||||
|
title: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HouseholdTopicBindingRecord {
|
||||||
|
householdId: string
|
||||||
|
role: HouseholdTopicRole
|
||||||
|
telegramThreadId: string
|
||||||
|
topicName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterTelegramHouseholdChatInput {
|
||||||
|
householdName: string
|
||||||
|
telegramChatId: string
|
||||||
|
telegramChatType: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterTelegramHouseholdChatResult {
|
||||||
|
status: 'created' | 'existing'
|
||||||
|
household: HouseholdTelegramChatRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HouseholdConfigurationRepository {
|
||||||
|
registerTelegramHouseholdChat(
|
||||||
|
input: RegisterTelegramHouseholdChatInput
|
||||||
|
): Promise<RegisterTelegramHouseholdChatResult>
|
||||||
|
getTelegramHouseholdChat(telegramChatId: string): Promise<HouseholdTelegramChatRecord | null>
|
||||||
|
bindHouseholdTopic(input: {
|
||||||
|
householdId: string
|
||||||
|
role: HouseholdTopicRole
|
||||||
|
telegramThreadId: string
|
||||||
|
topicName?: string
|
||||||
|
}): Promise<HouseholdTopicBindingRecord>
|
||||||
|
getHouseholdTopicBinding(
|
||||||
|
householdId: string,
|
||||||
|
role: HouseholdTopicRole
|
||||||
|
): Promise<HouseholdTopicBindingRecord | null>
|
||||||
|
findHouseholdTopicByTelegramContext(input: {
|
||||||
|
telegramChatId: string
|
||||||
|
telegramThreadId: string
|
||||||
|
}): Promise<HouseholdTopicBindingRecord | null>
|
||||||
|
listHouseholdTopicBindings(householdId: string): Promise<readonly HouseholdTopicBindingRecord[]>
|
||||||
|
}
|
||||||
@@ -5,6 +5,15 @@ export {
|
|||||||
type ReminderDispatchRepository,
|
type ReminderDispatchRepository,
|
||||||
type ReminderType
|
type ReminderType
|
||||||
} from './reminders'
|
} from './reminders'
|
||||||
|
export {
|
||||||
|
HOUSEHOLD_TOPIC_ROLES,
|
||||||
|
type HouseholdConfigurationRepository,
|
||||||
|
type HouseholdTelegramChatRecord,
|
||||||
|
type HouseholdTopicBindingRecord,
|
||||||
|
type HouseholdTopicRole,
|
||||||
|
type RegisterTelegramHouseholdChatInput,
|
||||||
|
type RegisterTelegramHouseholdChatResult
|
||||||
|
} from './household-config'
|
||||||
export type {
|
export type {
|
||||||
AnonymousFeedbackMemberRecord,
|
AnonymousFeedbackMemberRecord,
|
||||||
AnonymousFeedbackModerationStatus,
|
AnonymousFeedbackModerationStatus,
|
||||||
|
|||||||
Reference in New Issue
Block a user