mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 14:04:04 +00:00
feat(onboarding): add mini app household join flow
This commit is contained in:
@@ -114,7 +114,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
householdId !== undefined &&
|
householdId !== undefined &&
|
||||||
telegramHouseholdChatId !== undefined &&
|
telegramHouseholdChatId !== undefined &&
|
||||||
telegramFeedbackTopicId !== undefined
|
telegramFeedbackTopicId !== undefined
|
||||||
const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined
|
const miniAppAuthEnabled = databaseUrl !== undefined
|
||||||
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
||||||
const reminderJobsEnabled =
|
const reminderJobsEnabled =
|
||||||
databaseUrl !== undefined &&
|
databaseUrl !== undefined &&
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HouseholdSetupService } from '@household/application'
|
import type { HouseholdOnboardingService, HouseholdSetupService } from '@household/application'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
|
|
||||||
@@ -51,8 +51,72 @@ function bindRejectionMessage(
|
|||||||
export function registerHouseholdSetupCommands(options: {
|
export function registerHouseholdSetupCommands(options: {
|
||||||
bot: Bot
|
bot: Bot
|
||||||
householdSetupService: HouseholdSetupService
|
householdSetupService: HouseholdSetupService
|
||||||
|
householdOnboardingService: HouseholdOnboardingService
|
||||||
|
miniAppBaseUrl?: string
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): void {
|
}): void {
|
||||||
|
options.bot.command('start', async (ctx) => {
|
||||||
|
if (ctx.chat?.type !== 'private') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.from) {
|
||||||
|
await ctx.reply('Telegram user identity is required to join a household.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPayload = commandArgText(ctx)
|
||||||
|
if (!startPayload.startsWith('join_')) {
|
||||||
|
await ctx.reply('Send /help to see available commands.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinToken = startPayload.slice('join_'.length).trim()
|
||||||
|
if (!joinToken) {
|
||||||
|
await ctx.reply('Invalid household invite link.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = {
|
||||||
|
telegramUserId: ctx.from.id.toString(),
|
||||||
|
displayName:
|
||||||
|
[ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(' ').trim() ||
|
||||||
|
ctx.from.username ||
|
||||||
|
`Telegram ${ctx.from.id}`,
|
||||||
|
...(ctx.from.username
|
||||||
|
? {
|
||||||
|
username: ctx.from.username
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(ctx.from.language_code
|
||||||
|
? {
|
||||||
|
languageCode: ctx.from.language_code
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await options.householdOnboardingService.joinHousehold({
|
||||||
|
identity,
|
||||||
|
joinToken
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 'invalid_token') {
|
||||||
|
await ctx.reply('This household invite link is invalid or expired.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'active') {
|
||||||
|
await ctx.reply(
|
||||||
|
`You are already an active member. Open the mini app to view ${result.member.displayName}.`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`Join request sent for ${result.household.name}. Wait for a household admin to confirm you.`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
options.bot.command('setup', async (ctx) => {
|
options.bot.command('setup', async (ctx) => {
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
await ctx.reply('Use /setup inside the household group.')
|
await ctx.reply('Use /setup inside the household group.')
|
||||||
@@ -85,12 +149,64 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const action = result.status === 'created' ? 'created' : 'already registered'
|
const action = result.status === 'created' ? 'created' : 'already registered'
|
||||||
|
const joinToken = await options.householdOnboardingService.ensureHouseholdJoinToken({
|
||||||
|
householdId: result.household.householdId,
|
||||||
|
...(ctx.from?.id
|
||||||
|
? {
|
||||||
|
actorTelegramUserId: ctx.from.id.toString()
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
const joinDeepLink = ctx.me.username
|
||||||
|
? `https://t.me/${ctx.me.username}?start=join_${encodeURIComponent(joinToken.token)}`
|
||||||
|
: null
|
||||||
|
const joinMiniAppUrl = options.miniAppBaseUrl
|
||||||
|
? (() => {
|
||||||
|
const url = new URL(options.miniAppBaseUrl)
|
||||||
|
url.searchParams.set('join', joinToken.token)
|
||||||
|
if (ctx.me.username) {
|
||||||
|
url.searchParams.set('bot', ctx.me.username)
|
||||||
|
}
|
||||||
|
return url.toString()
|
||||||
|
})()
|
||||||
|
: null
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
[
|
[
|
||||||
`Household ${action}: ${result.household.householdName}`,
|
`Household ${action}: ${result.household.householdName}`,
|
||||||
`Chat ID: ${result.household.telegramChatId}`,
|
`Chat ID: ${result.household.telegramChatId}`,
|
||||||
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.'
|
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.',
|
||||||
].join('\n')
|
'Members can join from the button below or from the bot link.'
|
||||||
|
].join('\n'),
|
||||||
|
joinMiniAppUrl || joinDeepLink
|
||||||
|
? {
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
...(joinMiniAppUrl
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
text: 'Join household',
|
||||||
|
web_app: {
|
||||||
|
url: joinMiniAppUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(joinDeepLink
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
text: 'Open bot chat',
|
||||||
|
url: joinDeepLink
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { webhookCallback } from 'grammy'
|
|||||||
import {
|
import {
|
||||||
createAnonymousFeedbackService,
|
createAnonymousFeedbackService,
|
||||||
createFinanceCommandService,
|
createFinanceCommandService,
|
||||||
|
createHouseholdOnboardingService,
|
||||||
createHouseholdSetupService,
|
createHouseholdSetupService,
|
||||||
createReminderJobService
|
createReminderJobService
|
||||||
} from '@household/application'
|
} from '@household/application'
|
||||||
@@ -27,7 +28,7 @@ import {
|
|||||||
import { createReminderJobsHandler } from './reminder-jobs'
|
import { createReminderJobsHandler } from './reminder-jobs'
|
||||||
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
||||||
import { createBotWebhookServer } from './server'
|
import { createBotWebhookServer } from './server'
|
||||||
import { createMiniAppAuthHandler } from './miniapp-auth'
|
import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth'
|
||||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||||
|
|
||||||
const runtime = getBotRuntimeConfig()
|
const runtime = getBotRuntimeConfig()
|
||||||
@@ -51,6 +52,16 @@ const financeRepositoryClient =
|
|||||||
const financeService = financeRepositoryClient
|
const financeService = financeRepositoryClient
|
||||||
? createFinanceCommandService(financeRepositoryClient.repository)
|
? createFinanceCommandService(financeRepositoryClient.repository)
|
||||||
: null
|
: null
|
||||||
|
const householdOnboardingService = householdConfigurationRepositoryClient
|
||||||
|
? createHouseholdOnboardingService({
|
||||||
|
repository: householdConfigurationRepositoryClient.repository,
|
||||||
|
...(financeRepositoryClient
|
||||||
|
? {
|
||||||
|
getMemberByTelegramUserId: financeRepositoryClient.repository.getMemberByTelegramUserId
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
: null
|
||||||
const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled
|
const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled
|
||||||
? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!)
|
? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!)
|
||||||
: null
|
: null
|
||||||
@@ -118,6 +129,12 @@ if (householdConfigurationRepositoryClient) {
|
|||||||
householdSetupService: createHouseholdSetupService(
|
householdSetupService: createHouseholdSetupService(
|
||||||
householdConfigurationRepositoryClient.repository
|
householdConfigurationRepositoryClient.repository
|
||||||
),
|
),
|
||||||
|
householdOnboardingService: householdOnboardingService!,
|
||||||
|
...(runtime.miniAppAllowedOrigins[0]
|
||||||
|
? {
|
||||||
|
miniAppBaseUrl: runtime.miniAppAllowedOrigins[0]
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
logger: getLogger('household-setup')
|
logger: getLogger('household-setup')
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -177,11 +194,19 @@ const server = createBotWebhookServer({
|
|||||||
webhookPath: runtime.telegramWebhookPath,
|
webhookPath: runtime.telegramWebhookPath,
|
||||||
webhookSecret: runtime.telegramWebhookSecret,
|
webhookSecret: runtime.telegramWebhookSecret,
|
||||||
webhookHandler,
|
webhookHandler,
|
||||||
miniAppAuth: financeRepositoryClient
|
miniAppAuth: householdOnboardingService
|
||||||
? createMiniAppAuthHandler({
|
? createMiniAppAuthHandler({
|
||||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
botToken: runtime.telegramBotToken,
|
botToken: runtime.telegramBotToken,
|
||||||
repository: financeRepositoryClient.repository,
|
onboardingService: householdOnboardingService,
|
||||||
|
logger: getLogger('miniapp-auth')
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
miniAppJoin: householdOnboardingService
|
||||||
|
? createMiniAppJoinHandler({
|
||||||
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
botToken: runtime.telegramBotToken,
|
||||||
|
onboardingService: householdOnboardingService,
|
||||||
logger: getLogger('miniapp-auth')
|
logger: getLogger('miniapp-auth')
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -190,6 +215,7 @@ const server = createBotWebhookServer({
|
|||||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
botToken: runtime.telegramBotToken,
|
botToken: runtime.telegramBotToken,
|
||||||
financeService,
|
financeService,
|
||||||
|
onboardingService: householdOnboardingService!,
|
||||||
logger: getLogger('miniapp-dashboard')
|
logger: getLogger('miniapp-dashboard')
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -1,28 +1,77 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
import type { FinanceRepository } from '@household/ports'
|
import { createHouseholdOnboardingService } from '@household/application'
|
||||||
|
import type {
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
HouseholdTopicBindingRecord
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
import { createMiniAppAuthHandler } from './miniapp-auth'
|
import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth'
|
||||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||||
|
|
||||||
function repository(
|
function onboardingRepository(): HouseholdConfigurationRepository {
|
||||||
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
const household = {
|
||||||
): FinanceRepository {
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House'
|
||||||
|
}
|
||||||
|
let joinToken: string | null = 'join-token'
|
||||||
|
let pending: {
|
||||||
|
householdId: string
|
||||||
|
householdName: string
|
||||||
|
telegramUserId: string
|
||||||
|
displayName: string
|
||||||
|
username: string | null
|
||||||
|
languageCode: string | null
|
||||||
|
} | null = null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getMemberByTelegramUserId: async () => member,
|
registerTelegramHouseholdChat: async () => ({
|
||||||
listMembers: async () => [],
|
status: 'existing',
|
||||||
getOpenCycle: async () => null,
|
household
|
||||||
getCycleByPeriod: async () => null,
|
}),
|
||||||
getLatestCycle: async () => null,
|
getTelegramHouseholdChat: async () => household,
|
||||||
openCycle: async () => {},
|
bindHouseholdTopic: async (input) =>
|
||||||
closeCycle: async () => {},
|
({
|
||||||
saveRentRule: async () => {},
|
householdId: input.householdId,
|
||||||
addUtilityBill: async () => {},
|
role: input.role,
|
||||||
getRentRuleForPeriod: async () => null,
|
telegramThreadId: input.telegramThreadId,
|
||||||
getUtilityTotalForCycle: async () => 0n,
|
topicName: input.topicName?.trim() || null
|
||||||
listUtilityBillsForCycle: async () => [],
|
}) satisfies HouseholdTopicBindingRecord,
|
||||||
listParsedPurchasesForRange: async () => [],
|
getHouseholdTopicBinding: async () => null,
|
||||||
replaceSettlementSnapshot: async () => {}
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
upsertHouseholdJoinToken: async (input) => ({
|
||||||
|
householdId: household.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
token: input.token,
|
||||||
|
createdByTelegramUserId: input.createdByTelegramUserId ?? null
|
||||||
|
}),
|
||||||
|
getHouseholdJoinToken: async () =>
|
||||||
|
joinToken
|
||||||
|
? {
|
||||||
|
householdId: household.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
token: joinToken,
|
||||||
|
createdByTelegramUserId: null
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
getHouseholdByJoinToken: async (token) => (token === joinToken ? household : null),
|
||||||
|
upsertPendingHouseholdMember: async (input) => {
|
||||||
|
pending = {
|
||||||
|
householdId: household.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
telegramUserId: input.telegramUserId,
|
||||||
|
displayName: input.displayName,
|
||||||
|
username: input.username?.trim() || null,
|
||||||
|
languageCode: input.languageCode?.trim() || null
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
},
|
||||||
|
getPendingHouseholdMember: async () => pending,
|
||||||
|
findPendingHouseholdMemberByTelegramUserId: async () => pending
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,11 +81,14 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
const auth = createMiniAppAuthHandler({
|
const auth = createMiniAppAuthHandler({
|
||||||
allowedOrigins: ['http://localhost:5173'],
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
botToken: 'test-bot-token',
|
botToken: 'test-bot-token',
|
||||||
repository: repository({
|
onboardingService: createHouseholdOnboardingService({
|
||||||
id: 'member-1',
|
repository: onboardingRepository(),
|
||||||
telegramUserId: '123456',
|
getMemberByTelegramUserId: async () => ({
|
||||||
displayName: 'Stan',
|
id: 'member-1',
|
||||||
isAdmin: true
|
telegramUserId: '123456',
|
||||||
|
displayName: 'Stan',
|
||||||
|
isAdmin: true
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -67,10 +119,6 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
isAdmin: true
|
isAdmin: true
|
||||||
},
|
},
|
||||||
features: {
|
|
||||||
balances: true,
|
|
||||||
ledger: true
|
|
||||||
},
|
|
||||||
telegramUser: {
|
telegramUser: {
|
||||||
id: '123456',
|
id: '123456',
|
||||||
firstName: 'Stan',
|
firstName: 'Stan',
|
||||||
@@ -80,12 +128,14 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns membership gate failure for a non-member', async () => {
|
test('returns onboarding state for a non-member with a valid household token', async () => {
|
||||||
const authDate = Math.floor(Date.now() / 1000)
|
const authDate = Math.floor(Date.now() / 1000)
|
||||||
const auth = createMiniAppAuthHandler({
|
const auth = createMiniAppAuthHandler({
|
||||||
allowedOrigins: ['http://localhost:5173'],
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
botToken: 'test-bot-token',
|
botToken: 'test-bot-token',
|
||||||
repository: repository(null)
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository: onboardingRepository()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await auth.handler(
|
const response = await auth.handler(
|
||||||
@@ -99,16 +149,58 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||||
id: 123456,
|
id: 123456,
|
||||||
first_name: 'Stan'
|
first_name: 'Stan'
|
||||||
})
|
}),
|
||||||
|
joinToken: 'join-token'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.status).toBe(403)
|
expect(response.status).toBe(200)
|
||||||
expect(await response.json()).toEqual({
|
expect(await response.json()).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
authorized: false,
|
authorized: false,
|
||||||
reason: 'not_member'
|
onboarding: {
|
||||||
|
status: 'join_required',
|
||||||
|
householdName: 'Kojori House'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a pending join request from the mini app', async () => {
|
||||||
|
const authDate = Math.floor(Date.now() / 1000)
|
||||||
|
const join = createMiniAppJoinHandler({
|
||||||
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
|
botToken: 'test-bot-token',
|
||||||
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository: onboardingRepository()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await join.handler(
|
||||||
|
new Request('http://localhost/api/miniapp/join', {
|
||||||
|
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'
|
||||||
|
}),
|
||||||
|
joinToken: 'join-token'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
authorized: false,
|
||||||
|
onboarding: {
|
||||||
|
status: 'pending',
|
||||||
|
householdName: 'Kojori House'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,7 +208,9 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
const auth = createMiniAppAuthHandler({
|
const auth = createMiniAppAuthHandler({
|
||||||
allowedOrigins: ['http://localhost:5173'],
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
botToken: 'test-bot-token',
|
botToken: 'test-bot-token',
|
||||||
repository: repository(null)
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository: onboardingRepository()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await auth.handler(
|
const response = await auth.handler(
|
||||||
@@ -136,48 +230,4 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
error: 'Invalid JSON body'
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { FinanceMemberRecord, FinanceRepository } from '@household/ports'
|
import type { HouseholdOnboardingService } from '@household/application'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
|
|
||||||
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
||||||
|
|
||||||
|
export interface MiniAppRequestPayload {
|
||||||
|
initData: string | null
|
||||||
|
joinToken?: string
|
||||||
|
}
|
||||||
|
|
||||||
export function miniAppJsonResponse(body: object, status = 200, origin?: string): Response {
|
export function miniAppJsonResponse(body: object, status = 200, origin?: string): Response {
|
||||||
const headers = new Headers({
|
const headers = new Headers({
|
||||||
'content-type': 'application/json; charset=utf-8'
|
'content-type': 'application/json; charset=utf-8'
|
||||||
@@ -42,23 +47,33 @@ export function allowedMiniAppOrigin(
|
|||||||
return allowedOrigins.includes(origin) ? origin : undefined
|
return allowedOrigins.includes(origin) ? origin : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readMiniAppInitData(request: Request): Promise<string | null> {
|
export async function readMiniAppRequestPayload(request: Request): Promise<MiniAppRequestPayload> {
|
||||||
const text = await request.text()
|
const text = await request.text()
|
||||||
|
|
||||||
if (text.trim().length === 0) {
|
if (text.trim().length === 0) {
|
||||||
return null
|
return {
|
||||||
|
initData: null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: { initData?: string }
|
let parsed: { initData?: string; joinToken?: string }
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(text) as { initData?: string }
|
parsed = JSON.parse(text) as { initData?: string; joinToken?: string }
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Invalid JSON body')
|
throw new Error('Invalid JSON body')
|
||||||
}
|
}
|
||||||
|
|
||||||
const initData = parsed.initData?.trim()
|
const initData = parsed.initData?.trim()
|
||||||
|
const joinToken = parsed.joinToken?.trim()
|
||||||
|
|
||||||
return initData && initData.length > 0 ? initData : null
|
return {
|
||||||
|
initData: initData && initData.length > 0 ? initData : null,
|
||||||
|
...(joinToken && joinToken.length > 0
|
||||||
|
? {
|
||||||
|
joinToken
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function miniAppErrorResponse(error: unknown, origin?: string, logger?: Logger): Response {
|
export function miniAppErrorResponse(error: unknown, origin?: string, logger?: Logger): Response {
|
||||||
@@ -81,46 +96,83 @@ export function miniAppErrorResponse(error: unknown, origin?: string, logger?: L
|
|||||||
|
|
||||||
export interface MiniAppSessionResult {
|
export interface MiniAppSessionResult {
|
||||||
authorized: boolean
|
authorized: boolean
|
||||||
reason?: 'not_member'
|
|
||||||
member?: {
|
member?: {
|
||||||
id: string
|
id: string
|
||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
telegramUser?: ReturnType<typeof verifyTelegramMiniAppInitData>
|
telegramUser?: ReturnType<typeof verifyTelegramMiniAppInitData>
|
||||||
|
onboarding?: {
|
||||||
|
status: 'join_required' | 'pending' | 'open_from_group'
|
||||||
|
householdName?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MiniAppMemberLookup = (telegramUserId: string) => Promise<FinanceMemberRecord | null>
|
|
||||||
|
|
||||||
export function createMiniAppSessionService(options: {
|
export function createMiniAppSessionService(options: {
|
||||||
botToken: string
|
botToken: string
|
||||||
getMemberByTelegramUserId: MiniAppMemberLookup
|
onboardingService: HouseholdOnboardingService
|
||||||
}): {
|
}): {
|
||||||
authenticate: (initData: string) => Promise<MiniAppSessionResult | null>
|
authenticate: (payload: MiniAppRequestPayload) => Promise<MiniAppSessionResult | null>
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
authenticate: async (initData) => {
|
authenticate: async (payload) => {
|
||||||
const telegramUser = verifyTelegramMiniAppInitData(initData, options.botToken)
|
if (!payload.initData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramUser = verifyTelegramMiniAppInitData(payload.initData, options.botToken)
|
||||||
if (!telegramUser) {
|
if (!telegramUser) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await options.getMemberByTelegramUserId(telegramUser.id)
|
const access = await options.onboardingService.getMiniAppAccess({
|
||||||
if (!member) {
|
identity: {
|
||||||
return {
|
telegramUserId: telegramUser.id,
|
||||||
authorized: false,
|
displayName:
|
||||||
reason: 'not_member'
|
telegramUser.firstName ?? telegramUser.username ?? `Telegram ${telegramUser.id}`,
|
||||||
}
|
username: telegramUser.username,
|
||||||
}
|
languageCode: telegramUser.languageCode
|
||||||
|
|
||||||
return {
|
|
||||||
authorized: true,
|
|
||||||
member: {
|
|
||||||
id: member.id,
|
|
||||||
displayName: member.displayName,
|
|
||||||
isAdmin: member.isAdmin
|
|
||||||
},
|
},
|
||||||
telegramUser
|
...(payload.joinToken
|
||||||
|
? {
|
||||||
|
joinToken: payload.joinToken
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (access.status) {
|
||||||
|
case 'active':
|
||||||
|
return {
|
||||||
|
authorized: true,
|
||||||
|
member: access.member,
|
||||||
|
telegramUser
|
||||||
|
}
|
||||||
|
case 'pending':
|
||||||
|
return {
|
||||||
|
authorized: false,
|
||||||
|
telegramUser,
|
||||||
|
onboarding: {
|
||||||
|
status: 'pending',
|
||||||
|
householdName: access.household.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'join_required':
|
||||||
|
return {
|
||||||
|
authorized: false,
|
||||||
|
telegramUser,
|
||||||
|
onboarding: {
|
||||||
|
status: 'join_required',
|
||||||
|
householdName: access.household.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'open_from_group':
|
||||||
|
return {
|
||||||
|
authorized: false,
|
||||||
|
telegramUser,
|
||||||
|
onboarding: {
|
||||||
|
status: 'open_from_group'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,14 +181,14 @@ export function createMiniAppSessionService(options: {
|
|||||||
export function createMiniAppAuthHandler(options: {
|
export function createMiniAppAuthHandler(options: {
|
||||||
allowedOrigins: readonly string[]
|
allowedOrigins: readonly string[]
|
||||||
botToken: string
|
botToken: string
|
||||||
repository: FinanceRepository
|
onboardingService: HouseholdOnboardingService
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): {
|
}): {
|
||||||
handler: (request: Request) => Promise<Response>
|
handler: (request: Request) => Promise<Response>
|
||||||
} {
|
} {
|
||||||
const sessionService = createMiniAppSessionService({
|
const sessionService = createMiniAppSessionService({
|
||||||
botToken: options.botToken,
|
botToken: options.botToken,
|
||||||
getMemberByTelegramUserId: options.repository.getMemberByTelegramUserId
|
onboardingService: options.onboardingService
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -152,12 +204,12 @@ export function createMiniAppAuthHandler(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const initData = await readMiniAppInitData(request)
|
const payload = await readMiniAppRequestPayload(request)
|
||||||
if (!initData) {
|
if (!payload.initData) {
|
||||||
return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin)
|
return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await sessionService.authenticate(initData)
|
const session = await sessionService.authenticate(payload)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return miniAppJsonResponse(
|
return miniAppJsonResponse(
|
||||||
{ ok: false, error: 'Invalid Telegram init data' },
|
{ ok: false, error: 'Invalid Telegram init data' },
|
||||||
@@ -171,9 +223,10 @@ export function createMiniAppAuthHandler(options: {
|
|||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
authorized: false,
|
authorized: false,
|
||||||
reason: 'not_member'
|
onboarding: session.onboarding,
|
||||||
|
telegramUser: session.telegramUser
|
||||||
},
|
},
|
||||||
403,
|
200,
|
||||||
origin
|
origin
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -198,3 +251,98 @@ export function createMiniAppAuthHandler(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createMiniAppJoinHandler(options: {
|
||||||
|
allowedOrigins: readonly string[]
|
||||||
|
botToken: string
|
||||||
|
onboardingService: HouseholdOnboardingService
|
||||||
|
logger?: Logger
|
||||||
|
}): {
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
} {
|
||||||
|
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 payload = await readMiniAppRequestPayload(request)
|
||||||
|
if (!payload.initData) {
|
||||||
|
return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.joinToken) {
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{ ok: false, error: 'Missing household join token' },
|
||||||
|
400,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramUser = verifyTelegramMiniAppInitData(payload.initData, options.botToken)
|
||||||
|
if (!telegramUser) {
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{ ok: false, error: 'Invalid Telegram init data' },
|
||||||
|
401,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await options.onboardingService.joinHousehold({
|
||||||
|
identity: {
|
||||||
|
telegramUserId: telegramUser.id,
|
||||||
|
displayName:
|
||||||
|
telegramUser.firstName ?? telegramUser.username ?? `Telegram ${telegramUser.id}`,
|
||||||
|
username: telegramUser.username,
|
||||||
|
languageCode: telegramUser.languageCode
|
||||||
|
},
|
||||||
|
joinToken: payload.joinToken
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 'invalid_token') {
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{ ok: false, error: 'Invalid household join token' },
|
||||||
|
404,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'active') {
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
member: result.member,
|
||||||
|
telegramUser
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
authorized: false,
|
||||||
|
onboarding: {
|
||||||
|
status: 'pending',
|
||||||
|
householdName: result.household.name
|
||||||
|
},
|
||||||
|
telegramUser
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
return miniAppErrorResponse(error, origin, options.logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
import { createFinanceCommandService } from '@household/application'
|
import {
|
||||||
import type { FinanceRepository } from '@household/ports'
|
createFinanceCommandService,
|
||||||
|
createHouseholdOnboardingService
|
||||||
|
} from '@household/application'
|
||||||
|
import type {
|
||||||
|
FinanceRepository,
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
HouseholdTopicBindingRecord
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||||
@@ -62,6 +69,52 @@ function repository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onboardingRepository(): HouseholdConfigurationRepository {
|
||||||
|
const household = {
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
registerTelegramHouseholdChat: async () => ({
|
||||||
|
status: 'existing',
|
||||||
|
household
|
||||||
|
}),
|
||||||
|
getTelegramHouseholdChat: async () => household,
|
||||||
|
bindHouseholdTopic: async (input) =>
|
||||||
|
({
|
||||||
|
householdId: input.householdId,
|
||||||
|
role: input.role,
|
||||||
|
telegramThreadId: input.telegramThreadId,
|
||||||
|
topicName: input.topicName?.trim() || null
|
||||||
|
}) satisfies HouseholdTopicBindingRecord,
|
||||||
|
getHouseholdTopicBinding: async () => null,
|
||||||
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
upsertHouseholdJoinToken: async (input) => ({
|
||||||
|
householdId: household.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
token: input.token,
|
||||||
|
createdByTelegramUserId: input.createdByTelegramUserId ?? null
|
||||||
|
}),
|
||||||
|
getHouseholdJoinToken: async () => null,
|
||||||
|
getHouseholdByJoinToken: async () => null,
|
||||||
|
upsertPendingHouseholdMember: async (input) => ({
|
||||||
|
householdId: household.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
telegramUserId: input.telegramUserId,
|
||||||
|
displayName: input.displayName,
|
||||||
|
username: input.username?.trim() || null,
|
||||||
|
languageCode: input.languageCode?.trim() || null
|
||||||
|
}),
|
||||||
|
getPendingHouseholdMember: async () => null,
|
||||||
|
findPendingHouseholdMemberByTelegramUserId: async () => null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('createMiniAppDashboardHandler', () => {
|
describe('createMiniAppDashboardHandler', () => {
|
||||||
test('returns a dashboard for an authenticated household member', async () => {
|
test('returns a dashboard for an authenticated household member', async () => {
|
||||||
const authDate = Math.floor(Date.now() / 1000)
|
const authDate = Math.floor(Date.now() / 1000)
|
||||||
@@ -77,7 +130,11 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
const dashboard = createMiniAppDashboardHandler({
|
const dashboard = createMiniAppDashboardHandler({
|
||||||
allowedOrigins: ['http://localhost:5173'],
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
botToken: 'test-bot-token',
|
botToken: 'test-bot-token',
|
||||||
financeService
|
financeService,
|
||||||
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository: onboardingRepository(),
|
||||||
|
getMemberByTelegramUserId: financeService.getMemberByTelegramUserId
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await dashboard.handler(
|
const response = await dashboard.handler(
|
||||||
@@ -140,7 +197,11 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
const dashboard = createMiniAppDashboardHandler({
|
const dashboard = createMiniAppDashboardHandler({
|
||||||
allowedOrigins: ['http://localhost:5173'],
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
botToken: 'test-bot-token',
|
botToken: 'test-bot-token',
|
||||||
financeService
|
financeService,
|
||||||
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository: onboardingRepository(),
|
||||||
|
getMemberByTelegramUserId: financeService.getMemberByTelegramUserId
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await dashboard.handler(
|
const response = await dashboard.handler(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FinanceCommandService } from '@household/application'
|
import type { FinanceCommandService, HouseholdOnboardingService } from '@household/application'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -6,20 +6,21 @@ import {
|
|||||||
createMiniAppSessionService,
|
createMiniAppSessionService,
|
||||||
miniAppErrorResponse,
|
miniAppErrorResponse,
|
||||||
miniAppJsonResponse,
|
miniAppJsonResponse,
|
||||||
readMiniAppInitData
|
readMiniAppRequestPayload
|
||||||
} from './miniapp-auth'
|
} from './miniapp-auth'
|
||||||
|
|
||||||
export function createMiniAppDashboardHandler(options: {
|
export function createMiniAppDashboardHandler(options: {
|
||||||
allowedOrigins: readonly string[]
|
allowedOrigins: readonly string[]
|
||||||
botToken: string
|
botToken: string
|
||||||
financeService: FinanceCommandService
|
financeService: FinanceCommandService
|
||||||
|
onboardingService: HouseholdOnboardingService
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): {
|
}): {
|
||||||
handler: (request: Request) => Promise<Response>
|
handler: (request: Request) => Promise<Response>
|
||||||
} {
|
} {
|
||||||
const sessionService = createMiniAppSessionService({
|
const sessionService = createMiniAppSessionService({
|
||||||
botToken: options.botToken,
|
botToken: options.botToken,
|
||||||
getMemberByTelegramUserId: options.financeService.getMemberByTelegramUserId
|
onboardingService: options.onboardingService
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -35,12 +36,12 @@ export function createMiniAppDashboardHandler(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const initData = await readMiniAppInitData(request)
|
const payload = await readMiniAppRequestPayload(request)
|
||||||
if (!initData) {
|
if (!payload.initData) {
|
||||||
return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin)
|
return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await sessionService.authenticate(initData)
|
const session = await sessionService.authenticate(payload)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return miniAppJsonResponse(
|
return miniAppJsonResponse(
|
||||||
{ ok: false, error: 'Invalid Telegram init data' },
|
{ ok: false, error: 'Invalid Telegram init data' },
|
||||||
@@ -54,7 +55,7 @@ export function createMiniAppDashboardHandler(options: {
|
|||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
authorized: false,
|
authorized: false,
|
||||||
reason: 'not_member'
|
onboarding: session.onboarding
|
||||||
},
|
},
|
||||||
403,
|
403,
|
||||||
origin
|
origin
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export interface BotWebhookServerOptions {
|
|||||||
handler: (request: Request) => Promise<Response>
|
handler: (request: Request) => Promise<Response>
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
|
miniAppJoin?:
|
||||||
|
| {
|
||||||
|
path?: string
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
scheduler?:
|
scheduler?:
|
||||||
| {
|
| {
|
||||||
pathPrefix?: string
|
pathPrefix?: string
|
||||||
@@ -46,6 +52,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
: `/${options.webhookPath}`
|
: `/${options.webhookPath}`
|
||||||
const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session'
|
const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session'
|
||||||
const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard'
|
const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard'
|
||||||
|
const miniAppJoinPath = options.miniAppJoin?.path ?? '/api/miniapp/join'
|
||||||
const schedulerPathPrefix = options.scheduler
|
const schedulerPathPrefix = options.scheduler
|
||||||
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
||||||
: null
|
: null
|
||||||
@@ -66,6 +73,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
return await options.miniAppDashboard.handler(request)
|
return await options.miniAppDashboard.handler(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.miniAppJoin && url.pathname === miniAppJoinPath) {
|
||||||
|
return await options.miniAppJoin.handler(request)
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname !== normalizedWebhookPath) {
|
if (url.pathname !== normalizedWebhookPath) {
|
||||||
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
|
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
|
||||||
if (request.method !== 'POST') {
|
if (request.method !== 'POST') {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js'
|
import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js'
|
||||||
|
|
||||||
import { dictionary, type Locale } from './i18n'
|
import { dictionary, type Locale } from './i18n'
|
||||||
import { fetchMiniAppDashboard, fetchMiniAppSession, type MiniAppDashboard } from './miniapp-api'
|
import {
|
||||||
|
fetchMiniAppDashboard,
|
||||||
|
fetchMiniAppSession,
|
||||||
|
joinMiniAppHousehold,
|
||||||
|
type MiniAppDashboard
|
||||||
|
} from './miniapp-api'
|
||||||
import { getTelegramWebApp } from './telegram-webapp'
|
import { getTelegramWebApp } from './telegram-webapp'
|
||||||
|
|
||||||
type SessionState =
|
type SessionState =
|
||||||
@@ -10,7 +15,17 @@ type SessionState =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
status: 'blocked'
|
status: 'blocked'
|
||||||
reason: 'not_member' | 'telegram_only' | 'error'
|
reason: 'telegram_only' | 'error'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'onboarding'
|
||||||
|
mode: 'join_required' | 'pending' | 'open_from_group'
|
||||||
|
householdName?: string
|
||||||
|
telegramUser: {
|
||||||
|
firstName: string | null
|
||||||
|
username: string | null
|
||||||
|
languageCode: string | null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
status: 'ready'
|
status: 'ready'
|
||||||
@@ -49,6 +64,41 @@ function detectLocale(): Locale {
|
|||||||
return (telegramLocale ?? browserLocale).startsWith('ru') ? 'ru' : 'en'
|
return (telegramLocale ?? browserLocale).startsWith('ru') ? 'ru' : 'en'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function joinContext(): {
|
||||||
|
joinToken?: string
|
||||||
|
botUsername?: string
|
||||||
|
} {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const joinToken = params.get('join')?.trim()
|
||||||
|
const botUsername = params.get('bot')?.trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(joinToken
|
||||||
|
? {
|
||||||
|
joinToken
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(botUsername
|
||||||
|
? {
|
||||||
|
botUsername
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinDeepLink(): string | null {
|
||||||
|
const context = joinContext()
|
||||||
|
if (!context.botUsername || !context.joinToken) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://t.me/${context.botUsername}?start=join_${encodeURIComponent(context.joinToken)}`
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [locale, setLocale] = createSignal<Locale>('en')
|
const [locale, setLocale] = createSignal<Locale>('en')
|
||||||
const [session, setSession] = createSignal<SessionState>({
|
const [session, setSession] = createSignal<SessionState>({
|
||||||
@@ -56,8 +106,13 @@ function App() {
|
|||||||
})
|
})
|
||||||
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
|
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
|
||||||
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
|
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
|
||||||
|
const [joining, setJoining] = createSignal(false)
|
||||||
|
|
||||||
const copy = createMemo(() => dictionary[locale()])
|
const copy = createMemo(() => dictionary[locale()])
|
||||||
|
const onboardingSession = createMemo(() => {
|
||||||
|
const current = session()
|
||||||
|
return current.status === 'onboarding' ? current : null
|
||||||
|
})
|
||||||
const blockedSession = createMemo(() => {
|
const blockedSession = createMemo(() => {
|
||||||
const current = session()
|
const current = session()
|
||||||
return current.status === 'blocked' ? current : null
|
return current.status === 'blocked' ? current : null
|
||||||
@@ -68,7 +123,19 @@ function App() {
|
|||||||
})
|
})
|
||||||
const webApp = getTelegramWebApp()
|
const webApp = getTelegramWebApp()
|
||||||
|
|
||||||
onMount(async () => {
|
async function loadDashboard(initData: string) {
|
||||||
|
try {
|
||||||
|
setDashboard(await fetchMiniAppDashboard(initData))
|
||||||
|
} catch (error) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('Failed to load mini app dashboard', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDashboard(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
setLocale(detectLocale())
|
setLocale(detectLocale())
|
||||||
|
|
||||||
webApp?.ready?.()
|
webApp?.ready?.()
|
||||||
@@ -89,11 +156,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await fetchMiniAppSession(initData)
|
const payload = await fetchMiniAppSession(initData, joinContext().joinToken)
|
||||||
if (!payload.authorized || !payload.member || !payload.telegramUser) {
|
if (!payload.authorized || !payload.member || !payload.telegramUser) {
|
||||||
setSession({
|
setSession({
|
||||||
status: 'blocked',
|
status: 'onboarding',
|
||||||
reason: payload.reason === 'not_member' ? 'not_member' : 'error'
|
mode: payload.onboarding?.status ?? 'open_from_group',
|
||||||
|
...(payload.onboarding?.householdName
|
||||||
|
? {
|
||||||
|
householdName: payload.onboarding.householdName
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
telegramUser: payload.telegramUser ?? {
|
||||||
|
firstName: null,
|
||||||
|
username: null,
|
||||||
|
languageCode: null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -105,15 +182,7 @@ function App() {
|
|||||||
telegramUser: payload.telegramUser
|
telegramUser: payload.telegramUser
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
await loadDashboard(initData)
|
||||||
setDashboard(await fetchMiniAppDashboard(initData))
|
|
||||||
} catch (error) {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.warn('Failed to load mini app dashboard', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
setDashboard(null)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
setSession(demoSession)
|
setSession(demoSession)
|
||||||
@@ -168,8 +237,59 @@ function App() {
|
|||||||
reason: 'error'
|
reason: 'error'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void bootstrap()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function handleJoinHousehold() {
|
||||||
|
const initData = webApp?.initData?.trim()
|
||||||
|
const joinToken = joinContext().joinToken
|
||||||
|
|
||||||
|
if (!initData || !joinToken || joining()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setJoining(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await joinMiniAppHousehold(initData, joinToken)
|
||||||
|
if (payload.authorized && payload.member && payload.telegramUser) {
|
||||||
|
setSession({
|
||||||
|
status: 'ready',
|
||||||
|
mode: 'live',
|
||||||
|
member: payload.member,
|
||||||
|
telegramUser: payload.telegramUser
|
||||||
|
})
|
||||||
|
await loadDashboard(initData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession({
|
||||||
|
status: 'onboarding',
|
||||||
|
mode: payload.onboarding?.status ?? 'pending',
|
||||||
|
...(payload.onboarding?.householdName
|
||||||
|
? {
|
||||||
|
householdName: payload.onboarding.householdName
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
telegramUser: payload.telegramUser ?? {
|
||||||
|
firstName: null,
|
||||||
|
username: null,
|
||||||
|
languageCode: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setSession({
|
||||||
|
status: 'blocked',
|
||||||
|
reason: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setJoining(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const renderPanel = () => {
|
const renderPanel = () => {
|
||||||
switch (activeNav()) {
|
switch (activeNav()) {
|
||||||
case 'balances':
|
case 'balances':
|
||||||
@@ -291,12 +411,12 @@ function App() {
|
|||||||
<h2>
|
<h2>
|
||||||
{blockedSession()?.reason === 'telegram_only'
|
{blockedSession()?.reason === 'telegram_only'
|
||||||
? copy().telegramOnlyTitle
|
? copy().telegramOnlyTitle
|
||||||
: copy().unauthorizedTitle}
|
: copy().unexpectedErrorTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
{blockedSession()?.reason === 'telegram_only'
|
{blockedSession()?.reason === 'telegram_only'
|
||||||
? copy().telegramOnlyBody
|
? copy().telegramOnlyBody
|
||||||
: copy().unauthorizedBody}
|
: copy().unexpectedErrorBody}
|
||||||
</p>
|
</p>
|
||||||
<button class="ghost-button" type="button" onClick={() => window.location.reload()}>
|
<button class="ghost-button" type="button" onClick={() => window.location.reload()}>
|
||||||
{copy().reload}
|
{copy().reload}
|
||||||
@@ -304,6 +424,57 @@ function App() {
|
|||||||
</section>
|
</section>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
|
<Match when={session().status === 'onboarding'}>
|
||||||
|
<section class="hero-card">
|
||||||
|
<span class="pill">{copy().navHint}</span>
|
||||||
|
<h2>
|
||||||
|
{onboardingSession()?.mode === 'pending'
|
||||||
|
? copy().pendingTitle
|
||||||
|
: onboardingSession()?.mode === 'open_from_group'
|
||||||
|
? copy().openFromGroupTitle
|
||||||
|
: copy().joinTitle}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
{onboardingSession()?.mode === 'pending'
|
||||||
|
? copy().pendingBody.replace(
|
||||||
|
'{household}',
|
||||||
|
onboardingSession()?.householdName ?? copy().householdFallback
|
||||||
|
)
|
||||||
|
: onboardingSession()?.mode === 'open_from_group'
|
||||||
|
? copy().openFromGroupBody
|
||||||
|
: copy().joinBody.replace(
|
||||||
|
'{household}',
|
||||||
|
onboardingSession()?.householdName ?? copy().householdFallback
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div class="nav-grid">
|
||||||
|
{onboardingSession()?.mode === 'join_required' ? (
|
||||||
|
<button
|
||||||
|
class="ghost-button"
|
||||||
|
type="button"
|
||||||
|
disabled={joining()}
|
||||||
|
onClick={handleJoinHousehold}
|
||||||
|
>
|
||||||
|
{joining() ? copy().joining : copy().joinAction}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{joinDeepLink() ? (
|
||||||
|
<a
|
||||||
|
class="ghost-button"
|
||||||
|
href={joinDeepLink() ?? '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{copy().botLinkAction}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<button class="ghost-button" type="button" onClick={() => window.location.reload()}>
|
||||||
|
{copy().reload}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Match>
|
||||||
|
|
||||||
<Match when={session().status === 'ready'}>
|
<Match when={session().status === 'ready'}>
|
||||||
<section class="hero-card">
|
<section class="hero-card">
|
||||||
<div class="hero-card__meta">
|
<div class="hero-card__meta">
|
||||||
|
|||||||
@@ -7,9 +7,21 @@ export const dictionary = {
|
|||||||
loadingTitle: 'Checking your household access',
|
loadingTitle: 'Checking your household access',
|
||||||
loadingBody: 'Validating Telegram session and membership…',
|
loadingBody: 'Validating Telegram session and membership…',
|
||||||
demoBadge: 'Demo mode',
|
demoBadge: 'Demo mode',
|
||||||
unauthorizedTitle: 'Access is limited to active household members',
|
joinTitle: 'Welcome to your household',
|
||||||
unauthorizedBody:
|
joinBody:
|
||||||
'Open the mini app from Telegram after the bot admin adds you to the household.',
|
'You are not a member of {household} yet. Send a join request and wait for admin approval.',
|
||||||
|
pendingTitle: 'Join request sent',
|
||||||
|
pendingBody: 'Your request for {household} is pending admin approval.',
|
||||||
|
openFromGroupTitle: 'Open this from your household group',
|
||||||
|
openFromGroupBody:
|
||||||
|
'Use the join button from the household group setup message so the app knows which household you want to join.',
|
||||||
|
unexpectedErrorTitle: 'Unable to load the household app',
|
||||||
|
unexpectedErrorBody:
|
||||||
|
'Retry in Telegram. If this keeps failing, ask the household admin to resend the join button.',
|
||||||
|
householdFallback: 'this household',
|
||||||
|
joinAction: 'Join household',
|
||||||
|
joining: 'Sending request…',
|
||||||
|
botLinkAction: 'Open bot chat',
|
||||||
telegramOnlyTitle: 'Open this app from Telegram',
|
telegramOnlyTitle: 'Open this app from Telegram',
|
||||||
telegramOnlyBody:
|
telegramOnlyBody:
|
||||||
'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.',
|
'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.',
|
||||||
@@ -51,9 +63,21 @@ export const dictionary = {
|
|||||||
loadingTitle: 'Проверяем доступ к дому',
|
loadingTitle: 'Проверяем доступ к дому',
|
||||||
loadingBody: 'Проверяем Telegram-сессию и членство…',
|
loadingBody: 'Проверяем Telegram-сессию и членство…',
|
||||||
demoBadge: 'Демо режим',
|
demoBadge: 'Демо режим',
|
||||||
unauthorizedTitle: 'Доступ открыт только для активных участников дома',
|
joinTitle: 'Добро пожаловать домой',
|
||||||
unauthorizedBody:
|
joinBody:
|
||||||
'Открой мини-апп из Telegram после того, как админ бота добавит тебя в household.',
|
'Ты пока не участник {household}. Отправь заявку на вступление и дождись подтверждения админа.',
|
||||||
|
pendingTitle: 'Заявка отправлена',
|
||||||
|
pendingBody: 'Твоя заявка в {household} ждёт подтверждения админа.',
|
||||||
|
openFromGroupTitle: 'Открой приложение из группового чата',
|
||||||
|
openFromGroupBody:
|
||||||
|
'Используй кнопку подключения из сообщения настройки household, чтобы приложение поняло, к какому дому ты хочешь присоединиться.',
|
||||||
|
unexpectedErrorTitle: 'Не удалось открыть household app',
|
||||||
|
unexpectedErrorBody:
|
||||||
|
'Попробуй снова из Telegram. Если ошибка повторяется, попроси админа ещё раз прислать кнопку подключения.',
|
||||||
|
householdFallback: 'этот household',
|
||||||
|
joinAction: 'Вступить в household',
|
||||||
|
joining: 'Отправляем заявку…',
|
||||||
|
botLinkAction: 'Открыть чат с ботом',
|
||||||
telegramOnlyTitle: 'Открой приложение из Telegram',
|
telegramOnlyTitle: 'Открой приложение из Telegram',
|
||||||
telegramOnlyBody:
|
telegramOnlyBody:
|
||||||
'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.',
|
'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.',
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export interface MiniAppSession {
|
|||||||
username: string | null
|
username: string | null
|
||||||
languageCode: string | null
|
languageCode: string | null
|
||||||
}
|
}
|
||||||
reason?: string
|
onboarding?: {
|
||||||
|
status: 'join_required' | 'pending' | 'open_from_group'
|
||||||
|
householdName?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MiniAppDashboard {
|
export interface MiniAppDashboard {
|
||||||
@@ -56,14 +59,22 @@ function apiBaseUrl(): string {
|
|||||||
return window.location.origin
|
return window.location.origin
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMiniAppSession(initData: string): Promise<MiniAppSession> {
|
export async function fetchMiniAppSession(
|
||||||
|
initData: string,
|
||||||
|
joinToken?: string
|
||||||
|
): Promise<MiniAppSession> {
|
||||||
const response = await fetch(`${apiBaseUrl()}/api/miniapp/session`, {
|
const response = await fetch(`${apiBaseUrl()}/api/miniapp/session`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json'
|
'content-type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
initData
|
initData,
|
||||||
|
...(joinToken
|
||||||
|
? {
|
||||||
|
joinToken
|
||||||
|
}
|
||||||
|
: {})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,7 +83,7 @@ export async function fetchMiniAppSession(initData: string): Promise<MiniAppSess
|
|||||||
authorized?: boolean
|
authorized?: boolean
|
||||||
member?: MiniAppSession['member']
|
member?: MiniAppSession['member']
|
||||||
telegramUser?: MiniAppSession['telegramUser']
|
telegramUser?: MiniAppSession['telegramUser']
|
||||||
reason?: string
|
onboarding?: MiniAppSession['onboarding']
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +95,43 @@ export async function fetchMiniAppSession(initData: string): Promise<MiniAppSess
|
|||||||
authorized: payload.authorized === true,
|
authorized: payload.authorized === true,
|
||||||
...(payload.member ? { member: payload.member } : {}),
|
...(payload.member ? { member: payload.member } : {}),
|
||||||
...(payload.telegramUser ? { telegramUser: payload.telegramUser } : {}),
|
...(payload.telegramUser ? { telegramUser: payload.telegramUser } : {}),
|
||||||
...(payload.reason ? { reason: payload.reason } : {})
|
...(payload.onboarding ? { onboarding: payload.onboarding } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinMiniAppHousehold(
|
||||||
|
initData: string,
|
||||||
|
joinToken: string
|
||||||
|
): Promise<MiniAppSession> {
|
||||||
|
const response = await fetch(`${apiBaseUrl()}/api/miniapp/join`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData,
|
||||||
|
joinToken
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
ok: boolean
|
||||||
|
authorized?: boolean
|
||||||
|
member?: MiniAppSession['member']
|
||||||
|
telegramUser?: MiniAppSession['telegramUser']
|
||||||
|
onboarding?: MiniAppSession['onboarding']
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error ?? 'Failed to join household')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorized: payload.authorized === true,
|
||||||
|
...(payload.member ? { member: payload.member } : {}),
|
||||||
|
...(payload.telegramUser ? { telegramUser: payload.telegramUser } : {}),
|
||||||
|
...(payload.onboarding ? { onboarding: payload.onboarding } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { createDbClient, schema } from '@household/db'
|
|||||||
import {
|
import {
|
||||||
HOUSEHOLD_TOPIC_ROLES,
|
HOUSEHOLD_TOPIC_ROLES,
|
||||||
type HouseholdConfigurationRepository,
|
type HouseholdConfigurationRepository,
|
||||||
|
type HouseholdJoinTokenRecord,
|
||||||
|
type HouseholdPendingMemberRecord,
|
||||||
type HouseholdTelegramChatRecord,
|
type HouseholdTelegramChatRecord,
|
||||||
type HouseholdTopicBindingRecord,
|
type HouseholdTopicBindingRecord,
|
||||||
type HouseholdTopicRole,
|
type HouseholdTopicRole,
|
||||||
@@ -50,6 +52,38 @@ function toHouseholdTopicBindingRecord(row: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toHouseholdJoinTokenRecord(row: {
|
||||||
|
householdId: string
|
||||||
|
householdName: string
|
||||||
|
token: string
|
||||||
|
createdByTelegramUserId: string | null
|
||||||
|
}): HouseholdJoinTokenRecord {
|
||||||
|
return {
|
||||||
|
householdId: row.householdId,
|
||||||
|
householdName: row.householdName,
|
||||||
|
token: row.token,
|
||||||
|
createdByTelegramUserId: row.createdByTelegramUserId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHouseholdPendingMemberRecord(row: {
|
||||||
|
householdId: string
|
||||||
|
householdName: string
|
||||||
|
telegramUserId: string
|
||||||
|
displayName: string
|
||||||
|
username: string | null
|
||||||
|
languageCode: string | null
|
||||||
|
}): HouseholdPendingMemberRecord {
|
||||||
|
return {
|
||||||
|
householdId: row.householdId,
|
||||||
|
householdName: row.householdName,
|
||||||
|
telegramUserId: row.telegramUserId,
|
||||||
|
displayName: row.displayName,
|
||||||
|
username: row.username,
|
||||||
|
languageCode: row.languageCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||||
repository: HouseholdConfigurationRepository
|
repository: HouseholdConfigurationRepository
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
@@ -261,6 +295,208 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
.orderBy(schema.householdTopicBindings.role)
|
.orderBy(schema.householdTopicBindings.role)
|
||||||
|
|
||||||
return rows.map(toHouseholdTopicBindingRecord)
|
return rows.map(toHouseholdTopicBindingRecord)
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertHouseholdJoinToken(input) {
|
||||||
|
const rows = await db
|
||||||
|
.insert(schema.householdJoinTokens)
|
||||||
|
.values({
|
||||||
|
householdId: input.householdId,
|
||||||
|
token: input.token,
|
||||||
|
createdByTelegramUserId: input.createdByTelegramUserId ?? null
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [schema.householdJoinTokens.householdId],
|
||||||
|
set: {
|
||||||
|
token: input.token,
|
||||||
|
createdByTelegramUserId: input.createdByTelegramUserId ?? null,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
householdId: schema.householdJoinTokens.householdId,
|
||||||
|
token: schema.householdJoinTokens.token,
|
||||||
|
createdByTelegramUserId: schema.householdJoinTokens.createdByTelegramUserId
|
||||||
|
})
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Failed to save household join token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const householdRows = await db
|
||||||
|
.select({
|
||||||
|
householdId: schema.households.id,
|
||||||
|
householdName: schema.households.name
|
||||||
|
})
|
||||||
|
.from(schema.households)
|
||||||
|
.where(eq(schema.households.id, row.householdId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const household = householdRows[0]
|
||||||
|
if (!household) {
|
||||||
|
throw new Error('Failed to resolve household for join token')
|
||||||
|
}
|
||||||
|
|
||||||
|
return toHouseholdJoinTokenRecord({
|
||||||
|
householdId: row.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
token: row.token,
|
||||||
|
createdByTelegramUserId: row.createdByTelegramUserId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHouseholdJoinToken(householdId) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
householdId: schema.householdJoinTokens.householdId,
|
||||||
|
householdName: schema.households.name,
|
||||||
|
token: schema.householdJoinTokens.token,
|
||||||
|
createdByTelegramUserId: schema.householdJoinTokens.createdByTelegramUserId
|
||||||
|
})
|
||||||
|
.from(schema.householdJoinTokens)
|
||||||
|
.innerJoin(
|
||||||
|
schema.households,
|
||||||
|
eq(schema.householdJoinTokens.householdId, schema.households.id)
|
||||||
|
)
|
||||||
|
.where(eq(schema.householdJoinTokens.householdId, householdId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
return row ? toHouseholdJoinTokenRecord(row) : null
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHouseholdByJoinToken(token) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
householdId: schema.householdJoinTokens.householdId,
|
||||||
|
householdName: schema.households.name,
|
||||||
|
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
||||||
|
telegramChatType: schema.householdTelegramChats.telegramChatType,
|
||||||
|
title: schema.householdTelegramChats.title
|
||||||
|
})
|
||||||
|
.from(schema.householdJoinTokens)
|
||||||
|
.innerJoin(
|
||||||
|
schema.households,
|
||||||
|
eq(schema.householdJoinTokens.householdId, schema.households.id)
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
schema.householdTelegramChats,
|
||||||
|
eq(schema.householdJoinTokens.householdId, schema.householdTelegramChats.householdId)
|
||||||
|
)
|
||||||
|
.where(eq(schema.householdJoinTokens.token, token))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
return row ? toHouseholdTelegramChatRecord(row) : null
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertPendingHouseholdMember(input) {
|
||||||
|
const rows = await db
|
||||||
|
.insert(schema.householdPendingMembers)
|
||||||
|
.values({
|
||||||
|
householdId: input.householdId,
|
||||||
|
telegramUserId: input.telegramUserId,
|
||||||
|
displayName: input.displayName,
|
||||||
|
username: input.username?.trim() || null,
|
||||||
|
languageCode: input.languageCode?.trim() || null
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
schema.householdPendingMembers.householdId,
|
||||||
|
schema.householdPendingMembers.telegramUserId
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
displayName: input.displayName,
|
||||||
|
username: input.username?.trim() || null,
|
||||||
|
languageCode: input.languageCode?.trim() || null,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
householdId: schema.householdPendingMembers.householdId,
|
||||||
|
telegramUserId: schema.householdPendingMembers.telegramUserId,
|
||||||
|
displayName: schema.householdPendingMembers.displayName,
|
||||||
|
username: schema.householdPendingMembers.username,
|
||||||
|
languageCode: schema.householdPendingMembers.languageCode
|
||||||
|
})
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Failed to save pending household member')
|
||||||
|
}
|
||||||
|
|
||||||
|
const householdRows = await db
|
||||||
|
.select({
|
||||||
|
householdId: schema.households.id,
|
||||||
|
householdName: schema.households.name
|
||||||
|
})
|
||||||
|
.from(schema.households)
|
||||||
|
.where(eq(schema.households.id, row.householdId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const household = householdRows[0]
|
||||||
|
if (!household) {
|
||||||
|
throw new Error('Failed to resolve household for pending member')
|
||||||
|
}
|
||||||
|
|
||||||
|
return toHouseholdPendingMemberRecord({
|
||||||
|
householdId: row.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
telegramUserId: row.telegramUserId,
|
||||||
|
displayName: row.displayName,
|
||||||
|
username: row.username,
|
||||||
|
languageCode: row.languageCode
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPendingHouseholdMember(householdId, telegramUserId) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
householdId: schema.householdPendingMembers.householdId,
|
||||||
|
householdName: schema.households.name,
|
||||||
|
telegramUserId: schema.householdPendingMembers.telegramUserId,
|
||||||
|
displayName: schema.householdPendingMembers.displayName,
|
||||||
|
username: schema.householdPendingMembers.username,
|
||||||
|
languageCode: schema.householdPendingMembers.languageCode
|
||||||
|
})
|
||||||
|
.from(schema.householdPendingMembers)
|
||||||
|
.innerJoin(
|
||||||
|
schema.households,
|
||||||
|
eq(schema.householdPendingMembers.householdId, schema.households.id)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.householdPendingMembers.householdId, householdId),
|
||||||
|
eq(schema.householdPendingMembers.telegramUserId, telegramUserId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
return row ? toHouseholdPendingMemberRecord(row) : null
|
||||||
|
},
|
||||||
|
|
||||||
|
async findPendingHouseholdMemberByTelegramUserId(telegramUserId) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
householdId: schema.householdPendingMembers.householdId,
|
||||||
|
householdName: schema.households.name,
|
||||||
|
telegramUserId: schema.householdPendingMembers.telegramUserId,
|
||||||
|
displayName: schema.householdPendingMembers.displayName,
|
||||||
|
username: schema.householdPendingMembers.username,
|
||||||
|
languageCode: schema.householdPendingMembers.languageCode
|
||||||
|
})
|
||||||
|
.from(schema.householdPendingMembers)
|
||||||
|
.innerJoin(
|
||||||
|
schema.households,
|
||||||
|
eq(schema.householdPendingMembers.householdId, schema.households.id)
|
||||||
|
)
|
||||||
|
.where(eq(schema.householdPendingMembers.telegramUserId, telegramUserId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
return row ? toHouseholdPendingMemberRecord(row) : null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
213
packages/application/src/household-onboarding-service.test.ts
Normal file
213
packages/application/src/household-onboarding-service.test.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FinanceMemberRecord,
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
HouseholdJoinTokenRecord,
|
||||||
|
HouseholdPendingMemberRecord,
|
||||||
|
HouseholdTelegramChatRecord,
|
||||||
|
HouseholdTopicBindingRecord
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
import { createHouseholdOnboardingService } from './household-onboarding-service'
|
||||||
|
|
||||||
|
function createRepositoryStub() {
|
||||||
|
const household: HouseholdTelegramChatRecord = {
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House'
|
||||||
|
}
|
||||||
|
let joinToken: HouseholdJoinTokenRecord | null = null
|
||||||
|
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
|
||||||
|
|
||||||
|
const repository: HouseholdConfigurationRepository = {
|
||||||
|
async registerTelegramHouseholdChat() {
|
||||||
|
return {
|
||||||
|
status: 'existing',
|
||||||
|
household
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getTelegramHouseholdChat() {
|
||||||
|
return household
|
||||||
|
},
|
||||||
|
async bindHouseholdTopic(input) {
|
||||||
|
const binding: HouseholdTopicBindingRecord = {
|
||||||
|
householdId: input.householdId,
|
||||||
|
role: input.role,
|
||||||
|
telegramThreadId: input.telegramThreadId,
|
||||||
|
topicName: input.topicName?.trim() || null
|
||||||
|
}
|
||||||
|
return binding
|
||||||
|
},
|
||||||
|
async getHouseholdTopicBinding() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async findHouseholdTopicByTelegramContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async listHouseholdTopicBindings() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
async upsertHouseholdJoinToken(input) {
|
||||||
|
joinToken = {
|
||||||
|
householdId: household.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
token: input.token,
|
||||||
|
createdByTelegramUserId: input.createdByTelegramUserId ?? null
|
||||||
|
}
|
||||||
|
return joinToken
|
||||||
|
},
|
||||||
|
async getHouseholdJoinToken() {
|
||||||
|
return joinToken
|
||||||
|
},
|
||||||
|
async getHouseholdByJoinToken(token) {
|
||||||
|
return joinToken?.token === token ? household : null
|
||||||
|
},
|
||||||
|
async upsertPendingHouseholdMember(input) {
|
||||||
|
const record: HouseholdPendingMemberRecord = {
|
||||||
|
householdId: household.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
telegramUserId: input.telegramUserId,
|
||||||
|
displayName: input.displayName,
|
||||||
|
username: input.username?.trim() || null,
|
||||||
|
languageCode: input.languageCode?.trim() || null
|
||||||
|
}
|
||||||
|
pendingMembers.set(input.telegramUserId, record)
|
||||||
|
return record
|
||||||
|
},
|
||||||
|
async getPendingHouseholdMember(_householdId, telegramUserId) {
|
||||||
|
return pendingMembers.get(telegramUserId) ?? null
|
||||||
|
},
|
||||||
|
async findPendingHouseholdMemberByTelegramUserId(telegramUserId) {
|
||||||
|
return pendingMembers.get(telegramUserId) ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repository
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createHouseholdOnboardingService', () => {
|
||||||
|
test('creates and reuses a stable join token for a household', async () => {
|
||||||
|
const { repository } = createRepositoryStub()
|
||||||
|
const service = createHouseholdOnboardingService({
|
||||||
|
repository,
|
||||||
|
tokenFactory: () => 'join-token'
|
||||||
|
})
|
||||||
|
|
||||||
|
const created = await service.ensureHouseholdJoinToken({
|
||||||
|
householdId: 'household-1',
|
||||||
|
actorTelegramUserId: '1'
|
||||||
|
})
|
||||||
|
const reused = await service.ensureHouseholdJoinToken({
|
||||||
|
householdId: 'household-1'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(created.token).toBe('join-token')
|
||||||
|
expect(reused.token).toBe('join-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reports join_required for a valid token and non-member', async () => {
|
||||||
|
const { repository } = createRepositoryStub()
|
||||||
|
const service = createHouseholdOnboardingService({
|
||||||
|
repository,
|
||||||
|
tokenFactory: () => 'join-token'
|
||||||
|
})
|
||||||
|
await service.ensureHouseholdJoinToken({
|
||||||
|
householdId: 'household-1'
|
||||||
|
})
|
||||||
|
|
||||||
|
const access = await service.getMiniAppAccess({
|
||||||
|
identity: {
|
||||||
|
telegramUserId: '42',
|
||||||
|
displayName: 'Stan'
|
||||||
|
},
|
||||||
|
joinToken: 'join-token'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(access).toEqual({
|
||||||
|
status: 'join_required',
|
||||||
|
household: {
|
||||||
|
id: 'household-1',
|
||||||
|
name: 'Kojori House'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a pending join request', async () => {
|
||||||
|
const { repository } = createRepositoryStub()
|
||||||
|
const service = createHouseholdOnboardingService({
|
||||||
|
repository,
|
||||||
|
tokenFactory: () => 'join-token'
|
||||||
|
})
|
||||||
|
await service.ensureHouseholdJoinToken({
|
||||||
|
householdId: 'household-1'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.joinHousehold({
|
||||||
|
identity: {
|
||||||
|
telegramUserId: '42',
|
||||||
|
displayName: 'Stan',
|
||||||
|
username: 'stan'
|
||||||
|
},
|
||||||
|
joinToken: 'join-token'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'pending',
|
||||||
|
household: {
|
||||||
|
id: 'household-1',
|
||||||
|
name: 'Kojori House'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const access = await service.getMiniAppAccess({
|
||||||
|
identity: {
|
||||||
|
telegramUserId: '42',
|
||||||
|
displayName: 'Stan'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(access).toEqual({
|
||||||
|
status: 'pending',
|
||||||
|
household: {
|
||||||
|
id: 'household-1',
|
||||||
|
name: 'Kojori House'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns active when the user is already a finance member', async () => {
|
||||||
|
const { repository } = createRepositoryStub()
|
||||||
|
const member: FinanceMemberRecord = {
|
||||||
|
id: 'member-1',
|
||||||
|
telegramUserId: '42',
|
||||||
|
displayName: 'Stan',
|
||||||
|
isAdmin: true
|
||||||
|
}
|
||||||
|
const service = createHouseholdOnboardingService({
|
||||||
|
repository,
|
||||||
|
getMemberByTelegramUserId: async () => member
|
||||||
|
})
|
||||||
|
|
||||||
|
const access = await service.getMiniAppAccess({
|
||||||
|
identity: {
|
||||||
|
telegramUserId: '42',
|
||||||
|
displayName: 'Stan'
|
||||||
|
},
|
||||||
|
joinToken: 'anything'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(access).toEqual({
|
||||||
|
status: 'active',
|
||||||
|
member: {
|
||||||
|
id: 'member-1',
|
||||||
|
displayName: 'Stan',
|
||||||
|
isAdmin: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
228
packages/application/src/household-onboarding-service.ts
Normal file
228
packages/application/src/household-onboarding-service.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
|
import type { FinanceMemberRecord, HouseholdConfigurationRepository } from '@household/ports'
|
||||||
|
|
||||||
|
export interface HouseholdOnboardingIdentity {
|
||||||
|
telegramUserId: string
|
||||||
|
displayName: string
|
||||||
|
username?: string | null
|
||||||
|
languageCode?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HouseholdMiniAppAccess =
|
||||||
|
| {
|
||||||
|
status: 'active'
|
||||||
|
member: {
|
||||||
|
id: string
|
||||||
|
displayName: string
|
||||||
|
isAdmin: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'pending'
|
||||||
|
household: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'join_required'
|
||||||
|
household: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'open_from_group'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HouseholdOnboardingService {
|
||||||
|
ensureHouseholdJoinToken(input: { householdId: string; actorTelegramUserId?: string }): Promise<{
|
||||||
|
householdId: string
|
||||||
|
householdName: string
|
||||||
|
token: string
|
||||||
|
}>
|
||||||
|
getMiniAppAccess(input: {
|
||||||
|
identity: HouseholdOnboardingIdentity
|
||||||
|
joinToken?: string
|
||||||
|
}): Promise<HouseholdMiniAppAccess>
|
||||||
|
joinHousehold(input: { identity: HouseholdOnboardingIdentity; joinToken: string }): Promise<
|
||||||
|
| {
|
||||||
|
status: 'pending'
|
||||||
|
household: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'active'
|
||||||
|
member: {
|
||||||
|
id: string
|
||||||
|
displayName: string
|
||||||
|
isAdmin: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'invalid_token'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMember(member: FinanceMemberRecord): {
|
||||||
|
id: string
|
||||||
|
displayName: string
|
||||||
|
isAdmin: boolean
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
id: member.id,
|
||||||
|
displayName: member.displayName,
|
||||||
|
isAdmin: member.isAdmin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateJoinToken(): string {
|
||||||
|
return randomBytes(24).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHouseholdOnboardingService(options: {
|
||||||
|
repository: HouseholdConfigurationRepository
|
||||||
|
getMemberByTelegramUserId?: (telegramUserId: string) => Promise<FinanceMemberRecord | null>
|
||||||
|
tokenFactory?: () => string
|
||||||
|
}): HouseholdOnboardingService {
|
||||||
|
const createToken = options.tokenFactory ?? generateJoinToken
|
||||||
|
|
||||||
|
return {
|
||||||
|
async ensureHouseholdJoinToken(input) {
|
||||||
|
const existing = await options.repository.getHouseholdJoinToken(input.householdId)
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
householdId: existing.householdId,
|
||||||
|
householdName: existing.householdName,
|
||||||
|
token: existing.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createToken()
|
||||||
|
const created = await options.repository.upsertHouseholdJoinToken({
|
||||||
|
householdId: input.householdId,
|
||||||
|
token,
|
||||||
|
...(input.actorTelegramUserId
|
||||||
|
? {
|
||||||
|
createdByTelegramUserId: input.actorTelegramUserId
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
householdId: created.householdId,
|
||||||
|
householdName: created.householdName,
|
||||||
|
token: created.token
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMiniAppAccess(input) {
|
||||||
|
const activeMember = options.getMemberByTelegramUserId
|
||||||
|
? await options.getMemberByTelegramUserId(input.identity.telegramUserId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (activeMember) {
|
||||||
|
return {
|
||||||
|
status: 'active',
|
||||||
|
member: toMember(activeMember)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPending = await options.repository.findPendingHouseholdMemberByTelegramUserId(
|
||||||
|
input.identity.telegramUserId
|
||||||
|
)
|
||||||
|
if (existingPending) {
|
||||||
|
return {
|
||||||
|
status: 'pending',
|
||||||
|
household: {
|
||||||
|
id: existingPending.householdId,
|
||||||
|
name: existingPending.householdName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.joinToken) {
|
||||||
|
return {
|
||||||
|
status: 'open_from_group'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const household = await options.repository.getHouseholdByJoinToken(input.joinToken)
|
||||||
|
if (!household) {
|
||||||
|
return {
|
||||||
|
status: 'open_from_group'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = await options.repository.getPendingHouseholdMember(
|
||||||
|
household.householdId,
|
||||||
|
input.identity.telegramUserId
|
||||||
|
)
|
||||||
|
if (pending) {
|
||||||
|
return {
|
||||||
|
status: 'pending',
|
||||||
|
household: {
|
||||||
|
id: pending.householdId,
|
||||||
|
name: pending.householdName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'join_required',
|
||||||
|
household: {
|
||||||
|
id: household.householdId,
|
||||||
|
name: household.householdName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async joinHousehold(input) {
|
||||||
|
const household = await options.repository.getHouseholdByJoinToken(input.joinToken)
|
||||||
|
if (!household) {
|
||||||
|
return {
|
||||||
|
status: 'invalid_token'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMember = options.getMemberByTelegramUserId
|
||||||
|
? await options.getMemberByTelegramUserId(input.identity.telegramUserId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (activeMember) {
|
||||||
|
return {
|
||||||
|
status: 'active',
|
||||||
|
member: toMember(activeMember)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = await options.repository.upsertPendingHouseholdMember({
|
||||||
|
householdId: household.householdId,
|
||||||
|
telegramUserId: input.identity.telegramUserId,
|
||||||
|
displayName: input.identity.displayName,
|
||||||
|
...(input.identity.username
|
||||||
|
? {
|
||||||
|
username: input.identity.username
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(input.identity.languageCode
|
||||||
|
? {
|
||||||
|
languageCode: input.identity.languageCode
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'pending',
|
||||||
|
household: {
|
||||||
|
id: pending.householdId,
|
||||||
|
name: pending.householdName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test'
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
|
HouseholdJoinTokenRecord,
|
||||||
|
HouseholdPendingMemberRecord,
|
||||||
HouseholdTelegramChatRecord,
|
HouseholdTelegramChatRecord,
|
||||||
HouseholdTopicBindingRecord
|
HouseholdTopicBindingRecord
|
||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
@@ -11,6 +13,8 @@ import { createHouseholdSetupService } from './household-setup-service'
|
|||||||
function createRepositoryStub() {
|
function createRepositoryStub() {
|
||||||
const households = new Map<string, HouseholdTelegramChatRecord>()
|
const households = new Map<string, HouseholdTelegramChatRecord>()
|
||||||
const bindings = new Map<string, HouseholdTopicBindingRecord[]>()
|
const bindings = new Map<string, HouseholdTopicBindingRecord[]>()
|
||||||
|
const joinTokens = new Map<string, HouseholdJoinTokenRecord>()
|
||||||
|
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
|
||||||
|
|
||||||
const repository: HouseholdConfigurationRepository = {
|
const repository: HouseholdConfigurationRepository = {
|
||||||
async registerTelegramHouseholdChat(input) {
|
async registerTelegramHouseholdChat(input) {
|
||||||
@@ -79,6 +83,71 @@ function createRepositoryStub() {
|
|||||||
|
|
||||||
async listHouseholdTopicBindings(householdId) {
|
async listHouseholdTopicBindings(householdId) {
|
||||||
return bindings.get(householdId) ?? []
|
return bindings.get(householdId) ?? []
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertHouseholdJoinToken(input) {
|
||||||
|
const household = [...households.values()].find(
|
||||||
|
(entry) => entry.householdId === input.householdId
|
||||||
|
)
|
||||||
|
if (!household) {
|
||||||
|
throw new Error('Missing household')
|
||||||
|
}
|
||||||
|
|
||||||
|
const record: HouseholdJoinTokenRecord = {
|
||||||
|
householdId: household.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
token: input.token,
|
||||||
|
createdByTelegramUserId: input.createdByTelegramUserId ?? null
|
||||||
|
}
|
||||||
|
joinTokens.set(household.householdId, record)
|
||||||
|
return record
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHouseholdJoinToken(householdId) {
|
||||||
|
return joinTokens.get(householdId) ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHouseholdByJoinToken(token) {
|
||||||
|
const record = [...joinTokens.values()].find((entry) => entry.token === token)
|
||||||
|
if (!record) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
[...households.values()].find((entry) => entry.householdId === record.householdId) ?? null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
async upsertPendingHouseholdMember(input) {
|
||||||
|
const household = [...households.values()].find(
|
||||||
|
(entry) => entry.householdId === input.householdId
|
||||||
|
)
|
||||||
|
if (!household) {
|
||||||
|
throw new Error('Missing household')
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${input.householdId}:${input.telegramUserId}`
|
||||||
|
const record: HouseholdPendingMemberRecord = {
|
||||||
|
householdId: household.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
telegramUserId: input.telegramUserId,
|
||||||
|
displayName: input.displayName,
|
||||||
|
username: input.username?.trim() || null,
|
||||||
|
languageCode: input.languageCode?.trim() || null
|
||||||
|
}
|
||||||
|
pendingMembers.set(key, record)
|
||||||
|
return record
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPendingHouseholdMember(householdId, telegramUserId) {
|
||||||
|
return pendingMembers.get(`${householdId}:${telegramUserId}`) ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async findPendingHouseholdMemberByTelegramUserId(telegramUserId) {
|
||||||
|
return (
|
||||||
|
[...pendingMembers.values()].find((entry) => entry.telegramUserId === telegramUserId) ??
|
||||||
|
null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ export {
|
|||||||
} from './anonymous-feedback-service'
|
} from './anonymous-feedback-service'
|
||||||
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
||||||
export { createHouseholdSetupService, type HouseholdSetupService } from './household-setup-service'
|
export { createHouseholdSetupService, type HouseholdSetupService } from './household-setup-service'
|
||||||
|
export {
|
||||||
|
createHouseholdOnboardingService,
|
||||||
|
type HouseholdMiniAppAccess,
|
||||||
|
type HouseholdOnboardingIdentity,
|
||||||
|
type HouseholdOnboardingService
|
||||||
|
} from './household-onboarding-service'
|
||||||
export {
|
export {
|
||||||
createReminderJobService,
|
createReminderJobService,
|
||||||
type ReminderJobResult,
|
type ReminderJobResult,
|
||||||
|
|||||||
26
packages/db/drizzle/0006_marvelous_nehzno.sql
Normal file
26
packages/db/drizzle/0006_marvelous_nehzno.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE "household_join_tokens" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"created_by_telegram_user_id" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "household_pending_members" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"telegram_user_id" text NOT NULL,
|
||||||
|
"display_name" text NOT NULL,
|
||||||
|
"username" text,
|
||||||
|
"language_code" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "household_join_tokens" ADD CONSTRAINT "household_join_tokens_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "household_pending_members" ADD CONSTRAINT "household_pending_members_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "household_join_tokens_household_unique" ON "household_join_tokens" USING btree ("household_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "household_join_tokens_token_unique" ON "household_join_tokens" USING btree ("token");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "household_pending_members_household_user_unique" ON "household_pending_members" USING btree ("household_id","telegram_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "household_pending_members_telegram_user_idx" ON "household_pending_members" USING btree ("telegram_user_id");
|
||||||
2022
packages/db/drizzle/meta/0006_snapshot.json
Normal file
2022
packages/db/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1773012360748,
|
"when": 1773012360748,
|
||||||
"tag": "0005_free_kang",
|
"tag": "0005_free_kang",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773015092441,
|
||||||
|
"tag": "0006_marvelous_nehzno",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,47 @@ export const householdTopicBindings = pgTable(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const householdJoinTokens = pgTable(
|
||||||
|
'household_join_tokens',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
householdId: uuid('household_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: 'cascade' }),
|
||||||
|
token: text('token').notNull(),
|
||||||
|
createdByTelegramUserId: text('created_by_telegram_user_id'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
householdUnique: uniqueIndex('household_join_tokens_household_unique').on(table.householdId),
|
||||||
|
tokenUnique: uniqueIndex('household_join_tokens_token_unique').on(table.token)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const householdPendingMembers = pgTable(
|
||||||
|
'household_pending_members',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
householdId: uuid('household_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: 'cascade' }),
|
||||||
|
telegramUserId: text('telegram_user_id').notNull(),
|
||||||
|
displayName: text('display_name').notNull(),
|
||||||
|
username: text('username'),
|
||||||
|
languageCode: text('language_code'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
householdUserUnique: uniqueIndex('household_pending_members_household_user_unique').on(
|
||||||
|
table.householdId,
|
||||||
|
table.telegramUserId
|
||||||
|
),
|
||||||
|
telegramUserIdx: index('household_pending_members_telegram_user_idx').on(table.telegramUserId)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const members = pgTable(
|
export const members = pgTable(
|
||||||
'members',
|
'members',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,22 @@ export interface HouseholdTopicBindingRecord {
|
|||||||
topicName: string | null
|
topicName: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HouseholdJoinTokenRecord {
|
||||||
|
householdId: string
|
||||||
|
householdName: string
|
||||||
|
token: string
|
||||||
|
createdByTelegramUserId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HouseholdPendingMemberRecord {
|
||||||
|
householdId: string
|
||||||
|
householdName: string
|
||||||
|
telegramUserId: string
|
||||||
|
displayName: string
|
||||||
|
username: string | null
|
||||||
|
languageCode: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegisterTelegramHouseholdChatInput {
|
export interface RegisterTelegramHouseholdChatInput {
|
||||||
householdName: string
|
householdName: string
|
||||||
telegramChatId: string
|
telegramChatId: string
|
||||||
@@ -49,4 +65,25 @@ export interface HouseholdConfigurationRepository {
|
|||||||
telegramThreadId: string
|
telegramThreadId: string
|
||||||
}): Promise<HouseholdTopicBindingRecord | null>
|
}): Promise<HouseholdTopicBindingRecord | null>
|
||||||
listHouseholdTopicBindings(householdId: string): Promise<readonly HouseholdTopicBindingRecord[]>
|
listHouseholdTopicBindings(householdId: string): Promise<readonly HouseholdTopicBindingRecord[]>
|
||||||
|
upsertHouseholdJoinToken(input: {
|
||||||
|
householdId: string
|
||||||
|
token: string
|
||||||
|
createdByTelegramUserId?: string
|
||||||
|
}): Promise<HouseholdJoinTokenRecord>
|
||||||
|
getHouseholdJoinToken(householdId: string): Promise<HouseholdJoinTokenRecord | null>
|
||||||
|
getHouseholdByJoinToken(token: string): Promise<HouseholdTelegramChatRecord | null>
|
||||||
|
upsertPendingHouseholdMember(input: {
|
||||||
|
householdId: string
|
||||||
|
telegramUserId: string
|
||||||
|
displayName: string
|
||||||
|
username?: string
|
||||||
|
languageCode?: string
|
||||||
|
}): Promise<HouseholdPendingMemberRecord>
|
||||||
|
getPendingHouseholdMember(
|
||||||
|
householdId: string,
|
||||||
|
telegramUserId: string
|
||||||
|
): Promise<HouseholdPendingMemberRecord | null>
|
||||||
|
findPendingHouseholdMemberByTelegramUserId(
|
||||||
|
telegramUserId: string
|
||||||
|
): Promise<HouseholdPendingMemberRecord | null>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export {
|
|||||||
export {
|
export {
|
||||||
HOUSEHOLD_TOPIC_ROLES,
|
HOUSEHOLD_TOPIC_ROLES,
|
||||||
type HouseholdConfigurationRepository,
|
type HouseholdConfigurationRepository,
|
||||||
|
type HouseholdJoinTokenRecord,
|
||||||
|
type HouseholdPendingMemberRecord,
|
||||||
type HouseholdTelegramChatRecord,
|
type HouseholdTelegramChatRecord,
|
||||||
type HouseholdTopicBindingRecord,
|
type HouseholdTopicBindingRecord,
|
||||||
type HouseholdTopicRole,
|
type HouseholdTopicRole,
|
||||||
|
|||||||
Reference in New Issue
Block a user