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

@@ -1,9 +1,15 @@
import { and, asc, eq } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import { instantToDate, normalizeSupportedLocale, nowInstant } from '@household/domain'
import {
instantToDate,
normalizeSupportedLocale,
nowInstant,
type CurrencyCode
} from '@household/domain'
import {
HOUSEHOLD_TOPIC_ROLES,
type HouseholdBillingSettingsRecord,
type HouseholdConfigurationRepository,
type HouseholdJoinTokenRecord,
type HouseholdMemberRecord,
@@ -11,6 +17,7 @@ import {
type HouseholdTelegramChatRecord,
type HouseholdTopicBindingRecord,
type HouseholdTopicRole,
type HouseholdUtilityCategoryRecord,
type ReminderTarget,
type RegisterTelegramHouseholdChatResult
} from '@household/ports'
@@ -147,6 +154,65 @@ function toReminderTarget(row: {
}
}
function toCurrencyCode(raw: string): CurrencyCode {
const normalized = raw.trim().toUpperCase()
if (normalized !== 'USD' && normalized !== 'GEL') {
throw new Error(`Unsupported household billing currency: ${raw}`)
}
return normalized
}
function toHouseholdBillingSettingsRecord(row: {
householdId: string
rentAmountMinor: bigint | null
rentCurrency: string
rentDueDay: number
rentWarningDay: number
utilitiesDueDay: number
utilitiesReminderDay: number
timezone: string
}): HouseholdBillingSettingsRecord {
return {
householdId: row.householdId,
rentAmountMinor: row.rentAmountMinor,
rentCurrency: toCurrencyCode(row.rentCurrency),
rentDueDay: row.rentDueDay,
rentWarningDay: row.rentWarningDay,
utilitiesDueDay: row.utilitiesDueDay,
utilitiesReminderDay: row.utilitiesReminderDay,
timezone: row.timezone
}
}
function toHouseholdUtilityCategoryRecord(row: {
id: string
householdId: string
slug: string
name: string
sortOrder: number
isActive: number
}): HouseholdUtilityCategoryRecord {
return {
id: row.id,
householdId: row.householdId,
slug: row.slug,
name: row.name,
sortOrder: row.sortOrder,
isActive: row.isActive === 1
}
}
function utilityCategorySlug(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 48)
}
export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
repository: HouseholdConfigurationRepository
close: () => Promise<void>
@@ -156,6 +222,43 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
prepare: false
})
const defaultUtilityCategories = [
{ slug: 'internet', name: 'Internet', sortOrder: 0 },
{ slug: 'gas_water', name: 'Gas (Water)', sortOrder: 1 },
{ slug: 'cleaning', name: 'Cleaning', sortOrder: 2 },
{ slug: 'electricity', name: 'Electricity', sortOrder: 3 }
] as const
async function ensureBillingSettings(householdId: string): Promise<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 = {
async registerTelegramHouseholdChat(input) {
return await db.transaction(async (tx): Promise<RegisterTelegramHouseholdChatResult> => {
@@ -713,6 +816,161 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
return rows.map(toHouseholdMemberRecord)
},
async getHouseholdBillingSettings(householdId) {
await ensureBillingSettings(householdId)
const rows = await db
.select({
householdId: schema.householdBillingSettings.householdId,
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
rentCurrency: schema.householdBillingSettings.rentCurrency,
rentDueDay: schema.householdBillingSettings.rentDueDay,
rentWarningDay: schema.householdBillingSettings.rentWarningDay,
utilitiesDueDay: schema.householdBillingSettings.utilitiesDueDay,
utilitiesReminderDay: schema.householdBillingSettings.utilitiesReminderDay,
timezone: schema.householdBillingSettings.timezone
})
.from(schema.householdBillingSettings)
.where(eq(schema.householdBillingSettings.householdId, householdId))
.limit(1)
const row = rows[0]
if (!row) {
throw new Error('Failed to load household billing settings')
}
return toHouseholdBillingSettingsRecord(row)
},
async updateHouseholdBillingSettings(input) {
await ensureBillingSettings(input.householdId)
const rows = await db
.update(schema.householdBillingSettings)
.set({
...(input.rentAmountMinor !== undefined
? {
rentAmountMinor: input.rentAmountMinor
}
: {}),
...(input.rentCurrency
? {
rentCurrency: input.rentCurrency
}
: {}),
...(input.rentDueDay !== undefined
? {
rentDueDay: input.rentDueDay
}
: {}),
...(input.rentWarningDay !== undefined
? {
rentWarningDay: input.rentWarningDay
}
: {}),
...(input.utilitiesDueDay !== undefined
? {
utilitiesDueDay: input.utilitiesDueDay
}
: {}),
...(input.utilitiesReminderDay !== undefined
? {
utilitiesReminderDay: input.utilitiesReminderDay
}
: {}),
...(input.timezone
? {
timezone: input.timezone
}
: {}),
updatedAt: instantToDate(nowInstant())
})
.where(eq(schema.householdBillingSettings.householdId, input.householdId))
.returning({
householdId: schema.householdBillingSettings.householdId,
rentAmountMinor: schema.householdBillingSettings.rentAmountMinor,
rentCurrency: schema.householdBillingSettings.rentCurrency,
rentDueDay: schema.householdBillingSettings.rentDueDay,
rentWarningDay: schema.householdBillingSettings.rentWarningDay,
utilitiesDueDay: schema.householdBillingSettings.utilitiesDueDay,
utilitiesReminderDay: schema.householdBillingSettings.utilitiesReminderDay,
timezone: schema.householdBillingSettings.timezone
})
const row = rows[0]
if (!row) {
throw new Error('Failed to update household billing settings')
}
return toHouseholdBillingSettingsRecord(row)
},
async listHouseholdUtilityCategories(householdId) {
await ensureUtilityCategories(householdId)
const rows = await db
.select({
id: schema.householdUtilityCategories.id,
householdId: schema.householdUtilityCategories.householdId,
slug: schema.householdUtilityCategories.slug,
name: schema.householdUtilityCategories.name,
sortOrder: schema.householdUtilityCategories.sortOrder,
isActive: schema.householdUtilityCategories.isActive
})
.from(schema.householdUtilityCategories)
.where(eq(schema.householdUtilityCategories.householdId, householdId))
.orderBy(
asc(schema.householdUtilityCategories.sortOrder),
asc(schema.householdUtilityCategories.name)
)
return rows.map(toHouseholdUtilityCategoryRecord)
},
async upsertHouseholdUtilityCategory(input) {
const slug = utilityCategorySlug(input.slug ?? input.name)
if (!slug) {
throw new Error('Utility category slug cannot be empty')
}
const rows = await db
.insert(schema.householdUtilityCategories)
.values({
householdId: input.householdId,
slug,
name: input.name.trim(),
sortOrder: input.sortOrder,
isActive: input.isActive ? 1 : 0
})
.onConflictDoUpdate({
target: [
schema.householdUtilityCategories.householdId,
schema.householdUtilityCategories.slug
],
set: {
name: input.name.trim(),
sortOrder: input.sortOrder,
isActive: input.isActive ? 1 : 0,
updatedAt: instantToDate(nowInstant())
}
})
.returning({
id: schema.householdUtilityCategories.id,
householdId: schema.householdUtilityCategories.householdId,
slug: schema.householdUtilityCategories.slug,
name: schema.householdUtilityCategories.name,
sortOrder: schema.householdUtilityCategories.sortOrder,
isActive: schema.householdUtilityCategories.isActive
})
const row = rows[0]
if (!row) {
throw new Error('Failed to upsert household utility category')
}
return toHouseholdUtilityCategoryRecord(row)
},
async listHouseholdMembersByTelegramUserId(telegramUserId) {
const rows = await db
.select({
@@ -896,6 +1154,38 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
throw new Error('Failed to resolve household chat after member locale update')
}
return toHouseholdMemberRecord({
...row,
defaultLocale: household.defaultLocale
})
},
async promoteHouseholdAdmin(householdId, memberId) {
const rows = await db
.update(schema.members)
.set({
isAdmin: 1
})
.where(and(eq(schema.members.householdId, householdId), eq(schema.members.id, memberId)))
.returning({
id: schema.members.id,
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
preferredLocale: schema.members.preferredLocale,
isAdmin: schema.members.isAdmin
})
const row = rows[0]
if (!row) {
return null
}
const household = await this.getHouseholdChatByHouseholdId(householdId)
if (!household) {
throw new Error('Failed to resolve household chat after admin promotion')
}
return toHouseholdMemberRecord({
...row,
defaultLocale: household.defaultLocale

View File

@@ -137,7 +137,37 @@ function createRepositoryStub() {
preferredLocale: locale
}
: null
}
},
getHouseholdBillingSettings: async (householdId) => ({
householdId,
rentAmountMinor: null,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
}),
updateHouseholdBillingSettings: async (input) => ({
householdId: input.householdId,
rentAmountMinor: input.rentAmountMinor ?? null,
rentCurrency: input.rentCurrency ?? 'USD',
rentDueDay: input.rentDueDay ?? 20,
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi'
}),
listHouseholdUtilityCategories: async () => [],
upsertHouseholdUtilityCategory: async (input) => ({
id: input.slug ?? 'utility-category-1',
householdId: input.householdId,
slug: input.slug ?? 'custom',
name: input.name,
sortOrder: input.sortOrder,
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null
}
return {

View File

@@ -150,6 +150,46 @@ function createRepositoryStub() {
preferredLocale: locale
}
: null
},
async getHouseholdBillingSettings(householdId) {
return {
householdId,
rentAmountMinor: null,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
}
},
async updateHouseholdBillingSettings(input) {
return {
householdId: input.householdId,
rentAmountMinor: input.rentAmountMinor ?? null,
rentCurrency: input.rentCurrency ?? 'USD',
rentDueDay: input.rentDueDay ?? 20,
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi'
}
},
async listHouseholdUtilityCategories() {
return []
},
async upsertHouseholdUtilityCategory(input) {
return {
id: input.slug ?? 'utility-category-1',
householdId: input.householdId,
slug: input.slug ?? 'custom',
name: input.name,
sortOrder: input.sortOrder,
isActive: input.isActive
}
},
async promoteHouseholdAdmin() {
return null
}
}

View File

@@ -244,6 +244,58 @@ function createRepositoryStub() {
preferredLocale: locale
}
: null
},
async getHouseholdBillingSettings(householdId) {
return {
householdId,
rentAmountMinor: null,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
}
},
async updateHouseholdBillingSettings(input) {
return {
householdId: input.householdId,
rentAmountMinor: input.rentAmountMinor ?? null,
rentCurrency: input.rentCurrency ?? 'USD',
rentDueDay: input.rentDueDay ?? 20,
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi'
}
},
async listHouseholdUtilityCategories() {
return []
},
async upsertHouseholdUtilityCategory(input) {
return {
id: input.slug ?? 'utility-category-1',
householdId: input.householdId,
slug: input.slug ?? 'custom',
name: input.name,
sortOrder: input.sortOrder,
isActive: input.isActive
}
},
async promoteHouseholdAdmin(householdId, memberId) {
const member = [...members.values()].find(
(entry) => entry.householdId === householdId && entry.id === memberId
)
if (!member) {
return null
}
const next = {
...member,
isAdmin: true
}
members.set(`${householdId}:${member.telegramUserId}`, next)
return next
}
}

View File

@@ -74,7 +74,37 @@ function createRepository(): HouseholdConfigurationRepository {
preferredLocale: locale,
householdDefaultLocale: 'ru'
}
: null
: null,
getHouseholdBillingSettings: async (householdId) => ({
householdId,
rentAmountMinor: null,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
}),
updateHouseholdBillingSettings: async (input) => ({
householdId: input.householdId,
rentAmountMinor: input.rentAmountMinor ?? null,
rentCurrency: input.rentCurrency ?? 'USD',
rentDueDay: input.rentDueDay ?? 20,
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi'
}),
listHouseholdUtilityCategories: async () => [],
upsertHouseholdUtilityCategory: async (input) => ({
id: input.slug ?? 'utility-category-1',
householdId: input.householdId,
slug: input.slug ?? 'custom',
name: input.name,
sortOrder: input.sortOrder,
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null
}
}

View File

@@ -102,11 +102,131 @@ function repository(): HouseholdConfigurationRepository {
householdDefaultLocale: 'ru',
isAdmin: false
}
: null,
getHouseholdBillingSettings: async (householdId) => ({
householdId,
rentAmountMinor: null,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
}),
updateHouseholdBillingSettings: async (input) => ({
householdId: input.householdId,
rentAmountMinor: input.rentAmountMinor ?? null,
rentCurrency: input.rentCurrency ?? 'USD',
rentDueDay: input.rentDueDay ?? 20,
rentWarningDay: input.rentWarningDay ?? 17,
utilitiesDueDay: input.utilitiesDueDay ?? 4,
utilitiesReminderDay: input.utilitiesReminderDay ?? 3,
timezone: input.timezone ?? 'Asia/Tbilisi'
}),
listHouseholdUtilityCategories: async () => [],
upsertHouseholdUtilityCategory: async (input) => ({
id: input.slug ?? 'utility-category-1',
householdId: input.householdId,
slug: input.slug ?? 'custom',
name: input.name,
sortOrder: input.sortOrder,
isActive: input.isActive
}),
promoteHouseholdAdmin: async (householdId, memberId) =>
memberId === 'member-123456'
? {
id: memberId,
householdId,
telegramUserId: '123456',
displayName: 'Stan',
preferredLocale: null,
householdDefaultLocale: 'ru',
isAdmin: true
}
: null
}
}
describe('createMiniAppAdminService', () => {
test('returns billing settings, utility categories, and members for admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.getSettings({
householdId: 'household-1',
actorIsAdmin: true
})
expect(result).toEqual({
status: 'ok',
settings: {
householdId: 'household-1',
rentAmountMinor: null,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
},
categories: [],
members: []
})
})
test('updates billing settings for admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.updateSettings({
householdId: 'household-1',
actorIsAdmin: true,
rentAmountMajor: '700',
rentCurrency: 'USD',
rentDueDay: 21,
rentWarningDay: 18,
utilitiesDueDay: 5,
utilitiesReminderDay: 4,
timezone: 'Asia/Tbilisi'
})
expect(result).toEqual({
status: 'ok',
settings: {
householdId: 'household-1',
rentAmountMinor: 70000n,
rentCurrency: 'USD',
rentDueDay: 21,
rentWarningDay: 18,
utilitiesDueDay: 5,
utilitiesReminderDay: 4,
timezone: 'Asia/Tbilisi'
}
})
})
test('upserts utility categories for admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.upsertUtilityCategory({
householdId: 'household-1',
actorIsAdmin: true,
name: 'Internet',
sortOrder: 0,
isActive: true
})
expect(result).toEqual({
status: 'ok',
category: {
id: 'utility-category-1',
householdId: 'household-1',
slug: 'custom',
name: 'Internet',
sortOrder: 0,
isActive: true
}
})
})
test('lists pending members for admins', async () => {
const service = createMiniAppAdminService(repository())
@@ -167,4 +287,27 @@ describe('createMiniAppAdminService', () => {
}
})
})
test('promotes an active member to household admin', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.promoteMemberToAdmin({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456'
})
expect(result).toEqual({
status: 'ok',
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
preferredLocale: null,
householdDefaultLocale: 'ru',
isAdmin: true
}
})
})
})

View File

@@ -1,10 +1,75 @@
import type {
HouseholdBillingSettingsRecord,
HouseholdConfigurationRepository,
HouseholdMemberRecord,
HouseholdPendingMemberRecord
HouseholdPendingMemberRecord,
HouseholdUtilityCategoryRecord
} from '@household/ports'
import { Money, type CurrencyCode } from '@household/domain'
function isValidDay(value: number): boolean {
return Number.isInteger(value) && value >= 1 && value <= 31
}
function parseCurrency(raw: string): CurrencyCode {
const normalized = raw.trim().toUpperCase()
if (normalized !== 'USD' && normalized !== 'GEL') {
throw new Error(`Unsupported currency: ${raw}`)
}
return normalized
}
export interface MiniAppAdminService {
getSettings(input: { householdId: string; actorIsAdmin: boolean }): Promise<
| {
status: 'ok'
settings: HouseholdBillingSettingsRecord
categories: readonly HouseholdUtilityCategoryRecord[]
members: readonly HouseholdMemberRecord[]
}
| {
status: 'rejected'
reason: 'not_admin'
}
>
updateSettings(input: {
householdId: string
actorIsAdmin: boolean
rentAmountMajor?: string
rentCurrency?: string
rentDueDay: number
rentWarningDay: number
utilitiesDueDay: number
utilitiesReminderDay: number
timezone: string
}): Promise<
| {
status: 'ok'
settings: HouseholdBillingSettingsRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'invalid_settings'
}
>
upsertUtilityCategory(input: {
householdId: string
actorIsAdmin: boolean
slug?: string
name: string
sortOrder: number
isActive: boolean
}): Promise<
| {
status: 'ok'
category: HouseholdUtilityCategoryRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'invalid_category'
}
>
listPendingMembers(input: { householdId: string; actorIsAdmin: boolean }): Promise<
| {
status: 'ok'
@@ -29,12 +94,144 @@ export interface MiniAppAdminService {
reason: 'not_admin' | 'pending_not_found'
}
>
promoteMemberToAdmin(input: {
householdId: string
actorIsAdmin: boolean
memberId: string
}): Promise<
| {
status: 'ok'
member: HouseholdMemberRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'member_not_found'
}
>
}
export function createMiniAppAdminService(
repository: HouseholdConfigurationRepository
): MiniAppAdminService {
return {
async getSettings(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const [settings, categories, members] = await Promise.all([
repository.getHouseholdBillingSettings(input.householdId),
repository.listHouseholdUtilityCategories(input.householdId),
repository.listHouseholdMembers(input.householdId)
])
return {
status: 'ok',
settings,
categories,
members
}
},
async updateSettings(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
if (
!isValidDay(input.rentDueDay) ||
!isValidDay(input.rentWarningDay) ||
!isValidDay(input.utilitiesDueDay) ||
!isValidDay(input.utilitiesReminderDay) ||
input.timezone.trim().length === 0 ||
input.rentWarningDay > input.rentDueDay ||
input.utilitiesReminderDay > input.utilitiesDueDay
) {
return {
status: 'rejected',
reason: 'invalid_settings'
}
}
let rentAmountMinor: bigint | null | undefined
let rentCurrency: CurrencyCode | undefined
if (input.rentAmountMajor && input.rentAmountMajor.trim().length > 0) {
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
rentAmountMinor = Money.fromMajor(input.rentAmountMajor, rentCurrency).amountMinor
} else if (input.rentAmountMajor === '') {
rentAmountMinor = null
rentCurrency = parseCurrency(input.rentCurrency ?? 'USD')
}
const settings = await repository.updateHouseholdBillingSettings({
householdId: input.householdId,
...(rentAmountMinor !== undefined
? {
rentAmountMinor
}
: {}),
...(rentCurrency
? {
rentCurrency
}
: {}),
rentDueDay: input.rentDueDay,
rentWarningDay: input.rentWarningDay,
utilitiesDueDay: input.utilitiesDueDay,
utilitiesReminderDay: input.utilitiesReminderDay,
timezone: input.timezone.trim()
})
return {
status: 'ok',
settings
}
},
async upsertUtilityCategory(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
if (
input.name.trim().length === 0 ||
!Number.isInteger(input.sortOrder) ||
input.sortOrder < 0
) {
return {
status: 'rejected',
reason: 'invalid_category'
}
}
const category = await repository.upsertHouseholdUtilityCategory({
householdId: input.householdId,
...(input.slug
? {
slug: input.slug
}
: {}),
name: input.name.trim(),
sortOrder: input.sortOrder,
isActive: input.isActive
})
return {
status: 'ok',
category
}
},
async listPendingMembers(input) {
if (!input.actorIsAdmin) {
return {
@@ -73,6 +270,28 @@ export function createMiniAppAdminService(
status: 'approved',
member
}
},
async promoteMemberToAdmin(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const member = await repository.promoteHouseholdAdmin(input.householdId, input.memberId)
if (!member) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
return {
status: 'ok',
member
}
}
}
}

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,
"tag": "0009_quiet_wallflower",
"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()
})
export const householdBillingSettings = pgTable(
'household_billing_settings',
{
id: uuid('id').defaultRandom().primaryKey(),
householdId: uuid('household_id')
.notNull()
.references(() => households.id, { onDelete: 'cascade' }),
rentAmountMinor: bigint('rent_amount_minor', { mode: 'bigint' }),
rentCurrency: text('rent_currency').default('USD').notNull(),
rentDueDay: integer('rent_due_day').default(20).notNull(),
rentWarningDay: integer('rent_warning_day').default(17).notNull(),
utilitiesDueDay: integer('utilities_due_day').default(4).notNull(),
utilitiesReminderDay: integer('utilities_reminder_day').default(3).notNull(),
timezone: text('timezone').default('Asia/Tbilisi').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
householdUnique: uniqueIndex('household_billing_settings_household_unique').on(
table.householdId
)
})
)
export const householdUtilityCategories = pgTable(
'household_utility_categories',
{
id: uuid('id').defaultRandom().primaryKey(),
householdId: uuid('household_id')
.notNull()
.references(() => households.id, { onDelete: 'cascade' }),
slug: text('slug').notNull(),
name: text('name').notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
isActive: integer('is_active').default(1).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
householdSlugUnique: uniqueIndex('household_utility_categories_household_slug_unique').on(
table.householdId,
table.slug
),
householdSortIdx: index('household_utility_categories_household_sort_idx').on(
table.householdId,
table.sortOrder
)
})
)
export const householdTelegramChats = pgTable(
'household_telegram_chats',
{
@@ -460,8 +510,10 @@ export const settlementLines = pgTable(
)
export type Household = typeof households.$inferSelect
export type HouseholdBillingSettings = typeof householdBillingSettings.$inferSelect
export type HouseholdTelegramChat = typeof householdTelegramChats.$inferSelect
export type HouseholdTopicBinding = typeof householdTopicBindings.$inferSelect
export type HouseholdUtilityCategory = typeof householdUtilityCategories.$inferSelect
export type Member = typeof members.$inferSelect
export type BillingCycle = typeof billingCycles.$inferSelect
export type UtilityBill = typeof utilityBills.$inferSelect

View File

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

View File

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