mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
fix(review): harden miniapp auth and finance flows
This commit is contained in:
@@ -1,27 +1,9 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
import type { FinanceRepository } from '@household/ports'
|
||||
|
||||
import { createMiniAppAuthHandler } from './miniapp-auth'
|
||||
|
||||
function buildInitData(botToken: string, authDate: number, user: object): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('auth_date', authDate.toString())
|
||||
params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc')
|
||||
params.set('user', JSON.stringify(user))
|
||||
|
||||
const dataCheckString = [...params.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
|
||||
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
|
||||
const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
|
||||
params.set('hash', hash)
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||
|
||||
function repository(
|
||||
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
||||
@@ -66,7 +48,7 @@ describe('createMiniAppAuthHandler', () => {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData: buildInitData('test-bot-token', authDate, {
|
||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan',
|
||||
username: 'stanislav',
|
||||
@@ -114,7 +96,7 @@ describe('createMiniAppAuthHandler', () => {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData: buildInitData('test-bot-token', authDate, {
|
||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
})
|
||||
@@ -129,4 +111,73 @@ describe('createMiniAppAuthHandler', () => {
|
||||
reason: 'not_member'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns 400 for malformed JSON bodies', async () => {
|
||||
const auth = createMiniAppAuthHandler({
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
botToken: 'test-bot-token',
|
||||
repository: repository(null)
|
||||
})
|
||||
|
||||
const response = await auth.handler(
|
||||
new Request('http://localhost/api/miniapp/session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'http://localhost:5173',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: '{"initData":'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
ok: false,
|
||||
error: 'Invalid JSON body'
|
||||
})
|
||||
})
|
||||
|
||||
test('does not reflect arbitrary origins in production without an allow-list', async () => {
|
||||
const previousNodeEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
try {
|
||||
const authDate = Math.floor(Date.now() / 1000)
|
||||
const auth = createMiniAppAuthHandler({
|
||||
allowedOrigins: [],
|
||||
botToken: 'test-bot-token',
|
||||
repository: repository({
|
||||
id: 'member-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
isAdmin: true
|
||||
})
|
||||
})
|
||||
|
||||
const response = await auth.handler(
|
||||
new Request('http://localhost/api/miniapp/session', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'https://unknown.example',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull()
|
||||
} finally {
|
||||
if (previousNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = previousNodeEnv
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,10 @@ export function miniAppJsonResponse(body: object, status = 200, origin?: string)
|
||||
|
||||
export function allowedMiniAppOrigin(
|
||||
request: Request,
|
||||
allowedOrigins: readonly string[]
|
||||
allowedOrigins: readonly string[],
|
||||
options: {
|
||||
allowDynamicOrigin?: boolean
|
||||
} = {}
|
||||
): string | undefined {
|
||||
const origin = request.headers.get('origin')
|
||||
|
||||
@@ -31,7 +34,8 @@ export function allowedMiniAppOrigin(
|
||||
}
|
||||
|
||||
if (allowedOrigins.length === 0) {
|
||||
return origin
|
||||
const allowDynamicOrigin = options.allowDynamicOrigin ?? process.env.NODE_ENV !== 'production'
|
||||
return allowDynamicOrigin ? origin : undefined
|
||||
}
|
||||
|
||||
return allowedOrigins.includes(origin) ? origin : undefined
|
||||
@@ -44,12 +48,35 @@ export async function readMiniAppInitData(request: Request): Promise<string | nu
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text) as { initData?: string }
|
||||
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'
|
||||
@@ -163,8 +190,7 @@ export function createMiniAppAuthHandler(options: {
|
||||
origin
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown mini app auth error'
|
||||
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
|
||||
return miniAppErrorResponse(error, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,10 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
import { createFinanceCommandService } from '@household/application'
|
||||
import type { FinanceRepository } from '@household/ports'
|
||||
|
||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||
|
||||
function buildInitData(botToken: string, authDate: number, user: object): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('auth_date', authDate.toString())
|
||||
params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc')
|
||||
params.set('user', JSON.stringify(user))
|
||||
|
||||
const dataCheckString = [...params.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
|
||||
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
|
||||
const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
|
||||
params.set('hash', hash)
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||
|
||||
function repository(
|
||||
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
||||
@@ -106,7 +88,7 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData: buildInitData('test-bot-token', authDate, {
|
||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||
id: 123456,
|
||||
first_name: 'Stan',
|
||||
username: 'stanislav',
|
||||
@@ -144,4 +126,38 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('returns 400 for malformed JSON bodies', async () => {
|
||||
const financeService = createFinanceCommandService(
|
||||
repository({
|
||||
id: 'member-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
isAdmin: true
|
||||
})
|
||||
)
|
||||
|
||||
const dashboard = createMiniAppDashboardHandler({
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
botToken: 'test-bot-token',
|
||||
financeService
|
||||
})
|
||||
|
||||
const response = await dashboard.handler(
|
||||
new Request('http://localhost/api/miniapp/dashboard', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'http://localhost:5173',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: '{"initData":'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
ok: false,
|
||||
error: 'Invalid JSON body'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FinanceCommandService } from '@household/application'
|
||||
import {
|
||||
allowedMiniAppOrigin,
|
||||
createMiniAppSessionService,
|
||||
miniAppErrorResponse,
|
||||
miniAppJsonResponse,
|
||||
readMiniAppInitData
|
||||
} from './miniapp-auth'
|
||||
@@ -98,8 +99,7 @@ export function createMiniAppDashboardHandler(options: {
|
||||
origin
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown mini app dashboard error'
|
||||
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
|
||||
return miniAppErrorResponse(error, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BillingPeriod } from '@household/domain'
|
||||
import type { ReminderJobService } from '@household/application'
|
||||
|
||||
const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const
|
||||
type ReminderType = (typeof REMINDER_TYPES)[number]
|
||||
import { BillingPeriod } from '@household/domain'
|
||||
import { REMINDER_TYPES, type ReminderType } from '@household/ports'
|
||||
|
||||
interface ReminderJobRequestBody {
|
||||
period?: string
|
||||
@@ -42,8 +40,11 @@ async function readBody(request: Request): Promise<ReminderJobRequestBody> {
|
||||
return {}
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text) as ReminderJobRequestBody
|
||||
return parsed
|
||||
try {
|
||||
return JSON.parse(text) as ReminderJobRequestBody
|
||||
} catch {
|
||||
throw new Error('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
|
||||
export function createReminderJobsHandler(options: {
|
||||
|
||||
@@ -57,10 +57,8 @@ export function createSchedulerRequestAuthorizer(options: {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!oidcAudience || allowedEmails.size === 0) {
|
||||
if (allowedEmails.size === 0) {
|
||||
return false
|
||||
}
|
||||
if (allowedEmails.size === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
||||
|
||||
function buildInitData(botToken: string, authDate: number, user: object): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('auth_date', authDate.toString())
|
||||
params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc')
|
||||
params.set('user', JSON.stringify(user))
|
||||
|
||||
const dataCheckString = [...params.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
|
||||
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
|
||||
const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
|
||||
params.set('hash', hash)
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||
|
||||
describe('verifyTelegramMiniAppInitData', () => {
|
||||
test('verifies valid init data and extracts user payload', () => {
|
||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
||||
const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
||||
const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
||||
id: 123456,
|
||||
first_name: 'Stan',
|
||||
username: 'stanislav'
|
||||
@@ -44,7 +26,7 @@ describe('verifyTelegramMiniAppInitData', () => {
|
||||
test('rejects invalid hash', () => {
|
||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
||||
const params = new URLSearchParams(
|
||||
buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
||||
buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
})
|
||||
@@ -58,7 +40,23 @@ describe('verifyTelegramMiniAppInitData', () => {
|
||||
|
||||
test('rejects expired init data', () => {
|
||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
||||
const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000) - 7200, {
|
||||
const initData = buildMiniAppInitData(
|
||||
'test-bot-token',
|
||||
Math.floor(now.getTime() / 1000) - 7200,
|
||||
{
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
}
|
||||
)
|
||||
|
||||
const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('rejects init data timestamps from the future', () => {
|
||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
||||
const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000) + 5, {
|
||||
id: 123456,
|
||||
first_name: 'Stan'
|
||||
})
|
||||
|
||||
@@ -36,7 +36,11 @@ export function verifyTelegramMiniAppInitData(
|
||||
|
||||
const authDateSeconds = Number(authDateRaw)
|
||||
const nowSeconds = Math.floor(now.getTime() / 1000)
|
||||
if (Math.abs(nowSeconds - authDateSeconds) > maxAgeSeconds) {
|
||||
if (authDateSeconds > nowSeconds) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (nowSeconds - authDateSeconds > maxAgeSeconds) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -107,7 +107,11 @@ function App() {
|
||||
|
||||
try {
|
||||
setDashboard(await fetchMiniAppDashboard(initData))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Failed to load mini app dashboard', error)
|
||||
}
|
||||
|
||||
setDashboard(null)
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -131,14 +131,31 @@ button {
|
||||
background: rgb(247 179 137 / 0.14);
|
||||
}
|
||||
|
||||
.locale-switch__buttons button:focus-visible,
|
||||
.nav-grid button:focus-visible,
|
||||
.ghost-button:focus-visible {
|
||||
outline: 2px solid #f7b389;
|
||||
outline-offset: 2px;
|
||||
border-color: rgb(247 179 137 / 0.7);
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.panel {
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
background-color: rgb(18 26 36 / 0.82);
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.02));
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 24px 64px rgb(0 0 0 / 0.22);
|
||||
}
|
||||
|
||||
@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
|
||||
.hero-card,
|
||||
.panel {
|
||||
background: rgb(18 26 36 / 0.94);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
margin-top: 28px;
|
||||
border-radius: 28px;
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function fetchMiniAppSession(initData: string): Promise<MiniAppSess
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error)
|
||||
throw new Error(payload.error ?? 'Failed to create mini app session')
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -23,5 +23,9 @@ declare global {
|
||||
}
|
||||
|
||||
export function getTelegramWebApp(): TelegramWebApp | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return window.Telegram?.WebApp
|
||||
}
|
||||
|
||||
@@ -48,8 +48,13 @@ Keep bot runtime config that is not secret in your `*.tfvars` file:
|
||||
- `bot_household_id`
|
||||
- `bot_household_chat_id`
|
||||
- `bot_purchase_topic_id`
|
||||
- optional `bot_feedback_topic_id`
|
||||
- `bot_mini_app_allowed_origins`
|
||||
- optional `bot_parser_model`
|
||||
|
||||
Set `bot_mini_app_allowed_origins` to the exact mini app origins you expect in each environment.
|
||||
Do not rely on permissive origin reflection in production.
|
||||
|
||||
## Reminder jobs
|
||||
|
||||
Terraform provisions three separate Cloud Scheduler jobs:
|
||||
@@ -67,6 +72,7 @@ They target the bot runtime endpoints:
|
||||
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
|
||||
|
||||
@@ -39,7 +39,7 @@ Build the first usable SolidJS mini app shell with a real Telegram initData veri
|
||||
|
||||
- Telegram initData is verified with the bot token before membership lookup.
|
||||
- Mini app access depends on an actual household membership match.
|
||||
- CORS can be limited via `MINI_APP_ALLOWED_ORIGINS`; if unset, the endpoint falls back to permissive origin reflection for deployment simplicity.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { and, desc, eq, gte, inArray, sql } from 'drizzle-orm'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
|
||||
import { createDbClient, schema } from '@household/db'
|
||||
import type { AnonymousFeedbackRepository } from '@household/ports'
|
||||
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
|
||||
@@ -38,23 +49,10 @@ export function createDbAnonymousFeedbackRepository(
|
||||
},
|
||||
|
||||
async getRateLimitSnapshot(memberId, acceptedSince) {
|
||||
const countRows = await db
|
||||
const rows = await db
|
||||
.select({
|
||||
count: sql<string>`count(*)`
|
||||
})
|
||||
.from(schema.anonymousMessages)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.anonymousMessages.householdId, householdId),
|
||||
eq(schema.anonymousMessages.submittedByMemberId, memberId),
|
||||
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES),
|
||||
gte(schema.anonymousMessages.createdAt, acceptedSince)
|
||||
)
|
||||
)
|
||||
|
||||
const lastRows = await db
|
||||
.select({
|
||||
createdAt: schema.anonymousMessages.createdAt
|
||||
acceptedCountSince: sql<string>`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSince})`,
|
||||
lastAcceptedAt: sql<Date | null>`max(${schema.anonymousMessages.createdAt})`
|
||||
})
|
||||
.from(schema.anonymousMessages)
|
||||
.where(
|
||||
@@ -64,12 +62,10 @@ export function createDbAnonymousFeedbackRepository(
|
||||
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.anonymousMessages.createdAt))
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
acceptedCountSince: Number(countRows[0]?.count ?? '0'),
|
||||
lastAcceptedAt: lastRows[0]?.createdAt ?? null
|
||||
acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'),
|
||||
lastAcceptedAt: rows[0]?.lastAcceptedAt ?? null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -99,11 +95,7 @@ export function createDbAnonymousFeedbackRepository(
|
||||
return {
|
||||
submission: {
|
||||
id: inserted[0].id,
|
||||
moderationStatus: inserted[0].moderationStatus as
|
||||
| 'accepted'
|
||||
| 'posted'
|
||||
| 'rejected'
|
||||
| 'failed'
|
||||
moderationStatus: parseModerationStatus(inserted[0].moderationStatus)
|
||||
},
|
||||
duplicate: false
|
||||
}
|
||||
@@ -131,7 +123,7 @@ export function createDbAnonymousFeedbackRepository(
|
||||
return {
|
||||
submission: {
|
||||
id: row.id,
|
||||
moderationStatus: row.moderationStatus as 'accepted' | 'posted' | 'rejected' | 'failed'
|
||||
moderationStatus: parseModerationStatus(row.moderationStatus)
|
||||
},
|
||||
duplicate: true
|
||||
}
|
||||
|
||||
@@ -299,48 +299,54 @@ export function createDbFinanceRepository(
|
||||
},
|
||||
|
||||
async replaceSettlementSnapshot(snapshot) {
|
||||
const upserted = await db
|
||||
.insert(schema.settlements)
|
||||
.values({
|
||||
householdId,
|
||||
cycleId: snapshot.cycleId,
|
||||
inputHash: snapshot.inputHash,
|
||||
totalDueMinor: snapshot.totalDueMinor,
|
||||
currency: snapshot.currency,
|
||||
metadata: snapshot.metadata
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.settlements.cycleId],
|
||||
set: {
|
||||
await db.transaction(async (tx) => {
|
||||
const upserted = await tx
|
||||
.insert(schema.settlements)
|
||||
.values({
|
||||
householdId,
|
||||
cycleId: snapshot.cycleId,
|
||||
inputHash: snapshot.inputHash,
|
||||
totalDueMinor: snapshot.totalDueMinor,
|
||||
currency: snapshot.currency,
|
||||
computedAt: new Date(),
|
||||
metadata: snapshot.metadata
|
||||
}
|
||||
})
|
||||
.returning({ id: schema.settlements.id })
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.settlements.cycleId],
|
||||
set: {
|
||||
inputHash: snapshot.inputHash,
|
||||
totalDueMinor: snapshot.totalDueMinor,
|
||||
currency: snapshot.currency,
|
||||
computedAt: new Date(),
|
||||
metadata: snapshot.metadata
|
||||
}
|
||||
})
|
||||
.returning({ id: schema.settlements.id })
|
||||
|
||||
const settlementId = upserted[0]?.id
|
||||
if (!settlementId) {
|
||||
throw new Error('Failed to persist settlement snapshot')
|
||||
}
|
||||
const settlementId = upserted[0]?.id
|
||||
if (!settlementId) {
|
||||
throw new Error('Failed to persist settlement snapshot')
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(schema.settlementLines)
|
||||
.where(eq(schema.settlementLines.settlementId, settlementId))
|
||||
await tx
|
||||
.delete(schema.settlementLines)
|
||||
.where(eq(schema.settlementLines.settlementId, settlementId))
|
||||
|
||||
await db.insert(schema.settlementLines).values(
|
||||
snapshot.lines.map((line) => ({
|
||||
settlementId,
|
||||
memberId: line.memberId,
|
||||
rentShareMinor: line.rentShareMinor,
|
||||
utilityShareMinor: line.utilityShareMinor,
|
||||
purchaseOffsetMinor: line.purchaseOffsetMinor,
|
||||
netDueMinor: line.netDueMinor,
|
||||
explanations: line.explanations
|
||||
}))
|
||||
)
|
||||
if (snapshot.lines.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await tx.insert(schema.settlementLines).values(
|
||||
snapshot.lines.map((line) => ({
|
||||
settlementId,
|
||||
memberId: line.memberId,
|
||||
rentShareMinor: line.rentShareMinor,
|
||||
utilityShareMinor: line.utilityShareMinor,
|
||||
purchaseOffsetMinor: line.purchaseOffsetMinor,
|
||||
netDueMinor: line.netDueMinor,
|
||||
explanations: line.explanations
|
||||
}))
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -232,11 +232,12 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
||||
|
||||
await repository.openCycle(period, currency)
|
||||
|
||||
return {
|
||||
id: '',
|
||||
period,
|
||||
currency
|
||||
const cycle = await repository.getCycleByPeriod(period)
|
||||
if (!cycle) {
|
||||
throw new Error(`Failed to load billing cycle for period ${period}`)
|
||||
}
|
||||
|
||||
return cycle
|
||||
},
|
||||
|
||||
async closeCycle(periodArg) {
|
||||
|
||||
@@ -44,6 +44,10 @@ describe('createReminderJobService', () => {
|
||||
|
||||
test('claims a dispatch once and returns the dedupe key', async () => {
|
||||
const repository = new ReminderDispatchRepositoryStub()
|
||||
repository.nextResult = {
|
||||
dedupeKey: '2026-03:rent-due',
|
||||
claimed: true
|
||||
}
|
||||
const service = createReminderJobService(repository)
|
||||
|
||||
const result = await service.handleJob({
|
||||
@@ -53,6 +57,7 @@ describe('createReminderJobService', () => {
|
||||
})
|
||||
|
||||
expect(result.status).toBe('claimed')
|
||||
expect(result.dedupeKey).toBe('2026-03:rent-due')
|
||||
expect(repository.lastClaim).toMatchObject({
|
||||
householdId: 'household-1',
|
||||
period: '2026-03',
|
||||
|
||||
@@ -11,6 +11,10 @@ function computePayloadHash(payload: object): string {
|
||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
|
||||
}
|
||||
|
||||
function buildReminderDedupeKey(period: string, reminderType: ReminderType): string {
|
||||
return `${period}:${reminderType}`
|
||||
}
|
||||
|
||||
function createReminderMessage(reminderType: ReminderType, period: string): string {
|
||||
switch (reminderType) {
|
||||
case 'utilities':
|
||||
@@ -56,7 +60,7 @@ export function createReminderJobService(
|
||||
if (input.dryRun === true) {
|
||||
return {
|
||||
status: 'dry-run',
|
||||
dedupeKey: `${period}:${input.reminderType}`,
|
||||
dedupeKey: buildReminderDedupeKey(period, input.reminderType),
|
||||
payloadHash,
|
||||
reminderType: input.reminderType,
|
||||
period,
|
||||
|
||||
@@ -15,7 +15,15 @@ function toUrl(base: string, path: string): URL {
|
||||
async function expectJson(url: URL, init: RequestInit, expectedStatus: number): Promise<any> {
|
||||
const response = await fetch(url, init)
|
||||
const text = await response.text()
|
||||
const payload = (text.length > 0 ? JSON.parse(text) : null) as unknown
|
||||
let payload: unknown = null
|
||||
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
payload = JSON.parse(text) as unknown
|
||||
} catch {
|
||||
throw new Error(`${url.toString()} returned invalid JSON: ${text}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== expectedStatus) {
|
||||
throw new Error(
|
||||
|
||||
@@ -9,11 +9,21 @@ function requireEnv(name: string): string {
|
||||
return value
|
||||
}
|
||||
|
||||
async function telegramRequest(
|
||||
function parseCommand(raw: string | undefined): WebhookCommand {
|
||||
const command = raw?.trim() || 'info'
|
||||
|
||||
if (command === 'info' || command === 'set' || command === 'delete') {
|
||||
return command
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported command: ${command}`)
|
||||
}
|
||||
|
||||
async function telegramRequest<T>(
|
||||
botToken: string,
|
||||
method: string,
|
||||
body?: URLSearchParams
|
||||
): Promise<any> {
|
||||
): Promise<T> {
|
||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
body
|
||||
@@ -27,11 +37,11 @@ async function telegramRequest(
|
||||
throw new Error(`Telegram ${method} failed: ${JSON.stringify(payload)}`)
|
||||
}
|
||||
|
||||
return payload.result
|
||||
return payload.result as T
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const command = (process.argv[2] ?? 'info') as WebhookCommand
|
||||
const command = parseCommand(process.argv[2])
|
||||
const botToken = requireEnv('TELEGRAM_BOT_TOKEN')
|
||||
|
||||
switch (command) {
|
||||
|
||||
Reference in New Issue
Block a user