feat(onboarding): add mini app household join flow

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

View File

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

View File

@@ -0,0 +1,213 @@
import { describe, expect, test } from 'bun:test'
import type {
FinanceMemberRecord,
HouseholdConfigurationRepository,
HouseholdJoinTokenRecord,
HouseholdPendingMemberRecord,
HouseholdTelegramChatRecord,
HouseholdTopicBindingRecord
} from '@household/ports'
import { createHouseholdOnboardingService } from './household-onboarding-service'
function createRepositoryStub() {
const household: HouseholdTelegramChatRecord = {
householdId: 'household-1',
householdName: 'Kojori House',
telegramChatId: '-100123',
telegramChatType: 'supergroup',
title: 'Kojori House'
}
let joinToken: HouseholdJoinTokenRecord | null = null
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
const repository: HouseholdConfigurationRepository = {
async registerTelegramHouseholdChat() {
return {
status: 'existing',
household
}
},
async getTelegramHouseholdChat() {
return household
},
async bindHouseholdTopic(input) {
const binding: HouseholdTopicBindingRecord = {
householdId: input.householdId,
role: input.role,
telegramThreadId: input.telegramThreadId,
topicName: input.topicName?.trim() || null
}
return binding
},
async getHouseholdTopicBinding() {
return null
},
async findHouseholdTopicByTelegramContext() {
return null
},
async listHouseholdTopicBindings() {
return []
},
async upsertHouseholdJoinToken(input) {
joinToken = {
householdId: household.householdId,
householdName: household.householdName,
token: input.token,
createdByTelegramUserId: input.createdByTelegramUserId ?? null
}
return joinToken
},
async getHouseholdJoinToken() {
return joinToken
},
async getHouseholdByJoinToken(token) {
return joinToken?.token === token ? household : null
},
async upsertPendingHouseholdMember(input) {
const record: HouseholdPendingMemberRecord = {
householdId: household.householdId,
householdName: household.householdName,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
username: input.username?.trim() || null,
languageCode: input.languageCode?.trim() || null
}
pendingMembers.set(input.telegramUserId, record)
return record
},
async getPendingHouseholdMember(_householdId, telegramUserId) {
return pendingMembers.get(telegramUserId) ?? null
},
async findPendingHouseholdMemberByTelegramUserId(telegramUserId) {
return pendingMembers.get(telegramUserId) ?? null
}
}
return {
repository
}
}
describe('createHouseholdOnboardingService', () => {
test('creates and reuses a stable join token for a household', async () => {
const { repository } = createRepositoryStub()
const service = createHouseholdOnboardingService({
repository,
tokenFactory: () => 'join-token'
})
const created = await service.ensureHouseholdJoinToken({
householdId: 'household-1',
actorTelegramUserId: '1'
})
const reused = await service.ensureHouseholdJoinToken({
householdId: 'household-1'
})
expect(created.token).toBe('join-token')
expect(reused.token).toBe('join-token')
})
test('reports join_required for a valid token and non-member', async () => {
const { repository } = createRepositoryStub()
const service = createHouseholdOnboardingService({
repository,
tokenFactory: () => 'join-token'
})
await service.ensureHouseholdJoinToken({
householdId: 'household-1'
})
const access = await service.getMiniAppAccess({
identity: {
telegramUserId: '42',
displayName: 'Stan'
},
joinToken: 'join-token'
})
expect(access).toEqual({
status: 'join_required',
household: {
id: 'household-1',
name: 'Kojori House'
}
})
})
test('creates a pending join request', async () => {
const { repository } = createRepositoryStub()
const service = createHouseholdOnboardingService({
repository,
tokenFactory: () => 'join-token'
})
await service.ensureHouseholdJoinToken({
householdId: 'household-1'
})
const result = await service.joinHousehold({
identity: {
telegramUserId: '42',
displayName: 'Stan',
username: 'stan'
},
joinToken: 'join-token'
})
expect(result).toEqual({
status: 'pending',
household: {
id: 'household-1',
name: 'Kojori House'
}
})
const access = await service.getMiniAppAccess({
identity: {
telegramUserId: '42',
displayName: 'Stan'
}
})
expect(access).toEqual({
status: 'pending',
household: {
id: 'household-1',
name: 'Kojori House'
}
})
})
test('returns active when the user is already a finance member', async () => {
const { repository } = createRepositoryStub()
const member: FinanceMemberRecord = {
id: 'member-1',
telegramUserId: '42',
displayName: 'Stan',
isAdmin: true
}
const service = createHouseholdOnboardingService({
repository,
getMemberByTelegramUserId: async () => member
})
const access = await service.getMiniAppAccess({
identity: {
telegramUserId: '42',
displayName: 'Stan'
},
joinToken: 'anything'
})
expect(access).toEqual({
status: 'active',
member: {
id: 'member-1',
displayName: 'Stan',
isAdmin: true
}
})
})
})

View File

@@ -0,0 +1,228 @@
import { randomBytes } from 'node:crypto'
import type { FinanceMemberRecord, HouseholdConfigurationRepository } from '@household/ports'
export interface HouseholdOnboardingIdentity {
telegramUserId: string
displayName: string
username?: string | null
languageCode?: string | null
}
export type HouseholdMiniAppAccess =
| {
status: 'active'
member: {
id: string
displayName: string
isAdmin: boolean
}
}
| {
status: 'pending'
household: {
id: string
name: string
}
}
| {
status: 'join_required'
household: {
id: string
name: string
}
}
| {
status: 'open_from_group'
}
export interface HouseholdOnboardingService {
ensureHouseholdJoinToken(input: { householdId: string; actorTelegramUserId?: string }): Promise<{
householdId: string
householdName: string
token: string
}>
getMiniAppAccess(input: {
identity: HouseholdOnboardingIdentity
joinToken?: string
}): Promise<HouseholdMiniAppAccess>
joinHousehold(input: { identity: HouseholdOnboardingIdentity; joinToken: string }): Promise<
| {
status: 'pending'
household: {
id: string
name: string
}
}
| {
status: 'active'
member: {
id: string
displayName: string
isAdmin: boolean
}
}
| {
status: 'invalid_token'
}
>
}
function toMember(member: FinanceMemberRecord): {
id: string
displayName: string
isAdmin: boolean
} {
return {
id: member.id,
displayName: member.displayName,
isAdmin: member.isAdmin
}
}
function generateJoinToken(): string {
return randomBytes(24).toString('base64url')
}
export function createHouseholdOnboardingService(options: {
repository: HouseholdConfigurationRepository
getMemberByTelegramUserId?: (telegramUserId: string) => Promise<FinanceMemberRecord | null>
tokenFactory?: () => string
}): HouseholdOnboardingService {
const createToken = options.tokenFactory ?? generateJoinToken
return {
async ensureHouseholdJoinToken(input) {
const existing = await options.repository.getHouseholdJoinToken(input.householdId)
if (existing) {
return {
householdId: existing.householdId,
householdName: existing.householdName,
token: existing.token
}
}
const token = createToken()
const created = await options.repository.upsertHouseholdJoinToken({
householdId: input.householdId,
token,
...(input.actorTelegramUserId
? {
createdByTelegramUserId: input.actorTelegramUserId
}
: {})
})
return {
householdId: created.householdId,
householdName: created.householdName,
token: created.token
}
},
async getMiniAppAccess(input) {
const activeMember = options.getMemberByTelegramUserId
? await options.getMemberByTelegramUserId(input.identity.telegramUserId)
: null
if (activeMember) {
return {
status: 'active',
member: toMember(activeMember)
}
}
const existingPending = await options.repository.findPendingHouseholdMemberByTelegramUserId(
input.identity.telegramUserId
)
if (existingPending) {
return {
status: 'pending',
household: {
id: existingPending.householdId,
name: existingPending.householdName
}
}
}
if (!input.joinToken) {
return {
status: 'open_from_group'
}
}
const household = await options.repository.getHouseholdByJoinToken(input.joinToken)
if (!household) {
return {
status: 'open_from_group'
}
}
const pending = await options.repository.getPendingHouseholdMember(
household.householdId,
input.identity.telegramUserId
)
if (pending) {
return {
status: 'pending',
household: {
id: pending.householdId,
name: pending.householdName
}
}
}
return {
status: 'join_required',
household: {
id: household.householdId,
name: household.householdName
}
}
},
async joinHousehold(input) {
const household = await options.repository.getHouseholdByJoinToken(input.joinToken)
if (!household) {
return {
status: 'invalid_token'
}
}
const activeMember = options.getMemberByTelegramUserId
? await options.getMemberByTelegramUserId(input.identity.telegramUserId)
: null
if (activeMember) {
return {
status: 'active',
member: toMember(activeMember)
}
}
const pending = await options.repository.upsertPendingHouseholdMember({
householdId: household.householdId,
telegramUserId: input.identity.telegramUserId,
displayName: input.identity.displayName,
...(input.identity.username
? {
username: input.identity.username
}
: {}),
...(input.identity.languageCode
? {
languageCode: input.identity.languageCode
}
: {})
})
return {
status: 'pending',
household: {
id: pending.householdId,
name: pending.householdName
}
}
}
}
}

View File

@@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test'
import type {
HouseholdConfigurationRepository,
HouseholdJoinTokenRecord,
HouseholdPendingMemberRecord,
HouseholdTelegramChatRecord,
HouseholdTopicBindingRecord
} from '@household/ports'
@@ -11,6 +13,8 @@ import { createHouseholdSetupService } from './household-setup-service'
function createRepositoryStub() {
const households = new Map<string, HouseholdTelegramChatRecord>()
const bindings = new Map<string, HouseholdTopicBindingRecord[]>()
const joinTokens = new Map<string, HouseholdJoinTokenRecord>()
const pendingMembers = new Map<string, HouseholdPendingMemberRecord>()
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
)
}
}

View File

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

View File

@@ -0,0 +1,26 @@
CREATE TABLE "household_join_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"token" text NOT NULL,
"created_by_telegram_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "household_pending_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"telegram_user_id" text NOT NULL,
"display_name" text NOT NULL,
"username" text,
"language_code" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "household_join_tokens" ADD CONSTRAINT "household_join_tokens_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "household_pending_members" ADD CONSTRAINT "household_pending_members_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "household_join_tokens_household_unique" ON "household_join_tokens" USING btree ("household_id");--> statement-breakpoint
CREATE UNIQUE INDEX "household_join_tokens_token_unique" ON "household_join_tokens" USING btree ("token");--> statement-breakpoint
CREATE UNIQUE INDEX "household_pending_members_household_user_unique" ON "household_pending_members" USING btree ("household_id","telegram_user_id");--> statement-breakpoint
CREATE INDEX "household_pending_members_telegram_user_idx" ON "household_pending_members" USING btree ("telegram_user_id");

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1773012360748,
"tag": "0005_free_kang",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1773015092441,
"tag": "0006_marvelous_nehzno",
"breakpoints": true
}
]
}

View File

@@ -66,6 +66,47 @@ export const householdTopicBindings = pgTable(
})
)
export const householdJoinTokens = pgTable(
'household_join_tokens',
{
id: uuid('id').defaultRandom().primaryKey(),
householdId: uuid('household_id')
.notNull()
.references(() => households.id, { onDelete: 'cascade' }),
token: text('token').notNull(),
createdByTelegramUserId: text('created_by_telegram_user_id'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
householdUnique: uniqueIndex('household_join_tokens_household_unique').on(table.householdId),
tokenUnique: uniqueIndex('household_join_tokens_token_unique').on(table.token)
})
)
export const householdPendingMembers = pgTable(
'household_pending_members',
{
id: uuid('id').defaultRandom().primaryKey(),
householdId: uuid('household_id')
.notNull()
.references(() => households.id, { onDelete: 'cascade' }),
telegramUserId: text('telegram_user_id').notNull(),
displayName: text('display_name').notNull(),
username: text('username'),
languageCode: text('language_code'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
householdUserUnique: uniqueIndex('household_pending_members_household_user_unique').on(
table.householdId,
table.telegramUserId
),
telegramUserIdx: index('household_pending_members_telegram_user_idx').on(table.telegramUserId)
})
)
export const members = pgTable(
'members',
{

View File

@@ -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<HouseholdTopicBindingRecord | null>
listHouseholdTopicBindings(householdId: string): Promise<readonly HouseholdTopicBindingRecord[]>
upsertHouseholdJoinToken(input: {
householdId: string
token: string
createdByTelegramUserId?: string
}): Promise<HouseholdJoinTokenRecord>
getHouseholdJoinToken(householdId: string): Promise<HouseholdJoinTokenRecord | null>
getHouseholdByJoinToken(token: string): Promise<HouseholdTelegramChatRecord | null>
upsertPendingHouseholdMember(input: {
householdId: string
telegramUserId: string
displayName: string
username?: string
languageCode?: string
}): Promise<HouseholdPendingMemberRecord>
getPendingHouseholdMember(
householdId: string,
telegramUserId: string
): Promise<HouseholdPendingMemberRecord | null>
findPendingHouseholdMemberByTelegramUserId(
telegramUserId: string
): Promise<HouseholdPendingMemberRecord | null>
}

View File

@@ -8,6 +8,8 @@ export {
export {
HOUSEHOLD_TOPIC_ROLES,
type HouseholdConfigurationRepository,
type HouseholdJoinTokenRecord,
type HouseholdPendingMemberRecord,
type HouseholdTelegramChatRecord,
type HouseholdTopicBindingRecord,
type HouseholdTopicRole,