mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 19:24: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 bun.lock package.json tsconfig.base.json ./
|
||||||
COPY apps/bot/package.json apps/bot/package.json
|
COPY apps/bot/package.json apps/bot/package.json
|
||||||
COPY apps/miniapp/package.json apps/miniapp/package.json
|
COPY apps/miniapp/package.json apps/miniapp/package.json
|
||||||
|
COPY packages/adapters-db/package.json packages/adapters-db/package.json
|
||||||
COPY packages/application/package.json packages/application/package.json
|
COPY packages/application/package.json packages/application/package.json
|
||||||
COPY packages/config/package.json packages/config/package.json
|
COPY packages/config/package.json packages/config/package.json
|
||||||
COPY packages/contracts/package.json packages/contracts/package.json
|
COPY packages/contracts/package.json packages/contracts/package.json
|
||||||
|
|||||||
@@ -10,10 +10,13 @@
|
|||||||
"lint": "oxlint \"src\""
|
"lint": "oxlint \"src\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@household/adapters-db": "workspace:*",
|
||||||
"@household/application": "workspace:*",
|
"@household/application": "workspace:*",
|
||||||
"@household/db": "workspace:*",
|
"@household/db": "workspace:*",
|
||||||
"@household/domain": "workspace:*",
|
"@household/domain": "workspace:*",
|
||||||
|
"@household/ports": "workspace:*",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"google-auth-library": "^10.4.1",
|
||||||
"grammy": "1.41.1"
|
"grammy": "1.41.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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.',
|
'Household bot scaffold is live.',
|
||||||
'Available commands:',
|
'Available commands:',
|
||||||
'/help - Show command list',
|
'/help - Show command list',
|
||||||
'/household_status - Show placeholder household status'
|
'/household_status - Show placeholder household status',
|
||||||
|
'/anon <message> - Send anonymous household feedback in a private chat'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,8 +7,15 @@ export interface BotRuntimeConfig {
|
|||||||
householdId?: string
|
householdId?: string
|
||||||
telegramHouseholdChatId?: string
|
telegramHouseholdChatId?: string
|
||||||
telegramPurchaseTopicId?: number
|
telegramPurchaseTopicId?: number
|
||||||
|
telegramFeedbackTopicId?: number
|
||||||
purchaseTopicIngestionEnabled: boolean
|
purchaseTopicIngestionEnabled: boolean
|
||||||
financeCommandsEnabled: boolean
|
financeCommandsEnabled: boolean
|
||||||
|
anonymousFeedbackEnabled: boolean
|
||||||
|
miniAppAllowedOrigins: readonly string[]
|
||||||
|
miniAppAuthEnabled: boolean
|
||||||
|
schedulerSharedSecret?: string
|
||||||
|
schedulerOidcAllowedEmails: readonly string[]
|
||||||
|
reminderJobsEnabled: boolean
|
||||||
openaiApiKey?: string
|
openaiApiKey?: string
|
||||||
parserModel: string
|
parserModel: string
|
||||||
}
|
}
|
||||||
@@ -41,7 +48,7 @@ function parseOptionalTopicId(raw: string | undefined): number | undefined {
|
|||||||
|
|
||||||
const parsed = Number(raw)
|
const parsed = Number(raw)
|
||||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
throw new Error(`Invalid TELEGRAM_PURCHASE_TOPIC_ID value: ${raw}`)
|
throw new Error(`Invalid Telegram topic id value: ${raw}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed
|
return parsed
|
||||||
@@ -52,11 +59,28 @@ function parseOptionalValue(value: string | undefined): string | undefined {
|
|||||||
return trimmed && trimmed.length > 0 ? trimmed : undefined
|
return trimmed && trimmed.length > 0 ? trimmed : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOptionalCsv(value: string | undefined): readonly string[] {
|
||||||
|
const trimmed = value?.trim()
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig {
|
export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig {
|
||||||
const databaseUrl = parseOptionalValue(env.DATABASE_URL)
|
const databaseUrl = parseOptionalValue(env.DATABASE_URL)
|
||||||
const householdId = parseOptionalValue(env.HOUSEHOLD_ID)
|
const householdId = parseOptionalValue(env.HOUSEHOLD_ID)
|
||||||
const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID)
|
const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID)
|
||||||
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID)
|
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID)
|
||||||
|
const telegramFeedbackTopicId = parseOptionalTopicId(env.TELEGRAM_FEEDBACK_TOPIC_ID)
|
||||||
|
const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET)
|
||||||
|
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
|
||||||
|
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
|
||||||
|
|
||||||
const purchaseTopicIngestionEnabled =
|
const purchaseTopicIngestionEnabled =
|
||||||
databaseUrl !== undefined &&
|
databaseUrl !== undefined &&
|
||||||
@@ -65,6 +89,17 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
telegramPurchaseTopicId !== undefined
|
telegramPurchaseTopicId !== undefined
|
||||||
|
|
||||||
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
|
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
|
||||||
|
const anonymousFeedbackEnabled =
|
||||||
|
databaseUrl !== undefined &&
|
||||||
|
householdId !== undefined &&
|
||||||
|
telegramHouseholdChatId !== undefined &&
|
||||||
|
telegramFeedbackTopicId !== undefined
|
||||||
|
const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined
|
||||||
|
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
||||||
|
const reminderJobsEnabled =
|
||||||
|
databaseUrl !== undefined &&
|
||||||
|
householdId !== undefined &&
|
||||||
|
(schedulerSharedSecret !== undefined || hasSchedulerOidcConfig)
|
||||||
|
|
||||||
const runtime: BotRuntimeConfig = {
|
const runtime: BotRuntimeConfig = {
|
||||||
port: parsePort(env.PORT),
|
port: parsePort(env.PORT),
|
||||||
@@ -73,6 +108,11 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
|
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
|
||||||
purchaseTopicIngestionEnabled,
|
purchaseTopicIngestionEnabled,
|
||||||
financeCommandsEnabled,
|
financeCommandsEnabled,
|
||||||
|
anonymousFeedbackEnabled,
|
||||||
|
miniAppAllowedOrigins,
|
||||||
|
miniAppAuthEnabled,
|
||||||
|
schedulerOidcAllowedEmails,
|
||||||
|
reminderJobsEnabled,
|
||||||
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini'
|
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +128,12 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
if (telegramPurchaseTopicId !== undefined) {
|
if (telegramPurchaseTopicId !== undefined) {
|
||||||
runtime.telegramPurchaseTopicId = telegramPurchaseTopicId
|
runtime.telegramPurchaseTopicId = telegramPurchaseTopicId
|
||||||
}
|
}
|
||||||
|
if (telegramFeedbackTopicId !== undefined) {
|
||||||
|
runtime.telegramFeedbackTopicId = telegramFeedbackTopicId
|
||||||
|
}
|
||||||
|
if (schedulerSharedSecret !== undefined) {
|
||||||
|
runtime.schedulerSharedSecret = schedulerSharedSecret
|
||||||
|
}
|
||||||
const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY)
|
const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY)
|
||||||
if (openaiApiKey !== undefined) {
|
if (openaiApiKey !== undefined) {
|
||||||
runtime.openaiApiKey = openaiApiKey
|
runtime.openaiApiKey = openaiApiKey
|
||||||
|
|||||||
@@ -1,53 +1,6 @@
|
|||||||
import { calculateMonthlySettlement } from '@household/application'
|
import type { FinanceCommandService } from '@household/application'
|
||||||
import { createDbClient, schema } from '@household/db'
|
|
||||||
import { BillingCycleId, BillingPeriod, MemberId, Money, PurchaseEntryId } from '@household/domain'
|
|
||||||
import { and, desc, eq, gte, isNotNull, isNull, lte, or, sql } from 'drizzle-orm'
|
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
|
|
||||||
import { createHash } from 'node:crypto'
|
|
||||||
|
|
||||||
type SupportedCurrency = 'USD' | 'GEL'
|
|
||||||
|
|
||||||
interface FinanceCommandsConfig {
|
|
||||||
householdId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettlementCycleData {
|
|
||||||
id: string
|
|
||||||
period: string
|
|
||||||
currency: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HouseholdMemberData {
|
|
||||||
id: string
|
|
||||||
telegramUserId: string
|
|
||||||
displayName: string
|
|
||||||
isAdmin: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCurrency(raw: string | undefined, fallback: SupportedCurrency): SupportedCurrency {
|
|
||||||
if (!raw || raw.trim().length === 0) {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = raw.trim().toUpperCase()
|
|
||||||
if (normalized !== 'USD' && normalized !== 'GEL') {
|
|
||||||
throw new Error(`Unsupported currency: ${raw}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
function monthRange(period: BillingPeriod): { start: Date; end: Date } {
|
|
||||||
const start = new Date(Date.UTC(period.year, period.month - 1, 1, 0, 0, 0))
|
|
||||||
const end = new Date(Date.UTC(period.year, period.month, 0, 23, 59, 59))
|
|
||||||
|
|
||||||
return {
|
|
||||||
start,
|
|
||||||
end
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function commandArgs(ctx: Context): string[] {
|
function commandArgs(ctx: Context): string[] {
|
||||||
const raw = typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
const raw = typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
||||||
if (raw.length === 0) {
|
if (raw.length === 0) {
|
||||||
@@ -57,52 +10,17 @@ function commandArgs(ctx: Context): string[] {
|
|||||||
return raw.split(/\s+/).filter(Boolean)
|
return raw.split(/\s+/).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeInputHash(payload: object): string {
|
export function createFinanceCommandsService(financeService: FinanceCommandService): {
|
||||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFinanceCommandsService(
|
|
||||||
databaseUrl: string,
|
|
||||||
config: FinanceCommandsConfig
|
|
||||||
): {
|
|
||||||
register: (bot: Bot) => void
|
register: (bot: Bot) => void
|
||||||
close: () => Promise<void>
|
|
||||||
} {
|
} {
|
||||||
const { db, queryClient } = createDbClient(databaseUrl, {
|
async function requireMember(ctx: Context) {
|
||||||
max: 5,
|
|
||||||
prepare: false
|
|
||||||
})
|
|
||||||
|
|
||||||
async function getMemberByTelegramUserId(
|
|
||||||
telegramUserId: string
|
|
||||||
): Promise<HouseholdMemberData | null> {
|
|
||||||
const row = await db
|
|
||||||
.select({
|
|
||||||
id: schema.members.id,
|
|
||||||
telegramUserId: schema.members.telegramUserId,
|
|
||||||
displayName: schema.members.displayName,
|
|
||||||
isAdmin: schema.members.isAdmin
|
|
||||||
})
|
|
||||||
.from(schema.members)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.members.householdId, config.householdId),
|
|
||||||
eq(schema.members.telegramUserId, telegramUserId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return row[0] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requireMember(ctx: Context): Promise<HouseholdMemberData | null> {
|
|
||||||
const telegramUserId = ctx.from?.id?.toString()
|
const telegramUserId = ctx.from?.id?.toString()
|
||||||
if (!telegramUserId) {
|
if (!telegramUserId) {
|
||||||
await ctx.reply('Unable to identify sender for this command.')
|
await ctx.reply('Unable to identify sender for this command.')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await getMemberByTelegramUserId(telegramUserId)
|
const member = await financeService.getMemberByTelegramUserId(telegramUserId)
|
||||||
if (!member) {
|
if (!member) {
|
||||||
await ctx.reply('You are not a member of this household.')
|
await ctx.reply('You are not a member of this household.')
|
||||||
return null
|
return null
|
||||||
@@ -111,13 +29,13 @@ export function createFinanceCommandsService(
|
|||||||
return member
|
return member
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireAdmin(ctx: Context): Promise<HouseholdMemberData | null> {
|
async function requireAdmin(ctx: Context) {
|
||||||
const member = await requireMember(ctx)
|
const member = await requireMember(ctx)
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (member.isAdmin !== 1) {
|
if (!member.isAdmin) {
|
||||||
await ctx.reply('Only household admins can use this command.')
|
await ctx.reply('Only household admins can use this command.')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -125,217 +43,6 @@ export function createFinanceCommandsService(
|
|||||||
return member
|
return member
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOpenCycle(): Promise<SettlementCycleData | null> {
|
|
||||||
const cycle = await db
|
|
||||||
.select({
|
|
||||||
id: schema.billingCycles.id,
|
|
||||||
period: schema.billingCycles.period,
|
|
||||||
currency: schema.billingCycles.currency
|
|
||||||
})
|
|
||||||
.from(schema.billingCycles)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.billingCycles.householdId, config.householdId),
|
|
||||||
isNull(schema.billingCycles.closedAt)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(schema.billingCycles.startedAt))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return cycle[0] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCycleByPeriodOrLatest(periodArg?: string): Promise<SettlementCycleData | null> {
|
|
||||||
if (periodArg) {
|
|
||||||
const period = BillingPeriod.fromString(periodArg).toString()
|
|
||||||
const cycle = await db
|
|
||||||
.select({
|
|
||||||
id: schema.billingCycles.id,
|
|
||||||
period: schema.billingCycles.period,
|
|
||||||
currency: schema.billingCycles.currency
|
|
||||||
})
|
|
||||||
.from(schema.billingCycles)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.billingCycles.householdId, config.householdId),
|
|
||||||
eq(schema.billingCycles.period, period)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return cycle[0] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestCycle = await db
|
|
||||||
.select({
|
|
||||||
id: schema.billingCycles.id,
|
|
||||||
period: schema.billingCycles.period,
|
|
||||||
currency: schema.billingCycles.currency
|
|
||||||
})
|
|
||||||
.from(schema.billingCycles)
|
|
||||||
.where(eq(schema.billingCycles.householdId, config.householdId))
|
|
||||||
.orderBy(desc(schema.billingCycles.period))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return latestCycle[0] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertSettlementSnapshot(cycle: SettlementCycleData): Promise<string> {
|
|
||||||
const members = await db
|
|
||||||
.select({
|
|
||||||
id: schema.members.id,
|
|
||||||
displayName: schema.members.displayName
|
|
||||||
})
|
|
||||||
.from(schema.members)
|
|
||||||
.where(eq(schema.members.householdId, config.householdId))
|
|
||||||
.orderBy(schema.members.displayName)
|
|
||||||
|
|
||||||
if (members.length === 0) {
|
|
||||||
throw new Error('No household members configured')
|
|
||||||
}
|
|
||||||
|
|
||||||
const rentRule = await db
|
|
||||||
.select({
|
|
||||||
amountMinor: schema.rentRules.amountMinor,
|
|
||||||
currency: schema.rentRules.currency
|
|
||||||
})
|
|
||||||
.from(schema.rentRules)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.rentRules.householdId, config.householdId),
|
|
||||||
lte(schema.rentRules.effectiveFromPeriod, cycle.period),
|
|
||||||
or(
|
|
||||||
isNull(schema.rentRules.effectiveToPeriod),
|
|
||||||
gte(schema.rentRules.effectiveToPeriod, cycle.period)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(schema.rentRules.effectiveFromPeriod))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!rentRule[0]) {
|
|
||||||
throw new Error('No rent rule configured for this cycle period')
|
|
||||||
}
|
|
||||||
|
|
||||||
const utilityTotalRow = await db
|
|
||||||
.select({
|
|
||||||
totalMinor: sql<string>`coalesce(sum(${schema.utilityBills.amountMinor}), 0)`
|
|
||||||
})
|
|
||||||
.from(schema.utilityBills)
|
|
||||||
.where(eq(schema.utilityBills.cycleId, cycle.id))
|
|
||||||
|
|
||||||
const period = BillingPeriod.fromString(cycle.period)
|
|
||||||
const range = monthRange(period)
|
|
||||||
|
|
||||||
const purchases = await db
|
|
||||||
.select({
|
|
||||||
id: schema.purchaseMessages.id,
|
|
||||||
senderMemberId: schema.purchaseMessages.senderMemberId,
|
|
||||||
parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor
|
|
||||||
})
|
|
||||||
.from(schema.purchaseMessages)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.purchaseMessages.householdId, config.householdId),
|
|
||||||
isNotNull(schema.purchaseMessages.senderMemberId),
|
|
||||||
isNotNull(schema.purchaseMessages.parsedAmountMinor),
|
|
||||||
gte(schema.purchaseMessages.messageSentAt, range.start),
|
|
||||||
lte(schema.purchaseMessages.messageSentAt, range.end)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const currency = parseCurrency(rentRule[0].currency, 'USD')
|
|
||||||
const utilitiesMinor = BigInt(utilityTotalRow[0]?.totalMinor ?? '0')
|
|
||||||
|
|
||||||
const settlementInput = {
|
|
||||||
cycleId: BillingCycleId.from(cycle.id),
|
|
||||||
period,
|
|
||||||
rent: Money.fromMinor(rentRule[0].amountMinor, currency),
|
|
||||||
utilities: Money.fromMinor(utilitiesMinor, currency),
|
|
||||||
utilitySplitMode: 'equal' as const,
|
|
||||||
members: members.map((member) => ({
|
|
||||||
memberId: MemberId.from(member.id),
|
|
||||||
active: true
|
|
||||||
})),
|
|
||||||
purchases: purchases.map((purchase) => ({
|
|
||||||
purchaseId: PurchaseEntryId.from(purchase.id),
|
|
||||||
payerId: MemberId.from(purchase.senderMemberId!),
|
|
||||||
amount: Money.fromMinor(purchase.parsedAmountMinor!, currency)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const settlement = calculateMonthlySettlement(settlementInput)
|
|
||||||
const inputHash = computeInputHash({
|
|
||||||
cycleId: cycle.id,
|
|
||||||
rentMinor: rentRule[0].amountMinor.toString(),
|
|
||||||
utilitiesMinor: utilitiesMinor.toString(),
|
|
||||||
purchaseCount: purchases.length,
|
|
||||||
memberCount: members.length
|
|
||||||
})
|
|
||||||
|
|
||||||
const upserted = await db
|
|
||||||
.insert(schema.settlements)
|
|
||||||
.values({
|
|
||||||
householdId: config.householdId,
|
|
||||||
cycleId: cycle.id,
|
|
||||||
inputHash,
|
|
||||||
totalDueMinor: settlement.totalDue.amountMinor,
|
|
||||||
currency,
|
|
||||||
metadata: {
|
|
||||||
generatedBy: 'bot-command',
|
|
||||||
source: 'statement'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [schema.settlements.cycleId],
|
|
||||||
set: {
|
|
||||||
inputHash,
|
|
||||||
totalDueMinor: settlement.totalDue.amountMinor,
|
|
||||||
currency,
|
|
||||||
computedAt: new Date(),
|
|
||||||
metadata: {
|
|
||||||
generatedBy: 'bot-command',
|
|
||||||
source: 'statement'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.returning({ id: schema.settlements.id })
|
|
||||||
|
|
||||||
const settlementId = upserted[0]?.id
|
|
||||||
if (!settlementId) {
|
|
||||||
throw new Error('Failed to persist settlement snapshot')
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(schema.settlementLines)
|
|
||||||
.where(eq(schema.settlementLines.settlementId, settlementId))
|
|
||||||
|
|
||||||
const memberNameById = new Map(members.map((member) => [member.id, member.displayName]))
|
|
||||||
|
|
||||||
await db.insert(schema.settlementLines).values(
|
|
||||||
settlement.lines.map((line) => ({
|
|
||||||
settlementId,
|
|
||||||
memberId: line.memberId.toString(),
|
|
||||||
rentShareMinor: line.rentShare.amountMinor,
|
|
||||||
utilityShareMinor: line.utilityShare.amountMinor,
|
|
||||||
purchaseOffsetMinor: line.purchaseOffset.amountMinor,
|
|
||||||
netDueMinor: line.netDue.amountMinor,
|
|
||||||
explanations: line.explanations
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
const statementLines = settlement.lines.map((line) => {
|
|
||||||
const name = memberNameById.get(line.memberId.toString()) ?? line.memberId.toString()
|
|
||||||
return `- ${name}: ${line.netDue.toMajorString()} ${currency}`
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
|
||||||
`Statement for ${cycle.period}`,
|
|
||||||
...statementLines,
|
|
||||||
`Total: ${settlement.totalDue.toMajorString()} ${currency}`
|
|
||||||
].join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
function register(bot: Bot): void {
|
function register(bot: Bot): void {
|
||||||
bot.command('cycle_open', async (ctx) => {
|
bot.command('cycle_open', async (ctx) => {
|
||||||
const admin = await requireAdmin(ctx)
|
const admin = await requireAdmin(ctx)
|
||||||
@@ -350,21 +57,8 @@ export function createFinanceCommandsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const period = BillingPeriod.fromString(args[0]!).toString()
|
const cycle = await financeService.openCycle(args[0]!, args[1])
|
||||||
const currency = parseCurrency(args[1], 'USD')
|
await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`)
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(schema.billingCycles)
|
|
||||||
.values({
|
|
||||||
householdId: config.householdId,
|
|
||||||
period,
|
|
||||||
currency
|
|
||||||
})
|
|
||||||
.onConflictDoNothing({
|
|
||||||
target: [schema.billingCycles.householdId, schema.billingCycles.period]
|
|
||||||
})
|
|
||||||
|
|
||||||
await ctx.reply(`Cycle opened: ${period} (${currency})`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to open cycle: ${(error as Error).message}`)
|
await ctx.reply(`Failed to open cycle: ${(error as Error).message}`)
|
||||||
}
|
}
|
||||||
@@ -376,21 +70,13 @@ export function createFinanceCommandsService(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = commandArgs(ctx)
|
|
||||||
try {
|
try {
|
||||||
const cycle = await getCycleByPeriodOrLatest(args[0])
|
const cycle = await financeService.closeCycle(commandArgs(ctx)[0])
|
||||||
if (!cycle) {
|
if (!cycle) {
|
||||||
await ctx.reply('No cycle found to close.')
|
await ctx.reply('No cycle found to close.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
|
||||||
.update(schema.billingCycles)
|
|
||||||
.set({
|
|
||||||
closedAt: new Date()
|
|
||||||
})
|
|
||||||
.where(eq(schema.billingCycles.id, cycle.id))
|
|
||||||
|
|
||||||
await ctx.reply(`Cycle closed: ${cycle.period}`)
|
await ctx.reply(`Cycle closed: ${cycle.period}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to close cycle: ${(error as Error).message}`)
|
await ctx.reply(`Failed to close cycle: ${(error as Error).message}`)
|
||||||
@@ -410,34 +96,14 @@ export function createFinanceCommandsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const openCycle = await getOpenCycle()
|
const result = await financeService.setRent(args[0]!, args[1], args[2])
|
||||||
const period = args[2] ?? openCycle?.period
|
if (!result) {
|
||||||
if (!period) {
|
|
||||||
await ctx.reply('No period provided and no open cycle found.')
|
await ctx.reply('No period provided and no open cycle found.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const currency = parseCurrency(args[1], (openCycle?.currency as SupportedCurrency) ?? 'USD')
|
|
||||||
const amount = Money.fromMajor(args[0]!, currency)
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(schema.rentRules)
|
|
||||||
.values({
|
|
||||||
householdId: config.householdId,
|
|
||||||
amountMinor: amount.amountMinor,
|
|
||||||
currency,
|
|
||||||
effectiveFromPeriod: BillingPeriod.fromString(period).toString()
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [schema.rentRules.householdId, schema.rentRules.effectiveFromPeriod],
|
|
||||||
set: {
|
|
||||||
amountMinor: amount.amountMinor,
|
|
||||||
currency
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`Rent rule saved: ${amount.toMajorString()} ${currency} starting ${BillingPeriod.fromString(period).toString()}`
|
`Rent rule saved: ${result.amount.toMajorString()} ${result.currency} starting ${result.period}`
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to save rent rule: ${(error as Error).message}`)
|
await ctx.reply(`Failed to save rent rule: ${(error as Error).message}`)
|
||||||
@@ -457,29 +123,14 @@ export function createFinanceCommandsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const openCycle = await getOpenCycle()
|
const result = await financeService.addUtilityBill(args[0]!, args[1]!, admin.id, args[2])
|
||||||
if (!openCycle) {
|
if (!result) {
|
||||||
await ctx.reply('No open cycle found. Use /cycle_open first.')
|
await ctx.reply('No open cycle found. Use /cycle_open first.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = args[0]!
|
|
||||||
const amountRaw = args[1]!
|
|
||||||
const currency = parseCurrency(args[2], parseCurrency(openCycle.currency, 'USD'))
|
|
||||||
const amount = Money.fromMajor(amountRaw, currency)
|
|
||||||
|
|
||||||
await db.insert(schema.utilityBills).values({
|
|
||||||
householdId: config.householdId,
|
|
||||||
cycleId: openCycle.id,
|
|
||||||
billName: name,
|
|
||||||
amountMinor: amount.amountMinor,
|
|
||||||
currency,
|
|
||||||
source: 'manual',
|
|
||||||
createdByMemberId: admin.id
|
|
||||||
})
|
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`Utility bill added: ${name} ${amount.toMajorString()} ${currency} for ${openCycle.period}`
|
`Utility bill added: ${args[0]} ${result.amount.toMajorString()} ${result.currency} for ${result.period}`
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to add utility bill: ${(error as Error).message}`)
|
await ctx.reply(`Failed to add utility bill: ${(error as Error).message}`)
|
||||||
@@ -492,16 +143,14 @@ export function createFinanceCommandsService(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = commandArgs(ctx)
|
|
||||||
try {
|
try {
|
||||||
const cycle = await getCycleByPeriodOrLatest(args[0])
|
const statement = await financeService.generateStatement(commandArgs(ctx)[0])
|
||||||
if (!cycle) {
|
if (!statement) {
|
||||||
await ctx.reply('No cycle found for statement.')
|
await ctx.reply('No cycle found for statement.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = await upsertSettlementSnapshot(cycle)
|
await ctx.reply(statement)
|
||||||
await ctx.reply(message)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to generate statement: ${(error as Error).message}`)
|
await ctx.reply(`Failed to generate statement: ${(error as Error).message}`)
|
||||||
}
|
}
|
||||||
@@ -509,9 +158,6 @@ export function createFinanceCommandsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
register,
|
register
|
||||||
close: async () => {
|
|
||||||
await queryClient.end({ timeout: 5 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,57 @@
|
|||||||
import { webhookCallback } from 'grammy'
|
import { webhookCallback } from 'grammy'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createAnonymousFeedbackService,
|
||||||
|
createFinanceCommandService,
|
||||||
|
createReminderJobService
|
||||||
|
} from '@household/application'
|
||||||
|
import {
|
||||||
|
createDbAnonymousFeedbackRepository,
|
||||||
|
createDbFinanceRepository,
|
||||||
|
createDbReminderDispatchRepository
|
||||||
|
} from '@household/adapters-db'
|
||||||
|
|
||||||
|
import { registerAnonymousFeedback } from './anonymous-feedback'
|
||||||
|
import { createFinanceCommandsService } from './finance-commands'
|
||||||
import { createTelegramBot } from './bot'
|
import { createTelegramBot } from './bot'
|
||||||
import { getBotRuntimeConfig } from './config'
|
import { getBotRuntimeConfig } from './config'
|
||||||
import { createFinanceCommandsService } from './finance-commands'
|
|
||||||
import { createOpenAiParserFallback } from './openai-parser-fallback'
|
import { createOpenAiParserFallback } from './openai-parser-fallback'
|
||||||
import {
|
import {
|
||||||
createPurchaseMessageRepository,
|
createPurchaseMessageRepository,
|
||||||
registerPurchaseTopicIngestion
|
registerPurchaseTopicIngestion
|
||||||
} from './purchase-topic-ingestion'
|
} from './purchase-topic-ingestion'
|
||||||
|
import { createReminderJobsHandler } from './reminder-jobs'
|
||||||
|
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
||||||
import { createBotWebhookServer } from './server'
|
import { createBotWebhookServer } from './server'
|
||||||
|
import { createMiniAppAuthHandler } from './miniapp-auth'
|
||||||
|
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||||
|
|
||||||
const runtime = getBotRuntimeConfig()
|
const runtime = getBotRuntimeConfig()
|
||||||
const bot = createTelegramBot(runtime.telegramBotToken)
|
const bot = createTelegramBot(runtime.telegramBotToken)
|
||||||
const webhookHandler = webhookCallback(bot, 'std/http')
|
const webhookHandler = webhookCallback(bot, 'std/http')
|
||||||
|
|
||||||
const shutdownTasks: Array<() => Promise<void>> = []
|
const shutdownTasks: Array<() => Promise<void>> = []
|
||||||
|
const financeRepositoryClient =
|
||||||
|
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled
|
||||||
|
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
|
||||||
|
: null
|
||||||
|
const financeService = financeRepositoryClient
|
||||||
|
? createFinanceCommandService(financeRepositoryClient.repository)
|
||||||
|
: null
|
||||||
|
const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled
|
||||||
|
? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!)
|
||||||
|
: null
|
||||||
|
const anonymousFeedbackService = anonymousFeedbackRepositoryClient
|
||||||
|
? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (financeRepositoryClient) {
|
||||||
|
shutdownTasks.push(financeRepositoryClient.close)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anonymousFeedbackRepositoryClient) {
|
||||||
|
shutdownTasks.push(anonymousFeedbackRepositoryClient.close)
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime.purchaseTopicIngestionEnabled) {
|
if (runtime.purchaseTopicIngestionEnabled) {
|
||||||
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
||||||
@@ -42,20 +79,81 @@ if (runtime.purchaseTopicIngestionEnabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (runtime.financeCommandsEnabled) {
|
if (runtime.financeCommandsEnabled) {
|
||||||
const financeCommands = createFinanceCommandsService(runtime.databaseUrl!, {
|
const financeCommands = createFinanceCommandsService(financeService!)
|
||||||
householdId: runtime.householdId!
|
|
||||||
})
|
|
||||||
|
|
||||||
financeCommands.register(bot)
|
financeCommands.register(bot)
|
||||||
shutdownTasks.push(financeCommands.close)
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.')
|
console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reminderJobs = runtime.reminderJobsEnabled
|
||||||
|
? (() => {
|
||||||
|
const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!)
|
||||||
|
const reminderService = createReminderJobService(reminderRepositoryClient.repository)
|
||||||
|
|
||||||
|
shutdownTasks.push(reminderRepositoryClient.close)
|
||||||
|
|
||||||
|
return createReminderJobsHandler({
|
||||||
|
householdId: runtime.householdId!,
|
||||||
|
reminderService
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!runtime.reminderJobsEnabled) {
|
||||||
|
console.warn(
|
||||||
|
'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anonymousFeedbackService) {
|
||||||
|
registerAnonymousFeedback({
|
||||||
|
bot,
|
||||||
|
anonymousFeedbackService,
|
||||||
|
householdChatId: runtime.telegramHouseholdChatId!,
|
||||||
|
feedbackTopicId: runtime.telegramFeedbackTopicId!
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'Anonymous feedback is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_FEEDBACK_TOPIC_ID to enable.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const server = createBotWebhookServer({
|
const server = createBotWebhookServer({
|
||||||
webhookPath: runtime.telegramWebhookPath,
|
webhookPath: runtime.telegramWebhookPath,
|
||||||
webhookSecret: runtime.telegramWebhookSecret,
|
webhookSecret: runtime.telegramWebhookSecret,
|
||||||
webhookHandler
|
webhookHandler,
|
||||||
|
miniAppAuth: financeRepositoryClient
|
||||||
|
? createMiniAppAuthHandler({
|
||||||
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
botToken: runtime.telegramBotToken,
|
||||||
|
repository: financeRepositoryClient.repository
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
miniAppDashboard: financeService
|
||||||
|
? createMiniAppDashboardHandler({
|
||||||
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
botToken: runtime.telegramBotToken,
|
||||||
|
financeService
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
scheduler:
|
||||||
|
reminderJobs && runtime.schedulerSharedSecret
|
||||||
|
? {
|
||||||
|
authorize: createSchedulerRequestAuthorizer({
|
||||||
|
sharedSecret: runtime.schedulerSharedSecret,
|
||||||
|
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
|
||||||
|
}).authorize,
|
||||||
|
handler: reminderJobs.handle
|
||||||
|
}
|
||||||
|
: reminderJobs
|
||||||
|
? {
|
||||||
|
authorize: createSchedulerRequestAuthorizer({
|
||||||
|
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
|
||||||
|
}).authorize,
|
||||||
|
handler: reminderJobs.handle
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
|
|||||||
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({
|
const server = createBotWebhookServer({
|
||||||
webhookPath: '/webhook/telegram',
|
webhookPath: '/webhook/telegram',
|
||||||
webhookSecret: 'secret-token',
|
webhookSecret: 'secret-token',
|
||||||
webhookHandler: async () => new Response('ok', { status: 200 })
|
webhookHandler: async () => new Response('ok', { status: 200 }),
|
||||||
|
miniAppAuth: {
|
||||||
|
handler: async () =>
|
||||||
|
new Response(JSON.stringify({ ok: true, authorized: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
miniAppDashboard: {
|
||||||
|
handler: async () =>
|
||||||
|
new Response(JSON.stringify({ ok: true, authorized: true, dashboard: {} }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
scheduler: {
|
||||||
|
authorize: async (request) =>
|
||||||
|
request.headers.get('x-household-scheduler-secret') === 'scheduler-secret',
|
||||||
|
handler: async (_request, reminderType) =>
|
||||||
|
new Response(JSON.stringify({ ok: true, reminderType }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns health payload', async () => {
|
test('returns health payload', async () => {
|
||||||
@@ -59,4 +88,77 @@ describe('createBotWebhookServer', () => {
|
|||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(await response.text()).toBe('ok')
|
expect(await response.text()).toBe('ok')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('accepts mini app auth request', async () => {
|
||||||
|
const response = await server.fetch(
|
||||||
|
new Request('http://localhost/api/miniapp/session', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ initData: 'payload' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ok: true,
|
||||||
|
authorized: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts mini app dashboard request', async () => {
|
||||||
|
const response = await server.fetch(
|
||||||
|
new Request('http://localhost/api/miniapp/dashboard', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ initData: 'payload' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
dashboard: {}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects scheduler request with missing secret', async () => {
|
||||||
|
const response = await server.fetch(
|
||||||
|
new Request('http://localhost/jobs/reminder/utilities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ period: '2026-03' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects non-post method for scheduler endpoint', async () => {
|
||||||
|
const response = await server.fetch(
|
||||||
|
new Request('http://localhost/jobs/reminder/utilities', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'x-household-scheduler-secret': 'scheduler-secret'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(405)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts authorized scheduler request', async () => {
|
||||||
|
const response = await server.fetch(
|
||||||
|
new Request('http://localhost/jobs/reminder/rent-due', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-household-scheduler-secret': 'scheduler-secret'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ period: '2026-03' })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ok: true,
|
||||||
|
reminderType: 'rent-due'
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,25 @@ export interface BotWebhookServerOptions {
|
|||||||
webhookPath: string
|
webhookPath: string
|
||||||
webhookSecret: string
|
webhookSecret: string
|
||||||
webhookHandler: (request: Request) => Promise<Response> | Response
|
webhookHandler: (request: Request) => Promise<Response> | Response
|
||||||
|
miniAppAuth?:
|
||||||
|
| {
|
||||||
|
path?: string
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
miniAppDashboard?:
|
||||||
|
| {
|
||||||
|
path?: string
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
scheduler?:
|
||||||
|
| {
|
||||||
|
pathPrefix?: string
|
||||||
|
authorize: (request: Request) => Promise<boolean>
|
||||||
|
handler: (request: Request, reminderType: string) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function json(body: object, status = 200): Response {
|
function json(body: object, status = 200): Response {
|
||||||
@@ -25,6 +44,11 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
const normalizedWebhookPath = options.webhookPath.startsWith('/')
|
const normalizedWebhookPath = options.webhookPath.startsWith('/')
|
||||||
? options.webhookPath
|
? options.webhookPath
|
||||||
: `/${options.webhookPath}`
|
: `/${options.webhookPath}`
|
||||||
|
const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session'
|
||||||
|
const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard'
|
||||||
|
const schedulerPathPrefix = options.scheduler
|
||||||
|
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
||||||
|
: null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetch: async (request: Request) => {
|
fetch: async (request: Request) => {
|
||||||
@@ -34,7 +58,28 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
return json({ ok: true })
|
return json({ ok: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.miniAppAuth && url.pathname === miniAppAuthPath) {
|
||||||
|
return await options.miniAppAuth.handler(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.miniAppDashboard && url.pathname === miniAppDashboardPath) {
|
||||||
|
return await options.miniAppDashboard.handler(request)
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname !== normalizedWebhookPath) {
|
if (url.pathname !== normalizedWebhookPath) {
|
||||||
|
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
|
||||||
|
if (request.method !== 'POST') {
|
||||||
|
return new Response('Method Not Allowed', { status: 405 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await options.scheduler!.authorize(request))) {
|
||||||
|
return new Response('Unauthorized', { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const reminderType = url.pathname.slice(`${schedulerPathPrefix}/`.length)
|
||||||
|
return await options.scheduler!.handler(request, reminderType)
|
||||||
|
}
|
||||||
|
|
||||||
return new Response('Not Found', { status: 404 })
|
return new Response('Not Found', { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 bun.lock package.json tsconfig.base.json ./
|
||||||
COPY apps/bot/package.json apps/bot/package.json
|
COPY apps/bot/package.json apps/bot/package.json
|
||||||
COPY apps/miniapp/package.json apps/miniapp/package.json
|
COPY apps/miniapp/package.json apps/miniapp/package.json
|
||||||
|
COPY packages/adapters-db/package.json packages/adapters-db/package.json
|
||||||
COPY packages/application/package.json packages/application/package.json
|
COPY packages/application/package.json packages/application/package.json
|
||||||
COPY packages/config/package.json packages/config/package.json
|
COPY packages/config/package.json packages/config/package.json
|
||||||
COPY packages/contracts/package.json packages/contracts/package.json
|
COPY packages/contracts/package.json packages/contracts/package.json
|
||||||
@@ -26,10 +27,15 @@ RUN bun run --filter @household/miniapp build
|
|||||||
|
|
||||||
FROM nginx:1.27-alpine AS runtime
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
|
||||||
|
ENV BOT_API_URL=""
|
||||||
|
|
||||||
COPY apps/miniapp/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY apps/miniapp/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY apps/miniapp/config.template.js /usr/share/nginx/html/config.template.js
|
||||||
COPY --from=build /app/apps/miniapp/dist /usr/share/nginx/html
|
COPY --from=build /app/apps/miniapp/dist /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1
|
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1
|
||||||
|
|
||||||
|
CMD ["/bin/sh", "-c", "envsubst < /usr/share/nginx/html/config.template.js > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
|
||||||
|
|||||||
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>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#121a24" />
|
||||||
<title>Solid App</title>
|
<title>Kojori House</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="/config.js"></script>
|
||||||
<script src="/src/index.tsx" type="module"></script>
|
<script src="/src/index.tsx" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,10 +1,380 @@
|
|||||||
|
import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js'
|
||||||
|
|
||||||
|
import { dictionary, type Locale } from './i18n'
|
||||||
|
import { fetchMiniAppDashboard, fetchMiniAppSession, type MiniAppDashboard } from './miniapp-api'
|
||||||
|
import { getTelegramWebApp } from './telegram-webapp'
|
||||||
|
|
||||||
|
type SessionState =
|
||||||
|
| {
|
||||||
|
status: 'loading'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'blocked'
|
||||||
|
reason: 'not_member' | 'telegram_only' | 'error'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'ready'
|
||||||
|
mode: 'live' | 'demo'
|
||||||
|
member: {
|
||||||
|
displayName: string
|
||||||
|
isAdmin: boolean
|
||||||
|
}
|
||||||
|
telegramUser: {
|
||||||
|
firstName: string | null
|
||||||
|
username: string | null
|
||||||
|
languageCode: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
|
||||||
|
|
||||||
|
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
||||||
|
status: 'ready',
|
||||||
|
mode: 'demo',
|
||||||
|
member: {
|
||||||
|
displayName: 'Demo Resident',
|
||||||
|
isAdmin: false
|
||||||
|
},
|
||||||
|
telegramUser: {
|
||||||
|
firstName: 'Demo',
|
||||||
|
username: 'demo_user',
|
||||||
|
languageCode: 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLocale(): Locale {
|
||||||
|
const telegramLocale = getTelegramWebApp()?.initDataUnsafe?.user?.language_code
|
||||||
|
const browserLocale = navigator.language.toLowerCase()
|
||||||
|
|
||||||
|
return (telegramLocale ?? browserLocale).startsWith('ru') ? 'ru' : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [locale, setLocale] = createSignal<Locale>('en')
|
||||||
|
const [session, setSession] = createSignal<SessionState>({
|
||||||
|
status: 'loading'
|
||||||
|
})
|
||||||
|
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
|
||||||
|
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
|
||||||
|
|
||||||
|
const copy = createMemo(() => dictionary[locale()])
|
||||||
|
const blockedSession = createMemo(() => {
|
||||||
|
const current = session()
|
||||||
|
return current.status === 'blocked' ? current : null
|
||||||
|
})
|
||||||
|
const readySession = createMemo(() => {
|
||||||
|
const current = session()
|
||||||
|
return current.status === 'ready' ? current : null
|
||||||
|
})
|
||||||
|
const webApp = getTelegramWebApp()
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
setLocale(detectLocale())
|
||||||
|
|
||||||
|
webApp?.ready?.()
|
||||||
|
webApp?.expand?.()
|
||||||
|
|
||||||
|
const initData = webApp?.initData?.trim()
|
||||||
|
if (!initData) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
setSession(demoSession)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession({
|
||||||
|
status: 'blocked',
|
||||||
|
reason: 'telegram_only'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await fetchMiniAppSession(initData)
|
||||||
|
if (!payload.authorized || !payload.member || !payload.telegramUser) {
|
||||||
|
setSession({
|
||||||
|
status: 'blocked',
|
||||||
|
reason: payload.reason === 'not_member' ? 'not_member' : 'error'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession({
|
||||||
|
status: 'ready',
|
||||||
|
mode: 'live',
|
||||||
|
member: payload.member,
|
||||||
|
telegramUser: payload.telegramUser
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDashboard(await fetchMiniAppDashboard(initData))
|
||||||
|
} catch (error) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('Failed to load mini app dashboard', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDashboard(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
setSession(demoSession)
|
||||||
|
setDashboard({
|
||||||
|
period: '2026-03',
|
||||||
|
currency: 'USD',
|
||||||
|
totalDueMajor: '820.00',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
memberId: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
rentShareMajor: '350.00',
|
||||||
|
utilityShareMajor: '60.00',
|
||||||
|
purchaseOffsetMajor: '-15.00',
|
||||||
|
netDueMajor: '395.00',
|
||||||
|
explanations: ['Equal utility split', 'Shared purchase offset']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
memberId: 'bob',
|
||||||
|
displayName: 'Bob',
|
||||||
|
rentShareMajor: '350.00',
|
||||||
|
utilityShareMajor: '60.00',
|
||||||
|
purchaseOffsetMajor: '15.00',
|
||||||
|
netDueMajor: '425.00',
|
||||||
|
explanations: ['Equal utility split']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ledger: [
|
||||||
|
{
|
||||||
|
id: 'purchase-1',
|
||||||
|
kind: 'purchase',
|
||||||
|
title: 'Soap',
|
||||||
|
amountMajor: '30.00',
|
||||||
|
actorDisplayName: 'Alice',
|
||||||
|
occurredAt: '2026-03-12T11:00:00.000Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'utility-1',
|
||||||
|
kind: 'utility',
|
||||||
|
title: 'Electricity',
|
||||||
|
amountMajor: '120.00',
|
||||||
|
actorDisplayName: 'Alice',
|
||||||
|
occurredAt: '2026-03-12T12:00:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession({
|
||||||
|
status: 'blocked',
|
||||||
|
reason: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderPanel = () => {
|
||||||
|
switch (activeNav()) {
|
||||||
|
case 'balances':
|
||||||
return (
|
return (
|
||||||
<main>
|
<div class="balance-list">
|
||||||
<h1>Household Mini App</h1>
|
<ShowDashboard
|
||||||
<p>SolidJS scaffold is ready</p>
|
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 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>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ShowDashboard(props: {
|
||||||
|
dashboard: MiniAppDashboard | null
|
||||||
|
fallback: JSX.Element
|
||||||
|
render: (dashboard: MiniAppDashboard) => JSX.Element
|
||||||
|
}) {
|
||||||
|
return <>{props.dashboard ? props.render(props.dashboard) : props.fallback}</>
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
92
apps/miniapp/src/i18n.ts
Normal file
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';
|
@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": {
|
"apps/bot": {
|
||||||
"name": "@household/bot",
|
"name": "@household/bot",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@household/adapters-db": "workspace:*",
|
||||||
"@household/application": "workspace:*",
|
"@household/application": "workspace:*",
|
||||||
"@household/db": "workspace:*",
|
"@household/db": "workspace:*",
|
||||||
"@household/domain": "workspace:*",
|
"@household/domain": "workspace:*",
|
||||||
|
"@household/ports": "workspace:*",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"google-auth-library": "^10.4.1",
|
||||||
"grammy": "1.41.1",
|
"grammy": "1.41.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -37,10 +40,20 @@
|
|||||||
"vite-plugin-solid": "^2.11.8",
|
"vite-plugin-solid": "^2.11.8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/adapters-db": {
|
||||||
|
"name": "@household/adapters-db",
|
||||||
|
"dependencies": {
|
||||||
|
"@household/db": "workspace:*",
|
||||||
|
"@household/domain": "workspace:*",
|
||||||
|
"@household/ports": "workspace:*",
|
||||||
|
"drizzle-orm": "^0.44.7",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/application": {
|
"packages/application": {
|
||||||
"name": "@household/application",
|
"name": "@household/application",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@household/domain": "workspace:*",
|
"@household/domain": "workspace:*",
|
||||||
|
"@household/ports": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/config": {
|
"packages/config": {
|
||||||
@@ -68,6 +81,9 @@
|
|||||||
},
|
},
|
||||||
"packages/ports": {
|
"packages/ports": {
|
||||||
"name": "@household/ports",
|
"name": "@household/ports",
|
||||||
|
"dependencies": {
|
||||||
|
"@household/domain": "workspace:*",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"name": "@household/scripts",
|
"name": "@household/scripts",
|
||||||
@@ -177,6 +193,8 @@
|
|||||||
|
|
||||||
"@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="],
|
"@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="],
|
||||||
|
|
||||||
|
"@household/adapters-db": ["@household/adapters-db@workspace:packages/adapters-db"],
|
||||||
|
|
||||||
"@household/application": ["@household/application@workspace:packages/application"],
|
"@household/application": ["@household/application@workspace:packages/application"],
|
||||||
|
|
||||||
"@household/bot": ["@household/bot@workspace:apps/bot"],
|
"@household/bot": ["@household/bot@workspace:apps/bot"],
|
||||||
@@ -197,6 +215,8 @@
|
|||||||
|
|
||||||
"@household/scripts": ["@household/scripts@workspace:scripts"],
|
"@household/scripts": ["@household/scripts@workspace:scripts"],
|
||||||
|
|
||||||
|
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
@@ -247,6 +267,8 @@
|
|||||||
|
|
||||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.51.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw=="],
|
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.51.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw=="],
|
||||||
|
|
||||||
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||||
@@ -387,24 +409,48 @@
|
|||||||
|
|
||||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||||
|
|
||||||
|
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="],
|
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="],
|
||||||
|
|
||||||
"babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
|
"babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
||||||
|
|
||||||
|
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||||
|
|
||||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||||
|
|
||||||
|
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
@@ -413,8 +459,14 @@
|
|||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
||||||
|
|
||||||
|
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||||
|
|
||||||
|
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||||
|
|
||||||
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
|
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
|
||||||
|
|
||||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||||
@@ -427,30 +479,62 @@
|
|||||||
|
|
||||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||||
|
|
||||||
|
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||||
|
|
||||||
|
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||||
|
|
||||||
|
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
|
||||||
|
|
||||||
|
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||||
|
|
||||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||||
|
|
||||||
|
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||||
|
|
||||||
|
"google-auth-library": ["google-auth-library@10.6.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "7.1.3", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA=="],
|
||||||
|
|
||||||
|
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
"grammy": ["grammy@1.41.1", "", { "dependencies": { "@grammyjs/types": "3.25.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ=="],
|
"grammy": ["grammy@1.41.1", "", { "dependencies": { "@grammyjs/types": "3.25.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ=="],
|
||||||
|
|
||||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||||
|
|
||||||
|
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|
||||||
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
|
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
|
||||||
|
|
||||||
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
|
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||||
|
|
||||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||||
|
|
||||||
|
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||||
|
|
||||||
"lefthook": ["lefthook@2.1.2", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.2", "lefthook-darwin-x64": "2.1.2", "lefthook-freebsd-arm64": "2.1.2", "lefthook-freebsd-x64": "2.1.2", "lefthook-linux-arm64": "2.1.2", "lefthook-linux-x64": "2.1.2", "lefthook-openbsd-arm64": "2.1.2", "lefthook-openbsd-x64": "2.1.2", "lefthook-windows-arm64": "2.1.2", "lefthook-windows-x64": "2.1.2" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-HdAMl4g47kbWSkrUkCx3Kucq54omFS6piMJtXwXNtmCAfB40UaybTJuYtFW4hNzZ5SvaEimtxTp7P/MNIkEfsA=="],
|
"lefthook": ["lefthook@2.1.2", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.2", "lefthook-darwin-x64": "2.1.2", "lefthook-freebsd-arm64": "2.1.2", "lefthook-freebsd-x64": "2.1.2", "lefthook-linux-arm64": "2.1.2", "lefthook-linux-x64": "2.1.2", "lefthook-openbsd-arm64": "2.1.2", "lefthook-openbsd-x64": "2.1.2", "lefthook-windows-arm64": "2.1.2", "lefthook-windows-x64": "2.1.2" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-HdAMl4g47kbWSkrUkCx3Kucq54omFS6piMJtXwXNtmCAfB40UaybTJuYtFW4hNzZ5SvaEimtxTp7P/MNIkEfsA=="],
|
||||||
|
|
||||||
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AgHu93YuJtj1l9bcKlCbo4Tg8N8xFl9iD6BjXCGaGMu46LSjFiXbJFlkUdpgrL8fIbwoCjJi5FNp3POpqs4Wdw=="],
|
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AgHu93YuJtj1l9bcKlCbo4Tg8N8xFl9iD6BjXCGaGMu46LSjFiXbJFlkUdpgrL8fIbwoCjJi5FNp3POpqs4Wdw=="],
|
||||||
@@ -503,18 +587,30 @@
|
|||||||
|
|
||||||
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
|
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||||
|
|
||||||
|
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||||
|
|
||||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||||
|
|
||||||
"oxlint": ["oxlint@1.51.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.51.0", "@oxlint/binding-android-arm64": "1.51.0", "@oxlint/binding-darwin-arm64": "1.51.0", "@oxlint/binding-darwin-x64": "1.51.0", "@oxlint/binding-freebsd-x64": "1.51.0", "@oxlint/binding-linux-arm-gnueabihf": "1.51.0", "@oxlint/binding-linux-arm-musleabihf": "1.51.0", "@oxlint/binding-linux-arm64-gnu": "1.51.0", "@oxlint/binding-linux-arm64-musl": "1.51.0", "@oxlint/binding-linux-ppc64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-musl": "1.51.0", "@oxlint/binding-linux-s390x-gnu": "1.51.0", "@oxlint/binding-linux-x64-gnu": "1.51.0", "@oxlint/binding-linux-x64-musl": "1.51.0", "@oxlint/binding-openharmony-arm64": "1.51.0", "@oxlint/binding-win32-arm64-msvc": "1.51.0", "@oxlint/binding-win32-ia32-msvc": "1.51.0", "@oxlint/binding-win32-x64-msvc": "1.51.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ=="],
|
"oxlint": ["oxlint@1.51.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.51.0", "@oxlint/binding-android-arm64": "1.51.0", "@oxlint/binding-darwin-arm64": "1.51.0", "@oxlint/binding-darwin-x64": "1.51.0", "@oxlint/binding-freebsd-x64": "1.51.0", "@oxlint/binding-linux-arm-gnueabihf": "1.51.0", "@oxlint/binding-linux-arm-musleabihf": "1.51.0", "@oxlint/binding-linux-arm64-gnu": "1.51.0", "@oxlint/binding-linux-arm64-musl": "1.51.0", "@oxlint/binding-linux-ppc64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-gnu": "1.51.0", "@oxlint/binding-linux-riscv64-musl": "1.51.0", "@oxlint/binding-linux-s390x-gnu": "1.51.0", "@oxlint/binding-linux-x64-gnu": "1.51.0", "@oxlint/binding-linux-x64-musl": "1.51.0", "@oxlint/binding-openharmony-arm64": "1.51.0", "@oxlint/binding-win32-arm64-msvc": "1.51.0", "@oxlint/binding-win32-ia32-msvc": "1.51.0", "@oxlint/binding-win32-x64-msvc": "1.51.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ=="],
|
||||||
|
|
||||||
|
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||||
|
|
||||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||||
|
|
||||||
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
|
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
@@ -525,14 +621,24 @@
|
|||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
|
||||||
|
|
||||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
|
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
|
||||||
|
|
||||||
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
|
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
|
||||||
|
|
||||||
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
|
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
|
|
||||||
"solid-devtools": ["solid-devtools@0.34.5", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.6", "@solid-devtools/debugger": "^0.28.1", "@solid-devtools/shared": "^0.20.0" }, "peerDependencies": { "solid-js": "^1.9.0", "vite": "^2.2.3 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["vite"] }, "sha512-KNVdS9MQzzeVS++Vmg4JeU0fM6ZMuBEmkBA7SmqPS2s5UHpRjv1PNH8gShmlN9L/tki6OUAzJP3H1aKq2AcOSg=="],
|
"solid-devtools": ["solid-devtools@0.34.5", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.6", "@solid-devtools/debugger": "^0.28.1", "@solid-devtools/shared": "^0.20.0" }, "peerDependencies": { "solid-js": "^1.9.0", "vite": "^2.2.3 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["vite"] }, "sha512-KNVdS9MQzzeVS++Vmg4JeU0fM6ZMuBEmkBA7SmqPS2s5UHpRjv1PNH8gShmlN9L/tki6OUAzJP3H1aKq2AcOSg=="],
|
||||||
|
|
||||||
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
|
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
|
||||||
@@ -545,6 +651,14 @@
|
|||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||||
|
|
||||||
|
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||||
|
|
||||||
|
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
@@ -565,10 +679,18 @@
|
|||||||
|
|
||||||
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
|
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
|
||||||
|
|
||||||
|
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||||
|
|
||||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
@@ -589,8 +711,24 @@
|
|||||||
|
|
||||||
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
||||||
|
|
||||||
|
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||||
|
|
||||||
|
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
|
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
|
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
"vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||||
@@ -635,6 +773,8 @@
|
|||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||||
|
|
||||||
|
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||||
@@ -686,5 +826,9 @@
|
|||||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ bun run db:generate
|
|||||||
bun run db:check
|
bun run db:check
|
||||||
bun run db:migrate
|
bun run db:migrate
|
||||||
bun run db:seed
|
bun run db:seed
|
||||||
|
bun run ops:telegram:webhook info
|
||||||
|
bun run ops:deploy:smoke
|
||||||
bun run infra:fmt:check
|
bun run infra:fmt:check
|
||||||
bun run infra:validate
|
bun run infra:validate
|
||||||
```
|
```
|
||||||
@@ -60,6 +62,7 @@ bun run review:coderabbit
|
|||||||
- Typed environment validation lives in `packages/config/src/env.ts`.
|
- Typed environment validation lives in `packages/config/src/env.ts`.
|
||||||
- Copy `.env.example` to `.env` before running app/database commands.
|
- Copy `.env.example` to `.env` before running app/database commands.
|
||||||
- Migration workflow is documented in `docs/runbooks/migrations.md`.
|
- Migration workflow is documented in `docs/runbooks/migrations.md`.
|
||||||
|
- First deploy flow is documented in `docs/runbooks/first-deploy.md`.
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
|
|||||||
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_id`
|
||||||
- `bot_household_chat_id`
|
- `bot_household_chat_id`
|
||||||
- `bot_purchase_topic_id`
|
- `bot_purchase_topic_id`
|
||||||
|
- optional `bot_feedback_topic_id`
|
||||||
|
- `bot_mini_app_allowed_origins`
|
||||||
- optional `bot_parser_model`
|
- optional `bot_parser_model`
|
||||||
|
|
||||||
|
Set `bot_mini_app_allowed_origins` to the exact mini app origins you expect in each environment.
|
||||||
|
Do not rely on permissive origin reflection in production.
|
||||||
|
|
||||||
|
## Reminder jobs
|
||||||
|
|
||||||
|
Terraform provisions three separate Cloud Scheduler jobs:
|
||||||
|
|
||||||
|
- `utilities`
|
||||||
|
- `rent-warning`
|
||||||
|
- `rent-due`
|
||||||
|
|
||||||
|
They target the bot runtime endpoints:
|
||||||
|
|
||||||
|
- `/jobs/reminder/utilities`
|
||||||
|
- `/jobs/reminder/rent-warning`
|
||||||
|
- `/jobs/reminder/rent-due`
|
||||||
|
|
||||||
|
Recommended rollout:
|
||||||
|
|
||||||
|
- keep `scheduler_paused = true` and `scheduler_dry_run = true` on first apply
|
||||||
|
- confirm `bot_mini_app_allowed_origins` is set for the environment before exposing the mini app
|
||||||
|
- validate job responses and logs
|
||||||
|
- unpause when the delivery side is ready
|
||||||
|
- disable dry-run only after production verification
|
||||||
|
|
||||||
## Environment strategy
|
## Environment strategy
|
||||||
|
|
||||||
- Keep separate states for `dev` and `prod`.
|
- Keep separate states for `dev` and `prod`.
|
||||||
|
|||||||
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
|
- Artifact Registry Docker repository
|
||||||
- Cloud Run service: bot API (public webhook endpoint)
|
- Cloud Run service: bot API (public webhook endpoint)
|
||||||
- Cloud Run service: mini app (public web UI)
|
- Cloud Run service: mini app (public web UI)
|
||||||
- Cloud Scheduler job for reminder triggers
|
- Cloud Scheduler jobs for reminder triggers
|
||||||
- Runtime and scheduler service accounts with least-privilege bindings
|
- Runtime and scheduler service accounts with least-privilege bindings
|
||||||
- Secret Manager secrets (IDs only, secret values are added separately)
|
- Secret Manager secrets (IDs only, secret values are added separately)
|
||||||
- Optional GitHub OIDC Workload Identity setup for deploy automation
|
- Optional GitHub OIDC Workload Identity setup for deploy automation
|
||||||
@@ -16,7 +16,7 @@ This directory contains baseline IaC for deploying the household bot platform on
|
|||||||
|
|
||||||
- `bot-api`: Telegram webhook + app API endpoints
|
- `bot-api`: Telegram webhook + app API endpoints
|
||||||
- `mini-app`: front-end delivery
|
- `mini-app`: front-end delivery
|
||||||
- `scheduler`: triggers `bot-api` internal reminder endpoint using OIDC token
|
- `scheduler`: triggers `bot-api` reminder endpoints using OIDC tokens
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -72,7 +72,9 @@ Recommended approach:
|
|||||||
- `bot_household_id`
|
- `bot_household_id`
|
||||||
- `bot_household_chat_id`
|
- `bot_household_chat_id`
|
||||||
- `bot_purchase_topic_id`
|
- `bot_purchase_topic_id`
|
||||||
|
- optional `bot_feedback_topic_id`
|
||||||
- optional `bot_parser_model`
|
- optional `bot_parser_model`
|
||||||
|
- optional `bot_mini_app_allowed_origins`
|
||||||
|
|
||||||
## CI validation
|
## CI validation
|
||||||
|
|
||||||
@@ -84,5 +86,6 @@ CI runs:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Scheduler job defaults to `paused = true` to prevent accidental sends before app logic is ready.
|
- Scheduler jobs default to `paused = true` and `dry_run = true` to prevent accidental sends before live reminder delivery is ready.
|
||||||
- Bot API is public to accept Telegram webhooks; scheduler endpoint should still verify app-level auth.
|
- Bot API is public to accept Telegram webhooks; scheduler endpoint should still verify app-level auth.
|
||||||
|
- `bot_mini_app_allowed_origins` cannot be auto-derived in Terraform because the bot and mini app Cloud Run services reference each other; set it explicitly once the mini app URL is known.
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ locals {
|
|||||||
|
|
||||||
artifact_location = coalesce(var.artifact_repository_location, var.region)
|
artifact_location = coalesce(var.artifact_repository_location, var.region)
|
||||||
|
|
||||||
|
reminder_jobs = {
|
||||||
|
utilities = {
|
||||||
|
schedule = var.scheduler_utilities_cron
|
||||||
|
path = "/jobs/reminder/utilities"
|
||||||
|
}
|
||||||
|
rent-warning = {
|
||||||
|
schedule = var.scheduler_rent_warning_cron
|
||||||
|
path = "/jobs/reminder/rent-warning"
|
||||||
|
}
|
||||||
|
rent-due = {
|
||||||
|
schedule = var.scheduler_rent_due_cron
|
||||||
|
path = "/jobs/reminder/rent-due"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
runtime_secret_ids = toset(compact([
|
runtime_secret_ids = toset(compact([
|
||||||
var.telegram_webhook_secret_id,
|
var.telegram_webhook_secret_id,
|
||||||
var.scheduler_shared_secret_id,
|
var.scheduler_shared_secret_id,
|
||||||
|
|||||||
@@ -90,8 +90,17 @@ module "bot_api_service" {
|
|||||||
var.bot_purchase_topic_id == null ? {} : {
|
var.bot_purchase_topic_id == null ? {} : {
|
||||||
TELEGRAM_PURCHASE_TOPIC_ID = tostring(var.bot_purchase_topic_id)
|
TELEGRAM_PURCHASE_TOPIC_ID = tostring(var.bot_purchase_topic_id)
|
||||||
},
|
},
|
||||||
|
var.bot_feedback_topic_id == null ? {} : {
|
||||||
|
TELEGRAM_FEEDBACK_TOPIC_ID = tostring(var.bot_feedback_topic_id)
|
||||||
|
},
|
||||||
var.bot_parser_model == null ? {} : {
|
var.bot_parser_model == null ? {} : {
|
||||||
PARSER_MODEL = var.bot_parser_model
|
PARSER_MODEL = var.bot_parser_model
|
||||||
|
},
|
||||||
|
length(var.bot_mini_app_allowed_origins) == 0 ? {} : {
|
||||||
|
MINI_APP_ALLOWED_ORIGINS = join(",", var.bot_mini_app_allowed_origins)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SCHEDULER_OIDC_ALLOWED_EMAILS = google_service_account.scheduler_invoker.email
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,6 +147,7 @@ module "mini_app_service" {
|
|||||||
|
|
||||||
env = {
|
env = {
|
||||||
NODE_ENV = var.environment
|
NODE_ENV = var.environment
|
||||||
|
BOT_API_URL = module.bot_api_service.uri
|
||||||
}
|
}
|
||||||
|
|
||||||
depends_on = [google_project_service.enabled]
|
depends_on = [google_project_service.enabled]
|
||||||
@@ -158,22 +168,27 @@ resource "google_service_account_iam_member" "scheduler_token_creator" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resource "google_cloud_scheduler_job" "reminders" {
|
resource "google_cloud_scheduler_job" "reminders" {
|
||||||
|
for_each = local.reminder_jobs
|
||||||
|
|
||||||
project = var.project_id
|
project = var.project_id
|
||||||
region = var.region
|
region = var.region
|
||||||
name = "${local.name_prefix}-reminders"
|
name = "${local.name_prefix}-${each.key}"
|
||||||
schedule = var.scheduler_cron
|
schedule = each.value.schedule
|
||||||
time_zone = var.scheduler_timezone
|
time_zone = var.scheduler_timezone
|
||||||
paused = var.scheduler_paused
|
paused = var.scheduler_paused
|
||||||
|
|
||||||
http_target {
|
http_target {
|
||||||
uri = "${module.bot_api_service.uri}${var.scheduler_path}"
|
uri = "${module.bot_api_service.uri}${each.value.path}"
|
||||||
http_method = var.scheduler_http_method
|
http_method = "POST"
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type" = "application/json"
|
"Content-Type" = "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
body = base64encode(var.scheduler_body_json)
|
body = base64encode(jsonencode({
|
||||||
|
dryRun = var.scheduler_dry_run
|
||||||
|
jobId = "${local.name_prefix}-${each.key}"
|
||||||
|
}))
|
||||||
|
|
||||||
oidc_token {
|
oidc_token {
|
||||||
service_account_email = google_service_account.scheduler_invoker.email
|
service_account_email = google_service_account.scheduler_invoker.email
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ output "mini_app_service_url" {
|
|||||||
value = module.mini_app_service.uri
|
value = module.mini_app_service.uri
|
||||||
}
|
}
|
||||||
|
|
||||||
output "scheduler_job_name" {
|
output "scheduler_job_names" {
|
||||||
description = "Cloud Scheduler job for reminders"
|
description = "Cloud Scheduler jobs for reminders"
|
||||||
value = google_cloud_scheduler_job.reminders.name
|
value = { for name, job in google_cloud_scheduler_job.reminders : name => job.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
output "runtime_secret_ids" {
|
output "runtime_secret_ids" {
|
||||||
|
|||||||
@@ -11,11 +11,18 @@ mini_app_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/mini
|
|||||||
bot_household_id = "11111111-1111-4111-8111-111111111111"
|
bot_household_id = "11111111-1111-4111-8111-111111111111"
|
||||||
bot_household_chat_id = "-1001234567890"
|
bot_household_chat_id = "-1001234567890"
|
||||||
bot_purchase_topic_id = 777
|
bot_purchase_topic_id = 777
|
||||||
|
bot_feedback_topic_id = 778
|
||||||
bot_parser_model = "gpt-4.1-mini"
|
bot_parser_model = "gpt-4.1-mini"
|
||||||
|
bot_mini_app_allowed_origins = [
|
||||||
|
"https://household-dev-mini-app-abc123-ew.a.run.app"
|
||||||
|
]
|
||||||
|
|
||||||
scheduler_cron = "0 9 * * *"
|
scheduler_utilities_cron = "0 9 4 * *"
|
||||||
|
scheduler_rent_warning_cron = "0 9 17 * *"
|
||||||
|
scheduler_rent_due_cron = "0 9 20 * *"
|
||||||
scheduler_timezone = "Asia/Tbilisi"
|
scheduler_timezone = "Asia/Tbilisi"
|
||||||
scheduler_paused = true
|
scheduler_paused = true
|
||||||
|
scheduler_dry_run = true
|
||||||
|
|
||||||
create_workload_identity = true
|
create_workload_identity = true
|
||||||
github_repository = "whekin/household-bot"
|
github_repository = "whekin/household-bot"
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ variable "bot_purchase_topic_id" {
|
|||||||
nullable = true
|
nullable = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "bot_feedback_topic_id" {
|
||||||
|
description = "Optional TELEGRAM_FEEDBACK_TOPIC_ID value for bot runtime"
|
||||||
|
type = number
|
||||||
|
default = null
|
||||||
|
nullable = true
|
||||||
|
}
|
||||||
|
|
||||||
variable "bot_parser_model" {
|
variable "bot_parser_model" {
|
||||||
description = "Optional PARSER_MODEL override for bot runtime"
|
description = "Optional PARSER_MODEL override for bot runtime"
|
||||||
type = string
|
type = string
|
||||||
@@ -111,6 +118,12 @@ variable "bot_parser_model" {
|
|||||||
nullable = true
|
nullable = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "bot_mini_app_allowed_origins" {
|
||||||
|
description = "Optional allow-list of mini app origins for bot CORS handling"
|
||||||
|
type = list(string)
|
||||||
|
default = []
|
||||||
|
}
|
||||||
|
|
||||||
variable "openai_api_key_secret_id" {
|
variable "openai_api_key_secret_id" {
|
||||||
description = "Optional Secret Manager ID for OPENAI_API_KEY"
|
description = "Optional Secret Manager ID for OPENAI_API_KEY"
|
||||||
type = string
|
type = string
|
||||||
@@ -118,35 +131,34 @@ variable "openai_api_key_secret_id" {
|
|||||||
nullable = true
|
nullable = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
variable "scheduler_path" {
|
|
||||||
description = "Reminder endpoint path on bot API"
|
|
||||||
type = string
|
|
||||||
default = "/internal/scheduler/reminders"
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "scheduler_http_method" {
|
|
||||||
description = "Scheduler HTTP method"
|
|
||||||
type = string
|
|
||||||
default = "POST"
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "scheduler_cron" {
|
|
||||||
description = "Cron expression for reminder scheduler"
|
|
||||||
type = string
|
|
||||||
default = "0 9 * * *"
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "scheduler_timezone" {
|
variable "scheduler_timezone" {
|
||||||
description = "Scheduler timezone"
|
description = "Scheduler timezone"
|
||||||
type = string
|
type = string
|
||||||
default = "Asia/Tbilisi"
|
default = "Asia/Tbilisi"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "scheduler_body_json" {
|
variable "scheduler_utilities_cron" {
|
||||||
description = "JSON payload for scheduler requests"
|
description = "Cron expression for the utilities reminder scheduler job"
|
||||||
type = string
|
type = string
|
||||||
default = "{\"kind\":\"monthly-reminder\"}"
|
default = "0 9 4 * *"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "scheduler_rent_warning_cron" {
|
||||||
|
description = "Cron expression for the rent warning scheduler job"
|
||||||
|
type = string
|
||||||
|
default = "0 9 17 * *"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "scheduler_rent_due_cron" {
|
||||||
|
description = "Cron expression for the rent due scheduler job"
|
||||||
|
type = string
|
||||||
|
default = "0 9 20 * *"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "scheduler_dry_run" {
|
||||||
|
description = "Whether scheduler jobs should invoke the bot in dry-run mode"
|
||||||
|
type = bool
|
||||||
|
default = true
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "scheduler_paused" {
|
variable "scheduler_paused" {
|
||||||
|
|||||||
@@ -32,7 +32,9 @@
|
|||||||
"docker:build:miniapp": "docker build -f apps/miniapp/Dockerfile -t household-miniapp:local .",
|
"docker:build:miniapp": "docker build -f apps/miniapp/Dockerfile -t household-miniapp:local .",
|
||||||
"docker:build": "bun run docker:build:bot && bun run docker:build:miniapp",
|
"docker:build": "bun run docker:build:bot && bun run docker:build:miniapp",
|
||||||
"docker:smoke": "docker compose up --build",
|
"docker:smoke": "docker compose up --build",
|
||||||
"test:e2e": "bun run scripts/e2e/billing-flow.ts"
|
"test:e2e": "bun run scripts/e2e/billing-flow.ts",
|
||||||
|
"ops:deploy:smoke": "bun run scripts/ops/deploy-smoke.ts",
|
||||||
|
"ops:telegram:webhook": "bun run scripts/ops/telegram-webhook.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "1.3.10",
|
"@types/bun": "1.3.10",
|
||||||
|
|||||||
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\""
|
"lint": "oxlint \"src\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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 { calculateMonthlySettlement } from './settlement-engine'
|
||||||
|
export {
|
||||||
|
createAnonymousFeedbackService,
|
||||||
|
type AnonymousFeedbackService,
|
||||||
|
type AnonymousFeedbackSubmitResult
|
||||||
|
} from './anonymous-feedback-service'
|
||||||
|
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
||||||
|
export {
|
||||||
|
createReminderJobService,
|
||||||
|
type ReminderJobResult,
|
||||||
|
type ReminderJobService
|
||||||
|
} from './reminder-job-service'
|
||||||
export {
|
export {
|
||||||
parsePurchaseMessage,
|
parsePurchaseMessage,
|
||||||
type ParsedPurchaseResult,
|
type ParsedPurchaseResult,
|
||||||
|
|||||||
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,
|
"when": 1772671128084,
|
||||||
"tag": "0003_mature_roulette",
|
"tag": "0003_mature_roulette",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772995779819,
|
||||||
|
"tag": "0004_big_ultimatum",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,46 @@ export const processedBotMessages = pgTable(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const anonymousMessages = pgTable(
|
||||||
|
'anonymous_messages',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
householdId: uuid('household_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: 'cascade' }),
|
||||||
|
submittedByMemberId: uuid('submitted_by_member_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => members.id, { onDelete: 'restrict' }),
|
||||||
|
rawText: text('raw_text').notNull(),
|
||||||
|
sanitizedText: text('sanitized_text'),
|
||||||
|
moderationStatus: text('moderation_status').notNull(),
|
||||||
|
moderationReason: text('moderation_reason'),
|
||||||
|
telegramChatId: text('telegram_chat_id').notNull(),
|
||||||
|
telegramMessageId: text('telegram_message_id').notNull(),
|
||||||
|
telegramUpdateId: text('telegram_update_id').notNull(),
|
||||||
|
postedChatId: text('posted_chat_id'),
|
||||||
|
postedThreadId: text('posted_thread_id'),
|
||||||
|
postedMessageId: text('posted_message_id'),
|
||||||
|
failureReason: text('failure_reason'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
postedAt: timestamp('posted_at', { withTimezone: true })
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
householdUpdateUnique: uniqueIndex('anonymous_messages_household_tg_update_unique').on(
|
||||||
|
table.householdId,
|
||||||
|
table.telegramUpdateId
|
||||||
|
),
|
||||||
|
memberCreatedIdx: index('anonymous_messages_member_created_idx').on(
|
||||||
|
table.submittedByMemberId,
|
||||||
|
table.createdAt
|
||||||
|
),
|
||||||
|
statusCreatedIdx: index('anonymous_messages_status_created_idx').on(
|
||||||
|
table.moderationStatus,
|
||||||
|
table.createdAt
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const settlements = pgTable(
|
export const settlements = pgTable(
|
||||||
'settlements',
|
'settlements',
|
||||||
{
|
{
|
||||||
@@ -308,4 +348,5 @@ export type BillingCycle = typeof billingCycles.$inferSelect
|
|||||||
export type UtilityBill = typeof utilityBills.$inferSelect
|
export type UtilityBill = typeof utilityBills.$inferSelect
|
||||||
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
|
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
|
||||||
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
|
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
|
||||||
|
export type AnonymousMessage = typeof anonymousMessages.$inferSelect
|
||||||
export type Settlement = typeof settlements.$inferSelect
|
export type Settlement = typeof settlements.$inferSelect
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
"name": "@household/ports",
|
"name": "@household/ports",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun build src/index.ts --outdir dist --target bun",
|
"build": "bun build src/index.ts --outdir dist --target bun",
|
||||||
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
"typecheck": "tsgo --project tsconfig.json --noEmit",
|
||||||
"test": "bun test --pass-with-no-tests",
|
"test": "bun test --pass-with-no-tests",
|
||||||
"lint": "oxlint \"src\""
|
"lint": "oxlint \"src\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@household/domain": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { eq } from 'drizzle-orm'
|
||||||
|
|
||||||
|
import { createFinanceCommandService } from '@household/application'
|
||||||
|
import { createDbFinanceRepository } from '@household/adapters-db'
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
|
|
||||||
import { createTelegramBot } from '../../apps/bot/src/bot'
|
import { createTelegramBot } from '../../apps/bot/src/bot'
|
||||||
@@ -129,7 +131,7 @@ async function run(): Promise<void> {
|
|||||||
|
|
||||||
let coreClient: ReturnType<typeof createDbClient> | undefined
|
let coreClient: ReturnType<typeof createDbClient> | undefined
|
||||||
let ingestionClient: ReturnType<typeof createPurchaseMessageRepository> | undefined
|
let ingestionClient: ReturnType<typeof createPurchaseMessageRepository> | undefined
|
||||||
let financeService: ReturnType<typeof createFinanceCommandsService> | undefined
|
let financeRepositoryClient: ReturnType<typeof createDbFinanceRepository> | undefined
|
||||||
|
|
||||||
const bot = createTelegramBot('000000:test-token')
|
const bot = createTelegramBot('000000:test-token')
|
||||||
const replies: string[] = []
|
const replies: string[] = []
|
||||||
@@ -178,9 +180,9 @@ async function run(): Promise<void> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ingestionClient = createPurchaseMessageRepository(databaseUrl)
|
ingestionClient = createPurchaseMessageRepository(databaseUrl)
|
||||||
financeService = createFinanceCommandsService(databaseUrl, {
|
financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household)
|
||||||
householdId: ids.household
|
const financeService = createFinanceCommandService(financeRepositoryClient.repository)
|
||||||
})
|
const financeCommands = createFinanceCommandsService(financeService)
|
||||||
|
|
||||||
registerPurchaseTopicIngestion(
|
registerPurchaseTopicIngestion(
|
||||||
bot,
|
bot,
|
||||||
@@ -192,7 +194,7 @@ async function run(): Promise<void> {
|
|||||||
ingestionClient.repository
|
ingestionClient.repository
|
||||||
)
|
)
|
||||||
|
|
||||||
financeService.register(bot)
|
financeCommands.register(bot)
|
||||||
|
|
||||||
await coreClient.db.insert(schema.households).values({
|
await coreClient.db.insert(schema.households).values({
|
||||||
id: ids.household,
|
id: ids.household,
|
||||||
@@ -336,7 +338,7 @@ async function run(): Promise<void> {
|
|||||||
: undefined,
|
: undefined,
|
||||||
coreClient?.queryClient.end({ timeout: 5 }),
|
coreClient?.queryClient.end({ timeout: 5 }),
|
||||||
ingestionClient?.close(),
|
ingestionClient?.close(),
|
||||||
financeService?.close()
|
financeRepositoryClient?.close()
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
132
scripts/ops/deploy-smoke.ts
Normal file
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/db"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "./packages/adapters-db"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "./scripts"
|
"path": "./scripts"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user