From fac2dc0e9d15f1d9ac429f7455a5a4e7d243b12c Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 9 Mar 2026 04:58:14 +0400 Subject: [PATCH] feat(bot): add household admin approval flow --- apps/bot/src/bot.ts | 2 + apps/bot/src/household-setup.ts | 125 +++++++++++- apps/bot/src/index.ts | 4 + apps/bot/src/miniapp-auth.test.ts | 24 ++- apps/bot/src/miniapp-dashboard.test.ts | 11 +- .../src/household-config-repository.ts | 165 ++++++++++++++++ .../src/household-admin-service.test.ts | 180 ++++++++++++++++++ .../src/household-admin-service.ts | 109 +++++++++++ .../src/household-onboarding-service.test.ts | 29 +++ .../src/household-setup-service.test.ts | 51 +++++ .../src/household-setup-service.ts | 11 ++ packages/application/src/index.ts | 1 + packages/ports/src/household-config.ts | 23 +++ packages/ports/src/index.ts | 1 + 14 files changed, 724 insertions(+), 12 deletions(-) create mode 100644 packages/application/src/household-admin-service.test.ts create mode 100644 packages/application/src/household-admin-service.ts diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index 5b95640..9c54bc8 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -14,6 +14,8 @@ export function createTelegramBot(token: string, logger?: Logger): Bot { '/setup [household name] - Register this group as a household', '/bind_purchase_topic - Bind the current topic as the purchase topic', '/bind_feedback_topic - Bind the current topic as the feedback topic', + '/pending_members - List pending household join requests', + '/approve_member - Approve a pending member', '/anon - Send anonymous household feedback in a private chat' ].join('\n') ) diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index a9a3ec2..cd9ca1b 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -1,4 +1,8 @@ -import type { HouseholdOnboardingService, HouseholdSetupService } from '@household/application' +import type { + HouseholdAdminService, + HouseholdOnboardingService, + HouseholdSetupService +} from '@household/application' import type { Logger } from '@household/observability' import type { Bot, Context } from 'grammy' @@ -48,10 +52,32 @@ function bindRejectionMessage( } } +function adminRejectionMessage( + reason: 'not_admin' | 'household_not_found' | 'pending_not_found' +): string { + switch (reason) { + case 'not_admin': + return 'Only household admins can manage pending members.' + case 'household_not_found': + return 'Household is not configured for this chat yet. Run /setup first.' + case 'pending_not_found': + return 'Pending member not found. Use /pending_members to inspect the queue.' + } +} + +function actorDisplayName(ctx: Context): string | undefined { + const firstName = ctx.from?.first_name?.trim() + const lastName = ctx.from?.last_name?.trim() + const fullName = [firstName, lastName].filter(Boolean).join(' ').trim() + + return fullName || ctx.from?.username?.trim() || undefined +} + export function registerHouseholdSetupCommands(options: { bot: Bot householdSetupService: HouseholdSetupService householdOnboardingService: HouseholdOnboardingService + householdAdminService: HouseholdAdminService logger?: Logger }): void { options.bot.command('start', async (ctx) => { @@ -128,7 +154,17 @@ export function registerHouseholdSetupCommands(options: { telegramChatId: ctx.chat.id.toString(), telegramChatType: ctx.chat.type, title: ctx.chat.title, - householdName: commandArgText(ctx) + householdName: commandArgText(ctx), + ...(ctx.from?.id + ? { + actorTelegramUserId: ctx.from.id.toString() + } + : {}), + ...(actorDisplayName(ctx) + ? { + actorDisplayName: actorDisplayName(ctx)! + } + : {}) }) if (result.status === 'rejected') { @@ -172,14 +208,10 @@ export function registerHouseholdSetupCommands(options: { reply_markup: { inline_keyboard: [ [ - ...(joinDeepLink - ? [ - { - text: 'Join household', - url: joinDeepLink - } - ] - : []) + { + text: 'Join household', + url: joinDeepLink + } ] ] } @@ -275,4 +307,77 @@ export function registerHouseholdSetupCommands(options: { `Feedback topic saved for ${result.household.householdName} (thread ${result.binding.telegramThreadId}).` ) }) + + options.bot.command('pending_members', async (ctx) => { + if (!isGroupChat(ctx)) { + await ctx.reply('Use /pending_members inside the household group.') + return + } + + const actorTelegramUserId = ctx.from?.id?.toString() + if (!actorTelegramUserId) { + await ctx.reply('Unable to identify sender for this command.') + return + } + + const result = await options.householdAdminService.listPendingMembers({ + actorTelegramUserId, + telegramChatId: ctx.chat.id.toString() + }) + + if (result.status === 'rejected') { + await ctx.reply(adminRejectionMessage(result.reason)) + return + } + + if (result.members.length === 0) { + await ctx.reply(`No pending members for ${result.householdName}.`) + return + } + + await ctx.reply( + [ + `Pending members for ${result.householdName}:`, + ...result.members.map( + (member, index) => + `${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}` + ), + 'Approve with /approve_member .' + ].join('\n') + ) + }) + + options.bot.command('approve_member', async (ctx) => { + if (!isGroupChat(ctx)) { + await ctx.reply('Use /approve_member inside the household group.') + return + } + + const actorTelegramUserId = ctx.from?.id?.toString() + if (!actorTelegramUserId) { + await ctx.reply('Unable to identify sender for this command.') + return + } + + const pendingTelegramUserId = commandArgText(ctx) + if (!pendingTelegramUserId) { + await ctx.reply('Usage: /approve_member ') + return + } + + const result = await options.householdAdminService.approvePendingMember({ + actorTelegramUserId, + telegramChatId: ctx.chat.id.toString(), + pendingTelegramUserId + }) + + if (result.status === 'rejected') { + await ctx.reply(adminRejectionMessage(result.reason)) + return + } + + await ctx.reply( + `Approved ${result.member.displayName} as an active member of ${result.householdName}.` + ) + }) } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 527d739..aa63b69 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -2,6 +2,7 @@ import { webhookCallback } from 'grammy' import { createAnonymousFeedbackService, + createHouseholdAdminService, createFinanceCommandService, createHouseholdOnboardingService, createHouseholdSetupService, @@ -129,6 +130,9 @@ if (householdConfigurationRepositoryClient) { householdSetupService: createHouseholdSetupService( householdConfigurationRepositoryClient.repository ), + householdAdminService: createHouseholdAdminService( + householdConfigurationRepositoryClient.repository + ), householdOnboardingService: householdOnboardingService!, logger: getLogger('household-setup') }) diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index 517588a..376f429 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -71,7 +71,29 @@ function onboardingRepository(): HouseholdConfigurationRepository { return pending }, getPendingHouseholdMember: async () => pending, - findPendingHouseholdMemberByTelegramUserId: async () => pending + findPendingHouseholdMemberByTelegramUserId: async () => pending, + ensureHouseholdMember: async (input) => ({ + householdId: household.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + isAdmin: input.isAdmin === true + }), + getHouseholdMember: async () => null, + listPendingHouseholdMembers: async () => (pending ? [pending] : []), + approvePendingHouseholdMember: async (input) => { + if (!pending || pending.telegramUserId !== input.telegramUserId) { + return null + } + + const member = { + householdId: household.householdId, + telegramUserId: pending.telegramUserId, + displayName: pending.displayName, + isAdmin: input.isAdmin === true + } + pending = null + return member + } } } diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 56abc1b..133321e 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -111,7 +111,16 @@ function onboardingRepository(): HouseholdConfigurationRepository { languageCode: input.languageCode?.trim() || null }), getPendingHouseholdMember: async () => null, - findPendingHouseholdMemberByTelegramUserId: async () => null + findPendingHouseholdMemberByTelegramUserId: async () => null, + ensureHouseholdMember: async (input) => ({ + householdId: household.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + isAdmin: input.isAdmin === true + }), + getHouseholdMember: async () => null, + listPendingHouseholdMembers: async () => [], + approvePendingHouseholdMember: async () => null } } diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index df57db0..47c8d0e 100644 --- a/packages/adapters-db/src/household-config-repository.ts +++ b/packages/adapters-db/src/household-config-repository.ts @@ -5,6 +5,7 @@ import { HOUSEHOLD_TOPIC_ROLES, type HouseholdConfigurationRepository, type HouseholdJoinTokenRecord, + type HouseholdMemberRecord, type HouseholdPendingMemberRecord, type HouseholdTelegramChatRecord, type HouseholdTopicBindingRecord, @@ -84,6 +85,20 @@ function toHouseholdPendingMemberRecord(row: { } } +function toHouseholdMemberRecord(row: { + householdId: string + telegramUserId: string + displayName: string + isAdmin: number +}): HouseholdMemberRecord { + return { + householdId: row.householdId, + telegramUserId: row.telegramUserId, + displayName: row.displayName, + isAdmin: row.isAdmin === 1 + } +} + export function createDbHouseholdConfigurationRepository(databaseUrl: string): { repository: HouseholdConfigurationRepository close: () => Promise @@ -497,6 +512,156 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { const row = rows[0] return row ? toHouseholdPendingMemberRecord(row) : null + }, + + async ensureHouseholdMember(input) { + const rows = await db + .insert(schema.members) + .values({ + householdId: input.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + isAdmin: input.isAdmin ? 1 : 0 + }) + .onConflictDoUpdate({ + target: [schema.members.householdId, schema.members.telegramUserId], + set: { + displayName: input.displayName, + ...(input.isAdmin + ? { + isAdmin: 1 + } + : {}) + } + }) + .returning({ + householdId: schema.members.householdId, + telegramUserId: schema.members.telegramUserId, + displayName: schema.members.displayName, + isAdmin: schema.members.isAdmin + }) + + const row = rows[0] + if (!row) { + throw new Error('Failed to ensure household member') + } + + return toHouseholdMemberRecord(row) + }, + + async getHouseholdMember(householdId, telegramUserId) { + const rows = await db + .select({ + householdId: schema.members.householdId, + telegramUserId: schema.members.telegramUserId, + displayName: schema.members.displayName, + isAdmin: schema.members.isAdmin + }) + .from(schema.members) + .where( + and( + eq(schema.members.householdId, householdId), + eq(schema.members.telegramUserId, telegramUserId) + ) + ) + .limit(1) + + const row = rows[0] + return row ? toHouseholdMemberRecord(row) : null + }, + + async listPendingHouseholdMembers(householdId) { + 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.householdId, householdId)) + .orderBy(schema.householdPendingMembers.createdAt) + + return rows.map(toHouseholdPendingMemberRecord) + }, + + async approvePendingHouseholdMember(input) { + return await db.transaction(async (tx) => { + const pendingRows = await tx + .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, input.householdId), + eq(schema.householdPendingMembers.telegramUserId, input.telegramUserId) + ) + ) + .limit(1) + + const pending = pendingRows[0] + if (!pending) { + return null + } + + const memberRows = await tx + .insert(schema.members) + .values({ + householdId: pending.householdId, + telegramUserId: pending.telegramUserId, + displayName: pending.displayName, + isAdmin: input.isAdmin ? 1 : 0 + }) + .onConflictDoUpdate({ + target: [schema.members.householdId, schema.members.telegramUserId], + set: { + displayName: pending.displayName, + ...(input.isAdmin + ? { + isAdmin: 1 + } + : {}) + } + }) + .returning({ + householdId: schema.members.householdId, + telegramUserId: schema.members.telegramUserId, + displayName: schema.members.displayName, + isAdmin: schema.members.isAdmin + }) + + await tx + .delete(schema.householdPendingMembers) + .where( + and( + eq(schema.householdPendingMembers.householdId, input.householdId), + eq(schema.householdPendingMembers.telegramUserId, input.telegramUserId) + ) + ) + + const member = memberRows[0] + if (!member) { + throw new Error('Failed to approve pending household member') + } + + return toHouseholdMemberRecord(member) + }) } } diff --git a/packages/application/src/household-admin-service.test.ts b/packages/application/src/household-admin-service.test.ts new file mode 100644 index 0000000..73612a0 --- /dev/null +++ b/packages/application/src/household-admin-service.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from 'bun:test' + +import type { + HouseholdConfigurationRepository, + HouseholdJoinTokenRecord, + HouseholdMemberRecord, + HouseholdPendingMemberRecord, + HouseholdTelegramChatRecord, + HouseholdTopicBindingRecord +} from '@household/ports' + +import { createHouseholdAdminService } from './household-admin-service' + +function createRepositoryStub() { + const household: HouseholdTelegramChatRecord = { + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-100123', + telegramChatType: 'supergroup', + title: 'Kojori House' + } + const members = new Map() + const pendingMembers = new Map() + + members.set('1', { + householdId: household.householdId, + telegramUserId: '1', + displayName: 'Stan', + isAdmin: true + }) + pendingMembers.set('2', { + householdId: household.householdId, + householdName: household.householdName, + telegramUserId: '2', + displayName: 'Alice', + username: 'alice', + languageCode: 'en' + }) + + const repository: HouseholdConfigurationRepository = { + 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 + }) satisfies HouseholdJoinTokenRecord, + getHouseholdJoinToken: async () => null, + getHouseholdByJoinToken: async () => household, + upsertPendingHouseholdMember: async (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 + }, + getPendingHouseholdMember: async (_householdId, telegramUserId) => + pendingMembers.get(telegramUserId) ?? null, + findPendingHouseholdMemberByTelegramUserId: async (telegramUserId) => + pendingMembers.get(telegramUserId) ?? null, + ensureHouseholdMember: async (input) => { + const record: HouseholdMemberRecord = { + householdId: input.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + isAdmin: input.isAdmin === true + } + members.set(input.telegramUserId, record) + return record + }, + getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null, + listPendingHouseholdMembers: async () => [...pendingMembers.values()], + approvePendingHouseholdMember: async (input) => { + const pending = pendingMembers.get(input.telegramUserId) + if (!pending) { + return null + } + + pendingMembers.delete(input.telegramUserId) + + const member: HouseholdMemberRecord = { + householdId: pending.householdId, + telegramUserId: pending.telegramUserId, + displayName: pending.displayName, + isAdmin: input.isAdmin === true + } + members.set(member.telegramUserId, member) + return member + } + } + + return { + repository + } +} + +describe('createHouseholdAdminService', () => { + test('lists pending members for a household admin', async () => { + const { repository } = createRepositoryStub() + const service = createHouseholdAdminService(repository) + + const result = await service.listPendingMembers({ + actorTelegramUserId: '1', + telegramChatId: '-100123' + }) + + expect(result.status).toBe('ok') + if (result.status !== 'ok') { + return + } + + expect(result.members).toEqual([ + { + householdId: 'household-1', + householdName: 'Kojori House', + telegramUserId: '2', + displayName: 'Alice', + username: 'alice', + languageCode: 'en' + } + ]) + }) + + test('rejects pending list for a non-admin member', async () => { + const { repository } = createRepositoryStub() + const service = createHouseholdAdminService(repository) + + const result = await service.listPendingMembers({ + actorTelegramUserId: '2', + telegramChatId: '-100123' + }) + + expect(result).toEqual({ + status: 'rejected', + reason: 'not_admin' + }) + }) + + test('approves a pending member into active members', async () => { + const { repository } = createRepositoryStub() + const service = createHouseholdAdminService(repository) + + const result = await service.approvePendingMember({ + actorTelegramUserId: '1', + telegramChatId: '-100123', + pendingTelegramUserId: '2' + }) + + expect(result).toEqual({ + status: 'approved', + householdName: 'Kojori House', + member: { + householdId: 'household-1', + telegramUserId: '2', + displayName: 'Alice', + isAdmin: false + } + }) + }) +}) diff --git a/packages/application/src/household-admin-service.ts b/packages/application/src/household-admin-service.ts new file mode 100644 index 0000000..a23512e --- /dev/null +++ b/packages/application/src/household-admin-service.ts @@ -0,0 +1,109 @@ +import type { + HouseholdConfigurationRepository, + HouseholdMemberRecord, + HouseholdPendingMemberRecord +} from '@household/ports' + +export interface HouseholdAdminService { + listPendingMembers(input: { actorTelegramUserId: string; telegramChatId: string }): Promise< + | { + status: 'ok' + householdName: string + members: readonly HouseholdPendingMemberRecord[] + } + | { + status: 'rejected' + reason: 'household_not_found' | 'not_admin' + } + > + approvePendingMember(input: { + actorTelegramUserId: string + telegramChatId: string + pendingTelegramUserId: string + }): Promise< + | { + status: 'approved' + householdName: string + member: HouseholdMemberRecord + } + | { + status: 'rejected' + reason: 'household_not_found' | 'not_admin' | 'pending_not_found' + } + > +} + +export function createHouseholdAdminService( + repository: HouseholdConfigurationRepository +): HouseholdAdminService { + async function resolveAuthorizedHousehold(input: { + actorTelegramUserId: string + telegramChatId: string + }) { + const household = await repository.getTelegramHouseholdChat(input.telegramChatId) + if (!household) { + return { + status: 'rejected' as const, + reason: 'household_not_found' as const + } + } + + const actor = await repository.getHouseholdMember( + household.householdId, + input.actorTelegramUserId + ) + if (!actor?.isAdmin) { + return { + status: 'rejected' as const, + reason: 'not_admin' as const + } + } + + return { + status: 'ok' as const, + household + } + } + + return { + async listPendingMembers(input) { + const access = await resolveAuthorizedHousehold(input) + if (access.status === 'rejected') { + return access + } + + const members = await repository.listPendingHouseholdMembers(access.household.householdId) + + return { + status: 'ok', + householdName: access.household.householdName, + members + } + }, + + async approvePendingMember(input) { + const access = await resolveAuthorizedHousehold(input) + if (access.status === 'rejected') { + return access + } + + const member = await repository.approvePendingHouseholdMember({ + householdId: access.household.householdId, + telegramUserId: input.pendingTelegramUserId + }) + + if (!member) { + return { + status: 'rejected', + reason: 'pending_not_found' + } + } + + return { + status: 'approved', + householdName: access.household.householdName, + member + } + } + } +} diff --git a/packages/application/src/household-onboarding-service.test.ts b/packages/application/src/household-onboarding-service.test.ts index 8184269..b67d1c6 100644 --- a/packages/application/src/household-onboarding-service.test.ts +++ b/packages/application/src/household-onboarding-service.test.ts @@ -82,6 +82,35 @@ function createRepositoryStub() { }, async findPendingHouseholdMemberByTelegramUserId(telegramUserId) { return pendingMembers.get(telegramUserId) ?? null + }, + async ensureHouseholdMember(input) { + return { + householdId: input.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + isAdmin: input.isAdmin === true + } + }, + async getHouseholdMember() { + return null + }, + async listPendingHouseholdMembers() { + return [...pendingMembers.values()] + }, + async approvePendingHouseholdMember(input) { + const pending = pendingMembers.get(input.telegramUserId) + if (!pending) { + return null + } + + pendingMembers.delete(input.telegramUserId) + + return { + householdId: pending.householdId, + telegramUserId: pending.telegramUserId, + displayName: pending.displayName, + isAdmin: input.isAdmin === true + } } } diff --git a/packages/application/src/household-setup-service.test.ts b/packages/application/src/household-setup-service.test.ts index b0667c4..23f7be1 100644 --- a/packages/application/src/household-setup-service.test.ts +++ b/packages/application/src/household-setup-service.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test' import type { HouseholdConfigurationRepository, HouseholdJoinTokenRecord, + HouseholdMemberRecord, HouseholdPendingMemberRecord, HouseholdTelegramChatRecord, HouseholdTopicBindingRecord @@ -15,6 +16,7 @@ function createRepositoryStub() { const bindings = new Map() const joinTokens = new Map() const pendingMembers = new Map() + const members = new Map() const repository: HouseholdConfigurationRepository = { async registerTelegramHouseholdChat(input) { @@ -148,6 +150,46 @@ function createRepositoryStub() { [...pendingMembers.values()].find((entry) => entry.telegramUserId === telegramUserId) ?? null ) + }, + + async ensureHouseholdMember(input) { + const key = `${input.householdId}:${input.telegramUserId}` + const existing = members.get(key) + const next: HouseholdMemberRecord = { + householdId: input.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + isAdmin: input.isAdmin === true || existing?.isAdmin === true + } + members.set(key, next) + return next + }, + + async getHouseholdMember(householdId, telegramUserId) { + return members.get(`${householdId}:${telegramUserId}`) ?? null + }, + + async listPendingHouseholdMembers(householdId) { + return [...pendingMembers.values()].filter((entry) => entry.householdId === householdId) + }, + + async approvePendingHouseholdMember(input) { + const key = `${input.householdId}:${input.telegramUserId}` + const pending = pendingMembers.get(key) + if (!pending) { + return null + } + + pendingMembers.delete(key) + + const member: HouseholdMemberRecord = { + householdId: pending.householdId, + telegramUserId: pending.telegramUserId, + displayName: pending.displayName, + isAdmin: input.isAdmin === true + } + members.set(key, member) + return member } } @@ -163,6 +205,8 @@ describe('createHouseholdSetupService', () => { const result = await service.setupGroupChat({ actorIsAdmin: true, + actorTelegramUserId: '42', + actorDisplayName: 'Stan', telegramChatId: '-100123', telegramChatType: 'supergroup', title: 'Kojori House' @@ -174,6 +218,13 @@ describe('createHouseholdSetupService', () => { } expect(result.household.householdName).toBe('Kojori House') expect(result.household.telegramChatId).toBe('-100123') + const admin = await repository.getHouseholdMember(result.household.householdId, '42') + expect(admin).toEqual({ + householdId: result.household.householdId, + telegramUserId: '42', + displayName: 'Stan', + isAdmin: true + }) }) test('rejects setup when the actor is not a group admin', async () => { diff --git a/packages/application/src/household-setup-service.ts b/packages/application/src/household-setup-service.ts index ad2fe67..bf67fd5 100644 --- a/packages/application/src/household-setup-service.ts +++ b/packages/application/src/household-setup-service.ts @@ -8,6 +8,8 @@ import type { export interface HouseholdSetupService { setupGroupChat(input: { actorIsAdmin: boolean + actorTelegramUserId?: string + actorDisplayName?: string telegramChatId: string telegramChatType: string title?: string @@ -83,6 +85,15 @@ export function createHouseholdSetupService( : {}) }) + if (registered.status === 'created' && input.actorTelegramUserId && input.actorDisplayName) { + await repository.ensureHouseholdMember({ + householdId: registered.household.householdId, + telegramUserId: input.actorTelegramUserId, + displayName: input.actorDisplayName, + isAdmin: true + }) + } + return { status: registered.status, household: registered.household diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 5f65d61..5952619 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -6,6 +6,7 @@ export { } from './anonymous-feedback-service' export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service' export { createHouseholdSetupService, type HouseholdSetupService } from './household-setup-service' +export { createHouseholdAdminService, type HouseholdAdminService } from './household-admin-service' export { createHouseholdOnboardingService, type HouseholdMiniAppAccess, diff --git a/packages/ports/src/household-config.ts b/packages/ports/src/household-config.ts index 2fdda64..eb06989 100644 --- a/packages/ports/src/household-config.ts +++ b/packages/ports/src/household-config.ts @@ -33,6 +33,13 @@ export interface HouseholdPendingMemberRecord { languageCode: string | null } +export interface HouseholdMemberRecord { + householdId: string + telegramUserId: string + displayName: string + isAdmin: boolean +} + export interface RegisterTelegramHouseholdChatInput { householdName: string telegramChatId: string @@ -86,4 +93,20 @@ export interface HouseholdConfigurationRepository { findPendingHouseholdMemberByTelegramUserId( telegramUserId: string ): Promise + ensureHouseholdMember(input: { + householdId: string + telegramUserId: string + displayName: string + isAdmin?: boolean + }): Promise + getHouseholdMember( + householdId: string, + telegramUserId: string + ): Promise + listPendingHouseholdMembers(householdId: string): Promise + approvePendingHouseholdMember(input: { + householdId: string + telegramUserId: string + isAdmin?: boolean + }): Promise } diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 51cb684..e19bd74 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -9,6 +9,7 @@ export { HOUSEHOLD_TOPIC_ROLES, type HouseholdConfigurationRepository, type HouseholdJoinTokenRecord, + type HouseholdMemberRecord, type HouseholdPendingMemberRecord, type HouseholdTelegramChatRecord, type HouseholdTopicBindingRecord,