From f6d1f34acfea971161879c475d0c25551cfe526e Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 22:14:09 +0400 Subject: [PATCH 01/10] feat(architecture): add finance repository adapters --- apps/bot/package.json | 2 +- apps/bot/src/finance-commands.ts | 392 +----------------- apps/bot/src/index.ts | 16 +- bun.lock | 18 +- .../specs/HOUSEBOT-024-repository-adapters.md | 76 ++++ packages/adapters-db/package.json | 20 + .../adapters-db/src/finance-repository.ts | 329 +++++++++++++++ packages/adapters-db/src/index.ts | 1 + packages/adapters-db/tsconfig.json | 7 + packages/application/package.json | 3 +- .../src/finance-command-service.test.ts | 192 +++++++++ .../src/finance-command-service.ts | 233 +++++++++++ packages/application/src/index.ts | 1 + packages/ports/package.json | 6 + packages/ports/src/finance.ts | 68 +++ packages/ports/src/index.ts | 10 +- tsconfig.json | 3 +- 17 files changed, 994 insertions(+), 383 deletions(-) create mode 100644 docs/specs/HOUSEBOT-024-repository-adapters.md create mode 100644 packages/adapters-db/package.json create mode 100644 packages/adapters-db/src/finance-repository.ts create mode 100644 packages/adapters-db/src/index.ts create mode 100644 packages/adapters-db/tsconfig.json create mode 100644 packages/application/src/finance-command-service.test.ts create mode 100644 packages/application/src/finance-command-service.ts create mode 100644 packages/ports/src/finance.ts diff --git a/apps/bot/package.json b/apps/bot/package.json index dbf7d47..6ef3f2d 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -10,9 +10,9 @@ "lint": "oxlint \"src\"" }, "dependencies": { + "@household/adapters-db": "workspace:*", "@household/application": "workspace:*", "@household/db": "workspace:*", - "@household/domain": "workspace:*", "drizzle-orm": "^0.44.7", "grammy": "1.41.1" } diff --git a/apps/bot/src/finance-commands.ts b/apps/bot/src/finance-commands.ts index 7c7b4ae..7731bd7 100644 --- a/apps/bot/src/finance-commands.ts +++ b/apps/bot/src/finance-commands.ts @@ -1,53 +1,6 @@ -import { calculateMonthlySettlement } from '@household/application' -import { createDbClient, schema } from '@household/db' -import { BillingCycleId, BillingPeriod, MemberId, Money, PurchaseEntryId } from '@household/domain' -import { and, desc, eq, gte, isNotNull, isNull, lte, or, sql } from 'drizzle-orm' +import type { FinanceCommandService } from '@household/application' import type { Bot, Context } from 'grammy' -import { createHash } from 'node:crypto' - -type SupportedCurrency = 'USD' | 'GEL' - -interface FinanceCommandsConfig { - householdId: string -} - -interface SettlementCycleData { - id: string - period: string - currency: string -} - -interface HouseholdMemberData { - id: string - telegramUserId: string - displayName: string - isAdmin: number -} - -function parseCurrency(raw: string | undefined, fallback: SupportedCurrency): SupportedCurrency { - if (!raw || raw.trim().length === 0) { - return fallback - } - - const normalized = raw.trim().toUpperCase() - if (normalized !== 'USD' && normalized !== 'GEL') { - throw new Error(`Unsupported currency: ${raw}`) - } - - return normalized -} - -function monthRange(period: BillingPeriod): { start: Date; end: Date } { - const start = new Date(Date.UTC(period.year, period.month - 1, 1, 0, 0, 0)) - const end = new Date(Date.UTC(period.year, period.month, 0, 23, 59, 59)) - - return { - start, - end - } -} - function commandArgs(ctx: Context): string[] { const raw = typeof ctx.match === 'string' ? ctx.match.trim() : '' if (raw.length === 0) { @@ -57,52 +10,17 @@ function commandArgs(ctx: Context): string[] { return raw.split(/\s+/).filter(Boolean) } -function computeInputHash(payload: object): string { - return createHash('sha256').update(JSON.stringify(payload)).digest('hex') -} - -export function createFinanceCommandsService( - databaseUrl: string, - config: FinanceCommandsConfig -): { +export function createFinanceCommandsService(financeService: FinanceCommandService): { register: (bot: Bot) => void - close: () => Promise } { - const { db, queryClient } = createDbClient(databaseUrl, { - max: 5, - prepare: false - }) - - async function getMemberByTelegramUserId( - telegramUserId: string - ): Promise { - const row = await db - .select({ - id: schema.members.id, - telegramUserId: schema.members.telegramUserId, - displayName: schema.members.displayName, - isAdmin: schema.members.isAdmin - }) - .from(schema.members) - .where( - and( - eq(schema.members.householdId, config.householdId), - eq(schema.members.telegramUserId, telegramUserId) - ) - ) - .limit(1) - - return row[0] ?? null - } - - async function requireMember(ctx: Context): Promise { + async function requireMember(ctx: Context) { const telegramUserId = ctx.from?.id?.toString() if (!telegramUserId) { await ctx.reply('Unable to identify sender for this command.') return null } - const member = await getMemberByTelegramUserId(telegramUserId) + const member = await financeService.getMemberByTelegramUserId(telegramUserId) if (!member) { await ctx.reply('You are not a member of this household.') return null @@ -111,13 +29,13 @@ export function createFinanceCommandsService( return member } - async function requireAdmin(ctx: Context): Promise { + async function requireAdmin(ctx: Context) { const member = await requireMember(ctx) if (!member) { return null } - if (member.isAdmin !== 1) { + if (!member.isAdmin) { await ctx.reply('Only household admins can use this command.') return null } @@ -125,217 +43,6 @@ export function createFinanceCommandsService( return member } - async function getOpenCycle(): Promise { - const cycle = await db - .select({ - id: schema.billingCycles.id, - period: schema.billingCycles.period, - currency: schema.billingCycles.currency - }) - .from(schema.billingCycles) - .where( - and( - eq(schema.billingCycles.householdId, config.householdId), - isNull(schema.billingCycles.closedAt) - ) - ) - .orderBy(desc(schema.billingCycles.startedAt)) - .limit(1) - - return cycle[0] ?? null - } - - async function getCycleByPeriodOrLatest(periodArg?: string): Promise { - if (periodArg) { - const period = BillingPeriod.fromString(periodArg).toString() - const cycle = await db - .select({ - id: schema.billingCycles.id, - period: schema.billingCycles.period, - currency: schema.billingCycles.currency - }) - .from(schema.billingCycles) - .where( - and( - eq(schema.billingCycles.householdId, config.householdId), - eq(schema.billingCycles.period, period) - ) - ) - .limit(1) - - return cycle[0] ?? null - } - - const latestCycle = await db - .select({ - id: schema.billingCycles.id, - period: schema.billingCycles.period, - currency: schema.billingCycles.currency - }) - .from(schema.billingCycles) - .where(eq(schema.billingCycles.householdId, config.householdId)) - .orderBy(desc(schema.billingCycles.period)) - .limit(1) - - return latestCycle[0] ?? null - } - - async function upsertSettlementSnapshot(cycle: SettlementCycleData): Promise { - const members = await db - .select({ - id: schema.members.id, - displayName: schema.members.displayName - }) - .from(schema.members) - .where(eq(schema.members.householdId, config.householdId)) - .orderBy(schema.members.displayName) - - if (members.length === 0) { - throw new Error('No household members configured') - } - - const rentRule = await db - .select({ - amountMinor: schema.rentRules.amountMinor, - currency: schema.rentRules.currency - }) - .from(schema.rentRules) - .where( - and( - eq(schema.rentRules.householdId, config.householdId), - lte(schema.rentRules.effectiveFromPeriod, cycle.period), - or( - isNull(schema.rentRules.effectiveToPeriod), - gte(schema.rentRules.effectiveToPeriod, cycle.period) - ) - ) - ) - .orderBy(desc(schema.rentRules.effectiveFromPeriod)) - .limit(1) - - if (!rentRule[0]) { - throw new Error('No rent rule configured for this cycle period') - } - - const utilityTotalRow = await db - .select({ - totalMinor: sql`coalesce(sum(${schema.utilityBills.amountMinor}), 0)` - }) - .from(schema.utilityBills) - .where(eq(schema.utilityBills.cycleId, cycle.id)) - - const period = BillingPeriod.fromString(cycle.period) - const range = monthRange(period) - - const purchases = await db - .select({ - id: schema.purchaseMessages.id, - senderMemberId: schema.purchaseMessages.senderMemberId, - parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor - }) - .from(schema.purchaseMessages) - .where( - and( - eq(schema.purchaseMessages.householdId, config.householdId), - isNotNull(schema.purchaseMessages.senderMemberId), - isNotNull(schema.purchaseMessages.parsedAmountMinor), - gte(schema.purchaseMessages.messageSentAt, range.start), - lte(schema.purchaseMessages.messageSentAt, range.end) - ) - ) - - const currency = parseCurrency(rentRule[0].currency, 'USD') - const utilitiesMinor = BigInt(utilityTotalRow[0]?.totalMinor ?? '0') - - const settlementInput = { - cycleId: BillingCycleId.from(cycle.id), - period, - rent: Money.fromMinor(rentRule[0].amountMinor, currency), - utilities: Money.fromMinor(utilitiesMinor, currency), - utilitySplitMode: 'equal' as const, - members: members.map((member) => ({ - memberId: MemberId.from(member.id), - active: true - })), - purchases: purchases.map((purchase) => ({ - purchaseId: PurchaseEntryId.from(purchase.id), - payerId: MemberId.from(purchase.senderMemberId!), - amount: Money.fromMinor(purchase.parsedAmountMinor!, currency) - })) - } - - const settlement = calculateMonthlySettlement(settlementInput) - const inputHash = computeInputHash({ - cycleId: cycle.id, - rentMinor: rentRule[0].amountMinor.toString(), - utilitiesMinor: utilitiesMinor.toString(), - purchaseCount: purchases.length, - memberCount: members.length - }) - - const upserted = await db - .insert(schema.settlements) - .values({ - householdId: config.householdId, - cycleId: cycle.id, - inputHash, - totalDueMinor: settlement.totalDue.amountMinor, - currency, - metadata: { - generatedBy: 'bot-command', - source: 'statement' - } - }) - .onConflictDoUpdate({ - target: [schema.settlements.cycleId], - set: { - inputHash, - totalDueMinor: settlement.totalDue.amountMinor, - currency, - computedAt: new Date(), - metadata: { - generatedBy: 'bot-command', - source: 'statement' - } - } - }) - .returning({ id: schema.settlements.id }) - - const settlementId = upserted[0]?.id - if (!settlementId) { - throw new Error('Failed to persist settlement snapshot') - } - - await db - .delete(schema.settlementLines) - .where(eq(schema.settlementLines.settlementId, settlementId)) - - const memberNameById = new Map(members.map((member) => [member.id, member.displayName])) - - await db.insert(schema.settlementLines).values( - settlement.lines.map((line) => ({ - settlementId, - memberId: line.memberId.toString(), - rentShareMinor: line.rentShare.amountMinor, - utilityShareMinor: line.utilityShare.amountMinor, - purchaseOffsetMinor: line.purchaseOffset.amountMinor, - netDueMinor: line.netDue.amountMinor, - explanations: line.explanations - })) - ) - - const statementLines = settlement.lines.map((line) => { - const name = memberNameById.get(line.memberId.toString()) ?? line.memberId.toString() - return `- ${name}: ${line.netDue.toMajorString()} ${currency}` - }) - - return [ - `Statement for ${cycle.period}`, - ...statementLines, - `Total: ${settlement.totalDue.toMajorString()} ${currency}` - ].join('\n') - } - function register(bot: Bot): void { bot.command('cycle_open', async (ctx) => { const admin = await requireAdmin(ctx) @@ -350,21 +57,8 @@ export function createFinanceCommandsService( } try { - const period = BillingPeriod.fromString(args[0]!).toString() - const currency = parseCurrency(args[1], 'USD') - - await db - .insert(schema.billingCycles) - .values({ - householdId: config.householdId, - period, - currency - }) - .onConflictDoNothing({ - target: [schema.billingCycles.householdId, schema.billingCycles.period] - }) - - await ctx.reply(`Cycle opened: ${period} (${currency})`) + const cycle = await financeService.openCycle(args[0]!, args[1]) + await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`) } catch (error) { await ctx.reply(`Failed to open cycle: ${(error as Error).message}`) } @@ -376,21 +70,13 @@ export function createFinanceCommandsService( return } - const args = commandArgs(ctx) try { - const cycle = await getCycleByPeriodOrLatest(args[0]) + const cycle = await financeService.closeCycle(commandArgs(ctx)[0]) if (!cycle) { await ctx.reply('No cycle found to close.') return } - await db - .update(schema.billingCycles) - .set({ - closedAt: new Date() - }) - .where(eq(schema.billingCycles.id, cycle.id)) - await ctx.reply(`Cycle closed: ${cycle.period}`) } catch (error) { await ctx.reply(`Failed to close cycle: ${(error as Error).message}`) @@ -410,34 +96,14 @@ export function createFinanceCommandsService( } try { - const openCycle = await getOpenCycle() - const period = args[2] ?? openCycle?.period - if (!period) { + const result = await financeService.setRent(args[0]!, args[1], args[2]) + if (!result) { await ctx.reply('No period provided and no open cycle found.') return } - const currency = parseCurrency(args[1], (openCycle?.currency as SupportedCurrency) ?? 'USD') - const amount = Money.fromMajor(args[0]!, currency) - - await db - .insert(schema.rentRules) - .values({ - householdId: config.householdId, - amountMinor: amount.amountMinor, - currency, - effectiveFromPeriod: BillingPeriod.fromString(period).toString() - }) - .onConflictDoUpdate({ - target: [schema.rentRules.householdId, schema.rentRules.effectiveFromPeriod], - set: { - amountMinor: amount.amountMinor, - currency - } - }) - await ctx.reply( - `Rent rule saved: ${amount.toMajorString()} ${currency} starting ${BillingPeriod.fromString(period).toString()}` + `Rent rule saved: ${result.amount.toMajorString()} ${result.currency} starting ${result.period}` ) } catch (error) { await ctx.reply(`Failed to save rent rule: ${(error as Error).message}`) @@ -457,29 +123,14 @@ export function createFinanceCommandsService( } try { - const openCycle = await getOpenCycle() - if (!openCycle) { + const result = await financeService.addUtilityBill(args[0]!, args[1]!, admin.id, args[2]) + if (!result) { await ctx.reply('No open cycle found. Use /cycle_open first.') return } - const name = args[0]! - const amountRaw = args[1]! - const currency = parseCurrency(args[2], parseCurrency(openCycle.currency, 'USD')) - const amount = Money.fromMajor(amountRaw, currency) - - await db.insert(schema.utilityBills).values({ - householdId: config.householdId, - cycleId: openCycle.id, - billName: name, - amountMinor: amount.amountMinor, - currency, - source: 'manual', - createdByMemberId: admin.id - }) - await ctx.reply( - `Utility bill added: ${name} ${amount.toMajorString()} ${currency} for ${openCycle.period}` + `Utility bill added: ${args[0]} ${result.amount.toMajorString()} ${result.currency} for ${result.period}` ) } catch (error) { await ctx.reply(`Failed to add utility bill: ${(error as Error).message}`) @@ -492,16 +143,14 @@ export function createFinanceCommandsService( return } - const args = commandArgs(ctx) try { - const cycle = await getCycleByPeriodOrLatest(args[0]) - if (!cycle) { + const statement = await financeService.generateStatement(commandArgs(ctx)[0]) + if (!statement) { await ctx.reply('No cycle found for statement.') return } - const message = await upsertSettlementSnapshot(cycle) - await ctx.reply(message) + await ctx.reply(statement) } catch (error) { await ctx.reply(`Failed to generate statement: ${(error as Error).message}`) } @@ -509,9 +158,6 @@ export function createFinanceCommandsService( } return { - register, - close: async () => { - await queryClient.end({ timeout: 5 }) - } + register } } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 9d8d642..044510f 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,8 +1,11 @@ import { webhookCallback } from 'grammy' +import { createFinanceCommandService } from '@household/application' +import { createDbFinanceRepository } from '@household/adapters-db' + +import { createFinanceCommandsService } from './finance-commands' import { createTelegramBot } from './bot' import { getBotRuntimeConfig } from './config' -import { createFinanceCommandsService } from './finance-commands' import { createOpenAiParserFallback } from './openai-parser-fallback' import { createPurchaseMessageRepository, @@ -42,12 +45,15 @@ if (runtime.purchaseTopicIngestionEnabled) { } if (runtime.financeCommandsEnabled) { - const financeCommands = createFinanceCommandsService(runtime.databaseUrl!, { - householdId: runtime.householdId! - }) + const financeRepositoryClient = createDbFinanceRepository( + runtime.databaseUrl!, + runtime.householdId! + ) + const financeService = createFinanceCommandService(financeRepositoryClient.repository) + const financeCommands = createFinanceCommandsService(financeService) financeCommands.register(bot) - shutdownTasks.push(financeCommands.close) + shutdownTasks.push(financeRepositoryClient.close) } else { console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.') } diff --git a/bun.lock b/bun.lock index d39779d..34fe20f 100644 --- a/bun.lock +++ b/bun.lock @@ -15,9 +15,10 @@ "apps/bot": { "name": "@household/bot", "dependencies": { + "@household/adapters-db": "workspace:*", "@household/application": "workspace:*", "@household/db": "workspace:*", - "@household/domain": "workspace:*", + "@household/ports": "workspace:*", "drizzle-orm": "^0.44.7", "grammy": "1.41.1", }, @@ -36,10 +37,20 @@ "vite-plugin-solid": "^2.11.8", }, }, + "packages/adapters-db": { + "name": "@household/adapters-db", + "dependencies": { + "@household/db": "workspace:*", + "@household/domain": "workspace:*", + "@household/ports": "workspace:*", + "drizzle-orm": "^0.44.7", + }, + }, "packages/application": { "name": "@household/application", "dependencies": { "@household/domain": "workspace:*", + "@household/ports": "workspace:*", }, }, "packages/config": { @@ -67,6 +78,9 @@ }, "packages/ports": { "name": "@household/ports", + "dependencies": { + "@household/domain": "workspace:*", + }, }, }, "packages": { @@ -168,6 +182,8 @@ "@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="], + "@household/adapters-db": ["@household/adapters-db@workspace:packages/adapters-db"], + "@household/application": ["@household/application@workspace:packages/application"], "@household/bot": ["@household/bot@workspace:apps/bot"], diff --git a/docs/specs/HOUSEBOT-024-repository-adapters.md b/docs/specs/HOUSEBOT-024-repository-adapters.md new file mode 100644 index 0000000..e9d7b24 --- /dev/null +++ b/docs/specs/HOUSEBOT-024-repository-adapters.md @@ -0,0 +1,76 @@ +# HOUSEBOT-024: Repository Adapters for Application Ports + +## Summary + +Move persistence concerns behind explicit ports so application use-cases remain framework-free and bot delivery code stops querying Drizzle directly. + +## Goals + +- Define repository contracts in `packages/ports` for finance command workflows. +- Move concrete Drizzle persistence into an adapter package. +- Rewire bot finance commands to depend on application services instead of direct DB access. + +## Non-goals + +- Full persistence migration for every feature in one shot. +- Replacing Drizzle or Supabase. +- Changing finance behavior or settlement rules. + +## Scope + +- In: finance command repository ports, application service orchestration, Drizzle adapter, bot composition updates. +- Out: reminder scheduler adapters and mini app query adapters. + +## Interfaces and Contracts + +- Port: `FinanceRepository` +- Application service: + - member lookup + - open/close cycle + - rent rule save + - utility bill add + - statement generation with persisted settlement snapshot +- Adapter: Drizzle-backed repository implementation bound to a household. + +## Domain Rules + +- Domain money and settlement logic stay in `packages/domain` and `packages/application`. +- Application may orchestrate repository calls but cannot import DB/schema modules. +- Bot command handlers may translate Telegram context to use-case inputs, but may not query DB directly. + +## Data Model Changes + +- None. + +## Security and Privacy + +- Authorization remains in bot delivery layer using household membership/admin data from the application service. +- No new secrets or data exposure paths. + +## Observability + +- Existing command-level success/error logging behavior remains unchanged. +- Statement persistence remains deterministic and idempotent per cycle snapshot replacement. + +## Edge Cases and Failure Modes + +- Missing cycle, rent rule, or members should still return deterministic user-facing failures. +- Adapter wiring mistakes should fail in typecheck/build, not at runtime. +- Middleware or bot delivery bugs must not bypass application-level repository boundaries. + +## Test Plan + +- Unit: application service tests with repository stubs. +- Integration: Drizzle adapter exercised through bot/e2e flows. +- E2E: billing smoke test continues to pass after the refactor. + +## Acceptance Criteria + +- [ ] `packages/application` imports ports, not concrete DB code. +- [ ] `apps/bot/src/finance-commands.ts` contains no Drizzle/schema access. +- [ ] Finance command behavior remains green in repo tests and smoke flow. + +## Rollout Plan + +- Introduce finance repository ports first. +- Keep purchase ingestion adapter migration as a follow-up if needed. diff --git a/packages/adapters-db/package.json b/packages/adapters-db/package.json new file mode 100644 index 0000000..1f87dc2 --- /dev/null +++ b/packages/adapters-db/package.json @@ -0,0 +1,20 @@ +{ + "name": "@household/adapters-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", + "test": "bun test --pass-with-no-tests", + "lint": "oxlint \"src\"" + }, + "dependencies": { + "@household/db": "workspace:*", + "@household/domain": "workspace:*", + "@household/ports": "workspace:*", + "drizzle-orm": "^0.44.7" + } +} diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts new file mode 100644 index 0000000..fe04338 --- /dev/null +++ b/packages/adapters-db/src/finance-repository.ts @@ -0,0 +1,329 @@ +import { and, desc, eq, gte, isNotNull, isNull, lte, or, sql } from 'drizzle-orm' + +import { createDbClient, schema } from '@household/db' +import type { FinanceRepository } from '@household/ports' +import type { CurrencyCode } from '@household/domain' + +function toCurrencyCode(raw: string): CurrencyCode { + const normalized = raw.trim().toUpperCase() + + if (normalized !== 'USD' && normalized !== 'GEL') { + throw new Error(`Unsupported currency in finance repository: ${raw}`) + } + + return normalized +} + +export function createDbFinanceRepository( + databaseUrl: string, + householdId: string +): { + repository: FinanceRepository + close: () => Promise +} { + const { db, queryClient } = createDbClient(databaseUrl, { + max: 5, + prepare: false + }) + + const repository: FinanceRepository = { + async getMemberByTelegramUserId(telegramUserId) { + const rows = await db + .select({ + id: schema.members.id, + telegramUserId: schema.members.telegramUserId, + displayName: schema.members.displayName, + isAdmin: schema.members.isAdmin + }) + .from(schema.members) + .where( + and( + eq(schema.members.householdId, householdId), + eq(schema.members.telegramUserId, telegramUserId) + ) + ) + .limit(1) + + const row = rows[0] + if (!row) { + return null + } + + return { + ...row, + isAdmin: row.isAdmin === 1 + } + }, + + async listMembers() { + const rows = await db + .select({ + id: schema.members.id, + telegramUserId: schema.members.telegramUserId, + displayName: schema.members.displayName, + isAdmin: schema.members.isAdmin + }) + .from(schema.members) + .where(eq(schema.members.householdId, householdId)) + .orderBy(schema.members.displayName) + + return rows.map((row) => ({ + ...row, + isAdmin: row.isAdmin === 1 + })) + }, + + async getOpenCycle() { + const rows = await db + .select({ + id: schema.billingCycles.id, + period: schema.billingCycles.period, + currency: schema.billingCycles.currency + }) + .from(schema.billingCycles) + .where( + and( + eq(schema.billingCycles.householdId, householdId), + isNull(schema.billingCycles.closedAt) + ) + ) + .orderBy(desc(schema.billingCycles.startedAt)) + .limit(1) + + const row = rows[0] + + if (!row) { + return null + } + + return { + ...row, + currency: toCurrencyCode(row.currency) + } + }, + + async getCycleByPeriod(period) { + const rows = await db + .select({ + id: schema.billingCycles.id, + period: schema.billingCycles.period, + currency: schema.billingCycles.currency + }) + .from(schema.billingCycles) + .where( + and( + eq(schema.billingCycles.householdId, householdId), + eq(schema.billingCycles.period, period) + ) + ) + .limit(1) + + const row = rows[0] + + if (!row) { + return null + } + + return { + ...row, + currency: toCurrencyCode(row.currency) + } + }, + + async getLatestCycle() { + const rows = await db + .select({ + id: schema.billingCycles.id, + period: schema.billingCycles.period, + currency: schema.billingCycles.currency + }) + .from(schema.billingCycles) + .where(eq(schema.billingCycles.householdId, householdId)) + .orderBy(desc(schema.billingCycles.period)) + .limit(1) + + const row = rows[0] + + if (!row) { + return null + } + + return { + ...row, + currency: toCurrencyCode(row.currency) + } + }, + + async openCycle(period, currency) { + await db + .insert(schema.billingCycles) + .values({ + householdId, + period, + currency + }) + .onConflictDoNothing({ + target: [schema.billingCycles.householdId, schema.billingCycles.period] + }) + }, + + async closeCycle(cycleId, closedAt) { + await db + .update(schema.billingCycles) + .set({ + closedAt + }) + .where(eq(schema.billingCycles.id, cycleId)) + }, + + async saveRentRule(period, amountMinor, currency) { + await db + .insert(schema.rentRules) + .values({ + householdId, + amountMinor, + currency, + effectiveFromPeriod: period + }) + .onConflictDoUpdate({ + target: [schema.rentRules.householdId, schema.rentRules.effectiveFromPeriod], + set: { + amountMinor, + currency + } + }) + }, + + async addUtilityBill(input) { + await db.insert(schema.utilityBills).values({ + householdId, + cycleId: input.cycleId, + billName: input.billName, + amountMinor: input.amountMinor, + currency: input.currency, + source: 'manual', + createdByMemberId: input.createdByMemberId + }) + }, + + async getRentRuleForPeriod(period) { + const rows = await db + .select({ + amountMinor: schema.rentRules.amountMinor, + currency: schema.rentRules.currency + }) + .from(schema.rentRules) + .where( + and( + eq(schema.rentRules.householdId, householdId), + lte(schema.rentRules.effectiveFromPeriod, period), + or( + isNull(schema.rentRules.effectiveToPeriod), + gte(schema.rentRules.effectiveToPeriod, period) + ) + ) + ) + .orderBy(desc(schema.rentRules.effectiveFromPeriod)) + .limit(1) + + const row = rows[0] + + if (!row) { + return null + } + + return { + ...row, + currency: toCurrencyCode(row.currency) + } + }, + + async getUtilityTotalForCycle(cycleId) { + const rows = await db + .select({ + totalMinor: sql`coalesce(sum(${schema.utilityBills.amountMinor}), 0)` + }) + .from(schema.utilityBills) + .where(eq(schema.utilityBills.cycleId, cycleId)) + + return BigInt(rows[0]?.totalMinor ?? '0') + }, + + async listParsedPurchasesForRange(start, end) { + const rows = await db + .select({ + id: schema.purchaseMessages.id, + payerMemberId: schema.purchaseMessages.senderMemberId, + amountMinor: schema.purchaseMessages.parsedAmountMinor + }) + .from(schema.purchaseMessages) + .where( + and( + eq(schema.purchaseMessages.householdId, householdId), + isNotNull(schema.purchaseMessages.senderMemberId), + isNotNull(schema.purchaseMessages.parsedAmountMinor), + gte(schema.purchaseMessages.messageSentAt, start), + lte(schema.purchaseMessages.messageSentAt, end) + ) + ) + + return rows.map((row) => ({ + id: row.id, + payerMemberId: row.payerMemberId!, + amountMinor: row.amountMinor! + })) + }, + + async replaceSettlementSnapshot(snapshot) { + const upserted = await db + .insert(schema.settlements) + .values({ + householdId, + cycleId: snapshot.cycleId, + inputHash: snapshot.inputHash, + totalDueMinor: snapshot.totalDueMinor, + currency: snapshot.currency, + metadata: snapshot.metadata + }) + .onConflictDoUpdate({ + target: [schema.settlements.cycleId], + set: { + inputHash: snapshot.inputHash, + totalDueMinor: snapshot.totalDueMinor, + currency: snapshot.currency, + computedAt: new Date(), + metadata: snapshot.metadata + } + }) + .returning({ id: schema.settlements.id }) + + const settlementId = upserted[0]?.id + if (!settlementId) { + throw new Error('Failed to persist settlement snapshot') + } + + await db + .delete(schema.settlementLines) + .where(eq(schema.settlementLines.settlementId, settlementId)) + + await db.insert(schema.settlementLines).values( + snapshot.lines.map((line) => ({ + settlementId, + memberId: line.memberId, + rentShareMinor: line.rentShareMinor, + utilityShareMinor: line.utilityShareMinor, + purchaseOffsetMinor: line.purchaseOffsetMinor, + netDueMinor: line.netDueMinor, + explanations: line.explanations + })) + ) + } + } + + return { + repository, + close: async () => { + await queryClient.end({ timeout: 5 }) + } + } +} diff --git a/packages/adapters-db/src/index.ts b/packages/adapters-db/src/index.ts new file mode 100644 index 0000000..aa2d2ef --- /dev/null +++ b/packages/adapters-db/src/index.ts @@ -0,0 +1 @@ +export { createDbFinanceRepository } from './finance-repository' diff --git a/packages/adapters-db/tsconfig.json b/packages/adapters-db/tsconfig.json new file mode 100644 index 0000000..d0f1a85 --- /dev/null +++ b/packages/adapters-db/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/application/package.json b/packages/application/package.json index 90013ef..9c0dc24 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -12,6 +12,7 @@ "lint": "oxlint \"src\"" }, "dependencies": { - "@household/domain": "workspace:*" + "@household/domain": "workspace:*", + "@household/ports": "workspace:*" } } diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts new file mode 100644 index 0000000..2aec740 --- /dev/null +++ b/packages/application/src/finance-command-service.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from 'bun:test' + +import type { + FinanceCycleRecord, + FinanceMemberRecord, + FinanceParsedPurchaseRecord, + FinanceRentRuleRecord, + FinanceRepository, + SettlementSnapshotRecord +} from '@household/ports' + +import { createFinanceCommandService } from './finance-command-service' + +class FinanceRepositoryStub implements FinanceRepository { + member: FinanceMemberRecord | null = null + members: readonly FinanceMemberRecord[] = [] + openCycleRecord: FinanceCycleRecord | null = null + cycleByPeriodRecord: FinanceCycleRecord | null = null + latestCycleRecord: FinanceCycleRecord | null = null + rentRule: FinanceRentRuleRecord | null = null + utilityTotal: bigint = 0n + purchases: readonly FinanceParsedPurchaseRecord[] = [] + + lastSavedRentRule: { + period: string + amountMinor: bigint + currency: 'USD' | 'GEL' + } | null = null + + lastUtilityBill: { + cycleId: string + billName: string + amountMinor: bigint + currency: 'USD' | 'GEL' + createdByMemberId: string + } | null = null + + replacedSnapshot: SettlementSnapshotRecord | null = null + + async getMemberByTelegramUserId(): Promise { + return this.member + } + + async listMembers(): Promise { + return this.members + } + + async getOpenCycle(): Promise { + return this.openCycleRecord + } + + async getCycleByPeriod(): Promise { + return this.cycleByPeriodRecord + } + + async getLatestCycle(): Promise { + return this.latestCycleRecord + } + + async openCycle(period: string, currency: 'USD' | 'GEL'): Promise { + this.openCycleRecord = { + id: 'opened-cycle', + period, + currency + } + } + + async closeCycle(): Promise {} + + async saveRentRule(period: string, amountMinor: bigint, currency: 'USD' | 'GEL'): Promise { + this.lastSavedRentRule = { + period, + amountMinor, + currency + } + } + + async addUtilityBill(input: { + cycleId: string + billName: string + amountMinor: bigint + currency: 'USD' | 'GEL' + createdByMemberId: string + }): Promise { + this.lastUtilityBill = input + } + + async getRentRuleForPeriod(): Promise { + return this.rentRule + } + + async getUtilityTotalForCycle(): Promise { + return this.utilityTotal + } + + async listParsedPurchasesForRange(): Promise { + return this.purchases + } + + async replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise { + this.replacedSnapshot = snapshot + } +} + +describe('createFinanceCommandService', () => { + test('setRent falls back to the open cycle period when one is active', async () => { + const repository = new FinanceRepositoryStub() + repository.openCycleRecord = { + id: 'cycle-1', + period: '2026-03', + currency: 'USD' + } + + const service = createFinanceCommandService(repository) + const result = await service.setRent('700', undefined, undefined) + + expect(result).not.toBeNull() + expect(result?.period).toBe('2026-03') + expect(result?.currency).toBe('USD') + expect(result?.amount.amountMinor).toBe(70000n) + expect(repository.lastSavedRentRule).toEqual({ + period: '2026-03', + amountMinor: 70000n, + currency: 'USD' + }) + }) + + test('addUtilityBill returns null when no open cycle exists', async () => { + const repository = new FinanceRepositoryStub() + const service = createFinanceCommandService(repository) + + const result = await service.addUtilityBill('Electricity', '55.20', 'member-1') + + expect(result).toBeNull() + expect(repository.lastUtilityBill).toBeNull() + }) + + test('generateStatement persists settlement snapshot and returns member lines', async () => { + const repository = new FinanceRepositoryStub() + repository.latestCycleRecord = { + id: 'cycle-2026-03', + period: '2026-03', + currency: 'USD' + } + repository.members = [ + { + id: 'alice', + telegramUserId: '100', + displayName: 'Alice', + isAdmin: true + }, + { + id: 'bob', + telegramUserId: '200', + displayName: 'Bob', + isAdmin: false + } + ] + repository.rentRule = { + amountMinor: 70000n, + currency: 'USD' + } + repository.utilityTotal = 12000n + repository.purchases = [ + { + id: 'purchase-1', + payerMemberId: 'alice', + amountMinor: 3000n + } + ] + + const service = createFinanceCommandService(repository) + const statement = await service.generateStatement() + + expect(statement).toBe( + [ + 'Statement for 2026-03', + '- Alice: 395.00 USD', + '- Bob: 425.00 USD', + 'Total: 820.00 USD' + ].join('\n') + ) + expect(repository.replacedSnapshot).not.toBeNull() + expect(repository.replacedSnapshot?.cycleId).toBe('cycle-2026-03') + expect(repository.replacedSnapshot?.currency).toBe('USD') + expect(repository.replacedSnapshot?.totalDueMinor).toBe(82000n) + expect(repository.replacedSnapshot?.lines.map((line) => line.netDueMinor)).toEqual([ + 39500n, + 42500n + ]) + }) +}) diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts new file mode 100644 index 0000000..3562196 --- /dev/null +++ b/packages/application/src/finance-command-service.ts @@ -0,0 +1,233 @@ +import { createHash } from 'node:crypto' + +import type { FinanceCycleRecord, FinanceMemberRecord, FinanceRepository } from '@household/ports' +import { + BillingCycleId, + BillingPeriod, + MemberId, + Money, + PurchaseEntryId, + type CurrencyCode +} from '@household/domain' + +import { calculateMonthlySettlement } from './settlement-engine' + +function parseCurrency(raw: string | undefined, fallback: CurrencyCode): CurrencyCode { + if (!raw || raw.trim().length === 0) { + return fallback + } + + const normalized = raw.trim().toUpperCase() + if (normalized !== 'USD' && normalized !== 'GEL') { + throw new Error(`Unsupported currency: ${raw}`) + } + + return normalized +} + +function monthRange(period: BillingPeriod): { start: Date; end: Date } { + return { + start: new Date(Date.UTC(period.year, period.month - 1, 1, 0, 0, 0)), + end: new Date(Date.UTC(period.year, period.month, 0, 23, 59, 59)) + } +} + +function computeInputHash(payload: object): string { + return createHash('sha256').update(JSON.stringify(payload)).digest('hex') +} + +async function getCycleByPeriodOrLatest( + repository: FinanceRepository, + periodArg?: string +): Promise { + if (periodArg) { + return repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString()) + } + + return repository.getLatestCycle() +} + +export interface FinanceCommandService { + getMemberByTelegramUserId(telegramUserId: string): Promise + getOpenCycle(): Promise + openCycle(periodArg: string, currencyArg?: string): Promise + closeCycle(periodArg?: string): Promise + setRent( + amountArg: string, + currencyArg?: string, + periodArg?: string + ): Promise<{ + amount: Money + currency: CurrencyCode + period: string + } | null> + addUtilityBill( + billName: string, + amountArg: string, + createdByMemberId: string, + currencyArg?: string + ): Promise<{ + amount: Money + currency: CurrencyCode + period: string + } | null> + generateStatement(periodArg?: string): Promise +} + +export function createFinanceCommandService(repository: FinanceRepository): FinanceCommandService { + return { + getMemberByTelegramUserId(telegramUserId) { + return repository.getMemberByTelegramUserId(telegramUserId) + }, + + getOpenCycle() { + return repository.getOpenCycle() + }, + + async openCycle(periodArg, currencyArg) { + const period = BillingPeriod.fromString(periodArg).toString() + const currency = parseCurrency(currencyArg, 'USD') + + await repository.openCycle(period, currency) + + return { + id: '', + period, + currency + } + }, + + async closeCycle(periodArg) { + const cycle = await getCycleByPeriodOrLatest(repository, periodArg) + if (!cycle) { + return null + } + + await repository.closeCycle(cycle.id, new Date()) + return cycle + }, + + async setRent(amountArg, currencyArg, periodArg) { + const openCycle = await repository.getOpenCycle() + const period = periodArg ?? openCycle?.period + if (!period) { + return null + } + + const currency = parseCurrency(currencyArg, openCycle?.currency ?? 'USD') + const amount = Money.fromMajor(amountArg, currency) + + await repository.saveRentRule( + BillingPeriod.fromString(period).toString(), + amount.amountMinor, + currency + ) + + return { + amount, + currency, + period: BillingPeriod.fromString(period).toString() + } + }, + + async addUtilityBill(billName, amountArg, createdByMemberId, currencyArg) { + const openCycle = await repository.getOpenCycle() + if (!openCycle) { + return null + } + + const currency = parseCurrency(currencyArg, openCycle.currency) + const amount = Money.fromMajor(amountArg, currency) + + await repository.addUtilityBill({ + cycleId: openCycle.id, + billName, + amountMinor: amount.amountMinor, + currency, + createdByMemberId + }) + + return { + amount, + currency, + period: openCycle.period + } + }, + + async generateStatement(periodArg) { + const cycle = await getCycleByPeriodOrLatest(repository, periodArg) + if (!cycle) { + return null + } + + const members = await repository.listMembers() + if (members.length === 0) { + throw new Error('No household members configured') + } + + const rentRule = await repository.getRentRuleForPeriod(cycle.period) + if (!rentRule) { + throw new Error('No rent rule configured for this cycle period') + } + + const period = BillingPeriod.fromString(cycle.period) + const { start, end } = monthRange(period) + const purchases = await repository.listParsedPurchasesForRange(start, end) + const utilitiesMinor = await repository.getUtilityTotalForCycle(cycle.id) + + const settlement = calculateMonthlySettlement({ + cycleId: BillingCycleId.from(cycle.id), + period, + rent: Money.fromMinor(rentRule.amountMinor, rentRule.currency), + utilities: Money.fromMinor(utilitiesMinor, rentRule.currency), + utilitySplitMode: 'equal', + members: members.map((member) => ({ + memberId: MemberId.from(member.id), + active: true + })), + purchases: purchases.map((purchase) => ({ + purchaseId: PurchaseEntryId.from(purchase.id), + payerId: MemberId.from(purchase.payerMemberId), + amount: Money.fromMinor(purchase.amountMinor, rentRule.currency) + })) + }) + + await repository.replaceSettlementSnapshot({ + cycleId: cycle.id, + inputHash: computeInputHash({ + cycleId: cycle.id, + rentMinor: rentRule.amountMinor.toString(), + utilitiesMinor: utilitiesMinor.toString(), + purchaseCount: purchases.length, + memberCount: members.length + }), + totalDueMinor: settlement.totalDue.amountMinor, + currency: rentRule.currency, + metadata: { + generatedBy: 'bot-command', + source: 'statement' + }, + lines: settlement.lines.map((line) => ({ + memberId: line.memberId.toString(), + rentShareMinor: line.rentShare.amountMinor, + utilityShareMinor: line.utilityShare.amountMinor, + purchaseOffsetMinor: line.purchaseOffset.amountMinor, + netDueMinor: line.netDue.amountMinor, + explanations: line.explanations + })) + }) + + const memberNameById = new Map(members.map((member) => [member.id, member.displayName])) + const statementLines = settlement.lines.map((line) => { + const name = memberNameById.get(line.memberId.toString()) ?? line.memberId.toString() + return `- ${name}: ${line.netDue.toMajorString()} ${rentRule.currency}` + }) + + return [ + `Statement for ${cycle.period}`, + ...statementLines, + `Total: ${settlement.totalDue.toMajorString()} ${rentRule.currency}` + ].join('\n') + } + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 8973460..ce8c1fc 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -1,4 +1,5 @@ export { calculateMonthlySettlement } from './settlement-engine' +export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service' export { parsePurchaseMessage, type ParsedPurchaseResult, diff --git a/packages/ports/package.json b/packages/ports/package.json index 171ab24..31405b5 100644 --- a/packages/ports/package.json +++ b/packages/ports/package.json @@ -2,10 +2,16 @@ "name": "@household/ports", "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", "test": "bun test --pass-with-no-tests", "lint": "oxlint \"src\"" + }, + "dependencies": { + "@household/domain": "workspace:*" } } diff --git a/packages/ports/src/finance.ts b/packages/ports/src/finance.ts new file mode 100644 index 0000000..c71c80e --- /dev/null +++ b/packages/ports/src/finance.ts @@ -0,0 +1,68 @@ +import type { CurrencyCode } from '@household/domain' + +export interface FinanceMemberRecord { + id: string + telegramUserId: string + displayName: string + isAdmin: boolean +} + +export interface FinanceCycleRecord { + id: string + period: string + currency: CurrencyCode +} + +export interface FinanceRentRuleRecord { + amountMinor: bigint + currency: CurrencyCode +} + +export interface FinanceParsedPurchaseRecord { + id: string + payerMemberId: string + amountMinor: bigint +} + +export interface SettlementSnapshotLineRecord { + memberId: string + rentShareMinor: bigint + utilityShareMinor: bigint + purchaseOffsetMinor: bigint + netDueMinor: bigint + explanations: readonly string[] +} + +export interface SettlementSnapshotRecord { + cycleId: string + inputHash: string + totalDueMinor: bigint + currency: CurrencyCode + metadata: Record + lines: readonly SettlementSnapshotLineRecord[] +} + +export interface FinanceRepository { + getMemberByTelegramUserId(telegramUserId: string): Promise + listMembers(): Promise + getOpenCycle(): Promise + getCycleByPeriod(period: string): Promise + getLatestCycle(): Promise + openCycle(period: string, currency: CurrencyCode): Promise + closeCycle(cycleId: string, closedAt: Date): Promise + saveRentRule(period: string, amountMinor: bigint, currency: CurrencyCode): Promise + addUtilityBill(input: { + cycleId: string + billName: string + amountMinor: bigint + currency: CurrencyCode + createdByMemberId: string + }): Promise + getRentRuleForPeriod(period: string): Promise + getUtilityTotalForCycle(cycleId: string): Promise + listParsedPurchasesForRange( + start: Date, + end: Date + ): Promise + replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise +} diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index c1fa326..a9ba74a 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -1 +1,9 @@ -export const portsReady = true +export type { + FinanceCycleRecord, + FinanceMemberRecord, + FinanceParsedPurchaseRecord, + FinanceRentRuleRecord, + FinanceRepository, + SettlementSnapshotLineRecord, + SettlementSnapshotRecord +} from './finance' diff --git a/tsconfig.json b/tsconfig.json index d88127c..6739956 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ { "path": "./packages/contracts" }, { "path": "./packages/observability" }, { "path": "./packages/config" }, - { "path": "./packages/db" } + { "path": "./packages/db" }, + { "path": "./packages/adapters-db" } ] } From 6c0dbfc48e40e8f8298357e3aa988d715b03d056 Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 22:15:01 +0400 Subject: [PATCH 02/10] feat(bot): add secure reminder job runtime --- apps/bot/src/config.ts | 9 ++ apps/bot/src/index.ts | 37 +++++- apps/bot/src/reminder-jobs.test.ts | 110 +++++++++++++++++ apps/bot/src/reminder-jobs.ts | 111 ++++++++++++++++++ apps/bot/src/server.test.ts | 54 ++++++++- apps/bot/src/server.ts | 30 +++++ .../HOUSEBOT-031-secure-scheduler-endpoint.md | 81 +++++++++++++ packages/adapters-db/src/index.ts | 1 + .../src/reminder-dispatch-repository.ts | 46 ++++++++ packages/application/src/index.ts | 5 + .../src/reminder-job-service.test.ts | 80 +++++++++++++ .../application/src/reminder-job-service.ts | 84 +++++++++++++ packages/ports/src/index.ts | 7 ++ packages/ports/src/reminders.ts | 19 +++ 14 files changed, 670 insertions(+), 4 deletions(-) create mode 100644 apps/bot/src/reminder-jobs.test.ts create mode 100644 apps/bot/src/reminder-jobs.ts create mode 100644 docs/specs/HOUSEBOT-031-secure-scheduler-endpoint.md create mode 100644 packages/adapters-db/src/reminder-dispatch-repository.ts create mode 100644 packages/application/src/reminder-job-service.test.ts create mode 100644 packages/application/src/reminder-job-service.ts create mode 100644 packages/ports/src/reminders.ts diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index 1f57c13..6f79697 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -9,6 +9,8 @@ export interface BotRuntimeConfig { telegramPurchaseTopicId?: number purchaseTopicIngestionEnabled: boolean financeCommandsEnabled: boolean + schedulerSharedSecret?: string + reminderJobsEnabled: boolean openaiApiKey?: string parserModel: string } @@ -57,6 +59,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu const householdId = parseOptionalValue(env.HOUSEHOLD_ID) const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID) const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID) + const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET) const purchaseTopicIngestionEnabled = databaseUrl !== undefined && @@ -65,6 +68,8 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramPurchaseTopicId !== undefined const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined + const reminderJobsEnabled = + databaseUrl !== undefined && householdId !== undefined && schedulerSharedSecret !== undefined const runtime: BotRuntimeConfig = { port: parsePort(env.PORT), @@ -73,6 +78,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram', purchaseTopicIngestionEnabled, financeCommandsEnabled, + reminderJobsEnabled, parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini' } @@ -88,6 +94,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu if (telegramPurchaseTopicId !== undefined) { runtime.telegramPurchaseTopicId = telegramPurchaseTopicId } + if (schedulerSharedSecret !== undefined) { + runtime.schedulerSharedSecret = schedulerSharedSecret + } const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY) if (openaiApiKey !== undefined) { runtime.openaiApiKey = openaiApiKey diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 044510f..6ee0820 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,7 +1,10 @@ import { webhookCallback } from 'grammy' -import { createFinanceCommandService } from '@household/application' -import { createDbFinanceRepository } from '@household/adapters-db' +import { createFinanceCommandService, createReminderJobService } from '@household/application' +import { + createDbFinanceRepository, + createDbReminderDispatchRepository +} from '@household/adapters-db' import { createFinanceCommandsService } from './finance-commands' import { createTelegramBot } from './bot' @@ -11,6 +14,7 @@ import { createPurchaseMessageRepository, registerPurchaseTopicIngestion } from './purchase-topic-ingestion' +import { createReminderJobsHandler } from './reminder-jobs' import { createBotWebhookServer } from './server' const runtime = getBotRuntimeConfig() @@ -58,10 +62,37 @@ if (runtime.financeCommandsEnabled) { console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.') } +const reminderJobs = runtime.reminderJobsEnabled + ? (() => { + const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!) + const reminderService = createReminderJobService(reminderRepositoryClient.repository) + + shutdownTasks.push(reminderRepositoryClient.close) + + return createReminderJobsHandler({ + householdId: runtime.householdId!, + reminderService + }) + })() + : null + +if (!runtime.reminderJobsEnabled) { + console.warn( + 'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and SCHEDULER_SHARED_SECRET to enable.' + ) +} + const server = createBotWebhookServer({ webhookPath: runtime.telegramWebhookPath, webhookSecret: runtime.telegramWebhookSecret, - webhookHandler + webhookHandler, + scheduler: + reminderJobs && runtime.schedulerSharedSecret + ? { + sharedSecret: runtime.schedulerSharedSecret, + handler: reminderJobs.handle + } + : undefined }) if (import.meta.main) { diff --git a/apps/bot/src/reminder-jobs.test.ts b/apps/bot/src/reminder-jobs.test.ts new file mode 100644 index 0000000..a38b732 --- /dev/null +++ b/apps/bot/src/reminder-jobs.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, mock, test } from 'bun:test' + +import type { ReminderJobResult, ReminderJobService } from '@household/application' + +import { createReminderJobsHandler } from './reminder-jobs' + +describe('createReminderJobsHandler', () => { + test('returns job outcome with dedupe metadata', async () => { + const claimedResult: ReminderJobResult = { + status: 'claimed', + dedupeKey: '2026-03:utilities', + payloadHash: 'hash', + reminderType: 'utilities', + period: '2026-03', + messageText: 'Utilities reminder for 2026-03' + } + + const reminderService: ReminderJobService = { + handleJob: mock(async () => claimedResult) + } + + const handler = createReminderJobsHandler({ + householdId: 'household-1', + reminderService + }) + + const response = await handler.handle( + new Request('http://localhost/jobs/reminder/utilities', { + method: 'POST', + body: JSON.stringify({ + period: '2026-03', + jobId: 'job-1' + }) + }), + 'utilities' + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + jobId: 'job-1', + reminderType: 'utilities', + period: '2026-03', + dedupeKey: '2026-03:utilities', + outcome: 'claimed', + dryRun: false, + messageText: 'Utilities reminder for 2026-03' + }) + }) + + test('supports forced dry-run mode', async () => { + const dryRunResult: ReminderJobResult = { + status: 'dry-run', + dedupeKey: '2026-03:rent-warning', + payloadHash: 'hash', + reminderType: 'rent-warning', + period: '2026-03', + messageText: 'Rent reminder for 2026-03: payment is coming up soon.' + } + + const reminderService: ReminderJobService = { + handleJob: mock(async () => dryRunResult) + } + + const handler = createReminderJobsHandler({ + householdId: 'household-1', + reminderService, + forceDryRun: true + }) + + const response = await handler.handle( + new Request('http://localhost/jobs/reminder/rent-warning', { + method: 'POST', + body: JSON.stringify({ period: '2026-03', jobId: 'job-2' }) + }), + 'rent-warning' + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + outcome: 'dry-run', + dryRun: true + }) + }) + + test('rejects unsupported reminder type', async () => { + const handler = createReminderJobsHandler({ + householdId: 'household-1', + reminderService: { + handleJob: mock(async () => { + throw new Error('should not be called') + }) + } + }) + + const response = await handler.handle( + new Request('http://localhost/jobs/reminder/unknown', { + method: 'POST', + body: JSON.stringify({ period: '2026-03' }) + }), + 'unknown' + ) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + ok: false, + error: 'Invalid reminder type' + }) + }) +}) diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts new file mode 100644 index 0000000..a4cbb0a --- /dev/null +++ b/apps/bot/src/reminder-jobs.ts @@ -0,0 +1,111 @@ +import { BillingPeriod } from '@household/domain' +import type { ReminderJobService } from '@household/application' + +const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const +type ReminderType = (typeof REMINDER_TYPES)[number] + +interface ReminderJobRequestBody { + period?: string + jobId?: string + dryRun?: boolean +} + +function json(body: object, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) +} + +function parseReminderType(raw: string): ReminderType | null { + if ((REMINDER_TYPES as readonly string[]).includes(raw)) { + return raw as ReminderType + } + + return null +} + +function currentPeriod(): string { + const now = new Date() + const year = now.getUTCFullYear() + const month = `${now.getUTCMonth() + 1}`.padStart(2, '0') + + return `${year}-${month}` +} + +async function readBody(request: Request): Promise { + const text = await request.text() + + if (text.trim().length === 0) { + return {} + } + + const parsed = JSON.parse(text) as ReminderJobRequestBody + return parsed +} + +export function createReminderJobsHandler(options: { + householdId: string + reminderService: ReminderJobService + forceDryRun?: boolean +}): { + handle: (request: Request, rawReminderType: string) => Promise +} { + return { + handle: async (request, rawReminderType) => { + const reminderType = parseReminderType(rawReminderType) + if (!reminderType) { + return json({ ok: false, error: 'Invalid reminder type' }, 400) + } + + try { + const body = await readBody(request) + const period = BillingPeriod.fromString(body.period ?? currentPeriod()).toString() + const dryRun = options.forceDryRun === true || body.dryRun === true + const result = await options.reminderService.handleJob({ + householdId: options.householdId, + period, + reminderType, + dryRun + }) + + const logPayload = { + event: 'scheduler.reminder.dispatch', + reminderType, + period, + jobId: body.jobId ?? null, + dedupeKey: result.dedupeKey, + outcome: result.status, + dryRun + } + + console.log(JSON.stringify(logPayload)) + + return json({ + ok: true, + jobId: body.jobId ?? null, + reminderType, + period, + dedupeKey: result.dedupeKey, + outcome: result.status, + dryRun, + messageText: result.messageText + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown reminder job error' + + console.error( + JSON.stringify({ + event: 'scheduler.reminder.dispatch_failed', + reminderType: rawReminderType, + error: message + }) + ) + + return json({ ok: false, error: message }, 400) + } + } + } +} diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index ed771b3..f820a34 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -6,7 +6,17 @@ describe('createBotWebhookServer', () => { const server = createBotWebhookServer({ webhookPath: '/webhook/telegram', webhookSecret: 'secret-token', - webhookHandler: async () => new Response('ok', { status: 200 }) + webhookHandler: async () => new Response('ok', { status: 200 }), + scheduler: { + sharedSecret: 'scheduler-secret', + handler: async (_request, reminderType) => + new Response(JSON.stringify({ ok: true, reminderType }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + } }) test('returns health payload', async () => { @@ -59,4 +69,46 @@ describe('createBotWebhookServer', () => { expect(response.status).toBe(200) expect(await response.text()).toBe('ok') }) + + test('rejects scheduler request with missing secret', async () => { + const response = await server.fetch( + new Request('http://localhost/jobs/reminder/utilities', { + method: 'POST', + body: JSON.stringify({ period: '2026-03' }) + }) + ) + + expect(response.status).toBe(401) + }) + + test('rejects non-post method for scheduler endpoint', async () => { + const response = await server.fetch( + new Request('http://localhost/jobs/reminder/utilities', { + method: 'GET', + headers: { + 'x-household-scheduler-secret': 'scheduler-secret' + } + }) + ) + + expect(response.status).toBe(405) + }) + + test('accepts authorized scheduler request', async () => { + const response = await server.fetch( + new Request('http://localhost/jobs/reminder/rent-due', { + method: 'POST', + headers: { + 'x-household-scheduler-secret': 'scheduler-secret' + }, + body: JSON.stringify({ period: '2026-03' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + reminderType: 'rent-due' + }) + }) }) diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index d9fc210..b08a670 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -2,6 +2,13 @@ export interface BotWebhookServerOptions { webhookPath: string webhookSecret: string webhookHandler: (request: Request) => Promise | Response + scheduler?: + | { + pathPrefix?: string + sharedSecret: string + handler: (request: Request, reminderType: string) => Promise + } + | undefined } function json(body: object, status = 200): Response { @@ -19,12 +26,22 @@ function isAuthorized(request: Request, expectedSecret: string): boolean { return secretHeader === expectedSecret } +function isSchedulerAuthorized(request: Request, expectedSecret: string): boolean { + const customHeader = request.headers.get('x-household-scheduler-secret') + const authorizationHeader = request.headers.get('authorization') + + return customHeader === expectedSecret || authorizationHeader === `Bearer ${expectedSecret}` +} + export function createBotWebhookServer(options: BotWebhookServerOptions): { fetch: (request: Request) => Promise } { const normalizedWebhookPath = options.webhookPath.startsWith('/') ? options.webhookPath : `/${options.webhookPath}` + const schedulerPathPrefix = options.scheduler + ? (options.scheduler.pathPrefix ?? '/jobs/reminder') + : null return { fetch: async (request: Request) => { @@ -35,6 +52,19 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { } if (url.pathname !== normalizedWebhookPath) { + if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) { + if (request.method !== 'POST') { + return new Response('Method Not Allowed', { status: 405 }) + } + + if (!isSchedulerAuthorized(request, options.scheduler!.sharedSecret)) { + return new Response('Unauthorized', { status: 401 }) + } + + const reminderType = url.pathname.slice(`${schedulerPathPrefix}/`.length) + return await options.scheduler!.handler(request, reminderType) + } + return new Response('Not Found', { status: 404 }) } diff --git a/docs/specs/HOUSEBOT-031-secure-scheduler-endpoint.md b/docs/specs/HOUSEBOT-031-secure-scheduler-endpoint.md new file mode 100644 index 0000000..69d2f8a --- /dev/null +++ b/docs/specs/HOUSEBOT-031-secure-scheduler-endpoint.md @@ -0,0 +1,81 @@ +# HOUSEBOT-031: Secure Scheduler Endpoint and Idempotent Reminder Dispatch + +## Summary + +Add authenticated reminder job endpoints to the bot runtime with deterministic deduplication and dry-run support. + +## Goals + +- Accept reminder job calls through dedicated HTTP endpoints. +- Reject unauthorized or malformed scheduler requests. +- Prevent duplicate reminder dispatch for the same household, period, and reminder type. +- Emit structured outcomes for local validation and future monitoring. + +## Non-goals + +- Full Cloud Scheduler IaC setup. +- Final Telegram reminder copy or topic routing. +- OIDC verification in v1 of this runtime slice. + +## Scope + +- In: shared-secret auth, request validation, dry-run mode, dedupe persistence, structured logs. +- Out: live Telegram send integration and scheduler provisioning. + +## Interfaces and Contracts + +- Endpoint family: `/jobs/reminder/` +- Allowed types: + - `utilities` + - `rent-warning` + - `rent-due` +- Request body: + - `period?: YYYY-MM` + - `jobId?: string` + - `dryRun?: boolean` +- Auth: + - `x-household-scheduler-secret: ` or `Authorization: Bearer ` + +## Domain Rules + +- Dedupe key format: `:` +- Persistence uniqueness remains household-scoped. +- `dryRun=true` never persists a dispatch claim. + +## Data Model Changes + +- None. Reuse `processed_bot_messages` as the idempotency ledger for scheduler reminder claims. + +## Security and Privacy + +- Scheduler routes are unavailable unless `SCHEDULER_SHARED_SECRET` is configured. +- Unauthorized callers receive `401`. +- Request errors return `400` without leaking secrets. + +## Observability + +- Successful and failed job handling emits structured JSON logs. +- Log payload includes: + - `jobId` + - `dedupeKey` + - `outcome` + - `reminderType` + - `period` + +## Edge Cases and Failure Modes + +- Empty body defaults period to the current UTC billing month. +- Invalid period format is rejected. +- Replayed jobs return `duplicate` without a second dispatch claim. + +## Test Plan + +- Unit: reminder job service dry-run and dedupe results. +- Integration-ish: HTTP handler auth, route validation, and response payloads. + +## Acceptance Criteria + +- [ ] Unauthorized scheduler requests are rejected. +- [ ] Duplicate scheduler calls return a deterministic duplicate outcome. +- [ ] Dry-run mode skips persistence and still returns a structured payload. +- [ ] Logs include `jobId`, `dedupeKey`, and outcome. diff --git a/packages/adapters-db/src/index.ts b/packages/adapters-db/src/index.ts index aa2d2ef..bc08a9b 100644 --- a/packages/adapters-db/src/index.ts +++ b/packages/adapters-db/src/index.ts @@ -1 +1,2 @@ export { createDbFinanceRepository } from './finance-repository' +export { createDbReminderDispatchRepository } from './reminder-dispatch-repository' diff --git a/packages/adapters-db/src/reminder-dispatch-repository.ts b/packages/adapters-db/src/reminder-dispatch-repository.ts new file mode 100644 index 0000000..14064dc --- /dev/null +++ b/packages/adapters-db/src/reminder-dispatch-repository.ts @@ -0,0 +1,46 @@ +import { createDbClient, schema } from '@household/db' +import type { ReminderDispatchRepository } from '@household/ports' + +export function createDbReminderDispatchRepository(databaseUrl: string): { + repository: ReminderDispatchRepository + close: () => Promise +} { + const { db, queryClient } = createDbClient(databaseUrl, { + max: 3, + prepare: false + }) + + const repository: ReminderDispatchRepository = { + async claimReminderDispatch(input) { + const dedupeKey = `${input.period}:${input.reminderType}` + const rows = await db + .insert(schema.processedBotMessages) + .values({ + householdId: input.householdId, + source: 'scheduler-reminder', + sourceMessageKey: dedupeKey, + payloadHash: input.payloadHash + }) + .onConflictDoNothing({ + target: [ + schema.processedBotMessages.householdId, + schema.processedBotMessages.source, + schema.processedBotMessages.sourceMessageKey + ] + }) + .returning({ id: schema.processedBotMessages.id }) + + return { + dedupeKey, + claimed: rows.length > 0 + } + } + } + + return { + repository, + close: async () => { + await queryClient.end({ timeout: 5 }) + } + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index ce8c1fc..ffb204b 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -1,5 +1,10 @@ export { calculateMonthlySettlement } from './settlement-engine' export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service' +export { + createReminderJobService, + type ReminderJobResult, + type ReminderJobService +} from './reminder-job-service' export { parsePurchaseMessage, type ParsedPurchaseResult, diff --git a/packages/application/src/reminder-job-service.test.ts b/packages/application/src/reminder-job-service.test.ts new file mode 100644 index 0000000..2b1b620 --- /dev/null +++ b/packages/application/src/reminder-job-service.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test' + +import type { + ClaimReminderDispatchInput, + ClaimReminderDispatchResult, + ReminderDispatchRepository +} from '@household/ports' + +import { createReminderJobService } from './reminder-job-service' + +class ReminderDispatchRepositoryStub implements ReminderDispatchRepository { + nextResult: ClaimReminderDispatchResult = { + dedupeKey: '2026-03:utilities', + claimed: true + } + + lastClaim: ClaimReminderDispatchInput | null = null + + async claimReminderDispatch( + input: ClaimReminderDispatchInput + ): Promise { + this.lastClaim = input + return this.nextResult + } +} + +describe('createReminderJobService', () => { + test('returns dry-run result without touching the repository', async () => { + const repository = new ReminderDispatchRepositoryStub() + const service = createReminderJobService(repository) + + const result = await service.handleJob({ + householdId: 'household-1', + period: '2026-03', + reminderType: 'utilities', + dryRun: true + }) + + expect(result.status).toBe('dry-run') + expect(result.dedupeKey).toBe('2026-03:utilities') + expect(result.messageText).toBe('Utilities reminder for 2026-03') + expect(repository.lastClaim).toBeNull() + }) + + test('claims a dispatch once and returns the dedupe key', async () => { + const repository = new ReminderDispatchRepositoryStub() + const service = createReminderJobService(repository) + + const result = await service.handleJob({ + householdId: 'household-1', + period: '2026-03', + reminderType: 'rent-due' + }) + + expect(result.status).toBe('claimed') + expect(repository.lastClaim).toMatchObject({ + householdId: 'household-1', + period: '2026-03', + reminderType: 'rent-due' + }) + }) + + test('returns duplicate when the repository rejects a replay', async () => { + const repository = new ReminderDispatchRepositoryStub() + repository.nextResult = { + dedupeKey: '2026-03:rent-warning', + claimed: false + } + + const service = createReminderJobService(repository) + const result = await service.handleJob({ + householdId: 'household-1', + period: '2026-03', + reminderType: 'rent-warning' + }) + + expect(result.status).toBe('duplicate') + expect(result.dedupeKey).toBe('2026-03:rent-warning') + }) +}) diff --git a/packages/application/src/reminder-job-service.ts b/packages/application/src/reminder-job-service.ts new file mode 100644 index 0000000..dcdd121 --- /dev/null +++ b/packages/application/src/reminder-job-service.ts @@ -0,0 +1,84 @@ +import { createHash } from 'node:crypto' + +import { BillingPeriod } from '@household/domain' +import type { + ClaimReminderDispatchResult, + ReminderDispatchRepository, + ReminderType +} from '@household/ports' + +function computePayloadHash(payload: object): string { + return createHash('sha256').update(JSON.stringify(payload)).digest('hex') +} + +function createReminderMessage(reminderType: ReminderType, period: string): string { + switch (reminderType) { + case 'utilities': + return `Utilities reminder for ${period}` + case 'rent-warning': + return `Rent reminder for ${period}: payment is coming up soon.` + case 'rent-due': + return `Rent due reminder for ${period}: please settle payment today.` + } +} + +export interface ReminderJobResult { + status: 'dry-run' | 'claimed' | 'duplicate' + dedupeKey: string + payloadHash: string + reminderType: ReminderType + period: string + messageText: string +} + +export interface ReminderJobService { + handleJob(input: { + householdId: string + period: string + reminderType: ReminderType + dryRun?: boolean + }): Promise +} + +export function createReminderJobService( + repository: ReminderDispatchRepository +): ReminderJobService { + return { + async handleJob(input) { + const period = BillingPeriod.fromString(input.period).toString() + const payloadHash = computePayloadHash({ + householdId: input.householdId, + period, + reminderType: input.reminderType + }) + const messageText = createReminderMessage(input.reminderType, period) + + if (input.dryRun === true) { + return { + status: 'dry-run', + dedupeKey: `${period}:${input.reminderType}`, + payloadHash, + reminderType: input.reminderType, + period, + messageText + } + } + + const result: ClaimReminderDispatchResult = await repository.claimReminderDispatch({ + householdId: input.householdId, + period, + reminderType: input.reminderType, + payloadHash + }) + + return { + status: result.claimed ? 'claimed' : 'duplicate', + dedupeKey: result.dedupeKey, + payloadHash, + reminderType: input.reminderType, + period, + messageText + } + } + } +} diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index a9ba74a..6a8bff2 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -1,3 +1,10 @@ +export { + REMINDER_TYPES, + type ClaimReminderDispatchInput, + type ClaimReminderDispatchResult, + type ReminderDispatchRepository, + type ReminderType +} from './reminders' export type { FinanceCycleRecord, FinanceMemberRecord, diff --git a/packages/ports/src/reminders.ts b/packages/ports/src/reminders.ts new file mode 100644 index 0000000..cc6f1da --- /dev/null +++ b/packages/ports/src/reminders.ts @@ -0,0 +1,19 @@ +export const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const + +export type ReminderType = (typeof REMINDER_TYPES)[number] + +export interface ClaimReminderDispatchInput { + householdId: string + period: string + reminderType: ReminderType + payloadHash: string +} + +export interface ClaimReminderDispatchResult { + dedupeKey: string + claimed: boolean +} + +export interface ReminderDispatchRepository { + claimReminderDispatch(input: ClaimReminderDispatchInput): Promise +} From fd0680c8ef25f5772aa07460bba1417c0752707c Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 22:23:19 +0400 Subject: [PATCH 03/10] feat(infra): add reminder scheduler jobs --- apps/bot/package.json | 1 + apps/bot/src/config.ts | 21 ++- apps/bot/src/index.ts | 17 ++- apps/bot/src/reminder-jobs.ts | 5 +- apps/bot/src/scheduler-auth.test.ts | 75 ++++++++++ apps/bot/src/scheduler-auth.ts | 81 +++++++++++ apps/bot/src/server.test.ts | 3 +- apps/bot/src/server.ts | 11 +- bun.lock | 128 +++++++++++++++++- docs/runbooks/iac-terraform.md | 21 +++ .../HOUSEBOT-030-cloud-scheduler-jobs.md | 59 ++++++++ infra/terraform/README.md | 6 +- infra/terraform/locals.tf | 15 ++ infra/terraform/main.tf | 18 ++- infra/terraform/outputs.tf | 6 +- infra/terraform/terraform.tfvars.example | 9 +- infra/terraform/variables.tf | 43 +++--- scripts/e2e/billing-flow.ts | 14 +- 18 files changed, 474 insertions(+), 59 deletions(-) create mode 100644 apps/bot/src/scheduler-auth.test.ts create mode 100644 apps/bot/src/scheduler-auth.ts create mode 100644 docs/specs/HOUSEBOT-030-cloud-scheduler-jobs.md diff --git a/apps/bot/package.json b/apps/bot/package.json index 6ef3f2d..3308863 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -14,6 +14,7 @@ "@household/application": "workspace:*", "@household/db": "workspace:*", "drizzle-orm": "^0.44.7", + "google-auth-library": "^10.4.1", "grammy": "1.41.1" } } diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index 6f79697..3a7e07b 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -10,6 +10,7 @@ export interface BotRuntimeConfig { purchaseTopicIngestionEnabled: boolean financeCommandsEnabled: boolean schedulerSharedSecret?: string + schedulerOidcAllowedEmails: readonly string[] reminderJobsEnabled: boolean openaiApiKey?: string parserModel: string @@ -54,12 +55,26 @@ function parseOptionalValue(value: string | undefined): string | undefined { return trimmed && trimmed.length > 0 ? trimmed : undefined } +function parseOptionalCsv(value: string | undefined): readonly string[] { + const trimmed = value?.trim() + + if (!trimmed) { + return [] + } + + return trimmed + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) +} + export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig { 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 schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET) + const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS) const purchaseTopicIngestionEnabled = databaseUrl !== undefined && @@ -68,8 +83,11 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramPurchaseTopicId !== undefined const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined + const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 const reminderJobsEnabled = - databaseUrl !== undefined && householdId !== undefined && schedulerSharedSecret !== undefined + databaseUrl !== undefined && + householdId !== undefined && + (schedulerSharedSecret !== undefined || hasSchedulerOidcConfig) const runtime: BotRuntimeConfig = { port: parsePort(env.PORT), @@ -78,6 +96,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram', purchaseTopicIngestionEnabled, financeCommandsEnabled, + schedulerOidcAllowedEmails, reminderJobsEnabled, parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini' } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 6ee0820..09dee1b 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -15,6 +15,7 @@ import { registerPurchaseTopicIngestion } from './purchase-topic-ingestion' import { createReminderJobsHandler } from './reminder-jobs' +import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createBotWebhookServer } from './server' const runtime = getBotRuntimeConfig() @@ -78,7 +79,7 @@ const reminderJobs = runtime.reminderJobsEnabled if (!runtime.reminderJobsEnabled) { console.warn( - 'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and SCHEDULER_SHARED_SECRET to enable.' + 'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.' ) } @@ -89,10 +90,20 @@ const server = createBotWebhookServer({ scheduler: reminderJobs && runtime.schedulerSharedSecret ? { - sharedSecret: runtime.schedulerSharedSecret, + authorize: createSchedulerRequestAuthorizer({ + sharedSecret: runtime.schedulerSharedSecret, + oidcAllowedEmails: runtime.schedulerOidcAllowedEmails + }).authorize, handler: reminderJobs.handle } - : undefined + : reminderJobs + ? { + authorize: createSchedulerRequestAuthorizer({ + oidcAllowedEmails: runtime.schedulerOidcAllowedEmails + }).authorize, + handler: reminderJobs.handle + } + : undefined }) if (import.meta.main) { diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts index a4cbb0a..19618e3 100644 --- a/apps/bot/src/reminder-jobs.ts +++ b/apps/bot/src/reminder-jobs.ts @@ -62,6 +62,7 @@ export function createReminderJobsHandler(options: { try { const body = await readBody(request) + const schedulerJobName = request.headers.get('x-cloudscheduler-jobname') const period = BillingPeriod.fromString(body.period ?? currentPeriod()).toString() const dryRun = options.forceDryRun === true || body.dryRun === true const result = await options.reminderService.handleJob({ @@ -75,7 +76,7 @@ export function createReminderJobsHandler(options: { event: 'scheduler.reminder.dispatch', reminderType, period, - jobId: body.jobId ?? null, + jobId: body.jobId ?? schedulerJobName ?? null, dedupeKey: result.dedupeKey, outcome: result.status, dryRun @@ -85,7 +86,7 @@ export function createReminderJobsHandler(options: { return json({ ok: true, - jobId: body.jobId ?? null, + jobId: body.jobId ?? schedulerJobName ?? null, reminderType, period, dedupeKey: result.dedupeKey, diff --git a/apps/bot/src/scheduler-auth.test.ts b/apps/bot/src/scheduler-auth.test.ts new file mode 100644 index 0000000..f63120d --- /dev/null +++ b/apps/bot/src/scheduler-auth.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test' + +import { createSchedulerRequestAuthorizer, type IdTokenVerifier } from './scheduler-auth' + +describe('createSchedulerRequestAuthorizer', () => { + test('accepts matching shared secret header', async () => { + const authorizer = createSchedulerRequestAuthorizer({ + sharedSecret: 'secret' + }) + + const authorized = await authorizer.authorize( + new Request('http://localhost/jobs/reminder/utilities', { + headers: { + 'x-household-scheduler-secret': 'secret' + } + }) + ) + + expect(authorized).toBe(true) + }) + + test('accepts verified oidc token from an allowed service account', async () => { + const verifier: IdTokenVerifier = { + verifyIdToken: async () => ({ + getPayload: () => ({ + email: 'dev-scheduler@example.iam.gserviceaccount.com', + email_verified: true + }) + }) + } + + const authorizer = createSchedulerRequestAuthorizer({ + oidcAudience: 'https://household-dev-bot-api.run.app', + oidcAllowedEmails: ['dev-scheduler@example.iam.gserviceaccount.com'], + verifier + }) + + const authorized = await authorizer.authorize( + new Request('http://localhost/jobs/reminder/utilities', { + headers: { + authorization: 'Bearer signed-id-token' + } + }) + ) + + expect(authorized).toBe(true) + }) + + test('rejects oidc token from an unexpected service account', async () => { + const verifier: IdTokenVerifier = { + verifyIdToken: async () => ({ + getPayload: () => ({ + email: 'someone-else@example.iam.gserviceaccount.com', + email_verified: true + }) + }) + } + + const authorizer = createSchedulerRequestAuthorizer({ + oidcAudience: 'https://household-dev-bot-api.run.app', + oidcAllowedEmails: ['dev-scheduler@example.iam.gserviceaccount.com'], + verifier + }) + + const authorized = await authorizer.authorize( + new Request('http://localhost/jobs/reminder/utilities', { + headers: { + authorization: 'Bearer signed-id-token' + } + }) + ) + + expect(authorized).toBe(false) + }) +}) diff --git a/apps/bot/src/scheduler-auth.ts b/apps/bot/src/scheduler-auth.ts new file mode 100644 index 0000000..981a1c3 --- /dev/null +++ b/apps/bot/src/scheduler-auth.ts @@ -0,0 +1,81 @@ +import { OAuth2Client } from 'google-auth-library' + +interface IdTokenPayload { + email?: string + email_verified?: boolean +} + +interface IdTokenTicket { + getPayload(): IdTokenPayload | undefined +} + +export interface IdTokenVerifier { + verifyIdToken(input: { idToken: string; audience: string }): Promise +} + +const DEFAULT_VERIFIER: IdTokenVerifier = new OAuth2Client() + +function bearerToken(request: Request): string | null { + const header = request.headers.get('authorization') + + if (!header?.startsWith('Bearer ')) { + return null + } + + const token = header.slice('Bearer '.length).trim() + return token.length > 0 ? token : null +} + +export function createSchedulerRequestAuthorizer(options: { + sharedSecret?: string + oidcAudience?: string + oidcAllowedEmails?: readonly string[] + verifier?: IdTokenVerifier +}): { + authorize: (request: Request) => Promise +} { + const sharedSecret = options.sharedSecret?.trim() + const oidcAudience = options.oidcAudience?.trim() + const allowedEmails = new Set( + (options.oidcAllowedEmails ?? []).map((email) => email.trim()).filter(Boolean) + ) + const verifier = options.verifier ?? DEFAULT_VERIFIER + + return { + authorize: async (request) => { + const customHeader = request.headers.get('x-household-scheduler-secret') + if (sharedSecret && customHeader === sharedSecret) { + return true + } + + const token = bearerToken(request) + if (!token) { + return false + } + + if (sharedSecret && token === sharedSecret) { + return true + } + + if (!oidcAudience || allowedEmails.size === 0) { + if (allowedEmails.size === 0) { + return false + } + } + + try { + const audience = oidcAudience ?? new URL(request.url).origin + const ticket = await verifier.verifyIdToken({ + idToken: token, + audience + }) + const payload = ticket.getPayload() + const email = payload?.email?.trim() + + return payload?.email_verified === true && email !== undefined && allowedEmails.has(email) + } catch { + return false + } + } + } +} diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index f820a34..b747349 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -8,7 +8,8 @@ describe('createBotWebhookServer', () => { webhookSecret: 'secret-token', webhookHandler: async () => new Response('ok', { status: 200 }), scheduler: { - sharedSecret: 'scheduler-secret', + authorize: async (request) => + request.headers.get('x-household-scheduler-secret') === 'scheduler-secret', handler: async (_request, reminderType) => new Response(JSON.stringify({ ok: true, reminderType }), { status: 200, diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index b08a670..d8c3f9d 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -5,7 +5,7 @@ export interface BotWebhookServerOptions { scheduler?: | { pathPrefix?: string - sharedSecret: string + authorize: (request: Request) => Promise handler: (request: Request, reminderType: string) => Promise } | undefined @@ -26,13 +26,6 @@ function isAuthorized(request: Request, expectedSecret: string): boolean { return secretHeader === expectedSecret } -function isSchedulerAuthorized(request: Request, expectedSecret: string): boolean { - const customHeader = request.headers.get('x-household-scheduler-secret') - const authorizationHeader = request.headers.get('authorization') - - return customHeader === expectedSecret || authorizationHeader === `Bearer ${expectedSecret}` -} - export function createBotWebhookServer(options: BotWebhookServerOptions): { fetch: (request: Request) => Promise } { @@ -57,7 +50,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return new Response('Method Not Allowed', { status: 405 }) } - if (!isSchedulerAuthorized(request, options.scheduler!.sharedSecret)) { + if (!(await options.scheduler!.authorize(request))) { return new Response('Unauthorized', { status: 401 }) } diff --git a/bun.lock b/bun.lock index 4cdaff4..1270718 100644 --- a/bun.lock +++ b/bun.lock @@ -19,8 +19,8 @@ "@household/adapters-db": "workspace:*", "@household/application": "workspace:*", "@household/db": "workspace:*", - "@household/ports": "workspace:*", "drizzle-orm": "^0.44.7", + "google-auth-library": "^10.4.1", "grammy": "1.41.1", }, }, @@ -213,6 +213,8 @@ "@household/scripts": ["@household/scripts@workspace:scripts"], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -263,6 +265,8 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.51.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], @@ -403,24 +407,48 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="], "babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -429,8 +457,14 @@ "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="], + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -443,30 +477,62 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "google-auth-library": ["google-auth-library@10.6.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "7.1.3", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "grammy": ["grammy@1.41.1", "", { "dependencies": { "@grammyjs/types": "3.25.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ=="], "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "lefthook": ["lefthook@2.1.2", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.2", "lefthook-darwin-x64": "2.1.2", "lefthook-freebsd-arm64": "2.1.2", "lefthook-freebsd-x64": "2.1.2", "lefthook-linux-arm64": "2.1.2", "lefthook-linux-x64": "2.1.2", "lefthook-openbsd-arm64": "2.1.2", "lefthook-openbsd-x64": "2.1.2", "lefthook-windows-arm64": "2.1.2", "lefthook-windows-x64": "2.1.2" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-HdAMl4g47kbWSkrUkCx3Kucq54omFS6piMJtXwXNtmCAfB40UaybTJuYtFW4hNzZ5SvaEimtxTp7P/MNIkEfsA=="], "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AgHu93YuJtj1l9bcKlCbo4Tg8N8xFl9iD6BjXCGaGMu46LSjFiXbJFlkUdpgrL8fIbwoCjJi5FNp3POpqs4Wdw=="], @@ -519,18 +585,30 @@ "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], "oxlint": ["oxlint@1.51.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.51.0", "@oxlint/binding-android-arm64": "1.51.0", "@oxlint/binding-darwin-arm64": "1.51.0", "@oxlint/binding-darwin-x64": "1.51.0", "@oxlint/binding-freebsd-x64": "1.51.0", "@oxlint/binding-linux-arm-gnueabihf": "1.51.0", "@oxlint/binding-linux-arm-musleabihf": "1.51.0", "@oxlint/binding-linux-arm64-gnu": "1.51.0", "@oxlint/binding-linux-arm64-musl": "1.51.0", "@oxlint/binding-linux-ppc64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-musl": "1.51.0", "@oxlint/binding-linux-s390x-gnu": "1.51.0", "@oxlint/binding-linux-x64-gnu": "1.51.0", "@oxlint/binding-linux-x64-musl": "1.51.0", "@oxlint/binding-openharmony-arm64": "1.51.0", "@oxlint/binding-win32-arm64-msvc": "1.51.0", "@oxlint/binding-win32-ia32-msvc": "1.51.0", "@oxlint/binding-win32-x64-msvc": "1.51.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -541,14 +619,24 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "solid-devtools": ["solid-devtools@0.34.5", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.6", "@solid-devtools/debugger": "^0.28.1", "@solid-devtools/shared": "^0.20.0" }, "peerDependencies": { "solid-js": "^1.9.0", "vite": "^2.2.3 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["vite"] }, "sha512-KNVdS9MQzzeVS++Vmg4JeU0fM6ZMuBEmkBA7SmqPS2s5UHpRjv1PNH8gShmlN9L/tki6OUAzJP3H1aKq2AcOSg=="], "solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="], @@ -561,6 +649,14 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -581,10 +677,18 @@ "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -605,8 +709,24 @@ "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -651,6 +771,8 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -702,5 +824,9 @@ "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/docs/runbooks/iac-terraform.md b/docs/runbooks/iac-terraform.md index 8b63a6c..8e2ef5c 100644 --- a/docs/runbooks/iac-terraform.md +++ b/docs/runbooks/iac-terraform.md @@ -50,6 +50,27 @@ Keep bot runtime config that is not secret in your `*.tfvars` file: - `bot_purchase_topic_id` - optional `bot_parser_model` +## Reminder jobs + +Terraform provisions three separate Cloud Scheduler jobs: + +- `utilities` +- `rent-warning` +- `rent-due` + +They target the bot runtime endpoints: + +- `/jobs/reminder/utilities` +- `/jobs/reminder/rent-warning` +- `/jobs/reminder/rent-due` + +Recommended rollout: + +- keep `scheduler_paused = true` and `scheduler_dry_run = true` on first apply +- validate job responses and logs +- unpause when the delivery side is ready +- disable dry-run only after production verification + ## Environment strategy - Keep separate states for `dev` and `prod`. diff --git a/docs/specs/HOUSEBOT-030-cloud-scheduler-jobs.md b/docs/specs/HOUSEBOT-030-cloud-scheduler-jobs.md new file mode 100644 index 0000000..13c6f44 --- /dev/null +++ b/docs/specs/HOUSEBOT-030-cloud-scheduler-jobs.md @@ -0,0 +1,59 @@ +# HOUSEBOT-030: Cloud Scheduler Reminder Jobs + +## Summary + +Provision dedicated Cloud Scheduler jobs for the three reminder flows and align runtime auth with Cloud Scheduler OIDC tokens. + +## Goals + +- Provision separate scheduler jobs for utilities, rent warning, and rent due reminders. +- Target the runtime reminder endpoints added in `HOUSEBOT-031`. +- Keep first rollout safe with paused and dry-run controls. + +## Non-goals + +- Final live Telegram reminder delivery content. +- Per-household scheduler customization beyond cron variables. + +## Scope + +- In: Terraform scheduler resources, runtime OIDC config, runbook updates. +- Out: production cutover checklist and final enablement procedure. + +## Interfaces and Contracts + +- Cloud Scheduler jobs: + - `/jobs/reminder/utilities` + - `/jobs/reminder/rent-warning` + - `/jobs/reminder/rent-due` +- Runtime env: + - `SCHEDULER_OIDC_ALLOWED_EMAILS` + +## Domain Rules + +- Utility reminder defaults to day 4 at 09:00 `Asia/Tbilisi`, but remains cron-configurable. +- Rent warning defaults to day 17 at 09:00 `Asia/Tbilisi`. +- Rent due defaults to day 20 at 09:00 `Asia/Tbilisi`. +- Initial rollout should support dry-run mode. + +## Security and Privacy + +- Cloud Scheduler uses OIDC token auth with the scheduler service account. +- Runtime verifies the OIDC audience and the allowed service account email. +- Shared secret auth remains available for manual/dev invocation. + +## Observability + +- Scheduler request payloads include a stable `jobId`. +- Runtime logs include `jobId`, `dedupeKey`, and outcome. + +## Test Plan + +- Runtime auth unit tests for shared-secret and OIDC paths. +- Terraform validation for reminder job resources. + +## Acceptance Criteria + +- [ ] Three scheduler jobs are provisioned with distinct schedules. +- [ ] Runtime accepts Cloud Scheduler OIDC calls for those jobs. +- [ ] Initial rollout can remain paused and dry-run. diff --git a/infra/terraform/README.md b/infra/terraform/README.md index 2ecd84b..2756978 100644 --- a/infra/terraform/README.md +++ b/infra/terraform/README.md @@ -7,7 +7,7 @@ This directory contains baseline IaC for deploying the household bot platform on - Artifact Registry Docker repository - Cloud Run service: bot API (public webhook endpoint) - Cloud Run service: mini app (public web UI) -- Cloud Scheduler job for reminder triggers +- Cloud Scheduler jobs for reminder triggers - Runtime and scheduler service accounts with least-privilege bindings - Secret Manager secrets (IDs only, secret values are added separately) - Optional GitHub OIDC Workload Identity setup for deploy automation @@ -16,7 +16,7 @@ This directory contains baseline IaC for deploying the household bot platform on - `bot-api`: Telegram webhook + app API endpoints - `mini-app`: front-end delivery -- `scheduler`: triggers `bot-api` internal reminder endpoint using OIDC token +- `scheduler`: triggers `bot-api` reminder endpoints using OIDC tokens ## Prerequisites @@ -84,5 +84,5 @@ CI runs: ## Notes -- Scheduler job defaults to `paused = true` to prevent accidental sends before app logic is ready. +- Scheduler jobs default to `paused = true` and `dry_run = true` to prevent accidental sends before live reminder delivery is ready. - Bot API is public to accept Telegram webhooks; scheduler endpoint should still verify app-level auth. diff --git a/infra/terraform/locals.tf b/infra/terraform/locals.tf index aff78cb..160a629 100644 --- a/infra/terraform/locals.tf +++ b/infra/terraform/locals.tf @@ -12,6 +12,21 @@ locals { artifact_location = coalesce(var.artifact_repository_location, var.region) + reminder_jobs = { + utilities = { + schedule = var.scheduler_utilities_cron + path = "/jobs/reminder/utilities" + } + rent-warning = { + schedule = var.scheduler_rent_warning_cron + path = "/jobs/reminder/rent-warning" + } + rent-due = { + schedule = var.scheduler_rent_due_cron + path = "/jobs/reminder/rent-due" + } + } + runtime_secret_ids = toset(compact([ var.telegram_webhook_secret_id, var.scheduler_shared_secret_id, diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 2c3538c..be0eb94 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -92,6 +92,9 @@ module "bot_api_service" { }, var.bot_parser_model == null ? {} : { PARSER_MODEL = var.bot_parser_model + }, + { + SCHEDULER_OIDC_ALLOWED_EMAILS = google_service_account.scheduler_invoker.email } ) @@ -158,22 +161,27 @@ resource "google_service_account_iam_member" "scheduler_token_creator" { } resource "google_cloud_scheduler_job" "reminders" { + for_each = local.reminder_jobs + project = var.project_id region = var.region - name = "${local.name_prefix}-reminders" - schedule = var.scheduler_cron + name = "${local.name_prefix}-${each.key}" + schedule = each.value.schedule time_zone = var.scheduler_timezone paused = var.scheduler_paused http_target { - uri = "${module.bot_api_service.uri}${var.scheduler_path}" - http_method = var.scheduler_http_method + uri = "${module.bot_api_service.uri}${each.value.path}" + http_method = "POST" headers = { "Content-Type" = "application/json" } - body = base64encode(var.scheduler_body_json) + body = base64encode(jsonencode({ + dryRun = var.scheduler_dry_run + jobId = "${local.name_prefix}-${each.key}" + })) oidc_token { service_account_email = google_service_account.scheduler_invoker.email diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index cb6aa63..985fab2 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -23,9 +23,9 @@ output "mini_app_service_url" { value = module.mini_app_service.uri } -output "scheduler_job_name" { - description = "Cloud Scheduler job for reminders" - value = google_cloud_scheduler_job.reminders.name +output "scheduler_job_names" { + description = "Cloud Scheduler jobs for reminders" + value = { for name, job in google_cloud_scheduler_job.reminders : name => job.name } } output "runtime_secret_ids" { diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example index a3cb7fd..e8c3efd 100644 --- a/infra/terraform/terraform.tfvars.example +++ b/infra/terraform/terraform.tfvars.example @@ -13,9 +13,12 @@ bot_household_chat_id = "-1001234567890" bot_purchase_topic_id = 777 bot_parser_model = "gpt-4.1-mini" -scheduler_cron = "0 9 * * *" -scheduler_timezone = "Asia/Tbilisi" -scheduler_paused = true +scheduler_utilities_cron = "0 9 4 * *" +scheduler_rent_warning_cron = "0 9 17 * *" +scheduler_rent_due_cron = "0 9 20 * *" +scheduler_timezone = "Asia/Tbilisi" +scheduler_paused = true +scheduler_dry_run = true create_workload_identity = true github_repository = "whekin/household-bot" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 4be4fa3..2d7b508 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -118,35 +118,34 @@ variable "openai_api_key_secret_id" { nullable = true } - -variable "scheduler_path" { - description = "Reminder endpoint path on bot API" - type = string - default = "/internal/scheduler/reminders" -} - -variable "scheduler_http_method" { - description = "Scheduler HTTP method" - type = string - default = "POST" -} - -variable "scheduler_cron" { - description = "Cron expression for reminder scheduler" - type = string - default = "0 9 * * *" -} - variable "scheduler_timezone" { description = "Scheduler timezone" type = string default = "Asia/Tbilisi" } -variable "scheduler_body_json" { - description = "JSON payload for scheduler requests" +variable "scheduler_utilities_cron" { + description = "Cron expression for the utilities reminder scheduler job" type = string - default = "{\"kind\":\"monthly-reminder\"}" + default = "0 9 4 * *" +} + +variable "scheduler_rent_warning_cron" { + description = "Cron expression for the rent warning scheduler job" + type = string + default = "0 9 17 * *" +} + +variable "scheduler_rent_due_cron" { + description = "Cron expression for the rent due scheduler job" + type = string + default = "0 9 20 * *" +} + +variable "scheduler_dry_run" { + description = "Whether scheduler jobs should invoke the bot in dry-run mode" + type = bool + default = true } variable "scheduler_paused" { diff --git a/scripts/e2e/billing-flow.ts b/scripts/e2e/billing-flow.ts index d67b799..3951a13 100644 --- a/scripts/e2e/billing-flow.ts +++ b/scripts/e2e/billing-flow.ts @@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto' import { eq } from 'drizzle-orm' +import { createFinanceCommandService } from '@household/application' +import { createDbFinanceRepository } from '@household/adapters-db' import { createDbClient, schema } from '@household/db' import { createTelegramBot } from '../../apps/bot/src/bot' @@ -129,7 +131,7 @@ async function run(): Promise { let coreClient: ReturnType | undefined let ingestionClient: ReturnType | undefined - let financeService: ReturnType | undefined + let financeRepositoryClient: ReturnType | undefined const bot = createTelegramBot('000000:test-token') const replies: string[] = [] @@ -178,9 +180,9 @@ async function run(): Promise { }) ingestionClient = createPurchaseMessageRepository(databaseUrl) - financeService = createFinanceCommandsService(databaseUrl, { - householdId: ids.household - }) + financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household) + const financeService = createFinanceCommandService(financeRepositoryClient.repository) + const financeCommands = createFinanceCommandsService(financeService) registerPurchaseTopicIngestion( bot, @@ -192,7 +194,7 @@ async function run(): Promise { ingestionClient.repository ) - financeService.register(bot) + financeCommands.register(bot) await coreClient.db.insert(schema.households).values({ id: ids.household, @@ -336,7 +338,7 @@ async function run(): Promise { : undefined, coreClient?.queryClient.end({ timeout: 5 }), ingestionClient?.close(), - financeService?.close() + financeRepositoryClient?.close() ]) } } From f8478b717bbaece8ac809238e9089d32428663bf Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 22:30:59 +0400 Subject: [PATCH 04/10] feat(miniapp): add telegram-authenticated shell --- apps/bot/src/config.ts | 6 + apps/bot/src/index.ts | 23 +- apps/bot/src/miniapp-auth.test.ts | 127 +++++++++++ apps/bot/src/miniapp-auth.ts | 117 ++++++++++ apps/bot/src/server.test.ts | 24 ++ apps/bot/src/server.ts | 11 + apps/bot/src/telegram-miniapp-auth.test.ts | 70 ++++++ apps/bot/src/telegram-miniapp-auth.ts | 85 +++++++ apps/miniapp/Dockerfile | 5 + apps/miniapp/config.template.js | 3 + apps/miniapp/index.html | 5 +- apps/miniapp/src/App.tsx | 253 ++++++++++++++++++++- apps/miniapp/src/i18n.ts | 80 +++++++ apps/miniapp/src/index.css | 230 +++++++++++++++++++ apps/miniapp/src/miniapp-api.ts | 66 ++++++ apps/miniapp/src/runtime-config.ts | 13 ++ apps/miniapp/src/telegram-webapp.ts | 27 +++ apps/miniapp/src/vite-env.d.ts | 9 + docs/specs/HOUSEBOT-040-miniapp-shell.md | 60 +++++ infra/terraform/main.tf | 3 +- 20 files changed, 1205 insertions(+), 12 deletions(-) create mode 100644 apps/bot/src/miniapp-auth.test.ts create mode 100644 apps/bot/src/miniapp-auth.ts create mode 100644 apps/bot/src/telegram-miniapp-auth.test.ts create mode 100644 apps/bot/src/telegram-miniapp-auth.ts create mode 100644 apps/miniapp/config.template.js create mode 100644 apps/miniapp/src/i18n.ts create mode 100644 apps/miniapp/src/miniapp-api.ts create mode 100644 apps/miniapp/src/runtime-config.ts create mode 100644 apps/miniapp/src/telegram-webapp.ts create mode 100644 apps/miniapp/src/vite-env.d.ts create mode 100644 docs/specs/HOUSEBOT-040-miniapp-shell.md diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index 3a7e07b..f399042 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -9,6 +9,8 @@ export interface BotRuntimeConfig { telegramPurchaseTopicId?: number purchaseTopicIngestionEnabled: boolean financeCommandsEnabled: boolean + miniAppAllowedOrigins: readonly string[] + miniAppAuthEnabled: boolean schedulerSharedSecret?: string schedulerOidcAllowedEmails: readonly string[] reminderJobsEnabled: boolean @@ -75,6 +77,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID) const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET) const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS) + const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS) const purchaseTopicIngestionEnabled = databaseUrl !== undefined && @@ -83,6 +86,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramPurchaseTopicId !== undefined const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined + const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 const reminderJobsEnabled = databaseUrl !== undefined && @@ -96,6 +100,8 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram', purchaseTopicIngestionEnabled, financeCommandsEnabled, + miniAppAllowedOrigins, + miniAppAuthEnabled, schedulerOidcAllowedEmails, reminderJobsEnabled, parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini' diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 09dee1b..a05fd67 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -17,12 +17,21 @@ import { import { createReminderJobsHandler } from './reminder-jobs' import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createBotWebhookServer } from './server' +import { createMiniAppAuthHandler } from './miniapp-auth' const runtime = getBotRuntimeConfig() const bot = createTelegramBot(runtime.telegramBotToken) const webhookHandler = webhookCallback(bot, 'std/http') const shutdownTasks: Array<() => Promise> = [] +const financeRepositoryClient = + runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled + ? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!) + : null + +if (financeRepositoryClient) { + shutdownTasks.push(financeRepositoryClient.close) +} if (runtime.purchaseTopicIngestionEnabled) { const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) @@ -50,15 +59,10 @@ if (runtime.purchaseTopicIngestionEnabled) { } if (runtime.financeCommandsEnabled) { - const financeRepositoryClient = createDbFinanceRepository( - runtime.databaseUrl!, - runtime.householdId! - ) - const financeService = createFinanceCommandService(financeRepositoryClient.repository) + const financeService = createFinanceCommandService(financeRepositoryClient!.repository) const financeCommands = createFinanceCommandsService(financeService) financeCommands.register(bot) - shutdownTasks.push(financeRepositoryClient.close) } else { console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.') } @@ -87,6 +91,13 @@ const server = createBotWebhookServer({ webhookPath: runtime.telegramWebhookPath, webhookSecret: runtime.telegramWebhookSecret, webhookHandler, + miniAppAuth: financeRepositoryClient + ? createMiniAppAuthHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + repository: financeRepositoryClient.repository + }) + : undefined, scheduler: reminderJobs && runtime.schedulerSharedSecret ? { diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts new file mode 100644 index 0000000..adc669a --- /dev/null +++ b/apps/bot/src/miniapp-auth.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test' +import { createHmac } from 'node:crypto' + +import type { FinanceRepository } from '@household/ports' + +import { createMiniAppAuthHandler } from './miniapp-auth' + +function buildInitData(botToken: string, authDate: number, user: object): string { + const params = new URLSearchParams() + params.set('auth_date', authDate.toString()) + params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc') + params.set('user', JSON.stringify(user)) + + const dataCheckString = [...params.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + + const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() + const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') + params.set('hash', hash) + + return params.toString() +} + +function repository( + member: Awaited> +): FinanceRepository { + return { + getMemberByTelegramUserId: async () => member, + listMembers: async () => [], + getOpenCycle: async () => null, + getCycleByPeriod: async () => null, + getLatestCycle: async () => null, + openCycle: async () => {}, + closeCycle: async () => {}, + saveRentRule: async () => {}, + addUtilityBill: async () => {}, + getRentRuleForPeriod: async () => null, + getUtilityTotalForCycle: async () => 0n, + listParsedPurchasesForRange: async () => [], + replaceSettlementSnapshot: async () => {} + } +} + +describe('createMiniAppAuthHandler', () => { + test('returns an authorized session for a household member', async () => { + const authDate = Math.floor(Date.now() / 1000) + const auth = createMiniAppAuthHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + repository: repository({ + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + }) + }) + + const response = await auth.handler( + new Request('http://localhost/api/miniapp/session', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan', + username: 'stanislav', + language_code: 'ru' + }) + }) + }) + ) + + expect(response.status).toBe(200) + expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:5173') + expect(await response.json()).toMatchObject({ + ok: true, + authorized: true, + member: { + displayName: 'Stan', + isAdmin: true + }, + telegramUser: { + id: '123456', + firstName: 'Stan', + username: 'stanislav', + languageCode: 'ru' + } + }) + }) + + test('returns membership gate failure for a non-member', async () => { + const authDate = Math.floor(Date.now() / 1000) + const auth = createMiniAppAuthHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + repository: repository(null) + }) + + const response = await auth.handler( + new Request('http://localhost/api/miniapp/session', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan' + }) + }) + }) + ) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + ok: true, + authorized: false, + reason: 'not_member' + }) + }) +}) diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts new file mode 100644 index 0000000..035d701 --- /dev/null +++ b/apps/bot/src/miniapp-auth.ts @@ -0,0 +1,117 @@ +import type { FinanceRepository } from '@household/ports' + +import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' + +function json(body: object, status = 200, origin?: string): Response { + const headers = new Headers({ + 'content-type': 'application/json; charset=utf-8' + }) + + if (origin) { + headers.set('access-control-allow-origin', origin) + headers.set('access-control-allow-methods', 'POST, OPTIONS') + headers.set('access-control-allow-headers', 'content-type') + headers.set('vary', 'origin') + } + + return new Response(JSON.stringify(body), { + status, + headers + }) +} + +function allowedOrigin(request: Request, allowedOrigins: readonly string[]): string | undefined { + const origin = request.headers.get('origin') + + if (!origin) { + return undefined + } + + if (allowedOrigins.length === 0) { + return origin + } + + return allowedOrigins.includes(origin) ? origin : undefined +} + +async function readInitData(request: Request): Promise { + const text = await request.text() + + if (text.trim().length === 0) { + return null + } + + const parsed = JSON.parse(text) as { initData?: string } + const initData = parsed.initData?.trim() + + return initData && initData.length > 0 ? initData : null +} + +export function createMiniAppAuthHandler(options: { + allowedOrigins: readonly string[] + botToken: string + repository: FinanceRepository +}): { + handler: (request: Request) => Promise +} { + return { + handler: async (request) => { + const origin = allowedOrigin(request, options.allowedOrigins) + + if (request.method === 'OPTIONS') { + return json({ ok: true }, 204, origin) + } + + if (request.method !== 'POST') { + return json({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const initData = await readInitData(request) + if (!initData) { + return json({ ok: false, error: 'Missing initData' }, 400, origin) + } + + const telegramUser = verifyTelegramMiniAppInitData(initData, options.botToken) + if (!telegramUser) { + return json({ ok: false, error: 'Invalid Telegram init data' }, 401, origin) + } + + const member = await options.repository.getMemberByTelegramUserId(telegramUser.id) + if (!member) { + return json( + { + ok: true, + authorized: false, + reason: 'not_member' + }, + 403, + origin + ) + } + + return json( + { + ok: true, + authorized: true, + member: { + id: member.id, + displayName: member.displayName, + isAdmin: member.isAdmin + }, + telegramUser, + features: { + balances: false, + ledger: false + } + }, + 200, + origin + ) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown mini app auth error' + return json({ ok: false, error: message }, 400, origin) + } + } + } +} diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index b747349..83a2ab2 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -7,6 +7,15 @@ describe('createBotWebhookServer', () => { webhookPath: '/webhook/telegram', webhookSecret: 'secret-token', webhookHandler: async () => new Response('ok', { status: 200 }), + miniAppAuth: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, scheduler: { authorize: async (request) => request.headers.get('x-household-scheduler-secret') === 'scheduler-secret', @@ -71,6 +80,21 @@ describe('createBotWebhookServer', () => { expect(await response.text()).toBe('ok') }) + test('accepts mini app auth request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/session', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true + }) + }) + test('rejects scheduler request with missing secret', async () => { const response = await server.fetch( new Request('http://localhost/jobs/reminder/utilities', { diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index d8c3f9d..171feaa 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -2,6 +2,12 @@ export interface BotWebhookServerOptions { webhookPath: string webhookSecret: string webhookHandler: (request: Request) => Promise | Response + miniAppAuth?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined scheduler?: | { pathPrefix?: string @@ -32,6 +38,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { const normalizedWebhookPath = options.webhookPath.startsWith('/') ? options.webhookPath : `/${options.webhookPath}` + const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session' const schedulerPathPrefix = options.scheduler ? (options.scheduler.pathPrefix ?? '/jobs/reminder') : null @@ -44,6 +51,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return json({ ok: true }) } + if (options.miniAppAuth && url.pathname === miniAppAuthPath) { + return await options.miniAppAuth.handler(request) + } + if (url.pathname !== normalizedWebhookPath) { if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) { if (request.method !== 'POST') { diff --git a/apps/bot/src/telegram-miniapp-auth.test.ts b/apps/bot/src/telegram-miniapp-auth.test.ts new file mode 100644 index 0000000..45e53f1 --- /dev/null +++ b/apps/bot/src/telegram-miniapp-auth.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' +import { createHmac } from 'node:crypto' + +import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' + +function buildInitData(botToken: string, authDate: number, user: object): string { + const params = new URLSearchParams() + params.set('auth_date', authDate.toString()) + params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc') + params.set('user', JSON.stringify(user)) + + const dataCheckString = [...params.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + + const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() + const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') + params.set('hash', hash) + + return params.toString() +} + +describe('verifyTelegramMiniAppInitData', () => { + test('verifies valid init data and extracts user payload', () => { + const now = new Date('2026-03-08T12:00:00.000Z') + const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), { + id: 123456, + first_name: 'Stan', + username: 'stanislav' + }) + + const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now) + + expect(result).toEqual({ + id: '123456', + firstName: 'Stan', + lastName: null, + username: 'stanislav', + languageCode: null + }) + }) + + test('rejects invalid hash', () => { + const now = new Date('2026-03-08T12:00:00.000Z') + const params = new URLSearchParams( + buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), { + id: 123456, + first_name: 'Stan' + }) + ) + params.set('hash', '0'.repeat(64)) + + const result = verifyTelegramMiniAppInitData(params.toString(), 'test-bot-token', now) + + expect(result).toBeNull() + }) + + test('rejects expired init data', () => { + const now = new Date('2026-03-08T12:00:00.000Z') + const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000) - 7200, { + id: 123456, + first_name: 'Stan' + }) + + const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600) + + expect(result).toBeNull() + }) +}) diff --git a/apps/bot/src/telegram-miniapp-auth.ts b/apps/bot/src/telegram-miniapp-auth.ts new file mode 100644 index 0000000..fe543b9 --- /dev/null +++ b/apps/bot/src/telegram-miniapp-auth.ts @@ -0,0 +1,85 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' + +interface TelegramUserPayload { + id: number + first_name?: string + last_name?: string + username?: string + language_code?: string +} + +export interface VerifiedMiniAppUser { + id: string + firstName: string | null + lastName: string | null + username: string | null + languageCode: string | null +} + +export function verifyTelegramMiniAppInitData( + initData: string, + botToken: string, + now = new Date(), + maxAgeSeconds = 3600 +): VerifiedMiniAppUser | null { + const params = new URLSearchParams(initData) + const hash = params.get('hash') + + if (!hash) { + return null + } + + const authDateRaw = params.get('auth_date') + if (!authDateRaw || !/^\d+$/.test(authDateRaw)) { + return null + } + + const authDateSeconds = Number(authDateRaw) + const nowSeconds = Math.floor(now.getTime() / 1000) + if (Math.abs(nowSeconds - authDateSeconds) > maxAgeSeconds) { + return null + } + + const userRaw = params.get('user') + if (!userRaw) { + return null + } + + const payloadEntries = [...params.entries()] + .filter(([key]) => key !== 'hash') + .sort(([left], [right]) => left.localeCompare(right)) + + const dataCheckString = payloadEntries.map(([key, value]) => `${key}=${value}`).join('\n') + const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() + const expectedHash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') + + const expectedBuffer = Buffer.from(expectedHash, 'hex') + const actualBuffer = Buffer.from(hash, 'hex') + + if (expectedBuffer.length !== actualBuffer.length) { + return null + } + + if (!timingSafeEqual(expectedBuffer, actualBuffer)) { + return null + } + + let parsedUser: TelegramUserPayload + try { + parsedUser = JSON.parse(userRaw) as TelegramUserPayload + } catch { + return null + } + + if (!Number.isInteger(parsedUser.id) || parsedUser.id <= 0) { + return null + } + + return { + id: parsedUser.id.toString(), + firstName: parsedUser.first_name?.trim() || null, + lastName: parsedUser.last_name?.trim() || null, + username: parsedUser.username?.trim() || null, + languageCode: parsedUser.language_code?.trim() || null + } +} diff --git a/apps/miniapp/Dockerfile b/apps/miniapp/Dockerfile index b6a79f7..3590bca 100644 --- a/apps/miniapp/Dockerfile +++ b/apps/miniapp/Dockerfile @@ -26,10 +26,15 @@ RUN bun run --filter @household/miniapp build FROM nginx:1.27-alpine AS runtime +ENV BOT_API_URL="" + COPY apps/miniapp/nginx.conf /etc/nginx/conf.d/default.conf +COPY apps/miniapp/config.template.js /usr/share/nginx/html/config.template.js COPY --from=build /app/apps/miniapp/dist /usr/share/nginx/html EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1 + +CMD ["/bin/sh", "-c", "envsubst < /usr/share/nginx/html/config.template.js > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"] diff --git a/apps/miniapp/config.template.js b/apps/miniapp/config.template.js new file mode 100644 index 0000000..7e7a477 --- /dev/null +++ b/apps/miniapp/config.template.js @@ -0,0 +1,3 @@ +window.__HOUSEHOLD_CONFIG__ = { + botApiUrl: '${BOT_API_URL}' +} diff --git a/apps/miniapp/index.html b/apps/miniapp/index.html index 5ede123..decebfd 100644 --- a/apps/miniapp/index.html +++ b/apps/miniapp/index.html @@ -3,13 +3,14 @@ - - Solid App + + Kojori House
+ diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 46a7d2f..3eeb8e1 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -1,8 +1,255 @@ +import { Match, Switch, createMemo, createSignal, onMount } from 'solid-js' + +import { dictionary, type Locale } from './i18n' +import { fetchMiniAppSession } from './miniapp-api' +import { getTelegramWebApp } from './telegram-webapp' + +type SessionState = + | { + status: 'loading' + } + | { + status: 'blocked' + reason: 'not_member' | 'telegram_only' | 'error' + } + | { + status: 'ready' + mode: 'live' | 'demo' + member: { + displayName: string + isAdmin: boolean + } + telegramUser: { + firstName: string | null + username: string | null + languageCode: string | null + } + } + +type NavigationKey = 'home' | 'balances' | 'ledger' | 'house' + +const demoSession: Extract = { + status: 'ready', + mode: 'demo', + member: { + displayName: 'Demo Resident', + isAdmin: false + }, + telegramUser: { + firstName: 'Demo', + username: 'demo_user', + languageCode: 'en' + } +} + +function detectLocale(): Locale { + const telegramLocale = getTelegramWebApp()?.initDataUnsafe?.user?.language_code + const browserLocale = navigator.language.toLowerCase() + + return (telegramLocale ?? browserLocale).startsWith('ru') ? 'ru' : 'en' +} + function App() { + const [locale, setLocale] = createSignal('en') + const [session, setSession] = createSignal({ + status: 'loading' + }) + const [activeNav, setActiveNav] = createSignal('home') + + const copy = createMemo(() => dictionary[locale()]) + const blockedSession = createMemo(() => { + const current = session() + return current.status === 'blocked' ? current : null + }) + const readySession = createMemo(() => { + const current = session() + return current.status === 'ready' ? current : null + }) + const webApp = getTelegramWebApp() + + onMount(async () => { + setLocale(detectLocale()) + + webApp?.ready?.() + webApp?.expand?.() + + const initData = webApp?.initData?.trim() + if (!initData) { + if (import.meta.env.DEV) { + setSession(demoSession) + return + } + + setSession({ + status: 'blocked', + reason: 'telegram_only' + }) + return + } + + try { + const payload = await fetchMiniAppSession(initData) + if (!payload.authorized || !payload.member || !payload.telegramUser) { + setSession({ + status: 'blocked', + reason: payload.reason === 'not_member' ? 'not_member' : 'error' + }) + return + } + + setSession({ + status: 'ready', + mode: 'live', + member: payload.member, + telegramUser: payload.telegramUser + }) + } catch { + if (import.meta.env.DEV) { + setSession(demoSession) + return + } + + setSession({ + status: 'blocked', + reason: 'error' + }) + } + }) + + const renderPanel = () => { + switch (activeNav()) { + case 'balances': + return copy().balancesEmpty + case 'ledger': + return copy().ledgerEmpty + case 'house': + return copy().houseEmpty + default: + return copy().summaryBody + } + } + return ( -
-

Household Mini App

-

SolidJS scaffold is ready

+
+
+
+ +
+
+

{copy().appSubtitle}

+

{copy().appTitle}

+
+ + +
+ + + +
+ {copy().navHint} +

{copy().loadingTitle}

+

{copy().loadingBody}

+
+
+ + +
+ {copy().navHint} +

+ {blockedSession()?.reason === 'telegram_only' + ? copy().telegramOnlyTitle + : copy().unauthorizedTitle} +

+

+ {blockedSession()?.reason === 'telegram_only' + ? copy().telegramOnlyBody + : copy().unauthorizedBody} +

+ +
+
+ + +
+
+ + {readySession()?.mode === 'demo' ? copy().demoBadge : copy().navHint} + + + {readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag} + +
+ +

+ {copy().welcome},{' '} + {readySession()?.telegramUser.firstName ?? readySession()?.member.displayName} +

+

{copy().sectionBody}

+
+ + + +
+
+

{copy().summaryTitle}

+

{readySession()?.member.displayName}

+

{renderPanel()}

+
+ +
+

{copy().cardAccess}

+

{copy().cardAccessBody}

+
+ +
+

{copy().cardLocale}

+

{copy().cardLocaleBody}

+
+ +
+

{copy().cardNext}

+

{copy().cardNextBody}

+
+
+
+
) } diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts new file mode 100644 index 0000000..dc7a680 --- /dev/null +++ b/apps/miniapp/src/i18n.ts @@ -0,0 +1,80 @@ +export type Locale = 'en' | 'ru' + +export const dictionary = { + en: { + appTitle: 'Kojori House', + appSubtitle: 'Shared home dashboard', + loadingTitle: 'Checking your household access', + loadingBody: 'Validating Telegram session and membership…', + demoBadge: 'Demo mode', + unauthorizedTitle: 'Access is limited to active household members', + unauthorizedBody: + 'Open the mini app from Telegram after the bot admin adds you to the household.', + telegramOnlyTitle: 'Open this app from Telegram', + telegramOnlyBody: + 'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.', + reload: 'Retry', + language: 'Language', + home: 'Home', + balances: 'Balances', + ledger: 'Ledger', + house: 'House', + navHint: 'Shell v1', + welcome: 'Welcome back', + adminTag: 'Admin', + residentTag: 'Resident', + summaryTitle: 'Current shell', + summaryBody: + 'Balances, ledger, and house wiki will land in the next tickets. This shell focuses on verified access, navigation, and mobile layout.', + cardAccess: 'Access', + cardAccessBody: 'Telegram identity verified and matched to a household member.', + cardLocale: 'Locale', + cardLocaleBody: 'Switch RU/EN immediately without reloading the shell.', + cardNext: 'Next up', + cardNextBody: 'Balances, ledger, and house pages will plug into this navigation.', + sectionTitle: 'Ready for the next features', + sectionBody: + 'This layout is intentionally narrow and mobile-first so it behaves well inside the Telegram webview.', + balancesEmpty: 'Balances will appear here once the dashboard API lands.', + ledgerEmpty: 'Ledger entries will appear here after the finance view is connected.', + houseEmpty: 'House rules, Wi-Fi info, and practical notes will live here.' + }, + ru: { + appTitle: 'Kojori House', + appSubtitle: 'Панель общего дома', + loadingTitle: 'Проверяем доступ к дому', + loadingBody: 'Проверяем Telegram-сессию и членство…', + demoBadge: 'Демо режим', + unauthorizedTitle: 'Доступ открыт только для активных участников дома', + unauthorizedBody: + 'Открой мини-апп из Telegram после того, как админ бота добавит тебя в household.', + telegramOnlyTitle: 'Открой приложение из Telegram', + telegramOnlyBody: + 'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.', + reload: 'Повторить', + language: 'Язык', + home: 'Главная', + balances: 'Баланс', + ledger: 'Леджер', + house: 'Дом', + navHint: 'Shell v1', + welcome: 'С возвращением', + adminTag: 'Админ', + residentTag: 'Житель', + summaryTitle: 'Текущая оболочка', + summaryBody: + 'Баланс, леджер и вики дома появятся в следующих тикетах. Сейчас приоритет — проверенный доступ, навигация и мобильный layout.', + cardAccess: 'Доступ', + cardAccessBody: 'Telegram-личность подтверждена и сопоставлена с участником household.', + cardLocale: 'Локаль', + cardLocaleBody: 'RU/EN переключаются сразу, без перезагрузки.', + cardNext: 'Дальше', + cardNextBody: 'Баланс, леджер и страницы дома подключатся к этой навигации.', + sectionTitle: 'Основа готова для следующих функций', + sectionBody: + 'Этот layout специально сделан узким и mobile-first, чтобы хорошо жить внутри Telegram webview.', + balancesEmpty: 'Баланс появится здесь, когда подключим dashboard API.', + ledgerEmpty: 'Записи леджера появятся здесь после подключения finance view.', + houseEmpty: 'Правила дома, Wi-Fi и полезные инструкции будут здесь.' + } +} satisfies Record> diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index d4b5078..e5ebdb1 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -1 +1,231 @@ @import 'tailwindcss'; + +:root { + color: #f5efe1; + background: + radial-gradient(circle at top, rgb(225 116 58 / 0.32), transparent 32%), + radial-gradient(circle at bottom left, rgb(79 120 149 / 0.26), transparent 28%), + linear-gradient(180deg, #121a24 0%, #0b1118 100%); + font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: #f5efe1; + background: transparent; +} + +button { + font: inherit; +} + +#root { + min-height: 100vh; +} + +.shell { + position: relative; + min-height: 100vh; + overflow: hidden; + padding: 24px 18px 32px; +} + +.shell__backdrop { + position: absolute; + border-radius: 999px; + filter: blur(12px); + opacity: 0.85; +} + +.shell__backdrop--top { + top: -120px; + right: -60px; + width: 260px; + height: 260px; + background: rgb(237 131 74 / 0.3); +} + +.shell__backdrop--bottom { + bottom: -140px; + left: -80px; + width: 300px; + height: 300px; + background: rgb(87 129 159 / 0.22); +} + +.topbar, +.hero-card, +.nav-grid, +.content-grid { + position: relative; + z-index: 1; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.topbar h1, +.hero-card h2, +.panel h3 { + margin: 0; + font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif; + letter-spacing: -0.04em; +} + +.topbar h1 { + font-size: clamp(2rem, 5vw, 3rem); +} + +.eyebrow { + margin: 0 0 8px; + color: #f7b389; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.locale-switch { + display: grid; + gap: 8px; + min-width: 116px; + color: #d8d6cf; + font-size: 0.82rem; +} + +.locale-switch__buttons { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} + +.locale-switch__buttons button, +.nav-grid button, +.ghost-button { + border: 1px solid rgb(255 255 255 / 0.12); + background: rgb(255 255 255 / 0.04); + color: inherit; + transition: + transform 140ms ease, + border-color 140ms ease, + background 140ms ease; +} + +.locale-switch__buttons button { + border-radius: 999px; + padding: 10px 0; +} + +.locale-switch__buttons button.is-active, +.nav-grid button.is-active { + border-color: rgb(247 179 137 / 0.7); + background: rgb(247 179 137 / 0.14); +} + +.hero-card, +.panel { + border: 1px solid rgb(255 255 255 / 0.1); + background: linear-gradient(180deg, rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.02)); + backdrop-filter: blur(16px); + box-shadow: 0 24px 64px rgb(0 0 0 / 0.22); +} + +.hero-card { + margin-top: 28px; + border-radius: 28px; + padding: 22px; +} + +.hero-card__meta { + display: flex; + gap: 10px; + margin-bottom: 16px; +} + +.hero-card h2 { + font-size: clamp(1.5rem, 4vw, 2.4rem); + margin-bottom: 10px; +} + +.hero-card p, +.panel p { + margin: 0; + color: #d6d3cc; + line-height: 1.55; +} + +.pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 6px 10px; + background: rgb(247 179 137 / 0.14); + color: #ffd5b7; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.pill--muted { + background: rgb(255 255 255 / 0.08); + color: #e5e2d8; +} + +.ghost-button { + margin-top: 18px; + border-radius: 16px; + padding: 12px 16px; +} + +.nav-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 18px; +} + +.nav-grid button { + border-radius: 18px; + padding: 14px 8px; +} + +.content-grid { + display: grid; + gap: 12px; + margin-top: 16px; +} + +.panel { + border-radius: 24px; + padding: 18px; +} + +.panel--wide { + min-height: 170px; +} + +@media (min-width: 760px) { + .shell { + max-width: 920px; + margin: 0 auto; + padding: 32px 24px 40px; + } + + .content-grid { + grid-template-columns: 1.3fr 1fr 1fr; + } + + .panel--wide { + grid-column: 1 / -1; + } +} diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts new file mode 100644 index 0000000..2773cab --- /dev/null +++ b/apps/miniapp/src/miniapp-api.ts @@ -0,0 +1,66 @@ +import { runtimeBotApiUrl } from './runtime-config' + +export interface MiniAppSession { + authorized: boolean + member?: { + displayName: string + isAdmin: boolean + } + telegramUser?: { + firstName: string | null + username: string | null + languageCode: string | null + } + reason?: string +} + +function apiBaseUrl(): string { + const runtimeConfigured = runtimeBotApiUrl() + if (runtimeConfigured) { + return runtimeConfigured.replace(/\/$/, '') + } + + const configured = import.meta.env.VITE_BOT_API_URL?.trim() + + if (configured) { + return configured.replace(/\/$/, '') + } + + if (import.meta.env.DEV) { + return 'http://localhost:3000' + } + + return window.location.origin +} + +export async function fetchMiniAppSession(initData: string): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/session`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + member?: MiniAppSession['member'] + telegramUser?: MiniAppSession['telegramUser'] + reason?: string + error?: string + } + + if (!response.ok) { + throw new Error(payload.error) + } + + return { + authorized: payload.authorized === true, + ...(payload.member ? { member: payload.member } : {}), + ...(payload.telegramUser ? { telegramUser: payload.telegramUser } : {}), + ...(payload.reason ? { reason: payload.reason } : {}) + } +} diff --git a/apps/miniapp/src/runtime-config.ts b/apps/miniapp/src/runtime-config.ts new file mode 100644 index 0000000..a5b12c2 --- /dev/null +++ b/apps/miniapp/src/runtime-config.ts @@ -0,0 +1,13 @@ +declare global { + interface Window { + __HOUSEHOLD_CONFIG__?: { + botApiUrl?: string + } + } +} + +export function runtimeBotApiUrl(): string | undefined { + const configured = window.__HOUSEHOLD_CONFIG__?.botApiUrl?.trim() + + return configured && configured.length > 0 ? configured : undefined +} diff --git a/apps/miniapp/src/telegram-webapp.ts b/apps/miniapp/src/telegram-webapp.ts new file mode 100644 index 0000000..f581e7b --- /dev/null +++ b/apps/miniapp/src/telegram-webapp.ts @@ -0,0 +1,27 @@ +export interface TelegramWebAppUser { + id: number + first_name?: string + username?: string + language_code?: string +} + +export interface TelegramWebApp { + initData: string + initDataUnsafe?: { + user?: TelegramWebAppUser + } + ready?: () => void + expand?: () => void +} + +declare global { + interface Window { + Telegram?: { + WebApp?: TelegramWebApp + } + } +} + +export function getTelegramWebApp(): TelegramWebApp | undefined { + return window.Telegram?.WebApp +} diff --git a/apps/miniapp/src/vite-env.d.ts b/apps/miniapp/src/vite-env.d.ts new file mode 100644 index 0000000..7b83d49 --- /dev/null +++ b/apps/miniapp/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_BOT_API_URL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/docs/specs/HOUSEBOT-040-miniapp-shell.md b/docs/specs/HOUSEBOT-040-miniapp-shell.md new file mode 100644 index 0000000..8e660ef --- /dev/null +++ b/docs/specs/HOUSEBOT-040-miniapp-shell.md @@ -0,0 +1,60 @@ +# HOUSEBOT-040: Mini App Shell with Telegram Auth Gate + +## Summary + +Build the first usable SolidJS mini app shell with a real Telegram initData verification flow and a household membership gate. + +## Goals + +- Verify Telegram mini app initData on the backend. +- Block non-members from entering the mini app shell. +- Provide a bilingual RU/EN shell with navigation ready for later dashboard features. +- Keep local development usable with a demo fallback. + +## Non-goals + +- Full balances and ledger data rendering. +- House wiki content population. +- Production analytics or full design-system work. + +## Scope + +- In: backend auth endpoint, membership lookup, CORS handling, shell layout, locale toggle, runtime bot API URL injection. +- Out: real balances API, ledger API, notification center. + +## Interfaces and Contracts + +- Backend endpoint: `POST /api/miniapp/session` +- Request body: + - `initData: string` +- Success response: + - `authorized: true` + - `member` + - `telegramUser` +- Membership failure: + - `authorized: false` + - `reason: "not_member"` + +## Security and Privacy + +- Telegram initData is verified with the bot token before membership lookup. +- Mini app access depends on an actual household membership match. +- CORS can be limited via `MINI_APP_ALLOWED_ORIGINS`; if unset, the endpoint falls back to permissive origin reflection for deployment simplicity. + +## UX Notes + +- RU/EN switch is always visible. +- Demo shell appears automatically in local development when Telegram data is unavailable. +- Layout is mobile-first and Telegram webview friendly. + +## Test Plan + +- Unit tests for Telegram initData verification. +- Unit tests for mini app auth handler membership outcomes. +- Full repo typecheck, tests, and build. + +## Acceptance Criteria + +- [ ] Unauthorized users are blocked. +- [ ] RU/EN language switch is present. +- [ ] Base shell and navigation are ready for later finance views. diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index be0eb94..5383f1c 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -140,7 +140,8 @@ module "mini_app_service" { labels = local.common_labels env = { - NODE_ENV = var.environment + NODE_ENV = var.environment + BOT_API_URL = module.bot_api_service.uri } depends_on = [google_project_service.enabled] From c5c356f2b2ea63f492d8bbfc854c475ad5a36606 Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 22:40:49 +0400 Subject: [PATCH 05/10] feat(miniapp): add finance dashboard view --- apps/bot/src/index.ts | 14 +- apps/bot/src/miniapp-auth.test.ts | 5 + apps/bot/src/miniapp-auth.ts | 104 +++++++-- apps/bot/src/miniapp-dashboard.test.ts | 147 ++++++++++++ apps/bot/src/miniapp-dashboard.ts | 106 +++++++++ apps/bot/src/server.test.ts | 25 ++ apps/bot/src/server.ts | 11 + apps/miniapp/src/App.tsx | 129 ++++++++++- apps/miniapp/src/i18n.ts | 12 + apps/miniapp/src/index.css | 33 +++ apps/miniapp/src/miniapp-api.ts | 48 ++++ .../HOUSEBOT-041-miniapp-finance-dashboard.md | 79 +++++++ .../adapters-db/src/finance-repository.ts | 28 ++- .../src/finance-command-service.test.ts | 30 ++- .../src/finance-command-service.ts | 217 ++++++++++++------ packages/ports/src/finance.ts | 12 + packages/ports/src/index.ts | 1 + 17 files changed, 901 insertions(+), 100 deletions(-) create mode 100644 apps/bot/src/miniapp-dashboard.test.ts create mode 100644 apps/bot/src/miniapp-dashboard.ts create mode 100644 docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index a05fd67..42702a1 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -18,6 +18,7 @@ import { createReminderJobsHandler } from './reminder-jobs' import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createBotWebhookServer } from './server' import { createMiniAppAuthHandler } from './miniapp-auth' +import { createMiniAppDashboardHandler } from './miniapp-dashboard' const runtime = getBotRuntimeConfig() const bot = createTelegramBot(runtime.telegramBotToken) @@ -28,6 +29,9 @@ const financeRepositoryClient = runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled ? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!) : null +const financeService = financeRepositoryClient + ? createFinanceCommandService(financeRepositoryClient.repository) + : null if (financeRepositoryClient) { shutdownTasks.push(financeRepositoryClient.close) @@ -59,8 +63,7 @@ if (runtime.purchaseTopicIngestionEnabled) { } if (runtime.financeCommandsEnabled) { - const financeService = createFinanceCommandService(financeRepositoryClient!.repository) - const financeCommands = createFinanceCommandsService(financeService) + const financeCommands = createFinanceCommandsService(financeService!) financeCommands.register(bot) } else { @@ -98,6 +101,13 @@ const server = createBotWebhookServer({ repository: financeRepositoryClient.repository }) : undefined, + miniAppDashboard: financeService + ? createMiniAppDashboardHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + financeService + }) + : undefined, scheduler: reminderJobs && runtime.schedulerSharedSecret ? { diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index adc669a..e812255 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -38,6 +38,7 @@ function repository( addUtilityBill: async () => {}, getRentRuleForPeriod: async () => null, getUtilityTotalForCycle: async () => 0n, + listUtilityBillsForCycle: async () => [], listParsedPurchasesForRange: async () => [], replaceSettlementSnapshot: async () => {} } @@ -84,6 +85,10 @@ describe('createMiniAppAuthHandler', () => { displayName: 'Stan', isAdmin: true }, + features: { + balances: true, + ledger: true + }, telegramUser: { id: '123456', firstName: 'Stan', diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts index 035d701..3316a16 100644 --- a/apps/bot/src/miniapp-auth.ts +++ b/apps/bot/src/miniapp-auth.ts @@ -1,8 +1,8 @@ -import type { FinanceRepository } from '@household/ports' +import type { FinanceMemberRecord, FinanceRepository } from '@household/ports' import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' -function json(body: object, status = 200, origin?: string): Response { +export function miniAppJsonResponse(body: object, status = 200, origin?: string): Response { const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' }) @@ -20,7 +20,10 @@ function json(body: object, status = 200, origin?: string): Response { }) } -function allowedOrigin(request: Request, allowedOrigins: readonly string[]): string | undefined { +export function allowedMiniAppOrigin( + request: Request, + allowedOrigins: readonly string[] +): string | undefined { const origin = request.headers.get('origin') if (!origin) { @@ -34,7 +37,7 @@ function allowedOrigin(request: Request, allowedOrigins: readonly string[]): str return allowedOrigins.includes(origin) ? origin : undefined } -async function readInitData(request: Request): Promise { +export async function readMiniAppInitData(request: Request): Promise { const text = await request.text() if (text.trim().length === 0) { @@ -47,6 +50,53 @@ async function readInitData(request: Request): Promise { return initData && initData.length > 0 ? initData : null } +export interface MiniAppSessionResult { + authorized: boolean + reason?: 'not_member' + member?: { + id: string + displayName: string + isAdmin: boolean + } + telegramUser?: ReturnType +} + +type MiniAppMemberLookup = (telegramUserId: string) => Promise + +export function createMiniAppSessionService(options: { + botToken: string + getMemberByTelegramUserId: MiniAppMemberLookup +}): { + authenticate: (initData: string) => Promise +} { + return { + authenticate: async (initData) => { + const telegramUser = verifyTelegramMiniAppInitData(initData, options.botToken) + if (!telegramUser) { + return null + } + + const member = await options.getMemberByTelegramUserId(telegramUser.id) + if (!member) { + return { + authorized: false, + reason: 'not_member' + } + } + + return { + authorized: true, + member: { + id: member.id, + displayName: member.displayName, + isAdmin: member.isAdmin + }, + telegramUser + } + } + } +} + export function createMiniAppAuthHandler(options: { allowedOrigins: readonly string[] botToken: string @@ -54,32 +104,40 @@ export function createMiniAppAuthHandler(options: { }): { handler: (request: Request) => Promise } { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + getMemberByTelegramUserId: options.repository.getMemberByTelegramUserId + }) + return { handler: async (request) => { - const origin = allowedOrigin(request, options.allowedOrigins) + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) if (request.method === 'OPTIONS') { - return json({ ok: true }, 204, origin) + return miniAppJsonResponse({ ok: true }, 204, origin) } if (request.method !== 'POST') { - return json({ ok: false, error: 'Method Not Allowed' }, 405, origin) + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) } try { - const initData = await readInitData(request) + const initData = await readMiniAppInitData(request) if (!initData) { - return json({ ok: false, error: 'Missing initData' }, 400, origin) + return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) } - const telegramUser = verifyTelegramMiniAppInitData(initData, options.botToken) - if (!telegramUser) { - return json({ ok: false, error: 'Invalid Telegram init data' }, 401, origin) + const session = await sessionService.authenticate(initData) + if (!session) { + return miniAppJsonResponse( + { ok: false, error: 'Invalid Telegram init data' }, + 401, + origin + ) } - const member = await options.repository.getMemberByTelegramUserId(telegramUser.id) - if (!member) { - return json( + if (!session.authorized) { + return miniAppJsonResponse( { ok: true, authorized: false, @@ -90,19 +148,15 @@ export function createMiniAppAuthHandler(options: { ) } - return json( + return miniAppJsonResponse( { ok: true, authorized: true, - member: { - id: member.id, - displayName: member.displayName, - isAdmin: member.isAdmin - }, - telegramUser, + member: session.member, + telegramUser: session.telegramUser, features: { - balances: false, - ledger: false + balances: true, + ledger: true } }, 200, @@ -110,7 +164,7 @@ export function createMiniAppAuthHandler(options: { ) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown mini app auth error' - return json({ ok: false, error: message }, 400, origin) + return miniAppJsonResponse({ ok: false, error: message }, 400, origin) } } } diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts new file mode 100644 index 0000000..180c9be --- /dev/null +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from 'bun:test' +import { createHmac } from 'node:crypto' + +import { createFinanceCommandService } from '@household/application' +import type { FinanceRepository } from '@household/ports' + +import { createMiniAppDashboardHandler } from './miniapp-dashboard' + +function buildInitData(botToken: string, authDate: number, user: object): string { + const params = new URLSearchParams() + params.set('auth_date', authDate.toString()) + params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc') + params.set('user', JSON.stringify(user)) + + const dataCheckString = [...params.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + + const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() + const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') + params.set('hash', hash) + + return params.toString() +} + +function repository( + member: Awaited> +): FinanceRepository { + return { + getMemberByTelegramUserId: async () => member, + listMembers: async () => [ + member ?? { + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + } + ], + getOpenCycle: async () => ({ + id: 'cycle-1', + period: '2026-03', + currency: 'USD' + }), + getCycleByPeriod: async () => null, + getLatestCycle: async () => ({ + id: 'cycle-1', + period: '2026-03', + currency: 'USD' + }), + openCycle: async () => {}, + closeCycle: async () => {}, + saveRentRule: async () => {}, + addUtilityBill: async () => {}, + getRentRuleForPeriod: async () => ({ + amountMinor: 70000n, + currency: 'USD' + }), + getUtilityTotalForCycle: async () => 12000n, + listUtilityBillsForCycle: async () => [ + { + id: 'utility-1', + billName: 'Electricity', + amountMinor: 12000n, + currency: 'USD', + createdByMemberId: member?.id ?? 'member-1', + createdAt: new Date('2026-03-12T12:00:00.000Z') + } + ], + listParsedPurchasesForRange: async () => [ + { + id: 'purchase-1', + payerMemberId: member?.id ?? 'member-1', + amountMinor: 3000n, + description: 'Soap', + occurredAt: new Date('2026-03-12T11:00:00.000Z') + } + ], + replaceSettlementSnapshot: async () => {} + } +} + +describe('createMiniAppDashboardHandler', () => { + test('returns a dashboard for an authenticated household member', async () => { + const authDate = Math.floor(Date.now() / 1000) + const financeService = createFinanceCommandService( + repository({ + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + }) + ) + + const dashboard = createMiniAppDashboardHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + financeService + }) + + const response = await dashboard.handler( + new Request('http://localhost/api/miniapp/dashboard', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan', + username: 'stanislav', + language_code: 'ru' + }) + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + ok: true, + authorized: true, + dashboard: { + period: '2026-03', + currency: 'USD', + totalDueMajor: '820.00', + members: [ + { + displayName: 'Stan', + netDueMajor: '820.00', + rentShareMajor: '700.00', + utilityShareMajor: '120.00', + purchaseOffsetMajor: '0.00' + } + ], + ledger: [ + { + title: 'Soap' + }, + { + title: 'Electricity' + } + ] + } + }) + }) +}) diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts new file mode 100644 index 0000000..f8f8dc4 --- /dev/null +++ b/apps/bot/src/miniapp-dashboard.ts @@ -0,0 +1,106 @@ +import type { FinanceCommandService } from '@household/application' + +import { + allowedMiniAppOrigin, + createMiniAppSessionService, + miniAppJsonResponse, + readMiniAppInitData +} from './miniapp-auth' + +export function createMiniAppDashboardHandler(options: { + allowedOrigins: readonly string[] + botToken: string + financeService: FinanceCommandService +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + getMemberByTelegramUserId: options.financeService.getMemberByTelegramUserId + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const initData = await readMiniAppInitData(request) + if (!initData) { + return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) + } + + const session = await sessionService.authenticate(initData) + if (!session) { + return miniAppJsonResponse( + { ok: false, error: 'Invalid Telegram init data' }, + 401, + origin + ) + } + + if (!session.authorized) { + return miniAppJsonResponse( + { + ok: true, + authorized: false, + reason: 'not_member' + }, + 403, + origin + ) + } + + const dashboard = await options.financeService.generateDashboard() + if (!dashboard) { + return miniAppJsonResponse( + { ok: false, error: 'No billing cycle available' }, + 404, + origin + ) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + dashboard: { + period: dashboard.period, + currency: dashboard.currency, + totalDueMajor: dashboard.totalDue.toMajorString(), + members: dashboard.members.map((line) => ({ + memberId: line.memberId, + displayName: line.displayName, + rentShareMajor: line.rentShare.toMajorString(), + utilityShareMajor: line.utilityShare.toMajorString(), + purchaseOffsetMajor: line.purchaseOffset.toMajorString(), + netDueMajor: line.netDue.toMajorString(), + explanations: line.explanations + })), + ledger: dashboard.ledger.map((entry) => ({ + id: entry.id, + kind: entry.kind, + title: entry.title, + amountMajor: entry.amount.toMajorString(), + actorDisplayName: entry.actorDisplayName, + occurredAt: entry.occurredAt + })) + } + }, + 200, + origin + ) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown mini app dashboard error' + return miniAppJsonResponse({ ok: false, error: message }, 400, origin) + } + } + } +} diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index 83a2ab2..33c4258 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -16,6 +16,15 @@ describe('createBotWebhookServer', () => { } }) }, + miniAppDashboard: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, dashboard: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, scheduler: { authorize: async (request) => request.headers.get('x-household-scheduler-secret') === 'scheduler-secret', @@ -95,6 +104,22 @@ describe('createBotWebhookServer', () => { }) }) + test('accepts mini app dashboard request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/dashboard', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + dashboard: {} + }) + }) + test('rejects scheduler request with missing secret', async () => { const response = await server.fetch( new Request('http://localhost/jobs/reminder/utilities', { diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 171feaa..adfb4ec 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -8,6 +8,12 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppDashboard?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined scheduler?: | { pathPrefix?: string @@ -39,6 +45,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { ? options.webhookPath : `/${options.webhookPath}` const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session' + const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard' const schedulerPathPrefix = options.scheduler ? (options.scheduler.pathPrefix ?? '/jobs/reminder') : null @@ -55,6 +62,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppAuth.handler(request) } + if (options.miniAppDashboard && url.pathname === miniAppDashboardPath) { + return await options.miniAppDashboard.handler(request) + } + if (url.pathname !== normalizedWebhookPath) { if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) { if (request.method !== 'POST') { diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 3eeb8e1..1f6eb22 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -1,7 +1,7 @@ -import { Match, Switch, createMemo, createSignal, onMount } from 'solid-js' +import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js' import { dictionary, type Locale } from './i18n' -import { fetchMiniAppSession } from './miniapp-api' +import { fetchMiniAppDashboard, fetchMiniAppSession, type MiniAppDashboard } from './miniapp-api' import { getTelegramWebApp } from './telegram-webapp' type SessionState = @@ -55,6 +55,7 @@ function App() { status: 'loading' }) const [activeNav, setActiveNav] = createSignal('home') + const [dashboard, setDashboard] = createSignal(null) const copy = createMemo(() => dictionary[locale()]) const blockedSession = createMemo(() => { @@ -103,9 +104,58 @@ function App() { member: payload.member, telegramUser: payload.telegramUser }) + + try { + setDashboard(await fetchMiniAppDashboard(initData)) + } catch { + setDashboard(null) + } } catch { if (import.meta.env.DEV) { setSession(demoSession) + setDashboard({ + period: '2026-03', + currency: 'USD', + totalDueMajor: '820.00', + members: [ + { + memberId: 'alice', + displayName: 'Alice', + rentShareMajor: '350.00', + utilityShareMajor: '60.00', + purchaseOffsetMajor: '-15.00', + netDueMajor: '395.00', + explanations: ['Equal utility split', 'Shared purchase offset'] + }, + { + memberId: 'bob', + displayName: 'Bob', + rentShareMajor: '350.00', + utilityShareMajor: '60.00', + purchaseOffsetMajor: '15.00', + netDueMajor: '425.00', + explanations: ['Equal utility split'] + } + ], + ledger: [ + { + id: 'purchase-1', + kind: 'purchase', + title: 'Soap', + amountMajor: '30.00', + actorDisplayName: 'Alice', + occurredAt: '2026-03-12T11:00:00.000Z' + }, + { + id: 'utility-1', + kind: 'utility', + title: 'Electricity', + amountMajor: '120.00', + actorDisplayName: 'Alice', + occurredAt: '2026-03-12T12:00:00.000Z' + } + ] + }) return } @@ -119,13 +169,74 @@ function App() { const renderPanel = () => { switch (activeNav()) { case 'balances': - return copy().balancesEmpty + return ( +
+ {copy().emptyDashboard}

} + render={(data) => + data.members.map((member) => ( +
+
+ {member.displayName} + + {member.netDueMajor} {data.currency} + +
+

+ {copy().shareRent}: {member.rentShareMajor} {data.currency} +

+

+ {copy().shareUtilities}: {member.utilityShareMajor} {data.currency} +

+

+ {copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency} +

+
+ )) + } + /> +
+ ) case 'ledger': - return copy().ledgerEmpty + return ( +
+ {copy().emptyDashboard}

} + render={(data) => + data.ledger.map((entry) => ( +
+
+ {entry.title} + + {entry.amountMajor} {data.currency} + +
+

{entry.actorDisplayName ?? 'Household'}

+
+ )) + } + /> +
+ ) case 'house': return copy().houseEmpty default: - return copy().summaryBody + return ( + {copy().summaryBody}

} + render={(data) => ( + <> +

+ {copy().totalDue}: {data.totalDueMajor} {data.currency} +

+

{copy().summaryBody}

+ + )} + /> + ) } } @@ -254,4 +365,12 @@ function App() { ) } +function ShowDashboard(props: { + dashboard: MiniAppDashboard | null + fallback: JSX.Element + render: (dashboard: MiniAppDashboard) => JSX.Element +}) { + return <>{props.dashboard ? props.render(props.dashboard) : props.fallback} +} + export default App diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index dc7a680..2cd2bb4 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -26,6 +26,12 @@ export const dictionary = { summaryTitle: 'Current shell', summaryBody: 'Balances, ledger, and house wiki will land in the next tickets. This shell focuses on verified access, navigation, and mobile layout.', + totalDue: 'Total due', + shareRent: 'Rent', + shareUtilities: 'Utilities', + shareOffset: 'Shared buys', + ledgerTitle: 'Included ledger', + emptyDashboard: 'No billing cycle is ready yet.', cardAccess: 'Access', cardAccessBody: 'Telegram identity verified and matched to a household member.', cardLocale: 'Locale', @@ -64,6 +70,12 @@ export const dictionary = { summaryTitle: 'Текущая оболочка', summaryBody: 'Баланс, леджер и вики дома появятся в следующих тикетах. Сейчас приоритет — проверенный доступ, навигация и мобильный layout.', + totalDue: 'Итого к оплате', + shareRent: 'Аренда', + shareUtilities: 'Коммуналка', + shareOffset: 'Общие покупки', + ledgerTitle: 'Вошедшие операции', + emptyDashboard: 'Пока нет готового billing cycle.', cardAccess: 'Доступ', cardAccessBody: 'Telegram-личность подтверждена и сопоставлена с участником household.', cardLocale: 'Локаль', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index e5ebdb1..f65eb87 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -210,6 +210,39 @@ button { padding: 18px; } +.balance-list, +.ledger-list { + display: grid; + gap: 12px; +} + +.balance-item, +.ledger-item { + border: 1px solid rgb(255 255 255 / 0.08); + border-radius: 18px; + padding: 14px; + background: rgb(255 255 255 / 0.03); +} + +.balance-item header, +.ledger-item header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.balance-item strong, +.ledger-item strong { + font-size: 1rem; +} + +.balance-item p, +.ledger-item p { + margin-top: 6px; +} + .panel--wide { min-height: 170px; } diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 2773cab..c040b2f 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -14,6 +14,29 @@ export interface MiniAppSession { reason?: string } +export interface MiniAppDashboard { + period: string + currency: 'USD' | 'GEL' + totalDueMajor: string + members: { + memberId: string + displayName: string + rentShareMajor: string + utilityShareMajor: string + purchaseOffsetMajor: string + netDueMajor: string + explanations: readonly string[] + }[] + ledger: { + id: string + kind: 'purchase' | 'utility' + title: string + amountMajor: string + actorDisplayName: string | null + occurredAt: string | null + }[] +} + function apiBaseUrl(): string { const runtimeConfigured = runtimeBotApiUrl() if (runtimeConfigured) { @@ -64,3 +87,28 @@ export async function fetchMiniAppSession(initData: string): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/dashboard`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + dashboard?: MiniAppDashboard + error?: string + } + + if (!response.ok || !payload.authorized || !payload.dashboard) { + throw new Error(payload.error ?? 'Failed to load dashboard') + } + + return payload.dashboard +} diff --git a/docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md b/docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md new file mode 100644 index 0000000..5d7995c --- /dev/null +++ b/docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md @@ -0,0 +1,79 @@ +# HOUSEBOT-041: Mini App Finance Dashboard + +## Summary + +Expose the current settlement snapshot to the Telegram mini app so household members can inspect balances and included ledger items without leaving Telegram. + +## Goals + +- Reuse the same finance service and settlement calculation path as bot statements. +- Show per-member balances for the active or latest billing cycle. +- Show the ledger items that contributed to the cycle total. +- Keep the layout usable inside the Telegram mobile webview. + +## Non-goals + +- Editing balances or bills from the mini app. +- Historical multi-period browsing. +- Advanced charts or analytics. + +## Scope + +- In: backend dashboard endpoint, authenticated mini app access, structured balance payload, ledger rendering in the Solid shell. +- Out: write actions, filters, pagination, admin-only controls. + +## Interfaces and Contracts + +- Backend endpoint: `POST /api/miniapp/dashboard` +- Request body: + - `initData: string` +- Success response: + - `authorized: true` + - `dashboard.period` + - `dashboard.currency` + - `dashboard.totalDueMajor` + - `dashboard.members[]` + - `dashboard.ledger[]` +- Membership failure: + - `authorized: false` + - `reason: "not_member"` +- Missing cycle response: + - `404` + - `error: "No billing cycle available"` + +## Domain Rules + +- Dashboard totals must match the same settlement calculation used by `/finance statement`. +- Money remains in minor units internally and is formatted to major strings only at the API boundary. +- Ledger items are ordered by event time, then title for deterministic display. + +## Security and Privacy + +- Dashboard access requires valid Telegram initData and a mapped household member. +- CORS follows the same allow-list behavior as the mini app session endpoint. +- Only household-scoped finance data is returned. + +## Observability + +- Reuse existing HTTP request logs from the bot server. +- Handler errors return explicit 4xx responses for invalid auth or missing cycle state. + +## Edge Cases and Failure Modes + +- Invalid or expired initData returns `401`. +- Non-members receive `403`. +- Empty household billing state returns `404`. +- Missing purchase descriptions fall back to `Shared purchase`. + +## Test Plan + +- Unit: finance command service dashboard output and ledger ordering. +- Unit: mini app dashboard handler auth and payload contract. +- Integration: full repo typecheck, tests, build. + +## Acceptance Criteria + +- [ ] Mini app members can view current balances and total due. +- [ ] Ledger entries match the purchase and utility inputs used by the settlement. +- [ ] Dashboard totals stay consistent with the bot statement output. +- [ ] Mobile shell renders balances and ledger states without placeholder-only content. diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index fe04338..5d18644 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -249,12 +249,34 @@ export function createDbFinanceRepository( return BigInt(rows[0]?.totalMinor ?? '0') }, + async listUtilityBillsForCycle(cycleId) { + const rows = await db + .select({ + id: schema.utilityBills.id, + billName: schema.utilityBills.billName, + amountMinor: schema.utilityBills.amountMinor, + currency: schema.utilityBills.currency, + createdByMemberId: schema.utilityBills.createdByMemberId, + createdAt: schema.utilityBills.createdAt + }) + .from(schema.utilityBills) + .where(eq(schema.utilityBills.cycleId, cycleId)) + .orderBy(schema.utilityBills.createdAt) + + return rows.map((row) => ({ + ...row, + currency: toCurrencyCode(row.currency) + })) + }, + async listParsedPurchasesForRange(start, end) { const rows = await db .select({ id: schema.purchaseMessages.id, payerMemberId: schema.purchaseMessages.senderMemberId, - amountMinor: schema.purchaseMessages.parsedAmountMinor + amountMinor: schema.purchaseMessages.parsedAmountMinor, + description: schema.purchaseMessages.parsedItemDescription, + occurredAt: schema.purchaseMessages.messageSentAt }) .from(schema.purchaseMessages) .where( @@ -270,7 +292,9 @@ export function createDbFinanceRepository( return rows.map((row) => ({ id: row.id, payerMemberId: row.payerMemberId!, - amountMinor: row.amountMinor! + amountMinor: row.amountMinor!, + description: row.description, + occurredAt: row.occurredAt })) }, diff --git a/packages/application/src/finance-command-service.test.ts b/packages/application/src/finance-command-service.test.ts index 2aec740..2a71e2b 100644 --- a/packages/application/src/finance-command-service.test.ts +++ b/packages/application/src/finance-command-service.test.ts @@ -20,6 +20,14 @@ class FinanceRepositoryStub implements FinanceRepository { rentRule: FinanceRentRuleRecord | null = null utilityTotal: bigint = 0n purchases: readonly FinanceParsedPurchaseRecord[] = [] + utilityBills: readonly { + id: string + billName: string + amountMinor: bigint + currency: 'USD' | 'GEL' + createdByMemberId: string | null + createdAt: Date + }[] = [] lastSavedRentRule: { period: string @@ -93,6 +101,10 @@ class FinanceRepositoryStub implements FinanceRepository { return this.utilityTotal } + async listUtilityBillsForCycle() { + return this.utilityBills + } + async listParsedPurchasesForRange(): Promise { return this.purchases } @@ -161,17 +173,33 @@ describe('createFinanceCommandService', () => { currency: 'USD' } repository.utilityTotal = 12000n + repository.utilityBills = [ + { + id: 'utility-1', + billName: 'Electricity', + amountMinor: 12000n, + currency: 'USD', + createdByMemberId: 'alice', + createdAt: new Date('2026-03-12T12:00:00.000Z') + } + ] repository.purchases = [ { id: 'purchase-1', payerMemberId: 'alice', - amountMinor: 3000n + amountMinor: 3000n, + description: 'Soap', + occurredAt: new Date('2026-03-12T11:00:00.000Z') } ] const service = createFinanceCommandService(repository) + const dashboard = await service.generateDashboard() const statement = await service.generateStatement() + expect(dashboard).not.toBeNull() + expect(dashboard?.members.map((line) => line.netDue.amountMinor)).toEqual([39500n, 42500n]) + expect(dashboard?.ledger.map((entry) => entry.title)).toEqual(['Soap', 'Electricity']) expect(statement).toBe( [ 'Statement for 2026-03', diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index 3562196..8edc41b 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -47,6 +47,147 @@ async function getCycleByPeriodOrLatest( return repository.getLatestCycle() } +export interface FinanceDashboardMemberLine { + memberId: string + displayName: string + rentShare: Money + utilityShare: Money + purchaseOffset: Money + netDue: Money + explanations: readonly string[] +} + +export interface FinanceDashboardLedgerEntry { + id: string + kind: 'purchase' | 'utility' + title: string + amount: Money + actorDisplayName: string | null + occurredAt: string | null +} + +export interface FinanceDashboard { + period: string + currency: CurrencyCode + totalDue: Money + members: readonly FinanceDashboardMemberLine[] + ledger: readonly FinanceDashboardLedgerEntry[] +} + +async function buildFinanceDashboard( + repository: FinanceRepository, + periodArg?: string +): Promise { + const cycle = await getCycleByPeriodOrLatest(repository, periodArg) + if (!cycle) { + return null + } + + const members = await repository.listMembers() + if (members.length === 0) { + throw new Error('No household members configured') + } + + const rentRule = await repository.getRentRuleForPeriod(cycle.period) + if (!rentRule) { + throw new Error('No rent rule configured for this cycle period') + } + + const period = BillingPeriod.fromString(cycle.period) + const { start, end } = monthRange(period) + const purchases = await repository.listParsedPurchasesForRange(start, end) + const utilityBills = await repository.listUtilityBillsForCycle(cycle.id) + const utilitiesMinor = await repository.getUtilityTotalForCycle(cycle.id) + + const settlement = calculateMonthlySettlement({ + cycleId: BillingCycleId.from(cycle.id), + period, + rent: Money.fromMinor(rentRule.amountMinor, rentRule.currency), + utilities: Money.fromMinor(utilitiesMinor, rentRule.currency), + utilitySplitMode: 'equal', + members: members.map((member) => ({ + memberId: MemberId.from(member.id), + active: true + })), + purchases: purchases.map((purchase) => ({ + purchaseId: PurchaseEntryId.from(purchase.id), + payerId: MemberId.from(purchase.payerMemberId), + amount: Money.fromMinor(purchase.amountMinor, rentRule.currency) + })) + }) + + await repository.replaceSettlementSnapshot({ + cycleId: cycle.id, + inputHash: computeInputHash({ + cycleId: cycle.id, + rentMinor: rentRule.amountMinor.toString(), + utilitiesMinor: utilitiesMinor.toString(), + purchaseCount: purchases.length, + memberCount: members.length + }), + totalDueMinor: settlement.totalDue.amountMinor, + currency: rentRule.currency, + metadata: { + generatedBy: 'bot-command', + source: 'finance-service' + }, + lines: settlement.lines.map((line) => ({ + memberId: line.memberId.toString(), + rentShareMinor: line.rentShare.amountMinor, + utilityShareMinor: line.utilityShare.amountMinor, + purchaseOffsetMinor: line.purchaseOffset.amountMinor, + netDueMinor: line.netDue.amountMinor, + explanations: line.explanations + })) + }) + + const memberNameById = new Map(members.map((member) => [member.id, member.displayName])) + const dashboardMembers = settlement.lines.map((line) => ({ + memberId: line.memberId.toString(), + displayName: memberNameById.get(line.memberId.toString()) ?? line.memberId.toString(), + rentShare: line.rentShare, + utilityShare: line.utilityShare, + purchaseOffset: line.purchaseOffset, + netDue: line.netDue, + explanations: line.explanations + })) + + const ledger: FinanceDashboardLedgerEntry[] = [ + ...utilityBills.map((bill) => ({ + id: bill.id, + kind: 'utility' as const, + title: bill.billName, + amount: Money.fromMinor(bill.amountMinor, bill.currency), + actorDisplayName: bill.createdByMemberId + ? (memberNameById.get(bill.createdByMemberId) ?? null) + : null, + occurredAt: bill.createdAt.toISOString() + })), + ...purchases.map((purchase) => ({ + id: purchase.id, + kind: 'purchase' as const, + title: purchase.description ?? 'Shared purchase', + amount: Money.fromMinor(purchase.amountMinor, rentRule.currency), + actorDisplayName: memberNameById.get(purchase.payerMemberId) ?? null, + occurredAt: purchase.occurredAt?.toISOString() ?? null + })) + ].sort((left, right) => { + if (left.occurredAt === right.occurredAt) { + return left.title.localeCompare(right.title) + } + + return (left.occurredAt ?? '').localeCompare(right.occurredAt ?? '') + }) + + return { + period: cycle.period, + currency: rentRule.currency, + totalDue: settlement.totalDue, + members: dashboardMembers, + ledger + } +} + export interface FinanceCommandService { getMemberByTelegramUserId(telegramUserId: string): Promise getOpenCycle(): Promise @@ -71,6 +212,7 @@ export interface FinanceCommandService { currency: CurrencyCode period: string } | null> + generateDashboard(periodArg?: string): Promise generateStatement(periodArg?: string): Promise } @@ -155,79 +297,24 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina }, async generateStatement(periodArg) { - const cycle = await getCycleByPeriodOrLatest(repository, periodArg) - if (!cycle) { + const dashboard = await buildFinanceDashboard(repository, periodArg) + if (!dashboard) { return null } - const members = await repository.listMembers() - if (members.length === 0) { - throw new Error('No household members configured') - } - - const rentRule = await repository.getRentRuleForPeriod(cycle.period) - if (!rentRule) { - throw new Error('No rent rule configured for this cycle period') - } - - const period = BillingPeriod.fromString(cycle.period) - const { start, end } = monthRange(period) - const purchases = await repository.listParsedPurchasesForRange(start, end) - const utilitiesMinor = await repository.getUtilityTotalForCycle(cycle.id) - - const settlement = calculateMonthlySettlement({ - cycleId: BillingCycleId.from(cycle.id), - period, - rent: Money.fromMinor(rentRule.amountMinor, rentRule.currency), - utilities: Money.fromMinor(utilitiesMinor, rentRule.currency), - utilitySplitMode: 'equal', - members: members.map((member) => ({ - memberId: MemberId.from(member.id), - active: true - })), - purchases: purchases.map((purchase) => ({ - purchaseId: PurchaseEntryId.from(purchase.id), - payerId: MemberId.from(purchase.payerMemberId), - amount: Money.fromMinor(purchase.amountMinor, rentRule.currency) - })) - }) - - await repository.replaceSettlementSnapshot({ - cycleId: cycle.id, - inputHash: computeInputHash({ - cycleId: cycle.id, - rentMinor: rentRule.amountMinor.toString(), - utilitiesMinor: utilitiesMinor.toString(), - purchaseCount: purchases.length, - memberCount: members.length - }), - totalDueMinor: settlement.totalDue.amountMinor, - currency: rentRule.currency, - metadata: { - generatedBy: 'bot-command', - source: 'statement' - }, - lines: settlement.lines.map((line) => ({ - memberId: line.memberId.toString(), - rentShareMinor: line.rentShare.amountMinor, - utilityShareMinor: line.utilityShare.amountMinor, - purchaseOffsetMinor: line.purchaseOffset.amountMinor, - netDueMinor: line.netDue.amountMinor, - explanations: line.explanations - })) - }) - - const memberNameById = new Map(members.map((member) => [member.id, member.displayName])) - const statementLines = settlement.lines.map((line) => { - const name = memberNameById.get(line.memberId.toString()) ?? line.memberId.toString() - return `- ${name}: ${line.netDue.toMajorString()} ${rentRule.currency}` + const statementLines = dashboard.members.map((line) => { + return `- ${line.displayName}: ${line.netDue.toMajorString()} ${dashboard.currency}` }) return [ - `Statement for ${cycle.period}`, + `Statement for ${dashboard.period}`, ...statementLines, - `Total: ${settlement.totalDue.toMajorString()} ${rentRule.currency}` + `Total: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}` ].join('\n') + }, + + generateDashboard(periodArg) { + return buildFinanceDashboard(repository, periodArg) } } } diff --git a/packages/ports/src/finance.ts b/packages/ports/src/finance.ts index c71c80e..2abb898 100644 --- a/packages/ports/src/finance.ts +++ b/packages/ports/src/finance.ts @@ -22,6 +22,17 @@ export interface FinanceParsedPurchaseRecord { id: string payerMemberId: string amountMinor: bigint + description: string | null + occurredAt: Date | null +} + +export interface FinanceUtilityBillRecord { + id: string + billName: string + amountMinor: bigint + currency: CurrencyCode + createdByMemberId: string | null + createdAt: Date } export interface SettlementSnapshotLineRecord { @@ -60,6 +71,7 @@ export interface FinanceRepository { }): Promise getRentRuleForPeriod(period: string): Promise getUtilityTotalForCycle(cycleId: string): Promise + listUtilityBillsForCycle(cycleId: string): Promise listParsedPurchasesForRange( start: Date, end: Date diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 6a8bff2..6ec77f4 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -11,6 +11,7 @@ export type { FinanceParsedPurchaseRecord, FinanceRentRuleRecord, FinanceRepository, + FinanceUtilityBillRecord, SettlementSnapshotLineRecord, SettlementSnapshotRecord } from './finance' From c6a9ade586cf0911371f4eea7d54480cc25ad257 Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 22:44:36 +0400 Subject: [PATCH 06/10] feat(ops): add first deployment runbook tooling --- docs/runbooks/dev-setup.md | 3 + docs/runbooks/first-deploy.md | 255 ++++++++++++++++++ .../HOUSEBOT-062-first-deploy-runbook.md | 62 +++++ infra/terraform/README.md | 2 + infra/terraform/main.tf | 3 + infra/terraform/terraform.tfvars.example | 3 + infra/terraform/variables.tf | 6 + package.json | 4 +- scripts/ops/deploy-smoke.ts | 124 +++++++++ scripts/ops/telegram-webhook.ts | 86 ++++++ 10 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 docs/runbooks/first-deploy.md create mode 100644 docs/specs/HOUSEBOT-062-first-deploy-runbook.md create mode 100644 scripts/ops/deploy-smoke.ts create mode 100644 scripts/ops/telegram-webhook.ts diff --git a/docs/runbooks/dev-setup.md b/docs/runbooks/dev-setup.md index 5cb78f5..67f20d5 100644 --- a/docs/runbooks/dev-setup.md +++ b/docs/runbooks/dev-setup.md @@ -26,6 +26,8 @@ bun run db:generate bun run db:check bun run db:migrate bun run db:seed +bun run ops:telegram:webhook info +bun run ops:deploy:smoke bun run infra:fmt:check bun run infra:validate ``` @@ -60,6 +62,7 @@ bun run review:coderabbit - Typed environment validation lives in `packages/config/src/env.ts`. - Copy `.env.example` to `.env` before running app/database commands. - Migration workflow is documented in `docs/runbooks/migrations.md`. +- First deploy flow is documented in `docs/runbooks/first-deploy.md`. ## CI/CD diff --git a/docs/runbooks/first-deploy.md b/docs/runbooks/first-deploy.md new file mode 100644 index 0000000..507f973 --- /dev/null +++ b/docs/runbooks/first-deploy.md @@ -0,0 +1,255 @@ +# First Deployment Runbook + +## Purpose + +Execute the first real deployment with a repeatable sequence that covers infrastructure, secrets, webhook cutover, smoke checks, scheduler rollout, and rollback. + +## Preconditions + +- `main` is green in CI. +- Terraform baseline has already been reviewed for the target environment. +- You have access to: + - GCP project + - GitHub repo settings + - Telegram bot token + - Supabase project and database URL + +## Required Configuration Inventory + +### Terraform variables + +Required in your environment `*.tfvars`: + +- `project_id` +- `region` +- `environment` +- `bot_api_image` +- `mini_app_image` +- `bot_household_id` +- `bot_household_chat_id` +- `bot_purchase_topic_id` + +Recommended: + +- `bot_mini_app_allowed_origins` +- `scheduler_timezone` +- `scheduler_paused = true` +- `scheduler_dry_run = true` + +### Secret Manager values + +Create the secret resources via Terraform, then add secret versions for: + +- `telegram-bot-token` +- `telegram-webhook-secret` +- `scheduler-shared-secret` +- `database-url` +- optional `openai-api-key` +- optional `supabase-url` +- optional `supabase-publishable-key` + +### GitHub Actions secrets + +Required for CD: + +- `GCP_PROJECT_ID` +- `GCP_WORKLOAD_IDENTITY_PROVIDER` +- `GCP_SERVICE_ACCOUNT` + +Recommended: + +- `DATABASE_URL` + +### GitHub Actions variables + +Set if you do not want the defaults: + +- `GCP_REGION` +- `ARTIFACT_REPOSITORY` +- `CLOUD_RUN_SERVICE_BOT` +- `CLOUD_RUN_SERVICE_MINI` + +## Phase 1: Local Readiness + +Run the quality gates locally from the deployment ref: + +```bash +bun run format:check +bun run lint +bun run typecheck +bun run test +bun run build +``` + +If the release includes schema changes, also run: + +```bash +bun run db:check +E2E_SMOKE_ALLOW_WRITE=true bun run test:e2e +``` + +## Phase 2: Provision or Reconcile Infrastructure + +1. Prepare environment-specific variables: + +```bash +cp infra/terraform/terraform.tfvars.example infra/terraform/dev.tfvars +``` + +2. Initialize Terraform with the correct state bucket: + +```bash +terraform -chdir=infra/terraform init -backend-config="bucket=" +``` + +3. Review and apply: + +```bash +terraform -chdir=infra/terraform plan -var-file=dev.tfvars +terraform -chdir=infra/terraform apply -var-file=dev.tfvars +``` + +4. Capture outputs: + +```bash +BOT_API_URL="$(terraform -chdir=infra/terraform output -raw bot_api_service_url)" +MINI_APP_URL="$(terraform -chdir=infra/terraform output -raw mini_app_service_url)" +``` + +5. If you did not know the mini app URL before the first apply, set `bot_mini_app_allowed_origins = [\"${MINI_APP_URL}\"]` in `dev.tfvars` and apply again. + +## Phase 3: Add Runtime Secret Versions + +Use the real project ID from Terraform variables: + +```bash +echo -n "" | gcloud secrets versions add telegram-bot-token --data-file=- --project +echo -n "" | gcloud secrets versions add telegram-webhook-secret --data-file=- --project +echo -n "" | gcloud secrets versions add scheduler-shared-secret --data-file=- --project +echo -n "" | gcloud secrets versions add database-url --data-file=- --project +``` + +Add optional secret versions only if those integrations are enabled. + +## Phase 4: Configure GitHub CD + +Populate GitHub repository secrets with the Terraform outputs: + +- `GCP_PROJECT_ID` +- `GCP_WORKLOAD_IDENTITY_PROVIDER` +- `GCP_SERVICE_ACCOUNT` +- optional `DATABASE_URL` + +If you prefer the GitHub CLI: + +```bash +gh secret set GCP_PROJECT_ID +gh secret set GCP_WORKLOAD_IDENTITY_PROVIDER +gh secret set GCP_SERVICE_ACCOUNT +gh secret set DATABASE_URL +``` + +Set GitHub repository variables if you want to override the defaults used by `.github/workflows/cd.yml`. + +## Phase 5: Trigger the First Deployment + +You have two safe options: + +- Merge the deployment ref into `main` and let `CD` run after successful CI. +- Trigger `CD` manually from the GitHub Actions UI with `workflow_dispatch`. + +The workflow will: + +- optionally run `bun run db:migrate` if `DATABASE_URL` secret is configured +- build and push bot and mini app images +- deploy both Cloud Run services + +## Phase 6: Telegram Webhook Cutover + +After the bot service is live, set the webhook explicitly: + +```bash +export TELEGRAM_BOT_TOKEN="$(gcloud secrets versions access latest --secret telegram-bot-token --project )" +export TELEGRAM_WEBHOOK_SECRET="$(gcloud secrets versions access latest --secret telegram-webhook-secret --project )" +export TELEGRAM_WEBHOOK_URL="${BOT_API_URL}/webhook/telegram" + +bun run ops:telegram:webhook set +bun run ops:telegram:webhook info +``` + +If you want to discard queued updates during cutover: + +```bash +export TELEGRAM_DROP_PENDING_UPDATES=true +bun run ops:telegram:webhook set +``` + +## Phase 7: Post-Deploy Smoke Checks + +Run the smoke script: + +```bash +export BOT_API_URL +export MINI_APP_URL +export TELEGRAM_EXPECTED_WEBHOOK_URL="${BOT_API_URL}/webhook/telegram" + +bun run ops:deploy:smoke +``` + +The smoke script verifies: + +- bot health endpoint +- mini app root delivery +- mini app auth endpoint is mounted +- scheduler endpoint rejects unauthenticated requests +- Telegram webhook matches the expected URL when bot token is provided + +## Phase 8: Scheduler Enablement + +First release: + +1. Keep `scheduler_paused = true` and `scheduler_dry_run = true` on initial deploy. +2. After smoke checks pass, set `scheduler_paused = false` and apply Terraform. +3. Trigger one job manually: + +```bash +gcloud scheduler jobs run household-dev-utilities --location --project +``` + +4. Verify the reminder request succeeded and produced `dryRun: true` logs. +5. Set `scheduler_dry_run = false` and apply Terraform. +6. Trigger one job again and verify the delivery side behaves as expected. + +## Rollback + +If the release is unhealthy: + +1. Pause scheduler jobs again in Terraform: + +```bash +terraform -chdir=infra/terraform apply -var-file=dev.tfvars -var='scheduler_paused=true' +``` + +2. Move Cloud Run traffic back to the last healthy revision: + +```bash +gcloud run revisions list --service --region --project +gcloud run services update-traffic --region --project --to-revisions =100 +gcloud run revisions list --service --region --project +gcloud run services update-traffic --region --project --to-revisions =100 +``` + +3. If webhook traffic must stop immediately: + +```bash +bun run ops:telegram:webhook delete +``` + +4. If migrations were additive, leave schema in place and roll application code back. +5. If a destructive migration failed, stop and use the rollback SQL prepared in that PR. + +## Dev-to-Prod Promotion Notes + +- Repeat the same sequence in a separate `prod.tfvars` and Terraform state. +- Keep separate GCP projects for `dev` and `prod` when possible. +- Do not unpause production scheduler jobs until prod smoke checks are complete. diff --git a/docs/specs/HOUSEBOT-062-first-deploy-runbook.md b/docs/specs/HOUSEBOT-062-first-deploy-runbook.md new file mode 100644 index 0000000..2576c88 --- /dev/null +++ b/docs/specs/HOUSEBOT-062-first-deploy-runbook.md @@ -0,0 +1,62 @@ +# HOUSEBOT-062: First Deployment Runbook and Cutover Checklist + +## Summary + +Document the exact first-deploy sequence so one engineer can provision, deploy, cut over Telegram webhook traffic, validate the runtime, and roll back safely without tribal knowledge. + +## Goals + +- Provide one runbook that covers infrastructure, CD, webhook cutover, smoke checks, and scheduler enablement. +- Close configuration gaps that would otherwise require ad hoc manual fixes. +- Add lightweight operator scripts for webhook management and post-deploy validation. + +## Non-goals + +- Full production monitoring stack. +- Automated blue/green or canary deployment. +- Elimination of all manual steps from first deploy. + +## Scope + +- In: first-deploy runbook, config inventory, smoke scripts, Terraform runtime config needed for deploy safety. +- Out: continuous release automation redesign, incident response handbook. + +## Interfaces and Contracts + +- Operator scripts: + - `bun run ops:telegram:webhook info|set|delete` + - `bun run ops:deploy:smoke` +- Runbook: + - `docs/runbooks/first-deploy.md` +- Terraform runtime config: + - optional `bot_mini_app_allowed_origins` + +## Security and Privacy + +- Webhook setup uses Telegram secret token support. +- Post-deploy validation does not require scheduler auth bypass. +- Mini app origin allow-list is configurable through Terraform instead of ad hoc runtime mutation. + +## Observability + +- Smoke checks verify bot health, mounted app routes, and Telegram webhook state. +- Runbook includes explicit verification before scheduler jobs are unpaused. + +## Edge Cases and Failure Modes + +- First Terraform apply may not know the final mini app URL; runbook includes a second apply to set allowed origins. +- Missing `DATABASE_URL` in GitHub secrets skips migration automation. +- Scheduler jobs remain paused and dry-run by default to prevent accidental sends. + +## Test Plan + +- Unit: script typecheck through workspace `typecheck`. +- Integration: `bun run format:check`, `bun run lint`, `bun run typecheck`, `bun run test`, `bun run build`, `bun run infra:validate`. +- Manual: execute the runbook in dev before prod cutover. + +## Acceptance Criteria + +- [ ] A single runbook describes the full first deploy flow. +- [ ] Required secrets, vars, and Terraform values are enumerated. +- [ ] Webhook cutover and smoke checks are script-assisted. +- [ ] Rollback steps are explicit and environment-safe. diff --git a/infra/terraform/README.md b/infra/terraform/README.md index 2756978..0fa8bf3 100644 --- a/infra/terraform/README.md +++ b/infra/terraform/README.md @@ -73,6 +73,7 @@ Recommended approach: - `bot_household_chat_id` - `bot_purchase_topic_id` - optional `bot_parser_model` + - optional `bot_mini_app_allowed_origins` ## CI validation @@ -86,3 +87,4 @@ CI runs: - Scheduler jobs default to `paused = true` and `dry_run = true` to prevent accidental sends before live reminder delivery is ready. - Bot API is public to accept Telegram webhooks; scheduler endpoint should still verify app-level auth. +- `bot_mini_app_allowed_origins` cannot be auto-derived in Terraform because the bot and mini app Cloud Run services reference each other; set it explicitly once the mini app URL is known. diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 5383f1c..61b932c 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -93,6 +93,9 @@ module "bot_api_service" { var.bot_parser_model == null ? {} : { PARSER_MODEL = var.bot_parser_model }, + length(var.bot_mini_app_allowed_origins) == 0 ? {} : { + MINI_APP_ALLOWED_ORIGINS = join(",", var.bot_mini_app_allowed_origins) + }, { SCHEDULER_OIDC_ALLOWED_EMAILS = google_service_account.scheduler_invoker.email } diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example index e8c3efd..0ac34c4 100644 --- a/infra/terraform/terraform.tfvars.example +++ b/infra/terraform/terraform.tfvars.example @@ -12,6 +12,9 @@ bot_household_id = "11111111-1111-4111-8111-111111111111" bot_household_chat_id = "-1001234567890" bot_purchase_topic_id = 777 bot_parser_model = "gpt-4.1-mini" +bot_mini_app_allowed_origins = [ + "https://household-dev-mini-app-abc123-ew.a.run.app" +] scheduler_utilities_cron = "0 9 4 * *" scheduler_rent_warning_cron = "0 9 17 * *" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 2d7b508..c54b679 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -111,6 +111,12 @@ variable "bot_parser_model" { nullable = true } +variable "bot_mini_app_allowed_origins" { + description = "Optional allow-list of mini app origins for bot CORS handling" + type = list(string) + default = [] +} + variable "openai_api_key_secret_id" { description = "Optional Secret Manager ID for OPENAI_API_KEY" type = string diff --git a/package.json b/package.json index 8b2025e..9d52fc5 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "docker:build:miniapp": "docker build -f apps/miniapp/Dockerfile -t household-miniapp:local .", "docker:build": "bun run docker:build:bot && bun run docker:build:miniapp", "docker:smoke": "docker compose up --build", - "test:e2e": "bun run scripts/e2e/billing-flow.ts" + "test:e2e": "bun run scripts/e2e/billing-flow.ts", + "ops:deploy:smoke": "bun run scripts/ops/deploy-smoke.ts", + "ops:telegram:webhook": "bun run scripts/ops/telegram-webhook.ts" }, "devDependencies": { "@types/bun": "1.3.10", diff --git a/scripts/ops/deploy-smoke.ts b/scripts/ops/deploy-smoke.ts new file mode 100644 index 0000000..07edaff --- /dev/null +++ b/scripts/ops/deploy-smoke.ts @@ -0,0 +1,124 @@ +function requireEnv(name: string): string { + const value = process.env[name]?.trim() + if (!value) { + throw new Error(`${name} is required`) + } + + return value +} + +function toUrl(base: string, path: string): URL { + const normalizedBase = base.endsWith('/') ? base : `${base}/` + return new URL(path.replace(/^\//, ''), normalizedBase) +} + +async function expectJson(url: URL, init: RequestInit, expectedStatus: number): Promise { + const response = await fetch(url, init) + const text = await response.text() + const payload = (text.length > 0 ? JSON.parse(text) : null) as unknown + + if (response.status !== expectedStatus) { + throw new Error( + `${url.toString()} expected ${expectedStatus}, received ${response.status}: ${text}` + ) + } + + return payload +} + +async function fetchWebhookInfo(botToken: string): Promise { + const response = await fetch(`https://api.telegram.org/bot${botToken}/getWebhookInfo`) + const payload = (await response.json()) as { + ok?: boolean + result?: unknown + } + + if (!response.ok || payload.ok !== true) { + throw new Error(`Telegram getWebhookInfo failed: ${JSON.stringify(payload)}`) + } + + return payload.result +} + +async function run(): Promise { + const botApiUrl = requireEnv('BOT_API_URL') + const miniAppUrl = requireEnv('MINI_APP_URL') + + const health = await expectJson(toUrl(botApiUrl, '/healthz'), {}, 200) + if (health?.ok !== true) { + throw new Error('Bot health check returned unexpected payload') + } + + await expectJson( + toUrl(botApiUrl, '/api/miniapp/session'), + { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({}) + }, + 400 + ) + + await expectJson( + toUrl(botApiUrl, '/jobs/reminder/utilities'), + { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({}) + }, + 401 + ) + + const miniAppResponse = await fetch(miniAppUrl) + const miniAppHtml = await miniAppResponse.text() + if (!miniAppResponse.ok) { + throw new Error(`Mini app root returned ${miniAppResponse.status}`) + } + if (!miniAppHtml.includes('/config.js')) { + throw new Error('Mini app root does not reference runtime config') + } + + const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN?.trim() + const expectedWebhookUrl = process.env.TELEGRAM_EXPECTED_WEBHOOK_URL?.trim() + + if (telegramBotToken && expectedWebhookUrl) { + const webhookInfo = await fetchWebhookInfo(telegramBotToken) + + if (webhookInfo.url !== expectedWebhookUrl) { + throw new Error( + `Telegram webhook mismatch: expected ${expectedWebhookUrl}, received ${webhookInfo.url}` + ) + } + + if ( + typeof webhookInfo.last_error_message === 'string' && + webhookInfo.last_error_message.length > 0 + ) { + throw new Error( + `Telegram webhook reports last_error_message=${webhookInfo.last_error_message}` + ) + } + } + + console.log( + JSON.stringify( + { + ok: true, + botApiUrl, + miniAppUrl, + checkedWebhook: telegramBotToken !== undefined && expectedWebhookUrl !== undefined + }, + null, + 2 + ) + ) +} + +run().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/scripts/ops/telegram-webhook.ts b/scripts/ops/telegram-webhook.ts new file mode 100644 index 0000000..26b40ab --- /dev/null +++ b/scripts/ops/telegram-webhook.ts @@ -0,0 +1,86 @@ +type WebhookCommand = 'info' | 'set' | 'delete' + +function requireEnv(name: string): string { + const value = process.env[name]?.trim() + if (!value) { + throw new Error(`${name} is required`) + } + + return value +} + +async function telegramRequest( + botToken: string, + method: string, + body?: URLSearchParams +): Promise { + const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, { + method: body ? 'POST' : 'GET', + body + }) + + const payload = (await response.json()) as { + ok?: boolean + result?: unknown + } + if (!response.ok || payload.ok !== true) { + throw new Error(`Telegram ${method} failed: ${JSON.stringify(payload)}`) + } + + return payload.result +} + +async function run(): Promise { + const command = (process.argv[2] ?? 'info') as WebhookCommand + const botToken = requireEnv('TELEGRAM_BOT_TOKEN') + + switch (command) { + case 'info': { + const result = await telegramRequest(botToken, 'getWebhookInfo') + console.log(JSON.stringify(result, null, 2)) + return + } + case 'set': { + const params = new URLSearchParams({ + url: requireEnv('TELEGRAM_WEBHOOK_URL') + }) + + const secretToken = process.env.TELEGRAM_WEBHOOK_SECRET?.trim() + if (secretToken) { + params.set('secret_token', secretToken) + } + + const maxConnections = process.env.TELEGRAM_MAX_CONNECTIONS?.trim() + if (maxConnections) { + params.set('max_connections', maxConnections) + } + + const dropPendingUpdates = process.env.TELEGRAM_DROP_PENDING_UPDATES?.trim() + if (dropPendingUpdates) { + params.set('drop_pending_updates', dropPendingUpdates) + } + + const result = await telegramRequest(botToken, 'setWebhook', params) + console.log(JSON.stringify({ ok: true, result }, null, 2)) + return + } + case 'delete': { + const params = new URLSearchParams() + const dropPendingUpdates = process.env.TELEGRAM_DROP_PENDING_UPDATES?.trim() + if (dropPendingUpdates) { + params.set('drop_pending_updates', dropPendingUpdates) + } + + const result = await telegramRequest(botToken, 'deleteWebhook', params) + console.log(JSON.stringify({ ok: true, result }, null, 2)) + return + } + default: + throw new Error(`Unsupported command: ${command}`) + } +} + +run().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) From 7ffd81bda9aba6ff980db445586ec2382f89ab35 Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 22:50:55 +0400 Subject: [PATCH 07/10] feat(bot): add anonymous feedback flow --- apps/bot/src/anonymous-feedback.test.ts | 177 ++ apps/bot/src/anonymous-feedback.ts | 101 ++ apps/bot/src/bot.ts | 3 +- apps/bot/src/config.ts | 14 +- apps/bot/src/index.ts | 31 +- .../HOUSEBOT-050-anonymous-feedback-dm.md | 80 + infra/terraform/README.md | 1 + infra/terraform/main.tf | 3 + infra/terraform/terraform.tfvars.example | 1 + infra/terraform/variables.tf | 7 + .../src/anonymous-feedback-repository.ts | 171 ++ packages/adapters-db/src/index.ts | 1 + .../src/anonymous-feedback-service.test.ts | 217 +++ .../src/anonymous-feedback-service.ts | 223 +++ packages/application/src/index.ts | 5 + packages/db/drizzle/0004_big_ultimatum.sql | 24 + packages/db/drizzle/meta/0004_snapshot.json | 1587 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 41 + packages/ports/src/anonymous-feedback.ts | 51 + packages/ports/src/index.ts | 8 + 21 files changed, 2750 insertions(+), 3 deletions(-) create mode 100644 apps/bot/src/anonymous-feedback.test.ts create mode 100644 apps/bot/src/anonymous-feedback.ts create mode 100644 docs/specs/HOUSEBOT-050-anonymous-feedback-dm.md create mode 100644 packages/adapters-db/src/anonymous-feedback-repository.ts create mode 100644 packages/application/src/anonymous-feedback-service.test.ts create mode 100644 packages/application/src/anonymous-feedback-service.ts create mode 100644 packages/db/drizzle/0004_big_ultimatum.sql create mode 100644 packages/db/drizzle/meta/0004_snapshot.json create mode 100644 packages/ports/src/anonymous-feedback.ts diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts new file mode 100644 index 0000000..1be47a4 --- /dev/null +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, mock, test } from 'bun:test' + +import type { AnonymousFeedbackService } from '@household/application' + +import { createTelegramBot } from './bot' +import { registerAnonymousFeedback } from './anonymous-feedback' + +function anonUpdate(params: { + updateId: number + chatType: 'private' | 'supergroup' + text: string +}) { + const commandToken = params.text.split(' ')[0] ?? params.text + + return { + update_id: params.updateId, + message: { + message_id: params.updateId, + date: Math.floor(Date.now() / 1000), + chat: { + id: params.chatType === 'private' ? 123456 : -100123456, + type: params.chatType + }, + from: { + id: 123456, + is_bot: false, + first_name: 'Stan' + }, + text: params.text, + entities: [ + { + offset: 0, + length: commandToken.length, + type: 'bot_command' + } + ] + } + } +} + +describe('registerAnonymousFeedback', () => { + test('posts accepted feedback into the configured topic', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.botInfo = { + id: 999000, + is_bot: true, + first_name: 'Household Test Bot', + username: 'household_test_bot', + can_join_groups: true, + can_read_all_group_messages: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: true, + allows_users_to_create_topics: false + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: 1, + type: 'private' + }, + text: 'ok' + } + } as never + }) + + const anonymousFeedbackService: AnonymousFeedbackService = { + submit: mock(async () => ({ + status: 'accepted' as const, + submissionId: 'submission-1', + sanitizedText: 'Please clean the kitchen tonight.' + })), + markPosted: mock(async () => {}), + markFailed: mock(async () => {}) + } + + registerAnonymousFeedback({ + bot, + anonymousFeedbackService, + householdChatId: '-100222333', + feedbackTopicId: 77 + }) + + await bot.handleUpdate( + anonUpdate({ + updateId: 1001, + chatType: 'private', + text: '/anon Please clean the kitchen tonight.' + }) as never + ) + + expect(calls).toHaveLength(2) + expect(calls[0]?.method).toBe('sendMessage') + expect(calls[0]?.payload).toMatchObject({ + chat_id: '-100222333', + message_thread_id: 77, + text: 'Anonymous household note\n\nPlease clean the kitchen tonight.' + }) + expect(calls[1]?.payload).toMatchObject({ + text: 'Anonymous feedback delivered.' + }) + }) + + test('rejects group usage and keeps feedback private', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.botInfo = { + id: 999000, + is_bot: true, + first_name: 'Household Test Bot', + username: 'household_test_bot', + can_join_groups: true, + can_read_all_group_messages: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: true, + allows_users_to_create_topics: false + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup' + }, + text: 'ok' + } + } as never + }) + + registerAnonymousFeedback({ + bot, + anonymousFeedbackService: { + submit: mock(async () => ({ + status: 'accepted' as const, + submissionId: 'submission-1', + sanitizedText: 'unused' + })), + markPosted: mock(async () => {}), + markFailed: mock(async () => {}) + }, + householdChatId: '-100222333', + feedbackTopicId: 77 + }) + + await bot.handleUpdate( + anonUpdate({ + updateId: 1002, + chatType: 'supergroup', + text: '/anon Please clean the kitchen tonight.' + }) as never + ) + + expect(calls).toHaveLength(1) + expect(calls[0]?.payload).toMatchObject({ + text: 'Use /anon in a private chat with the bot.' + }) + }) +}) diff --git a/apps/bot/src/anonymous-feedback.ts b/apps/bot/src/anonymous-feedback.ts new file mode 100644 index 0000000..fa5f298 --- /dev/null +++ b/apps/bot/src/anonymous-feedback.ts @@ -0,0 +1,101 @@ +import type { AnonymousFeedbackService } from '@household/application' +import type { Bot, Context } from 'grammy' + +function isPrivateChat(ctx: Context): boolean { + return ctx.chat?.type === 'private' +} + +function feedbackText(sanitizedText: string): string { + return ['Anonymous household note', '', sanitizedText].join('\n') +} + +function rejectionMessage(reason: string): string { + switch (reason) { + case 'not_member': + return 'You are not a member of this household.' + case 'too_short': + return 'Anonymous feedback is too short. Add a little more detail.' + case 'too_long': + return 'Anonymous feedback is too long. Keep it under 500 characters.' + case 'cooldown': + return 'Anonymous feedback cooldown is active. Try again later.' + case 'daily_cap': + return 'Daily anonymous feedback limit reached. Try again tomorrow.' + case 'blocklisted': + return 'Message rejected by moderation. Rewrite it in calmer, non-abusive language.' + default: + return 'Anonymous feedback could not be submitted.' + } +} + +export function registerAnonymousFeedback(options: { + bot: Bot + anonymousFeedbackService: AnonymousFeedbackService + householdChatId: string + feedbackTopicId: number +}): void { + options.bot.command('anon', async (ctx) => { + if (!isPrivateChat(ctx)) { + await ctx.reply('Use /anon in a private chat with the bot.') + return + } + + const rawText = typeof ctx.match === 'string' ? ctx.match.trim() : '' + if (rawText.length === 0) { + await ctx.reply('Usage: /anon ') + return + } + + const telegramUserId = ctx.from?.id?.toString() + const telegramChatId = ctx.chat?.id?.toString() + const telegramMessageId = ctx.msg?.message_id?.toString() + const telegramUpdateId = + 'update_id' in ctx.update ? ctx.update.update_id?.toString() : undefined + + if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) { + await ctx.reply('Unable to identify this message for anonymous feedback.') + return + } + + const result = await options.anonymousFeedbackService.submit({ + telegramUserId, + rawText, + telegramChatId, + telegramMessageId, + telegramUpdateId + }) + + if (result.status === 'duplicate') { + await ctx.reply('This anonymous feedback message was already processed.') + return + } + + if (result.status === 'rejected') { + await ctx.reply(rejectionMessage(result.reason)) + return + } + + try { + const posted = await ctx.api.sendMessage( + options.householdChatId, + feedbackText(result.sanitizedText), + { + message_thread_id: options.feedbackTopicId + } + ) + + await options.anonymousFeedbackService.markPosted({ + submissionId: result.submissionId, + postedChatId: options.householdChatId, + postedThreadId: options.feedbackTopicId.toString(), + postedMessageId: posted.message_id.toString() + }) + + await ctx.reply('Anonymous feedback delivered.') + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown Telegram send failure' + await options.anonymousFeedbackService.markFailed(result.submissionId, message) + await ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.') + } + }) +} diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index 5b10144..8377090 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -9,7 +9,8 @@ export function createTelegramBot(token: string): Bot { 'Household bot scaffold is live.', 'Available commands:', '/help - Show command list', - '/household_status - Show placeholder household status' + '/household_status - Show placeholder household status', + '/anon - Send anonymous household feedback in a private chat' ].join('\n') ) }) diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index f399042..bed48f3 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -7,8 +7,10 @@ export interface BotRuntimeConfig { householdId?: string telegramHouseholdChatId?: string telegramPurchaseTopicId?: number + telegramFeedbackTopicId?: number purchaseTopicIngestionEnabled: boolean financeCommandsEnabled: boolean + anonymousFeedbackEnabled: boolean miniAppAllowedOrigins: readonly string[] miniAppAuthEnabled: boolean schedulerSharedSecret?: string @@ -46,7 +48,7 @@ function parseOptionalTopicId(raw: string | undefined): number | undefined { const parsed = Number(raw) if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error(`Invalid TELEGRAM_PURCHASE_TOPIC_ID value: ${raw}`) + throw new Error(`Invalid Telegram topic id value: ${raw}`) } return parsed @@ -75,6 +77,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu const householdId = parseOptionalValue(env.HOUSEHOLD_ID) const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID) const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID) + const telegramFeedbackTopicId = parseOptionalTopicId(env.TELEGRAM_FEEDBACK_TOPIC_ID) const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET) const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS) const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS) @@ -86,6 +89,11 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramPurchaseTopicId !== undefined const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined + const anonymousFeedbackEnabled = + databaseUrl !== undefined && + householdId !== undefined && + telegramHouseholdChatId !== undefined && + telegramFeedbackTopicId !== undefined const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 const reminderJobsEnabled = @@ -100,6 +108,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram', purchaseTopicIngestionEnabled, financeCommandsEnabled, + anonymousFeedbackEnabled, miniAppAllowedOrigins, miniAppAuthEnabled, schedulerOidcAllowedEmails, @@ -119,6 +128,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu if (telegramPurchaseTopicId !== undefined) { runtime.telegramPurchaseTopicId = telegramPurchaseTopicId } + if (telegramFeedbackTopicId !== undefined) { + runtime.telegramFeedbackTopicId = telegramFeedbackTopicId + } if (schedulerSharedSecret !== undefined) { runtime.schedulerSharedSecret = schedulerSharedSecret } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 42702a1..334e184 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,11 +1,17 @@ import { webhookCallback } from 'grammy' -import { createFinanceCommandService, createReminderJobService } from '@household/application' import { + createAnonymousFeedbackService, + createFinanceCommandService, + createReminderJobService +} from '@household/application' +import { + createDbAnonymousFeedbackRepository, createDbFinanceRepository, createDbReminderDispatchRepository } from '@household/adapters-db' +import { registerAnonymousFeedback } from './anonymous-feedback' import { createFinanceCommandsService } from './finance-commands' import { createTelegramBot } from './bot' import { getBotRuntimeConfig } from './config' @@ -32,11 +38,21 @@ const financeRepositoryClient = const financeService = financeRepositoryClient ? createFinanceCommandService(financeRepositoryClient.repository) : null +const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled + ? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!) + : null +const anonymousFeedbackService = anonymousFeedbackRepositoryClient + ? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository) + : null if (financeRepositoryClient) { shutdownTasks.push(financeRepositoryClient.close) } +if (anonymousFeedbackRepositoryClient) { + shutdownTasks.push(anonymousFeedbackRepositoryClient.close) +} + if (runtime.purchaseTopicIngestionEnabled) { const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) shutdownTasks.push(purchaseRepositoryClient.close) @@ -90,6 +106,19 @@ if (!runtime.reminderJobsEnabled) { ) } +if (anonymousFeedbackService) { + registerAnonymousFeedback({ + bot, + anonymousFeedbackService, + householdChatId: runtime.telegramHouseholdChatId!, + feedbackTopicId: runtime.telegramFeedbackTopicId! + }) +} else { + console.warn( + 'Anonymous feedback is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_FEEDBACK_TOPIC_ID to enable.' + ) +} + const server = createBotWebhookServer({ webhookPath: runtime.telegramWebhookPath, webhookSecret: runtime.telegramWebhookSecret, diff --git a/docs/specs/HOUSEBOT-050-anonymous-feedback-dm.md b/docs/specs/HOUSEBOT-050-anonymous-feedback-dm.md new file mode 100644 index 0000000..98e6d3e --- /dev/null +++ b/docs/specs/HOUSEBOT-050-anonymous-feedback-dm.md @@ -0,0 +1,80 @@ +# HOUSEBOT-050: Anonymous Feedback DM Flow + +## Summary + +Allow household members to send private `/anon` messages to the bot and have them reposted into a configured household topic without exposing the sender. + +## Goals + +- Keep sender identity hidden from the group. +- Enforce simple anti-abuse policy with cooldown, daily cap, and blocklist checks. +- Persist moderation and delivery metadata for audit without any reveal path. + +## Non-goals + +- Identity reveal tooling. +- LLM rewriting or sentiment analysis. +- Admin moderation UI. + +## Scope + +- In: DM command handling, persistence, reposting to topic, deterministic sanitization, policy enforcement. +- Out: anonymous reactions, editing or deleting previous posts. + +## Interfaces and Contracts + +- Telegram command: `/anon ` in private chat only +- Runtime config: + - `TELEGRAM_HOUSEHOLD_CHAT_ID` + - `TELEGRAM_FEEDBACK_TOPIC_ID` +- Persistence: + - `anonymous_messages` + +## Domain Rules + +- Sender identity is never included in the reposted group message. +- Cooldown is six hours between accepted submissions. +- Daily cap is three accepted submissions per member in a rolling 24-hour window. +- Blocklisted abusive phrases are rejected and recorded. +- Links, `@mentions`, and phone-like strings are sanitized before repost. + +## Data Model Changes + +- `anonymous_messages` + - household/member linkage + - raw text + - sanitized text + - moderation status and reason + - source Telegram message IDs + - posted Telegram message IDs + - failure reason and timestamps + +## Security and Privacy + +- Household membership is verified before accepting feedback. +- Group-facing text contains no sender identity or source metadata. +- Duplicate Telegram updates are deduplicated at persistence level. + +## Observability + +- Failed reposts are persisted with failure reasons. +- Moderation outcomes remain queryable in the database. + +## Edge Cases and Failure Modes + +- Command used outside DM is rejected. +- Duplicate webhook delivery does not repost. +- Telegram post failure marks the submission as failed without exposing the sender. + +## Test Plan + +- Unit: moderation, cooldown, and delivery state transitions. +- Bot tests: DM command path and private-chat enforcement. +- Integration: repo quality gates and migration generation. + +## Acceptance Criteria + +- [ ] DM to household topic repost works end-to-end. +- [ ] Sender identity is hidden from the reposted message. +- [ ] Cooldown, daily cap, and blocklist are enforced. +- [ ] Moderation and delivery metadata are persisted. diff --git a/infra/terraform/README.md b/infra/terraform/README.md index 0fa8bf3..d9ca677 100644 --- a/infra/terraform/README.md +++ b/infra/terraform/README.md @@ -72,6 +72,7 @@ Recommended approach: - `bot_household_id` - `bot_household_chat_id` - `bot_purchase_topic_id` + - optional `bot_feedback_topic_id` - optional `bot_parser_model` - optional `bot_mini_app_allowed_origins` diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 61b932c..776689f 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -90,6 +90,9 @@ module "bot_api_service" { var.bot_purchase_topic_id == null ? {} : { TELEGRAM_PURCHASE_TOPIC_ID = tostring(var.bot_purchase_topic_id) }, + var.bot_feedback_topic_id == null ? {} : { + TELEGRAM_FEEDBACK_TOPIC_ID = tostring(var.bot_feedback_topic_id) + }, var.bot_parser_model == null ? {} : { PARSER_MODEL = var.bot_parser_model }, diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example index 0ac34c4..5a7ae5b 100644 --- a/infra/terraform/terraform.tfvars.example +++ b/infra/terraform/terraform.tfvars.example @@ -11,6 +11,7 @@ mini_app_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/mini bot_household_id = "11111111-1111-4111-8111-111111111111" bot_household_chat_id = "-1001234567890" bot_purchase_topic_id = 777 +bot_feedback_topic_id = 778 bot_parser_model = "gpt-4.1-mini" bot_mini_app_allowed_origins = [ "https://household-dev-mini-app-abc123-ew.a.run.app" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index c54b679..f531b65 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -104,6 +104,13 @@ variable "bot_purchase_topic_id" { nullable = true } +variable "bot_feedback_topic_id" { + description = "Optional TELEGRAM_FEEDBACK_TOPIC_ID value for bot runtime" + type = number + default = null + nullable = true +} + variable "bot_parser_model" { description = "Optional PARSER_MODEL override for bot runtime" type = string diff --git a/packages/adapters-db/src/anonymous-feedback-repository.ts b/packages/adapters-db/src/anonymous-feedback-repository.ts new file mode 100644 index 0000000..07e54d6 --- /dev/null +++ b/packages/adapters-db/src/anonymous-feedback-repository.ts @@ -0,0 +1,171 @@ +import { and, desc, eq, gte, inArray, sql } from 'drizzle-orm' + +import { createDbClient, schema } from '@household/db' +import type { AnonymousFeedbackRepository } from '@household/ports' + +const ACCEPTED_STATUSES = ['accepted', 'posted', 'failed'] as const + +export function createDbAnonymousFeedbackRepository( + databaseUrl: string, + householdId: string +): { + repository: AnonymousFeedbackRepository + close: () => Promise +} { + const { db, queryClient } = createDbClient(databaseUrl, { + max: 5, + prepare: false + }) + + const repository: AnonymousFeedbackRepository = { + async getMemberByTelegramUserId(telegramUserId) { + const rows = await db + .select({ + id: schema.members.id, + telegramUserId: schema.members.telegramUserId, + displayName: schema.members.displayName + }) + .from(schema.members) + .where( + and( + eq(schema.members.householdId, householdId), + eq(schema.members.telegramUserId, telegramUserId) + ) + ) + .limit(1) + + return rows[0] ?? null + }, + + async getRateLimitSnapshot(memberId, acceptedSince) { + const countRows = await db + .select({ + count: sql`count(*)` + }) + .from(schema.anonymousMessages) + .where( + and( + eq(schema.anonymousMessages.householdId, householdId), + eq(schema.anonymousMessages.submittedByMemberId, memberId), + inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES), + gte(schema.anonymousMessages.createdAt, acceptedSince) + ) + ) + + const lastRows = await db + .select({ + createdAt: schema.anonymousMessages.createdAt + }) + .from(schema.anonymousMessages) + .where( + and( + eq(schema.anonymousMessages.householdId, householdId), + eq(schema.anonymousMessages.submittedByMemberId, memberId), + inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES) + ) + ) + .orderBy(desc(schema.anonymousMessages.createdAt)) + .limit(1) + + return { + acceptedCountSince: Number(countRows[0]?.count ?? '0'), + lastAcceptedAt: lastRows[0]?.createdAt ?? null + } + }, + + async createSubmission(input) { + const inserted = await db + .insert(schema.anonymousMessages) + .values({ + householdId, + submittedByMemberId: input.submittedByMemberId, + rawText: input.rawText, + sanitizedText: input.sanitizedText, + moderationStatus: input.moderationStatus, + moderationReason: input.moderationReason, + telegramChatId: input.telegramChatId, + telegramMessageId: input.telegramMessageId, + telegramUpdateId: input.telegramUpdateId + }) + .onConflictDoNothing({ + target: [schema.anonymousMessages.householdId, schema.anonymousMessages.telegramUpdateId] + }) + .returning({ + id: schema.anonymousMessages.id, + moderationStatus: schema.anonymousMessages.moderationStatus + }) + + if (inserted[0]) { + return { + submission: { + id: inserted[0].id, + moderationStatus: inserted[0].moderationStatus as + | 'accepted' + | 'posted' + | 'rejected' + | 'failed' + }, + duplicate: false + } + } + + const existing = await db + .select({ + id: schema.anonymousMessages.id, + moderationStatus: schema.anonymousMessages.moderationStatus + }) + .from(schema.anonymousMessages) + .where( + and( + eq(schema.anonymousMessages.householdId, householdId), + eq(schema.anonymousMessages.telegramUpdateId, input.telegramUpdateId) + ) + ) + .limit(1) + + const row = existing[0] + if (!row) { + throw new Error('Anonymous feedback insert conflict without stored row') + } + + return { + submission: { + id: row.id, + moderationStatus: row.moderationStatus as 'accepted' | 'posted' | 'rejected' | 'failed' + }, + duplicate: true + } + }, + + async markPosted(input) { + await db + .update(schema.anonymousMessages) + .set({ + moderationStatus: 'posted', + postedChatId: input.postedChatId, + postedThreadId: input.postedThreadId, + postedMessageId: input.postedMessageId, + postedAt: input.postedAt, + failureReason: null + }) + .where(eq(schema.anonymousMessages.id, input.submissionId)) + }, + + async markFailed(submissionId, failureReason) { + await db + .update(schema.anonymousMessages) + .set({ + moderationStatus: 'failed', + failureReason + }) + .where(eq(schema.anonymousMessages.id, submissionId)) + } + } + + return { + repository, + close: async () => { + await queryClient.end({ timeout: 5 }) + } + } +} diff --git a/packages/adapters-db/src/index.ts b/packages/adapters-db/src/index.ts index bc08a9b..7bc3377 100644 --- a/packages/adapters-db/src/index.ts +++ b/packages/adapters-db/src/index.ts @@ -1,2 +1,3 @@ +export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository' export { createDbFinanceRepository } from './finance-repository' export { createDbReminderDispatchRepository } from './reminder-dispatch-repository' diff --git a/packages/application/src/anonymous-feedback-service.test.ts b/packages/application/src/anonymous-feedback-service.test.ts new file mode 100644 index 0000000..c7aad24 --- /dev/null +++ b/packages/application/src/anonymous-feedback-service.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from 'bun:test' + +import type { + AnonymousFeedbackMemberRecord, + AnonymousFeedbackRepository, + AnonymousFeedbackSubmissionRecord +} from '@household/ports' + +import { createAnonymousFeedbackService } from './anonymous-feedback-service' + +class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository { + member: AnonymousFeedbackMemberRecord | null = { + id: 'member-1', + telegramUserId: '123', + displayName: 'Stan' + } + + acceptedCountSince = 0 + lastAcceptedAt: Date | null = null + duplicate = false + created: Array<{ + rawText: string + sanitizedText: string | null + moderationStatus: string + moderationReason: string | null + }> = [] + posted: Array<{ submissionId: string; postedThreadId: string; postedMessageId: string }> = [] + failed: Array<{ submissionId: string; failureReason: string }> = [] + + async getMemberByTelegramUserId() { + return this.member + } + + async getRateLimitSnapshot() { + return { + acceptedCountSince: this.acceptedCountSince, + lastAcceptedAt: this.lastAcceptedAt + } + } + + async createSubmission(input: { + submittedByMemberId: string + rawText: string + sanitizedText: string | null + moderationStatus: 'accepted' | 'posted' | 'rejected' | 'failed' + moderationReason: string | null + telegramChatId: string + telegramMessageId: string + telegramUpdateId: string + }): Promise<{ submission: AnonymousFeedbackSubmissionRecord; duplicate: boolean }> { + this.created.push({ + rawText: input.rawText, + sanitizedText: input.sanitizedText, + moderationStatus: input.moderationStatus, + moderationReason: input.moderationReason + }) + + return { + submission: { + id: 'submission-1', + moderationStatus: input.moderationStatus + }, + duplicate: this.duplicate + } + } + + async markPosted(input: { + submissionId: string + postedChatId: string + postedThreadId: string + postedMessageId: string + postedAt: Date + }) { + this.posted.push({ + submissionId: input.submissionId, + postedThreadId: input.postedThreadId, + postedMessageId: input.postedMessageId + }) + } + + async markFailed(submissionId: string, failureReason: string) { + this.failed.push({ submissionId, failureReason }) + } +} + +describe('createAnonymousFeedbackService', () => { + test('accepts and sanitizes a valid submission', async () => { + const repository = new AnonymousFeedbackRepositoryStub() + const service = createAnonymousFeedbackService(repository) + + const result = await service.submit({ + telegramUserId: '123', + rawText: 'Please clean the kitchen tonight @roommate https://example.com', + telegramChatId: 'chat-1', + telegramMessageId: 'message-1', + telegramUpdateId: 'update-1', + now: new Date('2026-03-08T12:00:00.000Z') + }) + + expect(result).toEqual({ + status: 'accepted', + submissionId: 'submission-1', + sanitizedText: 'Please clean the kitchen tonight [mention removed] [link removed]' + }) + expect(repository.created[0]).toMatchObject({ + moderationStatus: 'accepted' + }) + }) + + test('rejects non-members before persistence', async () => { + const repository = new AnonymousFeedbackRepositoryStub() + repository.member = null + const service = createAnonymousFeedbackService(repository) + + const result = await service.submit({ + telegramUserId: '404', + rawText: 'Please wash the dishes tonight', + telegramChatId: 'chat-1', + telegramMessageId: 'message-1', + telegramUpdateId: 'update-1' + }) + + expect(result).toEqual({ + status: 'rejected', + reason: 'not_member' + }) + expect(repository.created).toHaveLength(0) + }) + + test('rejects blocklisted content and persists moderation outcome', async () => { + const repository = new AnonymousFeedbackRepositoryStub() + const service = createAnonymousFeedbackService(repository) + + const result = await service.submit({ + telegramUserId: '123', + rawText: 'You are an idiot and this is disgusting', + telegramChatId: 'chat-1', + telegramMessageId: 'message-1', + telegramUpdateId: 'update-1' + }) + + expect(result).toEqual({ + status: 'rejected', + reason: 'blocklisted', + detail: 'idiot' + }) + expect(repository.created[0]).toMatchObject({ + moderationStatus: 'rejected', + moderationReason: 'blocklisted:idiot' + }) + }) + + test('enforces cooldown and daily cap', async () => { + const repository = new AnonymousFeedbackRepositoryStub() + const service = createAnonymousFeedbackService(repository) + + repository.lastAcceptedAt = new Date('2026-03-08T09:00:00.000Z') + + const cooldownResult = await service.submit({ + telegramUserId: '123', + rawText: 'Please take the trash out tonight', + telegramChatId: 'chat-1', + telegramMessageId: 'message-1', + telegramUpdateId: 'update-1', + now: new Date('2026-03-08T12:00:00.000Z') + }) + + expect(cooldownResult).toEqual({ + status: 'rejected', + reason: 'cooldown' + }) + + repository.lastAcceptedAt = new Date('2026-03-07T00:00:00.000Z') + repository.acceptedCountSince = 3 + + const dailyCapResult = await service.submit({ + telegramUserId: '123', + rawText: 'Please ventilate the bathroom after showers', + telegramChatId: 'chat-1', + telegramMessageId: 'message-2', + telegramUpdateId: 'update-2', + now: new Date('2026-03-08T12:00:00.000Z') + }) + + expect(dailyCapResult).toEqual({ + status: 'rejected', + reason: 'daily_cap' + }) + }) + + test('marks posted and failed submissions', async () => { + const repository = new AnonymousFeedbackRepositoryStub() + const service = createAnonymousFeedbackService(repository) + + await service.markPosted({ + submissionId: 'submission-1', + postedChatId: 'group-1', + postedThreadId: 'thread-1', + postedMessageId: 'post-1' + }) + await service.markFailed('submission-2', 'telegram send failed') + + expect(repository.posted).toEqual([ + { + submissionId: 'submission-1', + postedThreadId: 'thread-1', + postedMessageId: 'post-1' + } + ]) + expect(repository.failed).toEqual([ + { + submissionId: 'submission-2', + failureReason: 'telegram send failed' + } + ]) + }) +}) diff --git a/packages/application/src/anonymous-feedback-service.ts b/packages/application/src/anonymous-feedback-service.ts new file mode 100644 index 0000000..8ba7ea1 --- /dev/null +++ b/packages/application/src/anonymous-feedback-service.ts @@ -0,0 +1,223 @@ +import type { + AnonymousFeedbackRejectionReason, + AnonymousFeedbackRepository +} from '@household/ports' + +const MIN_MESSAGE_LENGTH = 12 +const MAX_MESSAGE_LENGTH = 500 +const COOLDOWN_HOURS = 6 +const DAILY_CAP = 3 +const BLOCKLIST = ['kill yourself', 'сука', 'тварь', 'идиот', 'idiot', 'hate you'] as const + +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +function sanitizeAnonymousText(rawText: string): string { + return collapseWhitespace(rawText) + .replace(/https?:\/\/\S+/gi, '[link removed]') + .replace(/@\w+/g, '[mention removed]') + .replace(/\+?\d[\d\s\-()]{8,}\d/g, '[contact removed]') +} + +function findBlocklistedPhrase(value: string): string | null { + const normalized = value.toLowerCase() + + for (const phrase of BLOCKLIST) { + if (normalized.includes(phrase)) { + return phrase + } + } + + return null +} + +export type AnonymousFeedbackSubmitResult = + | { + status: 'accepted' + submissionId: string + sanitizedText: string + } + | { + status: 'duplicate' + submissionId: string + } + | { + status: 'rejected' + reason: AnonymousFeedbackRejectionReason + detail?: string + } + +export interface AnonymousFeedbackService { + submit(input: { + telegramUserId: string + rawText: string + telegramChatId: string + telegramMessageId: string + telegramUpdateId: string + now?: Date + }): Promise + markPosted(input: { + submissionId: string + postedChatId: string + postedThreadId: string + postedMessageId: string + postedAt?: Date + }): Promise + markFailed(submissionId: string, failureReason: string): Promise +} + +async function rejectSubmission( + repository: AnonymousFeedbackRepository, + input: { + memberId: string + rawText: string + reason: AnonymousFeedbackRejectionReason + detail?: string + telegramChatId: string + telegramMessageId: string + telegramUpdateId: string + } +): Promise { + const created = await repository.createSubmission({ + submittedByMemberId: input.memberId, + rawText: input.rawText, + sanitizedText: null, + moderationStatus: 'rejected', + moderationReason: input.detail ? `${input.reason}:${input.detail}` : input.reason, + telegramChatId: input.telegramChatId, + telegramMessageId: input.telegramMessageId, + telegramUpdateId: input.telegramUpdateId + }) + + if (created.duplicate) { + return { + status: 'duplicate', + submissionId: created.submission.id + } + } + + return { + status: 'rejected', + reason: input.reason, + ...(input.detail ? { detail: input.detail } : {}) + } +} + +export function createAnonymousFeedbackService( + repository: AnonymousFeedbackRepository +): AnonymousFeedbackService { + return { + async submit(input) { + const member = await repository.getMemberByTelegramUserId(input.telegramUserId) + if (!member) { + return { + status: 'rejected', + reason: 'not_member' + } + } + + const sanitizedText = sanitizeAnonymousText(input.rawText) + if (sanitizedText.length < MIN_MESSAGE_LENGTH) { + return rejectSubmission(repository, { + memberId: member.id, + rawText: input.rawText, + reason: 'too_short', + telegramChatId: input.telegramChatId, + telegramMessageId: input.telegramMessageId, + telegramUpdateId: input.telegramUpdateId + }) + } + + if (sanitizedText.length > MAX_MESSAGE_LENGTH) { + return rejectSubmission(repository, { + memberId: member.id, + rawText: input.rawText, + reason: 'too_long', + telegramChatId: input.telegramChatId, + telegramMessageId: input.telegramMessageId, + telegramUpdateId: input.telegramUpdateId + }) + } + + const blockedPhrase = findBlocklistedPhrase(sanitizedText) + if (blockedPhrase) { + return rejectSubmission(repository, { + memberId: member.id, + rawText: input.rawText, + reason: 'blocklisted', + detail: blockedPhrase, + telegramChatId: input.telegramChatId, + telegramMessageId: input.telegramMessageId, + telegramUpdateId: input.telegramUpdateId + }) + } + + const now = input.now ?? new Date() + const acceptedSince = new Date(now.getTime() - 24 * 60 * 60 * 1000) + const rateLimit = await repository.getRateLimitSnapshot(member.id, acceptedSince) + if (rateLimit.acceptedCountSince >= DAILY_CAP) { + return rejectSubmission(repository, { + memberId: member.id, + rawText: input.rawText, + reason: 'daily_cap', + telegramChatId: input.telegramChatId, + telegramMessageId: input.telegramMessageId, + telegramUpdateId: input.telegramUpdateId + }) + } + + if (rateLimit.lastAcceptedAt) { + const cooldownBoundary = now.getTime() - COOLDOWN_HOURS * 60 * 60 * 1000 + if (rateLimit.lastAcceptedAt.getTime() > cooldownBoundary) { + return rejectSubmission(repository, { + memberId: member.id, + rawText: input.rawText, + reason: 'cooldown', + telegramChatId: input.telegramChatId, + telegramMessageId: input.telegramMessageId, + telegramUpdateId: input.telegramUpdateId + }) + } + } + + const created = await repository.createSubmission({ + submittedByMemberId: member.id, + rawText: input.rawText, + sanitizedText, + moderationStatus: 'accepted', + moderationReason: null, + telegramChatId: input.telegramChatId, + telegramMessageId: input.telegramMessageId, + telegramUpdateId: input.telegramUpdateId + }) + + if (created.duplicate) { + return { + status: 'duplicate', + submissionId: created.submission.id + } + } + + return { + status: 'accepted', + submissionId: created.submission.id, + sanitizedText + } + }, + + markPosted(input) { + return repository.markPosted({ + submissionId: input.submissionId, + postedChatId: input.postedChatId, + postedThreadId: input.postedThreadId, + postedMessageId: input.postedMessageId, + postedAt: input.postedAt ?? new Date() + }) + }, + + markFailed(submissionId, failureReason) { + return repository.markFailed(submissionId, failureReason) + } + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index ffb204b..6430490 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -1,4 +1,9 @@ export { calculateMonthlySettlement } from './settlement-engine' +export { + createAnonymousFeedbackService, + type AnonymousFeedbackService, + type AnonymousFeedbackSubmitResult +} from './anonymous-feedback-service' export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service' export { createReminderJobService, diff --git a/packages/db/drizzle/0004_big_ultimatum.sql b/packages/db/drizzle/0004_big_ultimatum.sql new file mode 100644 index 0000000..7cf7451 --- /dev/null +++ b/packages/db/drizzle/0004_big_ultimatum.sql @@ -0,0 +1,24 @@ +CREATE TABLE "anonymous_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "submitted_by_member_id" uuid NOT NULL, + "raw_text" text NOT NULL, + "sanitized_text" text, + "moderation_status" text NOT NULL, + "moderation_reason" text, + "telegram_chat_id" text NOT NULL, + "telegram_message_id" text NOT NULL, + "telegram_update_id" text NOT NULL, + "posted_chat_id" text, + "posted_thread_id" text, + "posted_message_id" text, + "failure_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "posted_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "anonymous_messages" ADD CONSTRAINT "anonymous_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 "anonymous_messages" ADD CONSTRAINT "anonymous_messages_submitted_by_member_id_members_id_fk" FOREIGN KEY ("submitted_by_member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "anonymous_messages_household_tg_update_unique" ON "anonymous_messages" USING btree ("household_id","telegram_update_id");--> statement-breakpoint +CREATE INDEX "anonymous_messages_member_created_idx" ON "anonymous_messages" USING btree ("submitted_by_member_id","created_at");--> statement-breakpoint +CREATE INDEX "anonymous_messages_status_created_idx" ON "anonymous_messages" USING btree ("moderation_status","created_at"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0004_snapshot.json b/packages/db/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..d571b31 --- /dev/null +++ b/packages/db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1587 @@ +{ + "id": "49fee72d-1d0d-4f6e-a74f-bc4f0cc15270", + "prevId": "67e9eddc-9734-443e-a731-8a63cbf49145", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.anonymous_messages": { + "name": "anonymous_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submitted_by_member_id": { + "name": "submitted_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sanitized_text": { + "name": "sanitized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderation_reason": { + "name": "moderation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "posted_chat_id": { + "name": "posted_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_thread_id": { + "name": "posted_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_message_id": { + "name": "posted_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "anonymous_messages_household_tg_update_unique": { + "name": "anonymous_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_member_created_idx": { + "name": "anonymous_messages_member_created_idx", + "columns": [ + { + "expression": "submitted_by_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_status_created_idx": { + "name": "anonymous_messages_status_created_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anonymous_messages_household_id_households_id_fk": { + "name": "anonymous_messages_household_id_households_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "anonymous_messages_submitted_by_member_id_members_id_fk": { + "name": "anonymous_messages_submitted_by_member_id_members_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "members", + "columnsFrom": ["submitted_by_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_cycles": { + "name": "billing_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_cycles_household_period_unique": { + "name": "billing_cycles_household_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycles_household_period_idx": { + "name": "billing_cycles_household_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycles_household_id_households_id_fk": { + "name": "billing_cycles_household_id_households_id_fk", + "tableFrom": "billing_cycles", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_household_idx": { + "name": "members_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_household_tg_user_unique": { + "name": "members_household_tg_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_household_id_households_id_fk": { + "name": "members_household_id_households_id_fk", + "tableFrom": "members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_overrides": { + "name": "presence_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "utility_days": { + "name": "utility_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "presence_overrides_cycle_member_unique": { + "name": "presence_overrides_cycle_member_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presence_overrides_cycle_idx": { + "name": "presence_overrides_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "presence_overrides_cycle_id_billing_cycles_id_fk": { + "name": "presence_overrides_cycle_id_billing_cycles_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "presence_overrides_member_id_members_id_fk": { + "name": "presence_overrides_member_id_members_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_bot_messages": { + "name": "processed_bot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_key": { + "name": "source_message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "processed_bot_messages_source_message_unique": { + "name": "processed_bot_messages_source_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "processed_bot_messages_household_id_households_id_fk": { + "name": "processed_bot_messages_household_id_households_id_fk", + "tableFrom": "processed_bot_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_entries": { + "name": "purchase_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer_member_id": { + "name": "payer_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_entries_household_cycle_idx": { + "name": "purchase_entries_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_payer_idx": { + "name": "purchase_entries_payer_idx", + "columns": [ + { + "expression": "payer_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_household_tg_message_unique": { + "name": "purchase_entries_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_entries_household_id_households_id_fk": { + "name": "purchase_entries_household_id_households_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_entries_cycle_id_billing_cycles_id_fk": { + "name": "purchase_entries_cycle_id_billing_cycles_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "purchase_entries_payer_member_id_members_id_fk": { + "name": "purchase_entries_payer_member_id_members_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "members", + "columnsFrom": ["payer_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_messages": { + "name": "purchase_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parsed_amount_minor": { + "name": "parsed_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "parsed_currency": { + "name": "parsed_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_item_description": { + "name": "parsed_item_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parser_error": { + "name": "parser_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_messages_household_thread_idx": { + "name": "purchase_messages_household_thread_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_sender_idx": { + "name": "purchase_messages_sender_idx", + "columns": [ + { + "expression": "sender_telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_message_unique": { + "name": "purchase_messages_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_update_unique": { + "name": "purchase_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_messages_household_id_households_id_fk": { + "name": "purchase_messages_household_id_households_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_messages_sender_member_id_members_id_fk": { + "name": "purchase_messages_sender_member_id_members_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "members", + "columnsFrom": ["sender_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rent_rules": { + "name": "rent_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_to_period": { + "name": "effective_to_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rent_rules_household_from_period_unique": { + "name": "rent_rules_household_from_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rent_rules_household_from_period_idx": { + "name": "rent_rules_household_from_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rent_rules_household_id_households_id_fk": { + "name": "rent_rules_household_id_households_id_fk", + "tableFrom": "rent_rules", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlement_lines": { + "name": "settlement_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_share_minor": { + "name": "rent_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "utility_share_minor": { + "name": "utility_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "purchase_offset_minor": { + "name": "purchase_offset_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "net_due_minor": { + "name": "net_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "explanations": { + "name": "explanations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlement_lines_settlement_member_unique": { + "name": "settlement_lines_settlement_member_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlement_lines_settlement_idx": { + "name": "settlement_lines_settlement_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlement_lines_settlement_id_settlements_id_fk": { + "name": "settlement_lines_settlement_id_settlements_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "settlements", + "columnsFrom": ["settlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlement_lines_member_id_members_id_fk": { + "name": "settlement_lines_member_id_members_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_due_minor": { + "name": "total_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "settlements_cycle_unique": { + "name": "settlements_cycle_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_household_computed_idx": { + "name": "settlements_household_computed_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "computed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_household_id_households_id_fk": { + "name": "settlements_household_id_households_id_fk", + "tableFrom": "settlements", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlements_cycle_id_billing_cycles_id_fk": { + "name": "settlements_cycle_id_billing_cycles_id_fk", + "tableFrom": "settlements", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.utility_bills": { + "name": "utility_bills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bill_name": { + "name": "bill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by_member_id": { + "name": "created_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "utility_bills_cycle_idx": { + "name": "utility_bills_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "utility_bills_household_cycle_idx": { + "name": "utility_bills_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "utility_bills_household_id_households_id_fk": { + "name": "utility_bills_household_id_households_id_fk", + "tableFrom": "utility_bills", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_cycle_id_billing_cycles_id_fk": { + "name": "utility_bills_cycle_id_billing_cycles_id_fk", + "tableFrom": "utility_bills", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_created_by_member_id_members_id_fk": { + "name": "utility_bills_created_by_member_id_members_id_fk", + "tableFrom": "utility_bills", + "tableTo": "members", + "columnsFrom": ["created_by_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 7bbefc1..65b03ec 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1772671128084, "tag": "0003_mature_roulette", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1772995779819, + "tag": "0004_big_ultimatum", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 9439eb6..4f2d645 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -247,6 +247,46 @@ export const processedBotMessages = pgTable( }) ) +export const anonymousMessages = pgTable( + 'anonymous_messages', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + submittedByMemberId: uuid('submitted_by_member_id') + .notNull() + .references(() => members.id, { onDelete: 'restrict' }), + rawText: text('raw_text').notNull(), + sanitizedText: text('sanitized_text'), + moderationStatus: text('moderation_status').notNull(), + moderationReason: text('moderation_reason'), + telegramChatId: text('telegram_chat_id').notNull(), + telegramMessageId: text('telegram_message_id').notNull(), + telegramUpdateId: text('telegram_update_id').notNull(), + postedChatId: text('posted_chat_id'), + postedThreadId: text('posted_thread_id'), + postedMessageId: text('posted_message_id'), + failureReason: text('failure_reason'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + postedAt: timestamp('posted_at', { withTimezone: true }) + }, + (table) => ({ + householdUpdateUnique: uniqueIndex('anonymous_messages_household_tg_update_unique').on( + table.householdId, + table.telegramUpdateId + ), + memberCreatedIdx: index('anonymous_messages_member_created_idx').on( + table.submittedByMemberId, + table.createdAt + ), + statusCreatedIdx: index('anonymous_messages_status_created_idx').on( + table.moderationStatus, + table.createdAt + ) + }) +) + export const settlements = pgTable( 'settlements', { @@ -308,4 +348,5 @@ 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 AnonymousMessage = typeof anonymousMessages.$inferSelect export type Settlement = typeof settlements.$inferSelect diff --git a/packages/ports/src/anonymous-feedback.ts b/packages/ports/src/anonymous-feedback.ts new file mode 100644 index 0000000..0805a2d --- /dev/null +++ b/packages/ports/src/anonymous-feedback.ts @@ -0,0 +1,51 @@ +export type AnonymousFeedbackModerationStatus = 'accepted' | 'posted' | 'rejected' | 'failed' + +export type AnonymousFeedbackRejectionReason = + | 'not_member' + | 'too_short' + | 'too_long' + | 'cooldown' + | 'daily_cap' + | 'blocklisted' + +export interface AnonymousFeedbackMemberRecord { + id: string + telegramUserId: string + displayName: string +} + +export interface AnonymousFeedbackRateLimitSnapshot { + acceptedCountSince: number + lastAcceptedAt: Date | null +} + +export interface AnonymousFeedbackSubmissionRecord { + id: string + moderationStatus: AnonymousFeedbackModerationStatus +} + +export interface AnonymousFeedbackRepository { + getMemberByTelegramUserId(telegramUserId: string): Promise + getRateLimitSnapshot( + memberId: string, + acceptedSince: Date + ): Promise + createSubmission(input: { + submittedByMemberId: string + rawText: string + sanitizedText: string | null + moderationStatus: AnonymousFeedbackModerationStatus + moderationReason: string | null + telegramChatId: string + telegramMessageId: string + telegramUpdateId: string + }): Promise<{ submission: AnonymousFeedbackSubmissionRecord; duplicate: boolean }> + markPosted(input: { + submissionId: string + postedChatId: string + postedThreadId: string + postedMessageId: string + postedAt: Date + }): Promise + markFailed(submissionId: string, failureReason: string): Promise +} diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 6ec77f4..f793c4a 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -5,6 +5,14 @@ export { type ReminderDispatchRepository, type ReminderType } from './reminders' +export type { + AnonymousFeedbackMemberRecord, + AnonymousFeedbackModerationStatus, + AnonymousFeedbackRateLimitSnapshot, + AnonymousFeedbackRejectionReason, + AnonymousFeedbackRepository, + AnonymousFeedbackSubmissionRecord +} from './anonymous-feedback' export type { FinanceCycleRecord, FinanceMemberRecord, From 01c58d2e9431d91267eddb706668473a1620f4b8 Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 23:54:07 +0400 Subject: [PATCH 08/10] fix(infra): include adapters workspace in docker builds --- apps/bot/Dockerfile | 1 + apps/miniapp/Dockerfile | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/bot/Dockerfile b/apps/bot/Dockerfile index a8745af..59f4b06 100644 --- a/apps/bot/Dockerfile +++ b/apps/bot/Dockerfile @@ -6,6 +6,7 @@ WORKDIR /app COPY bun.lock package.json tsconfig.base.json ./ COPY apps/bot/package.json apps/bot/package.json COPY apps/miniapp/package.json apps/miniapp/package.json +COPY packages/adapters-db/package.json packages/adapters-db/package.json COPY packages/application/package.json packages/application/package.json COPY packages/config/package.json packages/config/package.json COPY packages/contracts/package.json packages/contracts/package.json diff --git a/apps/miniapp/Dockerfile b/apps/miniapp/Dockerfile index 3590bca..9949a6f 100644 --- a/apps/miniapp/Dockerfile +++ b/apps/miniapp/Dockerfile @@ -6,6 +6,7 @@ WORKDIR /app COPY bun.lock package.json tsconfig.base.json ./ COPY apps/bot/package.json apps/bot/package.json COPY apps/miniapp/package.json apps/miniapp/package.json +COPY packages/adapters-db/package.json packages/adapters-db/package.json COPY packages/application/package.json packages/application/package.json COPY packages/config/package.json packages/config/package.json COPY packages/contracts/package.json packages/contracts/package.json From 91a040f2ee57a8e5f20eb05229fcc93cebe4affe Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 23:57:28 +0400 Subject: [PATCH 09/10] fix(bot): declare workspace runtime dependencies --- apps/bot/package.json | 2 ++ bun.lock | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/bot/package.json b/apps/bot/package.json index 3308863..f58e078 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -13,6 +13,8 @@ "@household/adapters-db": "workspace:*", "@household/application": "workspace:*", "@household/db": "workspace:*", + "@household/domain": "workspace:*", + "@household/ports": "workspace:*", "drizzle-orm": "^0.44.7", "google-auth-library": "^10.4.1", "grammy": "1.41.1" diff --git a/bun.lock b/bun.lock index 1270718..e6c53e5 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,8 @@ "@household/adapters-db": "workspace:*", "@household/application": "workspace:*", "@household/db": "workspace:*", + "@household/domain": "workspace:*", + "@household/ports": "workspace:*", "drizzle-orm": "^0.44.7", "google-auth-library": "^10.4.1", "grammy": "1.41.1", From c8b17136be9517671c59d8ac1bab3a771c6ffc96 Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 9 Mar 2026 00:30:31 +0400 Subject: [PATCH 10/10] fix(review): harden miniapp auth and finance flows --- apps/bot/src/miniapp-auth.test.ts | 93 ++++++++++++++----- apps/bot/src/miniapp-auth.ts | 36 ++++++- apps/bot/src/miniapp-dashboard.test.ts | 56 +++++++---- apps/bot/src/miniapp-dashboard.ts | 4 +- apps/bot/src/reminder-jobs.ts | 13 +-- apps/bot/src/scheduler-auth.ts | 6 +- apps/bot/src/telegram-miniapp-auth.test.ts | 42 ++++----- apps/bot/src/telegram-miniapp-auth.ts | 6 +- apps/bot/src/telegram-miniapp-test-helpers.ts | 19 ++++ apps/miniapp/src/App.tsx | 6 +- apps/miniapp/src/index.css | 17 ++++ apps/miniapp/src/miniapp-api.ts | 2 +- apps/miniapp/src/telegram-webapp.ts | 4 + docs/runbooks/iac-terraform.md | 6 ++ docs/specs/HOUSEBOT-040-miniapp-shell.md | 2 +- .../src/anonymous-feedback-repository.ts | 48 ++++------ .../adapters-db/src/finance-repository.ts | 76 ++++++++------- .../src/finance-command-service.ts | 9 +- .../src/reminder-job-service.test.ts | 5 + .../application/src/reminder-job-service.ts | 6 +- scripts/ops/deploy-smoke.ts | 10 +- scripts/ops/telegram-webhook.ts | 18 +++- 22 files changed, 327 insertions(+), 157 deletions(-) create mode 100644 apps/bot/src/telegram-miniapp-test-helpers.ts diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index e812255..d263453 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -1,27 +1,9 @@ import { describe, expect, test } from 'bun:test' -import { createHmac } from 'node:crypto' import type { FinanceRepository } from '@household/ports' import { createMiniAppAuthHandler } from './miniapp-auth' - -function buildInitData(botToken: string, authDate: number, user: object): string { - const params = new URLSearchParams() - params.set('auth_date', authDate.toString()) - params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc') - params.set('user', JSON.stringify(user)) - - const dataCheckString = [...params.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => `${key}=${value}`) - .join('\n') - - const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() - const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') - params.set('hash', hash) - - return params.toString() -} +import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' function repository( member: Awaited> @@ -66,7 +48,7 @@ describe('createMiniAppAuthHandler', () => { 'content-type': 'application/json' }, body: JSON.stringify({ - initData: buildInitData('test-bot-token', authDate, { + initData: buildMiniAppInitData('test-bot-token', authDate, { id: 123456, first_name: 'Stan', username: 'stanislav', @@ -114,7 +96,7 @@ describe('createMiniAppAuthHandler', () => { 'content-type': 'application/json' }, body: JSON.stringify({ - initData: buildInitData('test-bot-token', authDate, { + initData: buildMiniAppInitData('test-bot-token', authDate, { id: 123456, first_name: 'Stan' }) @@ -129,4 +111,73 @@ describe('createMiniAppAuthHandler', () => { reason: 'not_member' }) }) + + test('returns 400 for malformed JSON bodies', async () => { + const auth = createMiniAppAuthHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + repository: repository(null) + }) + + const response = await auth.handler( + new Request('http://localhost/api/miniapp/session', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: '{"initData":' + }) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + ok: false, + error: 'Invalid JSON body' + }) + }) + + test('does not reflect arbitrary origins in production without an allow-list', async () => { + const previousNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + + try { + const authDate = Math.floor(Date.now() / 1000) + const auth = createMiniAppAuthHandler({ + allowedOrigins: [], + botToken: 'test-bot-token', + repository: repository({ + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + }) + }) + + const response = await auth.handler( + new Request('http://localhost/api/miniapp/session', { + method: 'POST', + headers: { + origin: 'https://unknown.example', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildMiniAppInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan' + }) + }) + }) + ) + + expect(response.status).toBe(200) + expect(response.headers.get('access-control-allow-origin')).toBeNull() + } finally { + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previousNodeEnv + } + } + }) }) diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts index 3316a16..8d99693 100644 --- a/apps/bot/src/miniapp-auth.ts +++ b/apps/bot/src/miniapp-auth.ts @@ -22,7 +22,10 @@ export function miniAppJsonResponse(body: object, status = 200, origin?: string) export function allowedMiniAppOrigin( request: Request, - allowedOrigins: readonly string[] + allowedOrigins: readonly string[], + options: { + allowDynamicOrigin?: boolean + } = {} ): string | undefined { const origin = request.headers.get('origin') @@ -31,7 +34,8 @@ export function allowedMiniAppOrigin( } if (allowedOrigins.length === 0) { - return origin + const allowDynamicOrigin = options.allowDynamicOrigin ?? process.env.NODE_ENV !== 'production' + return allowDynamicOrigin ? origin : undefined } return allowedOrigins.includes(origin) ? origin : undefined @@ -44,12 +48,35 @@ export async function readMiniAppInitData(request: Request): Promise 0 ? initData : null } +export function miniAppErrorResponse(error: unknown, origin?: string): Response { + const message = error instanceof Error ? error.message : 'Unknown mini app error' + + if (message === 'Invalid JSON body') { + return miniAppJsonResponse({ ok: false, error: message }, 400, origin) + } + + console.error( + JSON.stringify({ + event: 'miniapp.request_failed', + error: message + }) + ) + + return miniAppJsonResponse({ ok: false, error: 'Internal Server Error' }, 500, origin) +} + export interface MiniAppSessionResult { authorized: boolean reason?: 'not_member' @@ -163,8 +190,7 @@ export function createMiniAppAuthHandler(options: { origin ) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown mini app auth error' - return miniAppJsonResponse({ ok: false, error: message }, 400, origin) + return miniAppErrorResponse(error, origin) } } } diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 180c9be..ec5e8cf 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -1,28 +1,10 @@ import { describe, expect, test } from 'bun:test' -import { createHmac } from 'node:crypto' import { createFinanceCommandService } from '@household/application' import type { FinanceRepository } from '@household/ports' import { createMiniAppDashboardHandler } from './miniapp-dashboard' - -function buildInitData(botToken: string, authDate: number, user: object): string { - const params = new URLSearchParams() - params.set('auth_date', authDate.toString()) - params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc') - params.set('user', JSON.stringify(user)) - - const dataCheckString = [...params.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => `${key}=${value}`) - .join('\n') - - const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() - const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') - params.set('hash', hash) - - return params.toString() -} +import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' function repository( member: Awaited> @@ -106,7 +88,7 @@ describe('createMiniAppDashboardHandler', () => { 'content-type': 'application/json' }, body: JSON.stringify({ - initData: buildInitData('test-bot-token', authDate, { + initData: buildMiniAppInitData('test-bot-token', authDate, { id: 123456, first_name: 'Stan', username: 'stanislav', @@ -144,4 +126,38 @@ describe('createMiniAppDashboardHandler', () => { } }) }) + + test('returns 400 for malformed JSON bodies', async () => { + const financeService = createFinanceCommandService( + repository({ + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + }) + ) + + const dashboard = createMiniAppDashboardHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + financeService + }) + + const response = await dashboard.handler( + new Request('http://localhost/api/miniapp/dashboard', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: '{"initData":' + }) + ) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + ok: false, + error: 'Invalid JSON body' + }) + }) }) diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index f8f8dc4..bbcf8cb 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -3,6 +3,7 @@ import type { FinanceCommandService } from '@household/application' import { allowedMiniAppOrigin, createMiniAppSessionService, + miniAppErrorResponse, miniAppJsonResponse, readMiniAppInitData } from './miniapp-auth' @@ -98,8 +99,7 @@ export function createMiniAppDashboardHandler(options: { origin ) } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown mini app dashboard error' - return miniAppJsonResponse({ ok: false, error: message }, 400, origin) + return miniAppErrorResponse(error, origin) } } } diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts index 19618e3..a05143e 100644 --- a/apps/bot/src/reminder-jobs.ts +++ b/apps/bot/src/reminder-jobs.ts @@ -1,8 +1,6 @@ -import { BillingPeriod } from '@household/domain' import type { ReminderJobService } from '@household/application' - -const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const -type ReminderType = (typeof REMINDER_TYPES)[number] +import { BillingPeriod } from '@household/domain' +import { REMINDER_TYPES, type ReminderType } from '@household/ports' interface ReminderJobRequestBody { period?: string @@ -42,8 +40,11 @@ async function readBody(request: Request): Promise { return {} } - const parsed = JSON.parse(text) as ReminderJobRequestBody - return parsed + try { + return JSON.parse(text) as ReminderJobRequestBody + } catch { + throw new Error('Invalid JSON body') + } } export function createReminderJobsHandler(options: { diff --git a/apps/bot/src/scheduler-auth.ts b/apps/bot/src/scheduler-auth.ts index 981a1c3..e579cd6 100644 --- a/apps/bot/src/scheduler-auth.ts +++ b/apps/bot/src/scheduler-auth.ts @@ -57,10 +57,8 @@ export function createSchedulerRequestAuthorizer(options: { return true } - if (!oidcAudience || allowedEmails.size === 0) { - if (allowedEmails.size === 0) { - return false - } + if (allowedEmails.size === 0) { + return false } try { diff --git a/apps/bot/src/telegram-miniapp-auth.test.ts b/apps/bot/src/telegram-miniapp-auth.test.ts index 45e53f1..c5c3fb6 100644 --- a/apps/bot/src/telegram-miniapp-auth.test.ts +++ b/apps/bot/src/telegram-miniapp-auth.test.ts @@ -1,30 +1,12 @@ import { describe, expect, test } from 'bun:test' -import { createHmac } from 'node:crypto' import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' - -function buildInitData(botToken: string, authDate: number, user: object): string { - const params = new URLSearchParams() - params.set('auth_date', authDate.toString()) - params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc') - params.set('user', JSON.stringify(user)) - - const dataCheckString = [...params.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => `${key}=${value}`) - .join('\n') - - const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() - const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') - params.set('hash', hash) - - return params.toString() -} +import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' describe('verifyTelegramMiniAppInitData', () => { test('verifies valid init data and extracts user payload', () => { const now = new Date('2026-03-08T12:00:00.000Z') - const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), { + const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), { id: 123456, first_name: 'Stan', username: 'stanislav' @@ -44,7 +26,7 @@ describe('verifyTelegramMiniAppInitData', () => { test('rejects invalid hash', () => { const now = new Date('2026-03-08T12:00:00.000Z') const params = new URLSearchParams( - buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), { + buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), { id: 123456, first_name: 'Stan' }) @@ -58,7 +40,23 @@ describe('verifyTelegramMiniAppInitData', () => { test('rejects expired init data', () => { const now = new Date('2026-03-08T12:00:00.000Z') - const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000) - 7200, { + const initData = buildMiniAppInitData( + 'test-bot-token', + Math.floor(now.getTime() / 1000) - 7200, + { + id: 123456, + first_name: 'Stan' + } + ) + + const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600) + + expect(result).toBeNull() + }) + + test('rejects init data timestamps from the future', () => { + const now = new Date('2026-03-08T12:00:00.000Z') + const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000) + 5, { id: 123456, first_name: 'Stan' }) diff --git a/apps/bot/src/telegram-miniapp-auth.ts b/apps/bot/src/telegram-miniapp-auth.ts index fe543b9..6582f82 100644 --- a/apps/bot/src/telegram-miniapp-auth.ts +++ b/apps/bot/src/telegram-miniapp-auth.ts @@ -36,7 +36,11 @@ export function verifyTelegramMiniAppInitData( const authDateSeconds = Number(authDateRaw) const nowSeconds = Math.floor(now.getTime() / 1000) - if (Math.abs(nowSeconds - authDateSeconds) > maxAgeSeconds) { + if (authDateSeconds > nowSeconds) { + return null + } + + if (nowSeconds - authDateSeconds > maxAgeSeconds) { return null } diff --git a/apps/bot/src/telegram-miniapp-test-helpers.ts b/apps/bot/src/telegram-miniapp-test-helpers.ts new file mode 100644 index 0000000..f622ac0 --- /dev/null +++ b/apps/bot/src/telegram-miniapp-test-helpers.ts @@ -0,0 +1,19 @@ +import { createHmac } from 'node:crypto' + +export function buildMiniAppInitData(botToken: string, authDate: number, user: object): string { + const params = new URLSearchParams() + params.set('auth_date', authDate.toString()) + params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc') + params.set('user', JSON.stringify(user)) + + const dataCheckString = [...params.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + + const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() + const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') + params.set('hash', hash) + + return params.toString() +} diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 1f6eb22..b9acbbe 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -107,7 +107,11 @@ function App() { try { setDashboard(await fetchMiniAppDashboard(initData)) - } catch { + } catch (error) { + if (import.meta.env.DEV) { + console.warn('Failed to load mini app dashboard', error) + } + setDashboard(null) } } catch { diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index f65eb87..43ad106 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -131,14 +131,31 @@ button { background: rgb(247 179 137 / 0.14); } +.locale-switch__buttons button:focus-visible, +.nav-grid button:focus-visible, +.ghost-button:focus-visible { + outline: 2px solid #f7b389; + outline-offset: 2px; + border-color: rgb(247 179 137 / 0.7); +} + .hero-card, .panel { border: 1px solid rgb(255 255 255 / 0.1); + background-color: rgb(18 26 36 / 0.82); background: linear-gradient(180deg, rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.02)); + -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 24px 64px rgb(0 0 0 / 0.22); } +@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + .hero-card, + .panel { + background: rgb(18 26 36 / 0.94); + } +} + .hero-card { margin-top: 28px; border-radius: 28px; diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index c040b2f..89fa262 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -77,7 +77,7 @@ export async function fetchMiniAppSession(initData: string): Promise`count(*)` - }) - .from(schema.anonymousMessages) - .where( - and( - eq(schema.anonymousMessages.householdId, householdId), - eq(schema.anonymousMessages.submittedByMemberId, memberId), - inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES), - gte(schema.anonymousMessages.createdAt, acceptedSince) - ) - ) - - const lastRows = await db - .select({ - createdAt: schema.anonymousMessages.createdAt + acceptedCountSince: sql`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSince})`, + lastAcceptedAt: sql`max(${schema.anonymousMessages.createdAt})` }) .from(schema.anonymousMessages) .where( @@ -64,12 +62,10 @@ export function createDbAnonymousFeedbackRepository( inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES) ) ) - .orderBy(desc(schema.anonymousMessages.createdAt)) - .limit(1) return { - acceptedCountSince: Number(countRows[0]?.count ?? '0'), - lastAcceptedAt: lastRows[0]?.createdAt ?? null + acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'), + lastAcceptedAt: rows[0]?.lastAcceptedAt ?? null } }, @@ -99,11 +95,7 @@ export function createDbAnonymousFeedbackRepository( return { submission: { id: inserted[0].id, - moderationStatus: inserted[0].moderationStatus as - | 'accepted' - | 'posted' - | 'rejected' - | 'failed' + moderationStatus: parseModerationStatus(inserted[0].moderationStatus) }, duplicate: false } @@ -131,7 +123,7 @@ export function createDbAnonymousFeedbackRepository( return { submission: { id: row.id, - moderationStatus: row.moderationStatus as 'accepted' | 'posted' | 'rejected' | 'failed' + moderationStatus: parseModerationStatus(row.moderationStatus) }, duplicate: true } diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index 5d18644..05fae4f 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -299,48 +299,54 @@ export function createDbFinanceRepository( }, async replaceSettlementSnapshot(snapshot) { - const upserted = await db - .insert(schema.settlements) - .values({ - householdId, - cycleId: snapshot.cycleId, - inputHash: snapshot.inputHash, - totalDueMinor: snapshot.totalDueMinor, - currency: snapshot.currency, - metadata: snapshot.metadata - }) - .onConflictDoUpdate({ - target: [schema.settlements.cycleId], - set: { + await db.transaction(async (tx) => { + const upserted = await tx + .insert(schema.settlements) + .values({ + householdId, + cycleId: snapshot.cycleId, inputHash: snapshot.inputHash, totalDueMinor: snapshot.totalDueMinor, currency: snapshot.currency, - computedAt: new Date(), metadata: snapshot.metadata - } - }) - .returning({ id: schema.settlements.id }) + }) + .onConflictDoUpdate({ + target: [schema.settlements.cycleId], + set: { + inputHash: snapshot.inputHash, + totalDueMinor: snapshot.totalDueMinor, + currency: snapshot.currency, + computedAt: new Date(), + metadata: snapshot.metadata + } + }) + .returning({ id: schema.settlements.id }) - const settlementId = upserted[0]?.id - if (!settlementId) { - throw new Error('Failed to persist settlement snapshot') - } + const settlementId = upserted[0]?.id + if (!settlementId) { + throw new Error('Failed to persist settlement snapshot') + } - await db - .delete(schema.settlementLines) - .where(eq(schema.settlementLines.settlementId, settlementId)) + await tx + .delete(schema.settlementLines) + .where(eq(schema.settlementLines.settlementId, settlementId)) - await db.insert(schema.settlementLines).values( - snapshot.lines.map((line) => ({ - settlementId, - memberId: line.memberId, - rentShareMinor: line.rentShareMinor, - utilityShareMinor: line.utilityShareMinor, - purchaseOffsetMinor: line.purchaseOffsetMinor, - netDueMinor: line.netDueMinor, - explanations: line.explanations - })) - ) + if (snapshot.lines.length === 0) { + return + } + + await tx.insert(schema.settlementLines).values( + snapshot.lines.map((line) => ({ + settlementId, + memberId: line.memberId, + rentShareMinor: line.rentShareMinor, + utilityShareMinor: line.utilityShareMinor, + purchaseOffsetMinor: line.purchaseOffsetMinor, + netDueMinor: line.netDueMinor, + explanations: line.explanations + })) + ) + }) } } diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index 8edc41b..e59f9a9 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -232,11 +232,12 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina await repository.openCycle(period, currency) - return { - id: '', - period, - currency + const cycle = await repository.getCycleByPeriod(period) + if (!cycle) { + throw new Error(`Failed to load billing cycle for period ${period}`) } + + return cycle }, async closeCycle(periodArg) { diff --git a/packages/application/src/reminder-job-service.test.ts b/packages/application/src/reminder-job-service.test.ts index 2b1b620..e3d05d5 100644 --- a/packages/application/src/reminder-job-service.test.ts +++ b/packages/application/src/reminder-job-service.test.ts @@ -44,6 +44,10 @@ describe('createReminderJobService', () => { test('claims a dispatch once and returns the dedupe key', async () => { const repository = new ReminderDispatchRepositoryStub() + repository.nextResult = { + dedupeKey: '2026-03:rent-due', + claimed: true + } const service = createReminderJobService(repository) const result = await service.handleJob({ @@ -53,6 +57,7 @@ describe('createReminderJobService', () => { }) expect(result.status).toBe('claimed') + expect(result.dedupeKey).toBe('2026-03:rent-due') expect(repository.lastClaim).toMatchObject({ householdId: 'household-1', period: '2026-03', diff --git a/packages/application/src/reminder-job-service.ts b/packages/application/src/reminder-job-service.ts index dcdd121..b86b5f4 100644 --- a/packages/application/src/reminder-job-service.ts +++ b/packages/application/src/reminder-job-service.ts @@ -11,6 +11,10 @@ function computePayloadHash(payload: object): string { return createHash('sha256').update(JSON.stringify(payload)).digest('hex') } +function buildReminderDedupeKey(period: string, reminderType: ReminderType): string { + return `${period}:${reminderType}` +} + function createReminderMessage(reminderType: ReminderType, period: string): string { switch (reminderType) { case 'utilities': @@ -56,7 +60,7 @@ export function createReminderJobService( if (input.dryRun === true) { return { status: 'dry-run', - dedupeKey: `${period}:${input.reminderType}`, + dedupeKey: buildReminderDedupeKey(period, input.reminderType), payloadHash, reminderType: input.reminderType, period, diff --git a/scripts/ops/deploy-smoke.ts b/scripts/ops/deploy-smoke.ts index 07edaff..01ca854 100644 --- a/scripts/ops/deploy-smoke.ts +++ b/scripts/ops/deploy-smoke.ts @@ -15,7 +15,15 @@ function toUrl(base: string, path: string): URL { async function expectJson(url: URL, init: RequestInit, expectedStatus: number): Promise { const response = await fetch(url, init) const text = await response.text() - const payload = (text.length > 0 ? JSON.parse(text) : null) as unknown + let payload: unknown = null + + if (text.length > 0) { + try { + payload = JSON.parse(text) as unknown + } catch { + throw new Error(`${url.toString()} returned invalid JSON: ${text}`) + } + } if (response.status !== expectedStatus) { throw new Error( diff --git a/scripts/ops/telegram-webhook.ts b/scripts/ops/telegram-webhook.ts index 26b40ab..09015f8 100644 --- a/scripts/ops/telegram-webhook.ts +++ b/scripts/ops/telegram-webhook.ts @@ -9,11 +9,21 @@ function requireEnv(name: string): string { return value } -async function telegramRequest( +function parseCommand(raw: string | undefined): WebhookCommand { + const command = raw?.trim() || 'info' + + if (command === 'info' || command === 'set' || command === 'delete') { + return command + } + + throw new Error(`Unsupported command: ${command}`) +} + +async function telegramRequest( botToken: string, method: string, body?: URLSearchParams -): Promise { +): Promise { const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, { method: body ? 'POST' : 'GET', body @@ -27,11 +37,11 @@ async function telegramRequest( throw new Error(`Telegram ${method} failed: ${JSON.stringify(payload)}`) } - return payload.result + return payload.result as T } async function run(): Promise { - const command = (process.argv[2] ?? 'info') as WebhookCommand + const command = parseCommand(process.argv[2]) const botToken = requireEnv('TELEGRAM_BOT_TOKEN') switch (command) {