From 0787847c199a479ea757342854a3fef2f77241dd Mon Sep 17 00:00:00 2001 From: whekin Date: Wed, 11 Mar 2026 15:10:20 +0400 Subject: [PATCH] feat(member): improve assistant roster awareness --- apps/bot/src/anonymous-feedback.test.ts | 1 + apps/bot/src/bot-i18n.test.ts | 1 + apps/bot/src/dm-assistant.test.ts | 140 +++++++- apps/bot/src/dm-assistant.ts | 73 ++++- apps/bot/src/finance-commands.test.ts | 1 + apps/bot/src/household-setup.test.ts | 3 + apps/bot/src/index.ts | 20 ++ apps/bot/src/member-queries.ts | 304 ++++++++++++++++++ apps/bot/src/miniapp-admin.test.ts | 147 +++++++++ apps/bot/src/miniapp-admin.ts | 204 ++++++++++++ apps/bot/src/miniapp-auth.test.ts | 1 + apps/bot/src/miniapp-billing.test.ts | 1 + apps/bot/src/miniapp-dashboard.test.ts | 1 + apps/bot/src/miniapp-locale.test.ts | 1 + apps/bot/src/server.test.ts | 50 +++ apps/bot/src/server.ts | 27 ++ apps/miniapp/src/App.tsx | 175 ++++++++++ apps/miniapp/src/i18n.ts | 8 + apps/miniapp/src/miniapp-api.ts | 60 ++++ .../src/household-config-repository.ts | 34 ++ .../src/household-admin-service.test.ts | 1 + .../src/household-onboarding-service.test.ts | 3 + .../src/household-setup-service.test.ts | 3 + .../src/locale-preference-service.test.ts | 1 + .../src/miniapp-admin-service.test.ts | 65 ++++ .../application/src/miniapp-admin-service.ts | 102 ++++++ packages/ports/src/household-config.ts | 5 + 27 files changed, 1429 insertions(+), 3 deletions(-) create mode 100644 apps/bot/src/member-queries.ts diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index 1f94cdf..058ff14 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -220,6 +220,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit rentShareWeight: 1, isAdmin: false }), + updateHouseholdMemberDisplayName: async () => null, getHouseholdBillingSettings: async (householdId) => ({ householdId, settlementCurrency: 'GEL', diff --git a/apps/bot/src/bot-i18n.test.ts b/apps/bot/src/bot-i18n.test.ts index 63edf71..13af8eb 100644 --- a/apps/bot/src/bot-i18n.test.ts +++ b/apps/bot/src/bot-i18n.test.ts @@ -131,6 +131,7 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository { throw new Error('not implemented') }, updateMemberPreferredLocale: async () => null, + updateHouseholdMemberDisplayName: async () => null, promoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberStatus: async () => null, diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index b4af981..c4e1c7e 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -157,7 +157,41 @@ function createHouseholdRepository(): HouseholdConfigurationRepository { rentShareWeight: 1, isAdmin: true }), - listHouseholdMembers: async () => [], + listHouseholdMembers: async () => [ + { + id: 'member-1', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'en', + rentShareWeight: 1, + isAdmin: true + }, + { + id: 'member-2', + householdId: 'household-1', + telegramUserId: '222222', + displayName: 'Dima', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'en', + rentShareWeight: 1, + isAdmin: false + }, + { + id: 'member-3', + householdId: 'household-1', + telegramUserId: '333333', + displayName: 'Chorbanaut', + status: 'away', + preferredLocale: null, + householdDefaultLocale: 'en', + rentShareWeight: 1, + isAdmin: false + } + ], getHouseholdBillingSettings: async () => ({ householdId: 'household-1', settlementCurrency: 'GEL', @@ -193,6 +227,7 @@ function createHouseholdRepository(): HouseholdConfigurationRepository { approvePendingHouseholdMember: async () => null, updateHouseholdDefaultLocale: async () => household, updateMemberPreferredLocale: async () => null, + updateHouseholdMemberDisplayName: async () => null, promoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberStatus: async () => null, @@ -266,6 +301,28 @@ function createFinanceService(): FinanceCommandService { paid: Money.fromMajor('500.00', 'GEL'), remaining: Money.fromMajor('350.00', 'GEL'), explanations: [] + }, + { + memberId: 'member-2', + displayName: 'Dima', + rentShare: Money.fromMajor('700.00', 'GEL'), + utilityShare: Money.fromMajor('100.00', 'GEL'), + purchaseOffset: Money.fromMajor('15.00', 'GEL'), + netDue: Money.fromMajor('815.00', 'GEL'), + paid: Money.fromMajor('200.00', 'GEL'), + remaining: Money.fromMajor('615.00', 'GEL'), + explanations: [] + }, + { + memberId: 'member-3', + displayName: 'Chorbanaut', + rentShare: Money.fromMajor('700.00', 'GEL'), + utilityShare: Money.fromMajor('0.00', 'GEL'), + purchaseOffset: Money.fromMajor('-20.00', 'GEL'), + netDue: Money.fromMajor('680.00', 'GEL'), + paid: Money.fromMajor('100.00', 'GEL'), + remaining: Money.fromMajor('580.00', 'GEL'), + explanations: [] } ], ledger: [ @@ -731,6 +788,87 @@ describe('registerDmAssistant', () => { expect(replyText).toContain('Suggested payment under utilities adjustment: 150.00 GEL') }) + test('answers household roster questions from real member data', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker() + }) + + await bot.handleUpdate(privateMessageUpdate('Who do we have in the household?') as never) + + const replyText = String( + ( + calls.find((call) => call.method === 'sendMessage')?.payload as + | { text?: unknown } + | undefined + )?.text ?? '' + ) + expect(replyText).toContain('Current household members:') + expect(replyText).toContain('Stan (active)') + expect(replyText).toContain('Dima (active)') + expect(replyText).toContain('Chorbanaut (away)') + }) + + test('answers another member purchase balance from real data', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker() + }) + + await bot.handleUpdate(privateMessageUpdate('What is Dima shared purchase balance?') as never) + + const replyText = String( + ( + calls.find((call) => call.method === 'sendMessage')?.payload as + | { text?: unknown } + | undefined + )?.text ?? '' + ) + expect(replyText).toContain("Dima's shared purchase balance is 15.00 GEL.") + }) + test('routes obvious purchase-like DMs into purchase confirmation flow', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts index 99d23ce..fc77ea9 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -19,6 +19,7 @@ import { maybeCreatePaymentProposal, parsePaymentProposalPayload } from './payment-proposals' +import { maybeCreateMemberInsightReply } from './member-queries' import type { PurchaseMessageIngestionRepository, PurchaseProposalActionResult, @@ -439,10 +440,11 @@ async function buildHouseholdContext(input: { householdConfigurationRepository: HouseholdConfigurationRepository financeService: FinanceCommandService }): Promise { - const [household, settings, dashboard] = await Promise.all([ + const [household, settings, dashboard, members] = await Promise.all([ input.householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId), input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId), - input.financeService.generateDashboard() + input.financeService.generateDashboard(), + input.householdConfigurationRepository.listHouseholdMembers(input.householdId) ]) const lines = [ @@ -491,6 +493,20 @@ async function buildHouseholdContext(input: { ) } + if (members.length > 0) { + const memberLines = members.map((member) => { + const dashboardMember = dashboard.members.find((line) => line.memberId === member.id) + + if (!dashboardMember) { + return `- ${member.displayName}: status=${member.status}, dashboard_line=missing` + } + + return `- ${member.displayName}: status=${member.status}, rent=${dashboardMember.rentShare.toMajorString()} ${dashboard.currency}, utilities=${dashboardMember.utilityShare.toMajorString()} ${dashboard.currency}, purchases=${dashboardMember.purchaseOffset.toMajorString()} ${dashboard.currency}, remaining=${dashboardMember.remaining.toMajorString()} ${dashboard.currency}` + }) + + lines.push(`Household roster and balances:\n${memberLines.join('\n')}`) + } + lines.push( `Household total remaining: ${dashboard.totalRemaining.toMajorString()} ${dashboard.currency}` ) @@ -1044,6 +1060,30 @@ export function registerDmAssistant(options: { return } + const memberInsightReply = await maybeCreateMemberInsightReply({ + rawText: ctx.msg.text, + locale, + householdId: member.householdId, + currentMemberId: member.id, + householdConfigurationRepository: options.householdConfigurationRepository, + financeService, + recentTurns: options.memoryStore.get(memoryKey).turns + }) + + if (memberInsightReply) { + options.memoryStore.appendTurn(memoryKey, { + role: 'user', + text: ctx.msg.text + }) + options.memoryStore.appendTurn(memoryKey, { + role: 'assistant', + text: memberInsightReply + }) + + await ctx.reply(memberInsightReply) + return + } + const paymentProposal = await maybeCreatePaymentProposal({ rawText: ctx.msg.text, householdId: member.householdId, @@ -1203,6 +1243,11 @@ export function registerDmAssistant(options: { try { const financeService = options.financeServiceForHousehold(household.householdId) + const memoryKey = conversationMemoryKey({ + telegramUserId, + telegramChatId, + isPrivateChat: false + }) const paymentBalanceReply = await maybeCreatePaymentBalanceReply({ rawText: mention.strippedText, householdId: household.householdId, @@ -1216,6 +1261,30 @@ export function registerDmAssistant(options: { return } + const memberInsightReply = await maybeCreateMemberInsightReply({ + rawText: mention.strippedText, + locale, + householdId: household.householdId, + currentMemberId: member.id, + householdConfigurationRepository: options.householdConfigurationRepository, + financeService, + recentTurns: options.memoryStore.get(memoryKey).turns + }) + + if (memberInsightReply) { + options.memoryStore.appendTurn(memoryKey, { + role: 'user', + text: mention.strippedText + }) + options.memoryStore.appendTurn(memoryKey, { + role: 'assistant', + text: memberInsightReply + }) + + await ctx.reply(memberInsightReply) + return + } + await replyWithAssistant({ ctx, assistant: options.assistant, diff --git a/apps/bot/src/finance-commands.test.ts b/apps/bot/src/finance-commands.test.ts index de47cd8..b1eb45f 100644 --- a/apps/bot/src/finance-commands.test.ts +++ b/apps/bot/src/finance-commands.test.ts @@ -109,6 +109,7 @@ function createRepository(): HouseholdConfigurationRepository { throw new Error('not implemented') }, updateMemberPreferredLocale: async () => null, + updateHouseholdMemberDisplayName: async () => null, promoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberStatus: async () => null, diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index ea6d256..0ccc694 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -489,6 +489,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit } : null }, + async updateHouseholdMemberDisplayName() { + return null + }, async promoteHouseholdAdmin() { return null }, diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 9ed565e..db4316d 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -49,7 +49,9 @@ import { createMiniAppPendingMembersHandler, createMiniAppPromoteMemberHandler, createMiniAppSettingsHandler, + createMiniAppUpdateMemberDisplayNameHandler, createMiniAppUpdateMemberAbsencePolicyHandler, + createMiniAppUpdateOwnDisplayNameHandler, createMiniAppUpdateMemberStatusHandler, createMiniAppUpdateMemberRentWeightHandler, createMiniAppUpdateSettingsHandler, @@ -529,6 +531,24 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-admin') }) : undefined, + miniAppUpdateOwnDisplayName: householdOnboardingService + ? createMiniAppUpdateOwnDisplayNameHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + miniAppAdminService: miniAppAdminService!, + logger: getLogger('miniapp-admin') + }) + : undefined, + miniAppUpdateMemberDisplayName: householdOnboardingService + ? createMiniAppUpdateMemberDisplayNameHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + miniAppAdminService: miniAppAdminService!, + logger: getLogger('miniapp-admin') + }) + : undefined, miniAppUpdateMemberRentWeight: householdOnboardingService ? createMiniAppUpdateMemberRentWeightHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, diff --git a/apps/bot/src/member-queries.ts b/apps/bot/src/member-queries.ts new file mode 100644 index 0000000..f5b6434 --- /dev/null +++ b/apps/bot/src/member-queries.ts @@ -0,0 +1,304 @@ +import type { FinanceCommandService } from '@household/application' +import type { + HouseholdConfigurationRepository, + HouseholdMemberLifecycleStatus, + HouseholdMemberRecord +} from '@household/ports' + +import type { BotLocale } from './i18n' + +type MemberBalanceMetric = 'purchase' | 'utilities' | 'rent' + +const ROSTER_PATTERNS = [ + /\b(who do we have|who is in|members|member list|roster)\b/i, + /кто у нас/i, + /кто.*(в доме|в household|в домохозяйстве)/i, + /участник/i, + /состав/i +] as const + +const PURCHASE_PATTERNS = [ + /\b(purchase|purchases|shared purchase|shared purchases|common purchases?)\b/i, + /покупк/i +] as const + +const UTILITIES_PATTERNS = [ + /\b(utilities|utility|gas|water|electricity|internet)\b/i, + /коммун/i, + /газ/i, + /вод/i, + /свет/i, + /элект/i, + /интернет/i +] as const + +const RENT_PATTERNS = [/\b(rent|landlord|apartment)\b/i, /аренд/i, /жиль[её]/i] as const +const SELF_PATTERNS = [/\b(i|my|me)\b/i, /\bя\b/i, /\bмне\b/i, /\bмой\b/i, /\bмоя\b/i] as const +const QUESTION_PATTERNS = [ + /\?/, + /\b(how much|what|who|which|and)\b/i, + /сколько/i, + /кто/i, + /како/i, + /\bу\b/i +] as const + +function hasMatch(patterns: readonly RegExp[], value: string): boolean { + return patterns.some((pattern) => pattern.test(value)) +} + +function normalizeText(value: string): string { + return value + .toLowerCase() + .replace(/['’]s\b/g, '') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function aliasVariants(token: string): string[] { + const aliases = new Set([token]) + + if (token.endsWith('а') && token.length > 2) { + aliases.add(`${token.slice(0, -1)}ы`) + aliases.add(`${token.slice(0, -1)}е`) + aliases.add(`${token.slice(0, -1)}у`) + } + + if (token.endsWith('я') && token.length > 2) { + aliases.add(`${token.slice(0, -1)}и`) + aliases.add(`${token.slice(0, -1)}ю`) + } + + return [...aliases] +} + +function memberAliases(member: HouseholdMemberRecord): string[] { + const normalized = normalizeText(member.displayName) + const tokens = normalized.split(' ').filter((token) => token.length >= 2) + const aliases = new Set([normalized, ...tokens]) + + for (const token of tokens) { + for (const alias of aliasVariants(token)) { + aliases.add(alias) + } + } + + return [...aliases] +} + +function inferMetric( + rawText: string, + recentTurns: readonly { role: 'user' | 'assistant'; text: string }[] +) { + if (hasMatch(PURCHASE_PATTERNS, rawText)) { + return 'purchase' + } + + if (hasMatch(UTILITIES_PATTERNS, rawText)) { + return 'utilities' + } + + if (hasMatch(RENT_PATTERNS, rawText)) { + return 'rent' + } + + const lastUserTurn = [...recentTurns].reverse().find((turn) => turn.role === 'user') + if (!lastUserTurn) { + return null + } + + return inferMetric(lastUserTurn.text, []) +} + +function resolveTargetMember(input: { + rawText: string + currentMemberId: string + members: readonly HouseholdMemberRecord[] +}): HouseholdMemberRecord | null { + if (hasMatch(SELF_PATTERNS, input.rawText)) { + return input.members.find((member) => member.id === input.currentMemberId) ?? null + } + + const normalizedText = ` ${normalizeText(input.rawText)} ` + const candidates = input.members + .map((member) => ({ + member, + score: memberAliases(member).reduce((best, alias) => { + const normalizedAlias = alias.trim() + if (normalizedAlias.length < 2) { + return best + } + + if ( + normalizedText.includes(` ${normalizedAlias} `) || + normalizedText.endsWith(` ${normalizedAlias}`) || + normalizedText.startsWith(`${normalizedAlias} `) + ) { + return Math.max(best, normalizedAlias.length + 10) + } + + if (normalizedAlias.length >= 3 && normalizedText.includes(normalizedAlias)) { + return Math.max(best, normalizedAlias.length) + } + + return best + }, 0) + })) + .filter((entry) => entry.score > 0) + .sort((left, right) => right.score - left.score) + + if (candidates[0]) { + return candidates[0].member + } + + return input.members.find((member) => member.id === input.currentMemberId) ?? null +} + +function formatStatus(locale: BotLocale, status: HouseholdMemberLifecycleStatus): string { + if (locale === 'ru') { + switch (status) { + case 'away': + return 'в отъезде' + case 'left': + return 'выехал' + default: + return 'активен' + } + } + + switch (status) { + case 'away': + return 'away' + case 'left': + return 'left' + default: + return 'active' + } +} + +function rosterReply(locale: BotLocale, members: readonly HouseholdMemberRecord[]): string { + const lines = members.map( + (member) => `- ${member.displayName} (${formatStatus(locale, member.status)})` + ) + + if (locale === 'ru') { + return `У нас в household сейчас:\n${lines.join('\n')}` + } + + return `Current household members:\n${lines.join('\n')}` +} + +function memberMetricReply(input: { + locale: BotLocale + metric: MemberBalanceMetric + targetMember: HouseholdMemberRecord + currentMemberId: string + currency: 'GEL' | 'USD' + values: { + purchase: string + utilities: string + rent: string + } +}): string { + const isCurrentMember = input.targetMember.id === input.currentMemberId + + if (input.locale === 'ru') { + switch (input.metric) { + case 'purchase': + return isCurrentMember + ? `Твой баланс по общим покупкам: ${input.values.purchase} ${input.currency}.` + : `Баланс ${input.targetMember.displayName} по общим покупкам: ${input.values.purchase} ${input.currency}.` + case 'utilities': + return isCurrentMember + ? `Твоя коммуналка к оплате: ${input.values.utilities} ${input.currency}.` + : `Коммуналка ${input.targetMember.displayName} к оплате: ${input.values.utilities} ${input.currency}.` + case 'rent': + return isCurrentMember + ? `Твоя аренда к оплате: ${input.values.rent} ${input.currency}.` + : `Аренда ${input.targetMember.displayName} к оплате: ${input.values.rent} ${input.currency}.` + } + } + + switch (input.metric) { + case 'purchase': + return isCurrentMember + ? `Your shared purchase balance is ${input.values.purchase} ${input.currency}.` + : `${input.targetMember.displayName}'s shared purchase balance is ${input.values.purchase} ${input.currency}.` + case 'utilities': + return isCurrentMember + ? `Your utilities due is ${input.values.utilities} ${input.currency}.` + : `${input.targetMember.displayName}'s utilities due is ${input.values.utilities} ${input.currency}.` + case 'rent': + return isCurrentMember + ? `Your rent due is ${input.values.rent} ${input.currency}.` + : `${input.targetMember.displayName}'s rent due is ${input.values.rent} ${input.currency}.` + } +} + +export async function maybeCreateMemberInsightReply(input: { + rawText: string + locale: BotLocale + householdId: string + currentMemberId: string + householdConfigurationRepository: Pick + financeService: FinanceCommandService + recentTurns: readonly { role: 'user' | 'assistant'; text: string }[] +}): Promise { + const normalizedText = input.rawText.trim() + if (normalizedText.length === 0) { + return null + } + + const members = await input.householdConfigurationRepository.listHouseholdMembers( + input.householdId + ) + if (members.length === 0) { + return null + } + + if (hasMatch(ROSTER_PATTERNS, normalizedText) && hasMatch(QUESTION_PATTERNS, normalizedText)) { + return rosterReply(input.locale, members) + } + + if (!hasMatch(QUESTION_PATTERNS, normalizedText)) { + return null + } + + const metric = inferMetric(normalizedText, input.recentTurns) + if (!metric) { + return null + } + + const dashboard = await input.financeService.generateDashboard() + if (!dashboard) { + return null + } + + const targetMember = resolveTargetMember({ + rawText: normalizedText, + currentMemberId: input.currentMemberId, + members + }) + if (!targetMember) { + return null + } + + const memberLine = dashboard.members.find((member) => member.memberId === targetMember.id) + if (!memberLine) { + return null + } + + return memberMetricReply({ + locale: input.locale, + metric, + targetMember, + currentMemberId: input.currentMemberId, + currency: dashboard.currency, + values: { + purchase: memberLine.purchaseOffset.toMajorString(), + utilities: memberLine.utilityShare.toMajorString(), + rent: memberLine.rentShare.toMajorString() + } + }) +} diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index fd4ea1e..8f61015 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -11,7 +11,9 @@ import { createMiniAppPendingMembersHandler, createMiniAppPromoteMemberHandler, createMiniAppSettingsHandler, + createMiniAppUpdateMemberDisplayNameHandler, createMiniAppUpdateMemberAbsencePolicyHandler, + createMiniAppUpdateOwnDisplayNameHandler, createMiniAppUpdateMemberStatusHandler, createMiniAppUpdateSettingsHandler } from './miniapp-admin' @@ -147,6 +149,20 @@ function onboardingRepository(): HouseholdConfigurationRepository { isAdmin: false } : null, + updateHouseholdMemberDisplayName: async (_householdId, memberId, displayName) => + memberId === 'member-123456' || memberId === 'member-555777' + ? { + id: memberId, + householdId: 'household-1', + telegramUserId: memberId === 'member-555777' ? '555777' : '123456', + displayName, + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: memberId === 'member-123456' + } + : null, getHouseholdBillingSettings: async (householdId) => ({ householdId, settlementCurrency: 'GEL', @@ -620,6 +636,137 @@ describe('createMiniAppPromoteMemberHandler', () => { }) }) +describe('createMiniAppUpdateOwnDisplayNameHandler', () => { + test('updates the acting member display name for an authenticated member', async () => { + const authDate = Math.floor(Date.now() / 1000) + const repository = onboardingRepository() + repository.listHouseholdMembersByTelegramUserId = async () => [ + { + id: 'member-555777', + householdId: 'household-1', + telegramUserId: '555777', + displayName: 'Mia', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: false + } + ] + + const handler = createMiniAppUpdateOwnDisplayNameHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + onboardingService: createHouseholdOnboardingService({ + repository + }), + miniAppAdminService: createMiniAppAdminService(repository) + }) + + const response = await handler.handler( + new Request('http://localhost/api/miniapp/member/display-name', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildMiniAppInitData('test-bot-token', authDate, { + id: 555777, + first_name: 'Mia', + username: 'mia', + language_code: 'ru' + }), + displayName: 'Mia Cozy' + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + member: { + id: 'member-555777', + householdId: 'household-1', + telegramUserId: '555777', + displayName: 'Mia Cozy', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: false + } + }) + }) +}) + +describe('createMiniAppUpdateMemberDisplayNameHandler', () => { + test('updates a household member display name for an authenticated admin', async () => { + const authDate = Math.floor(Date.now() / 1000) + const repository = onboardingRepository() + repository.listHouseholdMembersByTelegramUserId = async () => [ + { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: true + } + ] + + const handler = createMiniAppUpdateMemberDisplayNameHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + onboardingService: createHouseholdOnboardingService({ + repository + }), + miniAppAdminService: createMiniAppAdminService(repository) + }) + + const response = await handler.handler( + new Request('http://localhost/api/miniapp/admin/members/display-name', { + 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', + username: 'stanislav', + language_code: 'ru' + }), + memberId: 'member-555777', + displayName: 'Mia Cozy' + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + member: { + id: 'member-555777', + householdId: 'household-1', + telegramUserId: '555777', + displayName: 'Mia Cozy', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: false + } + }) + }) +}) + describe('createMiniAppUpdateMemberStatusHandler', () => { test('updates a household member status for an authenticated admin', async () => { const authDate = Math.floor(Date.now() / 1000) diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index 8dd2259..68df990 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -199,6 +199,41 @@ async function readPromoteMemberPayload(request: Request): Promise<{ } } +async function readDisplayNamePayload(request: Request): Promise<{ + initData: string + displayName: string + memberId?: string +}> { + const clonedRequest = request.clone() + const payload = await readMiniAppRequestPayload(request) + if (!payload.initData) { + throw new Error('Missing initData') + } + + const text = await clonedRequest.text() + let parsed: { memberId?: string; displayName?: string } + try { + parsed = JSON.parse(text) + } catch { + throw new Error('Invalid JSON body') + } + + const displayName = parsed.displayName?.trim() + if (!displayName) { + throw new Error('Missing displayName') + } + + return { + initData: payload.initData, + displayName, + ...(typeof parsed.memberId === 'string' && parsed.memberId.trim().length > 0 + ? { + memberId: parsed.memberId.trim() + } + : {}) + } +} + async function readRentWeightPayload(request: Request): Promise<{ initData: string memberId: string @@ -798,6 +833,82 @@ export function createMiniAppPromoteMemberHandler(options: { } } +export function createMiniAppUpdateOwnDisplayNameHandler(options: { + allowedOrigins: readonly string[] + botToken: string + onboardingService: HouseholdOnboardingService + miniAppAdminService: MiniAppAdminService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + 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 readDisplayNamePayload(request) + const session = await sessionService.authenticate({ + initData: payload.initData + }) + + if (!session || !session.authorized || !session.member) { + return miniAppJsonResponse( + { ok: false, error: 'Active household membership required' }, + session ? 403 : 401, + origin + ) + } + + const result = await options.miniAppAdminService.updateOwnDisplayName({ + householdId: session.member.householdId, + actorMemberId: session.member.id, + displayName: payload.displayName + }) + + if (result.status === 'rejected') { + return miniAppJsonResponse( + { + ok: false, + error: + result.reason === 'invalid_display_name' + ? 'Invalid display name' + : 'Member not found' + }, + result.reason === 'invalid_display_name' ? 400 : 404, + origin + ) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + member: result.member + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + export function createMiniAppUpdateMemberRentWeightHandler(options: { allowedOrigins: readonly string[] botToken: string @@ -894,6 +1005,99 @@ export function createMiniAppUpdateMemberRentWeightHandler(options: { } } +export function createMiniAppUpdateMemberDisplayNameHandler(options: { + allowedOrigins: readonly string[] + botToken: string + onboardingService: HouseholdOnboardingService + miniAppAdminService: MiniAppAdminService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + 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 readDisplayNamePayload(request) + const session = await sessionService.authenticate({ + initData: payload.initData + }) + + if ( + !session || + !session.authorized || + !session.member || + session.member.status !== 'active' || + !session.member.isAdmin + ) { + return miniAppJsonResponse( + { ok: false, error: 'Admin access required for active household members' }, + session ? 403 : 401, + origin + ) + } + + if (!payload.memberId) { + return miniAppJsonResponse({ ok: false, error: 'Missing memberId' }, 400, origin) + } + + const result = await options.miniAppAdminService.updateMemberDisplayName({ + householdId: session.member.householdId, + actorIsAdmin: session.member.isAdmin, + memberId: payload.memberId, + displayName: payload.displayName + }) + + if (result.status === 'rejected') { + return miniAppJsonResponse( + { + ok: false, + error: + result.reason === 'invalid_display_name' + ? 'Invalid display name' + : result.reason === 'member_not_found' + ? 'Member not found' + : 'Admin access required' + }, + result.reason === 'invalid_display_name' + ? 400 + : result.reason === 'member_not_found' + ? 404 + : 403, + origin + ) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + member: result.member + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + export function createMiniAppUpdateMemberStatusHandler(options: { allowedOrigins: readonly string[] botToken: string diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index 30d74cc..7008617 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -136,6 +136,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { } : null }, + updateHouseholdMemberDisplayName: async () => null, updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) => { const member = [...members.values()].find((entry) => entry.id === memberId) return member diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index 46179f0..add3a92 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -131,6 +131,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { defaultLocale: locale }), updateMemberPreferredLocale: async () => null, + updateHouseholdMemberDisplayName: async () => null, promoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, updateHouseholdMemberStatus: async () => null, diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index ea16e0c..2e3cfb0 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -206,6 +206,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { defaultLocale: locale }), updateMemberPreferredLocale: async () => null, + updateHouseholdMemberDisplayName: async () => null, getHouseholdBillingSettings: async (householdId) => ({ householdId, settlementCurrency: 'GEL', diff --git a/apps/bot/src/miniapp-locale.test.ts b/apps/bot/src/miniapp-locale.test.ts index 5d2bb1b..e337bd5 100644 --- a/apps/bot/src/miniapp-locale.test.ts +++ b/apps/bot/src/miniapp-locale.test.ts @@ -132,6 +132,7 @@ function repository(): HouseholdConfigurationRepository { members.set(telegramUserId, next) return next }, + updateHouseholdMemberDisplayName: async () => null, getHouseholdBillingSettings: async (householdId) => ({ householdId, settlementCurrency: 'GEL', diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index e7f1807..19505f1 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -73,6 +73,24 @@ describe('createBotWebhookServer', () => { } }) }, + miniAppUpdateOwnDisplayName: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, + miniAppUpdateMemberDisplayName: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, miniAppUpdateMemberRentWeight: { handler: async () => new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), { @@ -340,6 +358,38 @@ describe('createBotWebhookServer', () => { }) }) + test('accepts mini app own display name update request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/member/display-name', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + member: {} + }) + }) + + test('accepts mini app member display name update request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/members/display-name', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + member: {} + }) + }) + test('accepts mini app rent weight update request', async () => { const response = await server.fetch( new Request('http://localhost/api/miniapp/admin/members/rent-weight', { diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 606b82a..74ce863 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -56,6 +56,18 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppUpdateOwnDisplayName?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppUpdateMemberDisplayName?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppUpdateMemberRentWeight?: | { path?: string @@ -196,6 +208,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { options.miniAppUpsertUtilityCategory?.path ?? '/api/miniapp/admin/utility-categories/upsert' const miniAppPromoteMemberPath = options.miniAppPromoteMember?.path ?? '/api/miniapp/admin/members/promote' + const miniAppUpdateOwnDisplayNamePath = + options.miniAppUpdateOwnDisplayName?.path ?? '/api/miniapp/member/display-name' + const miniAppUpdateMemberDisplayNamePath = + options.miniAppUpdateMemberDisplayName?.path ?? '/api/miniapp/admin/members/display-name' const miniAppUpdateMemberRentWeightPath = options.miniAppUpdateMemberRentWeight?.path ?? '/api/miniapp/admin/members/rent-weight' const miniAppUpdateMemberStatusPath = @@ -277,6 +293,17 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppPromoteMember.handler(request) } + if (options.miniAppUpdateOwnDisplayName && url.pathname === miniAppUpdateOwnDisplayNamePath) { + return await options.miniAppUpdateOwnDisplayName.handler(request) + } + + if ( + options.miniAppUpdateMemberDisplayName && + url.pathname === miniAppUpdateMemberDisplayNamePath + ) { + return await options.miniAppUpdateMemberDisplayName.handler(request) + } + if ( options.miniAppUpdateMemberRentWeight && url.pathname === miniAppUpdateMemberRentWeightPath diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 30b3719..0eb700a 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -17,9 +17,11 @@ import { joinMiniAppHousehold, openMiniAppBillingCycle, promoteMiniAppMember, + updateMiniAppMemberDisplayName, updateMiniAppMemberAbsencePolicy, updateMiniAppMemberStatus, updateMiniAppMemberRentWeight, + updateMiniAppOwnDisplayName, type MiniAppAdminCycleState, type MiniAppAdminSettingsPayload, type MiniAppMemberAbsencePolicy, @@ -311,11 +313,19 @@ function App() { const [joining, setJoining] = createSignal(false) const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal(null) const [promotingMemberId, setPromotingMemberId] = createSignal(null) + const [savingOwnDisplayName, setSavingOwnDisplayName] = createSignal(false) + const [savingMemberDisplayNameId, setSavingMemberDisplayNameId] = createSignal( + null + ) const [savingRentWeightMemberId, setSavingRentWeightMemberId] = createSignal(null) const [savingMemberStatusId, setSavingMemberStatusId] = createSignal(null) const [savingMemberAbsencePolicyId, setSavingMemberAbsencePolicyId] = createSignal( null ) + const [displayNameDraft, setDisplayNameDraft] = createSignal('') + const [memberDisplayNameDrafts, setMemberDisplayNameDrafts] = createSignal< + Record + >({}) const [rentWeightDrafts, setRentWeightDrafts] = createSignal>({}) const [memberStatusDrafts, setMemberStatusDrafts] = createSignal< Record @@ -471,6 +481,65 @@ function App() { ) } + function syncDisplayName(memberId: string, displayName: string) { + setSession((current) => + current.status === 'ready' && current.member.id === memberId + ? { + ...current, + member: { + ...current.member, + displayName + } + } + : current + ) + setAdminSettings((current) => + current + ? { + ...current, + members: current.members.map((member) => + member.id === memberId + ? { + ...member, + displayName + } + : member + ) + } + : current + ) + setDashboard((current) => + current + ? { + ...current, + members: current.members.map((member) => + member.memberId === memberId + ? { + ...member, + displayName + } + : member + ), + ledger: current.ledger.map((entry) => + entry.memberId === memberId + ? { + ...entry, + actorDisplayName: displayName + } + : entry + ) + } + : current + ) + setDisplayNameDraft((current) => + readySession()?.member.id === memberId ? displayName : current + ) + setMemberDisplayNameDrafts((current) => ({ + ...current, + [memberId]: displayName + })) + } + async function loadDashboard(initData: string) { try { const nextDashboard = await fetchMiniAppDashboard(initData) @@ -504,6 +573,9 @@ function App() { try { const payload = await fetchMiniAppAdminSettings(initData) setAdminSettings(payload) + setMemberDisplayNameDrafts( + Object.fromEntries(payload.members.map((member) => [member.id, member.displayName])) + ) setRentWeightDrafts( Object.fromEntries( payload.members.map((member) => [member.id, String(member.rentShareWeight)]) @@ -655,6 +727,7 @@ function App() { } setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale) + setDisplayNameDraft(payload.member.displayName) setSession({ status: 'ready', mode: 'live', @@ -673,6 +746,7 @@ function App() { } } catch { if (import.meta.env.DEV) { + setDisplayNameDraft(demoSession.member.displayName) setSession(demoSession) setDashboard({ period: '2026-03', @@ -793,6 +867,7 @@ function App() { const payload = await joinMiniAppHousehold(initData, joinToken) if (payload.authorized && payload.member && payload.telegramUser) { setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale) + setDisplayNameDraft(payload.member.displayName) setSession({ status: 'ready', mode: 'live', @@ -922,6 +997,51 @@ function App() { } } + async function handleSaveOwnDisplayName() { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + const nextDisplayName = displayNameDraft().trim() + if (!initData || currentReady?.mode !== 'live' || nextDisplayName.length === 0) { + return + } + + setSavingOwnDisplayName(true) + + try { + const updatedMember = await updateMiniAppOwnDisplayName(initData, nextDisplayName) + syncDisplayName(updatedMember.id, updatedMember.displayName) + } finally { + setSavingOwnDisplayName(false) + } + } + + async function handleSaveMemberDisplayName(memberId: string) { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + const nextDisplayName = memberDisplayNameDrafts()[memberId]?.trim() + if ( + !initData || + currentReady?.mode !== 'live' || + !currentReady.member.isAdmin || + !nextDisplayName + ) { + return + } + + setSavingMemberDisplayNameId(memberId) + + try { + const updatedMember = await updateMiniAppMemberDisplayName( + initData, + memberId, + nextDisplayName + ) + syncDisplayName(updatedMember.id, updatedMember.displayName) + } finally { + setSavingMemberDisplayNameId(null) + } + } + async function handleSaveBillingSettings() { const initData = webApp?.initData?.trim() const currentReady = readySession() @@ -2764,6 +2884,18 @@ function App() {
+ +
+
+ +
+
{renderPanel()}
diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index dd103ec..033073a 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -148,6 +148,10 @@ export const dictionary = { savingCategory: 'Saving…', adminsTitle: 'Admins', adminsBody: 'Promote trusted household members so they can manage billing and approvals.', + displayNameLabel: 'Household display name', + displayNameHint: 'This name appears in balances, ledger entries, and assistant replies.', + saveDisplayName: 'Save name', + savingDisplayName: 'Saving name…', memberStatusLabel: 'Member status', saveMemberStatusAction: 'Save status', savingMemberStatus: 'Saving status…', @@ -332,6 +336,10 @@ export const dictionary = { adminsTitle: 'Админы', adminsBody: 'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.', + displayNameLabel: 'Имя в household', + displayNameHint: 'Это имя будет видно в балансе, леджере и ответах ассистента.', + saveDisplayName: 'Сохранить имя', + savingDisplayName: 'Сохраняем имя…', memberStatusLabel: 'Статус участника', saveMemberStatusAction: 'Сохранить статус', savingMemberStatus: 'Сохраняем статус…', diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 3691c2a..8b85218 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -509,6 +509,66 @@ export async function promoteMiniAppMember( return payload.member } +export async function updateMiniAppOwnDisplayName( + initData: string, + displayName: string +): Promise> { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/member/display-name`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + displayName + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + member?: MiniAppSession['member'] + error?: string + } + + if (!response.ok || !payload.authorized || !payload.member) { + throw new Error(payload.error ?? 'Failed to update display name') + } + + return payload.member +} + +export async function updateMiniAppMemberDisplayName( + initData: string, + memberId: string, + displayName: string +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/members/display-name`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + memberId, + displayName + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + member?: MiniAppMember + error?: string + } + + if (!response.ok || !payload.member) { + throw new Error(payload.error ?? 'Failed to update member display name') + } + + return payload.member +} + export async function updateMiniAppMemberRentWeight( initData: string, memberId: string, diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index 55bea5b..9f9295b 100644 --- a/packages/adapters-db/src/household-config-repository.ts +++ b/packages/adapters-db/src/household-config-repository.ts @@ -1297,6 +1297,40 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { }) }, + async updateHouseholdMemberDisplayName(householdId, memberId, displayName) { + const rows = await db + .update(schema.members) + .set({ + displayName + }) + .where(and(eq(schema.members.householdId, householdId), eq(schema.members.id, memberId))) + .returning({ + id: schema.members.id, + householdId: schema.members.householdId, + telegramUserId: schema.members.telegramUserId, + displayName: schema.members.displayName, + lifecycleStatus: schema.members.lifecycleStatus, + preferredLocale: schema.members.preferredLocale, + rentShareWeight: schema.members.rentShareWeight, + isAdmin: schema.members.isAdmin + }) + + const row = rows[0] + if (!row) { + return null + } + + const household = await this.getHouseholdChatByHouseholdId(householdId) + if (!household) { + throw new Error('Failed to resolve household chat after member display name update') + } + + return toHouseholdMemberRecord({ + ...row, + defaultLocale: household.defaultLocale + }) + }, + async promoteHouseholdAdmin(householdId, memberId) { const rows = await db .update(schema.members) diff --git a/packages/application/src/household-admin-service.test.ts b/packages/application/src/household-admin-service.test.ts index 432b043..63fadcf 100644 --- a/packages/application/src/household-admin-service.test.ts +++ b/packages/application/src/household-admin-service.test.ts @@ -145,6 +145,7 @@ function createRepositoryStub() { } : null }, + updateHouseholdMemberDisplayName: async () => null, getHouseholdBillingSettings: async (householdId) => ({ householdId, settlementCurrency: 'GEL', diff --git a/packages/application/src/household-onboarding-service.test.ts b/packages/application/src/household-onboarding-service.test.ts index 91f713d..0ab51bc 100644 --- a/packages/application/src/household-onboarding-service.test.ts +++ b/packages/application/src/household-onboarding-service.test.ts @@ -156,6 +156,9 @@ function createRepositoryStub() { } : null }, + async updateHouseholdMemberDisplayName() { + return null + }, async getHouseholdBillingSettings(householdId) { return { householdId, diff --git a/packages/application/src/household-setup-service.test.ts b/packages/application/src/household-setup-service.test.ts index dcf6f4f..e4a29f5 100644 --- a/packages/application/src/household-setup-service.test.ts +++ b/packages/application/src/household-setup-service.test.ts @@ -252,6 +252,9 @@ function createRepositoryStub() { } : null }, + async updateHouseholdMemberDisplayName() { + return null + }, async getHouseholdBillingSettings(householdId) { return { householdId, diff --git a/packages/application/src/locale-preference-service.test.ts b/packages/application/src/locale-preference-service.test.ts index 2b6f233..e367ab7 100644 --- a/packages/application/src/locale-preference-service.test.ts +++ b/packages/application/src/locale-preference-service.test.ts @@ -78,6 +78,7 @@ function createRepository(): HouseholdConfigurationRepository { householdDefaultLocale: 'ru' } : null, + updateHouseholdMemberDisplayName: async () => null, getHouseholdBillingSettings: async (householdId) => ({ householdId, settlementCurrency: 'GEL', diff --git a/packages/application/src/miniapp-admin-service.test.ts b/packages/application/src/miniapp-admin-service.test.ts index e5c0646..57cdb55 100644 --- a/packages/application/src/miniapp-admin-service.test.ts +++ b/packages/application/src/miniapp-admin-service.test.ts @@ -136,6 +136,20 @@ function repository(): HouseholdConfigurationRepository { isAdmin: false } : null, + updateHouseholdMemberDisplayName: async (_householdId, memberId, displayName) => + memberId === 'member-123456' + ? { + id: memberId, + householdId: 'household-1', + telegramUserId: '123456', + displayName, + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: false + } + : null, getHouseholdBillingSettings: async (householdId) => ({ householdId, settlementCurrency: 'GEL', @@ -445,6 +459,57 @@ describe('createMiniAppAdminService', () => { }) }) + test('updates the acting member display name', async () => { + const service = createMiniAppAdminService(repository()) + + const result = await service.updateOwnDisplayName({ + householdId: 'household-1', + actorMemberId: 'member-123456', + displayName: 'Stan Cozy' + }) + + expect(result).toEqual({ + status: 'ok', + member: { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan Cozy', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: false + } + }) + }) + + test('updates another member display name for admins', async () => { + const service = createMiniAppAdminService(repository()) + + const result = await service.updateMemberDisplayName({ + householdId: 'household-1', + actorIsAdmin: true, + memberId: 'member-123456', + displayName: 'Stan Cozy' + }) + + expect(result).toEqual({ + status: 'ok', + member: { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan Cozy', + status: 'active', + preferredLocale: null, + householdDefaultLocale: 'ru', + rentShareWeight: 1, + isAdmin: false + } + }) + }) + test('updates a household member lifecycle status for admins', async () => { const service = createMiniAppAdminService(repository()) diff --git a/packages/application/src/miniapp-admin-service.ts b/packages/application/src/miniapp-admin-service.ts index e8d2996..0a35917 100644 --- a/packages/application/src/miniapp-admin-service.ts +++ b/packages/application/src/miniapp-admin-service.ts @@ -146,6 +146,35 @@ export interface MiniAppAdminService { reason: 'not_admin' | 'member_not_found' } > + updateOwnDisplayName(input: { + householdId: string + actorMemberId: string + displayName: string + }): Promise< + | { + status: 'ok' + member: HouseholdMemberRecord + } + | { + status: 'rejected' + reason: 'invalid_display_name' | 'member_not_found' + } + > + updateMemberDisplayName(input: { + householdId: string + actorIsAdmin: boolean + memberId: string + displayName: string + }): Promise< + | { + status: 'ok' + member: HouseholdMemberRecord + } + | { + status: 'rejected' + reason: 'not_admin' | 'invalid_display_name' | 'member_not_found' + } + > updateMemberAbsencePolicy(input: { householdId: string actorIsAdmin: boolean @@ -171,6 +200,16 @@ function periodFromLocalDate(localDate: Temporal.PlainDate): string { return `${localDate.year}-${String(localDate.month).padStart(2, '0')}` } +function normalizeDisplayName(raw: string): string | null { + const trimmed = raw.trim() + + if (trimmed.length < 2 || trimmed.length > 80) { + return null + } + + return trimmed.replace(/\s+/g, ' ') +} + export function createMiniAppAdminService( repository: HouseholdConfigurationRepository ): MiniAppAdminService { @@ -445,6 +484,69 @@ export function createMiniAppAdminService( } }, + async updateOwnDisplayName(input) { + const displayName = normalizeDisplayName(input.displayName) + if (!displayName) { + return { + status: 'rejected', + reason: 'invalid_display_name' + } + } + + const member = await repository.updateHouseholdMemberDisplayName( + input.householdId, + input.actorMemberId, + displayName + ) + + if (!member) { + return { + status: 'rejected', + reason: 'member_not_found' + } + } + + return { + status: 'ok', + member + } + }, + + async updateMemberDisplayName(input) { + if (!input.actorIsAdmin) { + return { + status: 'rejected', + reason: 'not_admin' + } + } + + const displayName = normalizeDisplayName(input.displayName) + if (!displayName) { + return { + status: 'rejected', + reason: 'invalid_display_name' + } + } + + const member = await repository.updateHouseholdMemberDisplayName( + input.householdId, + input.memberId, + displayName + ) + + if (!member) { + return { + status: 'rejected', + reason: 'member_not_found' + } + } + + return { + status: 'ok', + member + } + }, + async updateMemberAbsencePolicy(input) { if (!input.actorIsAdmin) { return { diff --git a/packages/ports/src/household-config.ts b/packages/ports/src/household-config.ts index af8c2d0..30fc324 100644 --- a/packages/ports/src/household-config.ts +++ b/packages/ports/src/household-config.ts @@ -206,6 +206,11 @@ export interface HouseholdConfigurationRepository { telegramUserId: string, locale: SupportedLocale ): Promise + updateHouseholdMemberDisplayName( + householdId: string, + memberId: string, + displayName: string + ): Promise promoteHouseholdAdmin( householdId: string, memberId: string