diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 48f5ffa..8c2d971 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -39,6 +39,7 @@ import { createMiniAppPendingMembersHandler, createMiniAppPromoteMemberHandler, createMiniAppSettingsHandler, + createMiniAppUpdateMemberRentWeightHandler, createMiniAppUpdateSettingsHandler, createMiniAppUpsertUtilityCategoryHandler } from './miniapp-admin' @@ -358,6 +359,15 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-admin') }) : undefined, + miniAppUpdateMemberRentWeight: householdOnboardingService + ? createMiniAppUpdateMemberRentWeightHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + miniAppAdminService: miniAppAdminService!, + logger: getLogger('miniapp-admin') + }) + : undefined, miniAppBillingCycle: householdOnboardingService ? createMiniAppBillingCycleHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index 3f7c979..b20418f 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -69,6 +69,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { displayName: input.displayName, preferredLocale: input.preferredLocale ?? null, householdDefaultLocale: household.defaultLocale, + rentShareWeight: 1, isAdmin: input.isAdmin === true }), getHouseholdMember: async () => null, @@ -94,6 +95,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { displayName: 'Mia', preferredLocale: null, householdDefaultLocale: household.defaultLocale, + rentShareWeight: 1, isAdmin: false } : null, @@ -110,6 +112,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { displayName: 'Mia', preferredLocale: locale, householdDefaultLocale: household.defaultLocale, + rentShareWeight: 1, isAdmin: false } : null, @@ -151,6 +154,7 @@ function onboardingRepository(): HouseholdConfigurationRepository { displayName: 'Stan', preferredLocale: null, householdDefaultLocale: household.defaultLocale, + rentShareWeight: 1, isAdmin: false } ].find((entry) => entry.id === memberId) @@ -161,7 +165,20 @@ function onboardingRepository(): HouseholdConfigurationRepository { isAdmin: true } : null - } + }, + updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) => + memberId === 'member-123456' + ? { + id: memberId, + householdId: household.householdId, + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, + rentShareWeight, + isAdmin: false + } + : null } } @@ -177,6 +194,7 @@ describe('createMiniAppPendingMembersHandler', () => { displayName: 'Stan', preferredLocale: null, householdDefaultLocale: 'ru', + rentShareWeight: 1, isAdmin: true } ] @@ -239,6 +257,7 @@ describe('createMiniAppApproveMemberHandler', () => { displayName: 'Stan', preferredLocale: null, householdDefaultLocale: 'ru', + rentShareWeight: 1, isAdmin: true } ] @@ -282,6 +301,7 @@ describe('createMiniAppApproveMemberHandler', () => { displayName: 'Mia', preferredLocale: null, householdDefaultLocale: 'ru', + rentShareWeight: 1, isAdmin: false } }) @@ -300,6 +320,7 @@ describe('createMiniAppSettingsHandler', () => { displayName: 'Stan', preferredLocale: null, householdDefaultLocale: 'ru', + rentShareWeight: 1, isAdmin: true } ] @@ -311,6 +332,7 @@ describe('createMiniAppSettingsHandler', () => { displayName: 'Stan', preferredLocale: null, householdDefaultLocale: 'ru', + rentShareWeight: 1, isAdmin: true } ] @@ -365,6 +387,7 @@ describe('createMiniAppSettingsHandler', () => { displayName: 'Stan', preferredLocale: null, householdDefaultLocale: 'ru', + rentShareWeight: 1, isAdmin: true } ] @@ -384,6 +407,7 @@ describe('createMiniAppUpdateSettingsHandler', () => { displayName: 'Stan', preferredLocale: null, householdDefaultLocale: 'ru', + rentShareWeight: 1, isAdmin: true } ] @@ -452,6 +476,7 @@ describe('createMiniAppPromoteMemberHandler', () => { displayName: 'Stan', preferredLocale: null, householdDefaultLocale: 'ru', + rentShareWeight: 1, isAdmin: true } ] @@ -495,6 +520,7 @@ describe('createMiniAppPromoteMemberHandler', () => { displayName: 'Stan', preferredLocale: null, householdDefaultLocale: 'ru', + rentShareWeight: 1, isAdmin: true } }) diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index c676b60..2dbe2ed 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -178,6 +178,37 @@ async function readPromoteMemberPayload(request: Request): Promise<{ } } +async function readRentWeightPayload(request: Request): Promise<{ + initData: string + memberId: string + rentShareWeight: number +}> { + 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; rentShareWeight?: number } + try { + parsed = JSON.parse(text) + } catch { + throw new Error('Invalid JSON body') + } + + const memberId = parsed.memberId?.trim() + if (!memberId || typeof parsed.rentShareWeight !== 'number') { + throw new Error('Missing member rent weight fields') + } + + return { + initData: payload.initData, + memberId, + rentShareWeight: parsed.rentShareWeight + } +} + function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) { return { householdId: settings.householdId, @@ -629,6 +660,97 @@ export function createMiniAppPromoteMemberHandler(options: { } } +export function createMiniAppUpdateMemberRentWeightHandler(options: { + allowedOrigins: readonly string[] + botToken: string + onboardingService: HouseholdOnboardingService + miniAppAdminService: MiniAppAdminService + logger?: Logger +}): { + handler: (request: Request) => Promise +} { + const sessionService = createMiniAppSessionService({ + botToken: options.botToken, + onboardingService: options.onboardingService + }) + + return { + handler: async (request) => { + const origin = allowedMiniAppOrigin(request, options.allowedOrigins) + + if (request.method === 'OPTIONS') { + return miniAppJsonResponse({ ok: true }, 204, origin) + } + + if (request.method !== 'POST') { + return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const payload = await readRentWeightPayload(request) + const session = await sessionService.authenticate({ + initData: payload.initData + }) + + if (!session) { + return miniAppJsonResponse( + { ok: false, error: 'Invalid Telegram init data' }, + 401, + origin + ) + } + + if (!session.authorized || !session.member) { + return miniAppJsonResponse( + { ok: false, error: 'Access limited to active household members' }, + 403, + origin + ) + } + + const result = await options.miniAppAdminService.updateMemberRentShareWeight({ + householdId: session.member.householdId, + actorIsAdmin: session.member.isAdmin, + memberId: payload.memberId, + rentShareWeight: payload.rentShareWeight + }) + + if (result.status === 'rejected') { + return miniAppJsonResponse( + { + ok: false, + error: + result.reason === 'invalid_weight' + ? 'Invalid rent share weight' + : result.reason === 'member_not_found' + ? 'Member not found' + : 'Admin access required' + }, + result.reason === 'invalid_weight' + ? 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 createMiniAppApproveMemberHandler(options: { allowedOrigins: readonly string[] botToken: string diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index a112177..600b34e 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -73,6 +73,15 @@ describe('createBotWebhookServer', () => { } }) }, + miniAppUpdateMemberRentWeight: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, miniAppBillingCycle: { handler: async () => new Response(JSON.stringify({ ok: true, authorized: true, cycleState: {} }), { @@ -304,6 +313,22 @@ describe('createBotWebhookServer', () => { }) }) + test('accepts mini app rent weight update request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/members/rent-weight', { + 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 billing cycle request', async () => { const response = await server.fetch( new Request('http://localhost/api/miniapp/admin/billing-cycle', { diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 0e6ee25..c07260a 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -56,6 +56,12 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppUpdateMemberRentWeight?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppBillingCycle?: | { path?: string @@ -136,6 +142,8 @@ 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 miniAppUpdateMemberRentWeightPath = + options.miniAppUpdateMemberRentWeight?.path ?? '/api/miniapp/admin/members/rent-weight' const miniAppBillingCyclePath = options.miniAppBillingCycle?.path ?? '/api/miniapp/admin/billing-cycle' const miniAppOpenCyclePath = @@ -198,6 +206,13 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppPromoteMember.handler(request) } + if ( + options.miniAppUpdateMemberRentWeight && + url.pathname === miniAppUpdateMemberRentWeightPath + ) { + return await options.miniAppUpdateMemberRentWeight.handler(request) + } + if (options.miniAppBillingCycle && url.pathname === miniAppBillingCyclePath) { return await options.miniAppBillingCycle.handler(request) } diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 2dc3a59..807bd78 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -13,6 +13,7 @@ import { joinMiniAppHousehold, openMiniAppBillingCycle, promoteMiniAppMember, + updateMiniAppMemberRentWeight, type MiniAppAdminCycleState, type MiniAppAdminSettingsPayload, updateMiniAppLocalePreference, @@ -143,6 +144,8 @@ function App() { const [joining, setJoining] = createSignal(false) const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal(null) const [promotingMemberId, setPromotingMemberId] = createSignal(null) + const [savingRentWeightMemberId, setSavingRentWeightMemberId] = createSignal(null) + const [rentWeightDrafts, setRentWeightDrafts] = createSignal>({}) const [savingMemberLocale, setSavingMemberLocale] = createSignal(false) const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false) const [savingBillingSettings, setSavingBillingSettings] = createSignal(false) @@ -212,6 +215,11 @@ function App() { try { const payload = await fetchMiniAppAdminSettings(initData) setAdminSettings(payload) + setRentWeightDrafts( + Object.fromEntries( + payload.members.map((member) => [member.id, String(member.rentShareWeight)]) + ) + ) setCycleForm((current) => ({ ...current, utilityCategorySlug: @@ -721,11 +729,50 @@ function App() { } : current ) + setRentWeightDrafts((current) => ({ + ...current, + [member.id]: String(member.rentShareWeight) + })) } finally { setPromotingMemberId(null) } } + async function handleSaveRentWeight(memberId: string) { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + const nextWeight = Number(rentWeightDrafts()[memberId] ?? '') + if ( + !initData || + currentReady?.mode !== 'live' || + !currentReady.member.isAdmin || + !Number.isInteger(nextWeight) || + nextWeight <= 0 + ) { + return + } + + setSavingRentWeightMemberId(memberId) + + try { + const member = await updateMiniAppMemberRentWeight(initData, memberId, nextWeight) + setAdminSettings((current) => + current + ? { + ...current, + members: current.members.map((item) => (item.id === member.id ? member : item)) + } + : current + ) + setRentWeightDrafts((current) => ({ + ...current, + [member.id]: String(member.rentShareWeight) + })) + } finally { + setSavingRentWeightMemberId(null) + } + } + const renderPanel = () => { switch (activeNav()) { case 'balances': @@ -1223,6 +1270,32 @@ function App() { {member.displayName} {member.isAdmin ? copy().adminTag : copy().residentTag} + + {!member.isAdmin ? (