diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index e920823..7cd6d05 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -6,7 +6,9 @@ import type { HouseholdConfigurationRepository, ProcessedBotMessageRepository, TelegramPendingActionRecord, - TelegramPendingActionRepository + TelegramPendingActionRepository, + TopicMessageHistoryRecord, + TopicMessageHistoryRepository } from '@household/ports' import { createTelegramBot } from './bot' @@ -679,6 +681,37 @@ function createProcessedBotMessageRepository(): ProcessedBotMessageRepository { } } +function createTopicMessageHistoryRepository(): TopicMessageHistoryRepository { + const rows: TopicMessageHistoryRecord[] = [] + + return { + async saveMessage(input) { + rows.push(input) + }, + async listRecentThreadMessages(input) { + return rows + .filter( + (row) => + row.householdId === input.householdId && + row.telegramChatId === input.telegramChatId && + row.telegramThreadId === input.telegramThreadId + ) + .slice(-input.limit) + }, + async listRecentChatMessages(input) { + return rows + .filter( + (row) => + row.householdId === input.householdId && + row.telegramChatId === input.telegramChatId && + row.messageSentAt && + row.messageSentAt.epochMilliseconds >= input.sentAtOrAfter.epochMilliseconds + ) + .slice(-input.limit) + } + } +} + describe('registerDmAssistant', () => { test('replies with a conversational DM answer and records token usage', async () => { const bot = createTestBot() @@ -1703,6 +1736,81 @@ Confirm or cancel below.`, }) }) + test('loads persisted thread and same-day chat history for memory-style prompts', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + const topicMessageHistoryRepository = createTopicMessageHistoryRepository() + let recentThreadTexts: string[] = [] + let sameDayTexts: string[] = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123, + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + assistant: { + async respond(input) { + recentThreadTexts = input.recentThreadMessages?.map((message) => message.text) ?? [] + sameDayTexts = input.sameDayChatMessages?.map((message) => message.text) ?? [] + + return { + text: 'Yes. You were discussing a TV for the house.', + usage: { + inputTokens: 20, + outputTokens: 9, + totalTokens: 29 + } + } + } + }, + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker(), + topicMessageHistoryRepository + }) + + await bot.handleUpdate(topicMessageUpdate('I think we need a TV in the house') as never) + await bot.handleUpdate(topicMessageUpdate('Bot, do you remember what we said today?') as never) + + expect(recentThreadTexts).toContain('I think we need a TV in the house') + expect(sameDayTexts).toContain('I think we need a TV in the house') + expect(calls.at(-1)).toMatchObject({ + method: 'sendMessage', + payload: { + text: 'Yes. You were discussing a TV for the house.' + } + }) + }) + test('ignores duplicate deliveries of the same DM update', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts index c88ff1a..fdf2748 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -4,7 +4,8 @@ import type { Logger } from '@household/observability' import type { HouseholdConfigurationRepository, ProcessedBotMessageRepository, - TelegramPendingActionRepository + TelegramPendingActionRepository, + TopicMessageHistoryRepository } from '@household/ports' import type { Bot, Context } from 'grammy' @@ -37,6 +38,11 @@ import { getCachedTopicMessageRoute, looksLikeDirectBotAddress } from './topic-message-router' +import { + historyRecordToTurn, + shouldLoadExpandedChatHistory, + startOfCurrentDayInTimezone +} from './topic-history' import { startTypingIndicator } from './telegram-chat-action' import { stripExplicitBotMention } from './telegram-mentions' @@ -319,6 +325,92 @@ async function resolveAssistantConfig( } } +function currentThreadId(ctx: Context): string | null { + return ctx.msg && 'message_thread_id' in ctx.msg && ctx.msg.message_thread_id !== undefined + ? ctx.msg.message_thread_id.toString() + : null +} + +function currentMessageId(ctx: Context): string | null { + return ctx.msg?.message_id?.toString() ?? null +} + +function currentMessageSentAt(ctx: Context) { + return typeof ctx.msg?.date === 'number' ? instantFromEpochSeconds(ctx.msg.date) : null +} + +async function listRecentThreadMessages(input: { + repository: TopicMessageHistoryRepository | undefined + householdId: string + telegramChatId: string + telegramThreadId: string | null +}) { + if (!input.repository || !input.telegramThreadId) { + return [] + } + + const messages = await input.repository.listRecentThreadMessages({ + householdId: input.householdId, + telegramChatId: input.telegramChatId, + telegramThreadId: input.telegramThreadId, + limit: 8 + }) + + return messages.map(historyRecordToTurn) +} + +async function listExpandedChatMessages(input: { + repository: TopicMessageHistoryRepository | undefined + householdId: string + telegramChatId: string + timezone: string + shouldLoad: boolean +}) { + if (!input.repository || !input.shouldLoad) { + return [] + } + + const messages = await input.repository.listRecentChatMessages({ + householdId: input.householdId, + telegramChatId: input.telegramChatId, + sentAtOrAfter: startOfCurrentDayInTimezone(input.timezone), + limit: 40 + }) + + return messages.map(historyRecordToTurn) +} + +async function persistIncomingTopicMessage(input: { + repository: TopicMessageHistoryRepository | undefined + householdId: string + telegramChatId: string + telegramThreadId: string | null + telegramMessageId: string | null + telegramUpdateId: string | null + senderTelegramUserId: string + senderDisplayName: string | null + rawText: string + messageSentAt: ReturnType +}) { + const normalizedText = input.rawText.trim() + if (!input.repository || normalizedText.length === 0) { + return + } + + await input.repository.saveMessage({ + householdId: input.householdId, + telegramChatId: input.telegramChatId, + telegramThreadId: input.telegramThreadId, + telegramMessageId: input.telegramMessageId, + telegramUpdateId: input.telegramUpdateId, + senderTelegramUserId: input.senderTelegramUserId, + senderDisplayName: input.senderDisplayName, + isBot: false, + rawText: normalizedText, + messageSentAt: input.messageSentAt + }) +} + async function routeGroupAssistantMessage(input: { router: TopicMessageRouter | undefined locale: BotLocale @@ -330,6 +422,12 @@ async function routeGroupAssistantMessage(input: { assistantTone: string | null memoryStore: AssistantConversationMemoryStore memoryKey: string + recentThreadMessages: readonly { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null + }[] }) { if (!input.router) { return fallbackTopicMessageRoute({ @@ -341,7 +439,8 @@ async function routeGroupAssistantMessage(input: { activeWorkflow: null, assistantContext: input.assistantContext, assistantTone: input.assistantTone, - recentTurns: input.memoryStore.get(input.memoryKey).turns + recentTurns: input.memoryStore.get(input.memoryKey).turns, + recentThreadMessages: input.recentThreadMessages }) } @@ -354,7 +453,8 @@ async function routeGroupAssistantMessage(input: { activeWorkflow: null, assistantContext: input.assistantContext, assistantTone: input.assistantTone, - recentTurns: input.memoryStore.get(input.memoryKey).turns + recentTurns: input.memoryStore.get(input.memoryKey).turns, + recentThreadMessages: input.recentThreadMessages }) } @@ -469,6 +569,7 @@ async function buildHouseholdContext(input: { async function replyWithAssistant(input: { ctx: Context assistant: ConversationalAssistant | undefined + topicRole: TopicMessageRole householdId: string memberId: string memberDisplayName: string @@ -481,6 +582,18 @@ async function replyWithAssistant(input: { memoryStore: AssistantConversationMemoryStore usageTracker: AssistantUsageTracker logger: Logger | undefined + recentThreadMessages: readonly { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null + }[] + sameDayChatMessages: readonly { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null + }[] }): Promise { const t = getBotTranslations(input.locale).assistant @@ -516,9 +629,12 @@ async function replyWithAssistant(input: { const assistantResponseStartedAt = Date.now() const reply = await input.assistant.respond({ locale: input.locale, + topicRole: input.topicRole, householdContext, memorySummary: memory.summary, recentTurns: memory.turns, + recentThreadMessages: input.recentThreadMessages, + sameDayChatMessages: input.sameDayChatMessages, userMessage: input.userMessage }) assistantResponseMs = Date.now() - assistantResponseStartedAt @@ -582,6 +698,7 @@ export function registerDmAssistant(options: { bot: Bot assistant?: ConversationalAssistant topicRouter?: TopicMessageRouter + topicMessageHistoryRepository?: TopicMessageHistoryRepository purchaseRepository?: PurchaseMessageIngestionRepository purchaseInterpreter?: PurchaseMessageInterpreter householdConfigurationRepository: HouseholdConfigurationRepository @@ -1100,6 +1217,7 @@ export function registerDmAssistant(options: { await replyWithAssistant({ ctx, assistant: options.assistant, + topicRole: 'generic', householdId: member.householdId, memberId: member.id, memberDisplayName: member.displayName, @@ -1111,7 +1229,9 @@ export function registerDmAssistant(options: { financeService, memoryStore: options.memoryStore, usageTracker: options.usageTracker, - logger: options.logger + logger: options.logger, + recentThreadMessages: [], + sameDayChatMessages: [] }) } catch (error) { if (dedupeClaim) { @@ -1217,6 +1337,7 @@ export function registerDmAssistant(options: { telegramChatId, isPrivateChat: false }) + const telegramThreadId = currentThreadId(ctx) const messageText = mention?.strippedText ?? ctx.msg.text.trim() const assistantConfig = await resolveAssistantConfig( options.householdConfigurationRepository, @@ -1233,6 +1354,12 @@ export function registerDmAssistant(options: { topicRole === 'purchase' || topicRole === 'payments' ? getCachedTopicMessageRoute(ctx, topicRole) : null + const recentThreadMessages = await listRecentThreadMessages({ + repository: options.topicMessageHistoryRepository, + householdId: household.householdId, + telegramChatId, + telegramThreadId + }) const route = cachedRoute ?? (options.topicRouter @@ -1246,7 +1373,8 @@ export function registerDmAssistant(options: { assistantContext: assistantConfig.assistantContext, assistantTone: assistantConfig.assistantTone, memoryStore: options.memoryStore, - memoryKey + memoryKey, + recentThreadMessages }) : null) @@ -1367,6 +1495,7 @@ export function registerDmAssistant(options: { await replyWithAssistant({ ctx, assistant: options.assistant, + topicRole, householdId: household.householdId, memberId: member.id, memberDisplayName: member.displayName, @@ -1378,7 +1507,15 @@ export function registerDmAssistant(options: { financeService, memoryStore: options.memoryStore, usageTracker: options.usageTracker, - logger: options.logger + logger: options.logger, + recentThreadMessages, + sameDayChatMessages: await listExpandedChatMessages({ + repository: options.topicMessageHistoryRepository, + householdId: household.householdId, + telegramChatId, + timezone: settings.timezone, + shouldLoad: shouldLoadExpandedChatHistory(messageText) + }) }) } catch (error) { if (dedupeClaim) { @@ -1390,6 +1527,19 @@ export function registerDmAssistant(options: { } throw error + } finally { + await persistIncomingTopicMessage({ + repository: options.topicMessageHistoryRepository, + householdId: household.householdId, + telegramChatId, + telegramThreadId: currentThreadId(ctx), + telegramMessageId: currentMessageId(ctx), + telegramUpdateId: ctx.update.update_id?.toString() ?? null, + senderTelegramUserId: telegramUserId, + senderDisplayName: ctx.from?.first_name ?? member.displayName ?? ctx.from?.username ?? null, + rawText: mention?.strippedText ?? ctx.msg.text.trim(), + messageSentAt: currentMessageSentAt(ctx) + }) } }) } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 6b6e007..df41968 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -18,7 +18,8 @@ import { createDbHouseholdConfigurationRepository, createDbProcessedBotMessageRepository, createDbReminderDispatchRepository, - createDbTelegramPendingActionRepository + createDbTelegramPendingActionRepository, + createDbTopicMessageHistoryRepository } from '@household/adapters-db' import { configureLogger, getLogger } from '@household/observability' @@ -127,6 +128,9 @@ const processedBotMessageRepositoryClient = const purchaseRepositoryClient = runtime.databaseUrl ? createPurchaseMessageRepository(runtime.databaseUrl!) : null +const topicMessageHistoryRepositoryClient = runtime.databaseUrl + ? createDbTopicMessageHistoryRepository(runtime.databaseUrl!) + : null const purchaseInterpreter = createOpenAiPurchaseInterpreter( runtime.openaiApiKey, runtime.purchaseParserModel @@ -237,6 +241,10 @@ if (purchaseRepositoryClient) { shutdownTasks.push(purchaseRepositoryClient.close) } +if (topicMessageHistoryRepositoryClient) { + shutdownTasks.push(topicMessageHistoryRepositoryClient.close) +} + if (purchaseRepositoryClient && householdConfigurationRepositoryClient) { registerConfiguredPurchaseTopicIngestion( bot, @@ -246,7 +254,12 @@ if (purchaseRepositoryClient && householdConfigurationRepositoryClient) { ...(topicMessageRouter ? { router: topicMessageRouter, - memoryStore: assistantMemoryStore + memoryStore: assistantMemoryStore, + ...(topicMessageHistoryRepositoryClient + ? { + historyRepository: topicMessageHistoryRepositoryClient.repository + } + : {}) } : {}), ...(purchaseInterpreter @@ -268,7 +281,12 @@ if (purchaseRepositoryClient && householdConfigurationRepositoryClient) { ...(topicMessageRouter ? { router: topicMessageRouter, - memoryStore: assistantMemoryStore + memoryStore: assistantMemoryStore, + ...(topicMessageHistoryRepositoryClient + ? { + historyRepository: topicMessageHistoryRepositoryClient.repository + } + : {}) } : {}), logger: getLogger('payment-ingestion') @@ -440,6 +458,11 @@ if ( purchaseRepository: purchaseRepositoryClient.repository } : {}), + ...(topicMessageHistoryRepositoryClient + ? { + topicMessageHistoryRepository: topicMessageHistoryRepositoryClient.repository + } + : {}), ...(purchaseInterpreter ? { purchaseInterpreter @@ -471,6 +494,11 @@ if ( purchaseRepository: purchaseRepositoryClient.repository } : {}), + ...(topicMessageHistoryRepositoryClient + ? { + topicMessageHistoryRepository: topicMessageHistoryRepositoryClient.repository + } + : {}), ...(purchaseInterpreter ? { purchaseInterpreter diff --git a/apps/bot/src/openai-chat-assistant.test.ts b/apps/bot/src/openai-chat-assistant.test.ts index 8f0e824..2a26e05 100644 --- a/apps/bot/src/openai-chat-assistant.test.ts +++ b/apps/bot/src/openai-chat-assistant.test.ts @@ -41,6 +41,7 @@ describe('createOpenAiChatAssistant', () => { try { const reply = await assistant!.respond({ locale: 'en', + topicRole: 'reminders', householdContext: 'Household: Kojori House', memorySummary: null, recentTurns: [], @@ -51,10 +52,16 @@ describe('createOpenAiChatAssistant', () => { expect(capturedBody).not.toBeNull() expect(capturedBody!.max_output_tokens).toBe(220) expect(capturedBody!.model).toBe('gpt-5-mini') - expect(capturedBody!.input[0]).toMatchObject({ - role: 'system', - content: expect.stringContaining('Default to one to three short sentences.') - }) + expect(capturedBody!.input[0]?.role).toBe('system') + expect(capturedBody!.input[0]?.content).toContain('Default to one to three short sentences.') + expect(capturedBody!.input[0]?.content).toContain( + 'There is no general feature for creating or scheduling arbitrary personal reminders' + ) + expect(capturedBody!.input[1]?.role).toBe('system') + expect(capturedBody!.input[1]?.content).toContain('Topic role: reminders') + expect(capturedBody!.input[1]?.content).toContain( + 'You cannot create, schedule, snooze, or manage arbitrary personal reminders.' + ) } finally { globalThis.fetch = originalFetch } diff --git a/apps/bot/src/openai-chat-assistant.ts b/apps/bot/src/openai-chat-assistant.ts index 57d3fd0..cadc684 100644 --- a/apps/bot/src/openai-chat-assistant.ts +++ b/apps/bot/src/openai-chat-assistant.ts @@ -1,4 +1,5 @@ import { extractOpenAiResponseText, type OpenAiResponsePayload } from './openai-responses' +import type { TopicMessageRole } from './topic-message-router' const ASSISTANT_MAX_OUTPUT_TOKENS = 220 @@ -16,16 +17,70 @@ export interface AssistantReply { export interface ConversationalAssistant { respond(input: { locale: 'en' | 'ru' + topicRole: TopicMessageRole householdContext: string memorySummary: string | null recentTurns: readonly { role: 'user' | 'assistant' text: string }[] + recentThreadMessages?: readonly { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null + }[] + sameDayChatMessages?: readonly { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null + }[] userMessage: string }): Promise } +function topicCapabilityNotes(topicRole: TopicMessageRole): string { + switch (topicRole) { + case 'purchase': + return [ + 'Purchase topic capabilities:', + '- You can discuss shared household purchases, clarify intent, and help with purchase recording flow.', + '- You cannot claim a purchase was saved unless the system explicitly confirmed it.', + '- You cannot create unrelated reminders, tasks, or household settings changes.' + ].join('\n') + case 'payments': + return [ + 'Payments topic capabilities:', + '- You can discuss rent and utility payment status and supported payment confirmation flows.', + '- You cannot claim a payment was recorded unless the system explicitly confirmed it.', + '- You cannot schedule reminders or create arbitrary tasks.' + ].join('\n') + case 'reminders': + return [ + 'Reminders topic capabilities:', + '- You can discuss existing household rent/utilities reminder timing and the supported utility-bill collection flow.', + '- You cannot create, schedule, snooze, or manage arbitrary personal reminders.', + '- You cannot promise future reminder setup. If asked, say that this feature is not supported.' + ].join('\n') + case 'feedback': + return [ + 'Feedback topic capabilities:', + '- You can discuss the anonymous feedback flow and household feedback context.', + '- You cannot claim a submission was posted unless the system explicitly confirmed it.', + '- You cannot schedule reminders or create unrelated workflow items.' + ].join('\n') + case 'generic': + default: + return [ + 'General household chat capabilities:', + '- You can answer household finance and context questions using the provided information.', + '- You cannot create arbitrary reminders, scheduled tasks, or background jobs.', + '- Never imply unsupported features exist.' + ].join('\n') + } +} + const ASSISTANT_SYSTEM_PROMPT = [ 'You are Kojori, a household finance assistant for one specific household.', 'Stay within the provided household context and recent conversation context.', @@ -41,6 +96,8 @@ const ASSISTANT_SYSTEM_PROMPT = [ 'If the user tells you to stop, back off briefly and do not keep asking follow-up questions.', 'Do not repeat the same clarification after the user declines, backs off, or says they are only thinking.', 'Do not restate the full household context unless the user explicitly asks for details.', + 'Do not imply capabilities that are not explicitly provided in the system context.', + 'There is no general feature for creating or scheduling arbitrary personal reminders unless the system explicitly says so.', 'Avoid bullet lists unless the user asked for a list or several distinct items.', 'Reply in the user language inferred from the latest user message and locale context.' ].join(' ') @@ -79,6 +136,8 @@ export function createOpenAiChatAssistant( role: 'system', content: [ `User locale: ${input.locale}`, + `Topic role: ${input.topicRole}`, + topicCapabilityNotes(input.topicRole), 'Bounded household context:', input.householdContext, input.memorySummary ? `Conversation summary:\n${input.memorySummary}` : null, @@ -87,6 +146,24 @@ export function createOpenAiChatAssistant( 'Recent conversation turns:', ...input.recentTurns.map((turn) => `${turn.role}: ${turn.text}`) ].join('\n') + : null, + input.recentThreadMessages && input.recentThreadMessages.length > 0 + ? [ + 'Recent topic thread messages:', + ...input.recentThreadMessages.map( + (message) => `${message.speaker} (${message.role}): ${message.text}` + ) + ].join('\n') + : null, + input.sameDayChatMessages && input.sameDayChatMessages.length > 0 + ? [ + 'Additional same-day household chat history:', + ...input.sameDayChatMessages.map((message) => + message.threadId + ? `[thread ${message.threadId}] ${message.speaker} (${message.role}): ${message.text}` + : `${message.speaker} (${message.role}): ${message.text}` + ) + ].join('\n') : null ] .filter(Boolean) diff --git a/apps/bot/src/payment-topic-ingestion.ts b/apps/bot/src/payment-topic-ingestion.ts index f09a21f..a2d62c5 100644 --- a/apps/bot/src/payment-topic-ingestion.ts +++ b/apps/bot/src/payment-topic-ingestion.ts @@ -5,7 +5,8 @@ import type { Logger } from '@household/observability' import type { HouseholdConfigurationRepository, HouseholdTopicBindingRecord, - TelegramPendingActionRepository + TelegramPendingActionRepository, + TopicMessageHistoryRepository } from '@household/ports' import { getBotTranslations, type BotLocale } from './i18n' @@ -25,6 +26,7 @@ import { looksLikeDirectBotAddress, type TopicMessageRouter } from './topic-message-router' +import { historyRecordToTurn } from './topic-history' import { stripExplicitBotMention } from './telegram-mentions' const PAYMENT_TOPIC_CONFIRM_CALLBACK_PREFIX = 'payment_topic:confirm:' @@ -215,6 +217,46 @@ function appendConversation( }) } +async function listRecentThreadMessages( + repository: TopicMessageHistoryRepository | undefined, + record: PaymentTopicRecord +) { + if (!repository) { + return [] + } + + const messages = await repository.listRecentThreadMessages({ + householdId: record.householdId, + telegramChatId: record.chatId, + telegramThreadId: record.threadId, + limit: 8 + }) + + return messages.map(historyRecordToTurn) +} + +async function persistIncomingTopicMessage( + repository: TopicMessageHistoryRepository | undefined, + record: PaymentTopicRecord +) { + if (!repository || record.rawText.trim().length === 0) { + return + } + + await repository.saveMessage({ + householdId: record.householdId, + telegramChatId: record.chatId, + telegramThreadId: record.threadId, + telegramMessageId: record.messageId, + telegramUpdateId: String(record.updateId), + senderTelegramUserId: record.senderTelegramUserId, + senderDisplayName: null, + isBot: false, + rawText: record.rawText.trim(), + messageSentAt: record.messageSentAt + }) +} + async function routePaymentTopicMessage(input: { record: PaymentTopicRecord locale: BotLocale @@ -225,6 +267,7 @@ async function routePaymentTopicMessage(input: { assistantContext: string | null assistantTone: string | null memoryStore: AssistantConversationMemoryStore | undefined + historyRepository: TopicMessageHistoryRepository | undefined router: TopicMessageRouter | undefined }) { if (!input.router) { @@ -249,6 +292,8 @@ async function routePaymentTopicMessage(input: { } } + const recentThreadMessages = await listRecentThreadMessages(input.historyRepository, input.record) + return input.router({ locale: input.locale, topicRole: input.topicRole, @@ -258,7 +303,8 @@ async function routePaymentTopicMessage(input: { activeWorkflow: input.activeWorkflow, assistantContext: input.assistantContext, assistantTone: input.assistantTone, - recentTurns: input.memoryStore?.get(memoryKeyForRecord(input.record)).turns ?? [] + recentTurns: input.memoryStore?.get(memoryKeyForRecord(input.record)).turns ?? [], + recentThreadMessages }) } @@ -379,6 +425,7 @@ export function registerConfiguredPaymentTopicIngestion( options: { router?: TopicMessageRouter memoryStore?: AssistantConversationMemoryStore + historyRepository?: TopicMessageHistoryRepository logger?: Logger } = {} ): void { @@ -574,6 +621,7 @@ export function registerConfiguredPaymentTopicIngestion( assistantContext: assistantConfig.assistantContext, assistantTone: assistantConfig.assistantTone, memoryStore: options.memoryStore, + historyRepository: options.historyRepository, router: options.router })) cacheTopicMessageRoute(ctx, 'payments', route) @@ -722,6 +770,8 @@ export function registerConfiguredPaymentTopicIngestion( }, 'Failed to ingest payment confirmation' ) + } finally { + await persistIncomingTopicMessage(options.historyRepository, record) } }) } diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index ac71b41..620e25a 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from 'bun:test' import { instantFromIso } from '@household/domain' -import type { HouseholdConfigurationRepository } from '@household/ports' +import type { + HouseholdConfigurationRepository, + TopicMessageHistoryRecord, + TopicMessageHistoryRepository +} from '@household/ports' import { createTelegramBot } from './bot' -import { createInMemoryAssistantConversationMemoryStore } from './assistant-state' import { buildPurchaseAcknowledgement, @@ -154,6 +157,37 @@ function createTestBot() { return bot } +function createTopicMessageHistoryRepository(): TopicMessageHistoryRepository { + const rows: TopicMessageHistoryRecord[] = [] + + return { + async saveMessage(input) { + rows.push(input) + }, + async listRecentThreadMessages(input) { + return rows + .filter( + (row) => + row.householdId === input.householdId && + row.telegramChatId === input.telegramChatId && + row.telegramThreadId === input.telegramThreadId + ) + .slice(-input.limit) + }, + async listRecentChatMessages(input) { + return rows + .filter( + (row) => + row.householdId === input.householdId && + row.telegramChatId === input.telegramChatId && + row.messageSentAt && + row.messageSentAt.epochMilliseconds >= input.sentAtOrAfter.epochMilliseconds + ) + .slice(-input.limit) + } + } +} + describe('extractPurchaseTopicCandidate', () => { test('returns record when message belongs to configured topic', () => { const record = extractPurchaseTopicCandidate(candidate(), config) @@ -1667,7 +1701,7 @@ Confirm or cancel below.`, test('uses recent silent planning context for direct bot-address advice replies', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] - const memoryStore = createInMemoryAssistantConversationMemoryStore(12) + const historyRepository = createTopicMessageHistoryRepository() let sawDirectAddress = false let recentTurnTexts: string[] = [] @@ -1699,7 +1733,7 @@ Confirm or cancel below.`, } registerPurchaseTopicIngestion(bot, config, repository, { - memoryStore, + historyRepository, router: async (input) => { if (input.messageText.includes('думаю купить')) { return { @@ -1714,7 +1748,7 @@ Confirm or cancel below.`, } sawDirectAddress = input.isExplicitMention - recentTurnTexts = input.recentTurns?.map((turn) => turn.text) ?? [] + recentTurnTexts = input.recentThreadMessages?.map((turn) => turn.text) ?? [] return { route: 'chat_reply', @@ -1795,7 +1829,7 @@ Confirm or cancel below.`, test('keeps silent planning context scoped to the current purchase thread', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] - const memoryStore = createInMemoryAssistantConversationMemoryStore(12) + const historyRepository = createTopicMessageHistoryRepository() let recentTurnTexts: string[] = [] bot.api.config.use(async (_prev, method, payload) => { @@ -1874,7 +1908,7 @@ Confirm or cancel below.`, householdConfigurationRepository as unknown as HouseholdConfigurationRepository, repository, { - memoryStore, + historyRepository, router: async (input) => { if (input.messageText.includes('картошки')) { return { @@ -1888,7 +1922,7 @@ Confirm or cancel below.`, } } - recentTurnTexts = input.recentTurns?.map((turn) => turn.text) ?? [] + recentTurnTexts = input.recentThreadMessages?.map((turn) => turn.text) ?? [] return { route: 'chat_reply', diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 8eec66b..93c3eea 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -4,7 +4,8 @@ import type { Bot, Context } from 'grammy' import type { Logger } from '@household/observability' import type { HouseholdConfigurationRepository, - HouseholdTopicBindingRecord + HouseholdTopicBindingRecord, + TopicMessageHistoryRepository } from '@household/ports' import { createDbClient, schema } from '@household/db' @@ -22,6 +23,7 @@ import { type TopicMessageRouter, type TopicMessageRoutingResult } from './topic-message-router' +import { historyRecordToTurn } from './topic-history' import { startTypingIndicator } from './telegram-chat-action' import { stripExplicitBotMention } from './telegram-mentions' @@ -1476,6 +1478,46 @@ function rememberAssistantTurn( }) } +async function listRecentThreadMessages( + repository: TopicMessageHistoryRepository | undefined, + record: PurchaseTopicRecord +) { + if (!repository) { + return [] + } + + const messages = await repository.listRecentThreadMessages({ + householdId: record.householdId, + telegramChatId: record.chatId, + telegramThreadId: record.threadId, + limit: 8 + }) + + return messages.map(historyRecordToTurn) +} + +async function persistIncomingTopicMessage( + repository: TopicMessageHistoryRepository | undefined, + record: PurchaseTopicRecord +) { + if (!repository || record.rawText.trim().length === 0) { + return + } + + await repository.saveMessage({ + householdId: record.householdId, + telegramChatId: record.chatId, + telegramThreadId: record.threadId, + telegramMessageId: record.messageId, + telegramUpdateId: String(record.updateId), + senderTelegramUserId: record.senderTelegramUserId, + senderDisplayName: record.senderDisplayName ?? null, + isBot: false, + rawText: record.rawText.trim(), + messageSentAt: record.messageSentAt + }) +} + async function routePurchaseTopicMessage(input: { ctx: Pick record: PurchaseTopicRecord @@ -1486,6 +1528,7 @@ async function routePurchaseTopicMessage(input: { > router: TopicMessageRouter | undefined memoryStore: AssistantConversationMemoryStore | undefined + historyRepository: TopicMessageHistoryRepository | undefined assistantContext?: string | null assistantTone?: string | null }): Promise { @@ -1543,6 +1586,7 @@ async function routePurchaseTopicMessage(input: { const key = memoryKeyForRecord(input.record) const recentTurns = input.memoryStore?.get(key).turns ?? [] + const recentThreadMessages = await listRecentThreadMessages(input.historyRepository, input.record) return input.router({ locale: input.locale, @@ -1557,7 +1601,8 @@ async function routePurchaseTopicMessage(input: { : null, assistantContext: input.assistantContext ?? null, assistantTone: input.assistantTone ?? null, - recentTurns + recentTurns, + recentThreadMessages }) } @@ -1890,6 +1935,7 @@ export function registerPurchaseTopicIngestion( interpreter?: PurchaseMessageInterpreter router?: TopicMessageRouter memoryStore?: AssistantConversationMemoryStore + historyRepository?: TopicMessageHistoryRepository logger?: Logger } = {} ): void { @@ -1919,7 +1965,8 @@ export function registerPurchaseTopicIngestion( locale: 'en', repository, router: options.router, - memoryStore: options.memoryStore + memoryStore: options.memoryStore, + historyRepository: options.historyRepository })) cacheTopicMessageRoute(ctx, 'purchase', route) @@ -1980,6 +2027,7 @@ export function registerPurchaseTopicIngestion( 'Failed to ingest purchase topic message' ) } finally { + await persistIncomingTopicMessage(options.historyRepository, record) typingIndicator?.stop() } }) @@ -1993,6 +2041,7 @@ export function registerConfiguredPurchaseTopicIngestion( interpreter?: PurchaseMessageInterpreter router?: TopicMessageRouter memoryStore?: AssistantConversationMemoryStore + historyRepository?: TopicMessageHistoryRepository logger?: Logger } = {} ): void { @@ -2046,6 +2095,7 @@ export function registerConfiguredPurchaseTopicIngestion( repository, router: options.router, memoryStore: options.memoryStore, + historyRepository: options.historyRepository, assistantContext: assistantConfig.assistantContext, assistantTone: assistantConfig.assistantTone })) @@ -2121,6 +2171,7 @@ export function registerConfiguredPurchaseTopicIngestion( 'Failed to ingest purchase topic message' ) } finally { + await persistIncomingTopicMessage(options.historyRepository, record) typingIndicator?.stop() } }) diff --git a/apps/bot/src/topic-history.ts b/apps/bot/src/topic-history.ts new file mode 100644 index 0000000..851e5fd --- /dev/null +++ b/apps/bot/src/topic-history.ts @@ -0,0 +1,66 @@ +import { nowInstant, Temporal, type Instant } from '@household/domain' +import type { TopicMessageHistoryRecord } from '@household/ports' + +export interface TopicHistoryTurn { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null +} + +const MEMORY_LOOKUP_PATTERN = + /\b(?:do you remember|remember|what were we talking about|what did we say today)\b|(?:^|[^\p{L}])(?:помнишь|ты\s+помнишь|что\s+мы\s+сегодня\s+обсуждали|о\s+чем\s+мы\s+говорили)(?=$|[^\p{L}])/iu + +export function shouldLoadExpandedChatHistory(text: string): boolean { + return MEMORY_LOOKUP_PATTERN.test(text.trim()) +} + +export function startOfCurrentDayInTimezone( + timezone: string, + referenceInstant = nowInstant() +): Instant { + const zoned = referenceInstant.toZonedDateTimeISO(timezone) + const startOfDay = Temporal.ZonedDateTime.from({ + timeZone: timezone, + year: zoned.year, + month: zoned.month, + day: zoned.day, + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + microsecond: 0, + nanosecond: 0 + }) + + return startOfDay.toInstant() +} + +export function historyRecordToTurn(record: TopicMessageHistoryRecord): TopicHistoryTurn { + return { + role: record.isBot ? 'assistant' : 'user', + speaker: record.senderDisplayName ?? (record.isBot ? 'Kojori Bot' : 'Unknown'), + text: record.rawText.trim(), + threadId: record.telegramThreadId + } +} + +export function formatThreadHistory(turns: readonly TopicHistoryTurn[]): string | null { + const lines = turns + .map((turn) => `${turn.speaker} (${turn.role}): ${turn.text}`) + .filter((line) => line.trim().length > 0) + + return lines.length > 0 ? lines.join('\n') : null +} + +export function formatSameDayChatHistory(turns: readonly TopicHistoryTurn[]): string | null { + const lines = turns + .map((turn) => + turn.threadId + ? `[thread ${turn.threadId}] ${turn.speaker} (${turn.role}): ${turn.text}` + : `${turn.speaker} (${turn.role}): ${turn.text}` + ) + .filter((line) => line.trim().length > 0) + + return lines.length > 0 ? lines.join('\n') : null +} diff --git a/apps/bot/src/topic-message-router.ts b/apps/bot/src/topic-message-router.ts index 6df2568..2d71302 100644 --- a/apps/bot/src/topic-message-router.ts +++ b/apps/bot/src/topic-message-router.ts @@ -31,6 +31,12 @@ export interface TopicMessageRoutingInput { role: 'user' | 'assistant' text: string }[] + recentThreadMessages?: readonly { + role: 'user' | 'assistant' + speaker: string + text: string + threadId: string | null + }[] } export interface TopicMessageRoutingResult { @@ -251,6 +257,17 @@ function buildRecentTurns(input: TopicMessageRoutingInput): string | null { : null } +function buildRecentThreadMessages(input: TopicMessageRoutingInput): string | null { + const recentMessages = input.recentThreadMessages + ?.slice(-8) + .map((message) => `${message.speaker} (${message.role}): ${message.text.trim()}`) + .filter((line) => line.length > 0) + + return recentMessages && recentMessages.length > 0 + ? ['Recent messages in this topic thread:', ...recentMessages].join('\n') + : null +} + export function cacheTopicMessageRoute( ctx: Context, topicRole: CachedTopicMessageRole, @@ -305,6 +322,7 @@ export function createOpenAiTopicMessageRouter( 'When the user directly addresses the bot with small talk, joking, or testing, prefer chat_reply with one short sentence.', 'In a purchase topic, if the user is discussing a possible future purchase and asks for an opinion, prefer chat_reply with a short contextual opinion instead of a workflow.', 'Use the recent conversation when writing replyText. Do not ignore the already-established subject.', + 'The recent thread messages are more important than the per-user memory summary.', 'If the user asks what you think about a price or quantity, mention the actual item/price from context when possible.', 'Use topic_helper only when the message is a real question or request that likely needs household knowledge or a topic-specific helper.', 'Use purchase_candidate only for a clear completed shared purchase.', @@ -331,6 +349,7 @@ export function createOpenAiTopicMessageRouter( `Reply to bot: ${input.isReplyToBot ? 'yes' : 'no'}`, `Looks like direct address: ${looksLikeDirectBotAddress(input.messageText) ? 'yes' : 'no'}`, `Active workflow: ${input.activeWorkflow ?? 'none'}`, + buildRecentThreadMessages(input), buildRecentTurns(input), `Latest message:\n${input.messageText}` ] diff --git a/packages/adapters-db/src/index.ts b/packages/adapters-db/src/index.ts index 48073d1..8ae64ba 100644 --- a/packages/adapters-db/src/index.ts +++ b/packages/adapters-db/src/index.ts @@ -4,3 +4,4 @@ export { createDbHouseholdConfigurationRepository } from './household-config-rep export { createDbProcessedBotMessageRepository } from './processed-bot-message-repository' export { createDbReminderDispatchRepository } from './reminder-dispatch-repository' export { createDbTelegramPendingActionRepository } from './telegram-pending-action-repository' +export { createDbTopicMessageHistoryRepository } from './topic-message-history-repository' diff --git a/packages/adapters-db/src/topic-message-history-repository.ts b/packages/adapters-db/src/topic-message-history-repository.ts new file mode 100644 index 0000000..8d6576c --- /dev/null +++ b/packages/adapters-db/src/topic-message-history-repository.ts @@ -0,0 +1,99 @@ +import { and, asc, desc, eq, gte, isNotNull } from 'drizzle-orm' + +import { instantFromDatabaseValue, instantToDate } from '@household/domain' +import { createDbClient, schema } from '@household/db' +import type { TopicMessageHistoryRepository } from '@household/ports' + +export function createDbTopicMessageHistoryRepository(databaseUrl: string): { + repository: TopicMessageHistoryRepository + close: () => Promise +} { + const { db, queryClient } = createDbClient(databaseUrl, { + max: 3, + prepare: false + }) + + const repository: TopicMessageHistoryRepository = { + async saveMessage(input) { + await db + .insert(schema.topicMessages) + .values({ + householdId: input.householdId, + telegramChatId: input.telegramChatId, + telegramThreadId: input.telegramThreadId, + telegramMessageId: input.telegramMessageId, + telegramUpdateId: input.telegramUpdateId, + senderTelegramUserId: input.senderTelegramUserId, + senderDisplayName: input.senderDisplayName, + isBot: input.isBot ? 1 : 0, + rawText: input.rawText, + messageSentAt: input.messageSentAt ? instantToDate(input.messageSentAt) : null + }) + .onConflictDoNothing() + }, + + async listRecentThreadMessages(input) { + const rows = await db + .select() + .from(schema.topicMessages) + .where( + and( + eq(schema.topicMessages.householdId, input.householdId), + eq(schema.topicMessages.telegramChatId, input.telegramChatId), + eq(schema.topicMessages.telegramThreadId, input.telegramThreadId) + ) + ) + .orderBy(desc(schema.topicMessages.messageSentAt), desc(schema.topicMessages.createdAt)) + .limit(input.limit) + + return rows.reverse().map((row) => ({ + householdId: row.householdId, + telegramChatId: row.telegramChatId, + telegramThreadId: row.telegramThreadId, + telegramMessageId: row.telegramMessageId, + telegramUpdateId: row.telegramUpdateId, + senderTelegramUserId: row.senderTelegramUserId, + senderDisplayName: row.senderDisplayName, + isBot: row.isBot === 1, + rawText: row.rawText, + messageSentAt: instantFromDatabaseValue(row.messageSentAt) + })) + }, + + async listRecentChatMessages(input) { + const rows = await db + .select() + .from(schema.topicMessages) + .where( + and( + eq(schema.topicMessages.householdId, input.householdId), + eq(schema.topicMessages.telegramChatId, input.telegramChatId), + isNotNull(schema.topicMessages.messageSentAt), + gte(schema.topicMessages.messageSentAt, instantToDate(input.sentAtOrAfter)) + ) + ) + .orderBy(asc(schema.topicMessages.messageSentAt), asc(schema.topicMessages.createdAt)) + .limit(input.limit) + + return rows.map((row) => ({ + householdId: row.householdId, + telegramChatId: row.telegramChatId, + telegramThreadId: row.telegramThreadId, + telegramMessageId: row.telegramMessageId, + telegramUpdateId: row.telegramUpdateId, + senderTelegramUserId: row.senderTelegramUserId, + senderDisplayName: row.senderDisplayName, + isBot: row.isBot === 1, + rawText: row.rawText, + messageSentAt: instantFromDatabaseValue(row.messageSentAt) + })) + } + } + + return { + repository, + close: async () => { + await queryClient.end({ timeout: 5 }) + } + } +} diff --git a/packages/db/drizzle-checksums.json b/packages/db/drizzle-checksums.json index 28d9555..1b5c144 100644 --- a/packages/db/drizzle-checksums.json +++ b/packages/db/drizzle-checksums.json @@ -19,6 +19,7 @@ "0015_white_owl.sql": "a9dec4c536c660d7eb0fcea42a3bedb1301408551977d098dff8324d7d5b26bd", "0016_equal_susan_delgado.sql": "1698bf0516d16d2d7929dcb1bd2bb76d5a629eaba3d0bb2533c1ae926408de7a", "0017_gigantic_selene.sql": "232d61b979675ddb97c9d69d14406dc15dd095ee6a332d3fa71d10416204fade", - "0018_nimble_kojori.sql": "818738e729119c6de8049dcfca562926a5dc6e321ecbbf9cf38e02bc70b5a0dc" + "0018_nimble_kojori.sql": "818738e729119c6de8049dcfca562926a5dc6e321ecbbf9cf38e02bc70b5a0dc", + "0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641" } } diff --git a/packages/db/drizzle/0019_faithful_madame_masque.sql b/packages/db/drizzle/0019_faithful_madame_masque.sql new file mode 100644 index 0000000..e7a19d4 --- /dev/null +++ b/packages/db/drizzle/0019_faithful_madame_masque.sql @@ -0,0 +1,20 @@ +CREATE TABLE "topic_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "telegram_chat_id" text NOT NULL, + "telegram_thread_id" text, + "telegram_message_id" text, + "telegram_update_id" text, + "sender_telegram_user_id" text, + "sender_display_name" text, + "is_bot" integer DEFAULT 0 NOT NULL, + "raw_text" text NOT NULL, + "message_sent_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "topic_messages" ADD CONSTRAINT "topic_messages_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "topic_messages_household_thread_sent_idx" ON "topic_messages" USING btree ("household_id","telegram_chat_id","telegram_thread_id","message_sent_at");--> statement-breakpoint +CREATE INDEX "topic_messages_household_chat_sent_idx" ON "topic_messages" USING btree ("household_id","telegram_chat_id","message_sent_at");--> statement-breakpoint +CREATE UNIQUE INDEX "topic_messages_household_tg_message_unique" ON "topic_messages" USING btree ("household_id","telegram_chat_id","telegram_message_id");--> statement-breakpoint +CREATE UNIQUE INDEX "topic_messages_household_tg_update_unique" ON "topic_messages" USING btree ("household_id","telegram_update_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0019_snapshot.json b/packages/db/drizzle/meta/0019_snapshot.json new file mode 100644 index 0000000..e02457b --- /dev/null +++ b/packages/db/drizzle/meta/0019_snapshot.json @@ -0,0 +1,3441 @@ +{ + "id": "60e7d289-1961-42fc-bf48-69db6c8ac86b", + "prevId": "0eed7ba6-83cb-42b9-8766-47c7d6d3ffa9", + "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_cycle_exchange_rates": { + "name": "billing_cycle_exchange_rates", + "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 + }, + "source_currency": { + "name": "source_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_currency": { + "name": "target_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rate_micros": { + "name": "rate_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "effective_date": { + "name": "effective_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'nbg'" + }, + "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": { + "billing_cycle_exchange_rates_cycle_pair_unique": { + "name": "billing_cycle_exchange_rates_cycle_pair_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycle_exchange_rates_cycle_idx": { + "name": "billing_cycle_exchange_rates_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycle_exchange_rates_cycle_id_billing_cycles_id_fk": { + "name": "billing_cycle_exchange_rates_cycle_id_billing_cycles_id_fk", + "tableFrom": "billing_cycle_exchange_rates", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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_billing_settings": { + "name": "household_billing_settings", + "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 + }, + "settlement_currency": { + "name": "settlement_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'GEL'" + }, + "payment_balance_adjustment_policy": { + "name": "payment_balance_adjustment_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'utilities'" + }, + "rent_amount_minor": { + "name": "rent_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "rent_currency": { + "name": "rent_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "rent_due_day": { + "name": "rent_due_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "rent_warning_day": { + "name": "rent_warning_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 17 + }, + "utilities_due_day": { + "name": "utilities_due_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "utilities_reminder_day": { + "name": "utilities_reminder_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Asia/Tbilisi'" + }, + "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_billing_settings_household_unique": { + "name": "household_billing_settings_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_billing_settings_household_id_households_id_fk": { + "name": "household_billing_settings_household_id_households_id_fk", + "tableFrom": "household_billing_settings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_join_tokens": { + "name": "household_join_tokens", + "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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_telegram_user_id": { + "name": "created_by_telegram_user_id", + "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_join_tokens_household_unique": { + "name": "household_join_tokens_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_join_tokens_token_unique": { + "name": "household_join_tokens_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_join_tokens_household_id_households_id_fk": { + "name": "household_join_tokens_household_id_households_id_fk", + "tableFrom": "household_join_tokens", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_pending_members": { + "name": "household_pending_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 + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language_code": { + "name": "language_code", + "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_pending_members_household_user_unique": { + "name": "household_pending_members_household_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": {} + }, + "household_pending_members_telegram_user_idx": { + "name": "household_pending_members_telegram_user_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_pending_members_household_id_households_id_fk": { + "name": "household_pending_members_household_id_households_id_fk", + "tableFrom": "household_pending_members", + "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.household_utility_categories": { + "name": "household_utility_categories", + "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 + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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_utility_categories_household_slug_unique": { + "name": "household_utility_categories_household_slug_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_utility_categories_household_sort_idx": { + "name": "household_utility_categories_household_sort_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_utility_categories_household_id_households_id_fk": { + "name": "household_utility_categories_household_id_households_id_fk", + "tableFrom": "household_utility_categories", + "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 + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "assistant_context": { + "name": "assistant_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assistant_tone": { + "name": "assistant_tone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.member_absence_policies": { + "name": "member_absence_policies", + "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 + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "member_absence_policies_household_member_period_unique": { + "name": "member_absence_policies_household_member_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_absence_policies_household_member_idx": { + "name": "member_absence_policies_household_member_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_absence_policies_household_id_households_id_fk": { + "name": "member_absence_policies_household_id_households_id_fk", + "tableFrom": "member_absence_policies", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_absence_policies_member_id_members_id_fk": { + "name": "member_absence_policies_member_id_members_id_fk", + "tableFrom": "member_absence_policies", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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 + }, + "lifecycle_status": { + "name": "lifecycle_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rent_share_weight": { + "name": "rent_share_weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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.payment_confirmations": { + "name": "payment_confirmations", + "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 + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "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": true + }, + "detected_kind": { + "name": "detected_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "explicit_amount_minor": { + "name": "explicit_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "explicit_currency": { + "name": "explicit_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_amount_minor": { + "name": "resolved_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "resolved_currency": { + "name": "resolved_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachment_count": { + "name": "attachment_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_confirmations_household_tg_message_unique": { + "name": "payment_confirmations_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": {} + }, + "payment_confirmations_household_tg_update_unique": { + "name": "payment_confirmations_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": {} + }, + "payment_confirmations_household_status_idx": { + "name": "payment_confirmations_household_status_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_confirmations_member_created_idx": { + "name": "payment_confirmations_member_created_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_confirmations_household_id_households_id_fk": { + "name": "payment_confirmations_household_id_households_id_fk", + "tableFrom": "payment_confirmations", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_confirmations_cycle_id_billing_cycles_id_fk": { + "name": "payment_confirmations_cycle_id_billing_cycles_id_fk", + "tableFrom": "payment_confirmations", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "payment_confirmations_member_id_members_id_fk": { + "name": "payment_confirmations_member_id_members_id_fk", + "tableFrom": "payment_confirmations", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_records": { + "name": "payment_records", + "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 + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "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 + }, + "confirmation_id": { + "name": "confirmation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_records_cycle_member_idx": { + "name": "payment_records_cycle_member_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_records_cycle_kind_idx": { + "name": "payment_records_cycle_kind_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_records_confirmation_unique": { + "name": "payment_records_confirmation_unique", + "columns": [ + { + "expression": "confirmation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_records_household_id_households_id_fk": { + "name": "payment_records_household_id_households_id_fk", + "tableFrom": "payment_records", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_records_cycle_id_billing_cycles_id_fk": { + "name": "payment_records_cycle_id_billing_cycles_id_fk", + "tableFrom": "payment_records", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_records_member_id_members_id_fk": { + "name": "payment_records_member_id_members_id_fk", + "tableFrom": "payment_records", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "payment_records_confirmation_id_payment_confirmations_id_fk": { + "name": "payment_records_confirmation_id_payment_confirmations_id_fk", + "tableFrom": "payment_records", + "tableTo": "payment_confirmations", + "columnsFrom": ["confirmation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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_message_participants": { + "name": "purchase_message_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "purchase_message_id": { + "name": "purchase_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "included": { + "name": "included", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "share_amount_minor": { + "name": "share_amount_minor", + "type": "bigint", + "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": { + "purchase_message_participants_purchase_member_unique": { + "name": "purchase_message_participants_purchase_member_unique", + "columns": [ + { + "expression": "purchase_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_message_participants_purchase_idx": { + "name": "purchase_message_participants_purchase_idx", + "columns": [ + { + "expression": "purchase_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_message_participants_member_idx": { + "name": "purchase_message_participants_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_message_participants_purchase_message_id_purchase_messages_id_fk": { + "name": "purchase_message_participants_purchase_message_id_purchase_messages_id_fk", + "tableFrom": "purchase_message_participants", + "tableTo": "purchase_messages", + "columnsFrom": ["purchase_message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_message_participants_member_id_members_id_fk": { + "name": "purchase_message_participants_member_id_members_id_fk", + "tableFrom": "purchase_message_participants", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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 + }, + "participant_split_mode": { + "name": "participant_split_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'equal'" + }, + "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.telegram_pending_actions": { + "name": "telegram_pending_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_pending_actions_chat_user_unique": { + "name": "telegram_pending_actions_chat_user_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "telegram_pending_actions_user_action_idx": { + "name": "telegram_pending_actions_user_action_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.topic_messages": { + "name": "topic_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 + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_bot": { + "name": "is_bot", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "topic_messages_household_thread_sent_idx": { + "name": "topic_messages_household_thread_sent_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topic_messages_household_chat_sent_idx": { + "name": "topic_messages_household_chat_sent_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topic_messages_household_tg_message_unique": { + "name": "topic_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": {} + }, + "topic_messages_household_tg_update_unique": { + "name": "topic_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": { + "topic_messages_household_id_households_id_fk": { + "name": "topic_messages_household_id_households_id_fk", + "tableFrom": "topic_messages", + "tableTo": "households", + "columnsFrom": ["household_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 e9988c1..e305265 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1773252000000, "tag": "0018_nimble_kojori", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1773327708167, + "tag": "0019_faithful_madame_masque", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 7feb020..4ea4c2d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -499,6 +499,48 @@ export const processedBotMessages = pgTable( }) ) +export const topicMessages = pgTable( + 'topic_messages', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + telegramChatId: text('telegram_chat_id').notNull(), + telegramThreadId: text('telegram_thread_id'), + telegramMessageId: text('telegram_message_id'), + telegramUpdateId: text('telegram_update_id'), + senderTelegramUserId: text('sender_telegram_user_id'), + senderDisplayName: text('sender_display_name'), + isBot: integer('is_bot').default(0).notNull(), + rawText: text('raw_text').notNull(), + messageSentAt: timestamp('message_sent_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + householdThreadSentIdx: index('topic_messages_household_thread_sent_idx').on( + table.householdId, + table.telegramChatId, + table.telegramThreadId, + table.messageSentAt + ), + householdChatSentIdx: index('topic_messages_household_chat_sent_idx').on( + table.householdId, + table.telegramChatId, + table.messageSentAt + ), + householdMessageUnique: uniqueIndex('topic_messages_household_tg_message_unique').on( + table.householdId, + table.telegramChatId, + table.telegramMessageId + ), + householdUpdateUnique: uniqueIndex('topic_messages_household_tg_update_unique').on( + table.householdId, + table.telegramUpdateId + ) + }) +) + export const anonymousMessages = pgTable( 'anonymous_messages', { @@ -682,6 +724,7 @@ export type BillingCycleExchangeRate = typeof billingCycleExchangeRates.$inferSe export type UtilityBill = typeof utilityBills.$inferSelect export type PurchaseEntry = typeof purchaseEntries.$inferSelect export type PurchaseMessage = typeof purchaseMessages.$inferSelect +export type TopicMessage = typeof topicMessages.$inferSelect export type AnonymousMessage = typeof anonymousMessages.$inferSelect export type PaymentConfirmation = typeof paymentConfirmations.$inferSelect export type PaymentRecord = typeof paymentRecords.$inferSelect diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 8364a11..a7ab7b1 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -66,3 +66,9 @@ export { type TelegramPendingActionRepository, type TelegramPendingActionType } from './telegram-pending-actions' +export type { + ListRecentChatTopicMessagesInput, + ListRecentThreadTopicMessagesInput, + TopicMessageHistoryRecord, + TopicMessageHistoryRepository +} from './topic-message-history' diff --git a/packages/ports/src/topic-message-history.ts b/packages/ports/src/topic-message-history.ts new file mode 100644 index 0000000..4db563e --- /dev/null +++ b/packages/ports/src/topic-message-history.ts @@ -0,0 +1,38 @@ +import type { Instant } from '@household/domain' + +export interface TopicMessageHistoryRecord { + householdId: string + telegramChatId: string + telegramThreadId: string | null + telegramMessageId: string | null + telegramUpdateId: string | null + senderTelegramUserId: string | null + senderDisplayName: string | null + isBot: boolean + rawText: string + messageSentAt: Instant | null +} + +export interface ListRecentThreadTopicMessagesInput { + householdId: string + telegramChatId: string + telegramThreadId: string + limit: number +} + +export interface ListRecentChatTopicMessagesInput { + householdId: string + telegramChatId: string + sentAtOrAfter: Instant + limit: number +} + +export interface TopicMessageHistoryRepository { + saveMessage(input: TopicMessageHistoryRecord): Promise + listRecentThreadMessages( + input: ListRecentThreadTopicMessagesInput + ): Promise + listRecentChatMessages( + input: ListRecentChatTopicMessagesInput + ): Promise +}