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

871 lines
22 KiB
TypeScript

import type {
HouseholdAssistantConfigRecord,
HouseholdBillingSettingsRecord,
HouseholdConfigurationRepository,
HouseholdMemberAbsencePolicy,
HouseholdMemberAbsencePolicyRecord,
HouseholdMemberLifecycleStatus,
HouseholdMemberRecord,
HouseholdPendingMemberRecord,
HouseholdRentPaymentDestination,
HouseholdTopicBindingRecord,
HouseholdUtilityCategoryRecord
} from '@household/ports'
import { Money, Temporal, 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
}
function normalizeOptionalString(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function normalizeRentPaymentDestinations(
value: unknown
): readonly HouseholdRentPaymentDestination[] | null {
if (value === null) return null
if (!Array.isArray(value)) {
throw new Error('Invalid rent payment destinations')
}
return value
.map((entry): HouseholdRentPaymentDestination | null => {
if (!entry || typeof entry !== 'object') return null
const record = entry as Record<string, unknown>
const label = normalizeOptionalString(record.label)
const account = normalizeOptionalString(record.account)
if (!label || !account) return null
return {
label,
recipientName: normalizeOptionalString(record.recipientName),
bankName: normalizeOptionalString(record.bankName),
account,
note: normalizeOptionalString(record.note),
link: normalizeOptionalString(record.link)
}
})
.filter((entry): entry is HouseholdRentPaymentDestination => Boolean(entry))
}
export interface MiniAppAdminService {
getSettings(input: { householdId: string; actorIsAdmin: boolean }): Promise<
| {
status: 'ok'
householdName: string
settings: HouseholdBillingSettingsRecord
assistantConfig: HouseholdAssistantConfigRecord
categories: readonly HouseholdUtilityCategoryRecord[]
members: readonly HouseholdMemberRecord[]
memberAbsencePolicies: readonly HouseholdMemberAbsencePolicyRecord[]
topics: readonly HouseholdTopicBindingRecord[]
}
| {
status: 'rejected'
reason: 'not_admin'
}
>
updateSettings(input: {
householdId: string
actorIsAdmin: boolean
householdName?: string
settlementCurrency?: string
paymentBalanceAdjustmentPolicy?: string
rentAmountMajor?: string
rentCurrency?: string
rentDueDay: number
rentWarningDay: number
utilitiesDueDay: number
utilitiesReminderDay: number
timezone: string
rentPaymentDestinations?: unknown
assistantContext?: string
assistantTone?: string
}): Promise<
| {
status: 'ok'
householdName: string
settings: HouseholdBillingSettingsRecord
assistantConfig: HouseholdAssistantConfigRecord
}
| {
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'
}
>
rejectPendingMember(input: {
householdId: string
actorIsAdmin: boolean
pendingTelegramUserId: string
}): Promise<
| {
status: 'rejected_member'
}
| {
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'
}
>
demoteMemberFromAdmin(input: {
householdId: string
actorIsAdmin: boolean
memberId: string
}): Promise<
| {
status: 'ok'
member: HouseholdMemberRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'member_not_found' | 'last_admin'
}
>
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'
}
>
updateMemberStatus(input: {
householdId: string
actorIsAdmin: boolean
memberId: string
status: HouseholdMemberLifecycleStatus
}): Promise<
| {
status: 'ok'
member: HouseholdMemberRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'member_not_found'
}
>
updateOwnDisplayName(input: {
householdId: string
actorMemberId: string
displayName: string
}): Promise<
| {
status: 'ok'
member: HouseholdMemberRecord
}
| {
status: 'rejected'
reason: 'invalid_display_name' | 'member_not_found'
}
>
updateMemberDisplayName(input: {
householdId: string
actorIsAdmin: boolean
memberId: string
displayName: string
}): Promise<
| {
status: 'ok'
member: HouseholdMemberRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'invalid_display_name' | 'member_not_found'
}
>
updateMemberAbsencePolicy(input: {
householdId: string
actorIsAdmin: boolean
memberId: string
policy: HouseholdMemberAbsencePolicy
}): Promise<
| {
status: 'ok'
policy: HouseholdMemberAbsencePolicyRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'member_not_found'
}
>
}
function localDateInTimezone(timezone: string) {
return Temporal.Now.instant().toZonedDateTimeISO(timezone).toPlainDate()
}
function periodFromLocalDate(localDate: Temporal.PlainDate): string {
return `${localDate.year}-${String(localDate.month).padStart(2, '0')}`
}
function normalizeDisplayName(raw: string): string | null {
const trimmed = raw.trim()
if (trimmed.length < 2 || trimmed.length > 80) {
return null
}
return trimmed.replace(/\s+/g, ' ')
}
function normalizeHouseholdName(raw: string | undefined): string | null | undefined {
if (raw === undefined) {
return undefined
}
const trimmed = raw.trim()
if (trimmed.length < 2 || trimmed.length > 120) {
return null
}
return trimmed.replace(/\s+/g, ' ')
}
function normalizeTimezone(raw: string): string | null {
const trimmed = raw.trim()
if (trimmed.length === 0) {
return null
}
try {
return new Intl.DateTimeFormat('en-US', {
timeZone: trimmed
}).resolvedOptions().timeZone
} catch {
return null
}
}
function defaultAssistantConfig(householdId: string): HouseholdAssistantConfigRecord {
return {
householdId,
assistantContext: null,
assistantTone: null
}
}
function normalizeAssistantText(
raw: string | undefined,
maxLength: number
): string | null | undefined {
if (raw === undefined) {
return undefined
}
const trimmed = raw.trim()
if (trimmed.length === 0) {
return null
}
if (trimmed.length > maxLength) {
return null
}
return trimmed
}
export function createMiniAppAdminService(
repository: HouseholdConfigurationRepository
): MiniAppAdminService {
return {
async getSettings(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const household = await repository.getHouseholdChatByHouseholdId(input.householdId)
if (!household) {
throw new Error('Failed to resolve household chat for mini app settings')
}
const [settings, assistantConfig, categories, members, memberAbsencePolicies, topics] =
await Promise.all([
repository.getHouseholdBillingSettings(input.householdId),
repository.getHouseholdAssistantConfig
? repository.getHouseholdAssistantConfig(input.householdId)
: Promise.resolve(defaultAssistantConfig(input.householdId)),
repository.listHouseholdUtilityCategories(input.householdId),
repository.listHouseholdMembers(input.householdId),
repository.listHouseholdMemberAbsencePolicies(input.householdId),
repository.listHouseholdTopicBindings(input.householdId)
])
return {
status: 'ok',
householdName: household.householdName,
settings,
assistantConfig,
categories,
members,
memberAbsencePolicies,
topics
}
},
async updateSettings(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const timezone = normalizeTimezone(input.timezone)
if (
!isValidDay(input.rentDueDay) ||
!isValidDay(input.rentWarningDay) ||
!isValidDay(input.utilitiesDueDay) ||
!isValidDay(input.utilitiesReminderDay) ||
timezone === null ||
input.rentWarningDay > input.rentDueDay ||
input.utilitiesReminderDay > input.utilitiesDueDay
) {
return {
status: 'rejected',
reason: 'invalid_settings'
}
}
const assistantContext = normalizeAssistantText(input.assistantContext, 1200)
const assistantTone = normalizeAssistantText(input.assistantTone, 160)
const householdName = normalizeHouseholdName(input.householdName)
const nextHouseholdName = householdName ?? undefined
if (
(input.householdName !== undefined && householdName === null) ||
(input.assistantContext !== undefined &&
assistantContext === null &&
input.assistantContext.trim().length > 0) ||
(input.assistantTone !== undefined &&
assistantTone === null &&
input.assistantTone.trim().length > 0)
) {
return {
status: 'rejected',
reason: 'invalid_settings'
}
}
let rentAmountMinor: bigint | null | undefined
let rentCurrency: CurrencyCode | undefined
const settlementCurrency = input.settlementCurrency
? parseCurrency(input.settlementCurrency)
: undefined
const paymentBalanceAdjustmentPolicy = input.paymentBalanceAdjustmentPolicy
? input.paymentBalanceAdjustmentPolicy === 'utilities' ||
input.paymentBalanceAdjustmentPolicy === 'rent' ||
input.paymentBalanceAdjustmentPolicy === 'separate'
? input.paymentBalanceAdjustmentPolicy
: null
: undefined
if (paymentBalanceAdjustmentPolicy === null) {
return {
status: 'rejected',
reason: 'invalid_settings'
}
}
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')
}
let rentPaymentDestinations: readonly HouseholdRentPaymentDestination[] | null | undefined
if (input.rentPaymentDestinations !== undefined) {
try {
rentPaymentDestinations = normalizeRentPaymentDestinations(input.rentPaymentDestinations)
} catch {
return {
status: 'rejected',
reason: 'invalid_settings'
}
}
}
const shouldUpdateAssistantConfig =
assistantContext !== undefined || assistantTone !== undefined
const [settings, nextAssistantConfig, household] = await Promise.all([
repository.updateHouseholdBillingSettings({
householdId: input.householdId,
...(settlementCurrency
? {
settlementCurrency
}
: {}),
...(paymentBalanceAdjustmentPolicy
? {
paymentBalanceAdjustmentPolicy
}
: {}),
...(rentAmountMinor !== undefined
? {
rentAmountMinor
}
: {}),
...(rentCurrency
? {
rentCurrency
}
: {}),
rentDueDay: input.rentDueDay,
rentWarningDay: input.rentWarningDay,
utilitiesDueDay: input.utilitiesDueDay,
utilitiesReminderDay: input.utilitiesReminderDay,
timezone,
...(rentPaymentDestinations !== undefined
? {
rentPaymentDestinations
}
: {})
}),
repository.updateHouseholdAssistantConfig && shouldUpdateAssistantConfig
? repository.updateHouseholdAssistantConfig({
householdId: input.householdId,
...(assistantContext !== undefined
? {
assistantContext
}
: {}),
...(assistantTone !== undefined
? {
assistantTone
}
: {})
})
: repository.getHouseholdAssistantConfig
? repository.getHouseholdAssistantConfig(input.householdId)
: Promise.resolve({
householdId: input.householdId,
assistantContext: assistantContext ?? null,
assistantTone: assistantTone ?? null
}),
nextHouseholdName !== undefined && repository.updateHouseholdName
? repository.updateHouseholdName(input.householdId, nextHouseholdName)
: repository.getHouseholdChatByHouseholdId(input.householdId)
])
if (!household) {
throw new Error('Failed to resolve household chat after settings update')
}
return {
status: 'ok',
householdName: household.householdName,
settings,
assistantConfig: nextAssistantConfig
}
},
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 rejectPendingMember(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const success = await repository.rejectPendingHouseholdMember({
householdId: input.householdId,
telegramUserId: input.pendingTelegramUserId
})
if (!success) {
return {
status: 'rejected',
reason: 'pending_not_found'
}
}
return {
status: 'rejected_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 demoteMemberFromAdmin(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const members = await repository.listHouseholdMembers(input.householdId)
const targetMember = members.find((member) => member.id === input.memberId)
if (!targetMember) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
const adminCount = members.filter((member) => member.isAdmin).length
if (targetMember.isAdmin && adminCount <= 1) {
return {
status: 'rejected',
reason: 'last_admin'
}
}
const member = targetMember.isAdmin
? await repository.demoteHouseholdAdmin(input.householdId, input.memberId)
: targetMember
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
}
},
async updateMemberStatus(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const member = await repository.updateHouseholdMemberStatus(
input.householdId,
input.memberId,
input.status
)
if (!member) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
return {
status: 'ok',
member
}
},
async updateOwnDisplayName(input) {
const displayName = normalizeDisplayName(input.displayName)
if (!displayName) {
return {
status: 'rejected',
reason: 'invalid_display_name'
}
}
const member = await repository.updateHouseholdMemberDisplayName(
input.householdId,
input.actorMemberId,
displayName
)
if (!member) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
return {
status: 'ok',
member
}
},
async updateMemberDisplayName(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const displayName = normalizeDisplayName(input.displayName)
if (!displayName) {
return {
status: 'rejected',
reason: 'invalid_display_name'
}
}
const member = await repository.updateHouseholdMemberDisplayName(
input.householdId,
input.memberId,
displayName
)
if (!member) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
return {
status: 'ok',
member
}
},
async updateMemberAbsencePolicy(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const [member, settings] = await Promise.all([
repository.listHouseholdMembers(input.householdId),
repository.getHouseholdBillingSettings(input.householdId)
])
const target = member.find((candidate) => candidate.id === input.memberId)
if (!target) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
const effectiveFromPeriod = periodFromLocalDate(localDateInTimezone(settings.timezone))
const policy = await repository.upsertHouseholdMemberAbsencePolicy({
householdId: input.householdId,
memberId: input.memberId,
effectiveFromPeriod,
policy: input.policy
})
if (!policy) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
return {
status: 'ok',
policy
}
}
}
}