mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 00:14:03 +00:00
1290 lines
43 KiB
TypeScript
1290 lines
43 KiB
TypeScript
import { and, asc, eq, sql } from 'drizzle-orm'
|
|
|
|
import { createDbClient, schema } from '@household/db'
|
|
import {
|
|
instantToDate,
|
|
normalizeSupportedLocale,
|
|
nowInstant,
|
|
type CurrencyCode
|
|
} from '@household/domain'
|
|
import {
|
|
HOUSEHOLD_TOPIC_ROLES,
|
|
type HouseholdBillingSettingsRecord,
|
|
type HouseholdConfigurationRepository,
|
|
type HouseholdJoinTokenRecord,
|
|
type HouseholdMemberRecord,
|
|
type HouseholdPendingMemberRecord,
|
|
type HouseholdTelegramChatRecord,
|
|
type HouseholdTopicBindingRecord,
|
|
type HouseholdTopicRole,
|
|
type HouseholdUtilityCategoryRecord,
|
|
type ReminderTarget,
|
|
type RegisterTelegramHouseholdChatResult
|
|
} from '@household/ports'
|
|
|
|
function normalizeTopicRole(role: string): HouseholdTopicRole {
|
|
const normalized = role.trim().toLowerCase()
|
|
|
|
if ((HOUSEHOLD_TOPIC_ROLES as readonly string[]).includes(normalized)) {
|
|
return normalized as HouseholdTopicRole
|
|
}
|
|
|
|
throw new Error(`Unsupported household topic role: ${role}`)
|
|
}
|
|
|
|
function toHouseholdTelegramChatRecord(row: {
|
|
householdId: string
|
|
householdName: string
|
|
telegramChatId: string
|
|
telegramChatType: string
|
|
title: string | null
|
|
defaultLocale: string
|
|
}): HouseholdTelegramChatRecord {
|
|
const defaultLocale = normalizeSupportedLocale(row.defaultLocale)
|
|
if (!defaultLocale) {
|
|
throw new Error(`Unsupported household default locale: ${row.defaultLocale}`)
|
|
}
|
|
|
|
return {
|
|
householdId: row.householdId,
|
|
householdName: row.householdName,
|
|
telegramChatId: row.telegramChatId,
|
|
telegramChatType: row.telegramChatType,
|
|
title: row.title,
|
|
defaultLocale
|
|
}
|
|
}
|
|
|
|
function toHouseholdTopicBindingRecord(row: {
|
|
householdId: string
|
|
role: string
|
|
telegramThreadId: string
|
|
topicName: string | null
|
|
}): HouseholdTopicBindingRecord {
|
|
return {
|
|
householdId: row.householdId,
|
|
role: normalizeTopicRole(row.role),
|
|
telegramThreadId: row.telegramThreadId,
|
|
topicName: row.topicName
|
|
}
|
|
}
|
|
|
|
function toHouseholdJoinTokenRecord(row: {
|
|
householdId: string
|
|
householdName: string
|
|
token: string
|
|
createdByTelegramUserId: string | null
|
|
}): HouseholdJoinTokenRecord {
|
|
return {
|
|
householdId: row.householdId,
|
|
householdName: row.householdName,
|
|
token: row.token,
|
|
createdByTelegramUserId: row.createdByTelegramUserId
|
|
}
|
|
}
|
|
|
|
function toHouseholdPendingMemberRecord(row: {
|
|
householdId: string
|
|
householdName: string
|
|
telegramUserId: string
|
|
displayName: string
|
|
username: string | null
|
|
languageCode: string | null
|
|
defaultLocale: string
|
|
}): HouseholdPendingMemberRecord {
|
|
const householdDefaultLocale = normalizeSupportedLocale(row.defaultLocale)
|
|
if (!householdDefaultLocale) {
|
|
throw new Error(`Unsupported household default locale: ${row.defaultLocale}`)
|
|
}
|
|
|
|
return {
|
|
householdId: row.householdId,
|
|
householdName: row.householdName,
|
|
telegramUserId: row.telegramUserId,
|
|
displayName: row.displayName,
|
|
username: row.username,
|
|
languageCode: row.languageCode,
|
|
householdDefaultLocale
|
|
}
|
|
}
|
|
|
|
function toHouseholdMemberRecord(row: {
|
|
id: string
|
|
householdId: string
|
|
telegramUserId: string
|
|
displayName: string
|
|
preferredLocale: string | null
|
|
defaultLocale: string
|
|
rentShareWeight: number
|
|
isAdmin: number
|
|
}): HouseholdMemberRecord {
|
|
const householdDefaultLocale = normalizeSupportedLocale(row.defaultLocale)
|
|
if (!householdDefaultLocale) {
|
|
throw new Error(`Unsupported household default locale: ${row.defaultLocale}`)
|
|
}
|
|
|
|
return {
|
|
id: row.id,
|
|
householdId: row.householdId,
|
|
telegramUserId: row.telegramUserId,
|
|
displayName: row.displayName,
|
|
preferredLocale: normalizeSupportedLocale(row.preferredLocale),
|
|
householdDefaultLocale,
|
|
rentShareWeight: row.rentShareWeight,
|
|
isAdmin: row.isAdmin === 1
|
|
}
|
|
}
|
|
|
|
function toReminderTarget(row: {
|
|
householdId: string
|
|
householdName: string
|
|
telegramChatId: string
|
|
reminderThreadId: string | null
|
|
defaultLocale: string
|
|
timezone: string
|
|
rentDueDay: number
|
|
rentWarningDay: number
|
|
utilitiesDueDay: number
|
|
utilitiesReminderDay: number
|
|
}): ReminderTarget {
|
|
const locale = normalizeSupportedLocale(row.defaultLocale)
|
|
if (!locale) {
|
|
throw new Error(`Unsupported household default locale: ${row.defaultLocale}`)
|
|
}
|
|
|
|
return {
|
|
householdId: row.householdId,
|
|
householdName: row.householdName,
|
|
telegramChatId: row.telegramChatId,
|
|
telegramThreadId: row.reminderThreadId,
|
|
locale,
|
|
timezone: row.timezone,
|
|
rentDueDay: row.rentDueDay,
|
|
rentWarningDay: row.rentWarningDay,
|
|
utilitiesDueDay: row.utilitiesDueDay,
|
|
utilitiesReminderDay: row.utilitiesReminderDay
|
|
}
|
|
}
|
|
|
|
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
|
|
settlementCurrency: string
|
|
rentAmountMinor: bigint | null
|
|
rentCurrency: string
|
|
rentDueDay: number
|
|
rentWarningDay: number
|
|
utilitiesDueDay: number
|
|
utilitiesReminderDay: number
|
|
timezone: string
|
|
}): HouseholdBillingSettingsRecord {
|
|
return {
|
|
householdId: row.householdId,
|
|
settlementCurrency: toCurrencyCode(row.settlementCurrency),
|
|
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>
|
|
} {
|
|
const { db, queryClient } = createDbClient(databaseUrl, {
|
|
max: 5,
|
|
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> => {
|
|
const existingRows = await tx
|
|
.select({
|
|
householdId: schema.householdTelegramChats.householdId,
|
|
householdName: schema.households.name,
|
|
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
|
telegramChatType: schema.householdTelegramChats.telegramChatType,
|
|
title: schema.householdTelegramChats.title,
|
|
defaultLocale: schema.households.defaultLocale
|
|
})
|
|
.from(schema.householdTelegramChats)
|
|
.innerJoin(
|
|
schema.households,
|
|
eq(schema.householdTelegramChats.householdId, schema.households.id)
|
|
)
|
|
.where(eq(schema.householdTelegramChats.telegramChatId, input.telegramChatId))
|
|
.limit(1)
|
|
|
|
const existing = existingRows[0]
|
|
if (existing) {
|
|
const nextTitle = input.title?.trim() || existing.title
|
|
|
|
await tx
|
|
.update(schema.householdTelegramChats)
|
|
.set({
|
|
telegramChatType: input.telegramChatType,
|
|
title: nextTitle,
|
|
updatedAt: instantToDate(nowInstant())
|
|
})
|
|
.where(eq(schema.householdTelegramChats.telegramChatId, input.telegramChatId))
|
|
|
|
return {
|
|
status: 'existing',
|
|
household: toHouseholdTelegramChatRecord({
|
|
...existing,
|
|
telegramChatType: input.telegramChatType,
|
|
title: nextTitle
|
|
})
|
|
}
|
|
}
|
|
|
|
const insertedHouseholds = await tx
|
|
.insert(schema.households)
|
|
.values({
|
|
name: input.householdName
|
|
})
|
|
.returning({
|
|
id: schema.households.id,
|
|
name: schema.households.name,
|
|
defaultLocale: schema.households.defaultLocale
|
|
})
|
|
|
|
const household = insertedHouseholds[0]
|
|
if (!household) {
|
|
throw new Error('Failed to create household record')
|
|
}
|
|
|
|
const insertedChats = await tx
|
|
.insert(schema.householdTelegramChats)
|
|
.values({
|
|
householdId: household.id,
|
|
telegramChatId: input.telegramChatId,
|
|
telegramChatType: input.telegramChatType,
|
|
title: input.title?.trim() || null
|
|
})
|
|
.returning({
|
|
householdId: schema.householdTelegramChats.householdId,
|
|
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
|
telegramChatType: schema.householdTelegramChats.telegramChatType,
|
|
title: schema.householdTelegramChats.title
|
|
})
|
|
|
|
const chat = insertedChats[0]
|
|
if (!chat) {
|
|
throw new Error('Failed to create Telegram household chat binding')
|
|
}
|
|
|
|
return {
|
|
status: 'created',
|
|
household: toHouseholdTelegramChatRecord({
|
|
householdId: chat.householdId,
|
|
householdName: household.name,
|
|
telegramChatId: chat.telegramChatId,
|
|
telegramChatType: chat.telegramChatType,
|
|
title: chat.title,
|
|
defaultLocale: household.defaultLocale
|
|
})
|
|
}
|
|
})
|
|
},
|
|
|
|
async getTelegramHouseholdChat(telegramChatId) {
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdTelegramChats.householdId,
|
|
householdName: schema.households.name,
|
|
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
|
telegramChatType: schema.householdTelegramChats.telegramChatType,
|
|
title: schema.householdTelegramChats.title,
|
|
defaultLocale: schema.households.defaultLocale
|
|
})
|
|
.from(schema.householdTelegramChats)
|
|
.innerJoin(
|
|
schema.households,
|
|
eq(schema.householdTelegramChats.householdId, schema.households.id)
|
|
)
|
|
.where(eq(schema.householdTelegramChats.telegramChatId, telegramChatId))
|
|
.limit(1)
|
|
|
|
const row = rows[0]
|
|
return row ? toHouseholdTelegramChatRecord(row) : null
|
|
},
|
|
|
|
async getHouseholdChatByHouseholdId(householdId) {
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdTelegramChats.householdId,
|
|
householdName: schema.households.name,
|
|
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
|
telegramChatType: schema.householdTelegramChats.telegramChatType,
|
|
title: schema.householdTelegramChats.title,
|
|
defaultLocale: schema.households.defaultLocale
|
|
})
|
|
.from(schema.householdTelegramChats)
|
|
.innerJoin(
|
|
schema.households,
|
|
eq(schema.householdTelegramChats.householdId, schema.households.id)
|
|
)
|
|
.where(eq(schema.householdTelegramChats.householdId, householdId))
|
|
.limit(1)
|
|
|
|
const row = rows[0]
|
|
return row ? toHouseholdTelegramChatRecord(row) : null
|
|
},
|
|
|
|
async bindHouseholdTopic(input) {
|
|
const rows = await db
|
|
.insert(schema.householdTopicBindings)
|
|
.values({
|
|
householdId: input.householdId,
|
|
role: input.role,
|
|
telegramThreadId: input.telegramThreadId,
|
|
topicName: input.topicName?.trim() || null
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [schema.householdTopicBindings.householdId, schema.householdTopicBindings.role],
|
|
set: {
|
|
telegramThreadId: input.telegramThreadId,
|
|
topicName: input.topicName?.trim() || null,
|
|
updatedAt: instantToDate(nowInstant())
|
|
}
|
|
})
|
|
.returning({
|
|
householdId: schema.householdTopicBindings.householdId,
|
|
role: schema.householdTopicBindings.role,
|
|
telegramThreadId: schema.householdTopicBindings.telegramThreadId,
|
|
topicName: schema.householdTopicBindings.topicName
|
|
})
|
|
|
|
const row = rows[0]
|
|
if (!row) {
|
|
throw new Error('Failed to bind household topic')
|
|
}
|
|
|
|
return toHouseholdTopicBindingRecord(row)
|
|
},
|
|
|
|
async getHouseholdTopicBinding(householdId, role) {
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdTopicBindings.householdId,
|
|
role: schema.householdTopicBindings.role,
|
|
telegramThreadId: schema.householdTopicBindings.telegramThreadId,
|
|
topicName: schema.householdTopicBindings.topicName
|
|
})
|
|
.from(schema.householdTopicBindings)
|
|
.where(
|
|
and(
|
|
eq(schema.householdTopicBindings.householdId, householdId),
|
|
eq(schema.householdTopicBindings.role, role)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
const row = rows[0]
|
|
return row ? toHouseholdTopicBindingRecord(row) : null
|
|
},
|
|
|
|
async findHouseholdTopicByTelegramContext(input) {
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdTopicBindings.householdId,
|
|
role: schema.householdTopicBindings.role,
|
|
telegramThreadId: schema.householdTopicBindings.telegramThreadId,
|
|
topicName: schema.householdTopicBindings.topicName
|
|
})
|
|
.from(schema.householdTopicBindings)
|
|
.innerJoin(
|
|
schema.householdTelegramChats,
|
|
eq(schema.householdTopicBindings.householdId, schema.householdTelegramChats.householdId)
|
|
)
|
|
.where(
|
|
and(
|
|
eq(schema.householdTelegramChats.telegramChatId, input.telegramChatId),
|
|
eq(schema.householdTopicBindings.telegramThreadId, input.telegramThreadId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
const row = rows[0]
|
|
return row ? toHouseholdTopicBindingRecord(row) : null
|
|
},
|
|
|
|
async listHouseholdTopicBindings(householdId) {
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdTopicBindings.householdId,
|
|
role: schema.householdTopicBindings.role,
|
|
telegramThreadId: schema.householdTopicBindings.telegramThreadId,
|
|
topicName: schema.householdTopicBindings.topicName
|
|
})
|
|
.from(schema.householdTopicBindings)
|
|
.where(eq(schema.householdTopicBindings.householdId, householdId))
|
|
.orderBy(schema.householdTopicBindings.role)
|
|
|
|
return rows.map(toHouseholdTopicBindingRecord)
|
|
},
|
|
|
|
async listReminderTargets() {
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdTelegramChats.householdId,
|
|
householdName: schema.households.name,
|
|
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
|
reminderThreadId: schema.householdTopicBindings.telegramThreadId,
|
|
defaultLocale: schema.households.defaultLocale,
|
|
timezone:
|
|
sql<string>`coalesce(${schema.householdBillingSettings.timezone}, 'Asia/Tbilisi')`.as(
|
|
'timezone'
|
|
),
|
|
rentDueDay: sql<number>`coalesce(${schema.householdBillingSettings.rentDueDay}, 20)`.as(
|
|
'rent_due_day'
|
|
),
|
|
rentWarningDay:
|
|
sql<number>`coalesce(${schema.householdBillingSettings.rentWarningDay}, 17)`.as(
|
|
'rent_warning_day'
|
|
),
|
|
utilitiesDueDay:
|
|
sql<number>`coalesce(${schema.householdBillingSettings.utilitiesDueDay}, 4)`.as(
|
|
'utilities_due_day'
|
|
),
|
|
utilitiesReminderDay:
|
|
sql<number>`coalesce(${schema.householdBillingSettings.utilitiesReminderDay}, 3)`.as(
|
|
'utilities_reminder_day'
|
|
)
|
|
})
|
|
.from(schema.householdTelegramChats)
|
|
.innerJoin(
|
|
schema.households,
|
|
eq(schema.householdTelegramChats.householdId, schema.households.id)
|
|
)
|
|
.leftJoin(
|
|
schema.householdBillingSettings,
|
|
eq(schema.householdBillingSettings.householdId, schema.householdTelegramChats.householdId)
|
|
)
|
|
.leftJoin(
|
|
schema.householdTopicBindings,
|
|
and(
|
|
eq(
|
|
schema.householdTopicBindings.householdId,
|
|
schema.householdTelegramChats.householdId
|
|
),
|
|
eq(schema.householdTopicBindings.role, 'reminders')
|
|
)
|
|
)
|
|
.orderBy(asc(schema.householdTelegramChats.telegramChatId), asc(schema.households.name))
|
|
|
|
return rows.map(toReminderTarget)
|
|
},
|
|
|
|
async upsertHouseholdJoinToken(input) {
|
|
const rows = await db
|
|
.insert(schema.householdJoinTokens)
|
|
.values({
|
|
householdId: input.householdId,
|
|
token: input.token,
|
|
createdByTelegramUserId: input.createdByTelegramUserId ?? null
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [schema.householdJoinTokens.householdId],
|
|
set: {
|
|
token: input.token,
|
|
createdByTelegramUserId: input.createdByTelegramUserId ?? null,
|
|
updatedAt: instantToDate(nowInstant())
|
|
}
|
|
})
|
|
.returning({
|
|
householdId: schema.householdJoinTokens.householdId,
|
|
token: schema.householdJoinTokens.token,
|
|
createdByTelegramUserId: schema.householdJoinTokens.createdByTelegramUserId
|
|
})
|
|
|
|
const row = rows[0]
|
|
if (!row) {
|
|
throw new Error('Failed to save household join token')
|
|
}
|
|
|
|
const householdRows = await db
|
|
.select({
|
|
householdId: schema.households.id,
|
|
householdName: schema.households.name
|
|
})
|
|
.from(schema.households)
|
|
.where(eq(schema.households.id, row.householdId))
|
|
.limit(1)
|
|
|
|
const household = householdRows[0]
|
|
if (!household) {
|
|
throw new Error('Failed to resolve household for join token')
|
|
}
|
|
|
|
return toHouseholdJoinTokenRecord({
|
|
householdId: row.householdId,
|
|
householdName: household.householdName,
|
|
token: row.token,
|
|
createdByTelegramUserId: row.createdByTelegramUserId
|
|
})
|
|
},
|
|
|
|
async getHouseholdJoinToken(householdId) {
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdJoinTokens.householdId,
|
|
householdName: schema.households.name,
|
|
token: schema.householdJoinTokens.token,
|
|
createdByTelegramUserId: schema.householdJoinTokens.createdByTelegramUserId
|
|
})
|
|
.from(schema.householdJoinTokens)
|
|
.innerJoin(
|
|
schema.households,
|
|
eq(schema.householdJoinTokens.householdId, schema.households.id)
|
|
)
|
|
.where(eq(schema.householdJoinTokens.householdId, householdId))
|
|
.limit(1)
|
|
|
|
const row = rows[0]
|
|
return row ? toHouseholdJoinTokenRecord(row) : null
|
|
},
|
|
|
|
async getHouseholdByJoinToken(token) {
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdJoinTokens.householdId,
|
|
householdName: schema.households.name,
|
|
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
|
telegramChatType: schema.householdTelegramChats.telegramChatType,
|
|
title: schema.householdTelegramChats.title,
|
|
defaultLocale: schema.households.defaultLocale
|
|
})
|
|
.from(schema.householdJoinTokens)
|
|
.innerJoin(
|
|
schema.households,
|
|
eq(schema.householdJoinTokens.householdId, schema.households.id)
|
|
)
|
|
.innerJoin(
|
|
schema.householdTelegramChats,
|
|
eq(schema.householdJoinTokens.householdId, schema.householdTelegramChats.householdId)
|
|
)
|
|
.where(eq(schema.householdJoinTokens.token, token))
|
|
.limit(1)
|
|
|
|
const row = rows[0]
|
|
return row ? toHouseholdTelegramChatRecord(row) : null
|
|
},
|
|
|
|
async upsertPendingHouseholdMember(input) {
|
|
const rows = await db
|
|
.insert(schema.householdPendingMembers)
|
|
.values({
|
|
householdId: input.householdId,
|
|
telegramUserId: input.telegramUserId,
|
|
displayName: input.displayName,
|
|
username: input.username?.trim() || null,
|
|
languageCode: input.languageCode?.trim() || null
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
schema.householdPendingMembers.householdId,
|
|
schema.householdPendingMembers.telegramUserId
|
|
],
|
|
set: {
|
|
displayName: input.displayName,
|
|
username: input.username?.trim() || null,
|
|
languageCode: input.languageCode?.trim() || null,
|
|
updatedAt: instantToDate(nowInstant())
|
|
}
|
|
})
|
|
.returning({
|
|
householdId: schema.householdPendingMembers.householdId,
|
|
telegramUserId: schema.householdPendingMembers.telegramUserId,
|
|
displayName: schema.householdPendingMembers.displayName,
|
|
username: schema.householdPendingMembers.username,
|
|
languageCode: schema.householdPendingMembers.languageCode
|
|
})
|
|
|
|
const row = rows[0]
|
|
if (!row) {
|
|
throw new Error('Failed to save pending household member')
|
|
}
|
|
|
|
const householdRows = await db
|
|
.select({
|
|
householdId: schema.households.id,
|
|
householdName: schema.households.name,
|
|
defaultLocale: schema.households.defaultLocale
|
|
})
|
|
.from(schema.households)
|
|
.where(eq(schema.households.id, row.householdId))
|
|
.limit(1)
|
|
|
|
const household = householdRows[0]
|
|
if (!household) {
|
|
throw new Error('Failed to resolve household for pending member')
|
|
}
|
|
|
|
return toHouseholdPendingMemberRecord({
|
|
householdId: row.householdId,
|
|
householdName: household.householdName,
|
|
telegramUserId: row.telegramUserId,
|
|
displayName: row.displayName,
|
|
username: row.username,
|
|
languageCode: row.languageCode,
|
|
defaultLocale: household.defaultLocale
|
|
})
|
|
},
|
|
|
|
async getPendingHouseholdMember(householdId, telegramUserId) {
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdPendingMembers.householdId,
|
|
householdName: schema.households.name,
|
|
telegramUserId: schema.householdPendingMembers.telegramUserId,
|
|
displayName: schema.householdPendingMembers.displayName,
|
|
username: schema.householdPendingMembers.username,
|
|
languageCode: schema.householdPendingMembers.languageCode,
|
|
defaultLocale: schema.households.defaultLocale
|
|
})
|
|
.from(schema.householdPendingMembers)
|
|
.innerJoin(
|
|
schema.households,
|
|
eq(schema.householdPendingMembers.householdId, schema.households.id)
|
|
)
|
|
.where(
|
|
and(
|
|
eq(schema.householdPendingMembers.householdId, householdId),
|
|
eq(schema.householdPendingMembers.telegramUserId, telegramUserId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
const row = rows[0]
|
|
return row ? toHouseholdPendingMemberRecord(row) : null
|
|
},
|
|
|
|
async findPendingHouseholdMemberByTelegramUserId(telegramUserId) {
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdPendingMembers.householdId,
|
|
householdName: schema.households.name,
|
|
telegramUserId: schema.householdPendingMembers.telegramUserId,
|
|
displayName: schema.householdPendingMembers.displayName,
|
|
username: schema.householdPendingMembers.username,
|
|
languageCode: schema.householdPendingMembers.languageCode,
|
|
defaultLocale: schema.households.defaultLocale
|
|
})
|
|
.from(schema.householdPendingMembers)
|
|
.innerJoin(
|
|
schema.households,
|
|
eq(schema.householdPendingMembers.householdId, schema.households.id)
|
|
)
|
|
.where(eq(schema.householdPendingMembers.telegramUserId, telegramUserId))
|
|
.limit(1)
|
|
|
|
const row = rows[0]
|
|
return row ? toHouseholdPendingMemberRecord(row) : null
|
|
},
|
|
|
|
async ensureHouseholdMember(input) {
|
|
const rows = await db
|
|
.insert(schema.members)
|
|
.values({
|
|
householdId: input.householdId,
|
|
telegramUserId: input.telegramUserId,
|
|
displayName: input.displayName,
|
|
preferredLocale: input.preferredLocale ?? null,
|
|
rentShareWeight: input.rentShareWeight ?? 1,
|
|
isAdmin: input.isAdmin ? 1 : 0
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [schema.members.householdId, schema.members.telegramUserId],
|
|
set: {
|
|
displayName: input.displayName,
|
|
preferredLocale: input.preferredLocale ?? schema.members.preferredLocale,
|
|
rentShareWeight: input.rentShareWeight ?? schema.members.rentShareWeight,
|
|
...(input.isAdmin
|
|
? {
|
|
isAdmin: 1
|
|
}
|
|
: {})
|
|
}
|
|
})
|
|
.returning({
|
|
id: schema.members.id,
|
|
householdId: schema.members.householdId,
|
|
telegramUserId: schema.members.telegramUserId,
|
|
displayName: schema.members.displayName,
|
|
preferredLocale: schema.members.preferredLocale,
|
|
rentShareWeight: schema.members.rentShareWeight,
|
|
isAdmin: schema.members.isAdmin
|
|
})
|
|
|
|
const row = rows[0]
|
|
if (!row) {
|
|
throw new Error('Failed to ensure household member')
|
|
}
|
|
|
|
const household = await this.getHouseholdChatByHouseholdId(row.householdId)
|
|
if (!household) {
|
|
throw new Error('Failed to resolve household for member')
|
|
}
|
|
|
|
return toHouseholdMemberRecord({
|
|
...row,
|
|
defaultLocale: household.defaultLocale
|
|
})
|
|
},
|
|
|
|
async getHouseholdMember(householdId, telegramUserId) {
|
|
const rows = await db
|
|
.select({
|
|
id: schema.members.id,
|
|
householdId: schema.members.householdId,
|
|
telegramUserId: schema.members.telegramUserId,
|
|
displayName: schema.members.displayName,
|
|
preferredLocale: schema.members.preferredLocale,
|
|
rentShareWeight: schema.members.rentShareWeight,
|
|
defaultLocale: schema.households.defaultLocale,
|
|
isAdmin: schema.members.isAdmin
|
|
})
|
|
.from(schema.members)
|
|
.innerJoin(schema.households, eq(schema.members.householdId, schema.households.id))
|
|
.where(
|
|
and(
|
|
eq(schema.members.householdId, householdId),
|
|
eq(schema.members.telegramUserId, telegramUserId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
const row = rows[0]
|
|
return row ? toHouseholdMemberRecord(row) : null
|
|
},
|
|
|
|
async listHouseholdMembers(householdId) {
|
|
const rows = await db
|
|
.select({
|
|
id: schema.members.id,
|
|
householdId: schema.members.householdId,
|
|
telegramUserId: schema.members.telegramUserId,
|
|
displayName: schema.members.displayName,
|
|
preferredLocale: schema.members.preferredLocale,
|
|
rentShareWeight: schema.members.rentShareWeight,
|
|
defaultLocale: schema.households.defaultLocale,
|
|
isAdmin: schema.members.isAdmin
|
|
})
|
|
.from(schema.members)
|
|
.innerJoin(schema.households, eq(schema.members.householdId, schema.households.id))
|
|
.where(eq(schema.members.householdId, householdId))
|
|
.orderBy(schema.members.displayName, schema.members.telegramUserId)
|
|
|
|
return rows.map(toHouseholdMemberRecord)
|
|
},
|
|
|
|
async getHouseholdBillingSettings(householdId) {
|
|
await ensureBillingSettings(householdId)
|
|
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdBillingSettings.householdId,
|
|
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
|
|
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.settlementCurrency
|
|
? {
|
|
settlementCurrency: input.settlementCurrency
|
|
}
|
|
: {}),
|
|
...(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,
|
|
settlementCurrency: schema.householdBillingSettings.settlementCurrency,
|
|
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({
|
|
id: schema.members.id,
|
|
householdId: schema.members.householdId,
|
|
telegramUserId: schema.members.telegramUserId,
|
|
displayName: schema.members.displayName,
|
|
preferredLocale: schema.members.preferredLocale,
|
|
rentShareWeight: schema.members.rentShareWeight,
|
|
defaultLocale: schema.households.defaultLocale,
|
|
isAdmin: schema.members.isAdmin
|
|
})
|
|
.from(schema.members)
|
|
.innerJoin(schema.households, eq(schema.members.householdId, schema.households.id))
|
|
.where(eq(schema.members.telegramUserId, telegramUserId))
|
|
.orderBy(schema.members.householdId, schema.members.displayName)
|
|
|
|
return rows.map(toHouseholdMemberRecord)
|
|
},
|
|
|
|
async listPendingHouseholdMembers(householdId) {
|
|
const rows = await db
|
|
.select({
|
|
householdId: schema.householdPendingMembers.householdId,
|
|
householdName: schema.households.name,
|
|
telegramUserId: schema.householdPendingMembers.telegramUserId,
|
|
displayName: schema.householdPendingMembers.displayName,
|
|
username: schema.householdPendingMembers.username,
|
|
languageCode: schema.householdPendingMembers.languageCode,
|
|
defaultLocale: schema.households.defaultLocale
|
|
})
|
|
.from(schema.householdPendingMembers)
|
|
.innerJoin(
|
|
schema.households,
|
|
eq(schema.householdPendingMembers.householdId, schema.households.id)
|
|
)
|
|
.where(eq(schema.householdPendingMembers.householdId, householdId))
|
|
.orderBy(schema.householdPendingMembers.createdAt)
|
|
|
|
return rows.map(toHouseholdPendingMemberRecord)
|
|
},
|
|
|
|
async approvePendingHouseholdMember(input) {
|
|
return await db.transaction(async (tx) => {
|
|
const pendingRows = await tx
|
|
.select({
|
|
householdId: schema.householdPendingMembers.householdId,
|
|
householdName: schema.households.name,
|
|
telegramUserId: schema.householdPendingMembers.telegramUserId,
|
|
displayName: schema.householdPendingMembers.displayName,
|
|
username: schema.householdPendingMembers.username,
|
|
languageCode: schema.householdPendingMembers.languageCode,
|
|
defaultLocale: schema.households.defaultLocale
|
|
})
|
|
.from(schema.householdPendingMembers)
|
|
.innerJoin(
|
|
schema.households,
|
|
eq(schema.householdPendingMembers.householdId, schema.households.id)
|
|
)
|
|
.where(
|
|
and(
|
|
eq(schema.householdPendingMembers.householdId, input.householdId),
|
|
eq(schema.householdPendingMembers.telegramUserId, input.telegramUserId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
const pending = pendingRows[0]
|
|
if (!pending) {
|
|
return null
|
|
}
|
|
|
|
const memberRows = await tx
|
|
.insert(schema.members)
|
|
.values({
|
|
householdId: pending.householdId,
|
|
telegramUserId: pending.telegramUserId,
|
|
displayName: pending.displayName,
|
|
preferredLocale: normalizeSupportedLocale(pending.languageCode),
|
|
rentShareWeight: 1,
|
|
isAdmin: input.isAdmin ? 1 : 0
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [schema.members.householdId, schema.members.telegramUserId],
|
|
set: {
|
|
displayName: pending.displayName,
|
|
preferredLocale:
|
|
normalizeSupportedLocale(pending.languageCode) ?? schema.members.preferredLocale,
|
|
...(input.isAdmin
|
|
? {
|
|
isAdmin: 1
|
|
}
|
|
: {})
|
|
}
|
|
})
|
|
.returning({
|
|
id: schema.members.id,
|
|
householdId: schema.members.householdId,
|
|
telegramUserId: schema.members.telegramUserId,
|
|
displayName: schema.members.displayName,
|
|
preferredLocale: schema.members.preferredLocale,
|
|
rentShareWeight: schema.members.rentShareWeight,
|
|
isAdmin: schema.members.isAdmin
|
|
})
|
|
|
|
await tx
|
|
.delete(schema.householdPendingMembers)
|
|
.where(
|
|
and(
|
|
eq(schema.householdPendingMembers.householdId, input.householdId),
|
|
eq(schema.householdPendingMembers.telegramUserId, input.telegramUserId)
|
|
)
|
|
)
|
|
|
|
const member = memberRows[0]
|
|
if (!member) {
|
|
throw new Error('Failed to approve pending household member')
|
|
}
|
|
|
|
return toHouseholdMemberRecord({
|
|
...member,
|
|
defaultLocale: pending.defaultLocale
|
|
})
|
|
})
|
|
},
|
|
|
|
async updateHouseholdDefaultLocale(householdId, locale) {
|
|
const updatedHouseholds = await db
|
|
.update(schema.households)
|
|
.set({
|
|
defaultLocale: locale
|
|
})
|
|
.where(eq(schema.households.id, householdId))
|
|
.returning({
|
|
id: schema.households.id,
|
|
name: schema.households.name,
|
|
defaultLocale: schema.households.defaultLocale
|
|
})
|
|
|
|
const household = updatedHouseholds[0]
|
|
if (!household) {
|
|
throw new Error('Failed to update household default locale')
|
|
}
|
|
|
|
const chat = await this.getHouseholdChatByHouseholdId(householdId)
|
|
if (!chat) {
|
|
throw new Error('Failed to resolve household chat after locale update')
|
|
}
|
|
|
|
return {
|
|
...chat,
|
|
defaultLocale: normalizeSupportedLocale(household.defaultLocale) ?? chat.defaultLocale
|
|
}
|
|
},
|
|
|
|
async updateMemberPreferredLocale(householdId, telegramUserId, locale) {
|
|
const rows = await db
|
|
.update(schema.members)
|
|
.set({
|
|
preferredLocale: locale
|
|
})
|
|
.where(
|
|
and(
|
|
eq(schema.members.householdId, householdId),
|
|
eq(schema.members.telegramUserId, telegramUserId)
|
|
)
|
|
)
|
|
.returning({
|
|
id: schema.members.id,
|
|
householdId: schema.members.householdId,
|
|
telegramUserId: schema.members.telegramUserId,
|
|
displayName: schema.members.displayName,
|
|
preferredLocale: schema.members.preferredLocale,
|
|
rentShareWeight: schema.members.rentShareWeight,
|
|
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 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,
|
|
rentShareWeight: schema.members.rentShareWeight,
|
|
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
|
|
})
|
|
},
|
|
|
|
async updateHouseholdMemberRentShareWeight(householdId, memberId, rentShareWeight) {
|
|
const rows = await db
|
|
.update(schema.members)
|
|
.set({
|
|
rentShareWeight
|
|
})
|
|
.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,
|
|
rentShareWeight: schema.members.rentShareWeight,
|
|
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 rent weight update')
|
|
}
|
|
|
|
return toHouseholdMemberRecord({
|
|
...row,
|
|
defaultLocale: household.defaultLocale
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
repository,
|
|
close: async () => {
|
|
await queryClient.end({ timeout: 5 })
|
|
}
|
|
}
|
|
}
|