feat(member): add household lifecycle states

This commit is contained in:
2026-03-11 13:44:38 +04:00
parent 015298281c
commit 773abf2531
32 changed files with 3671 additions and 38 deletions

View File

@@ -8,10 +8,12 @@ import {
type CurrencyCode
} from '@household/domain'
import {
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
HOUSEHOLD_TOPIC_ROLES,
type HouseholdBillingSettingsRecord,
type HouseholdConfigurationRepository,
type HouseholdJoinTokenRecord,
type HouseholdMemberLifecycleStatus,
type HouseholdMemberRecord,
type HouseholdPendingMemberRecord,
type HouseholdTelegramChatRecord,
@@ -32,6 +34,16 @@ function normalizeTopicRole(role: string): HouseholdTopicRole {
throw new Error(`Unsupported household topic role: ${role}`)
}
function normalizeMemberLifecycleStatus(raw: string): HouseholdMemberLifecycleStatus {
const normalized = raw.trim().toLowerCase()
if ((HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES as readonly string[]).includes(normalized)) {
return normalized as HouseholdMemberLifecycleStatus
}
throw new Error(`Unsupported household member lifecycle status: ${raw}`)
}
function toHouseholdTelegramChatRecord(row: {
householdId: string
householdName: string
@@ -113,6 +125,7 @@ function toHouseholdMemberRecord(row: {
householdId: string
telegramUserId: string
displayName: string
lifecycleStatus: string
preferredLocale: string | null
defaultLocale: string
rentShareWeight: number
@@ -128,6 +141,7 @@ function toHouseholdMemberRecord(row: {
householdId: row.householdId,
telegramUserId: row.telegramUserId,
displayName: row.displayName,
status: normalizeMemberLifecycleStatus(row.lifecycleStatus),
preferredLocale: normalizeSupportedLocale(row.preferredLocale),
householdDefaultLocale,
rentShareWeight: row.rentShareWeight,
@@ -775,6 +789,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
lifecycleStatus: input.status ?? 'active',
preferredLocale: input.preferredLocale ?? null,
rentShareWeight: input.rentShareWeight ?? 1,
isAdmin: input.isAdmin ? 1 : 0
@@ -783,6 +798,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
target: [schema.members.householdId, schema.members.telegramUserId],
set: {
displayName: input.displayName,
lifecycleStatus: input.status ?? schema.members.lifecycleStatus,
preferredLocale: input.preferredLocale ?? schema.members.preferredLocale,
rentShareWeight: input.rentShareWeight ?? schema.members.rentShareWeight,
...(input.isAdmin
@@ -797,6 +813,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
lifecycleStatus: schema.members.lifecycleStatus,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin
@@ -825,6 +842,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
lifecycleStatus: schema.members.lifecycleStatus,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
defaultLocale: schema.households.defaultLocale,
@@ -851,6 +869,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
lifecycleStatus: schema.members.lifecycleStatus,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
defaultLocale: schema.households.defaultLocale,
@@ -1033,6 +1052,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
lifecycleStatus: schema.members.lifecycleStatus,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
defaultLocale: schema.households.defaultLocale,
@@ -1104,6 +1124,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
householdId: pending.householdId,
telegramUserId: pending.telegramUserId,
displayName: pending.displayName,
lifecycleStatus: 'active',
preferredLocale: normalizeSupportedLocale(pending.languageCode),
rentShareWeight: 1,
isAdmin: input.isAdmin ? 1 : 0
@@ -1112,6 +1133,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
target: [schema.members.householdId, schema.members.telegramUserId],
set: {
displayName: pending.displayName,
lifecycleStatus: 'active',
preferredLocale:
normalizeSupportedLocale(pending.languageCode) ?? schema.members.preferredLocale,
...(input.isAdmin
@@ -1126,6 +1148,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
lifecycleStatus: schema.members.lifecycleStatus,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin
@@ -1198,6 +1221,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
lifecycleStatus: schema.members.lifecycleStatus,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin
@@ -1231,6 +1255,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
lifecycleStatus: schema.members.lifecycleStatus,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin
@@ -1264,6 +1289,7 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
lifecycleStatus: schema.members.lifecycleStatus,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin
@@ -1279,6 +1305,40 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
throw new Error('Failed to resolve household chat after rent weight update')
}
return toHouseholdMemberRecord({
...row,
defaultLocale: household.defaultLocale
})
},
async updateHouseholdMemberStatus(householdId, memberId, status) {
const rows = await db
.update(schema.members)
.set({
lifecycleStatus: status
})
.where(and(eq(schema.members.householdId, householdId), eq(schema.members.id, memberId)))
.returning({
id: schema.members.id,
householdId: schema.members.householdId,
telegramUserId: schema.members.telegramUserId,
displayName: schema.members.displayName,
lifecycleStatus: schema.members.lifecycleStatus,
preferredLocale: schema.members.preferredLocale,
rentShareWeight: schema.members.rentShareWeight,
isAdmin: schema.members.isAdmin
})
const row = rows[0]
if (!row) {
return null
}
const household = await this.getHouseholdChatByHouseholdId(householdId)
if (!household) {
throw new Error('Failed to resolve household chat after member status update')
}
return toHouseholdMemberRecord({
...row,
defaultLocale: household.defaultLocale

View File

@@ -28,6 +28,7 @@ function createRepositoryStub() {
householdId: household.householdId,
telegramUserId: '1',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
@@ -94,6 +95,7 @@ function createRepositoryStub() {
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
status: input.status ?? 'active',
preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
@@ -121,6 +123,7 @@ function createRepositoryStub() {
householdId: pending.householdId,
telegramUserId: pending.telegramUserId,
displayName: pending.displayName,
status: 'active',
preferredLocale: null,
householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
@@ -174,7 +177,8 @@ function createRepositoryStub() {
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null
}
return {
@@ -243,6 +247,7 @@ describe('createHouseholdAdminService', () => {
householdId: 'household-1',
telegramUserId: '2',
displayName: 'Alice',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,

View File

@@ -99,6 +99,7 @@ function createRepositoryStub() {
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
status: input.status ?? 'active',
preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
@@ -133,6 +134,7 @@ function createRepositoryStub() {
householdId: pending.householdId,
telegramUserId: pending.telegramUserId,
displayName: pending.displayName,
status: 'active',
preferredLocale: null,
householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
@@ -198,6 +200,9 @@ function createRepositoryStub() {
},
async updateHouseholdMemberRentShareWeight() {
return null
},
async updateHouseholdMemberStatus() {
return null
}
}
@@ -325,6 +330,7 @@ describe('createHouseholdOnboardingService', () => {
id: 'member-42',
householdId: 'household-1',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
@@ -340,6 +346,7 @@ describe('createHouseholdOnboardingService', () => {
householdId: 'household-1',
telegramUserId: '42',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
@@ -358,6 +365,7 @@ describe('createHouseholdOnboardingService', () => {
householdId: 'household-2',
telegramUserId: '42',
displayName: 'Stan elsewhere',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,

View File

@@ -17,6 +17,7 @@ export type HouseholdMiniAppAccess =
id: string
householdId: string
displayName: string
status: HouseholdMemberRecord['status']
isAdmin: boolean
preferredLocale: SupportedLocale | null
householdDefaultLocale: SupportedLocale
@@ -68,6 +69,7 @@ export interface HouseholdOnboardingService {
id: string
householdId: string
displayName: string
status: HouseholdMemberRecord['status']
isAdmin: boolean
preferredLocale: SupportedLocale | null
householdDefaultLocale: SupportedLocale
@@ -84,6 +86,7 @@ function toMember(member: HouseholdMemberRecord): {
id: string
householdId: string
displayName: string
status: HouseholdMemberRecord['status']
isAdmin: boolean
preferredLocale: SupportedLocale | null
householdDefaultLocale: SupportedLocale
@@ -93,6 +96,7 @@ function toMember(member: HouseholdMemberRecord): {
id: member.id,
householdId: member.householdId,
displayName: member.displayName,
status: member.status,
isAdmin: member.isAdmin,
preferredLocale: member.preferredLocale,
householdDefaultLocale: member.householdDefaultLocale,

View File

@@ -174,6 +174,7 @@ function createRepositoryStub() {
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
status: input.status ?? existing?.status ?? 'active',
preferredLocale: input.preferredLocale ?? existing?.preferredLocale ?? null,
householdDefaultLocale:
[...households.values()].find((household) => household.householdId === input.householdId)
@@ -215,6 +216,7 @@ function createRepositoryStub() {
householdId: pending.householdId,
telegramUserId: pending.telegramUserId,
displayName: pending.displayName,
status: 'active',
preferredLocale: null,
householdDefaultLocale:
[...households.values()].find(
@@ -319,6 +321,22 @@ function createRepositoryStub() {
}
members.set(`${householdId}:${member.telegramUserId}`, next)
return next
},
async updateHouseholdMemberStatus(householdId, memberId, status) {
const member = [...members.values()].find(
(entry) => entry.householdId === householdId && entry.id === memberId
)
if (!member) {
return null
}
const next = {
...member,
status
}
members.set(`${householdId}:${member.telegramUserId}`, next)
return next
}
}
@@ -353,6 +371,7 @@ describe('createHouseholdSetupService', () => {
householdId: result.household.householdId,
telegramUserId: '42',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
@@ -391,6 +410,7 @@ describe('createHouseholdSetupService', () => {
householdId: result.household.householdId,
telegramUserId: '77',
displayName: 'Mia',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,

View File

@@ -19,6 +19,7 @@ function createRepository(): HouseholdConfigurationRepository {
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
@@ -109,7 +110,8 @@ function createRepository(): HouseholdConfigurationRepository {
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null
}
}

View File

@@ -61,6 +61,7 @@ function repository(): HouseholdConfigurationRepository {
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: input.displayName,
status: input.status ?? 'active',
preferredLocale: input.preferredLocale ?? null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
@@ -87,6 +88,7 @@ function repository(): HouseholdConfigurationRepository {
householdId: input.householdId,
telegramUserId: input.telegramUserId,
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
@@ -108,6 +110,7 @@ function repository(): HouseholdConfigurationRepository {
householdId: 'household-1',
telegramUserId,
displayName: 'Stan',
status: 'active',
preferredLocale: locale,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
@@ -152,6 +155,7 @@ function repository(): HouseholdConfigurationRepository {
householdId,
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
@@ -165,11 +169,26 @@ function repository(): HouseholdConfigurationRepository {
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight,
isAdmin: false
}
: null,
updateHouseholdMemberStatus: async (_householdId, memberId, status) =>
memberId === 'member-123456'
? {
id: memberId,
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status,
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
: null
}
}
@@ -318,6 +337,7 @@ describe('createMiniAppAdminService', () => {
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
@@ -342,6 +362,7 @@ describe('createMiniAppAdminService', () => {
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
@@ -349,4 +370,30 @@ describe('createMiniAppAdminService', () => {
}
})
})
test('updates a household member lifecycle status for admins', async () => {
const service = createMiniAppAdminService(repository())
const result = await service.updateMemberStatus({
householdId: 'household-1',
actorIsAdmin: true,
memberId: 'member-123456',
status: 'away'
})
expect(result).toEqual({
status: 'ok',
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'away',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
})
})
})

View File

@@ -1,6 +1,7 @@
import type {
HouseholdBillingSettingsRecord,
HouseholdConfigurationRepository,
HouseholdMemberLifecycleStatus,
HouseholdMemberRecord,
HouseholdPendingMemberRecord,
HouseholdTopicBindingRecord,
@@ -126,6 +127,21 @@ export interface MiniAppAdminService {
reason: 'not_admin' | 'invalid_weight' | 'member_not_found'
}
>
updateMemberStatus(input: {
householdId: string
actorIsAdmin: boolean
memberId: string
status: HouseholdMemberLifecycleStatus
}): Promise<
| {
status: 'ok'
member: HouseholdMemberRecord
}
| {
status: 'rejected'
reason: 'not_admin' | 'member_not_found'
}
>
}
export function createMiniAppAdminService(
@@ -349,6 +365,32 @@ export function createMiniAppAdminService(
}
}
return {
status: 'ok',
member
}
},
async updateMemberStatus(input) {
if (!input.actorIsAdmin) {
return {
status: 'rejected',
reason: 'not_admin'
}
}
const member = await repository.updateHouseholdMemberStatus(
input.householdId,
input.memberId,
input.status
)
if (!member) {
return {
status: 'rejected',
reason: 'member_not_found'
}
}
return {
status: 'ok',
member

View File

@@ -0,0 +1 @@
ALTER TABLE "members" ADD COLUMN "lifecycle_status" text DEFAULT 'active' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,13 @@
"when": 1773147481265,
"tag": "0013_wild_avengers",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1773222186943,
"tag": "0014_empty_risque",
"breakpoints": true
}
]
}

View File

@@ -194,6 +194,7 @@ export const members = pgTable(
.references(() => households.id, { onDelete: 'cascade' }),
telegramUserId: text('telegram_user_id').notNull(),
displayName: text('display_name').notNull(),
lifecycleStatus: text('lifecycle_status').default('active').notNull(),
preferredLocale: text('preferred_locale'),
rentShareWeight: integer('rent_share_weight').default(1).notNull(),
isAdmin: integer('is_admin').default(0).notNull(),

View File

@@ -2,8 +2,10 @@ import type { CurrencyCode, SupportedLocale } from '@household/domain'
import type { ReminderTarget } from './reminders'
export const HOUSEHOLD_TOPIC_ROLES = ['purchase', 'feedback', 'reminders', 'payments'] as const
export const HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES = ['active', 'away', 'left'] as const
export type HouseholdTopicRole = (typeof HOUSEHOLD_TOPIC_ROLES)[number]
export type HouseholdMemberLifecycleStatus = (typeof HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES)[number]
export interface HouseholdTelegramChatRecord {
householdId: string
@@ -43,6 +45,7 @@ export interface HouseholdMemberRecord {
householdId: string
telegramUserId: string
displayName: string
status: HouseholdMemberLifecycleStatus
preferredLocale: SupportedLocale | null
householdDefaultLocale: SupportedLocale
rentShareWeight: number
@@ -130,6 +133,7 @@ export interface HouseholdConfigurationRepository {
householdId: string
telegramUserId: string
displayName: string
status?: HouseholdMemberLifecycleStatus
preferredLocale?: SupportedLocale | null
rentShareWeight?: number
isAdmin?: boolean
@@ -188,4 +192,9 @@ export interface HouseholdConfigurationRepository {
memberId: string,
rentShareWeight: number
): Promise<HouseholdMemberRecord | null>
updateHouseholdMemberStatus(
householdId: string,
memberId: string,
status: HouseholdMemberLifecycleStatus
): Promise<HouseholdMemberRecord | null>
}

View File

@@ -13,10 +13,12 @@ export type {
ReleaseProcessedBotMessageInput
} from './processed-bot-messages'
export {
HOUSEHOLD_MEMBER_LIFECYCLE_STATUSES,
HOUSEHOLD_TOPIC_ROLES,
type HouseholdConfigurationRepository,
type HouseholdBillingSettingsRecord,
type HouseholdJoinTokenRecord,
type HouseholdMemberLifecycleStatus,
type HouseholdMemberRecord,
type HouseholdPendingMemberRecord,
type HouseholdTelegramChatRecord,