feat(member): improve assistant roster awareness

This commit is contained in:
2026-03-11 15:10:20 +04:00
parent 79f96ba45b
commit 0787847c19
27 changed files with 1429 additions and 3 deletions

View File

@@ -220,6 +220,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
rentShareWeight: 1,
isAdmin: false
}),
updateHouseholdMemberDisplayName: async () => null,
getHouseholdBillingSettings: async (householdId) => ({
householdId,
settlementCurrency: 'GEL',

View File

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

View File

@@ -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 }> = []

View File

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

View File

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

View File

@@ -489,6 +489,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
}
: null
},
async updateHouseholdMemberDisplayName() {
return null
},
async promoteHouseholdAdmin() {
return null
},

View File

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

View File

@@ -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<string>([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<string>([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<HouseholdConfigurationRepository, 'listHouseholdMembers'>
financeService: FinanceCommandService
recentTurns: readonly { role: 'user' | 'assistant'; text: string }[]
}): Promise<string | null> {
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()
}
})
}

View File

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

View File

@@ -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<Response>
} {
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<Response>
} {
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

View File

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

View File

@@ -131,6 +131,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
defaultLocale: locale
}),
updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,

View File

@@ -206,6 +206,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
defaultLocale: locale
}),
updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null,
getHouseholdBillingSettings: async (householdId) => ({
householdId,
settlementCurrency: 'GEL',

View File

@@ -132,6 +132,7 @@ function repository(): HouseholdConfigurationRepository {
members.set(telegramUserId, next)
return next
},
updateHouseholdMemberDisplayName: async () => null,
getHouseholdBillingSettings: async (householdId) => ({
householdId,
settlementCurrency: 'GEL',

View File

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

View File

@@ -56,6 +56,18 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppUpdateOwnDisplayName?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppUpdateMemberDisplayName?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| 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