mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:44:03 +00:00
Merge pull request #15 from whekin/codex/whe-31-adapters
feat: add dashboard, deploy tooling, and anonymous feedback
This commit is contained in:
@@ -6,6 +6,7 @@ WORKDIR /app
|
||||
COPY bun.lock package.json tsconfig.base.json ./
|
||||
COPY apps/bot/package.json apps/bot/package.json
|
||||
COPY apps/miniapp/package.json apps/miniapp/package.json
|
||||
COPY packages/adapters-db/package.json packages/adapters-db/package.json
|
||||
COPY packages/application/package.json packages/application/package.json
|
||||
COPY packages/config/package.json packages/config/package.json
|
||||
COPY packages/contracts/package.json packages/contracts/package.json
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
"lint": "oxlint \"src\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@household/adapters-db": "workspace:*",
|
||||
"@household/application": "workspace:*",
|
||||
"@household/db": "workspace:*",
|
||||
"@household/domain": "workspace:*",
|
||||
"@household/ports": "workspace:*",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"google-auth-library": "^10.4.1",
|
||||
"grammy": "1.41.1"
|
||||
}
|
||||
}
|
||||
|
||||
177
apps/bot/src/anonymous-feedback.test.ts
Normal file
177
apps/bot/src/anonymous-feedback.test.ts
Normal 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.'
|
||||
})
|
||||
})
|
||||
})
|
||||
101
apps/bot/src/anonymous-feedback.ts
Normal file
101
apps/bot/src/anonymous-feedback.ts
Normal 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.')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -9,7 +9,8 @@ export function createTelegramBot(token: string): Bot {
|
||||
'Household bot scaffold is live.',
|
||||
'Available commands:',
|
||||
'/help - Show command list',
|
||||
'/household_status - Show placeholder household status'
|
||||
'/household_status - Show placeholder household status',
|
||||
'/anon <message> - Send anonymous household feedback in a private chat'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,8 +7,15 @@ export interface BotRuntimeConfig {
|
||||
householdId?: string
|
||||
telegramHouseholdChatId?: string
|
||||
telegramPurchaseTopicId?: number
|
||||
telegramFeedbackTopicId?: number
|
||||
purchaseTopicIngestionEnabled: boolean
|
||||
financeCommandsEnabled: boolean
|
||||
anonymousFeedbackEnabled: boolean
|
||||
miniAppAllowedOrigins: readonly string[]
|
||||
miniAppAuthEnabled: boolean
|
||||
schedulerSharedSecret?: string
|
||||
schedulerOidcAllowedEmails: readonly string[]
|
||||
reminderJobsEnabled: boolean
|
||||
openaiApiKey?: string
|
||||
parserModel: string
|
||||
}
|
||||
@@ -41,7 +48,7 @@ function parseOptionalTopicId(raw: string | undefined): number | undefined {
|
||||
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid TELEGRAM_PURCHASE_TOPIC_ID value: ${raw}`)
|
||||
throw new Error(`Invalid Telegram topic id value: ${raw}`)
|
||||
}
|
||||
|
||||
return parsed
|
||||
@@ -52,11 +59,28 @@ function parseOptionalValue(value: string | undefined): string | undefined {
|
||||
return trimmed && trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
function parseOptionalCsv(value: string | undefined): readonly string[] {
|
||||
const trimmed = value?.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return []
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig {
|
||||
const databaseUrl = parseOptionalValue(env.DATABASE_URL)
|
||||
const householdId = parseOptionalValue(env.HOUSEHOLD_ID)
|
||||
const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID)
|
||||
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID)
|
||||
const 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 =
|
||||
databaseUrl !== undefined &&
|
||||
@@ -65,6 +89,17 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
||||
telegramPurchaseTopicId !== undefined
|
||||
|
||||
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
|
||||
const anonymousFeedbackEnabled =
|
||||
databaseUrl !== undefined &&
|
||||
householdId !== undefined &&
|
||||
telegramHouseholdChatId !== undefined &&
|
||||
telegramFeedbackTopicId !== undefined
|
||||
const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined
|
||||
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
||||
const reminderJobsEnabled =
|
||||
databaseUrl !== undefined &&
|
||||
householdId !== undefined &&
|
||||
(schedulerSharedSecret !== undefined || hasSchedulerOidcConfig)
|
||||
|
||||
const runtime: BotRuntimeConfig = {
|
||||
port: parsePort(env.PORT),
|
||||
@@ -73,6 +108,11 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
||||
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
|
||||
purchaseTopicIngestionEnabled,
|
||||
financeCommandsEnabled,
|
||||
anonymousFeedbackEnabled,
|
||||
miniAppAllowedOrigins,
|
||||
miniAppAuthEnabled,
|
||||
schedulerOidcAllowedEmails,
|
||||
reminderJobsEnabled,
|
||||
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) {
|
||||
runtime.telegramPurchaseTopicId = telegramPurchaseTopicId
|
||||
}
|
||||
if (telegramFeedbackTopicId !== undefined) {
|
||||
runtime.telegramFeedbackTopicId = telegramFeedbackTopicId
|
||||
}
|
||||
if (schedulerSharedSecret !== undefined) {
|
||||
runtime.schedulerSharedSecret = schedulerSharedSecret
|
||||
}
|
||||
const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY)
|
||||
if (openaiApiKey !== undefined) {
|
||||
runtime.openaiApiKey = openaiApiKey
|
||||
|
||||
@@ -1,53 +1,6 @@
|
||||
import { calculateMonthlySettlement } from '@household/application'
|
||||
import { createDbClient, schema } from '@household/db'
|
||||
import { BillingCycleId, BillingPeriod, MemberId, Money, PurchaseEntryId } from '@household/domain'
|
||||
import { and, desc, eq, gte, isNotNull, isNull, lte, or, sql } from 'drizzle-orm'
|
||||
import type { FinanceCommandService } from '@household/application'
|
||||
import type { Bot, Context } from 'grammy'
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
type SupportedCurrency = 'USD' | 'GEL'
|
||||
|
||||
interface FinanceCommandsConfig {
|
||||
householdId: string
|
||||
}
|
||||
|
||||
interface SettlementCycleData {
|
||||
id: string
|
||||
period: string
|
||||
currency: string
|
||||
}
|
||||
|
||||
interface HouseholdMemberData {
|
||||
id: string
|
||||
telegramUserId: string
|
||||
displayName: string
|
||||
isAdmin: number
|
||||
}
|
||||
|
||||
function parseCurrency(raw: string | undefined, fallback: SupportedCurrency): SupportedCurrency {
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const normalized = raw.trim().toUpperCase()
|
||||
if (normalized !== 'USD' && normalized !== 'GEL') {
|
||||
throw new Error(`Unsupported currency: ${raw}`)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function monthRange(period: BillingPeriod): { start: Date; end: Date } {
|
||||
const start = new Date(Date.UTC(period.year, period.month - 1, 1, 0, 0, 0))
|
||||
const end = new Date(Date.UTC(period.year, period.month, 0, 23, 59, 59))
|
||||
|
||||
return {
|
||||
start,
|
||||
end
|
||||
}
|
||||
}
|
||||
|
||||
function commandArgs(ctx: Context): string[] {
|
||||
const raw = typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
||||
if (raw.length === 0) {
|
||||
@@ -57,52 +10,17 @@ function commandArgs(ctx: Context): string[] {
|
||||
return raw.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function computeInputHash(payload: object): string {
|
||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
|
||||
}
|
||||
|
||||
export function createFinanceCommandsService(
|
||||
databaseUrl: string,
|
||||
config: FinanceCommandsConfig
|
||||
): {
|
||||
export function createFinanceCommandsService(financeService: FinanceCommandService): {
|
||||
register: (bot: Bot) => void
|
||||
close: () => Promise<void>
|
||||
} {
|
||||
const { db, queryClient } = createDbClient(databaseUrl, {
|
||||
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> {
|
||||
async function requireMember(ctx: Context) {
|
||||
const telegramUserId = ctx.from?.id?.toString()
|
||||
if (!telegramUserId) {
|
||||
await ctx.reply('Unable to identify sender for this command.')
|
||||
return null
|
||||
}
|
||||
|
||||
const member = await getMemberByTelegramUserId(telegramUserId)
|
||||
const member = await financeService.getMemberByTelegramUserId(telegramUserId)
|
||||
if (!member) {
|
||||
await ctx.reply('You are not a member of this household.')
|
||||
return null
|
||||
@@ -111,13 +29,13 @@ export function createFinanceCommandsService(
|
||||
return member
|
||||
}
|
||||
|
||||
async function requireAdmin(ctx: Context): Promise<HouseholdMemberData | null> {
|
||||
async function requireAdmin(ctx: Context) {
|
||||
const member = await requireMember(ctx)
|
||||
if (!member) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (member.isAdmin !== 1) {
|
||||
if (!member.isAdmin) {
|
||||
await ctx.reply('Only household admins can use this command.')
|
||||
return null
|
||||
}
|
||||
@@ -125,217 +43,6 @@ export function createFinanceCommandsService(
|
||||
return member
|
||||
}
|
||||
|
||||
async function getOpenCycle(): Promise<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 {
|
||||
bot.command('cycle_open', async (ctx) => {
|
||||
const admin = await requireAdmin(ctx)
|
||||
@@ -350,21 +57,8 @@ export function createFinanceCommandsService(
|
||||
}
|
||||
|
||||
try {
|
||||
const period = BillingPeriod.fromString(args[0]!).toString()
|
||||
const currency = parseCurrency(args[1], 'USD')
|
||||
|
||||
await db
|
||||
.insert(schema.billingCycles)
|
||||
.values({
|
||||
householdId: config.householdId,
|
||||
period,
|
||||
currency
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [schema.billingCycles.householdId, schema.billingCycles.period]
|
||||
})
|
||||
|
||||
await ctx.reply(`Cycle opened: ${period} (${currency})`)
|
||||
const cycle = await financeService.openCycle(args[0]!, args[1])
|
||||
await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`)
|
||||
} catch (error) {
|
||||
await ctx.reply(`Failed to open cycle: ${(error as Error).message}`)
|
||||
}
|
||||
@@ -376,21 +70,13 @@ export function createFinanceCommandsService(
|
||||
return
|
||||
}
|
||||
|
||||
const args = commandArgs(ctx)
|
||||
try {
|
||||
const cycle = await getCycleByPeriodOrLatest(args[0])
|
||||
const cycle = await financeService.closeCycle(commandArgs(ctx)[0])
|
||||
if (!cycle) {
|
||||
await ctx.reply('No cycle found to close.')
|
||||
return
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.billingCycles)
|
||||
.set({
|
||||
closedAt: new Date()
|
||||
})
|
||||
.where(eq(schema.billingCycles.id, cycle.id))
|
||||
|
||||
await ctx.reply(`Cycle closed: ${cycle.period}`)
|
||||
} catch (error) {
|
||||
await ctx.reply(`Failed to close cycle: ${(error as Error).message}`)
|
||||
@@ -410,34 +96,14 @@ export function createFinanceCommandsService(
|
||||
}
|
||||
|
||||
try {
|
||||
const openCycle = await getOpenCycle()
|
||||
const period = args[2] ?? openCycle?.period
|
||||
if (!period) {
|
||||
const result = await financeService.setRent(args[0]!, args[1], args[2])
|
||||
if (!result) {
|
||||
await ctx.reply('No period provided and no open cycle found.')
|
||||
return
|
||||
}
|
||||
|
||||
const currency = parseCurrency(args[1], (openCycle?.currency as SupportedCurrency) ?? 'USD')
|
||||
const amount = Money.fromMajor(args[0]!, currency)
|
||||
|
||||
await db
|
||||
.insert(schema.rentRules)
|
||||
.values({
|
||||
householdId: config.householdId,
|
||||
amountMinor: amount.amountMinor,
|
||||
currency,
|
||||
effectiveFromPeriod: BillingPeriod.fromString(period).toString()
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.rentRules.householdId, schema.rentRules.effectiveFromPeriod],
|
||||
set: {
|
||||
amountMinor: amount.amountMinor,
|
||||
currency
|
||||
}
|
||||
})
|
||||
|
||||
await ctx.reply(
|
||||
`Rent rule saved: ${amount.toMajorString()} ${currency} starting ${BillingPeriod.fromString(period).toString()}`
|
||||
`Rent rule saved: ${result.amount.toMajorString()} ${result.currency} starting ${result.period}`
|
||||
)
|
||||
} catch (error) {
|
||||
await ctx.reply(`Failed to save rent rule: ${(error as Error).message}`)
|
||||
@@ -457,29 +123,14 @@ export function createFinanceCommandsService(
|
||||
}
|
||||
|
||||
try {
|
||||
const openCycle = await getOpenCycle()
|
||||
if (!openCycle) {
|
||||
const result = await financeService.addUtilityBill(args[0]!, args[1]!, admin.id, args[2])
|
||||
if (!result) {
|
||||
await ctx.reply('No open cycle found. Use /cycle_open first.')
|
||||
return
|
||||
}
|
||||
|
||||
const name = args[0]!
|
||||
const amountRaw = args[1]!
|
||||
const currency = parseCurrency(args[2], parseCurrency(openCycle.currency, 'USD'))
|
||||
const amount = Money.fromMajor(amountRaw, currency)
|
||||
|
||||
await db.insert(schema.utilityBills).values({
|
||||
householdId: config.householdId,
|
||||
cycleId: openCycle.id,
|
||||
billName: name,
|
||||
amountMinor: amount.amountMinor,
|
||||
currency,
|
||||
source: 'manual',
|
||||
createdByMemberId: admin.id
|
||||
})
|
||||
|
||||
await ctx.reply(
|
||||
`Utility bill added: ${name} ${amount.toMajorString()} ${currency} for ${openCycle.period}`
|
||||
`Utility bill added: ${args[0]} ${result.amount.toMajorString()} ${result.currency} for ${result.period}`
|
||||
)
|
||||
} catch (error) {
|
||||
await ctx.reply(`Failed to add utility bill: ${(error as Error).message}`)
|
||||
@@ -492,16 +143,14 @@ export function createFinanceCommandsService(
|
||||
return
|
||||
}
|
||||
|
||||
const args = commandArgs(ctx)
|
||||
try {
|
||||
const cycle = await getCycleByPeriodOrLatest(args[0])
|
||||
if (!cycle) {
|
||||
const statement = await financeService.generateStatement(commandArgs(ctx)[0])
|
||||
if (!statement) {
|
||||
await ctx.reply('No cycle found for statement.')
|
||||
return
|
||||
}
|
||||
|
||||
const message = await upsertSettlementSnapshot(cycle)
|
||||
await ctx.reply(message)
|
||||
await ctx.reply(statement)
|
||||
} catch (error) {
|
||||
await ctx.reply(`Failed to generate statement: ${(error as Error).message}`)
|
||||
}
|
||||
@@ -509,9 +158,6 @@ export function createFinanceCommandsService(
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
close: async () => {
|
||||
await queryClient.end({ timeout: 5 })
|
||||
}
|
||||
register
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
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 { getBotRuntimeConfig } from './config'
|
||||
import { createFinanceCommandsService } from './finance-commands'
|
||||
import { createOpenAiParserFallback } from './openai-parser-fallback'
|
||||
import {
|
||||
createPurchaseMessageRepository,
|
||||
registerPurchaseTopicIngestion
|
||||
} from './purchase-topic-ingestion'
|
||||
import { createReminderJobsHandler } from './reminder-jobs'
|
||||
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
||||
import { createBotWebhookServer } from './server'
|
||||
import { createMiniAppAuthHandler } from './miniapp-auth'
|
||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||
|
||||
const runtime = getBotRuntimeConfig()
|
||||
const bot = createTelegramBot(runtime.telegramBotToken)
|
||||
const webhookHandler = webhookCallback(bot, 'std/http')
|
||||
|
||||
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) {
|
||||
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
||||
@@ -42,20 +79,81 @@ if (runtime.purchaseTopicIngestionEnabled) {
|
||||
}
|
||||
|
||||
if (runtime.financeCommandsEnabled) {
|
||||
const financeCommands = createFinanceCommandsService(runtime.databaseUrl!, {
|
||||
householdId: runtime.householdId!
|
||||
})
|
||||
const financeCommands = createFinanceCommandsService(financeService!)
|
||||
|
||||
financeCommands.register(bot)
|
||||
shutdownTasks.push(financeCommands.close)
|
||||
} else {
|
||||
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({
|
||||
webhookPath: runtime.telegramWebhookPath,
|
||||
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) {
|
||||
|
||||
183
apps/bot/src/miniapp-auth.test.ts
Normal file
183
apps/bot/src/miniapp-auth.test.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
197
apps/bot/src/miniapp-auth.ts
Normal file
197
apps/bot/src/miniapp-auth.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
163
apps/bot/src/miniapp-dashboard.test.ts
Normal file
163
apps/bot/src/miniapp-dashboard.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
106
apps/bot/src/miniapp-dashboard.ts
Normal file
106
apps/bot/src/miniapp-dashboard.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
apps/bot/src/reminder-jobs.test.ts
Normal file
110
apps/bot/src/reminder-jobs.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
113
apps/bot/src/reminder-jobs.ts
Normal file
113
apps/bot/src/reminder-jobs.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
apps/bot/src/scheduler-auth.test.ts
Normal file
75
apps/bot/src/scheduler-auth.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
79
apps/bot/src/scheduler-auth.ts
Normal file
79
apps/bot/src/scheduler-auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,36 @@ describe('createBotWebhookServer', () => {
|
||||
const server = createBotWebhookServer({
|
||||
webhookPath: '/webhook/telegram',
|
||||
webhookSecret: 'secret-token',
|
||||
webhookHandler: async () => new Response('ok', { status: 200 })
|
||||
webhookHandler: async () => new Response('ok', { status: 200 }),
|
||||
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 () => {
|
||||
@@ -59,4 +88,77 @@ describe('createBotWebhookServer', () => {
|
||||
expect(response.status).toBe(200)
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,25 @@ export interface BotWebhookServerOptions {
|
||||
webhookPath: string
|
||||
webhookSecret: string
|
||||
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 {
|
||||
@@ -25,6 +44,11 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
const normalizedWebhookPath = options.webhookPath.startsWith('/')
|
||||
? 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 {
|
||||
fetch: async (request: Request) => {
|
||||
@@ -34,7 +58,28 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
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 (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 })
|
||||
}
|
||||
|
||||
|
||||
68
apps/bot/src/telegram-miniapp-auth.test.ts
Normal file
68
apps/bot/src/telegram-miniapp-auth.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
89
apps/bot/src/telegram-miniapp-auth.ts
Normal file
89
apps/bot/src/telegram-miniapp-auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
19
apps/bot/src/telegram-miniapp-test-helpers.ts
Normal file
19
apps/bot/src/telegram-miniapp-test-helpers.ts
Normal 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()
|
||||
}
|
||||
@@ -6,6 +6,7 @@ WORKDIR /app
|
||||
COPY bun.lock package.json tsconfig.base.json ./
|
||||
COPY apps/bot/package.json apps/bot/package.json
|
||||
COPY apps/miniapp/package.json apps/miniapp/package.json
|
||||
COPY packages/adapters-db/package.json packages/adapters-db/package.json
|
||||
COPY packages/application/package.json packages/application/package.json
|
||||
COPY packages/config/package.json packages/config/package.json
|
||||
COPY packages/contracts/package.json packages/contracts/package.json
|
||||
@@ -26,10 +27,15 @@ RUN bun run --filter @household/miniapp build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
ENV BOT_API_URL=""
|
||||
|
||||
COPY apps/miniapp/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY apps/miniapp/config.template.js /usr/share/nginx/html/config.template.js
|
||||
COPY --from=build /app/apps/miniapp/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1
|
||||
|
||||
CMD ["/bin/sh", "-c", "envsubst < /usr/share/nginx/html/config.template.js > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
|
||||
|
||||
3
apps/miniapp/config.template.js
Normal file
3
apps/miniapp/config.template.js
Normal file
@@ -0,0 +1,3 @@
|
||||
window.__HOUSEHOLD_CONFIG__ = {
|
||||
botApiUrl: '${BOT_API_URL}'
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>Solid App</title>
|
||||
<meta name="theme-color" content="#121a24" />
|
||||
<title>Kojori House</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/config.js"></script>
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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() {
|
||||
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 (
|
||||
<main>
|
||||
<h1>Household Mini App</h1>
|
||||
<p>SolidJS scaffold is ready</p>
|
||||
<main class="shell">
|
||||
<div class="shell__backdrop shell__backdrop--top" />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
92
apps/miniapp/src/i18n.ts
Normal file
92
apps/miniapp/src/i18n.ts
Normal 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>>
|
||||
@@ -1 +1,281 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
114
apps/miniapp/src/miniapp-api.ts
Normal file
114
apps/miniapp/src/miniapp-api.ts
Normal 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
|
||||
}
|
||||
13
apps/miniapp/src/runtime-config.ts
Normal file
13
apps/miniapp/src/runtime-config.ts
Normal 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
|
||||
}
|
||||
31
apps/miniapp/src/telegram-webapp.ts
Normal file
31
apps/miniapp/src/telegram-webapp.ts
Normal 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
9
apps/miniapp/src/vite-env.d.ts
vendored
Normal 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
144
bun.lock
@@ -16,10 +16,13 @@
|
||||
"apps/bot": {
|
||||
"name": "@household/bot",
|
||||
"dependencies": {
|
||||
"@household/adapters-db": "workspace:*",
|
||||
"@household/application": "workspace:*",
|
||||
"@household/db": "workspace:*",
|
||||
"@household/domain": "workspace:*",
|
||||
"@household/ports": "workspace:*",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"google-auth-library": "^10.4.1",
|
||||
"grammy": "1.41.1",
|
||||
},
|
||||
},
|
||||
@@ -37,10 +40,20 @@
|
||||
"vite-plugin-solid": "^2.11.8",
|
||||
},
|
||||
},
|
||||
"packages/adapters-db": {
|
||||
"name": "@household/adapters-db",
|
||||
"dependencies": {
|
||||
"@household/db": "workspace:*",
|
||||
"@household/domain": "workspace:*",
|
||||
"@household/ports": "workspace:*",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
},
|
||||
},
|
||||
"packages/application": {
|
||||
"name": "@household/application",
|
||||
"dependencies": {
|
||||
"@household/domain": "workspace:*",
|
||||
"@household/ports": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/config": {
|
||||
@@ -68,6 +81,9 @@
|
||||
},
|
||||
"packages/ports": {
|
||||
"name": "@household/ports",
|
||||
"dependencies": {
|
||||
"@household/domain": "workspace:*",
|
||||
},
|
||||
},
|
||||
"scripts": {
|
||||
"name": "@household/scripts",
|
||||
@@ -177,6 +193,8 @@
|
||||
|
||||
"@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="],
|
||||
|
||||
"@household/adapters-db": ["@household/adapters-db@workspace:packages/adapters-db"],
|
||||
|
||||
"@household/application": ["@household/application@workspace:packages/application"],
|
||||
|
||||
"@household/bot": ["@household/bot@workspace:apps/bot"],
|
||||
@@ -197,6 +215,8 @@
|
||||
|
||||
"@household/scripts": ["@household/scripts@workspace:scripts"],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -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=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||
@@ -387,24 +409,48 @@
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="],
|
||||
|
||||
"babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -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=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
@@ -427,30 +479,62 @@
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"google-auth-library": ["google-auth-library@10.6.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "7.1.3", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA=="],
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"grammy": ["grammy@1.41.1", "", { "dependencies": { "@grammyjs/types": "3.25.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ=="],
|
||||
|
||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"lefthook": ["lefthook@2.1.2", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.2", "lefthook-darwin-x64": "2.1.2", "lefthook-freebsd-arm64": "2.1.2", "lefthook-freebsd-x64": "2.1.2", "lefthook-linux-arm64": "2.1.2", "lefthook-linux-x64": "2.1.2", "lefthook-openbsd-arm64": "2.1.2", "lefthook-openbsd-x64": "2.1.2", "lefthook-windows-arm64": "2.1.2", "lefthook-windows-x64": "2.1.2" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-HdAMl4g47kbWSkrUkCx3Kucq54omFS6piMJtXwXNtmCAfB40UaybTJuYtFW4hNzZ5SvaEimtxTp7P/MNIkEfsA=="],
|
||||
|
||||
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AgHu93YuJtj1l9bcKlCbo4Tg8N8xFl9iD6BjXCGaGMu46LSjFiXbJFlkUdpgrL8fIbwoCjJi5FNp3POpqs4Wdw=="],
|
||||
@@ -503,18 +587,30 @@
|
||||
|
||||
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"oxlint": ["oxlint@1.51.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.51.0", "@oxlint/binding-android-arm64": "1.51.0", "@oxlint/binding-darwin-arm64": "1.51.0", "@oxlint/binding-darwin-x64": "1.51.0", "@oxlint/binding-freebsd-x64": "1.51.0", "@oxlint/binding-linux-arm-gnueabihf": "1.51.0", "@oxlint/binding-linux-arm-musleabihf": "1.51.0", "@oxlint/binding-linux-arm64-gnu": "1.51.0", "@oxlint/binding-linux-arm64-musl": "1.51.0", "@oxlint/binding-linux-ppc64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-musl": "1.51.0", "@oxlint/binding-linux-s390x-gnu": "1.51.0", "@oxlint/binding-linux-x64-gnu": "1.51.0", "@oxlint/binding-linux-x64-musl": "1.51.0", "@oxlint/binding-openharmony-arm64": "1.51.0", "@oxlint/binding-win32-arm64-msvc": "1.51.0", "@oxlint/binding-win32-ia32-msvc": "1.51.0", "@oxlint/binding-win32-x64-msvc": "1.51.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
@@ -525,14 +621,24 @@
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
|
||||
|
||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
|
||||
|
||||
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"solid-devtools": ["solid-devtools@0.34.5", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.6", "@solid-devtools/debugger": "^0.28.1", "@solid-devtools/shared": "^0.20.0" }, "peerDependencies": { "solid-js": "^1.9.0", "vite": "^2.2.3 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["vite"] }, "sha512-KNVdS9MQzzeVS++Vmg4JeU0fM6ZMuBEmkBA7SmqPS2s5UHpRjv1PNH8gShmlN9L/tki6OUAzJP3H1aKq2AcOSg=="],
|
||||
|
||||
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
|
||||
@@ -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=="],
|
||||
|
||||
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
@@ -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=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
@@ -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=="],
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||
|
||||
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
@@ -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=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||
@@ -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-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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ bun run db:generate
|
||||
bun run db:check
|
||||
bun run db:migrate
|
||||
bun run db:seed
|
||||
bun run ops:telegram:webhook info
|
||||
bun run ops:deploy:smoke
|
||||
bun run infra:fmt:check
|
||||
bun run infra:validate
|
||||
```
|
||||
@@ -60,6 +62,7 @@ bun run review:coderabbit
|
||||
- Typed environment validation lives in `packages/config/src/env.ts`.
|
||||
- Copy `.env.example` to `.env` before running app/database commands.
|
||||
- Migration workflow is documented in `docs/runbooks/migrations.md`.
|
||||
- First deploy flow is documented in `docs/runbooks/first-deploy.md`.
|
||||
|
||||
## CI/CD
|
||||
|
||||
|
||||
255
docs/runbooks/first-deploy.md
Normal file
255
docs/runbooks/first-deploy.md
Normal 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.
|
||||
@@ -48,8 +48,35 @@ Keep bot runtime config that is not secret in your `*.tfvars` file:
|
||||
- `bot_household_id`
|
||||
- `bot_household_chat_id`
|
||||
- `bot_purchase_topic_id`
|
||||
- optional `bot_feedback_topic_id`
|
||||
- `bot_mini_app_allowed_origins`
|
||||
- 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
|
||||
|
||||
- Keep separate states for `dev` and `prod`.
|
||||
|
||||
76
docs/specs/HOUSEBOT-024-repository-adapters.md
Normal file
76
docs/specs/HOUSEBOT-024-repository-adapters.md
Normal 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.
|
||||
59
docs/specs/HOUSEBOT-030-cloud-scheduler-jobs.md
Normal file
59
docs/specs/HOUSEBOT-030-cloud-scheduler-jobs.md
Normal 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.
|
||||
81
docs/specs/HOUSEBOT-031-secure-scheduler-endpoint.md
Normal file
81
docs/specs/HOUSEBOT-031-secure-scheduler-endpoint.md
Normal 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.
|
||||
60
docs/specs/HOUSEBOT-040-miniapp-shell.md
Normal file
60
docs/specs/HOUSEBOT-040-miniapp-shell.md
Normal 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.
|
||||
79
docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md
Normal file
79
docs/specs/HOUSEBOT-041-miniapp-finance-dashboard.md
Normal 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.
|
||||
80
docs/specs/HOUSEBOT-050-anonymous-feedback-dm.md
Normal file
80
docs/specs/HOUSEBOT-050-anonymous-feedback-dm.md
Normal 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.
|
||||
62
docs/specs/HOUSEBOT-062-first-deploy-runbook.md
Normal file
62
docs/specs/HOUSEBOT-062-first-deploy-runbook.md
Normal 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.
|
||||
@@ -7,7 +7,7 @@ This directory contains baseline IaC for deploying the household bot platform on
|
||||
- Artifact Registry Docker repository
|
||||
- Cloud Run service: bot API (public webhook endpoint)
|
||||
- Cloud Run service: mini app (public web UI)
|
||||
- Cloud Scheduler job for reminder triggers
|
||||
- Cloud Scheduler jobs for reminder triggers
|
||||
- Runtime and scheduler service accounts with least-privilege bindings
|
||||
- Secret Manager secrets (IDs only, secret values are added separately)
|
||||
- Optional GitHub OIDC Workload Identity setup for deploy automation
|
||||
@@ -16,7 +16,7 @@ This directory contains baseline IaC for deploying the household bot platform on
|
||||
|
||||
- `bot-api`: Telegram webhook + app API endpoints
|
||||
- `mini-app`: front-end delivery
|
||||
- `scheduler`: triggers `bot-api` internal reminder endpoint using OIDC token
|
||||
- `scheduler`: triggers `bot-api` reminder endpoints using OIDC tokens
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -72,7 +72,9 @@ Recommended approach:
|
||||
- `bot_household_id`
|
||||
- `bot_household_chat_id`
|
||||
- `bot_purchase_topic_id`
|
||||
- optional `bot_feedback_topic_id`
|
||||
- optional `bot_parser_model`
|
||||
- optional `bot_mini_app_allowed_origins`
|
||||
|
||||
## CI validation
|
||||
|
||||
@@ -84,5 +86,6 @@ CI runs:
|
||||
|
||||
## Notes
|
||||
|
||||
- Scheduler job defaults to `paused = true` to prevent accidental sends before app logic is ready.
|
||||
- Scheduler jobs default to `paused = true` and `dry_run = true` to prevent accidental sends before live reminder delivery is ready.
|
||||
- Bot API is public to accept Telegram webhooks; scheduler endpoint should still verify app-level auth.
|
||||
- `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.
|
||||
|
||||
@@ -12,6 +12,21 @@ locals {
|
||||
|
||||
artifact_location = coalesce(var.artifact_repository_location, var.region)
|
||||
|
||||
reminder_jobs = {
|
||||
utilities = {
|
||||
schedule = var.scheduler_utilities_cron
|
||||
path = "/jobs/reminder/utilities"
|
||||
}
|
||||
rent-warning = {
|
||||
schedule = var.scheduler_rent_warning_cron
|
||||
path = "/jobs/reminder/rent-warning"
|
||||
}
|
||||
rent-due = {
|
||||
schedule = var.scheduler_rent_due_cron
|
||||
path = "/jobs/reminder/rent-due"
|
||||
}
|
||||
}
|
||||
|
||||
runtime_secret_ids = toset(compact([
|
||||
var.telegram_webhook_secret_id,
|
||||
var.scheduler_shared_secret_id,
|
||||
|
||||
@@ -90,8 +90,17 @@ module "bot_api_service" {
|
||||
var.bot_purchase_topic_id == null ? {} : {
|
||||
TELEGRAM_PURCHASE_TOPIC_ID = tostring(var.bot_purchase_topic_id)
|
||||
},
|
||||
var.bot_feedback_topic_id == null ? {} : {
|
||||
TELEGRAM_FEEDBACK_TOPIC_ID = tostring(var.bot_feedback_topic_id)
|
||||
},
|
||||
var.bot_parser_model == null ? {} : {
|
||||
PARSER_MODEL = var.bot_parser_model
|
||||
},
|
||||
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
|
||||
|
||||
env = {
|
||||
NODE_ENV = var.environment
|
||||
NODE_ENV = var.environment
|
||||
BOT_API_URL = module.bot_api_service.uri
|
||||
}
|
||||
|
||||
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" {
|
||||
for_each = local.reminder_jobs
|
||||
|
||||
project = var.project_id
|
||||
region = var.region
|
||||
name = "${local.name_prefix}-reminders"
|
||||
schedule = var.scheduler_cron
|
||||
name = "${local.name_prefix}-${each.key}"
|
||||
schedule = each.value.schedule
|
||||
time_zone = var.scheduler_timezone
|
||||
paused = var.scheduler_paused
|
||||
|
||||
http_target {
|
||||
uri = "${module.bot_api_service.uri}${var.scheduler_path}"
|
||||
http_method = var.scheduler_http_method
|
||||
uri = "${module.bot_api_service.uri}${each.value.path}"
|
||||
http_method = "POST"
|
||||
|
||||
headers = {
|
||||
"Content-Type" = "application/json"
|
||||
}
|
||||
|
||||
body = base64encode(var.scheduler_body_json)
|
||||
body = base64encode(jsonencode({
|
||||
dryRun = var.scheduler_dry_run
|
||||
jobId = "${local.name_prefix}-${each.key}"
|
||||
}))
|
||||
|
||||
oidc_token {
|
||||
service_account_email = google_service_account.scheduler_invoker.email
|
||||
|
||||
@@ -23,9 +23,9 @@ output "mini_app_service_url" {
|
||||
value = module.mini_app_service.uri
|
||||
}
|
||||
|
||||
output "scheduler_job_name" {
|
||||
description = "Cloud Scheduler job for reminders"
|
||||
value = google_cloud_scheduler_job.reminders.name
|
||||
output "scheduler_job_names" {
|
||||
description = "Cloud Scheduler jobs for reminders"
|
||||
value = { for name, job in google_cloud_scheduler_job.reminders : name => job.name }
|
||||
}
|
||||
|
||||
output "runtime_secret_ids" {
|
||||
|
||||
@@ -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_chat_id = "-1001234567890"
|
||||
bot_purchase_topic_id = 777
|
||||
bot_feedback_topic_id = 778
|
||||
bot_parser_model = "gpt-4.1-mini"
|
||||
bot_mini_app_allowed_origins = [
|
||||
"https://household-dev-mini-app-abc123-ew.a.run.app"
|
||||
]
|
||||
|
||||
scheduler_cron = "0 9 * * *"
|
||||
scheduler_timezone = "Asia/Tbilisi"
|
||||
scheduler_paused = true
|
||||
scheduler_utilities_cron = "0 9 4 * *"
|
||||
scheduler_rent_warning_cron = "0 9 17 * *"
|
||||
scheduler_rent_due_cron = "0 9 20 * *"
|
||||
scheduler_timezone = "Asia/Tbilisi"
|
||||
scheduler_paused = true
|
||||
scheduler_dry_run = true
|
||||
|
||||
create_workload_identity = true
|
||||
github_repository = "whekin/household-bot"
|
||||
|
||||
@@ -104,6 +104,13 @@ variable "bot_purchase_topic_id" {
|
||||
nullable = true
|
||||
}
|
||||
|
||||
variable "bot_feedback_topic_id" {
|
||||
description = "Optional TELEGRAM_FEEDBACK_TOPIC_ID value for bot runtime"
|
||||
type = number
|
||||
default = null
|
||||
nullable = true
|
||||
}
|
||||
|
||||
variable "bot_parser_model" {
|
||||
description = "Optional PARSER_MODEL override for bot runtime"
|
||||
type = string
|
||||
@@ -111,6 +118,12 @@ variable "bot_parser_model" {
|
||||
nullable = true
|
||||
}
|
||||
|
||||
variable "bot_mini_app_allowed_origins" {
|
||||
description = "Optional allow-list of mini app origins for bot CORS handling"
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "openai_api_key_secret_id" {
|
||||
description = "Optional Secret Manager ID for OPENAI_API_KEY"
|
||||
type = string
|
||||
@@ -118,35 +131,34 @@ variable "openai_api_key_secret_id" {
|
||||
nullable = true
|
||||
}
|
||||
|
||||
|
||||
variable "scheduler_path" {
|
||||
description = "Reminder endpoint path on bot API"
|
||||
type = string
|
||||
default = "/internal/scheduler/reminders"
|
||||
}
|
||||
|
||||
variable "scheduler_http_method" {
|
||||
description = "Scheduler HTTP method"
|
||||
type = string
|
||||
default = "POST"
|
||||
}
|
||||
|
||||
variable "scheduler_cron" {
|
||||
description = "Cron expression for reminder scheduler"
|
||||
type = string
|
||||
default = "0 9 * * *"
|
||||
}
|
||||
|
||||
variable "scheduler_timezone" {
|
||||
description = "Scheduler timezone"
|
||||
type = string
|
||||
default = "Asia/Tbilisi"
|
||||
}
|
||||
|
||||
variable "scheduler_body_json" {
|
||||
description = "JSON payload for scheduler requests"
|
||||
variable "scheduler_utilities_cron" {
|
||||
description = "Cron expression for the utilities reminder scheduler job"
|
||||
type = string
|
||||
default = "{\"kind\":\"monthly-reminder\"}"
|
||||
default = "0 9 4 * *"
|
||||
}
|
||||
|
||||
variable "scheduler_rent_warning_cron" {
|
||||
description = "Cron expression for the rent warning scheduler job"
|
||||
type = string
|
||||
default = "0 9 17 * *"
|
||||
}
|
||||
|
||||
variable "scheduler_rent_due_cron" {
|
||||
description = "Cron expression for the rent due scheduler job"
|
||||
type = string
|
||||
default = "0 9 20 * *"
|
||||
}
|
||||
|
||||
variable "scheduler_dry_run" {
|
||||
description = "Whether scheduler jobs should invoke the bot in dry-run mode"
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "scheduler_paused" {
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
"docker:build:miniapp": "docker build -f apps/miniapp/Dockerfile -t household-miniapp:local .",
|
||||
"docker:build": "bun run docker:build:bot && bun run docker:build:miniapp",
|
||||
"docker:smoke": "docker compose up --build",
|
||||
"test:e2e": "bun run scripts/e2e/billing-flow.ts"
|
||||
"test:e2e": "bun run scripts/e2e/billing-flow.ts",
|
||||
"ops:deploy:smoke": "bun run scripts/ops/deploy-smoke.ts",
|
||||
"ops:telegram:webhook": "bun run scripts/ops/telegram-webhook.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.10",
|
||||
|
||||
20
packages/adapters-db/package.json
Normal file
20
packages/adapters-db/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
163
packages/adapters-db/src/anonymous-feedback-repository.ts
Normal file
163
packages/adapters-db/src/anonymous-feedback-repository.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
359
packages/adapters-db/src/finance-repository.ts
Normal file
359
packages/adapters-db/src/finance-repository.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/adapters-db/src/index.ts
Normal file
3
packages/adapters-db/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository'
|
||||
export { createDbFinanceRepository } from './finance-repository'
|
||||
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
|
||||
46
packages/adapters-db/src/reminder-dispatch-repository.ts
Normal file
46
packages/adapters-db/src/reminder-dispatch-repository.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
7
packages/adapters-db/tsconfig.json
Normal file
7
packages/adapters-db/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"lint": "oxlint \"src\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@household/domain": "workspace:*"
|
||||
"@household/domain": "workspace:*",
|
||||
"@household/ports": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
217
packages/application/src/anonymous-feedback-service.test.ts
Normal file
217
packages/application/src/anonymous-feedback-service.test.ts
Normal 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'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
223
packages/application/src/anonymous-feedback-service.ts
Normal file
223
packages/application/src/anonymous-feedback-service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
220
packages/application/src/finance-command-service.test.ts
Normal file
220
packages/application/src/finance-command-service.test.ts
Normal 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
|
||||
])
|
||||
})
|
||||
})
|
||||
321
packages/application/src/finance-command-service.ts
Normal file
321
packages/application/src/finance-command-service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
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 {
|
||||
parsePurchaseMessage,
|
||||
type ParsedPurchaseResult,
|
||||
|
||||
85
packages/application/src/reminder-job-service.test.ts
Normal file
85
packages/application/src/reminder-job-service.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
88
packages/application/src/reminder-job-service.ts
Normal file
88
packages/application/src/reminder-job-service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/db/drizzle/0004_big_ultimatum.sql
Normal file
24
packages/db/drizzle/0004_big_ultimatum.sql
Normal 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");
|
||||
1587
packages/db/drizzle/meta/0004_snapshot.json
Normal file
1587
packages/db/drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
||||
"when": 1772671128084,
|
||||
"tag": "0003_mature_roulette",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1772995779819,
|
||||
"tag": "0004_big_ultimatum",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -247,6 +247,46 @@ export const processedBotMessages = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
export const anonymousMessages = pgTable(
|
||||
'anonymous_messages',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
householdId: uuid('household_id')
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: 'cascade' }),
|
||||
submittedByMemberId: uuid('submitted_by_member_id')
|
||||
.notNull()
|
||||
.references(() => members.id, { onDelete: 'restrict' }),
|
||||
rawText: text('raw_text').notNull(),
|
||||
sanitizedText: text('sanitized_text'),
|
||||
moderationStatus: text('moderation_status').notNull(),
|
||||
moderationReason: text('moderation_reason'),
|
||||
telegramChatId: text('telegram_chat_id').notNull(),
|
||||
telegramMessageId: text('telegram_message_id').notNull(),
|
||||
telegramUpdateId: text('telegram_update_id').notNull(),
|
||||
postedChatId: text('posted_chat_id'),
|
||||
postedThreadId: text('posted_thread_id'),
|
||||
postedMessageId: text('posted_message_id'),
|
||||
failureReason: text('failure_reason'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
postedAt: timestamp('posted_at', { withTimezone: true })
|
||||
},
|
||||
(table) => ({
|
||||
householdUpdateUnique: uniqueIndex('anonymous_messages_household_tg_update_unique').on(
|
||||
table.householdId,
|
||||
table.telegramUpdateId
|
||||
),
|
||||
memberCreatedIdx: index('anonymous_messages_member_created_idx').on(
|
||||
table.submittedByMemberId,
|
||||
table.createdAt
|
||||
),
|
||||
statusCreatedIdx: index('anonymous_messages_status_created_idx').on(
|
||||
table.moderationStatus,
|
||||
table.createdAt
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
export const settlements = pgTable(
|
||||
'settlements',
|
||||
{
|
||||
@@ -308,4 +348,5 @@ export type BillingCycle = typeof billingCycles.$inferSelect
|
||||
export type UtilityBill = typeof utilityBills.$inferSelect
|
||||
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
|
||||
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
|
||||
export type AnonymousMessage = typeof anonymousMessages.$inferSelect
|
||||
export type Settlement = typeof settlements.$inferSelect
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
"name": "@household/ports",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts --outdir dist --target bun",
|
||||
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
||||
"test": "bun test --pass-with-no-tests",
|
||||
"lint": "oxlint \"src\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@household/domain": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
51
packages/ports/src/anonymous-feedback.ts
Normal file
51
packages/ports/src/anonymous-feedback.ts
Normal 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>
|
||||
}
|
||||
80
packages/ports/src/finance.ts
Normal file
80
packages/ports/src/finance.ts
Normal 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>
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
19
packages/ports/src/reminders.ts
Normal file
19
packages/ports/src/reminders.ts
Normal 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>
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
import { createFinanceCommandService } from '@household/application'
|
||||
import { createDbFinanceRepository } from '@household/adapters-db'
|
||||
import { createDbClient, schema } from '@household/db'
|
||||
|
||||
import { createTelegramBot } from '../../apps/bot/src/bot'
|
||||
@@ -129,7 +131,7 @@ async function run(): Promise<void> {
|
||||
|
||||
let coreClient: ReturnType<typeof createDbClient> | 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 replies: string[] = []
|
||||
@@ -178,9 +180,9 @@ async function run(): Promise<void> {
|
||||
})
|
||||
|
||||
ingestionClient = createPurchaseMessageRepository(databaseUrl)
|
||||
financeService = createFinanceCommandsService(databaseUrl, {
|
||||
householdId: ids.household
|
||||
})
|
||||
financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household)
|
||||
const financeService = createFinanceCommandService(financeRepositoryClient.repository)
|
||||
const financeCommands = createFinanceCommandsService(financeService)
|
||||
|
||||
registerPurchaseTopicIngestion(
|
||||
bot,
|
||||
@@ -192,7 +194,7 @@ async function run(): Promise<void> {
|
||||
ingestionClient.repository
|
||||
)
|
||||
|
||||
financeService.register(bot)
|
||||
financeCommands.register(bot)
|
||||
|
||||
await coreClient.db.insert(schema.households).values({
|
||||
id: ids.household,
|
||||
@@ -336,7 +338,7 @@ async function run(): Promise<void> {
|
||||
: undefined,
|
||||
coreClient?.queryClient.end({ timeout: 5 }),
|
||||
ingestionClient?.close(),
|
||||
financeService?.close()
|
||||
financeRepositoryClient?.close()
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
132
scripts/ops/deploy-smoke.ts
Normal file
132
scripts/ops/deploy-smoke.ts
Normal 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
|
||||
})
|
||||
96
scripts/ops/telegram-webhook.ts
Normal file
96
scripts/ops/telegram-webhook.ts
Normal 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
|
||||
})
|
||||
@@ -28,6 +28,9 @@
|
||||
{
|
||||
"path": "./packages/db"
|
||||
},
|
||||
{
|
||||
"path": "./packages/adapters-db"
|
||||
},
|
||||
{
|
||||
"path": "./scripts"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user