feat(onboarding): add mini app household join flow

This commit is contained in:
2026-03-09 04:16:34 +04:00
parent e63d81cda2
commit 8109163067
22 changed files with 3702 additions and 160 deletions

View File

@@ -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 &&

View File

@@ -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
}
]
: [])
]
]
}
}
: {}
) )
}) })

View File

@@ -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,

View File

@@ -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,13 +81,16 @@ 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({
repository: onboardingRepository(),
getMemberByTelegramUserId: async () => ({
id: 'member-1', id: 'member-1',
telegramUserId: '123456', telegramUserId: '123456',
displayName: 'Stan', displayName: 'Stan',
isAdmin: true isAdmin: true
}) })
}) })
})
const response = await auth.handler( const response = await auth.handler(
new Request('http://localhost/api/miniapp/session', { new Request('http://localhost/api/miniapp/session', {
@@ -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
}
}
})
}) })

View File

@@ -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,47 +96,84 @@ 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
},
...(payload.joinToken
? {
joinToken: payload.joinToken
} }
: {})
})
switch (access.status) {
case 'active':
return { return {
authorized: true, authorized: true,
member: { member: access.member,
id: member.id,
displayName: member.displayName,
isAdmin: member.isAdmin
},
telegramUser 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)
}
}
}
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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') {

View File

@@ -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">

View File

@@ -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. Локально показывается демо-оболочка.',

View File

@@ -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 } : {})
} }
} }

View File

@@ -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
} }
} }

View 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
}
})
})
})

View 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
}
}
}
}
}

View File

@@ -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
)
} }
} }

View File

@@ -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,

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }
] ]
} }

View File

@@ -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',
{ {

View File

@@ -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>
} }

View File

@@ -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,