feat(bot): cut over multi-household member flows

This commit is contained in:
2026-03-09 06:14:57 +04:00
parent de86706f4f
commit 7c602900ee
20 changed files with 1068 additions and 163 deletions

View File

@@ -23,6 +23,7 @@ function createRepositoryStub() {
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
members.set('1', {
id: 'member-1',
householdId: household.householdId,
telegramUserId: '1',
displayName: 'Stan',
@@ -43,6 +44,7 @@ function createRepositoryStub() {
household
}),
getTelegramHouseholdChat: async () => household,
getHouseholdChatByHouseholdId: async () => household,
bindHouseholdTopic: async (input) =>
({
householdId: input.householdId,
@@ -80,6 +82,7 @@ function createRepositoryStub() {
pendingMembers.get(telegramUserId) ?? null,
ensureHouseholdMember: async (input) => {
const record: HouseholdMemberRecord = {
id: `member-${input.telegramUserId}`,
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
@@ -89,6 +92,8 @@ function createRepositoryStub() {
return record
},
getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null,
listHouseholdMembersByTelegramUserId: async (telegramUserId) =>
[...members.values()].filter((member) => member.telegramUserId === telegramUserId),
listPendingHouseholdMembers: async () => [...pendingMembers.values()],
approvePendingHouseholdMember: async (input) => {
const pending = pendingMembers.get(input.telegramUserId)
@@ -99,6 +104,7 @@ function createRepositoryStub() {
pendingMembers.delete(input.telegramUserId)
const member: HouseholdMemberRecord = {
id: `member-${pending.telegramUserId}`,
householdId: pending.householdId,
telegramUserId: pending.telegramUserId,
displayName: pending.displayName,
@@ -170,6 +176,7 @@ describe('createHouseholdAdminService', () => {
status: 'approved',
householdName: 'Kojori House',
member: {
id: 'member-2',
householdId: 'household-1',
telegramUserId: '2',
displayName: 'Alice',

View File

@@ -1,8 +1,8 @@
import { describe, expect, test } from 'bun:test'
import type {
FinanceMemberRecord,
HouseholdConfigurationRepository,
HouseholdMemberRecord,
HouseholdJoinTokenRecord,
HouseholdPendingMemberRecord,
HouseholdTelegramChatRecord,
@@ -21,6 +21,7 @@ function createRepositoryStub() {
}
let joinToken: HouseholdJoinTokenRecord | null = null
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
const members = new Map<string, HouseholdMemberRecord>()
const repository: HouseholdConfigurationRepository = {
async registerTelegramHouseholdChat() {
@@ -32,6 +33,9 @@ function createRepositoryStub() {
async getTelegramHouseholdChat() {
return household
},
async getHouseholdChatByHouseholdId() {
return household
},
async bindHouseholdTopic(input) {
const binding: HouseholdTopicBindingRecord = {
householdId: input.householdId,
@@ -84,15 +88,22 @@ function createRepositoryStub() {
return pendingMembers.get(telegramUserId) ?? null
},
async ensureHouseholdMember(input) {
return {
const member = {
id: `member-${input.telegramUserId}`,
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
isAdmin: input.isAdmin === true
}
members.set(input.telegramUserId, member)
return member
},
async getHouseholdMember() {
return null
async getHouseholdMember(_householdId, telegramUserId) {
return members.get(telegramUserId) ?? null
},
async listHouseholdMembersByTelegramUserId(telegramUserId) {
const member = members.get(telegramUserId)
return member ? [member] : []
},
async listPendingHouseholdMembers() {
return [...pendingMembers.values()]
@@ -106,6 +117,7 @@ function createRepositoryStub() {
pendingMembers.delete(input.telegramUserId)
return {
id: `member-${pending.telegramUserId}`,
householdId: pending.householdId,
telegramUserId: pending.telegramUserId,
displayName: pending.displayName,
@@ -209,17 +221,16 @@ describe('createHouseholdOnboardingService', () => {
})
})
test('returns active when the user is already a finance member', async () => {
test('returns active when the user is already a household member', async () => {
const { repository } = createRepositoryStub()
const member: FinanceMemberRecord = {
id: 'member-1',
await repository.ensureHouseholdMember({
householdId: 'household-1',
telegramUserId: '42',
displayName: 'Stan',
isAdmin: true
}
})
const service = createHouseholdOnboardingService({
repository,
getMemberByTelegramUserId: async () => member
repository
})
const access = await service.getMiniAppAccess({
@@ -233,10 +244,49 @@ describe('createHouseholdOnboardingService', () => {
expect(access).toEqual({
status: 'active',
member: {
id: 'member-1',
id: 'member-42',
householdId: 'household-1',
displayName: 'Stan',
isAdmin: true
}
})
})
test('returns open_from_group when user belongs to multiple households and no join token is provided', async () => {
const { repository } = createRepositoryStub()
const member: HouseholdMemberRecord = {
id: 'member-1',
householdId: 'household-1',
telegramUserId: '42',
displayName: 'Stan',
isAdmin: true
}
const service = createHouseholdOnboardingService({ repository })
const duplicateRepository = repository as HouseholdConfigurationRepository & {
listHouseholdMembersByTelegramUserId: (
telegramUserId: string
) => Promise<readonly HouseholdMemberRecord[]>
}
duplicateRepository.listHouseholdMembersByTelegramUserId = async () => [
member,
{
id: 'member-2',
householdId: 'household-2',
telegramUserId: '42',
displayName: 'Stan elsewhere',
isAdmin: false
}
]
const access = await service.getMiniAppAccess({
identity: {
telegramUserId: '42',
displayName: 'Stan'
}
})
expect(access).toEqual({
status: 'open_from_group'
})
})
})

View File

@@ -1,6 +1,6 @@
import { randomBytes } from 'node:crypto'
import type { FinanceMemberRecord, HouseholdConfigurationRepository } from '@household/ports'
import type { HouseholdConfigurationRepository, HouseholdMemberRecord } from '@household/ports'
export interface HouseholdOnboardingIdentity {
telegramUserId: string
@@ -14,6 +14,7 @@ export type HouseholdMiniAppAccess =
status: 'active'
member: {
id: string
householdId: string
displayName: string
isAdmin: boolean
}
@@ -58,6 +59,7 @@ export interface HouseholdOnboardingService {
status: 'active'
member: {
id: string
householdId: string
displayName: string
isAdmin: boolean
}
@@ -68,13 +70,15 @@ export interface HouseholdOnboardingService {
>
}
function toMember(member: FinanceMemberRecord): {
function toMember(member: HouseholdMemberRecord): {
id: string
householdId: string
displayName: string
isAdmin: boolean
} {
return {
id: member.id,
householdId: member.householdId,
displayName: member.displayName,
isAdmin: member.isAdmin
}
@@ -86,7 +90,6 @@ function generateJoinToken(): string {
export function createHouseholdOnboardingService(options: {
repository: HouseholdConfigurationRepository
getMemberByTelegramUserId?: (telegramUserId: string) => Promise<FinanceMemberRecord | null>
tokenFactory?: () => string
}): HouseholdOnboardingService {
const createToken = options.tokenFactory ?? generateJoinToken
@@ -121,14 +124,26 @@ export function createHouseholdOnboardingService(options: {
},
async getMiniAppAccess(input) {
const activeMember = options.getMemberByTelegramUserId
? await options.getMemberByTelegramUserId(input.identity.telegramUserId)
: null
const activeMemberships = await options.repository.listHouseholdMembersByTelegramUserId(
input.identity.telegramUserId
)
const requestedHousehold =
input.joinToken !== undefined
? await options.repository.getHouseholdByJoinToken(input.joinToken)
: null
const matchingActiveMember =
requestedHousehold === null
? activeMemberships.length === 1
? activeMemberships[0]!
: null
: (activeMemberships.find(
(member) => member.householdId === requestedHousehold.householdId
) ?? null)
if (activeMember) {
if (matchingActiveMember) {
return {
status: 'active',
member: toMember(activeMember)
member: toMember(matchingActiveMember)
}
}
@@ -151,7 +166,7 @@ export function createHouseholdOnboardingService(options: {
}
}
const household = await options.repository.getHouseholdByJoinToken(input.joinToken)
const household = requestedHousehold
if (!household) {
return {
status: 'open_from_group'
@@ -189,9 +204,9 @@ export function createHouseholdOnboardingService(options: {
}
}
const activeMember = options.getMemberByTelegramUserId
? await options.getMemberByTelegramUserId(input.identity.telegramUserId)
: null
const activeMember = (
await options.repository.listHouseholdMembersByTelegramUserId(input.identity.telegramUserId)
).find((member) => member.householdId === household.householdId)
if (activeMember) {
return {

View File

@@ -53,6 +53,12 @@ function createRepositoryStub() {
return households.get(telegramChatId) ?? null
},
async getHouseholdChatByHouseholdId(householdId) {
return (
[...households.values()].find((household) => household.householdId === householdId) ?? null
)
},
async bindHouseholdTopic(input) {
const next: HouseholdTopicBindingRecord = {
householdId: input.householdId,
@@ -156,6 +162,7 @@ function createRepositoryStub() {
const key = `${input.householdId}:${input.telegramUserId}`
const existing = members.get(key)
const next: HouseholdMemberRecord = {
id: existing?.id ?? `member-${input.telegramUserId}`,
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
@@ -169,6 +176,10 @@ function createRepositoryStub() {
return members.get(`${householdId}:${telegramUserId}`) ?? null
},
async listHouseholdMembersByTelegramUserId(telegramUserId) {
return [...members.values()].filter((member) => member.telegramUserId === telegramUserId)
},
async listPendingHouseholdMembers(householdId) {
return [...pendingMembers.values()].filter((entry) => entry.householdId === householdId)
},
@@ -183,6 +194,7 @@ function createRepositoryStub() {
pendingMembers.delete(key)
const member: HouseholdMemberRecord = {
id: `member-${pending.telegramUserId}`,
householdId: pending.householdId,
telegramUserId: pending.telegramUserId,
displayName: pending.displayName,
@@ -220,6 +232,7 @@ describe('createHouseholdSetupService', () => {
expect(result.household.telegramChatId).toBe('-100123')
const admin = await repository.getHouseholdMember(result.household.householdId, '42')
expect(admin).toEqual({
id: 'member-42',
householdId: result.household.householdId,
telegramUserId: '42',
displayName: 'Stan',