Merge pull request #15 from whekin/codex/whe-31-adapters

feat: add dashboard, deploy tooling, and anonymous feedback
This commit is contained in:
Stas
2026-03-08 23:31:02 +03:00
committed by GitHub
76 changed files with 7590 additions and 433 deletions

View File

@@ -6,6 +6,7 @@ WORKDIR /app
COPY bun.lock package.json tsconfig.base.json ./ COPY bun.lock package.json tsconfig.base.json ./
COPY apps/bot/package.json apps/bot/package.json COPY apps/bot/package.json apps/bot/package.json
COPY apps/miniapp/package.json apps/miniapp/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/application/package.json packages/application/package.json
COPY packages/config/package.json packages/config/package.json COPY packages/config/package.json packages/config/package.json
COPY packages/contracts/package.json packages/contracts/package.json COPY packages/contracts/package.json packages/contracts/package.json

View File

@@ -10,10 +10,13 @@
"lint": "oxlint \"src\"" "lint": "oxlint \"src\""
}, },
"dependencies": { "dependencies": {
"@household/adapters-db": "workspace:*",
"@household/application": "workspace:*", "@household/application": "workspace:*",
"@household/db": "workspace:*", "@household/db": "workspace:*",
"@household/domain": "workspace:*", "@household/domain": "workspace:*",
"@household/ports": "workspace:*",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"google-auth-library": "^10.4.1",
"grammy": "1.41.1" "grammy": "1.41.1"
} }
} }

View File

@@ -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.'
})
})
})

View File

@@ -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 <message>')
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.')
}
})
}

View File

@@ -9,7 +9,8 @@ export function createTelegramBot(token: string): Bot {
'Household bot scaffold is live.', 'Household bot scaffold is live.',
'Available commands:', 'Available commands:',
'/help - Show command list', '/help - Show command list',
'/household_status - Show placeholder household status' '/household_status - Show placeholder household status',
'/anon <message> - Send anonymous household feedback in a private chat'
].join('\n') ].join('\n')
) )
}) })

View File

@@ -7,8 +7,15 @@ export interface BotRuntimeConfig {
householdId?: string householdId?: string
telegramHouseholdChatId?: string telegramHouseholdChatId?: string
telegramPurchaseTopicId?: number telegramPurchaseTopicId?: number
telegramFeedbackTopicId?: number
purchaseTopicIngestionEnabled: boolean purchaseTopicIngestionEnabled: boolean
financeCommandsEnabled: boolean financeCommandsEnabled: boolean
anonymousFeedbackEnabled: boolean
miniAppAllowedOrigins: readonly string[]
miniAppAuthEnabled: boolean
schedulerSharedSecret?: string
schedulerOidcAllowedEmails: readonly string[]
reminderJobsEnabled: boolean
openaiApiKey?: string openaiApiKey?: string
parserModel: string parserModel: string
} }
@@ -41,7 +48,7 @@ function parseOptionalTopicId(raw: string | undefined): number | undefined {
const parsed = Number(raw) const parsed = Number(raw)
if (!Number.isInteger(parsed) || parsed <= 0) { 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 return parsed
@@ -52,11 +59,28 @@ function parseOptionalValue(value: string | undefined): string | undefined {
return trimmed && trimmed.length > 0 ? trimmed : 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 { export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig {
const databaseUrl = parseOptionalValue(env.DATABASE_URL) const databaseUrl = parseOptionalValue(env.DATABASE_URL)
const householdId = parseOptionalValue(env.HOUSEHOLD_ID) const householdId = parseOptionalValue(env.HOUSEHOLD_ID)
const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID) const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID)
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_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)
const purchaseTopicIngestionEnabled = const purchaseTopicIngestionEnabled =
databaseUrl !== undefined && databaseUrl !== undefined &&
@@ -65,6 +89,17 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
telegramPurchaseTopicId !== undefined telegramPurchaseTopicId !== undefined
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== 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 =
databaseUrl !== undefined &&
householdId !== undefined &&
(schedulerSharedSecret !== undefined || hasSchedulerOidcConfig)
const runtime: BotRuntimeConfig = { const runtime: BotRuntimeConfig = {
port: parsePort(env.PORT), port: parsePort(env.PORT),
@@ -73,6 +108,11 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram', telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
purchaseTopicIngestionEnabled, purchaseTopicIngestionEnabled,
financeCommandsEnabled, financeCommandsEnabled,
anonymousFeedbackEnabled,
miniAppAllowedOrigins,
miniAppAuthEnabled,
schedulerOidcAllowedEmails,
reminderJobsEnabled,
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini' parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini'
} }
@@ -88,6 +128,12 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
if (telegramPurchaseTopicId !== undefined) { if (telegramPurchaseTopicId !== undefined) {
runtime.telegramPurchaseTopicId = telegramPurchaseTopicId runtime.telegramPurchaseTopicId = telegramPurchaseTopicId
} }
if (telegramFeedbackTopicId !== undefined) {
runtime.telegramFeedbackTopicId = telegramFeedbackTopicId
}
if (schedulerSharedSecret !== undefined) {
runtime.schedulerSharedSecret = schedulerSharedSecret
}
const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY) const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY)
if (openaiApiKey !== undefined) { if (openaiApiKey !== undefined) {
runtime.openaiApiKey = openaiApiKey runtime.openaiApiKey = openaiApiKey

View File

@@ -1,53 +1,6 @@
import { calculateMonthlySettlement } from '@household/application' import type { FinanceCommandService } 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 { Bot, Context } from 'grammy' 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[] { function commandArgs(ctx: Context): string[] {
const raw = typeof ctx.match === 'string' ? ctx.match.trim() : '' const raw = typeof ctx.match === 'string' ? ctx.match.trim() : ''
if (raw.length === 0) { if (raw.length === 0) {
@@ -57,52 +10,17 @@ function commandArgs(ctx: Context): string[] {
return raw.split(/\s+/).filter(Boolean) return raw.split(/\s+/).filter(Boolean)
} }
function computeInputHash(payload: object): string { export function createFinanceCommandsService(financeService: FinanceCommandService): {
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
}
export function createFinanceCommandsService(
databaseUrl: string,
config: FinanceCommandsConfig
): {
register: (bot: Bot) => void register: (bot: Bot) => void
close: () => Promise<void>
} { } {
const { db, queryClient } = createDbClient(databaseUrl, { async function requireMember(ctx: Context) {
max: 5,
prepare: false
})
async function getMemberByTelegramUserId(
telegramUserId: string
): Promise<HouseholdMemberData | null> {
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<HouseholdMemberData | null> {
const telegramUserId = ctx.from?.id?.toString() const telegramUserId = ctx.from?.id?.toString()
if (!telegramUserId) { if (!telegramUserId) {
await ctx.reply('Unable to identify sender for this command.') await ctx.reply('Unable to identify sender for this command.')
return null return null
} }
const member = await getMemberByTelegramUserId(telegramUserId) const member = await financeService.getMemberByTelegramUserId(telegramUserId)
if (!member) { if (!member) {
await ctx.reply('You are not a member of this household.') await ctx.reply('You are not a member of this household.')
return null return null
@@ -111,13 +29,13 @@ export function createFinanceCommandsService(
return member return member
} }
async function requireAdmin(ctx: Context): Promise<HouseholdMemberData | null> { async function requireAdmin(ctx: Context) {
const member = await requireMember(ctx) const member = await requireMember(ctx)
if (!member) { if (!member) {
return null return null
} }
if (member.isAdmin !== 1) { if (!member.isAdmin) {
await ctx.reply('Only household admins can use this command.') await ctx.reply('Only household admins can use this command.')
return null return null
} }
@@ -125,217 +43,6 @@ export function createFinanceCommandsService(
return member return member
} }
async function getOpenCycle(): Promise<SettlementCycleData | null> {
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<SettlementCycleData | null> {
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<string> {
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<string>`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 { function register(bot: Bot): void {
bot.command('cycle_open', async (ctx) => { bot.command('cycle_open', async (ctx) => {
const admin = await requireAdmin(ctx) const admin = await requireAdmin(ctx)
@@ -350,21 +57,8 @@ export function createFinanceCommandsService(
} }
try { try {
const period = BillingPeriod.fromString(args[0]!).toString() const cycle = await financeService.openCycle(args[0]!, args[1])
const currency = parseCurrency(args[1], 'USD') await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`)
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})`)
} catch (error) { } catch (error) {
await ctx.reply(`Failed to open cycle: ${(error as Error).message}`) await ctx.reply(`Failed to open cycle: ${(error as Error).message}`)
} }
@@ -376,21 +70,13 @@ export function createFinanceCommandsService(
return return
} }
const args = commandArgs(ctx)
try { try {
const cycle = await getCycleByPeriodOrLatest(args[0]) const cycle = await financeService.closeCycle(commandArgs(ctx)[0])
if (!cycle) { if (!cycle) {
await ctx.reply('No cycle found to close.') await ctx.reply('No cycle found to close.')
return return
} }
await db
.update(schema.billingCycles)
.set({
closedAt: new Date()
})
.where(eq(schema.billingCycles.id, cycle.id))
await ctx.reply(`Cycle closed: ${cycle.period}`) await ctx.reply(`Cycle closed: ${cycle.period}`)
} catch (error) { } catch (error) {
await ctx.reply(`Failed to close cycle: ${(error as Error).message}`) await ctx.reply(`Failed to close cycle: ${(error as Error).message}`)
@@ -410,34 +96,14 @@ export function createFinanceCommandsService(
} }
try { try {
const openCycle = await getOpenCycle() const result = await financeService.setRent(args[0]!, args[1], args[2])
const period = args[2] ?? openCycle?.period if (!result) {
if (!period) {
await ctx.reply('No period provided and no open cycle found.') await ctx.reply('No period provided and no open cycle found.')
return 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( 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) { } catch (error) {
await ctx.reply(`Failed to save rent rule: ${(error as Error).message}`) await ctx.reply(`Failed to save rent rule: ${(error as Error).message}`)
@@ -457,29 +123,14 @@ export function createFinanceCommandsService(
} }
try { try {
const openCycle = await getOpenCycle() const result = await financeService.addUtilityBill(args[0]!, args[1]!, admin.id, args[2])
if (!openCycle) { if (!result) {
await ctx.reply('No open cycle found. Use /cycle_open first.') await ctx.reply('No open cycle found. Use /cycle_open first.')
return 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( 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) { } catch (error) {
await ctx.reply(`Failed to add utility bill: ${(error as Error).message}`) await ctx.reply(`Failed to add utility bill: ${(error as Error).message}`)
@@ -492,16 +143,14 @@ export function createFinanceCommandsService(
return return
} }
const args = commandArgs(ctx)
try { try {
const cycle = await getCycleByPeriodOrLatest(args[0]) const statement = await financeService.generateStatement(commandArgs(ctx)[0])
if (!cycle) { if (!statement) {
await ctx.reply('No cycle found for statement.') await ctx.reply('No cycle found for statement.')
return return
} }
const message = await upsertSettlementSnapshot(cycle) await ctx.reply(statement)
await ctx.reply(message)
} catch (error) { } catch (error) {
await ctx.reply(`Failed to generate statement: ${(error as Error).message}`) await ctx.reply(`Failed to generate statement: ${(error as Error).message}`)
} }
@@ -509,9 +158,6 @@ export function createFinanceCommandsService(
} }
return { return {
register, register
close: async () => {
await queryClient.end({ timeout: 5 })
}
} }
} }

View File

@@ -1,20 +1,57 @@
import { webhookCallback } from 'grammy' import { webhookCallback } from 'grammy'
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 { createTelegramBot } from './bot'
import { getBotRuntimeConfig } from './config' import { getBotRuntimeConfig } from './config'
import { createFinanceCommandsService } from './finance-commands'
import { createOpenAiParserFallback } from './openai-parser-fallback' import { createOpenAiParserFallback } from './openai-parser-fallback'
import { import {
createPurchaseMessageRepository, createPurchaseMessageRepository,
registerPurchaseTopicIngestion registerPurchaseTopicIngestion
} from './purchase-topic-ingestion' } from './purchase-topic-ingestion'
import { createReminderJobsHandler } from './reminder-jobs'
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
import { createBotWebhookServer } from './server' import { createBotWebhookServer } from './server'
import { createMiniAppAuthHandler } from './miniapp-auth'
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
const runtime = getBotRuntimeConfig() const runtime = getBotRuntimeConfig()
const bot = createTelegramBot(runtime.telegramBotToken) const bot = createTelegramBot(runtime.telegramBotToken)
const webhookHandler = webhookCallback(bot, 'std/http') const webhookHandler = webhookCallback(bot, 'std/http')
const shutdownTasks: Array<() => Promise<void>> = [] const shutdownTasks: Array<() => Promise<void>> = []
const financeRepositoryClient =
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
: null
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) { if (runtime.purchaseTopicIngestionEnabled) {
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
@@ -42,20 +79,81 @@ if (runtime.purchaseTopicIngestionEnabled) {
} }
if (runtime.financeCommandsEnabled) { if (runtime.financeCommandsEnabled) {
const financeCommands = createFinanceCommandsService(runtime.databaseUrl!, { const financeCommands = createFinanceCommandsService(financeService!)
householdId: runtime.householdId!
})
financeCommands.register(bot) financeCommands.register(bot)
shutdownTasks.push(financeCommands.close)
} else { } else {
console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.') 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 either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.'
)
}
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({ const server = createBotWebhookServer({
webhookPath: runtime.telegramWebhookPath, webhookPath: runtime.telegramWebhookPath,
webhookSecret: runtime.telegramWebhookSecret, webhookSecret: runtime.telegramWebhookSecret,
webhookHandler webhookHandler,
miniAppAuth: financeRepositoryClient
? createMiniAppAuthHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
repository: financeRepositoryClient.repository
})
: undefined,
miniAppDashboard: financeService
? createMiniAppDashboardHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
financeService
})
: undefined,
scheduler:
reminderJobs && runtime.schedulerSharedSecret
? {
authorize: createSchedulerRequestAuthorizer({
sharedSecret: runtime.schedulerSharedSecret,
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
}).authorize,
handler: reminderJobs.handle
}
: reminderJobs
? {
authorize: createSchedulerRequestAuthorizer({
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
}).authorize,
handler: reminderJobs.handle
}
: undefined
}) })
if (import.meta.main) { if (import.meta.main) {

View File

@@ -0,0 +1,183 @@
import { describe, expect, test } from 'bun:test'
import type { FinanceRepository } from '@household/ports'
import { createMiniAppAuthHandler } from './miniapp-auth'
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
function repository(
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
): 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,
listUtilityBillsForCycle: async () => [],
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: buildMiniAppInitData('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
},
features: {
balances: true,
ledger: 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: buildMiniAppInitData('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'
})
})
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
}
}
})
})

View File

@@ -0,0 +1,197 @@
import type { FinanceMemberRecord, FinanceRepository } from '@household/ports'
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
export function miniAppJsonResponse(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
})
}
export function allowedMiniAppOrigin(
request: Request,
allowedOrigins: readonly string[],
options: {
allowDynamicOrigin?: boolean
} = {}
): string | undefined {
const origin = request.headers.get('origin')
if (!origin) {
return undefined
}
if (allowedOrigins.length === 0) {
const allowDynamicOrigin = options.allowDynamicOrigin ?? process.env.NODE_ENV !== 'production'
return allowDynamicOrigin ? origin : undefined
}
return allowedOrigins.includes(origin) ? origin : undefined
}
export async function readMiniAppInitData(request: Request): Promise<string | null> {
const text = await request.text()
if (text.trim().length === 0) {
return null
}
let parsed: { initData?: string }
try {
parsed = JSON.parse(text) as { initData?: string }
} catch {
throw new Error('Invalid JSON body')
}
const initData = parsed.initData?.trim()
return initData && initData.length > 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'
member?: {
id: string
displayName: string
isAdmin: boolean
}
telegramUser?: ReturnType<typeof verifyTelegramMiniAppInitData>
}
type MiniAppMemberLookup = (telegramUserId: string) => Promise<FinanceMemberRecord | null>
export function createMiniAppSessionService(options: {
botToken: string
getMemberByTelegramUserId: MiniAppMemberLookup
}): {
authenticate: (initData: string) => Promise<MiniAppSessionResult | null>
} {
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
repository: FinanceRepository
}): {
handler: (request: Request) => Promise<Response>
} {
const sessionService = createMiniAppSessionService({
botToken: options.botToken,
getMemberByTelegramUserId: options.repository.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
)
}
return miniAppJsonResponse(
{
ok: true,
authorized: true,
member: session.member,
telegramUser: session.telegramUser,
features: {
balances: true,
ledger: true
}
},
200,
origin
)
} catch (error) {
return miniAppErrorResponse(error, origin)
}
}
}
}

View File

@@ -0,0 +1,163 @@
import { describe, expect, test } from 'bun:test'
import { createFinanceCommandService } from '@household/application'
import type { FinanceRepository } from '@household/ports'
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
function repository(
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
): 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: buildMiniAppInitData('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'
}
]
}
})
})
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'
})
})
})

View File

@@ -0,0 +1,106 @@
import type { FinanceCommandService } from '@household/application'
import {
allowedMiniAppOrigin,
createMiniAppSessionService,
miniAppErrorResponse,
miniAppJsonResponse,
readMiniAppInitData
} from './miniapp-auth'
export function createMiniAppDashboardHandler(options: {
allowedOrigins: readonly string[]
botToken: string
financeService: FinanceCommandService
}): {
handler: (request: Request) => Promise<Response>
} {
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) {
return miniAppErrorResponse(error, origin)
}
}
}
}

View File

@@ -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'
})
})
})

View File

@@ -0,0 +1,113 @@
import type { ReminderJobService } from '@household/application'
import { BillingPeriod } from '@household/domain'
import { REMINDER_TYPES, type ReminderType } from '@household/ports'
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<ReminderJobRequestBody> {
const text = await request.text()
if (text.trim().length === 0) {
return {}
}
try {
return JSON.parse(text) as ReminderJobRequestBody
} catch {
throw new Error('Invalid JSON body')
}
}
export function createReminderJobsHandler(options: {
householdId: string
reminderService: ReminderJobService
forceDryRun?: boolean
}): {
handle: (request: Request, rawReminderType: string) => Promise<Response>
} {
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 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({
householdId: options.householdId,
period,
reminderType,
dryRun
})
const logPayload = {
event: 'scheduler.reminder.dispatch',
reminderType,
period,
jobId: body.jobId ?? schedulerJobName ?? null,
dedupeKey: result.dedupeKey,
outcome: result.status,
dryRun
}
console.log(JSON.stringify(logPayload))
return json({
ok: true,
jobId: body.jobId ?? schedulerJobName ?? 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)
}
}
}
}

View File

@@ -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)
})
})

View File

@@ -0,0 +1,79 @@
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<IdTokenTicket>
}
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<boolean>
} {
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 (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
}
}
}
}

View File

@@ -6,7 +6,36 @@ describe('createBotWebhookServer', () => {
const server = createBotWebhookServer({ const server = createBotWebhookServer({
webhookPath: '/webhook/telegram', webhookPath: '/webhook/telegram',
webhookSecret: 'secret-token', webhookSecret: 'secret-token',
webhookHandler: async () => new Response('ok', { status: 200 }) 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'
}
})
},
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',
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 () => { test('returns health payload', async () => {
@@ -59,4 +88,77 @@ describe('createBotWebhookServer', () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(await response.text()).toBe('ok') 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('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', {
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'
})
})
}) })

View File

@@ -2,6 +2,25 @@ export interface BotWebhookServerOptions {
webhookPath: string webhookPath: string
webhookSecret: string webhookSecret: string
webhookHandler: (request: Request) => Promise<Response> | Response webhookHandler: (request: Request) => Promise<Response> | Response
miniAppAuth?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppDashboard?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
scheduler?:
| {
pathPrefix?: string
authorize: (request: Request) => Promise<boolean>
handler: (request: Request, reminderType: string) => Promise<Response>
}
| undefined
} }
function json(body: object, status = 200): Response { function json(body: object, status = 200): Response {
@@ -25,6 +44,11 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
const normalizedWebhookPath = options.webhookPath.startsWith('/') const normalizedWebhookPath = options.webhookPath.startsWith('/')
? options.webhookPath ? options.webhookPath
: `/${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
return { return {
fetch: async (request: Request) => { fetch: async (request: Request) => {
@@ -34,7 +58,28 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return json({ ok: true }) return json({ ok: true })
} }
if (options.miniAppAuth && url.pathname === miniAppAuthPath) {
return await options.miniAppAuth.handler(request)
}
if (options.miniAppDashboard && url.pathname === miniAppDashboardPath) {
return await options.miniAppDashboard.handler(request)
}
if (url.pathname !== normalizedWebhookPath) { if (url.pathname !== normalizedWebhookPath) {
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 })
}
if (!(await options.scheduler!.authorize(request))) {
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 }) return new Response('Not Found', { status: 404 })
} }

View File

@@ -0,0 +1,68 @@
import { describe, expect, test } from 'bun:test'
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
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 = buildMiniAppInitData('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(
buildMiniAppInitData('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 = 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'
})
const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600)
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,89 @@
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 (authDateSeconds > nowSeconds) {
return null
}
if (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
}
}

View File

@@ -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()
}

View File

@@ -6,6 +6,7 @@ WORKDIR /app
COPY bun.lock package.json tsconfig.base.json ./ COPY bun.lock package.json tsconfig.base.json ./
COPY apps/bot/package.json apps/bot/package.json COPY apps/bot/package.json apps/bot/package.json
COPY apps/miniapp/package.json apps/miniapp/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/application/package.json packages/application/package.json
COPY packages/config/package.json packages/config/package.json COPY packages/config/package.json packages/config/package.json
COPY packages/contracts/package.json packages/contracts/package.json COPY packages/contracts/package.json packages/contracts/package.json
@@ -26,10 +27,15 @@ RUN bun run --filter @household/miniapp build
FROM nginx:1.27-alpine AS runtime 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/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 COPY --from=build /app/apps/miniapp/dist /usr/share/nginx/html
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ 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 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;'"]

View File

@@ -0,0 +1,3 @@
window.__HOUSEHOLD_CONFIG__ = {
botApiUrl: '${BOT_API_URL}'
}

View File

@@ -3,13 +3,14 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#121a24" />
<title>Solid App</title> <title>Kojori House</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<script src="/config.js"></script>
<script src="/src/index.tsx" type="module"></script> <script src="/src/index.tsx" type="module"></script>
</body> </body>
</html> </html>

View File

@@ -1,10 +1,380 @@
import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js'
import { dictionary, type Locale } from './i18n'
import { fetchMiniAppDashboard, fetchMiniAppSession, type MiniAppDashboard } 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<SessionState, { status: 'ready' }> = {
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() { function App() {
const [locale, setLocale] = createSignal<Locale>('en')
const [session, setSession] = createSignal<SessionState>({
status: 'loading'
})
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
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
})
try {
setDashboard(await fetchMiniAppDashboard(initData))
} catch (error) {
if (import.meta.env.DEV) {
console.warn('Failed to load mini app dashboard', error)
}
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
}
setSession({
status: 'blocked',
reason: 'error'
})
}
})
const renderPanel = () => {
switch (activeNav()) {
case 'balances':
return (
<div class="balance-list">
<ShowDashboard
dashboard={dashboard()}
fallback={<p>{copy().emptyDashboard}</p>}
render={(data) =>
data.members.map((member) => (
<article class="balance-item">
<header>
<strong>{member.displayName}</strong>
<span>
{member.netDueMajor} {data.currency}
</span>
</header>
<p>
{copy().shareRent}: {member.rentShareMajor} {data.currency}
</p>
<p>
{copy().shareUtilities}: {member.utilityShareMajor} {data.currency}
</p>
<p>
{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}
</p>
</article>
))
}
/>
</div>
)
case 'ledger':
return (
<div class="ledger-list">
<ShowDashboard
dashboard={dashboard()}
fallback={<p>{copy().emptyDashboard}</p>}
render={(data) =>
data.ledger.map((entry) => (
<article class="ledger-item">
<header>
<strong>{entry.title}</strong>
<span>
{entry.amountMajor} {data.currency}
</span>
</header>
<p>{entry.actorDisplayName ?? 'Household'}</p>
</article>
))
}
/>
</div>
)
case 'house':
return copy().houseEmpty
default:
return (
<ShowDashboard
dashboard={dashboard()}
fallback={<p>{copy().summaryBody}</p>}
render={(data) => (
<>
<p>
{copy().totalDue}: {data.totalDueMajor} {data.currency}
</p>
<p>{copy().summaryBody}</p>
</>
)}
/>
)
}
}
return ( return (
<main> <main class="shell">
<h1>Household Mini App</h1> <div class="shell__backdrop shell__backdrop--top" />
<p>SolidJS scaffold is ready</p> <div class="shell__backdrop shell__backdrop--bottom" />
<section class="topbar">
<div>
<p class="eyebrow">{copy().appSubtitle}</p>
<h1>{copy().appTitle}</h1>
</div>
<label class="locale-switch">
<span>{copy().language}</span>
<div class="locale-switch__buttons">
<button
classList={{ 'is-active': locale() === 'en' }}
type="button"
onClick={() => setLocale('en')}
>
EN
</button>
<button
classList={{ 'is-active': locale() === 'ru' }}
type="button"
onClick={() => setLocale('ru')}
>
RU
</button>
</div>
</label>
</section>
<Switch>
<Match when={session().status === 'loading'}>
<section class="hero-card">
<span class="pill">{copy().navHint}</span>
<h2>{copy().loadingTitle}</h2>
<p>{copy().loadingBody}</p>
</section>
</Match>
<Match when={session().status === 'blocked'}>
<section class="hero-card">
<span class="pill">{copy().navHint}</span>
<h2>
{blockedSession()?.reason === 'telegram_only'
? copy().telegramOnlyTitle
: copy().unauthorizedTitle}
</h2>
<p>
{blockedSession()?.reason === 'telegram_only'
? copy().telegramOnlyBody
: copy().unauthorizedBody}
</p>
<button class="ghost-button" type="button" onClick={() => window.location.reload()}>
{copy().reload}
</button>
</section>
</Match>
<Match when={session().status === 'ready'}>
<section class="hero-card">
<div class="hero-card__meta">
<span class="pill">
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().navHint}
</span>
<span class="pill pill--muted">
{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}
</span>
</div>
<h2>
{copy().welcome},{' '}
{readySession()?.telegramUser.firstName ?? readySession()?.member.displayName}
</h2>
<p>{copy().sectionBody}</p>
</section>
<nav class="nav-grid">
{(
[
['home', copy().home],
['balances', copy().balances],
['ledger', copy().ledger],
['house', copy().house]
] as const
).map(([key, label]) => (
<button
classList={{ 'is-active': activeNav() === key }}
type="button"
onClick={() => setActiveNav(key)}
>
{label}
</button>
))}
</nav>
<section class="content-grid">
<article class="panel panel--wide">
<p class="eyebrow">{copy().summaryTitle}</p>
<h3>{readySession()?.member.displayName}</h3>
<p>{renderPanel()}</p>
</article>
<article class="panel">
<p class="eyebrow">{copy().cardAccess}</p>
<p>{copy().cardAccessBody}</p>
</article>
<article class="panel">
<p class="eyebrow">{copy().cardLocale}</p>
<p>{copy().cardLocaleBody}</p>
</article>
<article class="panel">
<p class="eyebrow">{copy().cardNext}</p>
<p>{copy().cardNextBody}</p>
</article>
</section>
</Match>
</Switch>
</main> </main>
) )
} }
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 export default App

92
apps/miniapp/src/i18n.ts Normal file
View File

@@ -0,0 +1,92 @@
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.',
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',
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.',
totalDue: 'Итого к оплате',
shareRent: 'Аренда',
shareUtilities: 'Коммуналка',
shareOffset: 'Общие покупки',
ledgerTitle: 'Вошедшие операции',
emptyDashboard: 'Пока нет готового billing cycle.',
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<Locale, Record<string, string>>

View File

@@ -1 +1,281 @@
@import 'tailwindcss'; @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);
}
.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;
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;
}
.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;
}
@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;
}
}

View File

@@ -0,0 +1,114 @@
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
}
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) {
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<MiniAppSession> {
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 ?? 'Failed to create mini app session')
}
return {
authorized: payload.authorized === true,
...(payload.member ? { member: payload.member } : {}),
...(payload.telegramUser ? { telegramUser: payload.telegramUser } : {}),
...(payload.reason ? { reason: payload.reason } : {})
}
}
export async function fetchMiniAppDashboard(initData: string): Promise<MiniAppDashboard> {
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
}

View File

@@ -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
}

View File

@@ -0,0 +1,31 @@
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 {
if (typeof window === 'undefined') {
return undefined
}
return window.Telegram?.WebApp
}

9
apps/miniapp/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BOT_API_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

144
bun.lock
View File

@@ -16,10 +16,13 @@
"apps/bot": { "apps/bot": {
"name": "@household/bot", "name": "@household/bot",
"dependencies": { "dependencies": {
"@household/adapters-db": "workspace:*",
"@household/application": "workspace:*", "@household/application": "workspace:*",
"@household/db": "workspace:*", "@household/db": "workspace:*",
"@household/domain": "workspace:*", "@household/domain": "workspace:*",
"@household/ports": "workspace:*",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"google-auth-library": "^10.4.1",
"grammy": "1.41.1", "grammy": "1.41.1",
}, },
}, },
@@ -37,10 +40,20 @@
"vite-plugin-solid": "^2.11.8", "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": { "packages/application": {
"name": "@household/application", "name": "@household/application",
"dependencies": { "dependencies": {
"@household/domain": "workspace:*", "@household/domain": "workspace:*",
"@household/ports": "workspace:*",
}, },
}, },
"packages/config": { "packages/config": {
@@ -68,6 +81,9 @@
}, },
"packages/ports": { "packages/ports": {
"name": "@household/ports", "name": "@household/ports",
"dependencies": {
"@household/domain": "workspace:*",
},
}, },
"scripts": { "scripts": {
"name": "@household/scripts", "name": "@household/scripts",
@@ -177,6 +193,8 @@
"@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="], "@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/application": ["@household/application@workspace:packages/application"],
"@household/bot": ["@household/bot@workspace:apps/bot"], "@household/bot": ["@household/bot@workspace:apps/bot"],
@@ -197,6 +215,8 @@
"@household/scripts": ["@household/scripts@workspace:scripts"], "@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/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=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -247,6 +267,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=="], "@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-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=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
@@ -387,24 +409,48 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -413,8 +459,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=="], "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=="], "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=="], "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=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
@@ -427,30 +479,62 @@
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "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=="], "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": ["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=="], "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AgHu93YuJtj1l9bcKlCbo4Tg8N8xFl9iD6BjXCGaGMu46LSjFiXbJFlkUdpgrL8fIbwoCjJi5FNp3POpqs4Wdw=="],
@@ -503,18 +587,30 @@
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -525,14 +621,24 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "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=="], "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=="], "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": ["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=="], "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-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=="], "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=="],
@@ -545,6 +651,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=="], "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=="], "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
@@ -565,10 +679,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=="], "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=="], "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=="], "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=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@@ -589,8 +711,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=="], "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=="], "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-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=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
@@ -635,6 +773,8 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], "@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/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=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
@@ -686,5 +826,9 @@
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], "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=="], "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=="],
} }
} }

View File

@@ -26,6 +26,8 @@ bun run db:generate
bun run db:check bun run db:check
bun run db:migrate bun run db:migrate
bun run db:seed bun run db:seed
bun run ops:telegram:webhook info
bun run ops:deploy:smoke
bun run infra:fmt:check bun run infra:fmt:check
bun run infra:validate bun run infra:validate
``` ```
@@ -60,6 +62,7 @@ bun run review:coderabbit
- Typed environment validation lives in `packages/config/src/env.ts`. - Typed environment validation lives in `packages/config/src/env.ts`.
- Copy `.env.example` to `.env` before running app/database commands. - Copy `.env.example` to `.env` before running app/database commands.
- Migration workflow is documented in `docs/runbooks/migrations.md`. - Migration workflow is documented in `docs/runbooks/migrations.md`.
- First deploy flow is documented in `docs/runbooks/first-deploy.md`.
## CI/CD ## CI/CD

View File

@@ -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=<terraform-state-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 "<telegram-bot-token>" | gcloud secrets versions add telegram-bot-token --data-file=- --project <project_id>
echo -n "<telegram-webhook-secret>" | gcloud secrets versions add telegram-webhook-secret --data-file=- --project <project_id>
echo -n "<scheduler-shared-secret>" | gcloud secrets versions add scheduler-shared-secret --data-file=- --project <project_id>
echo -n "<database-url>" | gcloud secrets versions add database-url --data-file=- --project <project_id>
```
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 <project_id>)"
export TELEGRAM_WEBHOOK_SECRET="$(gcloud secrets versions access latest --secret telegram-webhook-secret --project <project_id>)"
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 <region> --project <project_id>
```
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 <bot-service-name> --region <region> --project <project_id>
gcloud run services update-traffic <bot-service-name> --region <region> --project <project_id> --to-revisions <previous-revision>=100
gcloud run revisions list --service <mini-service-name> --region <region> --project <project_id>
gcloud run services update-traffic <mini-service-name> --region <region> --project <project_id> --to-revisions <previous-revision>=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.

View File

@@ -48,8 +48,35 @@ Keep bot runtime config that is not secret in your `*.tfvars` file:
- `bot_household_id` - `bot_household_id`
- `bot_household_chat_id` - `bot_household_chat_id`
- `bot_purchase_topic_id` - `bot_purchase_topic_id`
- optional `bot_feedback_topic_id`
- `bot_mini_app_allowed_origins`
- optional `bot_parser_model` - optional `bot_parser_model`
Set `bot_mini_app_allowed_origins` to the exact mini app origins you expect in each environment.
Do not rely on permissive origin reflection in production.
## 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
- confirm `bot_mini_app_allowed_origins` is set for the environment before exposing the mini app
- validate job responses and logs
- unpause when the delivery side is ready
- disable dry-run only after production verification
## Environment strategy ## Environment strategy
- Keep separate states for `dev` and `prod`. - Keep separate states for `dev` and `prod`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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/<type>`
- Allowed types:
- `utilities`
- `rent-warning`
- `rent-due`
- Request body:
- `period?: YYYY-MM`
- `jobId?: string`
- `dryRun?: boolean`
- Auth:
- `x-household-scheduler-secret: <secret>` or `Authorization: Bearer <secret>`
## Domain Rules
- Dedupe key format: `<period>:<reminderType>`
- 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.

View File

@@ -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`; local development may use permissive origin reflection, but production must use an explicit allow-list.
## 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.

View File

@@ -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.

View File

@@ -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 <message>` 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.

View File

@@ -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.

View File

@@ -7,7 +7,7 @@ This directory contains baseline IaC for deploying the household bot platform on
- Artifact Registry Docker repository - Artifact Registry Docker repository
- Cloud Run service: bot API (public webhook endpoint) - Cloud Run service: bot API (public webhook endpoint)
- Cloud Run service: mini app (public web UI) - 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 - Runtime and scheduler service accounts with least-privilege bindings
- Secret Manager secrets (IDs only, secret values are added separately) - Secret Manager secrets (IDs only, secret values are added separately)
- Optional GitHub OIDC Workload Identity setup for deploy automation - 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 - `bot-api`: Telegram webhook + app API endpoints
- `mini-app`: front-end delivery - `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 ## Prerequisites
@@ -72,7 +72,9 @@ Recommended approach:
- `bot_household_id` - `bot_household_id`
- `bot_household_chat_id` - `bot_household_chat_id`
- `bot_purchase_topic_id` - `bot_purchase_topic_id`
- optional `bot_feedback_topic_id`
- optional `bot_parser_model` - optional `bot_parser_model`
- optional `bot_mini_app_allowed_origins`
## CI validation ## CI validation
@@ -84,5 +86,6 @@ CI runs:
## Notes ## 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. - 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.

View File

@@ -12,6 +12,21 @@ locals {
artifact_location = coalesce(var.artifact_repository_location, var.region) 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([ runtime_secret_ids = toset(compact([
var.telegram_webhook_secret_id, var.telegram_webhook_secret_id,
var.scheduler_shared_secret_id, var.scheduler_shared_secret_id,

View File

@@ -90,8 +90,17 @@ module "bot_api_service" {
var.bot_purchase_topic_id == null ? {} : { var.bot_purchase_topic_id == null ? {} : {
TELEGRAM_PURCHASE_TOPIC_ID = tostring(var.bot_purchase_topic_id) 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 ? {} : { var.bot_parser_model == null ? {} : {
PARSER_MODEL = var.bot_parser_model 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
} }
) )
@@ -137,7 +146,8 @@ module "mini_app_service" {
labels = local.common_labels labels = local.common_labels
env = { env = {
NODE_ENV = var.environment NODE_ENV = var.environment
BOT_API_URL = module.bot_api_service.uri
} }
depends_on = [google_project_service.enabled] depends_on = [google_project_service.enabled]
@@ -158,22 +168,27 @@ resource "google_service_account_iam_member" "scheduler_token_creator" {
} }
resource "google_cloud_scheduler_job" "reminders" { resource "google_cloud_scheduler_job" "reminders" {
for_each = local.reminder_jobs
project = var.project_id project = var.project_id
region = var.region region = var.region
name = "${local.name_prefix}-reminders" name = "${local.name_prefix}-${each.key}"
schedule = var.scheduler_cron schedule = each.value.schedule
time_zone = var.scheduler_timezone time_zone = var.scheduler_timezone
paused = var.scheduler_paused paused = var.scheduler_paused
http_target { http_target {
uri = "${module.bot_api_service.uri}${var.scheduler_path}" uri = "${module.bot_api_service.uri}${each.value.path}"
http_method = var.scheduler_http_method http_method = "POST"
headers = { headers = {
"Content-Type" = "application/json" "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 { oidc_token {
service_account_email = google_service_account.scheduler_invoker.email service_account_email = google_service_account.scheduler_invoker.email

View File

@@ -23,9 +23,9 @@ output "mini_app_service_url" {
value = module.mini_app_service.uri value = module.mini_app_service.uri
} }
output "scheduler_job_name" { output "scheduler_job_names" {
description = "Cloud Scheduler job for reminders" description = "Cloud Scheduler jobs for reminders"
value = google_cloud_scheduler_job.reminders.name value = { for name, job in google_cloud_scheduler_job.reminders : name => job.name }
} }
output "runtime_secret_ids" { output "runtime_secret_ids" {

View File

@@ -11,11 +11,18 @@ mini_app_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/mini
bot_household_id = "11111111-1111-4111-8111-111111111111" bot_household_id = "11111111-1111-4111-8111-111111111111"
bot_household_chat_id = "-1001234567890" bot_household_chat_id = "-1001234567890"
bot_purchase_topic_id = 777 bot_purchase_topic_id = 777
bot_feedback_topic_id = 778
bot_parser_model = "gpt-4.1-mini" bot_parser_model = "gpt-4.1-mini"
bot_mini_app_allowed_origins = [
"https://household-dev-mini-app-abc123-ew.a.run.app"
]
scheduler_cron = "0 9 * * *" scheduler_utilities_cron = "0 9 4 * *"
scheduler_timezone = "Asia/Tbilisi" scheduler_rent_warning_cron = "0 9 17 * *"
scheduler_paused = true scheduler_rent_due_cron = "0 9 20 * *"
scheduler_timezone = "Asia/Tbilisi"
scheduler_paused = true
scheduler_dry_run = true
create_workload_identity = true create_workload_identity = true
github_repository = "whekin/household-bot" github_repository = "whekin/household-bot"

View File

@@ -104,6 +104,13 @@ variable "bot_purchase_topic_id" {
nullable = true 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" { variable "bot_parser_model" {
description = "Optional PARSER_MODEL override for bot runtime" description = "Optional PARSER_MODEL override for bot runtime"
type = string type = string
@@ -111,6 +118,12 @@ variable "bot_parser_model" {
nullable = true 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" { variable "openai_api_key_secret_id" {
description = "Optional Secret Manager ID for OPENAI_API_KEY" description = "Optional Secret Manager ID for OPENAI_API_KEY"
type = string type = string
@@ -118,35 +131,34 @@ variable "openai_api_key_secret_id" {
nullable = true 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" { variable "scheduler_timezone" {
description = "Scheduler timezone" description = "Scheduler timezone"
type = string type = string
default = "Asia/Tbilisi" default = "Asia/Tbilisi"
} }
variable "scheduler_body_json" { variable "scheduler_utilities_cron" {
description = "JSON payload for scheduler requests" description = "Cron expression for the utilities reminder scheduler job"
type = string 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" { variable "scheduler_paused" {

View File

@@ -32,7 +32,9 @@
"docker:build:miniapp": "docker build -f apps/miniapp/Dockerfile -t household-miniapp:local .", "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:build": "bun run docker:build:bot && bun run docker:build:miniapp",
"docker:smoke": "docker compose up --build", "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": { "devDependencies": {
"@types/bun": "1.3.10", "@types/bun": "1.3.10",

View File

@@ -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"
}
}

View File

@@ -0,0 +1,163 @@
import { and, eq, inArray, sql } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import type {
AnonymousFeedbackModerationStatus,
AnonymousFeedbackRepository
} from '@household/ports'
const ACCEPTED_STATUSES = ['accepted', 'posted', 'failed'] as const
function parseModerationStatus(raw: string): AnonymousFeedbackModerationStatus {
if (raw === 'accepted' || raw === 'posted' || raw === 'rejected' || raw === 'failed') {
return raw
}
throw new Error(`Unexpected anonymous feedback moderation status: ${raw}`)
}
export function createDbAnonymousFeedbackRepository(
databaseUrl: string,
householdId: string
): {
repository: AnonymousFeedbackRepository
close: () => Promise<void>
} {
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 rows = await db
.select({
acceptedCountSince: sql<string>`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSince})`,
lastAcceptedAt: sql<Date | null>`max(${schema.anonymousMessages.createdAt})`
})
.from(schema.anonymousMessages)
.where(
and(
eq(schema.anonymousMessages.householdId, householdId),
eq(schema.anonymousMessages.submittedByMemberId, memberId),
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES)
)
)
return {
acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'),
lastAcceptedAt: rows[0]?.lastAcceptedAt ?? 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: parseModerationStatus(inserted[0].moderationStatus)
},
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: parseModerationStatus(row.moderationStatus)
},
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 })
}
}
}

View File

@@ -0,0 +1,359 @@
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<void>
} {
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<string>`coalesce(sum(${schema.utilityBills.amountMinor}), 0)`
})
.from(schema.utilityBills)
.where(eq(schema.utilityBills.cycleId, cycleId))
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,
description: schema.purchaseMessages.parsedItemDescription,
occurredAt: schema.purchaseMessages.messageSentAt
})
.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!,
description: row.description,
occurredAt: row.occurredAt
}))
},
async replaceSettlementSnapshot(snapshot) {
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,
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 tx
.delete(schema.settlementLines)
.where(eq(schema.settlementLines.settlementId, settlementId))
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
}))
)
})
}
}
return {
repository,
close: async () => {
await queryClient.end({ timeout: 5 })
}
}
}

View File

@@ -0,0 +1,3 @@
export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository'
export { createDbFinanceRepository } from './finance-repository'
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'

View File

@@ -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<void>
} {
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 })
}
}
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -12,6 +12,7 @@
"lint": "oxlint \"src\"" "lint": "oxlint \"src\""
}, },
"dependencies": { "dependencies": {
"@household/domain": "workspace:*" "@household/domain": "workspace:*",
"@household/ports": "workspace:*"
} }
} }

View File

@@ -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'
}
])
})
})

View File

@@ -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<AnonymousFeedbackSubmitResult>
markPosted(input: {
submissionId: string
postedChatId: string
postedThreadId: string
postedMessageId: string
postedAt?: Date
}): Promise<void>
markFailed(submissionId: string, failureReason: string): Promise<void>
}
async function rejectSubmission(
repository: AnonymousFeedbackRepository,
input: {
memberId: string
rawText: string
reason: AnonymousFeedbackRejectionReason
detail?: string
telegramChatId: string
telegramMessageId: string
telegramUpdateId: string
}
): Promise<AnonymousFeedbackSubmitResult> {
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)
}
}
}

View File

@@ -0,0 +1,220 @@
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[] = []
utilityBills: readonly {
id: string
billName: string
amountMinor: bigint
currency: 'USD' | 'GEL'
createdByMemberId: string | null
createdAt: Date
}[] = []
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<FinanceMemberRecord | null> {
return this.member
}
async listMembers(): Promise<readonly FinanceMemberRecord[]> {
return this.members
}
async getOpenCycle(): Promise<FinanceCycleRecord | null> {
return this.openCycleRecord
}
async getCycleByPeriod(): Promise<FinanceCycleRecord | null> {
return this.cycleByPeriodRecord
}
async getLatestCycle(): Promise<FinanceCycleRecord | null> {
return this.latestCycleRecord
}
async openCycle(period: string, currency: 'USD' | 'GEL'): Promise<void> {
this.openCycleRecord = {
id: 'opened-cycle',
period,
currency
}
}
async closeCycle(): Promise<void> {}
async saveRentRule(period: string, amountMinor: bigint, currency: 'USD' | 'GEL'): Promise<void> {
this.lastSavedRentRule = {
period,
amountMinor,
currency
}
}
async addUtilityBill(input: {
cycleId: string
billName: string
amountMinor: bigint
currency: 'USD' | 'GEL'
createdByMemberId: string
}): Promise<void> {
this.lastUtilityBill = input
}
async getRentRuleForPeriod(): Promise<FinanceRentRuleRecord | null> {
return this.rentRule
}
async getUtilityTotalForCycle(): Promise<bigint> {
return this.utilityTotal
}
async listUtilityBillsForCycle() {
return this.utilityBills
}
async listParsedPurchasesForRange(): Promise<readonly FinanceParsedPurchaseRecord[]> {
return this.purchases
}
async replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void> {
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.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,
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',
'- 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
])
})
})

View File

@@ -0,0 +1,321 @@
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<FinanceCycleRecord | null> {
if (periodArg) {
return repository.getCycleByPeriod(BillingPeriod.fromString(periodArg).toString())
}
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<FinanceDashboard | null> {
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<FinanceMemberRecord | null>
getOpenCycle(): Promise<FinanceCycleRecord | null>
openCycle(periodArg: string, currencyArg?: string): Promise<FinanceCycleRecord>
closeCycle(periodArg?: string): Promise<FinanceCycleRecord | null>
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>
generateDashboard(periodArg?: string): Promise<FinanceDashboard | null>
generateStatement(periodArg?: string): Promise<string | null>
}
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)
const cycle = await repository.getCycleByPeriod(period)
if (!cycle) {
throw new Error(`Failed to load billing cycle for period ${period}`)
}
return cycle
},
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 dashboard = await buildFinanceDashboard(repository, periodArg)
if (!dashboard) {
return null
}
const statementLines = dashboard.members.map((line) => {
return `- ${line.displayName}: ${line.netDue.toMajorString()} ${dashboard.currency}`
})
return [
`Statement for ${dashboard.period}`,
...statementLines,
`Total: ${dashboard.totalDue.toMajorString()} ${dashboard.currency}`
].join('\n')
},
generateDashboard(periodArg) {
return buildFinanceDashboard(repository, periodArg)
}
}
}

View File

@@ -1,4 +1,15 @@
export { calculateMonthlySettlement } from './settlement-engine' 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,
type ReminderJobResult,
type ReminderJobService
} from './reminder-job-service'
export { export {
parsePurchaseMessage, parsePurchaseMessage,
type ParsedPurchaseResult, type ParsedPurchaseResult,

View File

@@ -0,0 +1,85 @@
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<ClaimReminderDispatchResult> {
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()
repository.nextResult = {
dedupeKey: '2026-03:rent-due',
claimed: true
}
const service = createReminderJobService(repository)
const result = await service.handleJob({
householdId: 'household-1',
period: '2026-03',
reminderType: 'rent-due'
})
expect(result.status).toBe('claimed')
expect(result.dedupeKey).toBe('2026-03:rent-due')
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')
})
})

View File

@@ -0,0 +1,88 @@
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 buildReminderDedupeKey(period: string, reminderType: ReminderType): string {
return `${period}:${reminderType}`
}
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<ReminderJobResult>
}
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: buildReminderDedupeKey(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
}
}
}
}

View File

@@ -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");

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,13 @@
"when": 1772671128084, "when": 1772671128084,
"tag": "0003_mature_roulette", "tag": "0003_mature_roulette",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1772995779819,
"tag": "0004_big_ultimatum",
"breakpoints": true
} }
] ]
} }

View File

@@ -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( export const settlements = pgTable(
'settlements', 'settlements',
{ {
@@ -308,4 +348,5 @@ export type BillingCycle = typeof billingCycles.$inferSelect
export type UtilityBill = typeof utilityBills.$inferSelect export type UtilityBill = typeof utilityBills.$inferSelect
export type PurchaseEntry = typeof purchaseEntries.$inferSelect export type PurchaseEntry = typeof purchaseEntries.$inferSelect
export type PurchaseMessage = typeof purchaseMessages.$inferSelect export type PurchaseMessage = typeof purchaseMessages.$inferSelect
export type AnonymousMessage = typeof anonymousMessages.$inferSelect
export type Settlement = typeof settlements.$inferSelect export type Settlement = typeof settlements.$inferSelect

View File

@@ -2,10 +2,16 @@
"name": "@household/ports", "name": "@household/ports",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": { "scripts": {
"build": "bun build src/index.ts --outdir dist --target bun", "build": "bun build src/index.ts --outdir dist --target bun",
"typecheck": "tsgo --project tsconfig.json --noEmit", "typecheck": "tsgo --project tsconfig.json --noEmit",
"test": "bun test --pass-with-no-tests", "test": "bun test --pass-with-no-tests",
"lint": "oxlint \"src\"" "lint": "oxlint \"src\""
},
"dependencies": {
"@household/domain": "workspace:*"
} }
} }

View File

@@ -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<AnonymousFeedbackMemberRecord | null>
getRateLimitSnapshot(
memberId: string,
acceptedSince: Date
): Promise<AnonymousFeedbackRateLimitSnapshot>
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<void>
markFailed(submissionId: string, failureReason: string): Promise<void>
}

View File

@@ -0,0 +1,80 @@
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
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 {
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<string, unknown>
lines: readonly SettlementSnapshotLineRecord[]
}
export interface FinanceRepository {
getMemberByTelegramUserId(telegramUserId: string): Promise<FinanceMemberRecord | null>
listMembers(): Promise<readonly FinanceMemberRecord[]>
getOpenCycle(): Promise<FinanceCycleRecord | null>
getCycleByPeriod(period: string): Promise<FinanceCycleRecord | null>
getLatestCycle(): Promise<FinanceCycleRecord | null>
openCycle(period: string, currency: CurrencyCode): Promise<void>
closeCycle(cycleId: string, closedAt: Date): Promise<void>
saveRentRule(period: string, amountMinor: bigint, currency: CurrencyCode): Promise<void>
addUtilityBill(input: {
cycleId: string
billName: string
amountMinor: bigint
currency: CurrencyCode
createdByMemberId: string
}): Promise<void>
getRentRuleForPeriod(period: string): Promise<FinanceRentRuleRecord | null>
getUtilityTotalForCycle(cycleId: string): Promise<bigint>
listUtilityBillsForCycle(cycleId: string): Promise<readonly FinanceUtilityBillRecord[]>
listParsedPurchasesForRange(
start: Date,
end: Date
): Promise<readonly FinanceParsedPurchaseRecord[]>
replaceSettlementSnapshot(snapshot: SettlementSnapshotRecord): Promise<void>
}

View File

@@ -1 +1,25 @@
export const portsReady = true export {
REMINDER_TYPES,
type ClaimReminderDispatchInput,
type ClaimReminderDispatchResult,
type ReminderDispatchRepository,
type ReminderType
} from './reminders'
export type {
AnonymousFeedbackMemberRecord,
AnonymousFeedbackModerationStatus,
AnonymousFeedbackRateLimitSnapshot,
AnonymousFeedbackRejectionReason,
AnonymousFeedbackRepository,
AnonymousFeedbackSubmissionRecord
} from './anonymous-feedback'
export type {
FinanceCycleRecord,
FinanceMemberRecord,
FinanceParsedPurchaseRecord,
FinanceRentRuleRecord,
FinanceRepository,
FinanceUtilityBillRecord,
SettlementSnapshotLineRecord,
SettlementSnapshotRecord
} from './finance'

View File

@@ -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<ClaimReminderDispatchResult>
}

View File

@@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { createFinanceCommandService } from '@household/application'
import { createDbFinanceRepository } from '@household/adapters-db'
import { createDbClient, schema } from '@household/db' import { createDbClient, schema } from '@household/db'
import { createTelegramBot } from '../../apps/bot/src/bot' import { createTelegramBot } from '../../apps/bot/src/bot'
@@ -129,7 +131,7 @@ async function run(): Promise<void> {
let coreClient: ReturnType<typeof createDbClient> | undefined let coreClient: ReturnType<typeof createDbClient> | undefined
let ingestionClient: ReturnType<typeof createPurchaseMessageRepository> | undefined let ingestionClient: ReturnType<typeof createPurchaseMessageRepository> | undefined
let financeService: ReturnType<typeof createFinanceCommandsService> | undefined let financeRepositoryClient: ReturnType<typeof createDbFinanceRepository> | undefined
const bot = createTelegramBot('000000:test-token') const bot = createTelegramBot('000000:test-token')
const replies: string[] = [] const replies: string[] = []
@@ -178,9 +180,9 @@ async function run(): Promise<void> {
}) })
ingestionClient = createPurchaseMessageRepository(databaseUrl) ingestionClient = createPurchaseMessageRepository(databaseUrl)
financeService = createFinanceCommandsService(databaseUrl, { financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household)
householdId: ids.household const financeService = createFinanceCommandService(financeRepositoryClient.repository)
}) const financeCommands = createFinanceCommandsService(financeService)
registerPurchaseTopicIngestion( registerPurchaseTopicIngestion(
bot, bot,
@@ -192,7 +194,7 @@ async function run(): Promise<void> {
ingestionClient.repository ingestionClient.repository
) )
financeService.register(bot) financeCommands.register(bot)
await coreClient.db.insert(schema.households).values({ await coreClient.db.insert(schema.households).values({
id: ids.household, id: ids.household,
@@ -336,7 +338,7 @@ async function run(): Promise<void> {
: undefined, : undefined,
coreClient?.queryClient.end({ timeout: 5 }), coreClient?.queryClient.end({ timeout: 5 }),
ingestionClient?.close(), ingestionClient?.close(),
financeService?.close() financeRepositoryClient?.close()
]) ])
} }
} }

132
scripts/ops/deploy-smoke.ts Normal file
View File

@@ -0,0 +1,132 @@
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<any> {
const response = await fetch(url, init)
const text = await response.text()
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(
`${url.toString()} expected ${expectedStatus}, received ${response.status}: ${text}`
)
}
return payload
}
async function fetchWebhookInfo(botToken: string): Promise<any> {
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<void> {
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
})

View File

@@ -0,0 +1,96 @@
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
}
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<T>(
botToken: string,
method: string,
body?: URLSearchParams
): Promise<T> {
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 as T
}
async function run(): Promise<void> {
const command = parseCommand(process.argv[2])
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
})

View File

@@ -28,6 +28,9 @@
{ {
"path": "./packages/db" "path": "./packages/db"
}, },
{
"path": "./packages/adapters-db"
},
{ {
"path": "./scripts" "path": "./scripts"
} }