From 67e9e2dee2c7e83a06d8562e034ce7c473cc8e38 Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 5 Mar 2026 04:32:58 +0400 Subject: [PATCH] feat(WHE-22): ingest configured topic messages with idempotent persistence --- .env.example | 5 + apps/bot/package.json | 2 + apps/bot/src/config.ts | 54 +- apps/bot/src/index.ts | 29 + apps/bot/src/purchase-topic-ingestion.test.ts | 53 + apps/bot/src/purchase-topic-ingestion.ts | 179 +++ bun.lock | 3 +- docs/specs/HOUSEBOT-021-topic-ingestion.md | 73 + packages/db/drizzle/0002_tough_sandman.sql | 22 + packages/db/drizzle/meta/0002_snapshot.json | 1350 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/package.json | 4 +- packages/db/src/client.ts | 23 +- packages/db/src/index.ts | 2 +- packages/db/src/schema.ts | 40 + packages/db/src/seed.ts | 12 +- 16 files changed, 1838 insertions(+), 20 deletions(-) create mode 100644 apps/bot/src/purchase-topic-ingestion.test.ts create mode 100644 apps/bot/src/purchase-topic-ingestion.ts create mode 100644 docs/specs/HOUSEBOT-021-topic-ingestion.md create mode 100644 packages/db/drizzle/0002_tough_sandman.sql create mode 100644 packages/db/drizzle/meta/0002_snapshot.json diff --git a/.env.example b/.env.example index aa09cfe..84016da 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ TELEGRAM_BOT_TOKEN=your-telegram-bot-token TELEGRAM_WEBHOOK_SECRET=your-webhook-secret TELEGRAM_BOT_USERNAME=your_bot_username TELEGRAM_WEBHOOK_PATH=/webhook/telegram +TELEGRAM_HOUSEHOLD_CHAT_ID=-1001234567890 +TELEGRAM_PURCHASE_TOPIC_ID=777 + +# Household +HOUSEHOLD_ID=11111111-1111-4111-8111-111111111111 # Parsing / AI OPENAI_API_KEY=your-openai-api-key diff --git a/apps/bot/package.json b/apps/bot/package.json index 349dfff..4befa2a 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -10,6 +10,8 @@ "lint": "oxlint \"src\"" }, "dependencies": { + "@household/db": "workspace:*", + "drizzle-orm": "^0.44.7", "grammy": "1.41.1" } } diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index c01c832..2c88bda 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -3,6 +3,11 @@ export interface BotRuntimeConfig { telegramBotToken: string telegramWebhookSecret: string telegramWebhookPath: string + databaseUrl?: string + householdId?: string + telegramHouseholdChatId?: string + telegramPurchaseTopicId?: number + purchaseTopicIngestionEnabled: boolean } function parsePort(raw: string | undefined): number { @@ -26,11 +31,56 @@ function requireValue(value: string | undefined, key: string): string { return value } +function parseOptionalTopicId(raw: string | undefined): number | undefined { + if (!raw) { + return undefined + } + + const parsed = Number(raw) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid TELEGRAM_PURCHASE_TOPIC_ID value: ${raw}`) + } + + return parsed +} + +function parseOptionalValue(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed && trimmed.length > 0 ? trimmed : undefined +} + export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig { - return { + const databaseUrl = parseOptionalValue(env.DATABASE_URL) + const householdId = parseOptionalValue(env.HOUSEHOLD_ID) + const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID) + const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID) + + const purchaseTopicIngestionEnabled = + databaseUrl !== undefined && + householdId !== undefined && + telegramHouseholdChatId !== undefined && + telegramPurchaseTopicId !== undefined + + const runtime: BotRuntimeConfig = { port: parsePort(env.PORT), telegramBotToken: requireValue(env.TELEGRAM_BOT_TOKEN, 'TELEGRAM_BOT_TOKEN'), telegramWebhookSecret: requireValue(env.TELEGRAM_WEBHOOK_SECRET, 'TELEGRAM_WEBHOOK_SECRET'), - telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram' + telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram', + purchaseTopicIngestionEnabled } + + if (databaseUrl !== undefined) { + runtime.databaseUrl = databaseUrl + } + if (householdId !== undefined) { + runtime.householdId = householdId + } + if (telegramHouseholdChatId !== undefined) { + runtime.telegramHouseholdChatId = telegramHouseholdChatId + } + if (telegramPurchaseTopicId !== undefined) { + runtime.telegramPurchaseTopicId = telegramPurchaseTopicId + } + + return runtime } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 1fa34d8..39a8a33 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -2,12 +2,37 @@ import { webhookCallback } from 'grammy' import { createTelegramBot } from './bot' import { getBotRuntimeConfig } from './config' +import { + createPurchaseMessageRepository, + registerPurchaseTopicIngestion +} from './purchase-topic-ingestion' import { createBotWebhookServer } from './server' const runtime = getBotRuntimeConfig() const bot = createTelegramBot(runtime.telegramBotToken) const webhookHandler = webhookCallback(bot, 'std/http') +let closePurchaseRepository: (() => Promise) | undefined + +if (runtime.purchaseTopicIngestionEnabled) { + const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) + closePurchaseRepository = purchaseRepositoryClient.close + + registerPurchaseTopicIngestion( + bot, + { + householdId: runtime.householdId!, + householdChatId: runtime.telegramHouseholdChatId!, + purchaseTopicId: runtime.telegramPurchaseTopicId! + }, + purchaseRepositoryClient.repository + ) +} else { + console.warn( + 'Purchase topic ingestion is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_PURCHASE_TOPIC_ID to enable.' + ) +} + const server = createBotWebhookServer({ webhookPath: runtime.telegramWebhookPath, webhookSecret: runtime.telegramWebhookSecret, @@ -23,6 +48,10 @@ if (import.meta.main) { console.log( `@household/bot webhook server started on :${runtime.port} path=${runtime.telegramWebhookPath}` ) + + process.on('SIGTERM', () => { + void closePurchaseRepository?.() + }) } export { server } diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts new file mode 100644 index 0000000..9a941eb --- /dev/null +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' + +import { + extractPurchaseTopicCandidate, + type PurchaseTopicCandidate +} from './purchase-topic-ingestion' + +const config = { + householdId: '11111111-1111-4111-8111-111111111111', + householdChatId: '-10012345', + purchaseTopicId: 777 +} + +function candidate(overrides: Partial = {}): PurchaseTopicCandidate { + return { + updateId: 1, + chatId: '-10012345', + messageId: '10', + threadId: '777', + senderTelegramUserId: '10002', + rawText: 'Bought toilet paper 30 gel', + messageSentAt: new Date('2026-03-05T00:00:00.000Z'), + ...overrides + } +} + +describe('extractPurchaseTopicCandidate', () => { + test('returns record when message belongs to configured topic', () => { + const record = extractPurchaseTopicCandidate(candidate(), config) + + expect(record).not.toBeNull() + expect(record?.householdId).toBe(config.householdId) + expect(record?.rawText).toBe('Bought toilet paper 30 gel') + }) + + test('skips message from other chat', () => { + const record = extractPurchaseTopicCandidate(candidate({ chatId: '-10099999' }), config) + + expect(record).toBeNull() + }) + + test('skips message from other topic', () => { + const record = extractPurchaseTopicCandidate(candidate({ threadId: '778' }), config) + + expect(record).toBeNull() + }) + + test('skips blank text after trim', () => { + const record = extractPurchaseTopicCandidate(candidate({ rawText: ' ' }), config) + + expect(record).toBeNull() + }) +}) diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts new file mode 100644 index 0000000..7f0beb4 --- /dev/null +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -0,0 +1,179 @@ +import { and, eq } from 'drizzle-orm' +import type { Bot, Context } from 'grammy' + +import { createDbClient, schema } from '@household/db' + +export interface PurchaseTopicIngestionConfig { + householdId: string + householdChatId: string + purchaseTopicId: number +} + +export interface PurchaseTopicCandidate { + updateId: number + chatId: string + messageId: string + threadId: string + senderTelegramUserId: string + senderDisplayName?: string + rawText: string + messageSentAt: Date +} + +export interface PurchaseTopicRecord extends PurchaseTopicCandidate { + householdId: string +} + +export interface PurchaseMessageIngestionRepository { + save(record: PurchaseTopicRecord): Promise<'created' | 'duplicate'> +} + +export function extractPurchaseTopicCandidate( + value: PurchaseTopicCandidate, + config: PurchaseTopicIngestionConfig +): PurchaseTopicRecord | null { + if (value.chatId !== config.householdChatId) { + return null + } + + if (value.threadId !== String(config.purchaseTopicId)) { + return null + } + + const normalizedText = value.rawText.trim() + if (normalizedText.length === 0) { + return null + } + + return { + ...value, + rawText: normalizedText, + householdId: config.householdId + } +} + +export function createPurchaseMessageRepository(databaseUrl: string): { + repository: PurchaseMessageIngestionRepository + close: () => Promise +} { + const { db, queryClient } = createDbClient(databaseUrl, { + max: 5, + prepare: false + }) + + const repository: PurchaseMessageIngestionRepository = { + async save(record) { + const matchedMember = await db + .select({ id: schema.members.id }) + .from(schema.members) + .where( + and( + eq(schema.members.householdId, record.householdId), + eq(schema.members.telegramUserId, record.senderTelegramUserId) + ) + ) + .limit(1) + + const senderMemberId = matchedMember[0]?.id ?? null + + const inserted = await db + .insert(schema.purchaseMessages) + .values({ + householdId: record.householdId, + senderMemberId, + senderTelegramUserId: record.senderTelegramUserId, + senderDisplayName: record.senderDisplayName, + rawText: record.rawText, + telegramChatId: record.chatId, + telegramMessageId: record.messageId, + telegramThreadId: record.threadId, + telegramUpdateId: String(record.updateId), + messageSentAt: record.messageSentAt, + processingStatus: 'pending' + }) + .onConflictDoNothing({ + target: [ + schema.purchaseMessages.householdId, + schema.purchaseMessages.telegramChatId, + schema.purchaseMessages.telegramMessageId + ] + }) + .returning({ id: schema.purchaseMessages.id }) + + return inserted.length > 0 ? 'created' : 'duplicate' + } + } + + return { + repository, + close: async () => { + await queryClient.end({ timeout: 5 }) + } + } +} + +function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null { + const message = ctx.message + if (!message || !('text' in message)) { + return null + } + + if (!message.is_topic_message || message.message_thread_id === undefined) { + return null + } + + const senderTelegramUserId = ctx.from?.id?.toString() + if (!senderTelegramUserId) { + return null + } + + const senderDisplayName = [ctx.from?.first_name, ctx.from?.last_name] + .filter((part) => !!part && part.trim().length > 0) + .join(' ') + + const candidate: PurchaseTopicCandidate = { + updateId: ctx.update.update_id, + chatId: message.chat.id.toString(), + messageId: message.message_id.toString(), + threadId: message.message_thread_id.toString(), + senderTelegramUserId, + rawText: message.text, + messageSentAt: new Date(message.date * 1000) + } + + if (senderDisplayName.length > 0) { + candidate.senderDisplayName = senderDisplayName + } + + return candidate +} + +export function registerPurchaseTopicIngestion( + bot: Bot, + config: PurchaseTopicIngestionConfig, + repository: PurchaseMessageIngestionRepository +): void { + bot.on('message:text', async (ctx) => { + const candidate = toCandidateFromContext(ctx) + if (!candidate) { + return + } + + const record = extractPurchaseTopicCandidate(candidate, config) + if (!record) { + return + } + + try { + const status = await repository.save(record) + + if (status === 'created') { + console.log( + `purchase topic message ingested chat=${record.chatId} thread=${record.threadId} message=${record.messageId}` + ) + } + } catch (error) { + console.error('Failed to ingest purchase topic message', error) + } + }) +} diff --git a/bun.lock b/bun.lock index 64313d9..098f645 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,8 @@ "apps/bot": { "name": "@household/bot", "dependencies": { + "@household/db": "workspace:*", + "drizzle-orm": "^0.44.7", "grammy": "1.41.1", }, }, @@ -51,7 +53,6 @@ "packages/db": { "name": "@household/db", "dependencies": { - "@household/config": "workspace:*", "drizzle-orm": "^0.44.5", "postgres": "^3.4.7", }, diff --git a/docs/specs/HOUSEBOT-021-topic-ingestion.md b/docs/specs/HOUSEBOT-021-topic-ingestion.md new file mode 100644 index 0000000..0aa0141 --- /dev/null +++ b/docs/specs/HOUSEBOT-021-topic-ingestion.md @@ -0,0 +1,73 @@ +# HOUSEBOT-021: Purchase Topic Ingestion + +## Summary + +Ingest messages from configured Telegram household purchase topic (`Общие покупки`) and persist raw message metadata idempotently. + +## Goals + +- Process only configured chat/topic. +- Persist sender + raw message + Telegram metadata. +- Make ingestion idempotent for duplicate Telegram deliveries. + +## Non-goals + +- Purchase amount parsing. +- Settlement impact calculations. + +## Scope + +- In: bot middleware for topic filtering, persistence repository, DB schema for raw inbox records. +- Out: parser pipeline and command responses. + +## Interfaces and Contracts + +- Telegram webhook receives update. +- Bot middleware extracts candidate from `message:text` updates. +- DB write target: `purchase_messages`. + +## Domain Rules + +- Only configured `TELEGRAM_HOUSEHOLD_CHAT_ID` + `TELEGRAM_PURCHASE_TOPIC_ID` are accepted. +- Empty/blank messages are ignored. +- Duplicate message IDs are ignored via unique constraints. + +## Data Model Changes + +- Add `purchase_messages` with: + - sender metadata + - raw text + - Telegram IDs (chat/message/thread/update) + - processing status (`pending` default) + +## Security and Privacy + +- No PII beyond Telegram sender identifiers needed for household accounting. +- Webhook auth remains enforced by secret token header. + +## Observability + +- Log successful ingestion with chat/thread/message IDs. +- Log ingestion failures without crashing bot process. + +## Edge Cases and Failure Modes + +- Missing ingestion env config -> ingestion disabled. +- Unknown sender member -> stored with null member mapping. +- Duplicate webhook delivery -> ignored as duplicate. + +## Test Plan + +- Unit tests for topic filter extraction logic. +- Existing endpoint tests continue to pass. + +## Acceptance Criteria + +- [ ] Only configured topic messages are persisted. +- [ ] Sender + message metadata stored in DB. +- [ ] Duplicate deliveries are idempotent. + +## Rollout Plan + +- Deploy with ingestion enabled in dev group first. +- Validate rows in `purchase_messages` before enabling parser flow. diff --git a/packages/db/drizzle/0002_tough_sandman.sql b/packages/db/drizzle/0002_tough_sandman.sql new file mode 100644 index 0000000..8278319 --- /dev/null +++ b/packages/db/drizzle/0002_tough_sandman.sql @@ -0,0 +1,22 @@ +CREATE TABLE "purchase_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "sender_member_id" uuid, + "sender_telegram_user_id" text NOT NULL, + "sender_display_name" text, + "raw_text" text NOT NULL, + "telegram_chat_id" text NOT NULL, + "telegram_message_id" text NOT NULL, + "telegram_thread_id" text NOT NULL, + "telegram_update_id" text NOT NULL, + "message_sent_at" timestamp with time zone, + "processing_status" text DEFAULT 'pending' NOT NULL, + "ingested_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD CONSTRAINT "purchase_messages_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "purchase_messages" ADD CONSTRAINT "purchase_messages_sender_member_id_members_id_fk" FOREIGN KEY ("sender_member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "purchase_messages_household_thread_idx" ON "purchase_messages" USING btree ("household_id","telegram_thread_id");--> statement-breakpoint +CREATE INDEX "purchase_messages_sender_idx" ON "purchase_messages" USING btree ("sender_telegram_user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "purchase_messages_household_tg_message_unique" ON "purchase_messages" USING btree ("household_id","telegram_chat_id","telegram_message_id");--> statement-breakpoint +CREATE UNIQUE INDEX "purchase_messages_household_tg_update_unique" ON "purchase_messages" USING btree ("household_id","telegram_update_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0002_snapshot.json b/packages/db/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..8ec0c82 --- /dev/null +++ b/packages/db/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1350 @@ +{ + "id": "5ddb4be4-d1fb-4cef-b010-62371bbd13c9", + "prevId": "fbec9197-8029-45f9-8f7d-36416d39d0b8", + "version": "7", + "dialect": "postgresql", + "tables": { + "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.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_household_idx": { + "name": "members_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_household_tg_user_unique": { + "name": "members_household_tg_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_household_id_households_id_fk": { + "name": "members_household_id_households_id_fk", + "tableFrom": "members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_overrides": { + "name": "presence_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "utility_days": { + "name": "utility_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "presence_overrides_cycle_member_unique": { + "name": "presence_overrides_cycle_member_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presence_overrides_cycle_idx": { + "name": "presence_overrides_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "presence_overrides_cycle_id_billing_cycles_id_fk": { + "name": "presence_overrides_cycle_id_billing_cycles_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "presence_overrides_member_id_members_id_fk": { + "name": "presence_overrides_member_id_members_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_bot_messages": { + "name": "processed_bot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_key": { + "name": "source_message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "processed_bot_messages_source_message_unique": { + "name": "processed_bot_messages_source_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "processed_bot_messages_household_id_households_id_fk": { + "name": "processed_bot_messages_household_id_households_id_fk", + "tableFrom": "processed_bot_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_entries": { + "name": "purchase_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer_member_id": { + "name": "payer_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_entries_household_cycle_idx": { + "name": "purchase_entries_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_payer_idx": { + "name": "purchase_entries_payer_idx", + "columns": [ + { + "expression": "payer_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_household_tg_message_unique": { + "name": "purchase_entries_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_entries_household_id_households_id_fk": { + "name": "purchase_entries_household_id_households_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_entries_cycle_id_billing_cycles_id_fk": { + "name": "purchase_entries_cycle_id_billing_cycles_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "purchase_entries_payer_member_id_members_id_fk": { + "name": "purchase_entries_payer_member_id_members_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "members", + "columnsFrom": ["payer_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_messages": { + "name": "purchase_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_messages_household_thread_idx": { + "name": "purchase_messages_household_thread_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_sender_idx": { + "name": "purchase_messages_sender_idx", + "columns": [ + { + "expression": "sender_telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_message_unique": { + "name": "purchase_messages_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_update_unique": { + "name": "purchase_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_messages_household_id_households_id_fk": { + "name": "purchase_messages_household_id_households_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_messages_sender_member_id_members_id_fk": { + "name": "purchase_messages_sender_member_id_members_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "members", + "columnsFrom": ["sender_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rent_rules": { + "name": "rent_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_to_period": { + "name": "effective_to_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rent_rules_household_from_period_unique": { + "name": "rent_rules_household_from_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rent_rules_household_from_period_idx": { + "name": "rent_rules_household_from_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rent_rules_household_id_households_id_fk": { + "name": "rent_rules_household_id_households_id_fk", + "tableFrom": "rent_rules", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlement_lines": { + "name": "settlement_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_share_minor": { + "name": "rent_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "utility_share_minor": { + "name": "utility_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "purchase_offset_minor": { + "name": "purchase_offset_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "net_due_minor": { + "name": "net_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "explanations": { + "name": "explanations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlement_lines_settlement_member_unique": { + "name": "settlement_lines_settlement_member_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlement_lines_settlement_idx": { + "name": "settlement_lines_settlement_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlement_lines_settlement_id_settlements_id_fk": { + "name": "settlement_lines_settlement_id_settlements_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "settlements", + "columnsFrom": ["settlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlement_lines_member_id_members_id_fk": { + "name": "settlement_lines_member_id_members_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_due_minor": { + "name": "total_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "settlements_cycle_unique": { + "name": "settlements_cycle_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_household_computed_idx": { + "name": "settlements_household_computed_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "computed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_household_id_households_id_fk": { + "name": "settlements_household_id_households_id_fk", + "tableFrom": "settlements", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlements_cycle_id_billing_cycles_id_fk": { + "name": "settlements_cycle_id_billing_cycles_id_fk", + "tableFrom": "settlements", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.utility_bills": { + "name": "utility_bills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bill_name": { + "name": "bill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by_member_id": { + "name": "created_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "utility_bills_cycle_idx": { + "name": "utility_bills_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "utility_bills_household_cycle_idx": { + "name": "utility_bills_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "utility_bills_household_id_households_id_fk": { + "name": "utility_bills_household_id_households_id_fk", + "tableFrom": "utility_bills", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_cycle_id_billing_cycles_id_fk": { + "name": "utility_bills_cycle_id_billing_cycles_id_fk", + "tableFrom": "utility_bills", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_created_by_member_id_members_id_fk": { + "name": "utility_bills_created_by_member_id_members_id_fk", + "tableFrom": "utility_bills", + "tableTo": "members", + "columnsFrom": ["created_by_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 82ea0a1..babccc5 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1772669239939, "tag": "0001_spicy_sersi", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1772670548136, + "tag": "0002_tough_sandman", + "breakpoints": true } ] } diff --git a/packages/db/package.json b/packages/db/package.json index a8a6ed8..a11a254 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -2,6 +2,9 @@ "name": "@household/db", "private": true, "type": "module", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "build": "bun build src/index.ts --outdir dist --target bun", "typecheck": "tsgo --project tsconfig.json --noEmit", @@ -10,7 +13,6 @@ "seed": "bun run src/seed.ts" }, "dependencies": { - "@household/config": "workspace:*", "drizzle-orm": "^0.44.5", "postgres": "^3.4.7" } diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 189640b..224173e 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,12 +1,21 @@ import postgres from 'postgres' import { drizzle } from 'drizzle-orm/postgres-js' -import { env } from '@household/config' +export interface DbClientOptions { + max?: number + prepare?: boolean +} -const queryClient = postgres(env.DATABASE_URL, { - prepare: false, - max: 5 -}) +export function createDbClient(databaseUrl: string, options: DbClientOptions = {}) { + const queryClient = postgres(databaseUrl, { + max: options.max ?? 5, + prepare: options.prepare ?? false + }) -export const db = drizzle(queryClient) -export { queryClient } + const db = drizzle(queryClient) + + return { + db, + queryClient + } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index a344421..825229d 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,2 +1,2 @@ -export { db, queryClient } from './client' +export { createDbClient } from './client' export * as schema from './schema' diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index bfd028a..b67e618 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -180,6 +180,45 @@ export const purchaseEntries = pgTable( }) ) +export const purchaseMessages = pgTable( + 'purchase_messages', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + senderMemberId: uuid('sender_member_id').references(() => members.id, { + onDelete: 'set null' + }), + senderTelegramUserId: text('sender_telegram_user_id').notNull(), + senderDisplayName: text('sender_display_name'), + rawText: text('raw_text').notNull(), + telegramChatId: text('telegram_chat_id').notNull(), + telegramMessageId: text('telegram_message_id').notNull(), + telegramThreadId: text('telegram_thread_id').notNull(), + telegramUpdateId: text('telegram_update_id').notNull(), + messageSentAt: timestamp('message_sent_at', { withTimezone: true }), + processingStatus: text('processing_status').default('pending').notNull(), + ingestedAt: timestamp('ingested_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + householdThreadIdx: index('purchase_messages_household_thread_idx').on( + table.householdId, + table.telegramThreadId + ), + senderIdx: index('purchase_messages_sender_idx').on(table.senderTelegramUserId), + tgMessageUnique: uniqueIndex('purchase_messages_household_tg_message_unique').on( + table.householdId, + table.telegramChatId, + table.telegramMessageId + ), + tgUpdateUnique: uniqueIndex('purchase_messages_household_tg_update_unique').on( + table.householdId, + table.telegramUpdateId + ) + }) +) + export const processedBotMessages = pgTable( 'processed_bot_messages', { @@ -261,4 +300,5 @@ export type Member = typeof members.$inferSelect export type BillingCycle = typeof billingCycles.$inferSelect export type UtilityBill = typeof utilityBills.$inferSelect export type PurchaseEntry = typeof purchaseEntries.$inferSelect +export type PurchaseMessage = typeof purchaseMessages.$inferSelect export type Settlement = typeof settlements.$inferSelect diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index dfdde04..067d517 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -1,7 +1,5 @@ import { and, eq } from 'drizzle-orm' -import { drizzle } from 'drizzle-orm/postgres-js' -import postgres from 'postgres' - +import { createDbClient } from './client' import { billingCycles, households, @@ -20,13 +18,11 @@ if (!databaseUrl) { throw new Error('DATABASE_URL is required for db seed') } -const queryClient = postgres(databaseUrl, { - prepare: false, - max: 2 +const { db, queryClient } = createDbClient(databaseUrl, { + max: 2, + prepare: false }) -const db = drizzle(queryClient) - const FIXTURE_IDS = { household: '11111111-1111-4111-8111-111111111111', cycle: '22222222-2222-4222-8222-222222222222',