Files
household-bot/packages/application/src/miniapp-admin-service.ts

359 lines
8.5 KiB
TypeScript

import type {
HouseholdBillingSettingsRecord,
HouseholdConfigurationRepository,
HouseholdMemberRecord,
HouseholdPendingMemberRecord,
HouseholdTopicBindingRecord,
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[]
topics: readonly HouseholdTopicBindingRecord[]
}
| {
status: 'rejected'
reason: 'not_admin'
}
>
updateSettings(input: {
householdId: string
actorIsAdmin: boolean
settlementCurrency?: string
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'
members: readonly HouseholdPendingMemberRecord[]
}
| {
status: 'rejected'
reason: 'not_admin'
}
>
approvePendingMember(input: {
householdId: string
actorIsAdmin: boolean
pendingTelegramUserId: string
}): Promise<
| {
status: 'approved'
member: HouseholdMemberRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'pending_not_found'
}
>
promoteMemberToAdmin(input: {
householdId: string
actorIsAdmin: boolean
memberId: string
}): Promise<
| {
status: 'ok'
member: HouseholdMemberRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'member_not_found'
}
>
updateMemberRentShareWeight(input: {
householdId: string
actorIsAdmin: boolean
memberId: string
rentShareWeight: number
}): Promise<
| {
status: 'ok'
member: HouseholdMemberRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'invalid_weight' | '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, topics] = await Promise.all([
repository.getHouseholdBillingSettings(input.householdId),
repository.listHouseholdUtilityCategories(input.householdId),
repository.listHouseholdMembers(input.householdId),
repository.listHouseholdTopicBindings(input.householdId)
])
return {
status: 'ok',
settings,
categories,
members,
topics
}
},
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
const settlementCurrency = input.settlementCurrency
? parseCurrency(input.settlementCurrency)
: 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,
...(settlementCurrency
? {
settlementCurrency
}
: {}),
...(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 {
status: 'rejected',
reason: 'not_admin'
}
}
return {
status: 'ok',
members: await repository.listPendingHouseholdMembers(input.householdId)
}
},
async approvePendingMember(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const member = await repository.approvePendingHouseholdMember({
householdId: input.householdId,
telegramUserId: input.pendingTelegramUserId
})
if (!member) {
return {
status: 'rejected',
reason: 'pending_not_found'
}
}
return {
status: 'approved',
member
}
},
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
}
},
async updateMemberRentShareWeight(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
if (!Number.isInteger(input.rentShareWeight) || input.rentShareWeight <= 0) {
return {
status: 'rejected',
reason: 'invalid_weight'
}
}
const member = await repository.updateHouseholdMemberRentShareWeight(
input.householdId,
input.memberId,
input.rentShareWeight
)
if (!member) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
return {
status: 'ok',
member
}
}
}
}