mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
feat(member): improve assistant roster awareness
This commit is contained in:
@@ -220,6 +220,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
rentShareWeight: 1,
|
rentShareWeight: 1,
|
||||||
isAdmin: false
|
isAdmin: false
|
||||||
}),
|
}),
|
||||||
|
updateHouseholdMemberDisplayName: async () => null,
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
settlementCurrency: 'GEL',
|
settlementCurrency: 'GEL',
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository {
|
|||||||
throw new Error('not implemented')
|
throw new Error('not implemented')
|
||||||
},
|
},
|
||||||
updateMemberPreferredLocale: async () => null,
|
updateMemberPreferredLocale: async () => null,
|
||||||
|
updateHouseholdMemberDisplayName: async () => null,
|
||||||
promoteHouseholdAdmin: async () => null,
|
promoteHouseholdAdmin: async () => null,
|
||||||
updateHouseholdMemberRentShareWeight: async () => null,
|
updateHouseholdMemberRentShareWeight: async () => null,
|
||||||
updateHouseholdMemberStatus: async () => null,
|
updateHouseholdMemberStatus: async () => null,
|
||||||
|
|||||||
@@ -157,7 +157,41 @@ function createHouseholdRepository(): HouseholdConfigurationRepository {
|
|||||||
rentShareWeight: 1,
|
rentShareWeight: 1,
|
||||||
isAdmin: true
|
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 () => ({
|
getHouseholdBillingSettings: async () => ({
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
settlementCurrency: 'GEL',
|
settlementCurrency: 'GEL',
|
||||||
@@ -193,6 +227,7 @@ function createHouseholdRepository(): HouseholdConfigurationRepository {
|
|||||||
approvePendingHouseholdMember: async () => null,
|
approvePendingHouseholdMember: async () => null,
|
||||||
updateHouseholdDefaultLocale: async () => household,
|
updateHouseholdDefaultLocale: async () => household,
|
||||||
updateMemberPreferredLocale: async () => null,
|
updateMemberPreferredLocale: async () => null,
|
||||||
|
updateHouseholdMemberDisplayName: async () => null,
|
||||||
promoteHouseholdAdmin: async () => null,
|
promoteHouseholdAdmin: async () => null,
|
||||||
updateHouseholdMemberRentShareWeight: async () => null,
|
updateHouseholdMemberRentShareWeight: async () => null,
|
||||||
updateHouseholdMemberStatus: async () => null,
|
updateHouseholdMemberStatus: async () => null,
|
||||||
@@ -266,6 +301,28 @@ function createFinanceService(): FinanceCommandService {
|
|||||||
paid: Money.fromMajor('500.00', 'GEL'),
|
paid: Money.fromMajor('500.00', 'GEL'),
|
||||||
remaining: Money.fromMajor('350.00', 'GEL'),
|
remaining: Money.fromMajor('350.00', 'GEL'),
|
||||||
explanations: []
|
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: [
|
ledger: [
|
||||||
@@ -731,6 +788,87 @@ describe('registerDmAssistant', () => {
|
|||||||
expect(replyText).toContain('Suggested payment under utilities adjustment: 150.00 GEL')
|
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 () => {
|
test('routes obvious purchase-like DMs into purchase confirmation flow', async () => {
|
||||||
const bot = createTestBot()
|
const bot = createTestBot()
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
maybeCreatePaymentProposal,
|
maybeCreatePaymentProposal,
|
||||||
parsePaymentProposalPayload
|
parsePaymentProposalPayload
|
||||||
} from './payment-proposals'
|
} from './payment-proposals'
|
||||||
|
import { maybeCreateMemberInsightReply } from './member-queries'
|
||||||
import type {
|
import type {
|
||||||
PurchaseMessageIngestionRepository,
|
PurchaseMessageIngestionRepository,
|
||||||
PurchaseProposalActionResult,
|
PurchaseProposalActionResult,
|
||||||
@@ -439,10 +440,11 @@ async function buildHouseholdContext(input: {
|
|||||||
householdConfigurationRepository: HouseholdConfigurationRepository
|
householdConfigurationRepository: HouseholdConfigurationRepository
|
||||||
financeService: FinanceCommandService
|
financeService: FinanceCommandService
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const [household, settings, dashboard] = await Promise.all([
|
const [household, settings, dashboard, members] = await Promise.all([
|
||||||
input.householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId),
|
input.householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId),
|
||||||
input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId),
|
input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId),
|
||||||
input.financeService.generateDashboard()
|
input.financeService.generateDashboard(),
|
||||||
|
input.householdConfigurationRepository.listHouseholdMembers(input.householdId)
|
||||||
])
|
])
|
||||||
|
|
||||||
const lines = [
|
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(
|
lines.push(
|
||||||
`Household total remaining: ${dashboard.totalRemaining.toMajorString()} ${dashboard.currency}`
|
`Household total remaining: ${dashboard.totalRemaining.toMajorString()} ${dashboard.currency}`
|
||||||
)
|
)
|
||||||
@@ -1044,6 +1060,30 @@ export function registerDmAssistant(options: {
|
|||||||
return
|
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({
|
const paymentProposal = await maybeCreatePaymentProposal({
|
||||||
rawText: ctx.msg.text,
|
rawText: ctx.msg.text,
|
||||||
householdId: member.householdId,
|
householdId: member.householdId,
|
||||||
@@ -1203,6 +1243,11 @@ export function registerDmAssistant(options: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const financeService = options.financeServiceForHousehold(household.householdId)
|
const financeService = options.financeServiceForHousehold(household.householdId)
|
||||||
|
const memoryKey = conversationMemoryKey({
|
||||||
|
telegramUserId,
|
||||||
|
telegramChatId,
|
||||||
|
isPrivateChat: false
|
||||||
|
})
|
||||||
const paymentBalanceReply = await maybeCreatePaymentBalanceReply({
|
const paymentBalanceReply = await maybeCreatePaymentBalanceReply({
|
||||||
rawText: mention.strippedText,
|
rawText: mention.strippedText,
|
||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
@@ -1216,6 +1261,30 @@ export function registerDmAssistant(options: {
|
|||||||
return
|
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({
|
await replyWithAssistant({
|
||||||
ctx,
|
ctx,
|
||||||
assistant: options.assistant,
|
assistant: options.assistant,
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function createRepository(): HouseholdConfigurationRepository {
|
|||||||
throw new Error('not implemented')
|
throw new Error('not implemented')
|
||||||
},
|
},
|
||||||
updateMemberPreferredLocale: async () => null,
|
updateMemberPreferredLocale: async () => null,
|
||||||
|
updateHouseholdMemberDisplayName: async () => null,
|
||||||
promoteHouseholdAdmin: async () => null,
|
promoteHouseholdAdmin: async () => null,
|
||||||
updateHouseholdMemberRentShareWeight: async () => null,
|
updateHouseholdMemberRentShareWeight: async () => null,
|
||||||
updateHouseholdMemberStatus: async () => null,
|
updateHouseholdMemberStatus: async () => null,
|
||||||
|
|||||||
@@ -489,6 +489,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
},
|
},
|
||||||
|
async updateHouseholdMemberDisplayName() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
async promoteHouseholdAdmin() {
|
async promoteHouseholdAdmin() {
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ import {
|
|||||||
createMiniAppPendingMembersHandler,
|
createMiniAppPendingMembersHandler,
|
||||||
createMiniAppPromoteMemberHandler,
|
createMiniAppPromoteMemberHandler,
|
||||||
createMiniAppSettingsHandler,
|
createMiniAppSettingsHandler,
|
||||||
|
createMiniAppUpdateMemberDisplayNameHandler,
|
||||||
createMiniAppUpdateMemberAbsencePolicyHandler,
|
createMiniAppUpdateMemberAbsencePolicyHandler,
|
||||||
|
createMiniAppUpdateOwnDisplayNameHandler,
|
||||||
createMiniAppUpdateMemberStatusHandler,
|
createMiniAppUpdateMemberStatusHandler,
|
||||||
createMiniAppUpdateMemberRentWeightHandler,
|
createMiniAppUpdateMemberRentWeightHandler,
|
||||||
createMiniAppUpdateSettingsHandler,
|
createMiniAppUpdateSettingsHandler,
|
||||||
@@ -529,6 +531,24 @@ const server = createBotWebhookServer({
|
|||||||
logger: getLogger('miniapp-admin')
|
logger: getLogger('miniapp-admin')
|
||||||
})
|
})
|
||||||
: undefined,
|
: 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
|
miniAppUpdateMemberRentWeight: householdOnboardingService
|
||||||
? createMiniAppUpdateMemberRentWeightHandler({
|
? createMiniAppUpdateMemberRentWeightHandler({
|
||||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
|||||||
304
apps/bot/src/member-queries.ts
Normal file
304
apps/bot/src/member-queries.ts
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
createMiniAppPendingMembersHandler,
|
createMiniAppPendingMembersHandler,
|
||||||
createMiniAppPromoteMemberHandler,
|
createMiniAppPromoteMemberHandler,
|
||||||
createMiniAppSettingsHandler,
|
createMiniAppSettingsHandler,
|
||||||
|
createMiniAppUpdateMemberDisplayNameHandler,
|
||||||
createMiniAppUpdateMemberAbsencePolicyHandler,
|
createMiniAppUpdateMemberAbsencePolicyHandler,
|
||||||
|
createMiniAppUpdateOwnDisplayNameHandler,
|
||||||
createMiniAppUpdateMemberStatusHandler,
|
createMiniAppUpdateMemberStatusHandler,
|
||||||
createMiniAppUpdateSettingsHandler
|
createMiniAppUpdateSettingsHandler
|
||||||
} from './miniapp-admin'
|
} from './miniapp-admin'
|
||||||
@@ -147,6 +149,20 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
isAdmin: false
|
isAdmin: false
|
||||||
}
|
}
|
||||||
: null,
|
: 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) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
settlementCurrency: 'GEL',
|
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', () => {
|
describe('createMiniAppUpdateMemberStatusHandler', () => {
|
||||||
test('updates a household member status for an authenticated admin', async () => {
|
test('updates a household member status for an authenticated admin', async () => {
|
||||||
const authDate = Math.floor(Date.now() / 1000)
|
const authDate = Math.floor(Date.now() / 1000)
|
||||||
|
|||||||
@@ -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<{
|
async function readRentWeightPayload(request: Request): Promise<{
|
||||||
initData: string
|
initData: string
|
||||||
memberId: 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: {
|
export function createMiniAppUpdateMemberRentWeightHandler(options: {
|
||||||
allowedOrigins: readonly string[]
|
allowedOrigins: readonly string[]
|
||||||
botToken: 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: {
|
export function createMiniAppUpdateMemberStatusHandler(options: {
|
||||||
allowedOrigins: readonly string[]
|
allowedOrigins: readonly string[]
|
||||||
botToken: string
|
botToken: string
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
},
|
},
|
||||||
|
updateHouseholdMemberDisplayName: async () => null,
|
||||||
updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) => {
|
updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) => {
|
||||||
const member = [...members.values()].find((entry) => entry.id === memberId)
|
const member = [...members.values()].find((entry) => entry.id === memberId)
|
||||||
return member
|
return member
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
defaultLocale: locale
|
defaultLocale: locale
|
||||||
}),
|
}),
|
||||||
updateMemberPreferredLocale: async () => null,
|
updateMemberPreferredLocale: async () => null,
|
||||||
|
updateHouseholdMemberDisplayName: async () => null,
|
||||||
promoteHouseholdAdmin: async () => null,
|
promoteHouseholdAdmin: async () => null,
|
||||||
updateHouseholdMemberRentShareWeight: async () => null,
|
updateHouseholdMemberRentShareWeight: async () => null,
|
||||||
updateHouseholdMemberStatus: async () => null,
|
updateHouseholdMemberStatus: async () => null,
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
defaultLocale: locale
|
defaultLocale: locale
|
||||||
}),
|
}),
|
||||||
updateMemberPreferredLocale: async () => null,
|
updateMemberPreferredLocale: async () => null,
|
||||||
|
updateHouseholdMemberDisplayName: async () => null,
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
settlementCurrency: 'GEL',
|
settlementCurrency: 'GEL',
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ function repository(): HouseholdConfigurationRepository {
|
|||||||
members.set(telegramUserId, next)
|
members.set(telegramUserId, next)
|
||||||
return next
|
return next
|
||||||
},
|
},
|
||||||
|
updateHouseholdMemberDisplayName: async () => null,
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
settlementCurrency: 'GEL',
|
settlementCurrency: 'GEL',
|
||||||
|
|||||||
@@ -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: {
|
miniAppUpdateMemberRentWeight: {
|
||||||
handler: async () =>
|
handler: async () =>
|
||||||
new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), {
|
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 () => {
|
test('accepts mini app rent weight update request', async () => {
|
||||||
const response = await server.fetch(
|
const response = await server.fetch(
|
||||||
new Request('http://localhost/api/miniapp/admin/members/rent-weight', {
|
new Request('http://localhost/api/miniapp/admin/members/rent-weight', {
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ export interface BotWebhookServerOptions {
|
|||||||
handler: (request: Request) => Promise<Response>
|
handler: (request: Request) => Promise<Response>
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
|
miniAppUpdateOwnDisplayName?:
|
||||||
|
| {
|
||||||
|
path?: string
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
miniAppUpdateMemberDisplayName?:
|
||||||
|
| {
|
||||||
|
path?: string
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
miniAppUpdateMemberRentWeight?:
|
miniAppUpdateMemberRentWeight?:
|
||||||
| {
|
| {
|
||||||
path?: string
|
path?: string
|
||||||
@@ -196,6 +208,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
options.miniAppUpsertUtilityCategory?.path ?? '/api/miniapp/admin/utility-categories/upsert'
|
options.miniAppUpsertUtilityCategory?.path ?? '/api/miniapp/admin/utility-categories/upsert'
|
||||||
const miniAppPromoteMemberPath =
|
const miniAppPromoteMemberPath =
|
||||||
options.miniAppPromoteMember?.path ?? '/api/miniapp/admin/members/promote'
|
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 =
|
const miniAppUpdateMemberRentWeightPath =
|
||||||
options.miniAppUpdateMemberRentWeight?.path ?? '/api/miniapp/admin/members/rent-weight'
|
options.miniAppUpdateMemberRentWeight?.path ?? '/api/miniapp/admin/members/rent-weight'
|
||||||
const miniAppUpdateMemberStatusPath =
|
const miniAppUpdateMemberStatusPath =
|
||||||
@@ -277,6 +293,17 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
return await options.miniAppPromoteMember.handler(request)
|
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 (
|
if (
|
||||||
options.miniAppUpdateMemberRentWeight &&
|
options.miniAppUpdateMemberRentWeight &&
|
||||||
url.pathname === miniAppUpdateMemberRentWeightPath
|
url.pathname === miniAppUpdateMemberRentWeightPath
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import {
|
|||||||
joinMiniAppHousehold,
|
joinMiniAppHousehold,
|
||||||
openMiniAppBillingCycle,
|
openMiniAppBillingCycle,
|
||||||
promoteMiniAppMember,
|
promoteMiniAppMember,
|
||||||
|
updateMiniAppMemberDisplayName,
|
||||||
updateMiniAppMemberAbsencePolicy,
|
updateMiniAppMemberAbsencePolicy,
|
||||||
updateMiniAppMemberStatus,
|
updateMiniAppMemberStatus,
|
||||||
updateMiniAppMemberRentWeight,
|
updateMiniAppMemberRentWeight,
|
||||||
|
updateMiniAppOwnDisplayName,
|
||||||
type MiniAppAdminCycleState,
|
type MiniAppAdminCycleState,
|
||||||
type MiniAppAdminSettingsPayload,
|
type MiniAppAdminSettingsPayload,
|
||||||
type MiniAppMemberAbsencePolicy,
|
type MiniAppMemberAbsencePolicy,
|
||||||
@@ -311,11 +313,19 @@ function App() {
|
|||||||
const [joining, setJoining] = createSignal(false)
|
const [joining, setJoining] = createSignal(false)
|
||||||
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
|
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
|
||||||
const [promotingMemberId, setPromotingMemberId] = createSignal<string | null>(null)
|
const [promotingMemberId, setPromotingMemberId] = createSignal<string | null>(null)
|
||||||
|
const [savingOwnDisplayName, setSavingOwnDisplayName] = createSignal(false)
|
||||||
|
const [savingMemberDisplayNameId, setSavingMemberDisplayNameId] = createSignal<string | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
const [savingRentWeightMemberId, setSavingRentWeightMemberId] = createSignal<string | null>(null)
|
const [savingRentWeightMemberId, setSavingRentWeightMemberId] = createSignal<string | null>(null)
|
||||||
const [savingMemberStatusId, setSavingMemberStatusId] = createSignal<string | null>(null)
|
const [savingMemberStatusId, setSavingMemberStatusId] = createSignal<string | null>(null)
|
||||||
const [savingMemberAbsencePolicyId, setSavingMemberAbsencePolicyId] = createSignal<string | null>(
|
const [savingMemberAbsencePolicyId, setSavingMemberAbsencePolicyId] = createSignal<string | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
|
const [displayNameDraft, setDisplayNameDraft] = createSignal('')
|
||||||
|
const [memberDisplayNameDrafts, setMemberDisplayNameDrafts] = createSignal<
|
||||||
|
Record<string, string>
|
||||||
|
>({})
|
||||||
const [rentWeightDrafts, setRentWeightDrafts] = createSignal<Record<string, string>>({})
|
const [rentWeightDrafts, setRentWeightDrafts] = createSignal<Record<string, string>>({})
|
||||||
const [memberStatusDrafts, setMemberStatusDrafts] = createSignal<
|
const [memberStatusDrafts, setMemberStatusDrafts] = createSignal<
|
||||||
Record<string, 'active' | 'away' | 'left'>
|
Record<string, 'active' | 'away' | 'left'>
|
||||||
@@ -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) {
|
async function loadDashboard(initData: string) {
|
||||||
try {
|
try {
|
||||||
const nextDashboard = await fetchMiniAppDashboard(initData)
|
const nextDashboard = await fetchMiniAppDashboard(initData)
|
||||||
@@ -504,6 +573,9 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const payload = await fetchMiniAppAdminSettings(initData)
|
const payload = await fetchMiniAppAdminSettings(initData)
|
||||||
setAdminSettings(payload)
|
setAdminSettings(payload)
|
||||||
|
setMemberDisplayNameDrafts(
|
||||||
|
Object.fromEntries(payload.members.map((member) => [member.id, member.displayName]))
|
||||||
|
)
|
||||||
setRentWeightDrafts(
|
setRentWeightDrafts(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
payload.members.map((member) => [member.id, String(member.rentShareWeight)])
|
payload.members.map((member) => [member.id, String(member.rentShareWeight)])
|
||||||
@@ -655,6 +727,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
||||||
|
setDisplayNameDraft(payload.member.displayName)
|
||||||
setSession({
|
setSession({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
mode: 'live',
|
mode: 'live',
|
||||||
@@ -673,6 +746,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
|
setDisplayNameDraft(demoSession.member.displayName)
|
||||||
setSession(demoSession)
|
setSession(demoSession)
|
||||||
setDashboard({
|
setDashboard({
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
@@ -793,6 +867,7 @@ function App() {
|
|||||||
const payload = await joinMiniAppHousehold(initData, joinToken)
|
const payload = await joinMiniAppHousehold(initData, joinToken)
|
||||||
if (payload.authorized && payload.member && payload.telegramUser) {
|
if (payload.authorized && payload.member && payload.telegramUser) {
|
||||||
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
||||||
|
setDisplayNameDraft(payload.member.displayName)
|
||||||
setSession({
|
setSession({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
mode: 'live',
|
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() {
|
async function handleSaveBillingSettings() {
|
||||||
const initData = webApp?.initData?.trim()
|
const initData = webApp?.initData?.trim()
|
||||||
const currentReady = readySession()
|
const currentReady = readySession()
|
||||||
@@ -2764,6 +2884,18 @@ function App() {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
|
<label class="settings-field settings-field--wide">
|
||||||
|
<span>{copy().displayNameLabel}</span>
|
||||||
|
<input
|
||||||
|
value={memberDisplayNameDrafts()[member.id] ?? member.displayName}
|
||||||
|
onInput={(event) =>
|
||||||
|
setMemberDisplayNameDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[member.id]: event.currentTarget.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label class="settings-field settings-field--wide">
|
<label class="settings-field settings-field--wide">
|
||||||
<span>{copy().memberStatusLabel}</span>
|
<span>{copy().memberStatusLabel}</span>
|
||||||
<select
|
<select
|
||||||
@@ -2838,6 +2970,23 @@ function App() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-actions">
|
<div class="inline-actions">
|
||||||
|
<button
|
||||||
|
class="ghost-button"
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
savingMemberDisplayNameId() === member.id ||
|
||||||
|
(memberDisplayNameDrafts()[member.id] ?? member.displayName).trim()
|
||||||
|
.length < 2 ||
|
||||||
|
(
|
||||||
|
memberDisplayNameDrafts()[member.id] ?? member.displayName
|
||||||
|
).trim() === member.displayName
|
||||||
|
}
|
||||||
|
onClick={() => void handleSaveMemberDisplayName(member.id)}
|
||||||
|
>
|
||||||
|
{savingMemberDisplayNameId() === member.id
|
||||||
|
? copy().savingDisplayName
|
||||||
|
: copy().saveDisplayName}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="ghost-button"
|
class="ghost-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3284,6 +3433,32 @@ function App() {
|
|||||||
: copy().memberStatusActive
|
: copy().memberStatusActive
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
<Show when={readySession()?.mode === 'live'}>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="settings-field settings-field--wide">
|
||||||
|
<span>{copy().displayNameLabel}</span>
|
||||||
|
<input
|
||||||
|
value={displayNameDraft()}
|
||||||
|
onInput={(event) => setDisplayNameDraft(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<small>{copy().displayNameHint}</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<button
|
||||||
|
class="ghost-button"
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
savingOwnDisplayName() ||
|
||||||
|
displayNameDraft().trim().length < 2 ||
|
||||||
|
displayNameDraft().trim() === readySession()?.member.displayName
|
||||||
|
}
|
||||||
|
onClick={() => void handleSaveOwnDisplayName()}
|
||||||
|
>
|
||||||
|
{savingOwnDisplayName() ? copy().savingDisplayName : copy().saveDisplayName}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<div>{renderPanel()}</div>
|
<div>{renderPanel()}</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ export const dictionary = {
|
|||||||
savingCategory: 'Saving…',
|
savingCategory: 'Saving…',
|
||||||
adminsTitle: 'Admins',
|
adminsTitle: 'Admins',
|
||||||
adminsBody: 'Promote trusted household members so they can manage billing and approvals.',
|
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',
|
memberStatusLabel: 'Member status',
|
||||||
saveMemberStatusAction: 'Save status',
|
saveMemberStatusAction: 'Save status',
|
||||||
savingMemberStatus: 'Saving status…',
|
savingMemberStatus: 'Saving status…',
|
||||||
@@ -332,6 +336,10 @@ export const dictionary = {
|
|||||||
adminsTitle: 'Админы',
|
adminsTitle: 'Админы',
|
||||||
adminsBody:
|
adminsBody:
|
||||||
'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.',
|
'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.',
|
||||||
|
displayNameLabel: 'Имя в household',
|
||||||
|
displayNameHint: 'Это имя будет видно в балансе, леджере и ответах ассистента.',
|
||||||
|
saveDisplayName: 'Сохранить имя',
|
||||||
|
savingDisplayName: 'Сохраняем имя…',
|
||||||
memberStatusLabel: 'Статус участника',
|
memberStatusLabel: 'Статус участника',
|
||||||
saveMemberStatusAction: 'Сохранить статус',
|
saveMemberStatusAction: 'Сохранить статус',
|
||||||
savingMemberStatus: 'Сохраняем статус…',
|
savingMemberStatus: 'Сохраняем статус…',
|
||||||
|
|||||||
@@ -509,6 +509,66 @@ export async function promoteMiniAppMember(
|
|||||||
return payload.member
|
return payload.member
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateMiniAppOwnDisplayName(
|
||||||
|
initData: string,
|
||||||
|
displayName: string
|
||||||
|
): Promise<NonNullable<MiniAppSession['member']>> {
|
||||||
|
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<MiniAppMember> {
|
||||||
|
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(
|
export async function updateMiniAppMemberRentWeight(
|
||||||
initData: string,
|
initData: string,
|
||||||
memberId: string,
|
memberId: string,
|
||||||
|
|||||||
@@ -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) {
|
async promoteHouseholdAdmin(householdId, memberId) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.update(schema.members)
|
.update(schema.members)
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ function createRepositoryStub() {
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
},
|
},
|
||||||
|
updateHouseholdMemberDisplayName: async () => null,
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
settlementCurrency: 'GEL',
|
settlementCurrency: 'GEL',
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ function createRepositoryStub() {
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
},
|
},
|
||||||
|
async updateHouseholdMemberDisplayName() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
async getHouseholdBillingSettings(householdId) {
|
async getHouseholdBillingSettings(householdId) {
|
||||||
return {
|
return {
|
||||||
householdId,
|
householdId,
|
||||||
|
|||||||
@@ -252,6 +252,9 @@ function createRepositoryStub() {
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
},
|
},
|
||||||
|
async updateHouseholdMemberDisplayName() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
async getHouseholdBillingSettings(householdId) {
|
async getHouseholdBillingSettings(householdId) {
|
||||||
return {
|
return {
|
||||||
householdId,
|
householdId,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function createRepository(): HouseholdConfigurationRepository {
|
|||||||
householdDefaultLocale: 'ru'
|
householdDefaultLocale: 'ru'
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
updateHouseholdMemberDisplayName: async () => null,
|
||||||
getHouseholdBillingSettings: async (householdId) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
settlementCurrency: 'GEL',
|
settlementCurrency: 'GEL',
|
||||||
|
|||||||
@@ -136,6 +136,20 @@ function repository(): HouseholdConfigurationRepository {
|
|||||||
isAdmin: false
|
isAdmin: false
|
||||||
}
|
}
|
||||||
: null,
|
: 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) => ({
|
getHouseholdBillingSettings: async (householdId) => ({
|
||||||
householdId,
|
householdId,
|
||||||
settlementCurrency: 'GEL',
|
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 () => {
|
test('updates a household member lifecycle status for admins', async () => {
|
||||||
const service = createMiniAppAdminService(repository())
|
const service = createMiniAppAdminService(repository())
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,35 @@ export interface MiniAppAdminService {
|
|||||||
reason: 'not_admin' | 'member_not_found'
|
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: {
|
updateMemberAbsencePolicy(input: {
|
||||||
householdId: string
|
householdId: string
|
||||||
actorIsAdmin: boolean
|
actorIsAdmin: boolean
|
||||||
@@ -171,6 +200,16 @@ function periodFromLocalDate(localDate: Temporal.PlainDate): string {
|
|||||||
return `${localDate.year}-${String(localDate.month).padStart(2, '0')}`
|
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(
|
export function createMiniAppAdminService(
|
||||||
repository: HouseholdConfigurationRepository
|
repository: HouseholdConfigurationRepository
|
||||||
): MiniAppAdminService {
|
): 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) {
|
async updateMemberAbsencePolicy(input) {
|
||||||
if (!input.actorIsAdmin) {
|
if (!input.actorIsAdmin) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -206,6 +206,11 @@ export interface HouseholdConfigurationRepository {
|
|||||||
telegramUserId: string,
|
telegramUserId: string,
|
||||||
locale: SupportedLocale
|
locale: SupportedLocale
|
||||||
): Promise<HouseholdMemberRecord | null>
|
): Promise<HouseholdMemberRecord | null>
|
||||||
|
updateHouseholdMemberDisplayName(
|
||||||
|
householdId: string,
|
||||||
|
memberId: string,
|
||||||
|
displayName: string
|
||||||
|
): Promise<HouseholdMemberRecord | null>
|
||||||
promoteHouseholdAdmin(
|
promoteHouseholdAdmin(
|
||||||
householdId: string,
|
householdId: string,
|
||||||
memberId: string
|
memberId: string
|
||||||
|
|||||||
Reference in New Issue
Block a user