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 } { 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 { await db .insert(schema.householdBillingSettings) .values({ householdId }) .onConflictDoNothing({ target: [schema.householdBillingSettings.householdId] }) } async function ensureUtilityCategories(householdId: string): Promise { 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 => { 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`coalesce(${schema.householdBillingSettings.timezone}, 'Asia/Tbilisi')`.as( 'timezone' ), rentDueDay: sql`coalesce(${schema.householdBillingSettings.rentDueDay}, 20)`.as( 'rent_due_day' ), rentWarningDay: sql`coalesce(${schema.householdBillingSettings.rentWarningDay}, 17)`.as( 'rent_warning_day' ), utilitiesDueDay: sql`coalesce(${schema.householdBillingSettings.utilitiesDueDay}, 4)`.as( 'utilities_due_day' ), utilitiesReminderDay: sql`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 }) } } }