diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index d06b8cb..00fbf20 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -114,7 +114,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu householdId !== undefined && telegramHouseholdChatId !== undefined && telegramFeedbackTopicId !== undefined - const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined + const miniAppAuthEnabled = databaseUrl !== undefined const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 const reminderJobsEnabled = databaseUrl !== undefined && diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index 6535c90..880f71b 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -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 { Bot, Context } from 'grammy' @@ -51,8 +51,72 @@ function bindRejectionMessage( export function registerHouseholdSetupCommands(options: { bot: Bot householdSetupService: HouseholdSetupService + householdOnboardingService: HouseholdOnboardingService + miniAppBaseUrl?: string logger?: Logger }): 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) => { if (!isGroupChat(ctx)) { 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 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( [ `Household ${action}: ${result.household.householdName}`, `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.' - ].join('\n') + 'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.', + '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 + } + ] + : []) + ] + ] + } + } + : {} ) }) diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index a07efbc..c2d205a 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -3,6 +3,7 @@ import { webhookCallback } from 'grammy' import { createAnonymousFeedbackService, createFinanceCommandService, + createHouseholdOnboardingService, createHouseholdSetupService, createReminderJobService } from '@household/application' @@ -27,7 +28,7 @@ import { import { createReminderJobsHandler } from './reminder-jobs' import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createBotWebhookServer } from './server' -import { createMiniAppAuthHandler } from './miniapp-auth' +import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth' import { createMiniAppDashboardHandler } from './miniapp-dashboard' const runtime = getBotRuntimeConfig() @@ -51,6 +52,16 @@ const financeRepositoryClient = const financeService = financeRepositoryClient ? createFinanceCommandService(financeRepositoryClient.repository) : null +const householdOnboardingService = householdConfigurationRepositoryClient + ? createHouseholdOnboardingService({ + repository: householdConfigurationRepositoryClient.repository, + ...(financeRepositoryClient + ? { + getMemberByTelegramUserId: financeRepositoryClient.repository.getMemberByTelegramUserId + } + : {}) + }) + : null const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled ? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!) : null @@ -118,6 +129,12 @@ if (householdConfigurationRepositoryClient) { householdSetupService: createHouseholdSetupService( householdConfigurationRepositoryClient.repository ), + householdOnboardingService: householdOnboardingService!, + ...(runtime.miniAppAllowedOrigins[0] + ? { + miniAppBaseUrl: runtime.miniAppAllowedOrigins[0] + } + : {}), logger: getLogger('household-setup') }) } else { @@ -177,11 +194,19 @@ const server = createBotWebhookServer({ webhookPath: runtime.telegramWebhookPath, webhookSecret: runtime.telegramWebhookSecret, webhookHandler, - miniAppAuth: financeRepositoryClient + miniAppAuth: householdOnboardingService ? createMiniAppAuthHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, 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') }) : undefined, @@ -190,6 +215,7 @@ const server = createBotWebhookServer({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, financeService, + onboardingService: householdOnboardingService!, logger: getLogger('miniapp-dashboard') }) : undefined, diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index d263453..517588a 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -1,28 +1,77 @@ 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' -function repository( - member: Awaited> -): FinanceRepository { +function onboardingRepository(): HouseholdConfigurationRepository { + const household = { + 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 { - getMemberByTelegramUserId: async () => member, - listMembers: async () => [], - getOpenCycle: async () => null, - getCycleByPeriod: async () => null, - getLatestCycle: async () => null, - openCycle: async () => {}, - closeCycle: async () => {}, - saveRentRule: async () => {}, - addUtilityBill: async () => {}, - getRentRuleForPeriod: async () => null, - getUtilityTotalForCycle: async () => 0n, - listUtilityBillsForCycle: async () => [], - listParsedPurchasesForRange: async () => [], - replaceSettlementSnapshot: async () => {} + 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 () => + 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({ allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', - repository: repository({ - id: 'member-1', - telegramUserId: '123456', - displayName: 'Stan', - isAdmin: true + onboardingService: createHouseholdOnboardingService({ + repository: onboardingRepository(), + getMemberByTelegramUserId: async () => ({ + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + }) }) }) @@ -67,10 +119,6 @@ describe('createMiniAppAuthHandler', () => { displayName: 'Stan', isAdmin: true }, - features: { - balances: true, - ledger: true - }, telegramUser: { id: '123456', 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 auth = createMiniAppAuthHandler({ allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', - repository: repository(null) + onboardingService: createHouseholdOnboardingService({ + repository: onboardingRepository() + }) }) const response = await auth.handler( @@ -99,16 +149,58 @@ describe('createMiniAppAuthHandler', () => { initData: buildMiniAppInitData('test-bot-token', authDate, { id: 123456, first_name: 'Stan' - }) + }), + joinToken: 'join-token' }) }) ) - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: true, 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({ allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', - repository: repository(null) + onboardingService: createHouseholdOnboardingService({ + repository: onboardingRepository() + }) }) const response = await auth.handler( @@ -136,48 +230,4 @@ describe('createMiniAppAuthHandler', () => { 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 - } - } - }) }) diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts index bbb1240..5a3421e 100644 --- a/apps/bot/src/miniapp-auth.ts +++ b/apps/bot/src/miniapp-auth.ts @@ -1,8 +1,13 @@ -import type { FinanceMemberRecord, FinanceRepository } from '@household/ports' +import type { HouseholdOnboardingService } from '@household/application' import type { Logger } from '@household/observability' import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' +export interface MiniAppRequestPayload { + initData: string | null + joinToken?: string +} + export function miniAppJsonResponse(body: object, status = 200, origin?: string): Response { const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' @@ -42,23 +47,33 @@ export function allowedMiniAppOrigin( return allowedOrigins.includes(origin) ? origin : undefined } -export async function readMiniAppInitData(request: Request): Promise { +export async function readMiniAppRequestPayload(request: Request): Promise { const text = await request.text() if (text.trim().length === 0) { - return null + return { + initData: null + } } - let parsed: { initData?: string } + let parsed: { initData?: string; joinToken?: string } try { - parsed = JSON.parse(text) as { initData?: string } + parsed = JSON.parse(text) as { initData?: string; joinToken?: string } } catch { throw new Error('Invalid JSON body') } 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 { @@ -81,46 +96,83 @@ export function miniAppErrorResponse(error: unknown, origin?: string, logger?: L export interface MiniAppSessionResult { authorized: boolean - reason?: 'not_member' member?: { id: string displayName: string isAdmin: boolean } telegramUser?: ReturnType + onboarding?: { + status: 'join_required' | 'pending' | 'open_from_group' + householdName?: string + } } -type MiniAppMemberLookup = (telegramUserId: string) => Promise - export function createMiniAppSessionService(options: { botToken: string - getMemberByTelegramUserId: MiniAppMemberLookup + onboardingService: HouseholdOnboardingService }): { - authenticate: (initData: string) => Promise + authenticate: (payload: MiniAppRequestPayload) => Promise } { return { - authenticate: async (initData) => { - const telegramUser = verifyTelegramMiniAppInitData(initData, options.botToken) + authenticate: async (payload) => { + if (!payload.initData) { + return null + } + + const telegramUser = verifyTelegramMiniAppInitData(payload.initData, options.botToken) if (!telegramUser) { return null } - const member = await options.getMemberByTelegramUserId(telegramUser.id) - if (!member) { - return { - authorized: false, - reason: 'not_member' - } - } - - return { - authorized: true, - member: { - id: member.id, - displayName: member.displayName, - isAdmin: member.isAdmin + const access = await options.onboardingService.getMiniAppAccess({ + identity: { + telegramUserId: telegramUser.id, + displayName: + telegramUser.firstName ?? telegramUser.username ?? `Telegram ${telegramUser.id}`, + username: telegramUser.username, + languageCode: telegramUser.languageCode }, - 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: { allowedOrigins: readonly string[] botToken: string - repository: FinanceRepository + onboardingService: HouseholdOnboardingService logger?: Logger }): { handler: (request: Request) => Promise } { const sessionService = createMiniAppSessionService({ botToken: options.botToken, - getMemberByTelegramUserId: options.repository.getMemberByTelegramUserId + onboardingService: options.onboardingService }) return { @@ -152,12 +204,12 @@ export function createMiniAppAuthHandler(options: { } try { - const initData = await readMiniAppInitData(request) - if (!initData) { + const payload = await readMiniAppRequestPayload(request) + if (!payload.initData) { return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) } - const session = await sessionService.authenticate(initData) + const session = await sessionService.authenticate(payload) if (!session) { return miniAppJsonResponse( { ok: false, error: 'Invalid Telegram init data' }, @@ -171,9 +223,10 @@ export function createMiniAppAuthHandler(options: { { ok: true, authorized: false, - reason: 'not_member' + onboarding: session.onboarding, + telegramUser: session.telegramUser }, - 403, + 200, 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 +} { + 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) + } + } + } +} diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index ec5e8cf..56abc1b 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -1,7 +1,14 @@ import { describe, expect, test } from 'bun:test' -import { createFinanceCommandService } from '@household/application' -import type { FinanceRepository } from '@household/ports' +import { + createFinanceCommandService, + createHouseholdOnboardingService +} from '@household/application' +import type { + FinanceRepository, + HouseholdConfigurationRepository, + HouseholdTopicBindingRecord +} from '@household/ports' import { createMiniAppDashboardHandler } from './miniapp-dashboard' 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', () => { test('returns a dashboard for an authenticated household member', async () => { const authDate = Math.floor(Date.now() / 1000) @@ -77,7 +130,11 @@ describe('createMiniAppDashboardHandler', () => { const dashboard = createMiniAppDashboardHandler({ allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', - financeService + financeService, + onboardingService: createHouseholdOnboardingService({ + repository: onboardingRepository(), + getMemberByTelegramUserId: financeService.getMemberByTelegramUserId + }) }) const response = await dashboard.handler( @@ -140,7 +197,11 @@ describe('createMiniAppDashboardHandler', () => { const dashboard = createMiniAppDashboardHandler({ allowedOrigins: ['http://localhost:5173'], botToken: 'test-bot-token', - financeService + financeService, + onboardingService: createHouseholdOnboardingService({ + repository: onboardingRepository(), + getMemberByTelegramUserId: financeService.getMemberByTelegramUserId + }) }) const response = await dashboard.handler( diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index a4cf38b..0df494d 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -1,4 +1,4 @@ -import type { FinanceCommandService } from '@household/application' +import type { FinanceCommandService, HouseholdOnboardingService } from '@household/application' import type { Logger } from '@household/observability' import { @@ -6,20 +6,21 @@ import { createMiniAppSessionService, miniAppErrorResponse, miniAppJsonResponse, - readMiniAppInitData + readMiniAppRequestPayload } from './miniapp-auth' export function createMiniAppDashboardHandler(options: { allowedOrigins: readonly string[] botToken: string financeService: FinanceCommandService + onboardingService: HouseholdOnboardingService logger?: Logger }): { handler: (request: Request) => Promise } { const sessionService = createMiniAppSessionService({ botToken: options.botToken, - getMemberByTelegramUserId: options.financeService.getMemberByTelegramUserId + onboardingService: options.onboardingService }) return { @@ -35,12 +36,12 @@ export function createMiniAppDashboardHandler(options: { } try { - const initData = await readMiniAppInitData(request) - if (!initData) { + const payload = await readMiniAppRequestPayload(request) + if (!payload.initData) { return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) } - const session = await sessionService.authenticate(initData) + const session = await sessionService.authenticate(payload) if (!session) { return miniAppJsonResponse( { ok: false, error: 'Invalid Telegram init data' }, @@ -54,7 +55,7 @@ export function createMiniAppDashboardHandler(options: { { ok: true, authorized: false, - reason: 'not_member' + onboarding: session.onboarding }, 403, origin diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index adfb4ec..d887e8c 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -14,6 +14,12 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppJoin?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined scheduler?: | { pathPrefix?: string @@ -46,6 +52,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { : `/${options.webhookPath}` const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session' const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard' + const miniAppJoinPath = options.miniAppJoin?.path ?? '/api/miniapp/join' const schedulerPathPrefix = options.scheduler ? (options.scheduler.pathPrefix ?? '/jobs/reminder') : null @@ -66,6 +73,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppDashboard.handler(request) } + if (options.miniAppJoin && url.pathname === miniAppJoinPath) { + return await options.miniAppJoin.handler(request) + } + if (url.pathname !== normalizedWebhookPath) { if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) { if (request.method !== 'POST') { diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index b9acbbe..bb8fd70 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -1,7 +1,12 @@ import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js' import { dictionary, type Locale } from './i18n' -import { fetchMiniAppDashboard, fetchMiniAppSession, type MiniAppDashboard } from './miniapp-api' +import { + fetchMiniAppDashboard, + fetchMiniAppSession, + joinMiniAppHousehold, + type MiniAppDashboard +} from './miniapp-api' import { getTelegramWebApp } from './telegram-webapp' type SessionState = @@ -10,7 +15,17 @@ type SessionState = } | { 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' @@ -49,6 +64,41 @@ function detectLocale(): Locale { 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() { const [locale, setLocale] = createSignal('en') const [session, setSession] = createSignal({ @@ -56,8 +106,13 @@ function App() { }) const [activeNav, setActiveNav] = createSignal('home') const [dashboard, setDashboard] = createSignal(null) + const [joining, setJoining] = createSignal(false) const copy = createMemo(() => dictionary[locale()]) + const onboardingSession = createMemo(() => { + const current = session() + return current.status === 'onboarding' ? current : null + }) const blockedSession = createMemo(() => { const current = session() return current.status === 'blocked' ? current : null @@ -68,7 +123,19 @@ function App() { }) 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()) webApp?.ready?.() @@ -89,11 +156,21 @@ function App() { } try { - const payload = await fetchMiniAppSession(initData) + const payload = await fetchMiniAppSession(initData, joinContext().joinToken) if (!payload.authorized || !payload.member || !payload.telegramUser) { setSession({ - status: 'blocked', - reason: payload.reason === 'not_member' ? 'not_member' : 'error' + status: 'onboarding', + mode: payload.onboarding?.status ?? 'open_from_group', + ...(payload.onboarding?.householdName + ? { + householdName: payload.onboarding.householdName + } + : {}), + telegramUser: payload.telegramUser ?? { + firstName: null, + username: null, + languageCode: null + } }) return } @@ -105,15 +182,7 @@ function App() { telegramUser: payload.telegramUser }) - try { - setDashboard(await fetchMiniAppDashboard(initData)) - } catch (error) { - if (import.meta.env.DEV) { - console.warn('Failed to load mini app dashboard', error) - } - - setDashboard(null) - } + await loadDashboard(initData) } catch { if (import.meta.env.DEV) { setSession(demoSession) @@ -168,8 +237,59 @@ function App() { 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 = () => { switch (activeNav()) { case 'balances': @@ -291,12 +411,12 @@ function App() {

{blockedSession()?.reason === 'telegram_only' ? copy().telegramOnlyTitle - : copy().unauthorizedTitle} + : copy().unexpectedErrorTitle}

{blockedSession()?.reason === 'telegram_only' ? copy().telegramOnlyBody - : copy().unauthorizedBody} + : copy().unexpectedErrorBody}

+ ) : null} + {joinDeepLink() ? ( + + {copy().botLinkAction} + + ) : null} + + + + +
diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 2cd2bb4..ad9d2b7 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -7,9 +7,21 @@ export const dictionary = { loadingTitle: 'Checking your household access', loadingBody: 'Validating Telegram session and membership…', demoBadge: 'Demo mode', - unauthorizedTitle: 'Access is limited to active household members', - unauthorizedBody: - 'Open the mini app from Telegram after the bot admin adds you to the household.', + joinTitle: 'Welcome to your household', + joinBody: + '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', telegramOnlyBody: '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: 'Проверяем доступ к дому', loadingBody: 'Проверяем Telegram-сессию и членство…', demoBadge: 'Демо режим', - unauthorizedTitle: 'Доступ открыт только для активных участников дома', - unauthorizedBody: - 'Открой мини-апп из Telegram после того, как админ бота добавит тебя в household.', + joinTitle: 'Добро пожаловать домой', + joinBody: + 'Ты пока не участник {household}. Отправь заявку на вступление и дождись подтверждения админа.', + pendingTitle: 'Заявка отправлена', + pendingBody: 'Твоя заявка в {household} ждёт подтверждения админа.', + openFromGroupTitle: 'Открой приложение из группового чата', + openFromGroupBody: + 'Используй кнопку подключения из сообщения настройки household, чтобы приложение поняло, к какому дому ты хочешь присоединиться.', + unexpectedErrorTitle: 'Не удалось открыть household app', + unexpectedErrorBody: + 'Попробуй снова из Telegram. Если ошибка повторяется, попроси админа ещё раз прислать кнопку подключения.', + householdFallback: 'этот household', + joinAction: 'Вступить в household', + joining: 'Отправляем заявку…', + botLinkAction: 'Открыть чат с ботом', telegramOnlyTitle: 'Открой приложение из Telegram', telegramOnlyBody: 'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.', diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 89fa262..e082f30 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -11,7 +11,10 @@ export interface MiniAppSession { username: string | null languageCode: string | null } - reason?: string + onboarding?: { + status: 'join_required' | 'pending' | 'open_from_group' + householdName?: string + } } export interface MiniAppDashboard { @@ -56,14 +59,22 @@ function apiBaseUrl(): string { return window.location.origin } -export async function fetchMiniAppSession(initData: string): Promise { +export async function fetchMiniAppSession( + initData: string, + joinToken?: string +): Promise { const response = await fetch(`${apiBaseUrl()}/api/miniapp/session`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - initData + initData, + ...(joinToken + ? { + joinToken + } + : {}) }) }) @@ -72,7 +83,7 @@ export async function fetchMiniAppSession(initData: string): Promise { + 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 } : {}) } } diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index e0ccff4..df57db0 100644 --- a/packages/adapters-db/src/household-config-repository.ts +++ b/packages/adapters-db/src/household-config-repository.ts @@ -4,6 +4,8 @@ import { createDbClient, schema } from '@household/db' import { HOUSEHOLD_TOPIC_ROLES, type HouseholdConfigurationRepository, + type HouseholdJoinTokenRecord, + type HouseholdPendingMemberRecord, type HouseholdTelegramChatRecord, type HouseholdTopicBindingRecord, 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): { repository: HouseholdConfigurationRepository close: () => Promise @@ -261,6 +295,208 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { .orderBy(schema.householdTopicBindings.role) 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 } } diff --git a/packages/application/src/household-onboarding-service.test.ts b/packages/application/src/household-onboarding-service.test.ts new file mode 100644 index 0000000..8184269 --- /dev/null +++ b/packages/application/src/household-onboarding-service.test.ts @@ -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() + + 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 + } + }) + }) +}) diff --git a/packages/application/src/household-onboarding-service.ts b/packages/application/src/household-onboarding-service.ts new file mode 100644 index 0000000..e002df9 --- /dev/null +++ b/packages/application/src/household-onboarding-service.ts @@ -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 + 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 + 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 + } + } + } + } +} diff --git a/packages/application/src/household-setup-service.test.ts b/packages/application/src/household-setup-service.test.ts index e5a5549..b0667c4 100644 --- a/packages/application/src/household-setup-service.test.ts +++ b/packages/application/src/household-setup-service.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test' import type { HouseholdConfigurationRepository, + HouseholdJoinTokenRecord, + HouseholdPendingMemberRecord, HouseholdTelegramChatRecord, HouseholdTopicBindingRecord } from '@household/ports' @@ -11,6 +13,8 @@ import { createHouseholdSetupService } from './household-setup-service' function createRepositoryStub() { const households = new Map() const bindings = new Map() + const joinTokens = new Map() + const pendingMembers = new Map() const repository: HouseholdConfigurationRepository = { async registerTelegramHouseholdChat(input) { @@ -79,6 +83,71 @@ function createRepositoryStub() { async listHouseholdTopicBindings(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 + ) } } diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index fa0dccb..5f65d61 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -6,6 +6,12 @@ export { } from './anonymous-feedback-service' export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service' export { createHouseholdSetupService, type HouseholdSetupService } from './household-setup-service' +export { + createHouseholdOnboardingService, + type HouseholdMiniAppAccess, + type HouseholdOnboardingIdentity, + type HouseholdOnboardingService +} from './household-onboarding-service' export { createReminderJobService, type ReminderJobResult, diff --git a/packages/db/drizzle/0006_marvelous_nehzno.sql b/packages/db/drizzle/0006_marvelous_nehzno.sql new file mode 100644 index 0000000..680ab5e --- /dev/null +++ b/packages/db/drizzle/0006_marvelous_nehzno.sql @@ -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"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0006_snapshot.json b/packages/db/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..9239dcb --- /dev/null +++ b/packages/db/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2022 @@ +{ + "id": "3bb36a80-a332-4340-8673-084cc470c322", + "prevId": "f1aee806-f5dc-46a0-b17b-2d54b552592f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.anonymous_messages": { + "name": "anonymous_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submitted_by_member_id": { + "name": "submitted_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sanitized_text": { + "name": "sanitized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderation_reason": { + "name": "moderation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "posted_chat_id": { + "name": "posted_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_thread_id": { + "name": "posted_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_message_id": { + "name": "posted_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "anonymous_messages_household_tg_update_unique": { + "name": "anonymous_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_member_created_idx": { + "name": "anonymous_messages_member_created_idx", + "columns": [ + { + "expression": "submitted_by_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_status_created_idx": { + "name": "anonymous_messages_status_created_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anonymous_messages_household_id_households_id_fk": { + "name": "anonymous_messages_household_id_households_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "anonymous_messages_submitted_by_member_id_members_id_fk": { + "name": "anonymous_messages_submitted_by_member_id_members_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "members", + "columnsFrom": ["submitted_by_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_cycles": { + "name": "billing_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_cycles_household_period_unique": { + "name": "billing_cycles_household_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycles_household_period_idx": { + "name": "billing_cycles_household_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycles_household_id_households_id_fk": { + "name": "billing_cycles_household_id_households_id_fk", + "tableFrom": "billing_cycles", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_join_tokens": { + "name": "household_join_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_telegram_user_id": { + "name": "created_by_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_join_tokens_household_unique": { + "name": "household_join_tokens_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_join_tokens_token_unique": { + "name": "household_join_tokens_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_join_tokens_household_id_households_id_fk": { + "name": "household_join_tokens_household_id_households_id_fk", + "tableFrom": "household_join_tokens", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_pending_members": { + "name": "household_pending_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_pending_members_household_user_unique": { + "name": "household_pending_members_household_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_pending_members_telegram_user_idx": { + "name": "household_pending_members_telegram_user_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_pending_members_household_id_households_id_fk": { + "name": "household_pending_members_household_id_households_id_fk", + "tableFrom": "household_pending_members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_telegram_chats": { + "name": "household_telegram_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_type": { + "name": "telegram_chat_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_telegram_chats_household_unique": { + "name": "household_telegram_chats_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_telegram_chats_chat_unique": { + "name": "household_telegram_chats_chat_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_telegram_chats_household_id_households_id_fk": { + "name": "household_telegram_chats_household_id_households_id_fk", + "tableFrom": "household_telegram_chats", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_topic_bindings": { + "name": "household_topic_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_name": { + "name": "topic_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_topic_bindings_household_role_unique": { + "name": "household_topic_bindings_household_role_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_thread_unique": { + "name": "household_topic_bindings_household_thread_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_role_idx": { + "name": "household_topic_bindings_household_role_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_topic_bindings_household_id_households_id_fk": { + "name": "household_topic_bindings_household_id_households_id_fk", + "tableFrom": "household_topic_bindings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_household_idx": { + "name": "members_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_household_tg_user_unique": { + "name": "members_household_tg_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_household_id_households_id_fk": { + "name": "members_household_id_households_id_fk", + "tableFrom": "members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_overrides": { + "name": "presence_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "utility_days": { + "name": "utility_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "presence_overrides_cycle_member_unique": { + "name": "presence_overrides_cycle_member_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presence_overrides_cycle_idx": { + "name": "presence_overrides_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "presence_overrides_cycle_id_billing_cycles_id_fk": { + "name": "presence_overrides_cycle_id_billing_cycles_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "presence_overrides_member_id_members_id_fk": { + "name": "presence_overrides_member_id_members_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_bot_messages": { + "name": "processed_bot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_key": { + "name": "source_message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "processed_bot_messages_source_message_unique": { + "name": "processed_bot_messages_source_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "processed_bot_messages_household_id_households_id_fk": { + "name": "processed_bot_messages_household_id_households_id_fk", + "tableFrom": "processed_bot_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_entries": { + "name": "purchase_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer_member_id": { + "name": "payer_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_entries_household_cycle_idx": { + "name": "purchase_entries_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_payer_idx": { + "name": "purchase_entries_payer_idx", + "columns": [ + { + "expression": "payer_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_household_tg_message_unique": { + "name": "purchase_entries_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_entries_household_id_households_id_fk": { + "name": "purchase_entries_household_id_households_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_entries_cycle_id_billing_cycles_id_fk": { + "name": "purchase_entries_cycle_id_billing_cycles_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "purchase_entries_payer_member_id_members_id_fk": { + "name": "purchase_entries_payer_member_id_members_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "members", + "columnsFrom": ["payer_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_messages": { + "name": "purchase_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parsed_amount_minor": { + "name": "parsed_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "parsed_currency": { + "name": "parsed_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_item_description": { + "name": "parsed_item_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parser_error": { + "name": "parser_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_messages_household_thread_idx": { + "name": "purchase_messages_household_thread_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_sender_idx": { + "name": "purchase_messages_sender_idx", + "columns": [ + { + "expression": "sender_telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_message_unique": { + "name": "purchase_messages_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_update_unique": { + "name": "purchase_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_messages_household_id_households_id_fk": { + "name": "purchase_messages_household_id_households_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_messages_sender_member_id_members_id_fk": { + "name": "purchase_messages_sender_member_id_members_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "members", + "columnsFrom": ["sender_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rent_rules": { + "name": "rent_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_to_period": { + "name": "effective_to_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rent_rules_household_from_period_unique": { + "name": "rent_rules_household_from_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rent_rules_household_from_period_idx": { + "name": "rent_rules_household_from_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rent_rules_household_id_households_id_fk": { + "name": "rent_rules_household_id_households_id_fk", + "tableFrom": "rent_rules", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlement_lines": { + "name": "settlement_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_share_minor": { + "name": "rent_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "utility_share_minor": { + "name": "utility_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "purchase_offset_minor": { + "name": "purchase_offset_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "net_due_minor": { + "name": "net_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "explanations": { + "name": "explanations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlement_lines_settlement_member_unique": { + "name": "settlement_lines_settlement_member_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlement_lines_settlement_idx": { + "name": "settlement_lines_settlement_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlement_lines_settlement_id_settlements_id_fk": { + "name": "settlement_lines_settlement_id_settlements_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "settlements", + "columnsFrom": ["settlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlement_lines_member_id_members_id_fk": { + "name": "settlement_lines_member_id_members_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_due_minor": { + "name": "total_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "settlements_cycle_unique": { + "name": "settlements_cycle_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_household_computed_idx": { + "name": "settlements_household_computed_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "computed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_household_id_households_id_fk": { + "name": "settlements_household_id_households_id_fk", + "tableFrom": "settlements", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlements_cycle_id_billing_cycles_id_fk": { + "name": "settlements_cycle_id_billing_cycles_id_fk", + "tableFrom": "settlements", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.utility_bills": { + "name": "utility_bills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bill_name": { + "name": "bill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by_member_id": { + "name": "created_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "utility_bills_cycle_idx": { + "name": "utility_bills_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "utility_bills_household_cycle_idx": { + "name": "utility_bills_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "utility_bills_household_id_households_id_fk": { + "name": "utility_bills_household_id_households_id_fk", + "tableFrom": "utility_bills", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_cycle_id_billing_cycles_id_fk": { + "name": "utility_bills_cycle_id_billing_cycles_id_fk", + "tableFrom": "utility_bills", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_created_by_member_id_members_id_fk": { + "name": "utility_bills_created_by_member_id_members_id_fk", + "tableFrom": "utility_bills", + "tableTo": "members", + "columnsFrom": ["created_by_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 3432222..e881bd9 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1773012360748, "tag": "0005_free_kang", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1773015092441, + "tag": "0006_marvelous_nehzno", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 6b44f64..d3eb7c2 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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( 'members', { diff --git a/packages/ports/src/household-config.ts b/packages/ports/src/household-config.ts index cfd4dd5..2fdda64 100644 --- a/packages/ports/src/household-config.ts +++ b/packages/ports/src/household-config.ts @@ -17,6 +17,22 @@ export interface HouseholdTopicBindingRecord { 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 { householdName: string telegramChatId: string @@ -49,4 +65,25 @@ export interface HouseholdConfigurationRepository { telegramThreadId: string }): Promise listHouseholdTopicBindings(householdId: string): Promise + upsertHouseholdJoinToken(input: { + householdId: string + token: string + createdByTelegramUserId?: string + }): Promise + getHouseholdJoinToken(householdId: string): Promise + getHouseholdByJoinToken(token: string): Promise + upsertPendingHouseholdMember(input: { + householdId: string + telegramUserId: string + displayName: string + username?: string + languageCode?: string + }): Promise + getPendingHouseholdMember( + householdId: string, + telegramUserId: string + ): Promise + findPendingHouseholdMemberByTelegramUserId( + telegramUserId: string + ): Promise } diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 2798e77..51cb684 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -8,6 +8,8 @@ export { export { HOUSEHOLD_TOPIC_ROLES, type HouseholdConfigurationRepository, + type HouseholdJoinTokenRecord, + type HouseholdPendingMemberRecord, type HouseholdTelegramChatRecord, type HouseholdTopicBindingRecord, type HouseholdTopicRole,