From 565ac277c1689828e7c76116b985b4621ba1afbc Mon Sep 17 00:00:00 2001 From: whekin Date: Tue, 10 Mar 2026 01:38:03 +0400 Subject: [PATCH] feat(miniapp): add admin billing settings foundation --- apps/bot/src/anonymous-feedback.test.ts | 32 +- apps/bot/src/index.ts | 42 +- apps/bot/src/miniapp-admin.test.ts | 267 +- apps/bot/src/miniapp-admin.ts | 517 ++++ apps/bot/src/miniapp-auth.test.ts | 32 +- apps/bot/src/miniapp-dashboard.test.ts | 32 +- apps/bot/src/miniapp-locale.test.ts | 32 +- apps/bot/src/server.test.ts | 105 + apps/bot/src/server.ts | 50 + apps/miniapp/src/App.tsx | 389 +++ apps/miniapp/src/i18n.ts | 42 + apps/miniapp/src/index.css | 35 + apps/miniapp/src/miniapp-api.ts | 171 ++ .../src/household-config-repository.ts | 292 +- .../src/household-admin-service.test.ts | 32 +- .../src/household-onboarding-service.test.ts | 40 + .../src/household-setup-service.test.ts | 52 + .../src/locale-preference-service.test.ts | 32 +- .../src/miniapp-admin-service.test.ts | 143 + .../application/src/miniapp-admin-service.ts | 221 +- .../db/drizzle/0010_wild_molecule_man.sql | 30 + packages/db/drizzle/meta/0010_snapshot.json | 2376 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 52 + packages/ports/src/household-config.ts | 47 +- packages/ports/src/index.ts | 2 + 26 files changed, 5061 insertions(+), 11 deletions(-) create mode 100644 packages/db/drizzle/0010_wild_molecule_man.sql create mode 100644 packages/db/drizzle/meta/0010_snapshot.json diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index 6a5c9e1..fcd6669 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -192,7 +192,37 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit preferredLocale: locale, householdDefaultLocale: 'ru', isAdmin: false - }) + }), + getHouseholdBillingSettings: async (householdId) => ({ + householdId, + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async (input) => ({ + householdId: input.householdId, + rentAmountMinor: input.rentAmountMinor ?? null, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + }), + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async (input) => ({ + id: input.slug ?? 'utility-category-1', + householdId: input.householdId, + slug: input.slug ?? 'custom', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + }), + promoteHouseholdAdmin: async () => null } } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index e6e71a3..bfaf259 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -36,7 +36,11 @@ import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-au import { createMiniAppDashboardHandler } from './miniapp-dashboard' import { createMiniAppApproveMemberHandler, - createMiniAppPendingMembersHandler + createMiniAppPendingMembersHandler, + createMiniAppPromoteMemberHandler, + createMiniAppSettingsHandler, + createMiniAppUpdateSettingsHandler, + createMiniAppUpsertUtilityCategoryHandler } from './miniapp-admin' import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' @@ -311,6 +315,42 @@ const server = createBotWebhookServer({ logger: getLogger('miniapp-admin') }) : undefined, + miniAppSettings: householdOnboardingService + ? createMiniAppSettingsHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + miniAppAdminService: miniAppAdminService!, + logger: getLogger('miniapp-admin') + }) + : undefined, + miniAppUpdateSettings: householdOnboardingService + ? createMiniAppUpdateSettingsHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + miniAppAdminService: miniAppAdminService!, + logger: getLogger('miniapp-admin') + }) + : undefined, + miniAppUpsertUtilityCategory: householdOnboardingService + ? createMiniAppUpsertUtilityCategoryHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + miniAppAdminService: miniAppAdminService!, + logger: getLogger('miniapp-admin') + }) + : undefined, + miniAppPromoteMember: householdOnboardingService + ? createMiniAppPromoteMemberHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + onboardingService: householdOnboardingService, + miniAppAdminService: miniAppAdminService!, + logger: getLogger('miniapp-admin') + }) + : undefined, miniAppLocalePreference: householdOnboardingService ? createMiniAppLocalePreferenceHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index d6fbe9b..3f7c979 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -8,7 +8,10 @@ import type { import { createMiniAppApproveMemberHandler, - createMiniAppPendingMembersHandler + createMiniAppPendingMembersHandler, + createMiniAppPromoteMemberHandler, + createMiniAppSettingsHandler, + createMiniAppUpdateSettingsHandler } from './miniapp-admin' import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' @@ -109,7 +112,56 @@ function onboardingRepository(): HouseholdConfigurationRepository { householdDefaultLocale: household.defaultLocale, isAdmin: false } + : null, + getHouseholdBillingSettings: async (householdId) => ({ + householdId, + rentAmountMinor: 70000n, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async (input) => ({ + householdId: input.householdId, + rentAmountMinor: input.rentAmountMinor ?? 70000n, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + }), + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async (input) => ({ + id: input.slug ?? 'utility-category-1', + householdId: input.householdId, + slug: input.slug ?? 'custom', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + }), + promoteHouseholdAdmin: async (householdId, memberId) => { + const member = [ + { + id: 'member-123456', + householdId, + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, + isAdmin: false + } + ].find((entry) => entry.id === memberId) + + return member + ? { + ...member, + isAdmin: true + } : null + } } } @@ -235,3 +287,216 @@ describe('createMiniAppApproveMemberHandler', () => { }) }) }) + +describe('createMiniAppSettingsHandler', () => { + test('returns billing settings and admin 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', + preferredLocale: null, + householdDefaultLocale: 'ru', + isAdmin: true + } + ] + repository.listHouseholdMembers = async () => [ + { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', + isAdmin: true + } + ] + + const handler = createMiniAppSettingsHandler({ + 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/settings', { + 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, + settings: { + householdId: 'household-1', + rentAmountMinor: '70000', + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }, + categories: [], + members: [ + { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', + isAdmin: true + } + ] + }) + }) +}) + +describe('createMiniAppUpdateSettingsHandler', () => { + test('updates billing settings 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', + preferredLocale: null, + householdDefaultLocale: 'ru', + isAdmin: true + } + ] + + const handler = createMiniAppUpdateSettingsHandler({ + 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/settings/update', { + 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' + }), + rentAmountMajor: '750', + rentCurrency: 'USD', + rentDueDay: 22, + rentWarningDay: 19, + utilitiesDueDay: 6, + utilitiesReminderDay: 5, + timezone: 'Asia/Tbilisi' + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + settings: { + householdId: 'household-1', + rentAmountMinor: '75000', + rentCurrency: 'USD', + rentDueDay: 22, + rentWarningDay: 19, + utilitiesDueDay: 6, + utilitiesReminderDay: 5, + timezone: 'Asia/Tbilisi' + } + }) + }) +}) + +describe('createMiniAppPromoteMemberHandler', () => { + test('promotes a household member to admin 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', + preferredLocale: null, + householdDefaultLocale: 'ru', + isAdmin: true + } + ] + + const handler = createMiniAppPromoteMemberHandler({ + 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/promote', { + 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' + }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + member: { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', + isAdmin: true + } + }) + }) +}) diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index ac40b3b..c676b60 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -1,5 +1,7 @@ import type { HouseholdOnboardingService, MiniAppAdminService } from '@household/application' import type { Logger } from '@household/observability' +import type { HouseholdBillingSettingsRecord } from '@household/ports' +import type { MiniAppSessionResult } from './miniapp-auth' import { allowedMiniAppOrigin, @@ -38,6 +40,190 @@ async function readApprovalPayload(request: Request): Promise<{ } } +async function readSettingsUpdatePayload(request: Request): Promise<{ + initData: string + rentAmountMajor?: string + rentCurrency?: string + rentDueDay: number + rentWarningDay: number + utilitiesDueDay: number + utilitiesReminderDay: number + timezone: 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: { + rentAmountMajor?: string + rentCurrency?: string + rentDueDay?: number + rentWarningDay?: number + utilitiesDueDay?: number + utilitiesReminderDay?: number + timezone?: string + } + try { + parsed = JSON.parse(text) + } catch { + throw new Error('Invalid JSON body') + } + + if ( + typeof parsed.rentDueDay !== 'number' || + typeof parsed.rentWarningDay !== 'number' || + typeof parsed.utilitiesDueDay !== 'number' || + typeof parsed.utilitiesReminderDay !== 'number' || + typeof parsed.timezone !== 'string' + ) { + throw new Error('Missing billing settings fields') + } + + return { + initData: payload.initData, + ...(typeof parsed.rentAmountMajor === 'string' + ? { + rentAmountMajor: parsed.rentAmountMajor + } + : {}), + ...(typeof parsed.rentCurrency === 'string' + ? { + rentCurrency: parsed.rentCurrency + } + : {}), + rentDueDay: parsed.rentDueDay, + rentWarningDay: parsed.rentWarningDay, + utilitiesDueDay: parsed.utilitiesDueDay, + utilitiesReminderDay: parsed.utilitiesReminderDay, + timezone: parsed.timezone + } +} + +async function readUtilityCategoryPayload(request: Request): Promise<{ + initData: string + slug?: string + name: string + sortOrder: number + isActive: boolean +}> { + const clonedRequest = request.clone() + const payload = await readMiniAppRequestPayload(request) + if (!payload.initData) { + throw new Error('Missing initData') + } + + const text = await clonedRequest.text() + let parsed: { + slug?: string + name?: string + sortOrder?: number + isActive?: boolean + } + try { + parsed = JSON.parse(text) + } catch { + throw new Error('Invalid JSON body') + } + + if ( + typeof parsed.name !== 'string' || + typeof parsed.sortOrder !== 'number' || + typeof parsed.isActive !== 'boolean' + ) { + throw new Error('Missing utility category fields') + } + + return { + initData: payload.initData, + ...(typeof parsed.slug === 'string' && parsed.slug.trim().length > 0 + ? { + slug: parsed.slug.trim() + } + : {}), + name: parsed.name, + sortOrder: parsed.sortOrder, + isActive: parsed.isActive + } +} + +async function readPromoteMemberPayload(request: Request): Promise<{ + initData: 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 } + try { + parsed = JSON.parse(text) + } catch { + throw new Error('Invalid JSON body') + } + + const memberId = parsed.memberId?.trim() + if (!memberId) { + throw new Error('Missing memberId') + } + + return { + initData: payload.initData, + memberId + } +} + +function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) { + return { + householdId: settings.householdId, + rentAmountMinor: settings.rentAmountMinor?.toString() ?? null, + rentCurrency: settings.rentCurrency, + rentDueDay: settings.rentDueDay, + rentWarningDay: settings.rentWarningDay, + utilitiesDueDay: settings.utilitiesDueDay, + utilitiesReminderDay: settings.utilitiesReminderDay, + timezone: settings.timezone + } +} + +async function authenticateAdminSession( + request: Request, + sessionService: ReturnType, + origin: string | undefined +): Promise< + | Response + | { + member: NonNullable + } +> { + 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 + ) + } + + return { + member: session.member + } +} + export function createMiniAppPendingMembersHandler(options: { allowedOrigins: readonly string[] botToken: string @@ -112,6 +298,337 @@ export function createMiniAppPendingMembersHandler(options: { } } +export function createMiniAppSettingsHandler(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 auth = await authenticateAdminSession(request, sessionService, origin) + if (auth instanceof Response) { + return auth + } + const { member } = auth + + const result = await options.miniAppAdminService.getSettings({ + householdId: member.householdId, + actorIsAdmin: member.isAdmin + }) + + if (result.status === 'rejected') { + return miniAppJsonResponse({ ok: false, error: 'Admin access required' }, 403, origin) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + settings: serializeBillingSettings(result.settings), + categories: result.categories, + members: result.members + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppUpdateSettingsHandler(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 readSettingsUpdatePayload(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.updateSettings({ + householdId: session.member.householdId, + actorIsAdmin: session.member.isAdmin, + ...(payload.rentAmountMajor !== undefined + ? { + rentAmountMajor: payload.rentAmountMajor + } + : {}), + ...(payload.rentCurrency + ? { + rentCurrency: payload.rentCurrency + } + : {}), + rentDueDay: payload.rentDueDay, + rentWarningDay: payload.rentWarningDay, + utilitiesDueDay: payload.utilitiesDueDay, + utilitiesReminderDay: payload.utilitiesReminderDay, + timezone: payload.timezone + }) + + if (result.status === 'rejected') { + return miniAppJsonResponse( + { + ok: false, + error: + result.reason === 'invalid_settings' + ? 'Invalid billing settings' + : 'Admin access required' + }, + result.reason === 'invalid_settings' ? 400 : 403, + origin + ) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + settings: serializeBillingSettings(result.settings) + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppUpsertUtilityCategoryHandler(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 readUtilityCategoryPayload(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.upsertUtilityCategory({ + householdId: session.member.householdId, + actorIsAdmin: session.member.isAdmin, + ...(payload.slug + ? { + slug: payload.slug + } + : {}), + name: payload.name, + sortOrder: payload.sortOrder, + isActive: payload.isActive + }) + + if (result.status === 'rejected') { + return miniAppJsonResponse( + { + ok: false, + error: + result.reason === 'invalid_category' + ? 'Invalid utility category' + : 'Admin access required' + }, + result.reason === 'invalid_category' ? 400 : 403, + origin + ) + } + + return miniAppJsonResponse( + { + ok: true, + authorized: true, + category: result.category + }, + 200, + origin + ) + } catch (error) { + return miniAppErrorResponse(error, origin, options.logger) + } + } + } +} + +export function createMiniAppPromoteMemberHandler(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 readPromoteMemberPayload(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.promoteMemberToAdmin({ + householdId: session.member.householdId, + actorIsAdmin: session.member.isAdmin, + memberId: payload.memberId + }) + + 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, + 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/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index d0f38b6..d766dec 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -140,7 +140,37 @@ function onboardingRepository(): HouseholdConfigurationRepository { preferredLocale: locale } : null - } + }, + getHouseholdBillingSettings: async (householdId) => ({ + householdId, + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async (input) => ({ + householdId: input.householdId, + rentAmountMinor: input.rentAmountMinor ?? null, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + }), + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async (input) => ({ + id: input.slug ?? 'utility-category-1', + householdId: input.householdId, + slug: input.slug ?? 'custom', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + }), + promoteHouseholdAdmin: async () => null } } diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index cca9dd7..a2fe368 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -135,7 +135,37 @@ function onboardingRepository(): HouseholdConfigurationRepository { ...household, defaultLocale: locale }), - updateMemberPreferredLocale: async () => null + updateMemberPreferredLocale: async () => null, + getHouseholdBillingSettings: async (householdId) => ({ + householdId, + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async (input) => ({ + householdId: input.householdId, + rentAmountMinor: input.rentAmountMinor ?? null, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + }), + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async (input) => ({ + id: input.slug ?? 'utility-category-1', + householdId: input.householdId, + slug: input.slug ?? 'custom', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + }), + promoteHouseholdAdmin: async () => null } } diff --git a/apps/bot/src/miniapp-locale.test.ts b/apps/bot/src/miniapp-locale.test.ts index 98dfac9..d93b76f 100644 --- a/apps/bot/src/miniapp-locale.test.ts +++ b/apps/bot/src/miniapp-locale.test.ts @@ -124,7 +124,37 @@ function repository(): HouseholdConfigurationRepository { } members.set(telegramUserId, next) return next - } + }, + getHouseholdBillingSettings: async (householdId) => ({ + householdId, + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async (input) => ({ + householdId: input.householdId, + rentAmountMinor: input.rentAmountMinor ?? null, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + }), + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async (input) => ({ + id: input.slug ?? 'utility-category-1', + householdId: input.householdId, + slug: input.slug ?? 'custom', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + }), + promoteHouseholdAdmin: async () => null } } diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index 8c8f2af..45b8968 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -34,6 +34,45 @@ describe('createBotWebhookServer', () => { } }) }, + miniAppSettings: { + handler: async () => + new Response( + JSON.stringify({ ok: true, authorized: true, settings: {}, categories: [], members: [] }), + { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + } + ) + }, + miniAppUpdateSettings: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, settings: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, + miniAppUpsertUtilityCategory: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, category: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, + miniAppPromoteMember: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, miniAppApproveMember: { handler: async () => new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), { @@ -154,6 +193,72 @@ describe('createBotWebhookServer', () => { }) }) + test('accepts mini app settings request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/settings', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + settings: {}, + categories: [], + members: [] + }) + }) + + test('accepts mini app settings update request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/settings/update', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + settings: {} + }) + }) + + test('accepts mini app utility category upsert request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/utility-categories/upsert', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true, + category: {} + }) + }) + + test('accepts mini app promote member request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/admin/members/promote', { + 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 approve member request', async () => { const response = await server.fetch( new Request('http://localhost/api/miniapp/admin/approve-member', { diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index 47dd917..0567134 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -32,6 +32,30 @@ export interface BotWebhookServerOptions { handler: (request: Request) => Promise } | undefined + miniAppSettings?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppUpdateSettings?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppUpsertUtilityCategory?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined + miniAppPromoteMember?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined miniAppLocalePreference?: | { path?: string @@ -75,6 +99,13 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members' const miniAppApproveMemberPath = options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member' + const miniAppSettingsPath = options.miniAppSettings?.path ?? '/api/miniapp/admin/settings' + const miniAppUpdateSettingsPath = + options.miniAppUpdateSettings?.path ?? '/api/miniapp/admin/settings/update' + const miniAppUpsertUtilityCategoryPath = + options.miniAppUpsertUtilityCategory?.path ?? '/api/miniapp/admin/utility-categories/upsert' + const miniAppPromoteMemberPath = + options.miniAppPromoteMember?.path ?? '/api/miniapp/admin/members/promote' const miniAppLocalePreferencePath = options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale' const schedulerPathPrefix = options.scheduler @@ -109,6 +140,25 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return await options.miniAppApproveMember.handler(request) } + if (options.miniAppSettings && url.pathname === miniAppSettingsPath) { + return await options.miniAppSettings.handler(request) + } + + if (options.miniAppUpdateSettings && url.pathname === miniAppUpdateSettingsPath) { + return await options.miniAppUpdateSettings.handler(request) + } + + if ( + options.miniAppUpsertUtilityCategory && + url.pathname === miniAppUpsertUtilityCategoryPath + ) { + return await options.miniAppUpsertUtilityCategory.handler(request) + } + + if (options.miniAppPromoteMember && url.pathname === miniAppPromoteMemberPath) { + return await options.miniAppPromoteMember.handler(request) + } + if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) { return await options.miniAppLocalePreference.handler(request) } diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index f891cb4..599ce04 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -3,11 +3,16 @@ import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'soli import { dictionary, type Locale } from './i18n' import { approveMiniAppPendingMember, + fetchMiniAppAdminSettings, fetchMiniAppDashboard, fetchMiniAppPendingMembers, fetchMiniAppSession, joinMiniAppHousehold, + promoteMiniAppMember, + type MiniAppAdminSettingsPayload, updateMiniAppLocalePreference, + updateMiniAppBillingSettings, + upsertMiniAppUtilityCategory, type MiniAppDashboard, type MiniAppPendingMember } from './miniapp-api' @@ -123,10 +128,24 @@ function App() { const [activeNav, setActiveNav] = createSignal('home') const [dashboard, setDashboard] = createSignal(null) const [pendingMembers, setPendingMembers] = createSignal([]) + const [adminSettings, setAdminSettings] = createSignal(null) const [joining, setJoining] = createSignal(false) const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal(null) + const [promotingMemberId, setPromotingMemberId] = createSignal(null) const [savingMemberLocale, setSavingMemberLocale] = createSignal(false) const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false) + const [savingBillingSettings, setSavingBillingSettings] = createSignal(false) + const [savingCategorySlug, setSavingCategorySlug] = createSignal(null) + const [billingForm, setBillingForm] = createSignal({ + rentAmountMajor: '', + rentCurrency: 'USD' as 'USD' | 'GEL', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }) + const [newCategoryName, setNewCategoryName] = createSignal('') const copy = createMemo(() => dictionary[locale()]) const onboardingSession = createMemo(() => { @@ -167,6 +186,30 @@ function App() { } } + async function loadAdminSettings(initData: string) { + try { + const payload = await fetchMiniAppAdminSettings(initData) + setAdminSettings(payload) + setBillingForm({ + rentAmountMajor: payload.settings.rentAmountMinor + ? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2) + : '', + rentCurrency: payload.settings.rentCurrency, + rentDueDay: payload.settings.rentDueDay, + rentWarningDay: payload.settings.rentWarningDay, + utilitiesDueDay: payload.settings.utilitiesDueDay, + utilitiesReminderDay: payload.settings.utilitiesReminderDay, + timezone: payload.settings.timezone + }) + } catch (error) { + if (import.meta.env.DEV) { + console.warn('Failed to load mini app admin settings', error) + } + + setAdminSettings(null) + } + } + async function bootstrap() { const fallbackLocale = detectLocale() setLocale(fallbackLocale) @@ -223,6 +266,7 @@ function App() { await loadDashboard(initData) if (payload.member.isAdmin) { await loadPendingMembers(initData) + await loadAdminSettings(initData) } } catch { if (import.meta.env.DEV) { @@ -315,6 +359,7 @@ function App() { await loadDashboard(initData) if (payload.member.isAdmin) { await loadPendingMembers(initData) + await loadAdminSettings(initData) } return } @@ -430,6 +475,93 @@ function App() { } } + async function handleSaveBillingSettings() { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { + return + } + + setSavingBillingSettings(true) + + try { + const settings = await updateMiniAppBillingSettings(initData, billingForm()) + setAdminSettings((current) => + current + ? { + ...current, + settings + } + : current + ) + } finally { + setSavingBillingSettings(false) + } + } + + async function handleSaveUtilityCategory(input: { + slug?: string + name: string + sortOrder: number + isActive: boolean + }) { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { + return + } + + setSavingCategorySlug(input.slug ?? '__new__') + + try { + const category = await upsertMiniAppUtilityCategory(initData, input) + setAdminSettings((current) => { + if (!current) { + return current + } + + const categories = current.categories.some((item) => item.slug === category.slug) + ? current.categories.map((item) => (item.slug === category.slug ? category : item)) + : [...current.categories, category] + + return { + ...current, + categories: [...categories].sort((left, right) => left.sortOrder - right.sortOrder) + } + }) + + if (!input.slug) { + setNewCategoryName('') + } + } finally { + setSavingCategorySlug(null) + } + } + + async function handlePromoteMember(memberId: string) { + const initData = webApp?.initData?.trim() + const currentReady = readySession() + if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) { + return + } + + setPromotingMemberId(memberId) + + try { + const member = await promoteMiniAppMember(initData, memberId) + setAdminSettings((current) => + current + ? { + ...current, + members: current.members.map((item) => (item.id === member.id ? member : item)) + } + : current + ) + } finally { + setPromotingMemberId(null) + } + } + const renderPanel = () => { switch (activeNav()) { case 'balances': @@ -493,6 +625,120 @@ function App() {

{copy().householdSettingsBody}

+
+
+ {copy().billingSettingsTitle} +
+
+ + + + + + + +
+ +
{copy().householdLanguage} @@ -521,6 +767,149 @@ function App() {
+
+
+ {copy().utilityCategoriesTitle} +
+

{copy().utilityCategoriesBody}

+
+ {adminSettings()?.categories.map((category) => ( +
+
+ {category.name} + {category.isActive ? 'ON' : 'OFF'} +
+
+ + +
+ +
+ ))} +
+ + +
+
+
+
+
+ {copy().adminsTitle} +
+

{copy().adminsBody}

+
+ {adminSettings()?.members.map((member) => ( +
+
+ {member.displayName} + {member.isAdmin ? copy().adminTag : copy().residentTag} +
+ {!member.isAdmin ? ( + + ) : null} +
+ ))} +
+
{copy().pendingMembersTitle} diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index ab6e6b8..0adc039 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -54,6 +54,26 @@ export const dictionary = { latestActivityEmpty: 'Recent utility and purchase entries will appear here.', householdSettingsTitle: 'Household settings', householdSettingsBody: 'Control household defaults and approve roommates who requested access.', + billingSettingsTitle: 'Billing settings', + rentAmount: 'Rent amount', + rentDueDay: 'Rent due day', + rentWarningDay: 'Rent warning day', + utilitiesDueDay: 'Utilities due day', + utilitiesReminderDay: 'Utilities reminder day', + timezone: 'Timezone', + saveSettingsAction: 'Save settings', + savingSettings: 'Saving settings…', + utilityCategoriesTitle: 'Utility categories', + utilityCategoriesBody: 'Manage the categories admins use for monthly utility entry.', + utilityCategoryName: 'Category name', + utilityCategoryActive: 'Active', + addCategoryAction: 'Add category', + saveCategoryAction: 'Save category', + savingCategory: 'Saving…', + adminsTitle: 'Admins', + adminsBody: 'Promote trusted household members so they can manage billing and approvals.', + promoteAdminAction: 'Promote to admin', + promotingAdmin: 'Promoting…', residentHouseTitle: 'Household access', residentHouseBody: 'Your admins manage household settings and approvals here. You can still switch your own language above.', @@ -120,6 +140,28 @@ export const dictionary = { latestActivityEmpty: 'Здесь появятся последние коммунальные платежи и покупки.', householdSettingsTitle: 'Настройки household', householdSettingsBody: 'Здесь можно менять язык household и подтверждать новых соседей.', + billingSettingsTitle: 'Настройки биллинга', + rentAmount: 'Сумма аренды', + rentDueDay: 'День оплаты аренды', + rentWarningDay: 'День напоминания по аренде', + utilitiesDueDay: 'День оплаты коммуналки', + utilitiesReminderDay: 'День напоминания по коммуналке', + timezone: 'Часовой пояс', + saveSettingsAction: 'Сохранить настройки', + savingSettings: 'Сохраняем настройки…', + utilityCategoriesTitle: 'Категории коммуналки', + utilityCategoriesBody: + 'Управляй категориями, которые админы используют для ежемесячного ввода коммунальных счетов.', + utilityCategoryName: 'Название категории', + utilityCategoryActive: 'Активна', + addCategoryAction: 'Добавить категорию', + saveCategoryAction: 'Сохранить категорию', + savingCategory: 'Сохраняем…', + adminsTitle: 'Админы', + adminsBody: + 'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.', + promoteAdminAction: 'Сделать админом', + promotingAdmin: 'Повышаем…', residentHouseTitle: 'Доступ к household', residentHouseBody: 'Настройки household и подтверждение заявок управляются админами. Свой язык можно менять переключателем выше.', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 9c28e04..8577f52 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -283,6 +283,37 @@ button { font-size: clamp(1.2rem, 4vw, 1.7rem); } +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.settings-field { + display: grid; + gap: 6px; +} + +.settings-field span { + color: #c6c2bb; + font-size: 0.82rem; +} + +.settings-field input, +.settings-field select { + width: 100%; + border: 1px solid rgb(255 255 255 / 0.12); + border-radius: 14px; + padding: 12px 14px; + background: rgb(255 255 255 / 0.04); + color: inherit; +} + +.settings-field--wide { + grid-column: 1 / -1; +} + .panel--wide { min-height: 170px; } @@ -302,6 +333,10 @@ button { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .settings-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .panel--wide { grid-column: 1 / -1; } diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 3bd21b5..574fbdc 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -34,6 +34,32 @@ export interface MiniAppPendingMember { languageCode: string | null } +export interface MiniAppMember { + id: string + displayName: string + isAdmin: boolean +} + +export interface MiniAppBillingSettings { + householdId: string + rentAmountMinor: string | null + rentCurrency: 'USD' | 'GEL' + rentDueDay: number + rentWarningDay: number + utilitiesDueDay: number + utilitiesReminderDay: number + timezone: string +} + +export interface MiniAppUtilityCategory { + id: string + householdId: string + slug: string + name: string + sortOrder: number + isActive: boolean +} + export interface MiniAppDashboard { period: string currency: 'USD' | 'GEL' @@ -57,6 +83,12 @@ export interface MiniAppDashboard { }[] } +export interface MiniAppAdminSettingsPayload { + settings: MiniAppBillingSettings + categories: readonly MiniAppUtilityCategory[] + members: readonly MiniAppMember[] +} + function apiBaseUrl(): string { const runtimeConfigured = runtimeBotApiUrl() if (runtimeConfigured) { @@ -260,3 +292,142 @@ export async function updateMiniAppLocalePreference( return payload.locale } + +export async function fetchMiniAppAdminSettings( + initData: string +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/settings`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + settings?: MiniAppBillingSettings + categories?: MiniAppUtilityCategory[] + members?: MiniAppMember[] + error?: string + } + + if ( + !response.ok || + !payload.authorized || + !payload.settings || + !payload.categories || + !payload.members + ) { + throw new Error(payload.error ?? 'Failed to load admin settings') + } + + return { + settings: payload.settings, + categories: payload.categories, + members: payload.members + } +} + +export async function updateMiniAppBillingSettings( + initData: string, + input: { + rentAmountMajor?: string + rentCurrency: 'USD' | 'GEL' + rentDueDay: number + rentWarningDay: number + utilitiesDueDay: number + utilitiesReminderDay: number + timezone: string + } +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/settings/update`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + ...input + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + settings?: MiniAppBillingSettings + error?: string + } + + if (!response.ok || !payload.authorized || !payload.settings) { + throw new Error(payload.error ?? 'Failed to update billing settings') + } + + return payload.settings +} + +export async function upsertMiniAppUtilityCategory( + initData: string, + input: { + slug?: string + name: string + sortOrder: number + isActive: boolean + } +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/utility-categories/upsert`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + ...input + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + category?: MiniAppUtilityCategory + error?: string + } + + if (!response.ok || !payload.authorized || !payload.category) { + throw new Error(payload.error ?? 'Failed to save utility category') + } + + return payload.category +} + +export async function promoteMiniAppMember( + initData: string, + memberId: string +): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/members/promote`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData, + memberId + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + member?: MiniAppMember + error?: string + } + + if (!response.ok || !payload.authorized || !payload.member) { + throw new Error(payload.error ?? 'Failed to promote member') + } + + return payload.member +} diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index f6421f8..8e5ab23 100644 --- a/packages/adapters-db/src/household-config-repository.ts +++ b/packages/adapters-db/src/household-config-repository.ts @@ -1,9 +1,15 @@ import { and, asc, eq } from 'drizzle-orm' import { createDbClient, schema } from '@household/db' -import { instantToDate, normalizeSupportedLocale, nowInstant } from '@household/domain' +import { + instantToDate, + normalizeSupportedLocale, + nowInstant, + type CurrencyCode +} from '@household/domain' import { HOUSEHOLD_TOPIC_ROLES, + type HouseholdBillingSettingsRecord, type HouseholdConfigurationRepository, type HouseholdJoinTokenRecord, type HouseholdMemberRecord, @@ -11,6 +17,7 @@ import { type HouseholdTelegramChatRecord, type HouseholdTopicBindingRecord, type HouseholdTopicRole, + type HouseholdUtilityCategoryRecord, type ReminderTarget, type RegisterTelegramHouseholdChatResult } from '@household/ports' @@ -147,6 +154,65 @@ function toReminderTarget(row: { } } +function toCurrencyCode(raw: string): CurrencyCode { + const normalized = raw.trim().toUpperCase() + + if (normalized !== 'USD' && normalized !== 'GEL') { + throw new Error(`Unsupported household billing currency: ${raw}`) + } + + return normalized +} + +function toHouseholdBillingSettingsRecord(row: { + householdId: string + rentAmountMinor: bigint | null + rentCurrency: string + rentDueDay: number + rentWarningDay: number + utilitiesDueDay: number + utilitiesReminderDay: number + timezone: string +}): HouseholdBillingSettingsRecord { + return { + householdId: row.householdId, + rentAmountMinor: row.rentAmountMinor, + rentCurrency: toCurrencyCode(row.rentCurrency), + rentDueDay: row.rentDueDay, + rentWarningDay: row.rentWarningDay, + utilitiesDueDay: row.utilitiesDueDay, + utilitiesReminderDay: row.utilitiesReminderDay, + timezone: row.timezone + } +} + +function toHouseholdUtilityCategoryRecord(row: { + id: string + householdId: string + slug: string + name: string + sortOrder: number + isActive: number +}): HouseholdUtilityCategoryRecord { + return { + id: row.id, + householdId: row.householdId, + slug: row.slug, + name: row.name, + sortOrder: row.sortOrder, + isActive: row.isActive === 1 + } +} + +function utilityCategorySlug(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 48) +} + export function createDbHouseholdConfigurationRepository(databaseUrl: string): { repository: HouseholdConfigurationRepository close: () => Promise @@ -156,6 +222,43 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { prepare: false }) + const defaultUtilityCategories = [ + { slug: 'internet', name: 'Internet', sortOrder: 0 }, + { slug: 'gas_water', name: 'Gas (Water)', sortOrder: 1 }, + { slug: 'cleaning', name: 'Cleaning', sortOrder: 2 }, + { slug: 'electricity', name: 'Electricity', sortOrder: 3 } + ] as const + + async function ensureBillingSettings(householdId: string): Promise { + await db + .insert(schema.householdBillingSettings) + .values({ + householdId + }) + .onConflictDoNothing({ + target: [schema.householdBillingSettings.householdId] + }) + } + + async function ensureUtilityCategories(householdId: string): Promise { + await db + .insert(schema.householdUtilityCategories) + .values( + defaultUtilityCategories.map((category) => ({ + householdId, + slug: category.slug, + name: category.name, + sortOrder: category.sortOrder + })) + ) + .onConflictDoNothing({ + target: [ + schema.householdUtilityCategories.householdId, + schema.householdUtilityCategories.slug + ] + }) + } + const repository: HouseholdConfigurationRepository = { async registerTelegramHouseholdChat(input) { return await db.transaction(async (tx): Promise => { @@ -713,6 +816,161 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { return rows.map(toHouseholdMemberRecord) }, + async getHouseholdBillingSettings(householdId) { + await ensureBillingSettings(householdId) + + const rows = await db + .select({ + householdId: schema.householdBillingSettings.householdId, + rentAmountMinor: schema.householdBillingSettings.rentAmountMinor, + rentCurrency: schema.householdBillingSettings.rentCurrency, + rentDueDay: schema.householdBillingSettings.rentDueDay, + rentWarningDay: schema.householdBillingSettings.rentWarningDay, + utilitiesDueDay: schema.householdBillingSettings.utilitiesDueDay, + utilitiesReminderDay: schema.householdBillingSettings.utilitiesReminderDay, + timezone: schema.householdBillingSettings.timezone + }) + .from(schema.householdBillingSettings) + .where(eq(schema.householdBillingSettings.householdId, householdId)) + .limit(1) + + const row = rows[0] + if (!row) { + throw new Error('Failed to load household billing settings') + } + + return toHouseholdBillingSettingsRecord(row) + }, + + async updateHouseholdBillingSettings(input) { + await ensureBillingSettings(input.householdId) + + const rows = await db + .update(schema.householdBillingSettings) + .set({ + ...(input.rentAmountMinor !== undefined + ? { + rentAmountMinor: input.rentAmountMinor + } + : {}), + ...(input.rentCurrency + ? { + rentCurrency: input.rentCurrency + } + : {}), + ...(input.rentDueDay !== undefined + ? { + rentDueDay: input.rentDueDay + } + : {}), + ...(input.rentWarningDay !== undefined + ? { + rentWarningDay: input.rentWarningDay + } + : {}), + ...(input.utilitiesDueDay !== undefined + ? { + utilitiesDueDay: input.utilitiesDueDay + } + : {}), + ...(input.utilitiesReminderDay !== undefined + ? { + utilitiesReminderDay: input.utilitiesReminderDay + } + : {}), + ...(input.timezone + ? { + timezone: input.timezone + } + : {}), + updatedAt: instantToDate(nowInstant()) + }) + .where(eq(schema.householdBillingSettings.householdId, input.householdId)) + .returning({ + householdId: schema.householdBillingSettings.householdId, + rentAmountMinor: schema.householdBillingSettings.rentAmountMinor, + rentCurrency: schema.householdBillingSettings.rentCurrency, + rentDueDay: schema.householdBillingSettings.rentDueDay, + rentWarningDay: schema.householdBillingSettings.rentWarningDay, + utilitiesDueDay: schema.householdBillingSettings.utilitiesDueDay, + utilitiesReminderDay: schema.householdBillingSettings.utilitiesReminderDay, + timezone: schema.householdBillingSettings.timezone + }) + + const row = rows[0] + if (!row) { + throw new Error('Failed to update household billing settings') + } + + return toHouseholdBillingSettingsRecord(row) + }, + + async listHouseholdUtilityCategories(householdId) { + await ensureUtilityCategories(householdId) + + const rows = await db + .select({ + id: schema.householdUtilityCategories.id, + householdId: schema.householdUtilityCategories.householdId, + slug: schema.householdUtilityCategories.slug, + name: schema.householdUtilityCategories.name, + sortOrder: schema.householdUtilityCategories.sortOrder, + isActive: schema.householdUtilityCategories.isActive + }) + .from(schema.householdUtilityCategories) + .where(eq(schema.householdUtilityCategories.householdId, householdId)) + .orderBy( + asc(schema.householdUtilityCategories.sortOrder), + asc(schema.householdUtilityCategories.name) + ) + + return rows.map(toHouseholdUtilityCategoryRecord) + }, + + async upsertHouseholdUtilityCategory(input) { + const slug = utilityCategorySlug(input.slug ?? input.name) + if (!slug) { + throw new Error('Utility category slug cannot be empty') + } + + const rows = await db + .insert(schema.householdUtilityCategories) + .values({ + householdId: input.householdId, + slug, + name: input.name.trim(), + sortOrder: input.sortOrder, + isActive: input.isActive ? 1 : 0 + }) + .onConflictDoUpdate({ + target: [ + schema.householdUtilityCategories.householdId, + schema.householdUtilityCategories.slug + ], + set: { + name: input.name.trim(), + sortOrder: input.sortOrder, + isActive: input.isActive ? 1 : 0, + updatedAt: instantToDate(nowInstant()) + } + }) + .returning({ + id: schema.householdUtilityCategories.id, + householdId: schema.householdUtilityCategories.householdId, + slug: schema.householdUtilityCategories.slug, + name: schema.householdUtilityCategories.name, + sortOrder: schema.householdUtilityCategories.sortOrder, + isActive: schema.householdUtilityCategories.isActive + }) + + const row = rows[0] + if (!row) { + throw new Error('Failed to upsert household utility category') + } + + return toHouseholdUtilityCategoryRecord(row) + }, + async listHouseholdMembersByTelegramUserId(telegramUserId) { const rows = await db .select({ @@ -896,6 +1154,38 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { throw new Error('Failed to resolve household chat after member locale update') } + return toHouseholdMemberRecord({ + ...row, + defaultLocale: household.defaultLocale + }) + }, + + async promoteHouseholdAdmin(householdId, memberId) { + const rows = await db + .update(schema.members) + .set({ + isAdmin: 1 + }) + .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, + preferredLocale: schema.members.preferredLocale, + 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 admin promotion') + } + return toHouseholdMemberRecord({ ...row, defaultLocale: household.defaultLocale diff --git a/packages/application/src/household-admin-service.test.ts b/packages/application/src/household-admin-service.test.ts index c5ad96b..4b286f1 100644 --- a/packages/application/src/household-admin-service.test.ts +++ b/packages/application/src/household-admin-service.test.ts @@ -137,7 +137,37 @@ function createRepositoryStub() { preferredLocale: locale } : null - } + }, + getHouseholdBillingSettings: async (householdId) => ({ + householdId, + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async (input) => ({ + householdId: input.householdId, + rentAmountMinor: input.rentAmountMinor ?? null, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + }), + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async (input) => ({ + id: input.slug ?? 'utility-category-1', + householdId: input.householdId, + slug: input.slug ?? 'custom', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + }), + promoteHouseholdAdmin: async () => null } return { diff --git a/packages/application/src/household-onboarding-service.test.ts b/packages/application/src/household-onboarding-service.test.ts index cc91a14..a15256c 100644 --- a/packages/application/src/household-onboarding-service.test.ts +++ b/packages/application/src/household-onboarding-service.test.ts @@ -150,6 +150,46 @@ function createRepositoryStub() { preferredLocale: locale } : null + }, + async getHouseholdBillingSettings(householdId) { + return { + householdId, + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + } + }, + async updateHouseholdBillingSettings(input) { + return { + householdId: input.householdId, + rentAmountMinor: input.rentAmountMinor ?? null, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + } + }, + async listHouseholdUtilityCategories() { + return [] + }, + async upsertHouseholdUtilityCategory(input) { + return { + id: input.slug ?? 'utility-category-1', + householdId: input.householdId, + slug: input.slug ?? 'custom', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + } + }, + async promoteHouseholdAdmin() { + return null } } diff --git a/packages/application/src/household-setup-service.test.ts b/packages/application/src/household-setup-service.test.ts index d956da3..44f7f4c 100644 --- a/packages/application/src/household-setup-service.test.ts +++ b/packages/application/src/household-setup-service.test.ts @@ -244,6 +244,58 @@ function createRepositoryStub() { preferredLocale: locale } : null + }, + async getHouseholdBillingSettings(householdId) { + return { + householdId, + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + } + }, + async updateHouseholdBillingSettings(input) { + return { + householdId: input.householdId, + rentAmountMinor: input.rentAmountMinor ?? null, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + } + }, + async listHouseholdUtilityCategories() { + return [] + }, + async upsertHouseholdUtilityCategory(input) { + return { + id: input.slug ?? 'utility-category-1', + householdId: input.householdId, + slug: input.slug ?? 'custom', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + } + }, + async promoteHouseholdAdmin(householdId, memberId) { + const member = [...members.values()].find( + (entry) => entry.householdId === householdId && entry.id === memberId + ) + if (!member) { + return null + } + + const next = { + ...member, + isAdmin: true + } + members.set(`${householdId}:${member.telegramUserId}`, next) + return next } } diff --git a/packages/application/src/locale-preference-service.test.ts b/packages/application/src/locale-preference-service.test.ts index 1fe8a06..be97767 100644 --- a/packages/application/src/locale-preference-service.test.ts +++ b/packages/application/src/locale-preference-service.test.ts @@ -74,7 +74,37 @@ function createRepository(): HouseholdConfigurationRepository { preferredLocale: locale, householdDefaultLocale: 'ru' } - : null + : null, + getHouseholdBillingSettings: async (householdId) => ({ + householdId, + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async (input) => ({ + householdId: input.householdId, + rentAmountMinor: input.rentAmountMinor ?? null, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + }), + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async (input) => ({ + id: input.slug ?? 'utility-category-1', + householdId: input.householdId, + slug: input.slug ?? 'custom', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + }), + promoteHouseholdAdmin: async () => null } } diff --git a/packages/application/src/miniapp-admin-service.test.ts b/packages/application/src/miniapp-admin-service.test.ts index 650ba97..4128163 100644 --- a/packages/application/src/miniapp-admin-service.test.ts +++ b/packages/application/src/miniapp-admin-service.test.ts @@ -102,11 +102,131 @@ function repository(): HouseholdConfigurationRepository { householdDefaultLocale: 'ru', isAdmin: false } + : null, + getHouseholdBillingSettings: async (householdId) => ({ + householdId, + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async (input) => ({ + householdId: input.householdId, + rentAmountMinor: input.rentAmountMinor ?? null, + rentCurrency: input.rentCurrency ?? 'USD', + rentDueDay: input.rentDueDay ?? 20, + rentWarningDay: input.rentWarningDay ?? 17, + utilitiesDueDay: input.utilitiesDueDay ?? 4, + utilitiesReminderDay: input.utilitiesReminderDay ?? 3, + timezone: input.timezone ?? 'Asia/Tbilisi' + }), + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async (input) => ({ + id: input.slug ?? 'utility-category-1', + householdId: input.householdId, + slug: input.slug ?? 'custom', + name: input.name, + sortOrder: input.sortOrder, + isActive: input.isActive + }), + promoteHouseholdAdmin: async (householdId, memberId) => + memberId === 'member-123456' + ? { + id: memberId, + householdId, + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', + isAdmin: true + } : null } } describe('createMiniAppAdminService', () => { + test('returns billing settings, utility categories, and members for admins', async () => { + const service = createMiniAppAdminService(repository()) + + const result = await service.getSettings({ + householdId: 'household-1', + actorIsAdmin: true + }) + + expect(result).toEqual({ + status: 'ok', + settings: { + householdId: 'household-1', + rentAmountMinor: null, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }, + categories: [], + members: [] + }) + }) + + test('updates billing settings for admins', async () => { + const service = createMiniAppAdminService(repository()) + + const result = await service.updateSettings({ + householdId: 'household-1', + actorIsAdmin: true, + rentAmountMajor: '700', + rentCurrency: 'USD', + rentDueDay: 21, + rentWarningDay: 18, + utilitiesDueDay: 5, + utilitiesReminderDay: 4, + timezone: 'Asia/Tbilisi' + }) + + expect(result).toEqual({ + status: 'ok', + settings: { + householdId: 'household-1', + rentAmountMinor: 70000n, + rentCurrency: 'USD', + rentDueDay: 21, + rentWarningDay: 18, + utilitiesDueDay: 5, + utilitiesReminderDay: 4, + timezone: 'Asia/Tbilisi' + } + }) + }) + + test('upserts utility categories for admins', async () => { + const service = createMiniAppAdminService(repository()) + + const result = await service.upsertUtilityCategory({ + householdId: 'household-1', + actorIsAdmin: true, + name: 'Internet', + sortOrder: 0, + isActive: true + }) + + expect(result).toEqual({ + status: 'ok', + category: { + id: 'utility-category-1', + householdId: 'household-1', + slug: 'custom', + name: 'Internet', + sortOrder: 0, + isActive: true + } + }) + }) + test('lists pending members for admins', async () => { const service = createMiniAppAdminService(repository()) @@ -167,4 +287,27 @@ describe('createMiniAppAdminService', () => { } }) }) + + test('promotes an active member to household admin', async () => { + const service = createMiniAppAdminService(repository()) + + const result = await service.promoteMemberToAdmin({ + householdId: 'household-1', + actorIsAdmin: true, + memberId: 'member-123456' + }) + + expect(result).toEqual({ + status: 'ok', + member: { + id: 'member-123456', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', + isAdmin: true + } + }) + }) }) diff --git a/packages/application/src/miniapp-admin-service.ts b/packages/application/src/miniapp-admin-service.ts index fcd4ff0..abc403e 100644 --- a/packages/application/src/miniapp-admin-service.ts +++ b/packages/application/src/miniapp-admin-service.ts @@ -1,10 +1,75 @@ import type { + HouseholdBillingSettingsRecord, HouseholdConfigurationRepository, HouseholdMemberRecord, - HouseholdPendingMemberRecord + HouseholdPendingMemberRecord, + HouseholdUtilityCategoryRecord } from '@household/ports' +import { Money, type CurrencyCode } from '@household/domain' + +function isValidDay(value: number): boolean { + return Number.isInteger(value) && value >= 1 && value <= 31 +} + +function parseCurrency(raw: string): CurrencyCode { + const normalized = raw.trim().toUpperCase() + if (normalized !== 'USD' && normalized !== 'GEL') { + throw new Error(`Unsupported currency: ${raw}`) + } + + return normalized +} export interface MiniAppAdminService { + getSettings(input: { householdId: string; actorIsAdmin: boolean }): Promise< + | { + status: 'ok' + settings: HouseholdBillingSettingsRecord + categories: readonly HouseholdUtilityCategoryRecord[] + members: readonly HouseholdMemberRecord[] + } + | { + status: 'rejected' + reason: 'not_admin' + } + > + updateSettings(input: { + householdId: string + actorIsAdmin: boolean + rentAmountMajor?: string + rentCurrency?: string + rentDueDay: number + rentWarningDay: number + utilitiesDueDay: number + utilitiesReminderDay: number + timezone: string + }): Promise< + | { + status: 'ok' + settings: HouseholdBillingSettingsRecord + } + | { + status: 'rejected' + reason: 'not_admin' | 'invalid_settings' + } + > + upsertUtilityCategory(input: { + householdId: string + actorIsAdmin: boolean + slug?: string + name: string + sortOrder: number + isActive: boolean + }): Promise< + | { + status: 'ok' + category: HouseholdUtilityCategoryRecord + } + | { + status: 'rejected' + reason: 'not_admin' | 'invalid_category' + } + > listPendingMembers(input: { householdId: string; actorIsAdmin: boolean }): Promise< | { status: 'ok' @@ -29,12 +94,144 @@ export interface MiniAppAdminService { reason: 'not_admin' | 'pending_not_found' } > + promoteMemberToAdmin(input: { + householdId: string + actorIsAdmin: boolean + memberId: string + }): Promise< + | { + status: 'ok' + member: HouseholdMemberRecord + } + | { + status: 'rejected' + reason: 'not_admin' | 'member_not_found' + } + > } export function createMiniAppAdminService( repository: HouseholdConfigurationRepository ): MiniAppAdminService { return { + async getSettings(input) { + if (!input.actorIsAdmin) { + return { + status: 'rejected', + reason: 'not_admin' + } + } + + const [settings, categories, members] = await Promise.all([ + repository.getHouseholdBillingSettings(input.householdId), + repository.listHouseholdUtilityCategories(input.householdId), + repository.listHouseholdMembers(input.householdId) + ]) + + return { + status: 'ok', + settings, + categories, + members + } + }, + + async updateSettings(input) { + if (!input.actorIsAdmin) { + return { + status: 'rejected', + reason: 'not_admin' + } + } + + if ( + !isValidDay(input.rentDueDay) || + !isValidDay(input.rentWarningDay) || + !isValidDay(input.utilitiesDueDay) || + !isValidDay(input.utilitiesReminderDay) || + input.timezone.trim().length === 0 || + input.rentWarningDay > input.rentDueDay || + input.utilitiesReminderDay > input.utilitiesDueDay + ) { + return { + status: 'rejected', + reason: 'invalid_settings' + } + } + + let rentAmountMinor: bigint | null | undefined + let rentCurrency: CurrencyCode | undefined + + if (input.rentAmountMajor && input.rentAmountMajor.trim().length > 0) { + rentCurrency = parseCurrency(input.rentCurrency ?? 'USD') + rentAmountMinor = Money.fromMajor(input.rentAmountMajor, rentCurrency).amountMinor + } else if (input.rentAmountMajor === '') { + rentAmountMinor = null + rentCurrency = parseCurrency(input.rentCurrency ?? 'USD') + } + + const settings = await repository.updateHouseholdBillingSettings({ + householdId: input.householdId, + ...(rentAmountMinor !== undefined + ? { + rentAmountMinor + } + : {}), + ...(rentCurrency + ? { + rentCurrency + } + : {}), + rentDueDay: input.rentDueDay, + rentWarningDay: input.rentWarningDay, + utilitiesDueDay: input.utilitiesDueDay, + utilitiesReminderDay: input.utilitiesReminderDay, + timezone: input.timezone.trim() + }) + + return { + status: 'ok', + settings + } + }, + + async upsertUtilityCategory(input) { + if (!input.actorIsAdmin) { + return { + status: 'rejected', + reason: 'not_admin' + } + } + + if ( + input.name.trim().length === 0 || + !Number.isInteger(input.sortOrder) || + input.sortOrder < 0 + ) { + return { + status: 'rejected', + reason: 'invalid_category' + } + } + + const category = await repository.upsertHouseholdUtilityCategory({ + householdId: input.householdId, + ...(input.slug + ? { + slug: input.slug + } + : {}), + name: input.name.trim(), + sortOrder: input.sortOrder, + isActive: input.isActive + }) + + return { + status: 'ok', + category + } + }, + async listPendingMembers(input) { if (!input.actorIsAdmin) { return { @@ -73,6 +270,28 @@ export function createMiniAppAdminService( status: 'approved', member } + }, + + async promoteMemberToAdmin(input) { + if (!input.actorIsAdmin) { + return { + status: 'rejected', + reason: 'not_admin' + } + } + + const member = await repository.promoteHouseholdAdmin(input.householdId, input.memberId) + if (!member) { + return { + status: 'rejected', + reason: 'member_not_found' + } + } + + return { + status: 'ok', + member + } } } } diff --git a/packages/db/drizzle/0010_wild_molecule_man.sql b/packages/db/drizzle/0010_wild_molecule_man.sql new file mode 100644 index 0000000..35c6e65 --- /dev/null +++ b/packages/db/drizzle/0010_wild_molecule_man.sql @@ -0,0 +1,30 @@ +CREATE TABLE "household_billing_settings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "rent_amount_minor" bigint, + "rent_currency" text DEFAULT 'USD' NOT NULL, + "rent_due_day" integer DEFAULT 20 NOT NULL, + "rent_warning_day" integer DEFAULT 17 NOT NULL, + "utilities_due_day" integer DEFAULT 4 NOT NULL, + "utilities_reminder_day" integer DEFAULT 3 NOT NULL, + "timezone" text DEFAULT 'Asia/Tbilisi' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "household_utility_categories" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL, + "is_active" integer DEFAULT 1 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "household_billing_settings" ADD CONSTRAINT "household_billing_settings_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "household_utility_categories" ADD CONSTRAINT "household_utility_categories_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "household_billing_settings_household_unique" ON "household_billing_settings" USING btree ("household_id");--> statement-breakpoint +CREATE UNIQUE INDEX "household_utility_categories_household_slug_unique" ON "household_utility_categories" USING btree ("household_id","slug");--> statement-breakpoint +CREATE INDEX "household_utility_categories_household_sort_idx" ON "household_utility_categories" USING btree ("household_id","sort_order"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0010_snapshot.json b/packages/db/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..2151dbf --- /dev/null +++ b/packages/db/drizzle/meta/0010_snapshot.json @@ -0,0 +1,2376 @@ +{ + "id": "221ce07f-4404-4c10-8086-0017a4744327", + "prevId": "e7a61264-c80f-4724-ae76-ba7530d53f16", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.anonymous_messages": { + "name": "anonymous_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submitted_by_member_id": { + "name": "submitted_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sanitized_text": { + "name": "sanitized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderation_reason": { + "name": "moderation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "posted_chat_id": { + "name": "posted_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_thread_id": { + "name": "posted_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_message_id": { + "name": "posted_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "anonymous_messages_household_tg_update_unique": { + "name": "anonymous_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_member_created_idx": { + "name": "anonymous_messages_member_created_idx", + "columns": [ + { + "expression": "submitted_by_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_status_created_idx": { + "name": "anonymous_messages_status_created_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anonymous_messages_household_id_households_id_fk": { + "name": "anonymous_messages_household_id_households_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "anonymous_messages_submitted_by_member_id_members_id_fk": { + "name": "anonymous_messages_submitted_by_member_id_members_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "members", + "columnsFrom": ["submitted_by_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_cycles": { + "name": "billing_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_cycles_household_period_unique": { + "name": "billing_cycles_household_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycles_household_period_idx": { + "name": "billing_cycles_household_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycles_household_id_households_id_fk": { + "name": "billing_cycles_household_id_households_id_fk", + "tableFrom": "billing_cycles", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_billing_settings": { + "name": "household_billing_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_amount_minor": { + "name": "rent_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "rent_currency": { + "name": "rent_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "rent_due_day": { + "name": "rent_due_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "rent_warning_day": { + "name": "rent_warning_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 17 + }, + "utilities_due_day": { + "name": "utilities_due_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "utilities_reminder_day": { + "name": "utilities_reminder_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Asia/Tbilisi'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_billing_settings_household_unique": { + "name": "household_billing_settings_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_billing_settings_household_id_households_id_fk": { + "name": "household_billing_settings_household_id_households_id_fk", + "tableFrom": "household_billing_settings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_join_tokens": { + "name": "household_join_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_telegram_user_id": { + "name": "created_by_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_join_tokens_household_unique": { + "name": "household_join_tokens_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_join_tokens_token_unique": { + "name": "household_join_tokens_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_join_tokens_household_id_households_id_fk": { + "name": "household_join_tokens_household_id_households_id_fk", + "tableFrom": "household_join_tokens", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_pending_members": { + "name": "household_pending_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_pending_members_household_user_unique": { + "name": "household_pending_members_household_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_pending_members_telegram_user_idx": { + "name": "household_pending_members_telegram_user_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_pending_members_household_id_households_id_fk": { + "name": "household_pending_members_household_id_households_id_fk", + "tableFrom": "household_pending_members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_telegram_chats": { + "name": "household_telegram_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_type": { + "name": "telegram_chat_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_telegram_chats_household_unique": { + "name": "household_telegram_chats_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_telegram_chats_chat_unique": { + "name": "household_telegram_chats_chat_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_telegram_chats_household_id_households_id_fk": { + "name": "household_telegram_chats_household_id_households_id_fk", + "tableFrom": "household_telegram_chats", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_topic_bindings": { + "name": "household_topic_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_name": { + "name": "topic_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_topic_bindings_household_role_unique": { + "name": "household_topic_bindings_household_role_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_thread_unique": { + "name": "household_topic_bindings_household_thread_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_role_idx": { + "name": "household_topic_bindings_household_role_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_topic_bindings_household_id_households_id_fk": { + "name": "household_topic_bindings_household_id_households_id_fk", + "tableFrom": "household_topic_bindings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_utility_categories": { + "name": "household_utility_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_utility_categories_household_slug_unique": { + "name": "household_utility_categories_household_slug_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_utility_categories_household_sort_idx": { + "name": "household_utility_categories_household_sort_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_utility_categories_household_id_households_id_fk": { + "name": "household_utility_categories_household_id_households_id_fk", + "tableFrom": "household_utility_categories", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_household_idx": { + "name": "members_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_household_tg_user_unique": { + "name": "members_household_tg_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_household_id_households_id_fk": { + "name": "members_household_id_households_id_fk", + "tableFrom": "members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_overrides": { + "name": "presence_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "utility_days": { + "name": "utility_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "presence_overrides_cycle_member_unique": { + "name": "presence_overrides_cycle_member_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presence_overrides_cycle_idx": { + "name": "presence_overrides_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "presence_overrides_cycle_id_billing_cycles_id_fk": { + "name": "presence_overrides_cycle_id_billing_cycles_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "presence_overrides_member_id_members_id_fk": { + "name": "presence_overrides_member_id_members_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_bot_messages": { + "name": "processed_bot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_key": { + "name": "source_message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "processed_bot_messages_source_message_unique": { + "name": "processed_bot_messages_source_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "processed_bot_messages_household_id_households_id_fk": { + "name": "processed_bot_messages_household_id_households_id_fk", + "tableFrom": "processed_bot_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_entries": { + "name": "purchase_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer_member_id": { + "name": "payer_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_entries_household_cycle_idx": { + "name": "purchase_entries_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_payer_idx": { + "name": "purchase_entries_payer_idx", + "columns": [ + { + "expression": "payer_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_household_tg_message_unique": { + "name": "purchase_entries_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_entries_household_id_households_id_fk": { + "name": "purchase_entries_household_id_households_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_entries_cycle_id_billing_cycles_id_fk": { + "name": "purchase_entries_cycle_id_billing_cycles_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "purchase_entries_payer_member_id_members_id_fk": { + "name": "purchase_entries_payer_member_id_members_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "members", + "columnsFrom": ["payer_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_messages": { + "name": "purchase_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parsed_amount_minor": { + "name": "parsed_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "parsed_currency": { + "name": "parsed_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_item_description": { + "name": "parsed_item_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parser_error": { + "name": "parser_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_messages_household_thread_idx": { + "name": "purchase_messages_household_thread_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_sender_idx": { + "name": "purchase_messages_sender_idx", + "columns": [ + { + "expression": "sender_telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_message_unique": { + "name": "purchase_messages_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_update_unique": { + "name": "purchase_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_messages_household_id_households_id_fk": { + "name": "purchase_messages_household_id_households_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_messages_sender_member_id_members_id_fk": { + "name": "purchase_messages_sender_member_id_members_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "members", + "columnsFrom": ["sender_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rent_rules": { + "name": "rent_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_to_period": { + "name": "effective_to_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rent_rules_household_from_period_unique": { + "name": "rent_rules_household_from_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rent_rules_household_from_period_idx": { + "name": "rent_rules_household_from_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rent_rules_household_id_households_id_fk": { + "name": "rent_rules_household_id_households_id_fk", + "tableFrom": "rent_rules", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlement_lines": { + "name": "settlement_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_share_minor": { + "name": "rent_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "utility_share_minor": { + "name": "utility_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "purchase_offset_minor": { + "name": "purchase_offset_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "net_due_minor": { + "name": "net_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "explanations": { + "name": "explanations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlement_lines_settlement_member_unique": { + "name": "settlement_lines_settlement_member_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlement_lines_settlement_idx": { + "name": "settlement_lines_settlement_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlement_lines_settlement_id_settlements_id_fk": { + "name": "settlement_lines_settlement_id_settlements_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "settlements", + "columnsFrom": ["settlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlement_lines_member_id_members_id_fk": { + "name": "settlement_lines_member_id_members_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_due_minor": { + "name": "total_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "settlements_cycle_unique": { + "name": "settlements_cycle_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_household_computed_idx": { + "name": "settlements_household_computed_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "computed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_household_id_households_id_fk": { + "name": "settlements_household_id_households_id_fk", + "tableFrom": "settlements", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlements_cycle_id_billing_cycles_id_fk": { + "name": "settlements_cycle_id_billing_cycles_id_fk", + "tableFrom": "settlements", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_pending_actions": { + "name": "telegram_pending_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_pending_actions_chat_user_unique": { + "name": "telegram_pending_actions_chat_user_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "telegram_pending_actions_user_action_idx": { + "name": "telegram_pending_actions_user_action_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.utility_bills": { + "name": "utility_bills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bill_name": { + "name": "bill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by_member_id": { + "name": "created_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "utility_bills_cycle_idx": { + "name": "utility_bills_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "utility_bills_household_cycle_idx": { + "name": "utility_bills_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "utility_bills_household_id_households_id_fk": { + "name": "utility_bills_household_id_households_id_fk", + "tableFrom": "utility_bills", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_cycle_id_billing_cycles_id_fk": { + "name": "utility_bills_cycle_id_billing_cycles_id_fk", + "tableFrom": "utility_bills", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_created_by_member_id_members_id_fk": { + "name": "utility_bills_created_by_member_id_members_id_fk", + "tableFrom": "utility_bills", + "tableTo": "members", + "columnsFrom": ["created_by_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 95bb917..37d99fd 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1773055200000, "tag": "0009_quiet_wallflower", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1773092080214, + "tag": "0010_wild_molecule_man", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 19d676b..8a53774 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -19,6 +19,56 @@ export const households = pgTable('households', { createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() }) +export const householdBillingSettings = pgTable( + 'household_billing_settings', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + rentAmountMinor: bigint('rent_amount_minor', { mode: 'bigint' }), + rentCurrency: text('rent_currency').default('USD').notNull(), + rentDueDay: integer('rent_due_day').default(20).notNull(), + rentWarningDay: integer('rent_warning_day').default(17).notNull(), + utilitiesDueDay: integer('utilities_due_day').default(4).notNull(), + utilitiesReminderDay: integer('utilities_reminder_day').default(3).notNull(), + timezone: text('timezone').default('Asia/Tbilisi').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + householdUnique: uniqueIndex('household_billing_settings_household_unique').on( + table.householdId + ) + }) +) + +export const householdUtilityCategories = pgTable( + 'household_utility_categories', + { + id: uuid('id').defaultRandom().primaryKey(), + householdId: uuid('household_id') + .notNull() + .references(() => households.id, { onDelete: 'cascade' }), + slug: text('slug').notNull(), + name: text('name').notNull(), + sortOrder: integer('sort_order').default(0).notNull(), + isActive: integer('is_active').default(1).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => ({ + householdSlugUnique: uniqueIndex('household_utility_categories_household_slug_unique').on( + table.householdId, + table.slug + ), + householdSortIdx: index('household_utility_categories_household_sort_idx').on( + table.householdId, + table.sortOrder + ) + }) +) + export const householdTelegramChats = pgTable( 'household_telegram_chats', { @@ -460,8 +510,10 @@ export const settlementLines = pgTable( ) export type Household = typeof households.$inferSelect +export type HouseholdBillingSettings = typeof householdBillingSettings.$inferSelect export type HouseholdTelegramChat = typeof householdTelegramChats.$inferSelect export type HouseholdTopicBinding = typeof householdTopicBindings.$inferSelect +export type HouseholdUtilityCategory = typeof householdUtilityCategories.$inferSelect export type Member = typeof members.$inferSelect export type BillingCycle = typeof billingCycles.$inferSelect export type UtilityBill = typeof utilityBills.$inferSelect diff --git a/packages/ports/src/household-config.ts b/packages/ports/src/household-config.ts index 15eeb4c..7244d20 100644 --- a/packages/ports/src/household-config.ts +++ b/packages/ports/src/household-config.ts @@ -1,4 +1,4 @@ -import type { SupportedLocale } from '@household/domain' +import type { CurrencyCode, SupportedLocale } from '@household/domain' import type { ReminderTarget } from './reminders' export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const @@ -48,6 +48,26 @@ export interface HouseholdMemberRecord { isAdmin: boolean } +export interface HouseholdBillingSettingsRecord { + householdId: string + rentAmountMinor: bigint | null + rentCurrency: CurrencyCode + rentDueDay: number + rentWarningDay: number + utilitiesDueDay: number + utilitiesReminderDay: number + timezone: string +} + +export interface HouseholdUtilityCategoryRecord { + id: string + householdId: string + slug: string + name: string + sortOrder: number + isActive: boolean +} + export interface RegisterTelegramHouseholdChatInput { householdName: string telegramChatId: string @@ -115,6 +135,27 @@ export interface HouseholdConfigurationRepository { telegramUserId: string ): Promise listHouseholdMembers(householdId: string): Promise + getHouseholdBillingSettings(householdId: string): Promise + updateHouseholdBillingSettings(input: { + householdId: string + rentAmountMinor?: bigint | null + rentCurrency?: CurrencyCode + rentDueDay?: number + rentWarningDay?: number + utilitiesDueDay?: number + utilitiesReminderDay?: number + timezone?: string + }): Promise + listHouseholdUtilityCategories( + householdId: string + ): Promise + upsertHouseholdUtilityCategory(input: { + householdId: string + slug?: string + name: string + sortOrder: number + isActive: boolean + }): Promise listHouseholdMembersByTelegramUserId( telegramUserId: string ): Promise @@ -133,4 +174,8 @@ export interface HouseholdConfigurationRepository { telegramUserId: string, locale: SupportedLocale ): Promise + promoteHouseholdAdmin( + householdId: string, + memberId: string + ): Promise } diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 30798a8..e79d3ac 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -9,12 +9,14 @@ export { export { HOUSEHOLD_TOPIC_ROLES, type HouseholdConfigurationRepository, + type HouseholdBillingSettingsRecord, type HouseholdJoinTokenRecord, type HouseholdMemberRecord, type HouseholdPendingMemberRecord, type HouseholdTelegramChatRecord, type HouseholdTopicBindingRecord, type HouseholdTopicRole, + type HouseholdUtilityCategoryRecord, type RegisterTelegramHouseholdChatInput, type RegisterTelegramHouseholdChatResult } from './household-config'