diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index c4996ac..5b95640 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -11,6 +11,9 @@ export function createTelegramBot(token: string, logger?: Logger): Bot { 'Available commands:', '/help - Show command list', '/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 - Send anonymous household feedback in a private chat' ].join('\n') ) diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts new file mode 100644 index 0000000..6535c90 --- /dev/null +++ b/apps/bot/src/household-setup.ts @@ -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 & { 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 { + 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}).` + ) + }) +} diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 82e2870..a07efbc 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -3,11 +3,13 @@ import { webhookCallback } from 'grammy' import { createAnonymousFeedbackService, createFinanceCommandService, + createHouseholdSetupService, createReminderJobService } from '@household/application' import { createDbAnonymousFeedbackRepository, createDbFinanceRepository, + createDbHouseholdConfigurationRepository, createDbReminderDispatchRepository } from '@household/adapters-db' import { configureLogger, getLogger } from '@household/observability' @@ -16,10 +18,11 @@ import { registerAnonymousFeedback } from './anonymous-feedback' import { createFinanceCommandsService } from './finance-commands' import { createTelegramBot } from './bot' import { getBotRuntimeConfig } from './config' +import { registerHouseholdSetupCommands } from './household-setup' import { createOpenAiParserFallback } from './openai-parser-fallback' import { createPurchaseMessageRepository, - registerPurchaseTopicIngestion + registerConfiguredPurchaseTopicIngestion } from './purchase-topic-ingestion' import { createReminderJobsHandler } from './reminder-jobs' import { createSchedulerRequestAuthorizer } from './scheduler-auth' @@ -38,6 +41,9 @@ const bot = createTelegramBot(runtime.telegramBotToken, getLogger('telegram')) const webhookHandler = webhookCallback(bot, 'std/http') const shutdownTasks: Array<() => Promise> = [] +const householdConfigurationRepositoryClient = runtime.databaseUrl + ? createDbHouseholdConfigurationRepository(runtime.databaseUrl) + : null const financeRepositoryClient = runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled ? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!) @@ -56,22 +62,22 @@ if (financeRepositoryClient) { shutdownTasks.push(financeRepositoryClient.close) } +if (householdConfigurationRepositoryClient) { + shutdownTasks.push(householdConfigurationRepositoryClient.close) +} + if (anonymousFeedbackRepositoryClient) { shutdownTasks.push(anonymousFeedbackRepositoryClient.close) } -if (runtime.purchaseTopicIngestionEnabled) { +if (runtime.databaseUrl && householdConfigurationRepositoryClient) { const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) shutdownTasks.push(purchaseRepositoryClient.close) const llmFallback = createOpenAiParserFallback(runtime.openaiApiKey, runtime.parserModel) - registerPurchaseTopicIngestion( + registerConfiguredPurchaseTopicIngestion( bot, - { - householdId: runtime.householdId!, - householdChatId: runtime.telegramHouseholdChatId!, - purchaseTopicId: runtime.telegramPurchaseTopicId! - }, + householdConfigurationRepositoryClient.repository, purchaseRepositoryClient.repository, { ...(llmFallback @@ -88,7 +94,7 @@ if (runtime.purchaseTopicIngestionEnabled) { event: 'runtime.feature_disabled', 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 reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!) diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index e51e969..5f0a812 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { extractPurchaseTopicCandidate, + resolveConfiguredPurchaseTopicRecord, type PurchaseTopicCandidate } from './purchase-topic-ingestion' @@ -60,3 +61,28 @@ describe('extractPurchaseTopicCandidate', () => { 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() + }) +}) diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 7175f33..c084276 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -2,6 +2,10 @@ import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household import { and, eq } from 'drizzle-orm' import type { Bot, Context } from 'grammy' import type { Logger } from '@household/observability' +import type { + HouseholdConfigurationRepository, + HouseholdTopicBindingRecord +} from '@household/ports' 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 { 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' + ) + } + }) +} diff --git a/docs/decisions/ADR-003-multi-household-runtime-configuration.md b/docs/decisions/ADR-003-multi-household-runtime-configuration.md new file mode 100644 index 0000000..223bda3 --- /dev/null +++ b/docs/decisions/ADR-003-multi-household-runtime-configuration.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9157f20..db63367 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -63,6 +63,25 @@ Exit criteria: - Purchase messages are ingested and persisted. - 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 Goal: automate key payment reminders. diff --git a/docs/specs/HOUSEBOT-070-multi-household-telegram-setup.md b/docs/specs/HOUSEBOT-070-multi-household-telegram-setup.md new file mode 100644 index 0000000..64d486b --- /dev/null +++ b/docs/specs/HOUSEBOT-070-multi-household-telegram-setup.md @@ -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 diff --git a/packages/adapters-db/src/household-config-repository.test.ts b/packages/adapters-db/src/household-config-repository.test.ts new file mode 100644 index 0000000..c3b97fa --- /dev/null +++ b/packages/adapters-db/src/household-config-repository.test.ts @@ -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() + }) +}) diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts new file mode 100644 index 0000000..e0ccff4 --- /dev/null +++ b/packages/adapters-db/src/household-config-repository.ts @@ -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 +} { + const { db, queryClient } = createDbClient(databaseUrl, { + max: 5, + prepare: false + }) + + const repository: HouseholdConfigurationRepository = { + async registerTelegramHouseholdChat(input) { + return await db.transaction(async (tx): Promise => { + 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 }) + } + } +} diff --git a/packages/adapters-db/src/index.ts b/packages/adapters-db/src/index.ts index 7bc3377..1c0f4fc 100644 --- a/packages/adapters-db/src/index.ts +++ b/packages/adapters-db/src/index.ts @@ -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' diff --git a/packages/application/src/household-setup-service.test.ts b/packages/application/src/household-setup-service.test.ts new file mode 100644 index 0000000..e5a5549 --- /dev/null +++ b/packages/application/src/household-setup-service.test.ts @@ -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() + const bindings = new Map() + + 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' + }) + }) +}) diff --git a/packages/application/src/household-setup-service.ts b/packages/application/src/household-setup-service.ts new file mode 100644 index 0000000..ad2fe67 --- /dev/null +++ b/packages/application/src/household-setup-service.ts @@ -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 + } + } + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 6430490..fa0dccb 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -5,6 +5,7 @@ export { type AnonymousFeedbackSubmitResult } from './anonymous-feedback-service' export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service' +export { createHouseholdSetupService, type HouseholdSetupService } from './household-setup-service' export { createReminderJobService, type ReminderJobResult, diff --git a/packages/db/drizzle/0005_free_kang.sql b/packages/db/drizzle/0005_free_kang.sql new file mode 100644 index 0000000..6760de2 --- /dev/null +++ b/packages/db/drizzle/0005_free_kang.sql @@ -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"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0005_snapshot.json b/packages/db/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..13e5567 --- /dev/null +++ b/packages/db/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1818 @@ +{ + "id": "f1aee806-f5dc-46a0-b17b-2d54b552592f", + "prevId": "49fee72d-1d0d-4f6e-a74f-bc4f0cc15270", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.anonymous_messages": { + "name": "anonymous_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submitted_by_member_id": { + "name": "submitted_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sanitized_text": { + "name": "sanitized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderation_reason": { + "name": "moderation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "posted_chat_id": { + "name": "posted_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_thread_id": { + "name": "posted_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_message_id": { + "name": "posted_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "anonymous_messages_household_tg_update_unique": { + "name": "anonymous_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_member_created_idx": { + "name": "anonymous_messages_member_created_idx", + "columns": [ + { + "expression": "submitted_by_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_status_created_idx": { + "name": "anonymous_messages_status_created_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anonymous_messages_household_id_households_id_fk": { + "name": "anonymous_messages_household_id_households_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "anonymous_messages_submitted_by_member_id_members_id_fk": { + "name": "anonymous_messages_submitted_by_member_id_members_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "members", + "columnsFrom": ["submitted_by_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_cycles": { + "name": "billing_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_cycles_household_period_unique": { + "name": "billing_cycles_household_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycles_household_period_idx": { + "name": "billing_cycles_household_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycles_household_id_households_id_fk": { + "name": "billing_cycles_household_id_households_id_fk", + "tableFrom": "billing_cycles", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_telegram_chats": { + "name": "household_telegram_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_type": { + "name": "telegram_chat_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_telegram_chats_household_unique": { + "name": "household_telegram_chats_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_telegram_chats_chat_unique": { + "name": "household_telegram_chats_chat_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_telegram_chats_household_id_households_id_fk": { + "name": "household_telegram_chats_household_id_households_id_fk", + "tableFrom": "household_telegram_chats", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_topic_bindings": { + "name": "household_topic_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_name": { + "name": "topic_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_topic_bindings_household_role_unique": { + "name": "household_topic_bindings_household_role_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_thread_unique": { + "name": "household_topic_bindings_household_thread_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_role_idx": { + "name": "household_topic_bindings_household_role_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_topic_bindings_household_id_households_id_fk": { + "name": "household_topic_bindings_household_id_households_id_fk", + "tableFrom": "household_topic_bindings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_household_idx": { + "name": "members_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_household_tg_user_unique": { + "name": "members_household_tg_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_household_id_households_id_fk": { + "name": "members_household_id_households_id_fk", + "tableFrom": "members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_overrides": { + "name": "presence_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "utility_days": { + "name": "utility_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "presence_overrides_cycle_member_unique": { + "name": "presence_overrides_cycle_member_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presence_overrides_cycle_idx": { + "name": "presence_overrides_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "presence_overrides_cycle_id_billing_cycles_id_fk": { + "name": "presence_overrides_cycle_id_billing_cycles_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "presence_overrides_member_id_members_id_fk": { + "name": "presence_overrides_member_id_members_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_bot_messages": { + "name": "processed_bot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_key": { + "name": "source_message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "processed_bot_messages_source_message_unique": { + "name": "processed_bot_messages_source_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "processed_bot_messages_household_id_households_id_fk": { + "name": "processed_bot_messages_household_id_households_id_fk", + "tableFrom": "processed_bot_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_entries": { + "name": "purchase_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer_member_id": { + "name": "payer_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_entries_household_cycle_idx": { + "name": "purchase_entries_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_payer_idx": { + "name": "purchase_entries_payer_idx", + "columns": [ + { + "expression": "payer_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_household_tg_message_unique": { + "name": "purchase_entries_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_entries_household_id_households_id_fk": { + "name": "purchase_entries_household_id_households_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_entries_cycle_id_billing_cycles_id_fk": { + "name": "purchase_entries_cycle_id_billing_cycles_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "purchase_entries_payer_member_id_members_id_fk": { + "name": "purchase_entries_payer_member_id_members_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "members", + "columnsFrom": ["payer_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_messages": { + "name": "purchase_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parsed_amount_minor": { + "name": "parsed_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "parsed_currency": { + "name": "parsed_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_item_description": { + "name": "parsed_item_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parser_error": { + "name": "parser_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_messages_household_thread_idx": { + "name": "purchase_messages_household_thread_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_sender_idx": { + "name": "purchase_messages_sender_idx", + "columns": [ + { + "expression": "sender_telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_message_unique": { + "name": "purchase_messages_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_update_unique": { + "name": "purchase_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_messages_household_id_households_id_fk": { + "name": "purchase_messages_household_id_households_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_messages_sender_member_id_members_id_fk": { + "name": "purchase_messages_sender_member_id_members_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "members", + "columnsFrom": ["sender_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rent_rules": { + "name": "rent_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_to_period": { + "name": "effective_to_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rent_rules_household_from_period_unique": { + "name": "rent_rules_household_from_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rent_rules_household_from_period_idx": { + "name": "rent_rules_household_from_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rent_rules_household_id_households_id_fk": { + "name": "rent_rules_household_id_households_id_fk", + "tableFrom": "rent_rules", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlement_lines": { + "name": "settlement_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_share_minor": { + "name": "rent_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "utility_share_minor": { + "name": "utility_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "purchase_offset_minor": { + "name": "purchase_offset_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "net_due_minor": { + "name": "net_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "explanations": { + "name": "explanations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlement_lines_settlement_member_unique": { + "name": "settlement_lines_settlement_member_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlement_lines_settlement_idx": { + "name": "settlement_lines_settlement_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlement_lines_settlement_id_settlements_id_fk": { + "name": "settlement_lines_settlement_id_settlements_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "settlements", + "columnsFrom": ["settlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlement_lines_member_id_members_id_fk": { + "name": "settlement_lines_member_id_members_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_due_minor": { + "name": "total_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "settlements_cycle_unique": { + "name": "settlements_cycle_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_household_computed_idx": { + "name": "settlements_household_computed_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "computed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_household_id_households_id_fk": { + "name": "settlements_household_id_households_id_fk", + "tableFrom": "settlements", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlements_cycle_id_billing_cycles_id_fk": { + "name": "settlements_cycle_id_billing_cycles_id_fk", + "tableFrom": "settlements", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.utility_bills": { + "name": "utility_bills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bill_name": { + "name": "bill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by_member_id": { + "name": "created_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "utility_bills_cycle_idx": { + "name": "utility_bills_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "utility_bills_household_cycle_idx": { + "name": "utility_bills_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "utility_bills_household_id_households_id_fk": { + "name": "utility_bills_household_id_households_id_fk", + "tableFrom": "utility_bills", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_cycle_id_billing_cycles_id_fk": { + "name": "utility_bills_cycle_id_billing_cycles_id_fk", + "tableFrom": "utility_bills", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_created_by_member_id_members_id_fk": { + "name": "utility_bills_created_by_member_id_members_id_fk", + "tableFrom": "utility_bills", + "tableTo": "members", + "columnsFrom": ["created_by_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 65b03ec..3432222 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1772995779819, "tag": "0004_big_ultimatum", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1773012360748, + "tag": "0005_free_kang", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 4f2d645..6b44f64 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -18,6 +18,54 @@ export const households = pgTable('households', { 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( 'members', { @@ -343,6 +391,8 @@ export const settlementLines = pgTable( ) 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 BillingCycle = typeof billingCycles.$inferSelect export type UtilityBill = typeof utilityBills.$inferSelect diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 067d517..53bae7c 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -2,6 +2,8 @@ import { and, eq } from 'drizzle-orm' import { createDbClient } from './client' import { billingCycles, + householdTelegramChats, + householdTopicBindings, households, members, presenceOverrides, @@ -68,6 +70,34 @@ async function seed(): Promise { ]) .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 .insert(billingCycles) .values({ @@ -212,6 +242,16 @@ async function seed(): Promise { if (seededCycle.length === 0) { 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 { diff --git a/packages/ports/src/household-config.ts b/packages/ports/src/household-config.ts new file mode 100644 index 0000000..cfd4dd5 --- /dev/null +++ b/packages/ports/src/household-config.ts @@ -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 + getTelegramHouseholdChat(telegramChatId: string): Promise + bindHouseholdTopic(input: { + householdId: string + role: HouseholdTopicRole + telegramThreadId: string + topicName?: string + }): Promise + getHouseholdTopicBinding( + householdId: string, + role: HouseholdTopicRole + ): Promise + findHouseholdTopicByTelegramContext(input: { + telegramChatId: string + telegramThreadId: string + }): Promise + listHouseholdTopicBindings(householdId: string): Promise +} diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index f793c4a..2798e77 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -5,6 +5,15 @@ export { type ReminderDispatchRepository, type ReminderType } from './reminders' +export { + HOUSEHOLD_TOPIC_ROLES, + type HouseholdConfigurationRepository, + type HouseholdTelegramChatRecord, + type HouseholdTopicBindingRecord, + type HouseholdTopicRole, + type RegisterTelegramHouseholdChatInput, + type RegisterTelegramHouseholdChatResult +} from './household-config' export type { AnonymousFeedbackMemberRecord, AnonymousFeedbackModerationStatus,