diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index ff0401f..1f94cdf 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -253,7 +253,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit }), promoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, - updateHouseholdMemberStatus: async () => null + updateHouseholdMemberStatus: async () => null, + listHouseholdMemberAbsencePolicies: async () => [], + upsertHouseholdMemberAbsencePolicy: async () => null } } diff --git a/apps/bot/src/bot-i18n.test.ts b/apps/bot/src/bot-i18n.test.ts index 1ae548d..63edf71 100644 --- a/apps/bot/src/bot-i18n.test.ts +++ b/apps/bot/src/bot-i18n.test.ts @@ -133,7 +133,9 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository { updateMemberPreferredLocale: async () => null, promoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, - updateHouseholdMemberStatus: async () => null + updateHouseholdMemberStatus: async () => null, + listHouseholdMemberAbsencePolicies: async () => [], + upsertHouseholdMemberAbsencePolicy: async () => null } } diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index 6579166..0654328 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -194,7 +194,9 @@ function createHouseholdRepository(): HouseholdConfigurationRepository { updateMemberPreferredLocale: async () => null, promoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, - updateHouseholdMemberStatus: async () => null + updateHouseholdMemberStatus: async () => null, + listHouseholdMemberAbsencePolicies: async () => [], + upsertHouseholdMemberAbsencePolicy: async () => null } } diff --git a/apps/bot/src/finance-commands.test.ts b/apps/bot/src/finance-commands.test.ts index ca01ae4..de47cd8 100644 --- a/apps/bot/src/finance-commands.test.ts +++ b/apps/bot/src/finance-commands.test.ts @@ -111,7 +111,9 @@ function createRepository(): HouseholdConfigurationRepository { updateMemberPreferredLocale: async () => null, promoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, - updateHouseholdMemberStatus: async () => null + updateHouseholdMemberStatus: async () => null, + listHouseholdMemberAbsencePolicies: async () => [], + upsertHouseholdMemberAbsencePolicy: async () => null } } diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index 0e7a2bf..ea6d256 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -497,6 +497,12 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit }, async updateHouseholdMemberStatus() { return null + }, + async listHouseholdMemberAbsencePolicies() { + return [] + }, + async upsertHouseholdMemberAbsencePolicy() { + return null } } } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index d9e42a1..9ed565e 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -49,6 +49,7 @@ import { createMiniAppPendingMembersHandler, createMiniAppPromoteMemberHandler, createMiniAppSettingsHandler, + createMiniAppUpdateMemberAbsencePolicyHandler, createMiniAppUpdateMemberStatusHandler, createMiniAppUpdateMemberRentWeightHandler, createMiniAppUpdateSettingsHandler, @@ -546,6 +547,15 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-admin') }) : undefined, + miniAppUpdateMemberAbsencePolicy: householdOnboardingService + ? createMiniAppUpdateMemberAbsencePolicyHandler({ + 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 78d129e..9f742fd 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -11,6 +11,7 @@ import { createMiniAppPendingMembersHandler, createMiniAppPromoteMemberHandler, createMiniAppSettingsHandler, + createMiniAppUpdateMemberAbsencePolicyHandler, createMiniAppUpdateMemberStatusHandler, createMiniAppUpdateSettingsHandler } from './miniapp-admin' @@ -25,6 +26,12 @@ function onboardingRepository(): HouseholdConfigurationRepository { title: 'Kojori House', defaultLocale: 'ru' as const } + let memberAbsencePolicies: { + householdId: string + memberId: string + effectiveFromPeriod: string + policy: 'resident' | 'away_rent_and_utilities' | 'away_rent_only' | 'inactive' + }[] = [] return { registerTelegramHouseholdChat: async () => ({ @@ -83,7 +90,19 @@ function onboardingRepository(): HouseholdConfigurationRepository { isAdmin: input.isAdmin === true }), getHouseholdMember: async () => null, - listHouseholdMembers: async () => [], + listHouseholdMembers: async () => [ + { + id: 'member-123456', + householdId: household.householdId, + telegramUserId: '123456', + displayName: 'Stan', + status: 'active', + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, + rentShareWeight: 1, + isAdmin: true + } + ], listHouseholdMembersByTelegramUserId: async () => [], listPendingHouseholdMembers: async () => [ { @@ -208,7 +227,28 @@ function onboardingRepository(): HouseholdConfigurationRepository { rentShareWeight: 1, isAdmin: false } - : null + : null, + listHouseholdMemberAbsencePolicies: async () => memberAbsencePolicies, + upsertHouseholdMemberAbsencePolicy: async (input) => { + const next = { + householdId: input.householdId, + memberId: input.memberId, + effectiveFromPeriod: input.effectiveFromPeriod, + policy: input.policy + } + memberAbsencePolicies = [ + ...memberAbsencePolicies.filter( + (entry) => + !( + entry.householdId === input.householdId && + entry.memberId === input.memberId && + entry.effectiveFromPeriod === input.effectiveFromPeriod + ) + ), + next + ] + return next + } } } @@ -423,6 +463,7 @@ describe('createMiniAppSettingsHandler', () => { } ], categories: [], + memberAbsencePolicies: [], assistantUsage: [], members: [ { @@ -641,4 +682,63 @@ describe('createMiniAppUpdateMemberStatusHandler', () => { } }) }) + + test('updates a household member absence policy 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 = createMiniAppUpdateMemberAbsencePolicyHandler({ + 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/absence-policy', { + 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-123456', + policy: 'away_rent_only' + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + policy: { + householdId: 'household-1', + memberId: 'member-123456', + effectiveFromPeriod: '2026-03', + policy: 'away_rent_only' + } + }) + }) }) diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index 4d1a42b..556331d 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -1,8 +1,10 @@ import type { HouseholdOnboardingService, MiniAppAdminService } from '@household/application' import type { Logger } from '@household/observability' import { + HOUSEHOLD_MEMBER_ABSENCE_POLICIES, HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES, type HouseholdBillingSettingsRecord, + type HouseholdMemberAbsencePolicy, type HouseholdMemberLifecycleStatus } from '@household/ports' import type { MiniAppSessionResult } from './miniapp-auth' @@ -257,6 +259,42 @@ async function readMemberStatusPayload(request: Request): Promise<{ } } +async function readMemberAbsencePolicyPayload(request: Request): Promise<{ + initData: string + memberId: string + policy: HouseholdMemberAbsencePolicy +}> { + 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; policy?: string } + try { + parsed = JSON.parse(text) + } catch { + throw new Error('Invalid JSON body') + } + + const memberId = parsed.memberId?.trim() + const policy = parsed.policy?.trim().toLowerCase() + if (!memberId || !policy) { + throw new Error('Missing member absence policy fields') + } + + if (!(HOUSEHOLD_MEMBER_ABSENCE_POLICIES as readonly string[]).includes(policy)) { + throw new Error('Invalid member absence policy') + } + + return { + initData: payload.initData, + memberId, + policy: policy as HouseholdMemberAbsencePolicy + } +} + function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) { return { householdId: settings.householdId, @@ -442,6 +480,7 @@ export function createMiniAppSettingsHandler(options: { topics: result.topics, categories: result.categories, members: result.members, + memberAbsencePolicies: result.memberAbsencePolicies, assistantUsage: options.assistantUsageTracker?.listHouseholdUsage(member.householdId) ?? [] }, @@ -923,6 +962,87 @@ export function createMiniAppUpdateMemberStatusHandler(options: { } } +export function createMiniAppUpdateMemberAbsencePolicyHandler(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 readMemberAbsencePolicyPayload(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 + ) + } + + const result = await options.miniAppAdminService.updateMemberAbsencePolicy({ + householdId: session.member.householdId, + actorIsAdmin: session.member.isAdmin, + memberId: payload.memberId, + policy: payload.policy + }) + + if (result.status === 'rejected') { + return miniAppJsonResponse( + { + ok: false, + error: + result.reason === 'member_not_found' ? 'Member not found' : 'Admin access required' + }, + result.reason === 'member_not_found' ? 404 : 403, + origin + ) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + policy: result.policy + }, + 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/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index d84df97..30d74cc 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -154,6 +154,8 @@ function onboardingRepository(): HouseholdConfigurationRepository { } : null }, + listHouseholdMemberAbsencePolicies: async () => [], + upsertHouseholdMemberAbsencePolicy: async () => null, getHouseholdBillingSettings: async (householdId) => ({ householdId, settlementCurrency: 'GEL', diff --git a/apps/bot/src/miniapp-billing.test.ts b/apps/bot/src/miniapp-billing.test.ts index a948bb9..00cdf66 100644 --- a/apps/bot/src/miniapp-billing.test.ts +++ b/apps/bot/src/miniapp-billing.test.ts @@ -132,7 +132,9 @@ function onboardingRepository(): HouseholdConfigurationRepository { updateMemberPreferredLocale: async () => null, promoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, - updateHouseholdMemberStatus: async () => null + updateHouseholdMemberStatus: async () => null, + listHouseholdMemberAbsencePolicies: async () => [], + upsertHouseholdMemberAbsencePolicy: async () => null } } diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index ffa59fd..e521f2c 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -185,7 +185,19 @@ function onboardingRepository(): HouseholdConfigurationRepository { isAdmin: input.isAdmin === true }), getHouseholdMember: async () => null, - listHouseholdMembers: async () => [], + listHouseholdMembers: async () => [ + { + id: 'member-1', + householdId: household.householdId, + telegramUserId: '123456', + displayName: 'Stan', + status: 'active', + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, + rentShareWeight: 1, + isAdmin: true + } + ], listHouseholdMembersByTelegramUserId: async () => [], listPendingHouseholdMembers: async () => [], approvePendingHouseholdMember: async () => null, @@ -227,7 +239,9 @@ function onboardingRepository(): HouseholdConfigurationRepository { }), promoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, - updateHouseholdMemberStatus: async () => null + updateHouseholdMemberStatus: async () => null, + listHouseholdMemberAbsencePolicies: async () => [], + upsertHouseholdMemberAbsencePolicy: async () => null } } diff --git a/apps/bot/src/miniapp-locale.test.ts b/apps/bot/src/miniapp-locale.test.ts index aeffd94..5d2bb1b 100644 --- a/apps/bot/src/miniapp-locale.test.ts +++ b/apps/bot/src/miniapp-locale.test.ts @@ -165,7 +165,9 @@ function repository(): HouseholdConfigurationRepository { }), promoteHouseholdAdmin: async () => null, updateHouseholdMemberRentShareWeight: async () => null, - updateHouseholdMemberStatus: async () => null + updateHouseholdMemberStatus: async () => null, + listHouseholdMemberAbsencePolicies: async () => [], + upsertHouseholdMemberAbsencePolicy: async () => null } } diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 6cefea1..606b82a 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -68,6 +68,12 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppUpdateMemberAbsencePolicy?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppBillingCycle?: | { path?: string @@ -194,6 +200,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { options.miniAppUpdateMemberRentWeight?.path ?? '/api/miniapp/admin/members/rent-weight' const miniAppUpdateMemberStatusPath = options.miniAppUpdateMemberStatus?.path ?? '/api/miniapp/admin/members/status' + const miniAppUpdateMemberAbsencePolicyPath = + options.miniAppUpdateMemberAbsencePolicy?.path ?? '/api/miniapp/admin/members/absence-policy' const miniAppBillingCyclePath = options.miniAppBillingCycle?.path ?? '/api/miniapp/admin/billing-cycle' const miniAppOpenCyclePath = @@ -280,6 +288,13 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppUpdateMemberStatus.handler(request) } + if ( + options.miniAppUpdateMemberAbsencePolicy && + url.pathname === miniAppUpdateMemberAbsencePolicyPath + ) { + return await options.miniAppUpdateMemberAbsencePolicy.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 f07ac09..ceb8c25 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -17,10 +17,12 @@ import { joinMiniAppHousehold, openMiniAppBillingCycle, promoteMiniAppMember, + updateMiniAppMemberAbsencePolicy, updateMiniAppMemberStatus, updateMiniAppMemberRentWeight, type MiniAppAdminCycleState, type MiniAppAdminSettingsPayload, + type MiniAppMemberAbsencePolicy, updateMiniAppLocalePreference, updateMiniAppBillingSettings, updateMiniAppCycleRent, @@ -282,10 +284,16 @@ function App() { const [promotingMemberId, setPromotingMemberId] = createSignal(null) const [savingRentWeightMemberId, setSavingRentWeightMemberId] = createSignal(null) const [savingMemberStatusId, setSavingMemberStatusId] = createSignal(null) + const [savingMemberAbsencePolicyId, setSavingMemberAbsencePolicyId] = createSignal( + null + ) const [rentWeightDrafts, setRentWeightDrafts] = createSignal>({}) const [memberStatusDrafts, setMemberStatusDrafts] = createSignal< Record >({}) + const [memberAbsencePolicyDrafts, setMemberAbsencePolicyDrafts] = createSignal< + Record + >({}) const [savingMemberLocale, setSavingMemberLocale] = createSignal(false) const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false) const [savingBillingSettings, setSavingBillingSettings] = createSignal(false) @@ -400,6 +408,39 @@ function App() { } } + function defaultAbsencePolicyForStatus( + status: 'active' | 'away' | 'left' + ): MiniAppMemberAbsencePolicy { + if (status === 'away') { + return 'away_rent_and_utilities' + } + + if (status === 'left') { + return 'inactive' + } + + return 'resident' + } + + function resolvedMemberAbsencePolicy( + memberId: string, + status: 'active' | 'away' | 'left', + settings = adminSettings() + ) { + const current = settings?.memberAbsencePolicies + .filter((policy) => policy.memberId === memberId) + .sort((left, right) => left.effectiveFromPeriod.localeCompare(right.effectiveFromPeriod)) + .at(-1) + + return ( + current ?? { + memberId, + effectiveFromPeriod: '', + policy: defaultAbsencePolicyForStatus(status) + } + ) + } + async function loadDashboard(initData: string) { try { const nextDashboard = await fetchMiniAppDashboard(initData) @@ -441,6 +482,14 @@ function App() { setMemberStatusDrafts( Object.fromEntries(payload.members.map((member) => [member.id, member.status])) ) + setMemberAbsencePolicyDrafts( + Object.fromEntries( + payload.members.map((member) => [ + member.id, + resolvedMemberAbsencePolicy(member.id, member.status, payload).policy + ]) + ) + ) setCycleForm((current) => ({ ...current, rentCurrency: payload.settings.rentCurrency, @@ -1276,11 +1325,66 @@ function App() { ...current, [member.id]: member.status })) + setMemberAbsencePolicyDrafts((current) => ({ + ...current, + [member.id]: + current[member.id] ?? + resolvedMemberAbsencePolicy(member.id, member.status).policy ?? + defaultAbsencePolicyForStatus(member.status) + })) } finally { setSavingMemberStatusId(null) } } + async function handleSaveMemberAbsencePolicy(memberId: string) { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + const member = adminSettings()?.members.find((entry) => entry.id === memberId) + const nextPolicy = memberAbsencePolicyDrafts()[memberId] + const effectiveStatus = memberStatusDrafts()[memberId] ?? member?.status + + if ( + !initData || + currentReady?.mode !== 'live' || + !currentReady.member.isAdmin || + !member || + !nextPolicy || + effectiveStatus !== 'away' + ) { + return + } + + setSavingMemberAbsencePolicyId(memberId) + + try { + const savedPolicy = await updateMiniAppMemberAbsencePolicy(initData, memberId, nextPolicy) + setAdminSettings((current) => + current + ? { + ...current, + memberAbsencePolicies: [ + ...current.memberAbsencePolicies.filter( + (policy) => + !( + policy.memberId === savedPolicy.memberId && + policy.effectiveFromPeriod === savedPolicy.effectiveFromPeriod + ) + ), + savedPolicy + ] + } + : current + ) + setMemberAbsencePolicyDrafts((current) => ({ + ...current, + [memberId]: savedPolicy.policy + })) + } finally { + setSavingMemberAbsencePolicyId(null) + } + } + const renderPanel = () => { switch (activeNav()) { case 'balances': @@ -2447,6 +2551,44 @@ function App() { +