From 9de6bcc31b983a8bb1990a7655b6d6fa2a065494 Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 9 Mar 2026 13:17:25 +0400 Subject: [PATCH] feat(locale): persist household and member preferences --- .../src/household-config-repository.ts | 162 +- .../src/household-admin-service.test.ts | 33 +- .../src/household-onboarding-service.test.ts | 40 +- .../src/household-onboarding-service.ts | 26 +- .../src/household-setup-service.test.ts | 43 +- packages/application/src/index.ts | 4 + .../src/locale-preference-service.test.ts | 115 + .../src/locale-preference-service.ts | 94 + .../src/miniapp-admin-service.test.ts | 38 +- packages/db/drizzle/0008_lowly_spiral.sql | 2 + packages/db/drizzle/meta/0008_snapshot.json | 2143 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 2 + packages/domain/src/index.ts | 2 + packages/domain/src/locale.ts | 14 + packages/ports/src/household-config.ts | 16 + 16 files changed, 2704 insertions(+), 37 deletions(-) create mode 100644 packages/application/src/locale-preference-service.test.ts create mode 100644 packages/application/src/locale-preference-service.ts create mode 100644 packages/db/drizzle/0008_lowly_spiral.sql create mode 100644 packages/db/drizzle/meta/0008_snapshot.json create mode 100644 packages/domain/src/locale.ts diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index 6ffc365..9311f1d 100644 --- a/packages/adapters-db/src/household-config-repository.ts +++ b/packages/adapters-db/src/household-config-repository.ts @@ -1,7 +1,7 @@ import { and, eq } from 'drizzle-orm' import { createDbClient, schema } from '@household/db' -import { instantToDate, nowInstant } from '@household/domain' +import { instantToDate, normalizeSupportedLocale, nowInstant } from '@household/domain' import { HOUSEHOLD_TOPIC_ROLES, type HouseholdConfigurationRepository, @@ -30,13 +30,20 @@ function toHouseholdTelegramChatRecord(row: { 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 + title: row.title, + defaultLocale } } @@ -75,14 +82,21 @@ function toHouseholdPendingMemberRecord(row: { 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 + languageCode: row.languageCode, + householdDefaultLocale } } @@ -91,13 +105,22 @@ function toHouseholdMemberRecord(row: { householdId: string telegramUserId: string displayName: string + preferredLocale: string | null + defaultLocale: string 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, isAdmin: row.isAdmin === 1 } } @@ -120,7 +143,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdName: schema.households.name, telegramChatId: schema.householdTelegramChats.telegramChatId, telegramChatType: schema.householdTelegramChats.telegramChatType, - title: schema.householdTelegramChats.title + title: schema.householdTelegramChats.title, + defaultLocale: schema.households.defaultLocale }) .from(schema.householdTelegramChats) .innerJoin( @@ -160,7 +184,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { }) .returning({ id: schema.households.id, - name: schema.households.name + name: schema.households.name, + defaultLocale: schema.households.defaultLocale }) const household = insertedHouseholds[0] @@ -195,7 +220,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdName: household.name, telegramChatId: chat.telegramChatId, telegramChatType: chat.telegramChatType, - title: chat.title + title: chat.title, + defaultLocale: household.defaultLocale }) } }) @@ -208,7 +234,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdName: schema.households.name, telegramChatId: schema.householdTelegramChats.telegramChatId, telegramChatType: schema.householdTelegramChats.telegramChatType, - title: schema.householdTelegramChats.title + title: schema.householdTelegramChats.title, + defaultLocale: schema.households.defaultLocale }) .from(schema.householdTelegramChats) .innerJoin( @@ -229,7 +256,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdName: schema.households.name, telegramChatId: schema.householdTelegramChats.telegramChatId, telegramChatType: schema.householdTelegramChats.telegramChatType, - title: schema.householdTelegramChats.title + title: schema.householdTelegramChats.title, + defaultLocale: schema.households.defaultLocale }) .from(schema.householdTelegramChats) .innerJoin( @@ -412,7 +440,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdName: schema.households.name, telegramChatId: schema.householdTelegramChats.telegramChatId, telegramChatType: schema.householdTelegramChats.telegramChatType, - title: schema.householdTelegramChats.title + title: schema.householdTelegramChats.title, + defaultLocale: schema.households.defaultLocale }) .from(schema.householdJoinTokens) .innerJoin( @@ -468,7 +497,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { const householdRows = await db .select({ householdId: schema.households.id, - householdName: schema.households.name + householdName: schema.households.name, + defaultLocale: schema.households.defaultLocale }) .from(schema.households) .where(eq(schema.households.id, row.householdId)) @@ -485,7 +515,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { telegramUserId: row.telegramUserId, displayName: row.displayName, username: row.username, - languageCode: row.languageCode + languageCode: row.languageCode, + defaultLocale: household.defaultLocale }) }, @@ -497,7 +528,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { telegramUserId: schema.householdPendingMembers.telegramUserId, displayName: schema.householdPendingMembers.displayName, username: schema.householdPendingMembers.username, - languageCode: schema.householdPendingMembers.languageCode + languageCode: schema.householdPendingMembers.languageCode, + defaultLocale: schema.households.defaultLocale }) .from(schema.householdPendingMembers) .innerJoin( @@ -524,7 +556,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { telegramUserId: schema.householdPendingMembers.telegramUserId, displayName: schema.householdPendingMembers.displayName, username: schema.householdPendingMembers.username, - languageCode: schema.householdPendingMembers.languageCode + languageCode: schema.householdPendingMembers.languageCode, + defaultLocale: schema.households.defaultLocale }) .from(schema.householdPendingMembers) .innerJoin( @@ -545,12 +578,14 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, + preferredLocale: input.preferredLocale ?? null, isAdmin: input.isAdmin ? 1 : 0 }) .onConflictDoUpdate({ target: [schema.members.householdId, schema.members.telegramUserId], set: { displayName: input.displayName, + preferredLocale: input.preferredLocale ?? schema.members.preferredLocale, ...(input.isAdmin ? { isAdmin: 1 @@ -563,6 +598,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdId: schema.members.householdId, telegramUserId: schema.members.telegramUserId, displayName: schema.members.displayName, + preferredLocale: schema.members.preferredLocale, isAdmin: schema.members.isAdmin }) @@ -571,7 +607,15 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { throw new Error('Failed to ensure household member') } - return toHouseholdMemberRecord(row) + 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) { @@ -581,9 +625,12 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdId: schema.members.householdId, telegramUserId: schema.members.telegramUserId, displayName: schema.members.displayName, + preferredLocale: schema.members.preferredLocale, + 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), @@ -603,9 +650,12 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdId: schema.members.householdId, telegramUserId: schema.members.telegramUserId, displayName: schema.members.displayName, + preferredLocale: schema.members.preferredLocale, + 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) @@ -619,9 +669,12 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdId: schema.members.householdId, telegramUserId: schema.members.telegramUserId, displayName: schema.members.displayName, + preferredLocale: schema.members.preferredLocale, + 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) @@ -636,7 +689,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { telegramUserId: schema.householdPendingMembers.telegramUserId, displayName: schema.householdPendingMembers.displayName, username: schema.householdPendingMembers.username, - languageCode: schema.householdPendingMembers.languageCode + languageCode: schema.householdPendingMembers.languageCode, + defaultLocale: schema.households.defaultLocale }) .from(schema.householdPendingMembers) .innerJoin( @@ -658,7 +712,8 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { telegramUserId: schema.householdPendingMembers.telegramUserId, displayName: schema.householdPendingMembers.displayName, username: schema.householdPendingMembers.username, - languageCode: schema.householdPendingMembers.languageCode + languageCode: schema.householdPendingMembers.languageCode, + defaultLocale: schema.households.defaultLocale }) .from(schema.householdPendingMembers) .innerJoin( @@ -684,12 +739,15 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdId: pending.householdId, telegramUserId: pending.telegramUserId, displayName: pending.displayName, + preferredLocale: normalizeSupportedLocale(pending.languageCode), 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 @@ -702,6 +760,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { householdId: schema.members.householdId, telegramUserId: schema.members.telegramUserId, displayName: schema.members.displayName, + preferredLocale: schema.members.preferredLocale, isAdmin: schema.members.isAdmin }) @@ -719,7 +778,76 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): { throw new Error('Failed to approve pending household member') } - return toHouseholdMemberRecord(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, + 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 }) } } diff --git a/packages/application/src/household-admin-service.test.ts b/packages/application/src/household-admin-service.test.ts index 3545b41..3d31d5a 100644 --- a/packages/application/src/household-admin-service.test.ts +++ b/packages/application/src/household-admin-service.test.ts @@ -17,7 +17,8 @@ function createRepositoryStub() { householdName: 'Kojori House', telegramChatId: '-100123', telegramChatType: 'supergroup', - title: 'Kojori House' + title: 'Kojori House', + defaultLocale: 'ru' } const members = new Map() const pendingMembers = new Map() @@ -27,6 +28,8 @@ function createRepositoryStub() { householdId: household.householdId, telegramUserId: '1', displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, isAdmin: true }) pendingMembers.set('2', { @@ -35,7 +38,8 @@ function createRepositoryStub() { telegramUserId: '2', displayName: 'Alice', username: 'alice', - languageCode: 'en' + languageCode: 'en', + householdDefaultLocale: household.defaultLocale }) const repository: HouseholdConfigurationRepository = { @@ -71,7 +75,8 @@ function createRepositoryStub() { telegramUserId: input.telegramUserId, displayName: input.displayName, username: input.username?.trim() || null, - languageCode: input.languageCode?.trim() || null + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: household.defaultLocale } pendingMembers.set(input.telegramUserId, record) return record @@ -86,6 +91,8 @@ function createRepositoryStub() { householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, + preferredLocale: input.preferredLocale ?? null, + householdDefaultLocale: household.defaultLocale, isAdmin: input.isAdmin === true } members.set(input.telegramUserId, record) @@ -110,10 +117,25 @@ function createRepositoryStub() { householdId: pending.householdId, telegramUserId: pending.telegramUserId, displayName: pending.displayName, + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, isAdmin: input.isAdmin === true } members.set(member.telegramUserId, member) return member + }, + updateHouseholdDefaultLocale: async (_householdId, locale) => ({ + ...household, + defaultLocale: locale + }), + updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) => { + const member = members.get(telegramUserId) + return member + ? { + ...member, + preferredLocale: locale + } + : null } } @@ -144,7 +166,8 @@ describe('createHouseholdAdminService', () => { telegramUserId: '2', displayName: 'Alice', username: 'alice', - languageCode: 'en' + languageCode: 'en', + householdDefaultLocale: 'ru' } ]) }) @@ -182,6 +205,8 @@ describe('createHouseholdAdminService', () => { householdId: 'household-1', telegramUserId: '2', displayName: 'Alice', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: false } }) diff --git a/packages/application/src/household-onboarding-service.test.ts b/packages/application/src/household-onboarding-service.test.ts index 74027a1..c4bfbfb 100644 --- a/packages/application/src/household-onboarding-service.test.ts +++ b/packages/application/src/household-onboarding-service.test.ts @@ -17,7 +17,8 @@ function createRepositoryStub() { householdName: 'Kojori House', telegramChatId: '-100123', telegramChatType: 'supergroup', - title: 'Kojori House' + title: 'Kojori House', + defaultLocale: 'ru' } let joinToken: HouseholdJoinTokenRecord | null = null const pendingMembers = new Map() @@ -76,7 +77,8 @@ function createRepositoryStub() { telegramUserId: input.telegramUserId, displayName: input.displayName, username: input.username?.trim() || null, - languageCode: input.languageCode?.trim() || null + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: household.defaultLocale } pendingMembers.set(input.telegramUserId, record) return record @@ -93,6 +95,8 @@ function createRepositoryStub() { householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, + preferredLocale: input.preferredLocale ?? null, + householdDefaultLocale: household.defaultLocale, isAdmin: input.isAdmin === true } members.set(input.telegramUserId, member) @@ -124,8 +128,25 @@ function createRepositoryStub() { householdId: pending.householdId, telegramUserId: pending.telegramUserId, displayName: pending.displayName, + preferredLocale: null, + householdDefaultLocale: household.defaultLocale, isAdmin: input.isAdmin === true } + }, + async updateHouseholdDefaultLocale(_householdId, locale) { + return { + ...household, + defaultLocale: locale + } + }, + async updateMemberPreferredLocale(_householdId, telegramUserId, locale) { + const member = members.get(telegramUserId) + return member + ? { + ...member, + preferredLocale: locale + } + : null } } @@ -176,7 +197,8 @@ describe('createHouseholdOnboardingService', () => { status: 'join_required', household: { id: 'household-1', - name: 'Kojori House' + name: 'Kojori House', + defaultLocale: 'ru' } }) }) @@ -204,7 +226,8 @@ describe('createHouseholdOnboardingService', () => { status: 'pending', household: { id: 'household-1', - name: 'Kojori House' + name: 'Kojori House', + defaultLocale: 'ru' } }) @@ -219,7 +242,8 @@ describe('createHouseholdOnboardingService', () => { status: 'pending', household: { id: 'household-1', - name: 'Kojori House' + name: 'Kojori House', + defaultLocale: 'ru' } }) }) @@ -250,6 +274,8 @@ describe('createHouseholdOnboardingService', () => { id: 'member-42', householdId: 'household-1', displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: true } }) @@ -262,6 +288,8 @@ describe('createHouseholdOnboardingService', () => { householdId: 'household-1', telegramUserId: '42', displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: true } const service = createHouseholdOnboardingService({ repository }) @@ -277,6 +305,8 @@ describe('createHouseholdOnboardingService', () => { householdId: 'household-2', telegramUserId: '42', displayName: 'Stan elsewhere', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: false } ] diff --git a/packages/application/src/household-onboarding-service.ts b/packages/application/src/household-onboarding-service.ts index 1373654..5957a19 100644 --- a/packages/application/src/household-onboarding-service.ts +++ b/packages/application/src/household-onboarding-service.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto' +import type { SupportedLocale } from '@household/domain' import type { HouseholdConfigurationRepository, HouseholdMemberRecord } from '@household/ports' export interface HouseholdOnboardingIdentity { @@ -17,6 +18,8 @@ export type HouseholdMiniAppAccess = householdId: string displayName: string isAdmin: boolean + preferredLocale: SupportedLocale | null + householdDefaultLocale: SupportedLocale } } | { @@ -24,6 +27,7 @@ export type HouseholdMiniAppAccess = household: { id: string name: string + defaultLocale: SupportedLocale } } | { @@ -31,6 +35,7 @@ export type HouseholdMiniAppAccess = household: { id: string name: string + defaultLocale: SupportedLocale } } | { @@ -53,6 +58,7 @@ export interface HouseholdOnboardingService { household: { id: string name: string + defaultLocale: SupportedLocale } } | { @@ -62,6 +68,8 @@ export interface HouseholdOnboardingService { householdId: string displayName: string isAdmin: boolean + preferredLocale: SupportedLocale | null + householdDefaultLocale: SupportedLocale } } | { @@ -75,12 +83,16 @@ function toMember(member: HouseholdMemberRecord): { householdId: string displayName: string isAdmin: boolean + preferredLocale: SupportedLocale | null + householdDefaultLocale: SupportedLocale } { return { id: member.id, householdId: member.householdId, displayName: member.displayName, - isAdmin: member.isAdmin + isAdmin: member.isAdmin, + preferredLocale: member.preferredLocale, + householdDefaultLocale: member.householdDefaultLocale } } @@ -155,7 +167,8 @@ export function createHouseholdOnboardingService(options: { status: 'pending', household: { id: existingPending.householdId, - name: existingPending.householdName + name: existingPending.householdName, + defaultLocale: existingPending.householdDefaultLocale } } } @@ -182,7 +195,8 @@ export function createHouseholdOnboardingService(options: { status: 'pending', household: { id: pending.householdId, - name: pending.householdName + name: pending.householdName, + defaultLocale: pending.householdDefaultLocale } } } @@ -191,7 +205,8 @@ export function createHouseholdOnboardingService(options: { status: 'join_required', household: { id: household.householdId, - name: household.householdName + name: household.householdName, + defaultLocale: household.defaultLocale } } }, @@ -235,7 +250,8 @@ export function createHouseholdOnboardingService(options: { status: 'pending', household: { id: pending.householdId, - name: pending.householdName + name: pending.householdName, + defaultLocale: pending.householdDefaultLocale } } } diff --git a/packages/application/src/household-setup-service.test.ts b/packages/application/src/household-setup-service.test.ts index 6ab3cdb..5215ad1 100644 --- a/packages/application/src/household-setup-service.test.ts +++ b/packages/application/src/household-setup-service.test.ts @@ -39,7 +39,8 @@ function createRepositoryStub() { householdName: input.householdName, telegramChatId: input.telegramChatId, telegramChatType: input.telegramChatType, - title: input.title?.trim() || null + title: input.title?.trim() || null, + defaultLocale: 'ru' } households.set(input.telegramChatId, created) @@ -141,7 +142,8 @@ function createRepositoryStub() { telegramUserId: input.telegramUserId, displayName: input.displayName, username: input.username?.trim() || null, - languageCode: input.languageCode?.trim() || null + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: household.defaultLocale } pendingMembers.set(key, record) return record @@ -166,6 +168,10 @@ function createRepositoryStub() { householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, + preferredLocale: input.preferredLocale ?? existing?.preferredLocale ?? null, + householdDefaultLocale: + [...households.values()].find((household) => household.householdId === input.householdId) + ?.defaultLocale ?? 'ru', isAdmin: input.isAdmin === true || existing?.isAdmin === true } members.set(key, next) @@ -202,10 +208,39 @@ function createRepositoryStub() { householdId: pending.householdId, telegramUserId: pending.telegramUserId, displayName: pending.displayName, + preferredLocale: null, + householdDefaultLocale: + [...households.values()].find( + (household) => household.householdId === pending.householdId + )?.defaultLocale ?? 'ru', isAdmin: input.isAdmin === true } members.set(key, member) return member + }, + async updateHouseholdDefaultLocale(householdId, locale) { + const household = + [...households.values()].find((entry) => entry.householdId === householdId) ?? null + if (!household) { + throw new Error('Missing household') + } + + const next = { + ...household, + defaultLocale: locale + } + households.set(next.telegramChatId, next) + return next + }, + async updateMemberPreferredLocale(householdId, telegramUserId, locale) { + const key = `${householdId}:${telegramUserId}` + const member = members.get(key) + return member + ? { + ...member, + preferredLocale: locale + } + : null } } @@ -240,6 +275,8 @@ describe('createHouseholdSetupService', () => { householdId: result.household.householdId, telegramUserId: '42', displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: true }) }) @@ -275,6 +312,8 @@ describe('createHouseholdSetupService', () => { householdId: result.household.householdId, telegramUserId: '77', displayName: 'Mia', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: true }) }) diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 331439c..ba09cfb 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -19,6 +19,10 @@ export { type ReminderJobResult, type ReminderJobService } from './reminder-job-service' +export { + createLocalePreferenceService, + type LocalePreferenceService +} from './locale-preference-service' export { parsePurchaseMessage, type ParsedPurchaseResult, diff --git a/packages/application/src/locale-preference-service.test.ts b/packages/application/src/locale-preference-service.test.ts new file mode 100644 index 0000000..d695ef4 --- /dev/null +++ b/packages/application/src/locale-preference-service.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'bun:test' + +import type { HouseholdConfigurationRepository, HouseholdMemberRecord } from '@household/ports' + +import { createLocalePreferenceService } from './locale-preference-service' + +function createRepository(): HouseholdConfigurationRepository { + const household = { + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-100123', + telegramChatType: 'supergroup', + title: 'Kojori House', + defaultLocale: 'ru' as const + } + + const member: HouseholdMemberRecord = { + id: 'member-1', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', + isAdmin: true + } + + return { + registerTelegramHouseholdChat: async () => ({ status: 'existing', household }), + getTelegramHouseholdChat: async () => household, + getHouseholdChatByHouseholdId: async () => household, + bindHouseholdTopic: async (input) => ({ + householdId: input.householdId, + role: input.role, + telegramThreadId: input.telegramThreadId, + topicName: input.topicName?.trim() || null + }), + getHouseholdTopicBinding: async () => null, + findHouseholdTopicByTelegramContext: async () => null, + listHouseholdTopicBindings: async () => [], + upsertHouseholdJoinToken: async () => ({ + householdId: household.householdId, + householdName: household.householdName, + token: 'join-token', + createdByTelegramUserId: null + }), + getHouseholdJoinToken: async () => null, + getHouseholdByJoinToken: async () => household, + upsertPendingHouseholdMember: async (input) => ({ + householdId: input.householdId, + householdName: household.householdName, + telegramUserId: input.telegramUserId, + displayName: input.displayName, + username: input.username?.trim() || null, + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: 'ru' + }), + getPendingHouseholdMember: async () => null, + findPendingHouseholdMemberByTelegramUserId: async () => null, + ensureHouseholdMember: async () => member, + getHouseholdMember: async () => member, + listHouseholdMembers: async () => [member], + listHouseholdMembersByTelegramUserId: async () => [member], + listPendingHouseholdMembers: async () => [], + approvePendingHouseholdMember: async () => member, + updateHouseholdDefaultLocale: async (_householdId, locale) => ({ + ...household, + defaultLocale: locale + }), + updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) => + telegramUserId === member.telegramUserId + ? { + ...member, + preferredLocale: locale, + householdDefaultLocale: 'ru' + } + : null + } +} + +describe('createLocalePreferenceService', () => { + test('updates member locale preference', async () => { + const service = createLocalePreferenceService(createRepository()) + + const result = await service.updateMemberLocale({ + householdId: 'household-1', + telegramUserId: '123456', + locale: 'en' + }) + + expect(result).toEqual({ + status: 'updated', + member: { + householdId: 'household-1', + telegramUserId: '123456', + preferredLocale: 'en', + householdDefaultLocale: 'ru' + } + }) + }) + + test('rejects household locale update for non-admin actors', async () => { + const service = createLocalePreferenceService(createRepository()) + + const result = await service.updateHouseholdLocale({ + householdId: 'household-1', + actorIsAdmin: false, + locale: 'en' + }) + + expect(result).toEqual({ + status: 'rejected', + reason: 'not_admin' + }) + }) +}) diff --git a/packages/application/src/locale-preference-service.ts b/packages/application/src/locale-preference-service.ts new file mode 100644 index 0000000..9ea1f07 --- /dev/null +++ b/packages/application/src/locale-preference-service.ts @@ -0,0 +1,94 @@ +import type { SupportedLocale } from '@household/domain' +import type { HouseholdConfigurationRepository } from '@household/ports' + +export interface LocalePreferenceService { + updateMemberLocale(input: { + householdId: string + telegramUserId: string + locale: SupportedLocale + }): Promise< + | { + status: 'updated' + member: { + householdId: string + telegramUserId: string + preferredLocale: SupportedLocale | null + householdDefaultLocale: SupportedLocale + } + } + | { + status: 'rejected' + reason: 'member_not_found' + } + > + updateHouseholdLocale(input: { + householdId: string + actorIsAdmin: boolean + locale: SupportedLocale + }): Promise< + | { + status: 'updated' + household: { + householdId: string + defaultLocale: SupportedLocale + } + } + | { + status: 'rejected' + reason: 'not_admin' + } + > +} + +export function createLocalePreferenceService( + repository: HouseholdConfigurationRepository +): LocalePreferenceService { + return { + async updateMemberLocale(input) { + const member = await repository.updateMemberPreferredLocale( + input.householdId, + input.telegramUserId, + input.locale + ) + + if (!member) { + return { + status: 'rejected', + reason: 'member_not_found' as const + } + } + + return { + status: 'updated', + member: { + householdId: member.householdId, + telegramUserId: member.telegramUserId, + preferredLocale: member.preferredLocale, + householdDefaultLocale: member.householdDefaultLocale + } + } + }, + + async updateHouseholdLocale(input) { + if (!input.actorIsAdmin) { + return { + status: 'rejected', + reason: 'not_admin' as const + } + } + + const household = await repository.updateHouseholdDefaultLocale( + input.householdId, + input.locale + ) + + return { + status: 'updated', + household: { + householdId: household.householdId, + defaultLocale: household.defaultLocale + } + } + } + } +} diff --git a/packages/application/src/miniapp-admin-service.test.ts b/packages/application/src/miniapp-admin-service.test.ts index e29da2f..654e655 100644 --- a/packages/application/src/miniapp-admin-service.test.ts +++ b/packages/application/src/miniapp-admin-service.test.ts @@ -13,7 +13,8 @@ function repository(): HouseholdConfigurationRepository { householdName: 'Kojori House', telegramChatId: '-100123', telegramChatType: 'supergroup', - title: 'Kojori House' + title: 'Kojori House', + defaultLocale: 'ru' } }), getTelegramHouseholdChat: async () => null, @@ -41,7 +42,8 @@ function repository(): HouseholdConfigurationRepository { telegramUserId: input.telegramUserId, displayName: input.displayName, username: input.username?.trim() || null, - languageCode: input.languageCode?.trim() || null + languageCode: input.languageCode?.trim() || null, + householdDefaultLocale: 'ru' }), getPendingHouseholdMember: async () => null, findPendingHouseholdMemberByTelegramUserId: async () => null, @@ -50,6 +52,8 @@ function repository(): HouseholdConfigurationRepository { householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, + preferredLocale: input.preferredLocale ?? null, + householdDefaultLocale: 'ru', isAdmin: input.isAdmin === true }), getHouseholdMember: async () => null, @@ -62,7 +66,8 @@ function repository(): HouseholdConfigurationRepository { telegramUserId: '123456', displayName: 'Stan', username: 'stan', - languageCode: 'ru' + languageCode: 'ru', + householdDefaultLocale: 'ru' } ], approvePendingHouseholdMember: async (input) => @@ -72,6 +77,28 @@ function repository(): HouseholdConfigurationRepository { householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', + isAdmin: false + } + : null, + updateHouseholdDefaultLocale: async (_householdId, locale) => ({ + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-100123', + telegramChatType: 'supergroup', + title: 'Kojori House', + defaultLocale: locale + }), + updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) => + telegramUserId === '123456' + ? { + id: 'member-123456', + householdId: 'household-1', + telegramUserId, + displayName: 'Stan', + preferredLocale: locale, + householdDefaultLocale: 'ru', isAdmin: false } : null @@ -96,7 +123,8 @@ describe('createMiniAppAdminService', () => { telegramUserId: '123456', displayName: 'Stan', username: 'stan', - languageCode: 'ru' + languageCode: 'ru', + householdDefaultLocale: 'ru' } ] }) @@ -132,6 +160,8 @@ describe('createMiniAppAdminService', () => { householdId: 'household-1', telegramUserId: '123456', displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'ru', isAdmin: false } }) diff --git a/packages/db/drizzle/0008_lowly_spiral.sql b/packages/db/drizzle/0008_lowly_spiral.sql new file mode 100644 index 0000000..f5c88a5 --- /dev/null +++ b/packages/db/drizzle/0008_lowly_spiral.sql @@ -0,0 +1,2 @@ +ALTER TABLE "households" ADD COLUMN "default_locale" text DEFAULT 'ru' NOT NULL;--> statement-breakpoint +ALTER TABLE "members" ADD COLUMN "preferred_locale" text;--> statement-breakpoint diff --git a/packages/db/drizzle/meta/0008_snapshot.json b/packages/db/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..a9c0efa --- /dev/null +++ b/packages/db/drizzle/meta/0008_snapshot.json @@ -0,0 +1,2143 @@ +{ + "id": "e7a61264-c80f-4724-ae76-ba7530d53f16", + "prevId": "3bb36a80-a332-4340-8673-084cc470c322", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.anonymous_messages": { + "name": "anonymous_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "submitted_by_member_id": { + "name": "submitted_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sanitized_text": { + "name": "sanitized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moderation_reason": { + "name": "moderation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "posted_chat_id": { + "name": "posted_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_thread_id": { + "name": "posted_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_message_id": { + "name": "posted_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "anonymous_messages_household_tg_update_unique": { + "name": "anonymous_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_member_created_idx": { + "name": "anonymous_messages_member_created_idx", + "columns": [ + { + "expression": "submitted_by_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anonymous_messages_status_created_idx": { + "name": "anonymous_messages_status_created_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "anonymous_messages_household_id_households_id_fk": { + "name": "anonymous_messages_household_id_households_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "anonymous_messages_submitted_by_member_id_members_id_fk": { + "name": "anonymous_messages_submitted_by_member_id_members_id_fk", + "tableFrom": "anonymous_messages", + "tableTo": "members", + "columnsFrom": ["submitted_by_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_cycles": { + "name": "billing_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_cycles_household_period_unique": { + "name": "billing_cycles_household_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_cycles_household_period_idx": { + "name": "billing_cycles_household_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_cycles_household_id_households_id_fk": { + "name": "billing_cycles_household_id_households_id_fk", + "tableFrom": "billing_cycles", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_join_tokens": { + "name": "household_join_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_telegram_user_id": { + "name": "created_by_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_join_tokens_household_unique": { + "name": "household_join_tokens_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_join_tokens_token_unique": { + "name": "household_join_tokens_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_join_tokens_household_id_households_id_fk": { + "name": "household_join_tokens_household_id_households_id_fk", + "tableFrom": "household_join_tokens", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_pending_members": { + "name": "household_pending_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_pending_members_household_user_unique": { + "name": "household_pending_members_household_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_pending_members_telegram_user_idx": { + "name": "household_pending_members_telegram_user_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_pending_members_household_id_households_id_fk": { + "name": "household_pending_members_household_id_households_id_fk", + "tableFrom": "household_pending_members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_telegram_chats": { + "name": "household_telegram_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_type": { + "name": "telegram_chat_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_telegram_chats_household_unique": { + "name": "household_telegram_chats_household_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_telegram_chats_chat_unique": { + "name": "household_telegram_chats_chat_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_telegram_chats_household_id_households_id_fk": { + "name": "household_telegram_chats_household_id_households_id_fk", + "tableFrom": "household_telegram_chats", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_topic_bindings": { + "name": "household_topic_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_name": { + "name": "topic_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_topic_bindings_household_role_unique": { + "name": "household_topic_bindings_household_role_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_thread_unique": { + "name": "household_topic_bindings_household_thread_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "household_topic_bindings_household_role_idx": { + "name": "household_topic_bindings_household_role_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_topic_bindings_household_id_households_id_fk": { + "name": "household_topic_bindings_household_id_households_id_fk", + "tableFrom": "household_topic_bindings", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_household_idx": { + "name": "members_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_household_tg_user_unique": { + "name": "members_household_tg_user_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_household_id_households_id_fk": { + "name": "members_household_id_households_id_fk", + "tableFrom": "members", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.presence_overrides": { + "name": "presence_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "utility_days": { + "name": "utility_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "presence_overrides_cycle_member_unique": { + "name": "presence_overrides_cycle_member_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presence_overrides_cycle_idx": { + "name": "presence_overrides_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "presence_overrides_cycle_id_billing_cycles_id_fk": { + "name": "presence_overrides_cycle_id_billing_cycles_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "presence_overrides_member_id_members_id_fk": { + "name": "presence_overrides_member_id_members_id_fk", + "tableFrom": "presence_overrides", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_bot_messages": { + "name": "processed_bot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_key": { + "name": "source_message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "processed_bot_messages_source_message_unique": { + "name": "processed_bot_messages_source_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "processed_bot_messages_household_id_households_id_fk": { + "name": "processed_bot_messages_household_id_households_id_fk", + "tableFrom": "processed_bot_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_entries": { + "name": "purchase_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payer_member_id": { + "name": "payer_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_entries_household_cycle_idx": { + "name": "purchase_entries_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_payer_idx": { + "name": "purchase_entries_payer_idx", + "columns": [ + { + "expression": "payer_member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_entries_household_tg_message_unique": { + "name": "purchase_entries_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_entries_household_id_households_id_fk": { + "name": "purchase_entries_household_id_households_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_entries_cycle_id_billing_cycles_id_fk": { + "name": "purchase_entries_cycle_id_billing_cycles_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "purchase_entries_payer_member_id_members_id_fk": { + "name": "purchase_entries_payer_member_id_members_id_fk", + "tableFrom": "purchase_entries", + "tableTo": "members", + "columnsFrom": ["payer_member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.purchase_messages": { + "name": "purchase_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_member_id": { + "name": "sender_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_telegram_user_id": { + "name": "sender_telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_message_id": { + "name": "telegram_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_thread_id": { + "name": "telegram_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_update_id": { + "name": "telegram_update_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_sent_at": { + "name": "message_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parsed_amount_minor": { + "name": "parsed_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "parsed_currency": { + "name": "parsed_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_item_description": { + "name": "parsed_item_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_mode": { + "name": "parser_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parser_confidence": { + "name": "parser_confidence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "needs_review": { + "name": "needs_review", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "parser_error": { + "name": "parser_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "ingested_at": { + "name": "ingested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "purchase_messages_household_thread_idx": { + "name": "purchase_messages_household_thread_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_sender_idx": { + "name": "purchase_messages_sender_idx", + "columns": [ + { + "expression": "sender_telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_message_unique": { + "name": "purchase_messages_household_tg_message_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "purchase_messages_household_tg_update_unique": { + "name": "purchase_messages_household_tg_update_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "purchase_messages_household_id_households_id_fk": { + "name": "purchase_messages_household_id_households_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "purchase_messages_sender_member_id_members_id_fk": { + "name": "purchase_messages_sender_member_id_members_id_fk", + "tableFrom": "purchase_messages", + "tableTo": "members", + "columnsFrom": ["sender_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rent_rules": { + "name": "rent_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_from_period": { + "name": "effective_from_period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_to_period": { + "name": "effective_to_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rent_rules_household_from_period_unique": { + "name": "rent_rules_household_from_period_unique", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rent_rules_household_from_period_idx": { + "name": "rent_rules_household_from_period_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_from_period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rent_rules_household_id_households_id_fk": { + "name": "rent_rules_household_id_households_id_fk", + "tableFrom": "rent_rules", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlement_lines": { + "name": "settlement_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "settlement_id": { + "name": "settlement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rent_share_minor": { + "name": "rent_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "utility_share_minor": { + "name": "utility_share_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "purchase_offset_minor": { + "name": "purchase_offset_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "net_due_minor": { + "name": "net_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "explanations": { + "name": "explanations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settlement_lines_settlement_member_unique": { + "name": "settlement_lines_settlement_member_unique", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlement_lines_settlement_idx": { + "name": "settlement_lines_settlement_idx", + "columns": [ + { + "expression": "settlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlement_lines_settlement_id_settlements_id_fk": { + "name": "settlement_lines_settlement_id_settlements_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "settlements", + "columnsFrom": ["settlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlement_lines_member_id_members_id_fk": { + "name": "settlement_lines_member_id_members_id_fk", + "tableFrom": "settlement_lines", + "tableTo": "members", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settlements": { + "name": "settlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_due_minor": { + "name": "total_due_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "settlements_cycle_unique": { + "name": "settlements_cycle_unique", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settlements_household_computed_idx": { + "name": "settlements_household_computed_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "computed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "settlements_household_id_households_id_fk": { + "name": "settlements_household_id_households_id_fk", + "tableFrom": "settlements", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "settlements_cycle_id_billing_cycles_id_fk": { + "name": "settlements_cycle_id_billing_cycles_id_fk", + "tableFrom": "settlements", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_pending_actions": { + "name": "telegram_pending_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_pending_actions_chat_user_unique": { + "name": "telegram_pending_actions_chat_user_unique", + "columns": [ + { + "expression": "telegram_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "telegram_pending_actions_user_action_idx": { + "name": "telegram_pending_actions_user_action_idx", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.utility_bills": { + "name": "utility_bills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bill_name": { + "name": "bill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by_member_id": { + "name": "created_by_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "utility_bills_cycle_idx": { + "name": "utility_bills_cycle_idx", + "columns": [ + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "utility_bills_household_cycle_idx": { + "name": "utility_bills_household_cycle_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "utility_bills_household_id_households_id_fk": { + "name": "utility_bills_household_id_households_id_fk", + "tableFrom": "utility_bills", + "tableTo": "households", + "columnsFrom": ["household_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_cycle_id_billing_cycles_id_fk": { + "name": "utility_bills_cycle_id_billing_cycles_id_fk", + "tableFrom": "utility_bills", + "tableTo": "billing_cycles", + "columnsFrom": ["cycle_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "utility_bills_created_by_member_id_members_id_fk": { + "name": "utility_bills_created_by_member_id_members_id_fk", + "tableFrom": "utility_bills", + "tableTo": "members", + "columnsFrom": ["created_by_member_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index e1ba2d1..29ca766 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1773051000000, "tag": "0007_sudden_murmur", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1773047624171, + "tag": "0008_lowly_spiral", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 1af08ee..19d676b 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -15,6 +15,7 @@ import { export const households = pgTable('households', { id: uuid('id').defaultRandom().primaryKey(), name: text('name').notNull(), + defaultLocale: text('default_locale').default('ru').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() }) @@ -142,6 +143,7 @@ export const members = pgTable( .references(() => households.id, { onDelete: 'cascade' }), telegramUserId: text('telegram_user_id').notNull(), displayName: text('display_name').notNull(), + preferredLocale: text('preferred_locale'), isAdmin: integer('is_admin').default(0).notNull(), joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull() }, diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index d83f83a..876660b 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -2,6 +2,7 @@ export { BillingPeriod } from './billing-period' export { DOMAIN_ERROR_CODE, DomainError } from './errors' export { BillingCycleId, HouseholdId, MemberId, PurchaseEntryId } from './ids' export { CURRENCIES, Money } from './money' +export { normalizeSupportedLocale, SUPPORTED_LOCALES } from './locale' export { Temporal, instantFromDatabaseValue, @@ -13,6 +14,7 @@ export { nowInstant } from './time' export type { CurrencyCode } from './money' +export type { SupportedLocale } from './locale' export type { Instant } from './time' export type { SettlementInput, diff --git a/packages/domain/src/locale.ts b/packages/domain/src/locale.ts new file mode 100644 index 0000000..c909222 --- /dev/null +++ b/packages/domain/src/locale.ts @@ -0,0 +1,14 @@ +export const SUPPORTED_LOCALES = ['en', 'ru'] as const + +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number] + +export function normalizeSupportedLocale(value?: string | null): SupportedLocale | null { + const normalized = value?.trim().toLowerCase() + if (!normalized) { + return null + } + + return (SUPPORTED_LOCALES as readonly string[]).includes(normalized) + ? (normalized as SupportedLocale) + : null +} diff --git a/packages/ports/src/household-config.ts b/packages/ports/src/household-config.ts index 3c97675..beb37d2 100644 --- a/packages/ports/src/household-config.ts +++ b/packages/ports/src/household-config.ts @@ -1,3 +1,5 @@ +import type { SupportedLocale } from '@household/domain' + export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number] @@ -8,6 +10,7 @@ export interface HouseholdTelegramChatRecord { telegramChatId: string telegramChatType: string title: string | null + defaultLocale: SupportedLocale } export interface HouseholdTopicBindingRecord { @@ -31,6 +34,7 @@ export interface HouseholdPendingMemberRecord { displayName: string username: string | null languageCode: string | null + householdDefaultLocale: SupportedLocale } export interface HouseholdMemberRecord { @@ -38,6 +42,8 @@ export interface HouseholdMemberRecord { householdId: string telegramUserId: string displayName: string + preferredLocale: SupportedLocale | null + householdDefaultLocale: SupportedLocale isAdmin: boolean } @@ -99,6 +105,7 @@ export interface HouseholdConfigurationRepository { householdId: string telegramUserId: string displayName: string + preferredLocale?: SupportedLocale | null isAdmin?: boolean }): Promise getHouseholdMember( @@ -115,4 +122,13 @@ export interface HouseholdConfigurationRepository { telegramUserId: string isAdmin?: boolean }): Promise + updateHouseholdDefaultLocale( + householdId: string, + locale: SupportedLocale + ): Promise + updateMemberPreferredLocale( + householdId: string, + telegramUserId: string, + locale: SupportedLocale + ): Promise }