mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 00:24:03 +00:00
feat(bot): add multi-household setup flow
This commit is contained in:
102
packages/adapters-db/src/household-config-repository.test.ts
Normal file
102
packages/adapters-db/src/household-config-repository.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { afterAll, describe, expect, test } from 'bun:test'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
|
||||
import { createDbClient, schema } from '@household/db'
|
||||
|
||||
import { createDbHouseholdConfigurationRepository } from './household-config-repository'
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
const testIfDatabase = databaseUrl ? test : test.skip
|
||||
|
||||
describe('createDbHouseholdConfigurationRepository', () => {
|
||||
const createdHouseholdIds: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
if (!databaseUrl || createdHouseholdIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const { db, queryClient } = createDbClient(databaseUrl, {
|
||||
max: 1,
|
||||
prepare: false
|
||||
})
|
||||
|
||||
await db.delete(schema.households).where(inArray(schema.households.id, createdHouseholdIds))
|
||||
await queryClient.end({ timeout: 5 })
|
||||
})
|
||||
|
||||
testIfDatabase('registers a Telegram household chat and binds topics', async () => {
|
||||
const repositoryClient = createDbHouseholdConfigurationRepository(databaseUrl!)
|
||||
const suffix = randomUUID()
|
||||
const telegramChatId = `-100${Date.now()}`
|
||||
|
||||
const registered = await repositoryClient.repository.registerTelegramHouseholdChat({
|
||||
householdName: `Integration Household ${suffix}`,
|
||||
telegramChatId,
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Integration Household'
|
||||
})
|
||||
|
||||
createdHouseholdIds.push(registered.household.householdId)
|
||||
|
||||
expect(registered.status).toBe('created')
|
||||
expect(registered.household.telegramChatId).toBe(telegramChatId)
|
||||
|
||||
const existing = await repositoryClient.repository.registerTelegramHouseholdChat({
|
||||
householdName: 'Ignored replacement title',
|
||||
telegramChatId,
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Updated Integration Household'
|
||||
})
|
||||
|
||||
expect(existing.status).toBe('existing')
|
||||
expect(existing.household.householdId).toBe(registered.household.householdId)
|
||||
expect(existing.household.title).toBe('Updated Integration Household')
|
||||
|
||||
const purchase = await repositoryClient.repository.bindHouseholdTopic({
|
||||
householdId: registered.household.householdId,
|
||||
role: 'purchase',
|
||||
telegramThreadId: '7001',
|
||||
topicName: 'Общие покупки'
|
||||
})
|
||||
|
||||
const feedback = await repositoryClient.repository.bindHouseholdTopic({
|
||||
householdId: registered.household.householdId,
|
||||
role: 'feedback',
|
||||
telegramThreadId: '7002',
|
||||
topicName: 'Feedback'
|
||||
})
|
||||
|
||||
expect(purchase.role).toBe('purchase')
|
||||
expect(feedback.role).toBe('feedback')
|
||||
|
||||
const resolvedChat = await repositoryClient.repository.getTelegramHouseholdChat(telegramChatId)
|
||||
const resolvedPurchase = await repositoryClient.repository.findHouseholdTopicByTelegramContext({
|
||||
telegramChatId,
|
||||
telegramThreadId: '7001'
|
||||
})
|
||||
const bindings = await repositoryClient.repository.listHouseholdTopicBindings(
|
||||
registered.household.householdId
|
||||
)
|
||||
|
||||
expect(resolvedChat?.householdId).toBe(registered.household.householdId)
|
||||
expect(resolvedPurchase?.role).toBe('purchase')
|
||||
expect(bindings).toHaveLength(2)
|
||||
|
||||
const verificationClient = createDbClient(databaseUrl!, {
|
||||
max: 1,
|
||||
prepare: false
|
||||
})
|
||||
const storedChatRows = await verificationClient.db
|
||||
.select({ title: schema.householdTelegramChats.title })
|
||||
.from(schema.householdTelegramChats)
|
||||
.where(eq(schema.householdTelegramChats.telegramChatId, telegramChatId))
|
||||
.limit(1)
|
||||
|
||||
expect(storedChatRows[0]?.title).toBe('Updated Integration Household')
|
||||
|
||||
await verificationClient.queryClient.end({ timeout: 5 })
|
||||
await repositoryClient.close()
|
||||
})
|
||||
})
|
||||
273
packages/adapters-db/src/household-config-repository.ts
Normal file
273
packages/adapters-db/src/household-config-repository.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
|
||||
import { createDbClient, schema } from '@household/db'
|
||||
import {
|
||||
HOUSEHOLD_TOPIC_ROLES,
|
||||
type HouseholdConfigurationRepository,
|
||||
type HouseholdTelegramChatRecord,
|
||||
type HouseholdTopicBindingRecord,
|
||||
type HouseholdTopicRole,
|
||||
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
|
||||
}): HouseholdTelegramChatRecord {
|
||||
return {
|
||||
householdId: row.householdId,
|
||||
householdName: row.householdName,
|
||||
telegramChatId: row.telegramChatId,
|
||||
telegramChatType: row.telegramChatType,
|
||||
title: row.title
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
||||
repository: HouseholdConfigurationRepository
|
||||
close: () => Promise<void>
|
||||
} {
|
||||
const { db, queryClient } = createDbClient(databaseUrl, {
|
||||
max: 5,
|
||||
prepare: false
|
||||
})
|
||||
|
||||
const repository: HouseholdConfigurationRepository = {
|
||||
async registerTelegramHouseholdChat(input) {
|
||||
return await db.transaction(async (tx): Promise<RegisterTelegramHouseholdChatResult> => {
|
||||
const existingRows = await tx
|
||||
.select({
|
||||
householdId: schema.householdTelegramChats.householdId,
|
||||
householdName: schema.households.name,
|
||||
telegramChatId: schema.householdTelegramChats.telegramChatId,
|
||||
telegramChatType: schema.householdTelegramChats.telegramChatType,
|
||||
title: schema.householdTelegramChats.title
|
||||
})
|
||||
.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: new Date()
|
||||
})
|
||||
.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
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
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
|
||||
})
|
||||
.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 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: new Date()
|
||||
}
|
||||
})
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repository,
|
||||
close: async () => {
|
||||
await queryClient.end({ timeout: 5 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository'
|
||||
export { createDbFinanceRepository } from './finance-repository'
|
||||
export { createDbHouseholdConfigurationRepository } from './household-config-repository'
|
||||
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
|
||||
|
||||
193
packages/application/src/household-setup-service.test.ts
Normal file
193
packages/application/src/household-setup-service.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import type {
|
||||
HouseholdConfigurationRepository,
|
||||
HouseholdTelegramChatRecord,
|
||||
HouseholdTopicBindingRecord
|
||||
} from '@household/ports'
|
||||
|
||||
import { createHouseholdSetupService } from './household-setup-service'
|
||||
|
||||
function createRepositoryStub() {
|
||||
const households = new Map<string, HouseholdTelegramChatRecord>()
|
||||
const bindings = new Map<string, HouseholdTopicBindingRecord[]>()
|
||||
|
||||
const repository: HouseholdConfigurationRepository = {
|
||||
async registerTelegramHouseholdChat(input) {
|
||||
const existing = households.get(input.telegramChatId)
|
||||
if (existing) {
|
||||
const next = {
|
||||
...existing,
|
||||
telegramChatType: input.telegramChatType,
|
||||
title: input.title?.trim() || existing.title
|
||||
}
|
||||
households.set(input.telegramChatId, next)
|
||||
return {
|
||||
status: 'existing',
|
||||
household: next
|
||||
}
|
||||
}
|
||||
|
||||
const created: HouseholdTelegramChatRecord = {
|
||||
householdId: `household-${households.size + 1}`,
|
||||
householdName: input.householdName,
|
||||
telegramChatId: input.telegramChatId,
|
||||
telegramChatType: input.telegramChatType,
|
||||
title: input.title?.trim() || null
|
||||
}
|
||||
households.set(input.telegramChatId, created)
|
||||
|
||||
return {
|
||||
status: 'created',
|
||||
household: created
|
||||
}
|
||||
},
|
||||
|
||||
async getTelegramHouseholdChat(telegramChatId) {
|
||||
return households.get(telegramChatId) ?? null
|
||||
},
|
||||
|
||||
async bindHouseholdTopic(input) {
|
||||
const next: HouseholdTopicBindingRecord = {
|
||||
householdId: input.householdId,
|
||||
role: input.role,
|
||||
telegramThreadId: input.telegramThreadId,
|
||||
topicName: input.topicName?.trim() || null
|
||||
}
|
||||
const existing = bindings.get(input.householdId) ?? []
|
||||
const filtered = existing.filter((entry) => entry.role !== input.role)
|
||||
bindings.set(input.householdId, [...filtered, next])
|
||||
return next
|
||||
},
|
||||
|
||||
async getHouseholdTopicBinding(householdId, role) {
|
||||
return bindings.get(householdId)?.find((entry) => entry.role === role) ?? null
|
||||
},
|
||||
|
||||
async findHouseholdTopicByTelegramContext(input) {
|
||||
const household = households.get(input.telegramChatId)
|
||||
if (!household) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
bindings
|
||||
.get(household.householdId)
|
||||
?.find((entry) => entry.telegramThreadId === input.telegramThreadId) ?? null
|
||||
)
|
||||
},
|
||||
|
||||
async listHouseholdTopicBindings(householdId) {
|
||||
return bindings.get(householdId) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repository
|
||||
}
|
||||
}
|
||||
|
||||
describe('createHouseholdSetupService', () => {
|
||||
test('creates a new household chat binding for a group admin', async () => {
|
||||
const { repository } = createRepositoryStub()
|
||||
const service = createHouseholdSetupService(repository)
|
||||
|
||||
const result = await service.setupGroupChat({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
})
|
||||
|
||||
expect(result.status).toBe('created')
|
||||
if (result.status !== 'created') {
|
||||
return
|
||||
}
|
||||
expect(result.household.householdName).toBe('Kojori House')
|
||||
expect(result.household.telegramChatId).toBe('-100123')
|
||||
})
|
||||
|
||||
test('rejects setup when the actor is not a group admin', async () => {
|
||||
const { repository } = createRepositoryStub()
|
||||
const service = createHouseholdSetupService(repository)
|
||||
|
||||
const result = await service.setupGroupChat({
|
||||
actorIsAdmin: false,
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
reason: 'not_admin'
|
||||
})
|
||||
})
|
||||
|
||||
test('binds a purchase topic for an existing household', async () => {
|
||||
const { repository } = createRepositoryStub()
|
||||
const service = createHouseholdSetupService(repository)
|
||||
const setup = await service.setupGroupChat({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
})
|
||||
|
||||
expect(setup.status).toBe('created')
|
||||
|
||||
const result = await service.bindTopic({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
role: 'purchase',
|
||||
telegramThreadId: '777',
|
||||
topicName: 'Общие покупки'
|
||||
})
|
||||
|
||||
expect(result.status).toBe('bound')
|
||||
if (result.status !== 'bound') {
|
||||
return
|
||||
}
|
||||
expect(result.binding.role).toBe('purchase')
|
||||
expect(result.binding.telegramThreadId).toBe('777')
|
||||
})
|
||||
|
||||
test('rejects topic binding when the household is not set up yet', async () => {
|
||||
const { repository } = createRepositoryStub()
|
||||
const service = createHouseholdSetupService(repository)
|
||||
|
||||
const result = await service.bindTopic({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
role: 'feedback',
|
||||
telegramThreadId: '778'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
reason: 'household_not_found'
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects topic binding outside a topic thread', async () => {
|
||||
const { repository } = createRepositoryStub()
|
||||
const service = createHouseholdSetupService(repository)
|
||||
await service.setupGroupChat({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
})
|
||||
|
||||
const result = await service.bindTopic({
|
||||
actorIsAdmin: true,
|
||||
telegramChatId: '-100123',
|
||||
role: 'feedback'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
reason: 'not_topic_message'
|
||||
})
|
||||
})
|
||||
})
|
||||
133
packages/application/src/household-setup-service.ts
Normal file
133
packages/application/src/household-setup-service.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
HouseholdConfigurationRepository,
|
||||
HouseholdTelegramChatRecord,
|
||||
HouseholdTopicBindingRecord,
|
||||
HouseholdTopicRole
|
||||
} from '@household/ports'
|
||||
|
||||
export interface HouseholdSetupService {
|
||||
setupGroupChat(input: {
|
||||
actorIsAdmin: boolean
|
||||
telegramChatId: string
|
||||
telegramChatType: string
|
||||
title?: string
|
||||
householdName?: string
|
||||
}): Promise<
|
||||
| {
|
||||
status: 'created' | 'existing'
|
||||
household: HouseholdTelegramChatRecord
|
||||
}
|
||||
| {
|
||||
status: 'rejected'
|
||||
reason: 'not_admin' | 'invalid_chat_type'
|
||||
}
|
||||
>
|
||||
bindTopic(input: {
|
||||
actorIsAdmin: boolean
|
||||
telegramChatId: string
|
||||
role: HouseholdTopicRole
|
||||
telegramThreadId?: string
|
||||
topicName?: string
|
||||
}): Promise<
|
||||
| {
|
||||
status: 'bound'
|
||||
household: HouseholdTelegramChatRecord
|
||||
binding: HouseholdTopicBindingRecord
|
||||
}
|
||||
| {
|
||||
status: 'rejected'
|
||||
reason: 'not_admin' | 'household_not_found' | 'not_topic_message'
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
function isSupportedGroupChat(chatType: string): boolean {
|
||||
return chatType === 'group' || chatType === 'supergroup'
|
||||
}
|
||||
|
||||
function defaultHouseholdName(title: string | undefined, telegramChatId: string): string {
|
||||
const normalizedTitle = title?.trim()
|
||||
return normalizedTitle && normalizedTitle.length > 0
|
||||
? normalizedTitle
|
||||
: `Household ${telegramChatId}`
|
||||
}
|
||||
|
||||
export function createHouseholdSetupService(
|
||||
repository: HouseholdConfigurationRepository
|
||||
): HouseholdSetupService {
|
||||
return {
|
||||
async setupGroupChat(input) {
|
||||
if (!input.actorIsAdmin) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'not_admin'
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSupportedGroupChat(input.telegramChatType)) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'invalid_chat_type'
|
||||
}
|
||||
}
|
||||
|
||||
const registered = await repository.registerTelegramHouseholdChat({
|
||||
householdName:
|
||||
input.householdName?.trim() || defaultHouseholdName(input.title, input.telegramChatId),
|
||||
telegramChatId: input.telegramChatId,
|
||||
telegramChatType: input.telegramChatType,
|
||||
...(input.title?.trim()
|
||||
? {
|
||||
title: input.title.trim()
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
return {
|
||||
status: registered.status,
|
||||
household: registered.household
|
||||
}
|
||||
},
|
||||
|
||||
async bindTopic(input) {
|
||||
if (!input.actorIsAdmin) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'not_admin'
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.telegramThreadId) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'not_topic_message'
|
||||
}
|
||||
}
|
||||
|
||||
const household = await repository.getTelegramHouseholdChat(input.telegramChatId)
|
||||
if (!household) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'household_not_found'
|
||||
}
|
||||
}
|
||||
|
||||
const binding = await repository.bindHouseholdTopic({
|
||||
householdId: household.householdId,
|
||||
role: input.role,
|
||||
telegramThreadId: input.telegramThreadId,
|
||||
...(input.topicName?.trim()
|
||||
? {
|
||||
topicName: input.topicName.trim()
|
||||
}
|
||||
: {})
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'bound',
|
||||
household,
|
||||
binding
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
type AnonymousFeedbackSubmitResult
|
||||
} from './anonymous-feedback-service'
|
||||
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
||||
export { createHouseholdSetupService, type HouseholdSetupService } from './household-setup-service'
|
||||
export {
|
||||
createReminderJobService,
|
||||
type ReminderJobResult,
|
||||
|
||||
27
packages/db/drizzle/0005_free_kang.sql
Normal file
27
packages/db/drizzle/0005_free_kang.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE "household_telegram_chats" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"telegram_chat_id" text NOT NULL,
|
||||
"telegram_chat_type" text NOT NULL,
|
||||
"title" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "household_topic_bindings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"telegram_thread_id" text NOT NULL,
|
||||
"topic_name" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "household_telegram_chats" ADD CONSTRAINT "household_telegram_chats_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "household_topic_bindings" ADD CONSTRAINT "household_topic_bindings_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "household_telegram_chats_household_unique" ON "household_telegram_chats" USING btree ("household_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "household_telegram_chats_chat_unique" ON "household_telegram_chats" USING btree ("telegram_chat_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "household_topic_bindings_household_role_unique" ON "household_topic_bindings" USING btree ("household_id","role");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "household_topic_bindings_household_thread_unique" ON "household_topic_bindings" USING btree ("household_id","telegram_thread_id");--> statement-breakpoint
|
||||
CREATE INDEX "household_topic_bindings_household_role_idx" ON "household_topic_bindings" USING btree ("household_id","role");
|
||||
1818
packages/db/drizzle/meta/0005_snapshot.json
Normal file
1818
packages/db/drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1772995779819,
|
||||
"tag": "0004_big_ultimatum",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1773012360748,
|
||||
"tag": "0005_free_kang",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,54 @@ export const households = pgTable('households', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||
})
|
||||
|
||||
export const householdTelegramChats = pgTable(
|
||||
'household_telegram_chats',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
householdId: uuid('household_id')
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: 'cascade' }),
|
||||
telegramChatId: text('telegram_chat_id').notNull(),
|
||||
telegramChatType: text('telegram_chat_type').notNull(),
|
||||
title: text('title'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
householdUnique: uniqueIndex('household_telegram_chats_household_unique').on(table.householdId),
|
||||
chatUnique: uniqueIndex('household_telegram_chats_chat_unique').on(table.telegramChatId)
|
||||
})
|
||||
)
|
||||
|
||||
export const householdTopicBindings = pgTable(
|
||||
'household_topic_bindings',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
householdId: uuid('household_id')
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: 'cascade' }),
|
||||
role: text('role').notNull(),
|
||||
telegramThreadId: text('telegram_thread_id').notNull(),
|
||||
topicName: text('topic_name'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
householdRoleUnique: uniqueIndex('household_topic_bindings_household_role_unique').on(
|
||||
table.householdId,
|
||||
table.role
|
||||
),
|
||||
householdThreadUnique: uniqueIndex('household_topic_bindings_household_thread_unique').on(
|
||||
table.householdId,
|
||||
table.telegramThreadId
|
||||
),
|
||||
householdRoleIdx: index('household_topic_bindings_household_role_idx').on(
|
||||
table.householdId,
|
||||
table.role
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
export const members = pgTable(
|
||||
'members',
|
||||
{
|
||||
@@ -343,6 +391,8 @@ export const settlementLines = pgTable(
|
||||
)
|
||||
|
||||
export type Household = typeof households.$inferSelect
|
||||
export type HouseholdTelegramChat = typeof householdTelegramChats.$inferSelect
|
||||
export type HouseholdTopicBinding = typeof householdTopicBindings.$inferSelect
|
||||
export type Member = typeof members.$inferSelect
|
||||
export type BillingCycle = typeof billingCycles.$inferSelect
|
||||
export type UtilityBill = typeof utilityBills.$inferSelect
|
||||
|
||||
@@ -2,6 +2,8 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { createDbClient } from './client'
|
||||
import {
|
||||
billingCycles,
|
||||
householdTelegramChats,
|
||||
householdTopicBindings,
|
||||
households,
|
||||
members,
|
||||
presenceOverrides,
|
||||
@@ -68,6 +70,34 @@ async function seed(): Promise<void> {
|
||||
])
|
||||
.onConflictDoNothing()
|
||||
|
||||
await db
|
||||
.insert(householdTelegramChats)
|
||||
.values({
|
||||
householdId: FIXTURE_IDS.household,
|
||||
telegramChatId: '-1001234567890',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori Demo Household'
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
|
||||
await db
|
||||
.insert(householdTopicBindings)
|
||||
.values([
|
||||
{
|
||||
householdId: FIXTURE_IDS.household,
|
||||
role: 'purchase',
|
||||
telegramThreadId: '777',
|
||||
topicName: 'Общие покупки'
|
||||
},
|
||||
{
|
||||
householdId: FIXTURE_IDS.household,
|
||||
role: 'feedback',
|
||||
telegramThreadId: '778',
|
||||
topicName: 'Anonymous feedback'
|
||||
}
|
||||
])
|
||||
.onConflictDoNothing()
|
||||
|
||||
await db
|
||||
.insert(billingCycles)
|
||||
.values({
|
||||
@@ -212,6 +242,16 @@ async function seed(): Promise<void> {
|
||||
if (seededCycle.length === 0) {
|
||||
throw new Error('Seed verification failed: billing cycle not found')
|
||||
}
|
||||
|
||||
const seededChat = await db
|
||||
.select({ telegramChatId: householdTelegramChats.telegramChatId })
|
||||
.from(householdTelegramChats)
|
||||
.where(eq(householdTelegramChats.householdId, FIXTURE_IDS.household))
|
||||
.limit(1)
|
||||
|
||||
if (seededChat.length === 0) {
|
||||
throw new Error('Seed verification failed: Telegram household chat not found')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
52
packages/ports/src/household-config.ts
Normal file
52
packages/ports/src/household-config.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders'] as const
|
||||
|
||||
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
|
||||
|
||||
export interface HouseholdTelegramChatRecord {
|
||||
householdId: string
|
||||
householdName: string
|
||||
telegramChatId: string
|
||||
telegramChatType: string
|
||||
title: string | null
|
||||
}
|
||||
|
||||
export interface HouseholdTopicBindingRecord {
|
||||
householdId: string
|
||||
role: HouseholdTopicRole
|
||||
telegramThreadId: string
|
||||
topicName: string | null
|
||||
}
|
||||
|
||||
export interface RegisterTelegramHouseholdChatInput {
|
||||
householdName: string
|
||||
telegramChatId: string
|
||||
telegramChatType: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface RegisterTelegramHouseholdChatResult {
|
||||
status: 'created' | 'existing'
|
||||
household: HouseholdTelegramChatRecord
|
||||
}
|
||||
|
||||
export interface HouseholdConfigurationRepository {
|
||||
registerTelegramHouseholdChat(
|
||||
input: RegisterTelegramHouseholdChatInput
|
||||
): Promise<RegisterTelegramHouseholdChatResult>
|
||||
getTelegramHouseholdChat(telegramChatId: string): Promise<HouseholdTelegramChatRecord | null>
|
||||
bindHouseholdTopic(input: {
|
||||
householdId: string
|
||||
role: HouseholdTopicRole
|
||||
telegramThreadId: string
|
||||
topicName?: string
|
||||
}): Promise<HouseholdTopicBindingRecord>
|
||||
getHouseholdTopicBinding(
|
||||
householdId: string,
|
||||
role: HouseholdTopicRole
|
||||
): Promise<HouseholdTopicBindingRecord | null>
|
||||
findHouseholdTopicByTelegramContext(input: {
|
||||
telegramChatId: string
|
||||
telegramThreadId: string
|
||||
}): Promise<HouseholdTopicBindingRecord | null>
|
||||
listHouseholdTopicBindings(householdId: string): Promise<readonly HouseholdTopicBindingRecord[]>
|
||||
}
|
||||
@@ -5,6 +5,15 @@ export {
|
||||
type ReminderDispatchRepository,
|
||||
type ReminderType
|
||||
} from './reminders'
|
||||
export {
|
||||
HOUSEHOLD_TOPIC_ROLES,
|
||||
type HouseholdConfigurationRepository,
|
||||
type HouseholdTelegramChatRecord,
|
||||
type HouseholdTopicBindingRecord,
|
||||
type HouseholdTopicRole,
|
||||
type RegisterTelegramHouseholdChatInput,
|
||||
type RegisterTelegramHouseholdChatResult
|
||||
} from './household-config'
|
||||
export type {
|
||||
AnonymousFeedbackMemberRecord,
|
||||
AnonymousFeedbackModerationStatus,
|
||||
|
||||
Reference in New Issue
Block a user