diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index eb46aa1..a6a6014 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -5,6 +5,7 @@ import { createHouseholdAdminService, createFinanceCommandService, createHouseholdOnboardingService, + createMiniAppAdminService, createHouseholdSetupService, createReminderJobService } from '@household/application' @@ -32,6 +33,10 @@ import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createBotWebhookServer } from './server' import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth' import { createMiniAppDashboardHandler } from './miniapp-dashboard' +import { + createMiniAppApproveMemberHandler, + createMiniAppPendingMembersHandler +} from './miniapp-admin' const runtime = getBotRuntimeConfig() configureLogger({ @@ -54,6 +59,9 @@ const householdOnboardingService = householdConfigurationRepositoryClient repository: householdConfigurationRepositoryClient.repository }) : null +const miniAppAdminService = householdConfigurationRepositoryClient + ? createMiniAppAdminService(householdConfigurationRepositoryClient.repository) + : null const telegramPendingActionRepositoryClient = runtime.databaseUrl && runtime.anonymousFeedbackEnabled ? createDbTelegramPendingActionRepository(runtime.databaseUrl!) @@ -253,6 +261,24 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-dashboard') }) : undefined, + miniAppPendingMembers: householdOnboardingService + ? createMiniAppPendingMembersHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + miniAppAdminService: miniAppAdminService!, + logger: getLogger('miniapp-admin') + }) + : undefined, + miniAppApproveMember: householdOnboardingService + ? createMiniAppApproveMemberHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + miniAppAdminService: miniAppAdminService!, + logger: getLogger('miniapp-admin') + }) + : undefined, scheduler: reminderJobs && runtime.schedulerSharedSecret ? { diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts new file mode 100644 index 0000000..e2a5cc9 --- /dev/null +++ b/apps/bot/src/miniapp-admin.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from 'bun:test' + +import { createHouseholdOnboardingService, createMiniAppAdminService } from '@household/application' +import type { + HouseholdConfigurationRepository, + HouseholdTopicBindingRecord +} from '@household/ports' + +import { + createMiniAppApproveMemberHandler, + createMiniAppPendingMembersHandler +} from './miniapp-admin' +import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' + +function onboardingRepository(): HouseholdConfigurationRepository { + const household = { + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-100123', + telegramChatType: 'supergroup', + title: 'Kojori House' + } + + return { + registerTelegramHouseholdChat: async () => ({ + status: 'existing', + household + }), + getTelegramHouseholdChat: async () => household, + getHouseholdChatByHouseholdId: async () => household, + bindHouseholdTopic: async (input) => + ({ + householdId: input.householdId, + role: input.role, + telegramThreadId: input.telegramThreadId, + topicName: input.topicName?.trim() || null + }) satisfies HouseholdTopicBindingRecord, + getHouseholdTopicBinding: async () => null, + findHouseholdTopicByTelegramContext: async () => null, + listHouseholdTopicBindings: async () => [], + upsertHouseholdJoinToken: async (input) => ({ + householdId: household.householdId, + householdName: household.householdName, + token: input.token, + createdByTelegramUserId: input.createdByTelegramUserId ?? null + }), + getHouseholdJoinToken: async () => null, + getHouseholdByJoinToken: async () => null, + upsertPendingHouseholdMember: async (input) => ({ + householdId: household.householdId, + householdName: household.householdName, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + username: input.username?.trim() || null, + languageCode: input.languageCode?.trim() || null + }), + getPendingHouseholdMember: async () => null, + findPendingHouseholdMemberByTelegramUserId: async () => null, + ensureHouseholdMember: async (input) => ({ + id: `member-${input.telegramUserId}`, + householdId: household.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + isAdmin: input.isAdmin === true + }), + getHouseholdMember: async () => null, + listHouseholdMembersByTelegramUserId: async () => [], + listPendingHouseholdMembers: async () => [ + { + householdId: household.householdId, + householdName: household.householdName, + telegramUserId: '555777', + displayName: 'Mia', + username: 'mia', + languageCode: 'ru' + } + ], + approvePendingHouseholdMember: async (input) => + input.telegramUserId === '555777' + ? { + id: 'member-555777', + householdId: household.householdId, + telegramUserId: '555777', + displayName: 'Mia', + isAdmin: false + } + : null + } +} + +describe('createMiniAppPendingMembersHandler', () => { + test('lists pending members 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', + isAdmin: true + } + ] + + const handler = createMiniAppPendingMembersHandler({ + 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/pending-members', { + 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' + }) + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + members: [ + { + householdId: 'household-1', + householdName: 'Kojori House', + telegramUserId: '555777', + displayName: 'Mia', + username: 'mia', + languageCode: 'ru' + } + ] + }) + }) +}) + +describe('createMiniAppApproveMemberHandler', () => { + test('approves a pending member 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', + isAdmin: true + } + ] + + const handler = createMiniAppApproveMemberHandler({ + 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/approve-member', { + 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' + }), + pendingTelegramUserId: '555777' + }) + }) + ) + + 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', + isAdmin: false + } + }) + }) +}) diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts new file mode 100644 index 0000000..ac40b3b --- /dev/null +++ b/apps/bot/src/miniapp-admin.ts @@ -0,0 +1,193 @@ +import type { HouseholdOnboardingService, MiniAppAdminService } from '@household/application' +import type { Logger } from '@household/observability' + +import { + allowedMiniAppOrigin, + createMiniAppSessionService, + miniAppErrorResponse, + miniAppJsonResponse, + readMiniAppRequestPayload +} from './miniapp-auth' + +async function readApprovalPayload(request: Request): Promise<{ + initData: string + pendingTelegramUserId: 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: { pendingTelegramUserId?: string } + try { + parsed = JSON.parse(text) as { pendingTelegramUserId?: string } + } catch { + throw new Error('Invalid JSON body') + } + + const pendingTelegramUserId = parsed.pendingTelegramUserId?.trim() + if (!pendingTelegramUserId) { + throw new Error('Missing pendingTelegramUserId') + } + + return { + initData: payload.initData, + pendingTelegramUserId + } +} + +export function createMiniAppPendingMembersHandler(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 readMiniAppRequestPayload(request) + if (!payload.initData) { + return miniAppJsonResponse({ ok: false, error: 'Missing initData' }, 400, origin) + } + + const session = await sessionService.authenticate(payload) + 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.listPendingMembers({ + householdId: session.member.householdId, + actorIsAdmin: session.member.isAdmin + }) + + if (result.status === 'rejected') { + return miniAppJsonResponse({ ok: false, error: 'Admin access required' }, 403, origin) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + members: result.members + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppApproveMemberHandler(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 readApprovalPayload(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.approvePendingMember({ + householdId: session.member.householdId, + actorIsAdmin: session.member.isAdmin, + pendingTelegramUserId: payload.pendingTelegramUserId + }) + + if (result.status === 'rejected') { + const status = result.reason === 'pending_not_found' ? 404 : 403 + const error = + result.reason === 'pending_not_found' + ? 'Pending member not found' + : 'Admin access required' + + return miniAppJsonResponse({ ok: false, error }, status, origin) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + member: result.member + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index 33c4258..8c8f2af 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -25,6 +25,24 @@ describe('createBotWebhookServer', () => { } }) }, + miniAppPendingMembers: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, members: [] }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, + miniAppApproveMember: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, scheduler: { authorize: async (request) => request.headers.get('x-household-scheduler-secret') === 'scheduler-secret', @@ -120,6 +138,38 @@ describe('createBotWebhookServer', () => { }) }) + test('accepts mini app pending members request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/pending-members', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + members: [] + }) + }) + + test('accepts mini app approve member request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/approve-member', { + method: 'POST', + body: JSON.stringify({ initData: 'payload', pendingTelegramUserId: '123456' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + member: {} + }) + }) + test('rejects scheduler request with missing secret', async () => { const response = await server.fetch( new Request('http://localhost/jobs/reminder/utilities', { diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index d887e8c..023cf00 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -20,6 +20,18 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppPendingMembers?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppApproveMember?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined scheduler?: | { pathPrefix?: string @@ -53,6 +65,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session' const miniAppDashboardPath = options.miniAppDashboard?.path ?? '/api/miniapp/dashboard' const miniAppJoinPath = options.miniAppJoin?.path ?? '/api/miniapp/join' + const miniAppPendingMembersPath = + options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members' + const miniAppApproveMemberPath = + options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member' const schedulerPathPrefix = options.scheduler ? (options.scheduler.pathPrefix ?? '/jobs/reminder') : null @@ -77,6 +93,14 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppJoin.handler(request) } + if (options.miniAppPendingMembers && url.pathname === miniAppPendingMembersPath) { + return await options.miniAppPendingMembers.handler(request) + } + + if (options.miniAppApproveMember && url.pathname === miniAppApproveMemberPath) { + return await options.miniAppApproveMember.handler(request) + } + if (url.pathname !== normalizedWebhookPath) { if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) { if (request.method !== 'POST') { diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index bb8fd70..e33b620 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -2,10 +2,13 @@ import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'soli import { dictionary, type Locale } from './i18n' import { + approveMiniAppPendingMember, fetchMiniAppDashboard, + fetchMiniAppPendingMembers, fetchMiniAppSession, joinMiniAppHousehold, - type MiniAppDashboard + type MiniAppDashboard, + type MiniAppPendingMember } from './miniapp-api' import { getTelegramWebApp } from './telegram-webapp' @@ -106,7 +109,9 @@ function App() { }) const [activeNav, setActiveNav] = createSignal('home') const [dashboard, setDashboard] = createSignal(null) + const [pendingMembers, setPendingMembers] = createSignal([]) const [joining, setJoining] = createSignal(false) + const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal(null) const copy = createMemo(() => dictionary[locale()]) const onboardingSession = createMemo(() => { @@ -135,6 +140,18 @@ function App() { } } + async function loadPendingMembers(initData: string) { + try { + setPendingMembers(await fetchMiniAppPendingMembers(initData)) + } catch (error) { + if (import.meta.env.DEV) { + console.warn('Failed to load pending mini app members', error) + } + + setPendingMembers([]) + } + } + async function bootstrap() { setLocale(detectLocale()) @@ -183,6 +200,9 @@ function App() { }) await loadDashboard(initData) + if (payload.member.isAdmin) { + await loadPendingMembers(initData) + } } catch { if (import.meta.env.DEV) { setSession(demoSession) @@ -229,6 +249,14 @@ function App() { } ] }) + setPendingMembers([ + { + telegramUserId: '555777', + displayName: 'Mia', + username: 'mia', + languageCode: 'ru' + } + ]) return } @@ -263,6 +291,9 @@ function App() { telegramUser: payload.telegramUser }) await loadDashboard(initData) + if (payload.member.isAdmin) { + await loadPendingMembers(initData) + } return } @@ -290,6 +321,24 @@ function App() { } } + async function handleApprovePendingMember(pendingTelegramUserId: string) { + const initData = webApp?.initData?.trim() + if (!initData || approvingTelegramUserId()) { + return + } + + setApprovingTelegramUserId(pendingTelegramUserId) + + try { + await approveMiniAppPendingMember(initData, pendingTelegramUserId) + setPendingMembers((current) => + current.filter((member) => member.telegramUserId !== pendingTelegramUserId) + ) + } finally { + setApprovingTelegramUserId(null) + } + } + const renderPanel = () => { switch (activeNav()) { case 'balances': @@ -345,7 +394,47 @@ function App() { ) case 'house': - return copy().houseEmpty + return readySession()?.member.isAdmin ? ( +
+
+
+ {copy().pendingMembersTitle} +
+

{copy().pendingMembersBody}

+
+ {pendingMembers().length === 0 ? ( +
+

{copy().pendingMembersEmpty}

+
+ ) : ( + pendingMembers().map((member) => ( +
+
+ {member.displayName} + {member.telegramUserId} +
+

+ {member.username + ? copy().pendingMemberHandle.replace('{username}', member.username) + : (member.languageCode ?? 'Telegram')} +

+ +
+ )) + )} +
+ ) : ( + copy().houseEmpty + ) default: return (

{copy().summaryTitle}

{readySession()?.member.displayName}

-

{renderPanel()}

+
{renderPanel()}
diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index ad9d2b7..7698ce9 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -53,6 +53,13 @@ export const dictionary = { sectionTitle: 'Ready for the next features', sectionBody: 'This layout is intentionally narrow and mobile-first so it behaves well inside the Telegram webview.', + pendingMembersTitle: 'Pending members', + pendingMembersBody: + 'Approve roommates here after they request access from the group join flow.', + pendingMembersEmpty: 'No pending member requests right now.', + approveMemberAction: 'Approve', + approvingMember: 'Approving…', + pendingMemberHandle: '@{username}', balancesEmpty: 'Balances will appear here once the dashboard API lands.', ledgerEmpty: 'Ledger entries will appear here after the finance view is connected.', houseEmpty: 'House rules, Wi-Fi info, and practical notes will live here.' @@ -109,6 +116,13 @@ export const dictionary = { sectionTitle: 'Основа готова для следующих функций', sectionBody: 'Этот layout специально сделан узким и mobile-first, чтобы хорошо жить внутри Telegram webview.', + pendingMembersTitle: 'Ожидающие участники', + pendingMembersBody: + 'Подтверждай соседей здесь после того, как они отправят заявку через кнопку подключения.', + pendingMembersEmpty: 'Сейчас нет ожидающих заявок.', + approveMemberAction: 'Подтвердить', + approvingMember: 'Подтверждаем…', + pendingMemberHandle: '@{username}', balancesEmpty: 'Баланс появится здесь, когда подключим dashboard API.', ledgerEmpty: 'Записи леджера появятся здесь после подключения finance view.', houseEmpty: 'Правила дома, Wi-Fi и полезные инструкции будут здесь.' diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index e082f30..e553fce 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -17,6 +17,13 @@ export interface MiniAppSession { } } +export interface MiniAppPendingMember { + telegramUserId: string + displayName: string + username: string | null + languageCode: string | null +} + export interface MiniAppDashboard { period: string currency: 'USD' | 'GEL' @@ -159,3 +166,56 @@ export async function fetchMiniAppDashboard(initData: string): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/pending-members`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + members?: MiniAppPendingMember[] + error?: string + } + + if (!response.ok || !payload.authorized || !payload.members) { + throw new Error(payload.error ?? 'Failed to load pending members') + } + + return payload.members +} + +export async function approveMiniAppPendingMember( + initData: string, + pendingTelegramUserId: string +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/approve-member`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + pendingTelegramUserId + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + error?: string + } + + if (!response.ok || !payload.authorized) { + throw new Error(payload.error ?? 'Failed to approve member') + } +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 5952619..331439c 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -7,6 +7,7 @@ export { export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service' export { createHouseholdSetupService, type HouseholdSetupService } from './household-setup-service' export { createHouseholdAdminService, type HouseholdAdminService } from './household-admin-service' +export { createMiniAppAdminService, type MiniAppAdminService } from './miniapp-admin-service' export { createHouseholdOnboardingService, type HouseholdMiniAppAccess, diff --git a/packages/application/src/miniapp-admin-service.test.ts b/packages/application/src/miniapp-admin-service.test.ts new file mode 100644 index 0000000..05dd285 --- /dev/null +++ b/packages/application/src/miniapp-admin-service.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from 'bun:test' + +import type { HouseholdConfigurationRepository } from '@household/ports' + +import { createMiniAppAdminService } from './miniapp-admin-service' + +function repository(): HouseholdConfigurationRepository { + return { + registerTelegramHouseholdChat: async () => ({ + status: 'existing', + household: { + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-100123', + telegramChatType: 'supergroup', + title: 'Kojori House' + } + }), + getTelegramHouseholdChat: async () => null, + getHouseholdChatByHouseholdId: async () => null, + bindHouseholdTopic: async (input) => ({ + householdId: input.householdId, + role: input.role, + telegramThreadId: input.telegramThreadId, + topicName: input.topicName?.trim() || null + }), + getHouseholdTopicBinding: async () => null, + findHouseholdTopicByTelegramContext: async () => null, + listHouseholdTopicBindings: async () => [], + upsertHouseholdJoinToken: async () => ({ + householdId: 'household-1', + householdName: 'Kojori House', + token: 'join-token', + createdByTelegramUserId: null + }), + getHouseholdJoinToken: async () => null, + getHouseholdByJoinToken: async () => null, + upsertPendingHouseholdMember: async (input) => ({ + householdId: input.householdId, + householdName: 'Kojori House', + telegramUserId: input.telegramUserId, + displayName: input.displayName, + username: input.username?.trim() || null, + languageCode: input.languageCode?.trim() || null + }), + getPendingHouseholdMember: async () => null, + findPendingHouseholdMemberByTelegramUserId: async () => null, + ensureHouseholdMember: async (input) => ({ + id: `member-${input.telegramUserId}`, + householdId: input.householdId, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + isAdmin: input.isAdmin === true + }), + getHouseholdMember: async () => null, + listHouseholdMembersByTelegramUserId: async () => [], + listPendingHouseholdMembers: async () => [ + { + householdId: 'household-1', + householdName: 'Kojori House', + telegramUserId: '123456', + displayName: 'Stan', + username: 'stan', + languageCode: 'ru' + } + ], + approvePendingHouseholdMember: async (input) => + input.telegramUserId === '123456' + ? { + id: 'member-123456', + householdId: input.householdId, + telegramUserId: input.telegramUserId, + displayName: 'Stan', + isAdmin: false + } + : null + } +} + +describe('createMiniAppAdminService', () => { + test('lists pending members for admins', async () => { + const service = createMiniAppAdminService(repository()) + + const result = await service.listPendingMembers({ + householdId: 'household-1', + actorIsAdmin: true + }) + + expect(result).toEqual({ + status: 'ok', + members: [ + { + householdId: 'household-1', + householdName: 'Kojori House', + telegramUserId: '123456', + displayName: 'Stan', + username: 'stan', + languageCode: 'ru' + } + ] + }) + }) + + test('rejects pending member listing for non-admins', async () => { + const service = createMiniAppAdminService(repository()) + + const result = await service.listPendingMembers({ + householdId: 'household-1', + actorIsAdmin: false + }) + + expect(result).toEqual({ + status: 'rejected', + reason: 'not_admin' + }) + }) + + test('approves a pending member for admins', async () => { + const service = createMiniAppAdminService(repository()) + + const result = await service.approvePendingMember({ + householdId: 'household-1', + actorIsAdmin: true, + pendingTelegramUserId: '123456' + }) + + expect(result).toEqual({ + status: 'approved', + member: { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: false + } + }) + }) +}) diff --git a/packages/application/src/miniapp-admin-service.ts b/packages/application/src/miniapp-admin-service.ts new file mode 100644 index 0000000..fcd4ff0 --- /dev/null +++ b/packages/application/src/miniapp-admin-service.ts @@ -0,0 +1,78 @@ +import type { + HouseholdConfigurationRepository, + HouseholdMemberRecord, + HouseholdPendingMemberRecord +} from '@household/ports' + +export interface MiniAppAdminService { + listPendingMembers(input: { householdId: string; actorIsAdmin: boolean }): Promise< + | { + status: 'ok' + members: readonly HouseholdPendingMemberRecord[] + } + | { + status: 'rejected' + reason: 'not_admin' + } + > + approvePendingMember(input: { + householdId: string + actorIsAdmin: boolean + pendingTelegramUserId: string + }): Promise< + | { + status: 'approved' + member: HouseholdMemberRecord + } + | { + status: 'rejected' + reason: 'not_admin' | 'pending_not_found' + } + > +} + +export function createMiniAppAdminService( + repository: HouseholdConfigurationRepository +): MiniAppAdminService { + return { + async listPendingMembers(input) { + if (!input.actorIsAdmin) { + return { + status: 'rejected', + reason: 'not_admin' + } + } + + return { + status: 'ok', + members: await repository.listPendingHouseholdMembers(input.householdId) + } + }, + + async approvePendingMember(input) { + if (!input.actorIsAdmin) { + return { + status: 'rejected', + reason: 'not_admin' + } + } + + const member = await repository.approvePendingHouseholdMember({ + householdId: input.householdId, + telegramUserId: input.pendingTelegramUserId + }) + + if (!member) { + return { + status: 'rejected', + reason: 'pending_not_found' + } + } + + return { + status: 'approved', + member + } + } + } +}