mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 16:14:02 +00:00
feat(miniapp): add finance dashboard view
This commit is contained in:
@@ -18,6 +18,7 @@ import { createReminderJobsHandler } from './reminder-jobs'
|
||||
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
||||
import { createBotWebhookServer } from './server'
|
||||
import { createMiniAppAuthHandler } from './miniapp-auth'
|
||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||
|
||||
const runtime = getBotRuntimeConfig()
|
||||
const bot = createTelegramBot(runtime.telegramBotToken)
|
||||
@@ -28,6 +29,9 @@ const financeRepositoryClient =
|
||||
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled
|
||||
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
|
||||
: null
|
||||
const financeService = financeRepositoryClient
|
||||
? createFinanceCommandService(financeRepositoryClient.repository)
|
||||
: null
|
||||
|
||||
if (financeRepositoryClient) {
|
||||
shutdownTasks.push(financeRepositoryClient.close)
|
||||
@@ -59,8 +63,7 @@ if (runtime.purchaseTopicIngestionEnabled) {
|
||||
}
|
||||
|
||||
if (runtime.financeCommandsEnabled) {
|
||||
const financeService = createFinanceCommandService(financeRepositoryClient!.repository)
|
||||
const financeCommands = createFinanceCommandsService(financeService)
|
||||
const financeCommands = createFinanceCommandsService(financeService!)
|
||||
|
||||
financeCommands.register(bot)
|
||||
} else {
|
||||
@@ -98,6 +101,13 @@ const server = createBotWebhookServer({
|
||||
repository: financeRepositoryClient.repository
|
||||
})
|
||||
: undefined,
|
||||
miniAppDashboard: financeService
|
||||
? createMiniAppDashboardHandler({
|
||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||
botToken: runtime.telegramBotToken,
|
||||
financeService
|
||||
})
|
||||
: undefined,
|
||||
scheduler:
|
||||
reminderJobs && runtime.schedulerSharedSecret
|
||||
? {
|
||||
|
||||
@@ -38,6 +38,7 @@ function repository(
|
||||
addUtilityBill: async () => {},
|
||||
getRentRuleForPeriod: async () => null,
|
||||
getUtilityTotalForCycle: async () => 0n,
|
||||
listUtilityBillsForCycle: async () => [],
|
||||
listParsedPurchasesForRange: async () => [],
|
||||
replaceSettlementSnapshot: async () => {}
|
||||
}
|
||||
@@ -84,6 +85,10 @@ describe('createMiniAppAuthHandler', () => {
|
||||
displayName: 'Stan',
|
||||
isAdmin: true
|
||||
},
|
||||
features: {
|
||||
balances: true,
|
||||
ledger: true
|
||||
},
|
||||
telegramUser: {
|
||||
id: '123456',
|
||||
firstName: 'Stan',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { FinanceRepository } from '@household/ports'
|
||||
import type { FinanceMemberRecord, FinanceRepository } from '@household/ports'
|
||||
|
||||
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
||||
|
||||
function json(body: object, status = 200, origin?: string): Response {
|
||||
export function miniAppJsonResponse(body: object, status = 200, origin?: string): Response {
|
||||
const headers = new Headers({
|
||||
'content-type': 'application/json; charset=utf-8'
|
||||
})
|
||||
@@ -20,7 +20,10 @@ function json(body: object, status = 200, origin?: string): Response {
|
||||
})
|
||||
}
|
||||
|
||||
function allowedOrigin(request: Request, allowedOrigins: readonly string[]): string | undefined {
|
||||
export function allowedMiniAppOrigin(
|
||||
request: Request,
|
||||
allowedOrigins: readonly string[]
|
||||
): string | undefined {
|
||||
const origin = request.headers.get('origin')
|
||||
|
||||
if (!origin) {
|
||||
@@ -34,7 +37,7 @@ function allowedOrigin(request: Request, allowedOrigins: readonly string[]): str
|
||||
return allowedOrigins.includes(origin) ? origin : undefined
|
||||
}
|
||||
|
||||
async function readInitData(request: Request): Promise<string | null> {
|
||||
export async function readMiniAppInitData(request: Request): Promise<string | null> {
|
||||
const text = await request.text()
|
||||
|
||||
if (text.trim().length === 0) {
|
||||
@@ -47,6 +50,53 @@ async function readInitData(request: Request): Promise<string | null> {
|
||||
return initData && initData.length > 0 ? initData : null
|
||||
}
|
||||
|
||||
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
|
||||
@@ -54,32 +104,40 @@ export function createMiniAppAuthHandler(options: {
|
||||
}): {
|
||||
handler: (request: Request) => Promise<Response>
|
||||
} {
|
||||
const sessionService = createMiniAppSessionService({
|
||||
botToken: options.botToken,
|
||||
getMemberByTelegramUserId: options.repository.getMemberByTelegramUserId
|
||||
})
|
||||
|
||||
return {
|
||||
handler: async (request) => {
|
||||
const origin = allowedOrigin(request, options.allowedOrigins)
|
||||
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
return json({ ok: true }, 204, origin)
|
||||
return miniAppJsonResponse({ ok: true }, 204, origin)
|
||||
}
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({ ok: false, error: 'Method Not Allowed' }, 405, origin)
|
||||
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
|
||||
}
|
||||
|
||||
try {
|
||||
const initData = await readInitData(request)
|
||||
const initData = await readMiniAppInitData(request)
|
||||
if (!initData) {
|
||||
return json({ ok: false, error: 'Missing initData' }, 400, origin)
|
||||
return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin)
|
||||
}
|
||||
|
||||
const telegramUser = verifyTelegramMiniAppInitData(initData, options.botToken)
|
||||
if (!telegramUser) {
|
||||
return json({ ok: false, error: 'Invalid Telegram init data' }, 401, origin)
|
||||
const session = await sessionService.authenticate(initData)
|
||||
if (!session) {
|
||||
return miniAppJsonResponse(
|
||||
{ ok: false, error: 'Invalid Telegram init data' },
|
||||
401,
|
||||
origin
|
||||
)
|
||||
}
|
||||
|
||||
const member = await options.repository.getMemberByTelegramUserId(telegramUser.id)
|
||||
if (!member) {
|
||||
return json(
|
||||
if (!session.authorized) {
|
||||
return miniAppJsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
authorized: false,
|
||||
@@ -90,19 +148,15 @@ export function createMiniAppAuthHandler(options: {
|
||||
)
|
||||
}
|
||||
|
||||
return json(
|
||||
return miniAppJsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
authorized: true,
|
||||
member: {
|
||||
id: member.id,
|
||||
displayName: member.displayName,
|
||||
isAdmin: member.isAdmin
|
||||
},
|
||||
telegramUser,
|
||||
member: session.member,
|
||||
telegramUser: session.telegramUser,
|
||||
features: {
|
||||
balances: false,
|
||||
ledger: false
|
||||
balances: true,
|
||||
ledger: true
|
||||
}
|
||||
},
|
||||
200,
|
||||
@@ -110,7 +164,7 @@ export function createMiniAppAuthHandler(options: {
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown mini app auth error'
|
||||
return json({ ok: false, error: message }, 400, origin)
|
||||
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
147
apps/bot/src/miniapp-dashboard.test.ts
Normal file
147
apps/bot/src/miniapp-dashboard.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
import { createFinanceCommandService } from '@household/application'
|
||||
import type { FinanceRepository } from '@household/ports'
|
||||
|
||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||
|
||||
function buildInitData(botToken: string, authDate: number, user: object): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('auth_date', authDate.toString())
|
||||
params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc')
|
||||
params.set('user', JSON.stringify(user))
|
||||
|
||||
const dataCheckString = [...params.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
|
||||
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
|
||||
const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
|
||||
params.set('hash', hash)
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
function repository(
|
||||
member: Awaited<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: buildInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan',
|
||||
username: 'stanislav',
|
||||
language_code: 'ru'
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
authorized: true,
|
||||
dashboard: {
|
||||
period: '2026-03',
|
||||
currency: 'USD',
|
||||
totalDueMajor: '820.00',
|
||||
members: [
|
||||
{
|
||||
displayName: 'Stan',
|
||||
netDueMajor: '820.00',
|
||||
rentShareMajor: '700.00',
|
||||
utilityShareMajor: '120.00',
|
||||
purchaseOffsetMajor: '0.00'
|
||||
}
|
||||
],
|
||||
ledger: [
|
||||
{
|
||||
title: 'Soap'
|
||||
},
|
||||
{
|
||||
title: 'Electricity'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
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,
|
||||
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) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown mini app dashboard error'
|
||||
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,15 @@ describe('createBotWebhookServer', () => {
|
||||
}
|
||||
})
|
||||
},
|
||||
miniAppDashboard: {
|
||||
handler: async () =>
|
||||
new Response(JSON.stringify({ ok: true, authorized: true, dashboard: {} }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8'
|
||||
}
|
||||
})
|
||||
},
|
||||
scheduler: {
|
||||
authorize: async (request) =>
|
||||
request.headers.get('x-household-scheduler-secret') === 'scheduler-secret',
|
||||
@@ -95,6 +104,22 @@ describe('createBotWebhookServer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('accepts mini app dashboard request', async () => {
|
||||
const response = await server.fetch(
|
||||
new Request('http://localhost/api/miniapp/dashboard', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ initData: 'payload' })
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
ok: true,
|
||||
authorized: true,
|
||||
dashboard: {}
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects scheduler request with missing secret', async () => {
|
||||
const response = await server.fetch(
|
||||
new Request('http://localhost/jobs/reminder/utilities', {
|
||||
|
||||
@@ -8,6 +8,12 @@ export interface BotWebhookServerOptions {
|
||||
handler: (request: Request) => Promise<Response>
|
||||
}
|
||||
| undefined
|
||||
miniAppDashboard?:
|
||||
| {
|
||||
path?: string
|
||||
handler: (request: Request) => Promise<Response>
|
||||
}
|
||||
| undefined
|
||||
scheduler?:
|
||||
| {
|
||||
pathPrefix?: string
|
||||
@@ -39,6 +45,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
? options.webhookPath
|
||||
: `/${options.webhookPath}`
|
||||
const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session'
|
||||
const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard'
|
||||
const schedulerPathPrefix = options.scheduler
|
||||
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
||||
: null
|
||||
@@ -55,6 +62,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
return await options.miniAppAuth.handler(request)
|
||||
}
|
||||
|
||||
if (options.miniAppDashboard && url.pathname === miniAppDashboardPath) {
|
||||
return await options.miniAppDashboard.handler(request)
|
||||
}
|
||||
|
||||
if (url.pathname !== normalizedWebhookPath) {
|
||||
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
|
||||
if (request.method !== 'POST') {
|
||||
|
||||
Reference in New Issue
Block a user