feat(miniapp): add admin billing settings foundation

This commit is contained in:
2026-03-10 01:38:03 +04:00
parent 4797e4f200
commit 565ac277c1
26 changed files with 5061 additions and 11 deletions

View File

@@ -192,7 +192,37 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
preferredLocale: locale, preferredLocale: locale,
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
isAdmin: false 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
} }
} }

View File

@@ -36,7 +36,11 @@ import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-au
import { createMiniAppDashboardHandler } from './miniapp-dashboard' import { createMiniAppDashboardHandler } from './miniapp-dashboard'
import { import {
createMiniAppApproveMemberHandler, createMiniAppApproveMemberHandler,
createMiniAppPendingMembersHandler createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler,
createMiniAppSettingsHandler,
createMiniAppUpdateSettingsHandler,
createMiniAppUpsertUtilityCategoryHandler
} from './miniapp-admin' } from './miniapp-admin'
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale' import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
@@ -311,6 +315,42 @@ const server = createBotWebhookServer({
logger: getLogger('miniapp-admin') logger: getLogger('miniapp-admin')
}) })
: undefined, : 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 miniAppLocalePreference: householdOnboardingService
? createMiniAppLocalePreferenceHandler({ ? createMiniAppLocalePreferenceHandler({
allowedOrigins: runtime.miniAppAllowedOrigins, allowedOrigins: runtime.miniAppAllowedOrigins,

View File

@@ -8,7 +8,10 @@ import type {
import { import {
createMiniAppApproveMemberHandler, createMiniAppApproveMemberHandler,
createMiniAppPendingMembersHandler createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler,
createMiniAppSettingsHandler,
createMiniAppUpdateSettingsHandler
} from './miniapp-admin' } from './miniapp-admin'
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers' import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
@@ -109,9 +112,58 @@ function onboardingRepository(): HouseholdConfigurationRepository {
householdDefaultLocale: household.defaultLocale, householdDefaultLocale: household.defaultLocale,
isAdmin: false 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 : null
} }
} }
}
describe('createMiniAppPendingMembersHandler', () => { describe('createMiniAppPendingMembersHandler', () => {
test('lists pending members for an authenticated admin', async () => { test('lists pending members for an authenticated admin', async () => {
@@ -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
}
})
})
})

View File

@@ -1,5 +1,7 @@
import type { HouseholdOnboardingService, MiniAppAdminService } from '@household/application' import type { HouseholdOnboardingService, MiniAppAdminService } from '@household/application'
import type { Logger } from '@household/observability' import type { Logger } from '@household/observability'
import type { HouseholdBillingSettingsRecord } from '@household/ports'
import type { MiniAppSessionResult } from './miniapp-auth'
import { import {
allowedMiniAppOrigin, 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<typeof createMiniAppSessionService>,
origin: string | undefined
): Promise<
| Response
| {
member: NonNullable<MiniAppSessionResult['member']>
}
> {
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: { export function createMiniAppPendingMembersHandler(options: {
allowedOrigins: readonly string[] allowedOrigins: readonly string[]
botToken: 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<Response>
} {
const sessionService = createMiniAppSessionService({
botToken: options.botToken,
onboardingService: options.onboardingService
})
return {
handler: async (request) => {
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
if (request.method === 'OPTIONS') {
return miniAppJsonResponse({ ok: true }, 204, origin)
}
if (request.method !== 'POST') {
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
}
try {
const 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<Response>
} {
const sessionService = createMiniAppSessionService({
botToken: options.botToken,
onboardingService: options.onboardingService
})
return {
handler: async (request) => {
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
if (request.method === 'OPTIONS') {
return miniAppJsonResponse({ ok: true }, 204, origin)
}
if (request.method !== 'POST') {
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
}
try {
const payload = await 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<Response>
} {
const sessionService = createMiniAppSessionService({
botToken: options.botToken,
onboardingService: options.onboardingService
})
return {
handler: async (request) => {
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
if (request.method === 'OPTIONS') {
return miniAppJsonResponse({ ok: true }, 204, origin)
}
if (request.method !== 'POST') {
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
}
try {
const payload = await 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<Response>
} {
const sessionService = createMiniAppSessionService({
botToken: options.botToken,
onboardingService: options.onboardingService
})
return {
handler: async (request) => {
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
if (request.method === 'OPTIONS') {
return miniAppJsonResponse({ ok: true }, 204, origin)
}
if (request.method !== 'POST') {
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
}
try {
const payload = await 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: { export function createMiniAppApproveMemberHandler(options: {
allowedOrigins: readonly string[] allowedOrigins: readonly string[]
botToken: string botToken: string

View File

@@ -140,7 +140,37 @@ function onboardingRepository(): HouseholdConfigurationRepository {
preferredLocale: locale preferredLocale: locale
} }
: 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
} }
} }

View File

@@ -135,7 +135,37 @@ function onboardingRepository(): HouseholdConfigurationRepository {
...household, ...household,
defaultLocale: locale 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
} }
} }

View File

@@ -124,7 +124,37 @@ function repository(): HouseholdConfigurationRepository {
} }
members.set(telegramUserId, next) members.set(telegramUserId, next)
return 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
} }
} }

View File

@@ -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: { miniAppApproveMember: {
handler: async () => handler: async () =>
new Response(JSON.stringify({ ok: true, authorized: true, member: {} }), { 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 () => { test('accepts mini app approve member request', async () => {
const response = await server.fetch( const response = await server.fetch(
new Request('http://localhost/api/miniapp/admin/approve-member', { new Request('http://localhost/api/miniapp/admin/approve-member', {

View File

@@ -32,6 +32,30 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response> handler: (request: Request) => Promise<Response>
} }
| undefined | undefined
miniAppSettings?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppUpdateSettings?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppUpsertUtilityCategory?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppPromoteMember?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppLocalePreference?: miniAppLocalePreference?:
| { | {
path?: string path?: string
@@ -75,6 +99,13 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members' options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members'
const miniAppApproveMemberPath = const miniAppApproveMemberPath =
options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member' 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 = const miniAppLocalePreferencePath =
options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale' options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale'
const schedulerPathPrefix = options.scheduler const schedulerPathPrefix = options.scheduler
@@ -109,6 +140,25 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return await options.miniAppApproveMember.handler(request) 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) { if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) {
return await options.miniAppLocalePreference.handler(request) return await options.miniAppLocalePreference.handler(request)
} }

View File

@@ -3,11 +3,16 @@ import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'soli
import { dictionary, type Locale } from './i18n' import { dictionary, type Locale } from './i18n'
import { import {
approveMiniAppPendingMember, approveMiniAppPendingMember,
fetchMiniAppAdminSettings,
fetchMiniAppDashboard, fetchMiniAppDashboard,
fetchMiniAppPendingMembers, fetchMiniAppPendingMembers,
fetchMiniAppSession, fetchMiniAppSession,
joinMiniAppHousehold, joinMiniAppHousehold,
promoteMiniAppMember,
type MiniAppAdminSettingsPayload,
updateMiniAppLocalePreference, updateMiniAppLocalePreference,
updateMiniAppBillingSettings,
upsertMiniAppUtilityCategory,
type MiniAppDashboard, type MiniAppDashboard,
type MiniAppPendingMember type MiniAppPendingMember
} from './miniapp-api' } from './miniapp-api'
@@ -123,10 +128,24 @@ function App() {
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home') const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null) const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([]) const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
const [adminSettings, setAdminSettings] = createSignal<MiniAppAdminSettingsPayload | null>(null)
const [joining, setJoining] = createSignal(false) const [joining, setJoining] = createSignal(false)
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null) const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
const [promotingMemberId, setPromotingMemberId] = createSignal<string | null>(null)
const [savingMemberLocale, setSavingMemberLocale] = createSignal(false) const [savingMemberLocale, setSavingMemberLocale] = createSignal(false)
const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false) const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false)
const [savingBillingSettings, setSavingBillingSettings] = createSignal(false)
const [savingCategorySlug, setSavingCategorySlug] = createSignal<string | null>(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 copy = createMemo(() => dictionary[locale()])
const onboardingSession = createMemo(() => { 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() { async function bootstrap() {
const fallbackLocale = detectLocale() const fallbackLocale = detectLocale()
setLocale(fallbackLocale) setLocale(fallbackLocale)
@@ -223,6 +266,7 @@ function App() {
await loadDashboard(initData) await loadDashboard(initData)
if (payload.member.isAdmin) { if (payload.member.isAdmin) {
await loadPendingMembers(initData) await loadPendingMembers(initData)
await loadAdminSettings(initData)
} }
} catch { } catch {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@@ -315,6 +359,7 @@ function App() {
await loadDashboard(initData) await loadDashboard(initData)
if (payload.member.isAdmin) { if (payload.member.isAdmin) {
await loadPendingMembers(initData) await loadPendingMembers(initData)
await loadAdminSettings(initData)
} }
return 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 = () => { const renderPanel = () => {
switch (activeNav()) { switch (activeNav()) {
case 'balances': case 'balances':
@@ -493,6 +625,120 @@ function App() {
</header> </header>
<p>{copy().householdSettingsBody}</p> <p>{copy().householdSettingsBody}</p>
</article> </article>
<article class="balance-item">
<header>
<strong>{copy().billingSettingsTitle}</strong>
</header>
<div class="settings-grid">
<label class="settings-field">
<span>{copy().rentAmount}</span>
<input
value={billingForm().rentAmountMajor}
onInput={(event) =>
setBillingForm((current) => ({
...current,
rentAmountMajor: event.currentTarget.value
}))
}
/>
</label>
<label class="settings-field">
<span>{copy().shareRent}</span>
<select
value={billingForm().rentCurrency}
onChange={(event) =>
setBillingForm((current) => ({
...current,
rentCurrency: event.currentTarget.value as 'USD' | 'GEL'
}))
}
>
<option value="USD">USD</option>
<option value="GEL">GEL</option>
</select>
</label>
<label class="settings-field">
<span>{copy().rentDueDay}</span>
<input
type="number"
min="1"
max="31"
value={String(billingForm().rentDueDay)}
onInput={(event) =>
setBillingForm((current) => ({
...current,
rentDueDay: Number(event.currentTarget.value)
}))
}
/>
</label>
<label class="settings-field">
<span>{copy().rentWarningDay}</span>
<input
type="number"
min="1"
max="31"
value={String(billingForm().rentWarningDay)}
onInput={(event) =>
setBillingForm((current) => ({
...current,
rentWarningDay: Number(event.currentTarget.value)
}))
}
/>
</label>
<label class="settings-field">
<span>{copy().utilitiesDueDay}</span>
<input
type="number"
min="1"
max="31"
value={String(billingForm().utilitiesDueDay)}
onInput={(event) =>
setBillingForm((current) => ({
...current,
utilitiesDueDay: Number(event.currentTarget.value)
}))
}
/>
</label>
<label class="settings-field">
<span>{copy().utilitiesReminderDay}</span>
<input
type="number"
min="1"
max="31"
value={String(billingForm().utilitiesReminderDay)}
onInput={(event) =>
setBillingForm((current) => ({
...current,
utilitiesReminderDay: Number(event.currentTarget.value)
}))
}
/>
</label>
<label class="settings-field settings-field--wide">
<span>{copy().timezone}</span>
<input
value={billingForm().timezone}
onInput={(event) =>
setBillingForm((current) => ({
...current,
timezone: event.currentTarget.value
}))
}
/>
</label>
</div>
<button
class="ghost-button"
type="button"
disabled={savingBillingSettings()}
onClick={() => void handleSaveBillingSettings()}
>
{savingBillingSettings() ? copy().savingSettings : copy().saveSettingsAction}
</button>
</article>
<article class="balance-item"> <article class="balance-item">
<header> <header>
<strong>{copy().householdLanguage}</strong> <strong>{copy().householdLanguage}</strong>
@@ -521,6 +767,149 @@ function App() {
</button> </button>
</div> </div>
</article> </article>
<article class="balance-item">
<header>
<strong>{copy().utilityCategoriesTitle}</strong>
</header>
<p>{copy().utilityCategoriesBody}</p>
<div class="balance-list">
{adminSettings()?.categories.map((category) => (
<article class="ledger-item">
<header>
<strong>{category.name}</strong>
<span>{category.isActive ? 'ON' : 'OFF'}</span>
</header>
<div class="settings-grid">
<label class="settings-field settings-field--wide">
<span>{copy().utilityCategoryName}</span>
<input
value={category.name}
onInput={(event) =>
setAdminSettings((current) =>
current
? {
...current,
categories: current.categories.map((item) =>
item.slug === category.slug
? {
...item,
name: event.currentTarget.value
}
: item
)
}
: current
)
}
/>
</label>
<label class="settings-field">
<span>{copy().utilityCategoryActive}</span>
<select
value={category.isActive ? 'true' : 'false'}
onChange={(event) =>
setAdminSettings((current) =>
current
? {
...current,
categories: current.categories.map((item) =>
item.slug === category.slug
? {
...item,
isActive: event.currentTarget.value === 'true'
}
: item
)
}
: current
)
}
>
<option value="true">ON</option>
<option value="false">OFF</option>
</select>
</label>
</div>
<button
class="ghost-button"
type="button"
disabled={savingCategorySlug() === category.slug}
onClick={() =>
void handleSaveUtilityCategory({
slug: category.slug,
name:
adminSettings()?.categories.find((item) => item.slug === category.slug)
?.name ?? category.name,
sortOrder: category.sortOrder,
isActive:
adminSettings()?.categories.find((item) => item.slug === category.slug)
?.isActive ?? category.isActive
})
}
>
{savingCategorySlug() === category.slug
? copy().savingCategory
: copy().saveCategoryAction}
</button>
</article>
))}
<article class="ledger-item">
<label class="settings-field settings-field--wide">
<span>{copy().utilityCategoryName}</span>
<input
value={newCategoryName()}
onInput={(event) => setNewCategoryName(event.currentTarget.value)}
/>
</label>
<button
class="ghost-button"
type="button"
disabled={
newCategoryName().trim().length === 0 || savingCategorySlug() === '__new__'
}
onClick={() =>
void handleSaveUtilityCategory({
name: newCategoryName(),
sortOrder: adminSettings()?.categories.length ?? 0,
isActive: true
})
}
>
{savingCategorySlug() === '__new__'
? copy().savingCategory
: copy().addCategoryAction}
</button>
</article>
</div>
</article>
<article class="balance-item">
<header>
<strong>{copy().adminsTitle}</strong>
</header>
<p>{copy().adminsBody}</p>
<div class="balance-list">
{adminSettings()?.members.map((member) => (
<article class="ledger-item">
<header>
<strong>{member.displayName}</strong>
<span>{member.isAdmin ? copy().adminTag : copy().residentTag}</span>
</header>
{!member.isAdmin ? (
<button
class="ghost-button"
type="button"
disabled={promotingMemberId() === member.id}
onClick={() => void handlePromoteMember(member.id)}
>
{promotingMemberId() === member.id
? copy().promotingAdmin
: copy().promoteAdminAction}
</button>
) : null}
</article>
))}
</div>
</article>
<article class="balance-item"> <article class="balance-item">
<header> <header>
<strong>{copy().pendingMembersTitle}</strong> <strong>{copy().pendingMembersTitle}</strong>

View File

@@ -54,6 +54,26 @@ export const dictionary = {
latestActivityEmpty: 'Recent utility and purchase entries will appear here.', latestActivityEmpty: 'Recent utility and purchase entries will appear here.',
householdSettingsTitle: 'Household settings', householdSettingsTitle: 'Household settings',
householdSettingsBody: 'Control household defaults and approve roommates who requested access.', 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', residentHouseTitle: 'Household access',
residentHouseBody: residentHouseBody:
'Your admins manage household settings and approvals here. You can still switch your own language above.', 'Your admins manage household settings and approvals here. You can still switch your own language above.',
@@ -120,6 +140,28 @@ export const dictionary = {
latestActivityEmpty: 'Здесь появятся последние коммунальные платежи и покупки.', latestActivityEmpty: 'Здесь появятся последние коммунальные платежи и покупки.',
householdSettingsTitle: 'Настройки household', householdSettingsTitle: 'Настройки household',
householdSettingsBody: 'Здесь можно менять язык 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', residentHouseTitle: 'Доступ к household',
residentHouseBody: residentHouseBody:
'Настройки household и подтверждение заявок управляются админами. Свой язык можно менять переключателем выше.', 'Настройки household и подтверждение заявок управляются админами. Свой язык можно менять переключателем выше.',

View File

@@ -283,6 +283,37 @@ button {
font-size: clamp(1.2rem, 4vw, 1.7rem); 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 { .panel--wide {
min-height: 170px; min-height: 170px;
} }
@@ -302,6 +333,10 @@ button {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
.settings-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.panel--wide { .panel--wide {
grid-column: 1 / -1; grid-column: 1 / -1;
} }

View File

@@ -34,6 +34,32 @@ export interface MiniAppPendingMember {
languageCode: string | null 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 { export interface MiniAppDashboard {
period: string period: string
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
@@ -57,6 +83,12 @@ export interface MiniAppDashboard {
}[] }[]
} }
export interface MiniAppAdminSettingsPayload {
settings: MiniAppBillingSettings
categories: readonly MiniAppUtilityCategory[]
members: readonly MiniAppMember[]
}
function apiBaseUrl(): string { function apiBaseUrl(): string {
const runtimeConfigured = runtimeBotApiUrl() const runtimeConfigured = runtimeBotApiUrl()
if (runtimeConfigured) { if (runtimeConfigured) {
@@ -260,3 +292,142 @@ export async function updateMiniAppLocalePreference(
return payload.locale return payload.locale
} }
export async function fetchMiniAppAdminSettings(
initData: string
): Promise<MiniAppAdminSettingsPayload> {
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<MiniAppBillingSettings> {
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<MiniAppUtilityCategory> {
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<MiniAppMember> {
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
}

View File

@@ -1,9 +1,15 @@
import { and, asc, eq } from 'drizzle-orm' import { and, asc, eq } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db' import { createDbClient, schema } from '@household/db'
import { instantToDate, normalizeSupportedLocale, nowInstant } from '@household/domain' import {
instantToDate,
normalizeSupportedLocale,
nowInstant,
type CurrencyCode
} from '@household/domain'
import { import {
HOUSEHOLD_TOPIC_ROLES, HOUSEHOLD_TOPIC_ROLES,
type HouseholdBillingSettingsRecord,
type HouseholdConfigurationRepository, type HouseholdConfigurationRepository,
type HouseholdJoinTokenRecord, type HouseholdJoinTokenRecord,
type HouseholdMemberRecord, type HouseholdMemberRecord,
@@ -11,6 +17,7 @@ import {
type HouseholdTelegramChatRecord, type HouseholdTelegramChatRecord,
type HouseholdTopicBindingRecord, type HouseholdTopicBindingRecord,
type HouseholdTopicRole, type HouseholdTopicRole,
type HouseholdUtilityCategoryRecord,
type ReminderTarget, type ReminderTarget,
type RegisterTelegramHouseholdChatResult type RegisterTelegramHouseholdChatResult
} from '@household/ports' } 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): { export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
repository: HouseholdConfigurationRepository repository: HouseholdConfigurationRepository
close: () => Promise<void> close: () => Promise<void>
@@ -156,6 +222,43 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
prepare: false 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<void> {
await db
.insert(schema.householdBillingSettings)
.values({
householdId
})
.onConflictDoNothing({
target: [schema.householdBillingSettings.householdId]
})
}
async function ensureUtilityCategories(householdId: string): Promise<void> {
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 = { const repository: HouseholdConfigurationRepository = {
async registerTelegramHouseholdChat(input) { async registerTelegramHouseholdChat(input) {
return await db.transaction(async (tx): Promise<RegisterTelegramHouseholdChatResult> => { return await db.transaction(async (tx): Promise<RegisterTelegramHouseholdChatResult> => {
@@ -713,6 +816,161 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
return rows.map(toHouseholdMemberRecord) 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) { async listHouseholdMembersByTelegramUserId(telegramUserId) {
const rows = await db const rows = await db
.select({ .select({
@@ -896,6 +1154,38 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
throw new Error('Failed to resolve household chat after member locale update') 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({ return toHouseholdMemberRecord({
...row, ...row,
defaultLocale: household.defaultLocale defaultLocale: household.defaultLocale

View File

@@ -137,7 +137,37 @@ function createRepositoryStub() {
preferredLocale: locale preferredLocale: locale
} }
: 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
} }
return { return {

View File

@@ -150,6 +150,46 @@ function createRepositoryStub() {
preferredLocale: locale preferredLocale: locale
} }
: null : 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
} }
} }

View File

@@ -244,6 +244,58 @@ function createRepositoryStub() {
preferredLocale: locale preferredLocale: locale
} }
: null : 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
} }
} }

View File

@@ -74,7 +74,37 @@ function createRepository(): HouseholdConfigurationRepository {
preferredLocale: locale, preferredLocale: locale,
householdDefaultLocale: 'ru' 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
} }
} }

View File

@@ -102,11 +102,131 @@ function repository(): HouseholdConfigurationRepository {
householdDefaultLocale: 'ru', householdDefaultLocale: 'ru',
isAdmin: false 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 : null
} }
} }
describe('createMiniAppAdminService', () => { 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 () => { test('lists pending members for admins', async () => {
const service = createMiniAppAdminService(repository()) 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
}
})
})
}) })

View File

@@ -1,10 +1,75 @@
import type { import type {
HouseholdBillingSettingsRecord,
HouseholdConfigurationRepository, HouseholdConfigurationRepository,
HouseholdMemberRecord, HouseholdMemberRecord,
HouseholdPendingMemberRecord HouseholdPendingMemberRecord,
HouseholdUtilityCategoryRecord
} from '@household/ports' } 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 { 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< listPendingMembers(input: { householdId: string; actorIsAdmin: boolean }): Promise<
| { | {
status: 'ok' status: 'ok'
@@ -29,12 +94,144 @@ export interface MiniAppAdminService {
reason: 'not_admin' | 'pending_not_found' 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( export function createMiniAppAdminService(
repository: HouseholdConfigurationRepository repository: HouseholdConfigurationRepository
): MiniAppAdminService { ): MiniAppAdminService {
return { 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) { async listPendingMembers(input) {
if (!input.actorIsAdmin) { if (!input.actorIsAdmin) {
return { return {
@@ -73,6 +270,28 @@ export function createMiniAppAdminService(
status: 'approved', status: 'approved',
member 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
}
} }
} }
} }

View File

@@ -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");

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1773055200000, "when": 1773055200000,
"tag": "0009_quiet_wallflower", "tag": "0009_quiet_wallflower",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1773092080214,
"tag": "0010_wild_molecule_man",
"breakpoints": true
} }
] ]
} }

View File

@@ -19,6 +19,56 @@ export const households = pgTable('households', {
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() 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( export const householdTelegramChats = pgTable(
'household_telegram_chats', 'household_telegram_chats',
{ {
@@ -460,8 +510,10 @@ export const settlementLines = pgTable(
) )
export type Household = typeof households.$inferSelect export type Household = typeof households.$inferSelect
export type HouseholdBillingSettings = typeof householdBillingSettings.$inferSelect
export type HouseholdTelegramChat = typeof householdTelegramChats.$inferSelect export type HouseholdTelegramChat = typeof householdTelegramChats.$inferSelect
export type HouseholdTopicBinding = typeof householdTopicBindings.$inferSelect export type HouseholdTopicBinding = typeof householdTopicBindings.$inferSelect
export type HouseholdUtilityCategory = typeof householdUtilityCategories.$inferSelect
export type Member = typeof members.$inferSelect export type Member = typeof members.$inferSelect
export type BillingCycle = typeof billingCycles.$inferSelect export type BillingCycle = typeof billingCycles.$inferSelect
export type UtilityBill = typeof utilityBills.$inferSelect export type UtilityBill = typeof utilityBills.$inferSelect

View File

@@ -1,4 +1,4 @@
import type { SupportedLocale } from '@household/domain' import type { CurrencyCode, SupportedLocale } from '@household/domain'
import type { ReminderTarget } from './reminders' import type { ReminderTarget } from './reminders'
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const
@@ -48,6 +48,26 @@ export interface HouseholdMemberRecord {
isAdmin: boolean 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 { export interface RegisterTelegramHouseholdChatInput {
householdName: string householdName: string
telegramChatId: string telegramChatId: string
@@ -115,6 +135,27 @@ export interface HouseholdConfigurationRepository {
telegramUserId: string telegramUserId: string
): Promise<HouseholdMemberRecord | null> ): Promise<HouseholdMemberRecord | null>
listHouseholdMembers(householdId: string): Promise<readonly HouseholdMemberRecord[]> listHouseholdMembers(householdId: string): Promise<readonly HouseholdMemberRecord[]>
getHouseholdBillingSettings(householdId: string): Promise<HouseholdBillingSettingsRecord>
updateHouseholdBillingSettings(input: {
householdId: string
rentAmountMinor?: bigint | null
rentCurrency?: CurrencyCode
rentDueDay?: number
rentWarningDay?: number
utilitiesDueDay?: number
utilitiesReminderDay?: number
timezone?: string
}): Promise<HouseholdBillingSettingsRecord>
listHouseholdUtilityCategories(
householdId: string
): Promise<readonly HouseholdUtilityCategoryRecord[]>
upsertHouseholdUtilityCategory(input: {
householdId: string
slug?: string
name: string
sortOrder: number
isActive: boolean
}): Promise<HouseholdUtilityCategoryRecord>
listHouseholdMembersByTelegramUserId( listHouseholdMembersByTelegramUserId(
telegramUserId: string telegramUserId: string
): Promise<readonly HouseholdMemberRecord[]> ): Promise<readonly HouseholdMemberRecord[]>
@@ -133,4 +174,8 @@ export interface HouseholdConfigurationRepository {
telegramUserId: string, telegramUserId: string,
locale: SupportedLocale locale: SupportedLocale
): Promise<HouseholdMemberRecord | null> ): Promise<HouseholdMemberRecord | null>
promoteHouseholdAdmin(
householdId: string,
memberId: string
): Promise<HouseholdMemberRecord | null>
} }

View File

@@ -9,12 +9,14 @@ export {
export { export {
HOUSEHOLD_TOPIC_ROLES, HOUSEHOLD_TOPIC_ROLES,
type HouseholdConfigurationRepository, type HouseholdConfigurationRepository,
type HouseholdBillingSettingsRecord,
type HouseholdJoinTokenRecord, type HouseholdJoinTokenRecord,
type HouseholdMemberRecord, type HouseholdMemberRecord,
type HouseholdPendingMemberRecord, type HouseholdPendingMemberRecord,
type HouseholdTelegramChatRecord, type HouseholdTelegramChatRecord,
type HouseholdTopicBindingRecord, type HouseholdTopicBindingRecord,
type HouseholdTopicRole, type HouseholdTopicRole,
type HouseholdUtilityCategoryRecord,
type RegisterTelegramHouseholdChatInput, type RegisterTelegramHouseholdChatInput,
type RegisterTelegramHouseholdChatResult type RegisterTelegramHouseholdChatResult
} from './household-config' } from './household-config'