refactor(db): replace legacy fixture seed

This commit is contained in:
2026-03-10 18:50:50 +04:00
parent be4d388e2f
commit 54895c0bd2
3 changed files with 328 additions and 196 deletions

View File

@@ -0,0 +1,30 @@
# Development Reset
## Warning
This workflow is destructive for development data. Do not use it against any database you care about.
## Fixture refresh
`bun run db:seed` replaces the committed fixture household with a fresh multi-household-compatible dataset.
Use it when:
- you want current demo data without wiping the entire database
- you only need the seeded fixture household reset
## Full dev reset
Use a disposable local or dev database only.
Recommended flow:
1. point `DATABASE_URL` at a disposable database
2. recreate the database or reset the schema using your database host tooling
3. run `bun run db:migrate`
4. run `bun run db:seed`
## Notes
- The committed seed reflects the current product model: multi-household setup, GEL settlement, USD-denominated rent with locked FX, topic bindings, utility categories, and payment confirmations.
- If you need historical or household-specific fixtures, create them as separate scripts instead of editing old migrations or mutating the default seed ad hoc.

View File

@@ -61,6 +61,7 @@ bun run review:coderabbit
- Drizzle config is in `packages/db/drizzle.config.ts`. - Drizzle config is in `packages/db/drizzle.config.ts`.
- Typed environment validation lives in `packages/config/src/env.ts`. - Typed environment validation lives in `packages/config/src/env.ts`.
- Copy `.env.example` to `.env` before running app/database commands. - Copy `.env.example` to `.env` before running app/database commands.
- `bun run db:seed` refreshes the committed fixture household and is destructive for previously seeded fixture rows.
- Local bot feature flags come from env presence: - Local bot feature flags come from env presence:
- finance commands require `DATABASE_URL` plus household setup in Telegram via `/setup` - finance commands require `DATABASE_URL` plus household setup in Telegram via `/setup`
- purchase ingestion requires `DATABASE_URL` plus a bound purchase topic via `/bind_purchase_topic` - purchase ingestion requires `DATABASE_URL` plus a bound purchase topic via `/bind_purchase_topic`
@@ -69,6 +70,7 @@ bun run review:coderabbit
and optionally use a dedicated reminders topic via `/bind_reminders_topic` and optionally use a dedicated reminders topic via `/bind_reminders_topic`
- mini app CORS can be constrained with `MINI_APP_ALLOWED_ORIGINS` - mini app CORS can be constrained with `MINI_APP_ALLOWED_ORIGINS`
- Migration workflow is documented in `docs/runbooks/migrations.md`. - Migration workflow is documented in `docs/runbooks/migrations.md`.
- Destructive dev reset guidance is documented in `docs/runbooks/dev-reset.md`.
- First deploy flow is documented in `docs/runbooks/first-deploy.md`. - First deploy flow is documented in `docs/runbooks/first-deploy.md`.
## CI/CD ## CI/CD

View File

@@ -1,11 +1,16 @@
import { and, eq } from 'drizzle-orm' import { eq, inArray } from 'drizzle-orm'
import { createDbClient } from './client' import { createDbClient } from './client'
import { import {
billingCycleExchangeRates,
billingCycles, billingCycles,
householdBillingSettings,
householdTelegramChats, householdTelegramChats,
householdTopicBindings, householdTopicBindings,
householdUtilityCategories,
households, households,
members, members,
paymentConfirmations,
paymentRecords,
presenceOverrides, presenceOverrides,
processedBotMessages, processedBotMessages,
purchaseEntries, purchaseEntries,
@@ -25,232 +30,327 @@ const { db, queryClient } = createDbClient(databaseUrl, {
prepare: false prepare: false
}) })
const LEGACY_FIXTURE_HOUSEHOLD_IDS = ['11111111-1111-4111-8111-111111111111'] as const
const FIXTURE_IDS = { const FIXTURE_IDS = {
household: '11111111-1111-4111-8111-111111111111', household: '9f9aa111-1111-4c11-8111-111111111111',
cycle: '22222222-2222-4222-8222-222222222222', cycle: '9f9aa222-2222-4c22-8222-222222222222',
memberAlice: '33333333-3333-4333-8333-333333333331', memberDima: '9f9aa333-3333-4c33-8333-333333333331',
memberBob: '33333333-3333-4333-8333-333333333332', memberStas: '9f9aa333-3333-4c33-8333-333333333332',
memberCarol: '33333333-3333-4333-8333-333333333333', memberIon: '9f9aa333-3333-4c33-8333-333333333333',
settlement: '44444444-4444-4444-8444-444444444444' settlement: '9f9aa444-4444-4c44-8444-444444444444',
paymentConfirmation: '9f9aa555-5555-4c55-8555-555555555555'
} as const } as const
async function seed(): Promise<void> { async function seed(): Promise<void> {
await db await db
.insert(households) .delete(households)
.values({ .where(inArray(households.id, [...LEGACY_FIXTURE_HOUSEHOLD_IDS, FIXTURE_IDS.household]))
id: FIXTURE_IDS.household,
name: 'Kojori Demo Household'
})
.onConflictDoNothing()
await db await db.insert(households).values({
.insert(members) id: FIXTURE_IDS.household,
.values([ name: 'Kojori Fixture Household',
defaultLocale: 'ru'
})
await db.insert(householdBillingSettings).values({
householdId: FIXTURE_IDS.household,
settlementCurrency: 'GEL',
rentAmountMinor: 70000n,
rentCurrency: 'USD',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi'
})
await db.insert(members).values([
{ {
id: FIXTURE_IDS.memberAlice, id: FIXTURE_IDS.memberDima,
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
telegramUserId: '10001', telegramUserId: '10001',
displayName: 'Alice', displayName: 'Dima',
preferredLocale: 'ru',
rentShareWeight: 1,
isAdmin: 1 isAdmin: 1
}, },
{ {
id: FIXTURE_IDS.memberBob, id: FIXTURE_IDS.memberStas,
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
telegramUserId: '10002', telegramUserId: '10002',
displayName: 'Bob', displayName: 'Stas',
preferredLocale: 'en',
rentShareWeight: 1,
isAdmin: 0 isAdmin: 0
}, },
{ {
id: FIXTURE_IDS.memberCarol, id: FIXTURE_IDS.memberIon,
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
telegramUserId: '10003', telegramUserId: '10003',
displayName: 'Carol', displayName: 'Ion',
preferredLocale: 'ru',
rentShareWeight: 1,
isAdmin: 0 isAdmin: 0
} }
]) ])
.onConflictDoNothing()
await db await db.insert(householdTelegramChats).values({
.insert(householdTelegramChats)
.values({
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
telegramChatId: '-1001234567890', telegramChatId: '-1003000000001',
telegramChatType: 'supergroup', telegramChatType: 'supergroup',
title: 'Kojori Demo Household' title: 'Kojori Fixture Household'
}) })
.onConflictDoNothing()
await db await db.insert(householdTopicBindings).values([
.insert(householdTopicBindings)
.values([
{ {
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
role: 'purchase', role: 'purchase',
telegramThreadId: '777', telegramThreadId: '1001',
topicName: 'Общие покупки' topicName: 'Общие покупки'
}, },
{ {
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
role: 'feedback', role: 'feedback',
telegramThreadId: '778', telegramThreadId: '1002',
topicName: 'Anonymous feedback' topicName: 'Анонимно'
},
{
householdId: FIXTURE_IDS.household,
role: 'reminders',
telegramThreadId: '1003',
topicName: 'Напоминания'
},
{
householdId: FIXTURE_IDS.household,
role: 'payments',
telegramThreadId: '1004',
topicName: 'Быт или не быт'
} }
]) ])
.onConflictDoNothing()
await db await db.insert(householdUtilityCategories).values([
.insert(billingCycles) {
.values({ householdId: FIXTURE_IDS.household,
slug: 'internet',
name: 'Internet',
sortOrder: 0,
isActive: 1
},
{
householdId: FIXTURE_IDS.household,
slug: 'gas-water',
name: 'Gas (water included)',
sortOrder: 1,
isActive: 1
},
{
householdId: FIXTURE_IDS.household,
slug: 'cleaning',
name: 'Cleaning',
sortOrder: 2,
isActive: 1
},
{
householdId: FIXTURE_IDS.household,
slug: 'electricity',
name: 'Electricity',
sortOrder: 3,
isActive: 1
}
])
await db.insert(billingCycles).values({
id: FIXTURE_IDS.cycle, id: FIXTURE_IDS.cycle,
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
period: '2026-03', period: '2026-03',
currency: 'USD' currency: 'GEL'
}) })
.onConflictDoNothing()
await db await db.insert(rentRules).values({
.insert(rentRules)
.values({
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
amountMinor: 70000n, amountMinor: 70000n,
currency: 'USD', currency: 'USD',
effectiveFromPeriod: '2026-03' effectiveFromPeriod: '2026-03'
}) })
.onConflictDoNothing()
await db await db.insert(billingCycleExchangeRates).values({
.insert(utilityBills) cycleId: FIXTURE_IDS.cycle,
.values({ sourceCurrency: 'USD',
targetCurrency: 'GEL',
rateMicros: 2760000n,
effectiveDate: '2026-03-17',
source: 'nbg'
})
await db.insert(utilityBills).values([
{
householdId: FIXTURE_IDS.household,
cycleId: FIXTURE_IDS.cycle,
billName: 'Internet',
amountMinor: 3200n,
currency: 'GEL',
source: 'manual',
createdByMemberId: FIXTURE_IDS.memberDima
},
{
householdId: FIXTURE_IDS.household,
cycleId: FIXTURE_IDS.cycle,
billName: 'Cleaning',
amountMinor: 1000n,
currency: 'GEL',
source: 'manual',
createdByMemberId: FIXTURE_IDS.memberDima
},
{
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
cycleId: FIXTURE_IDS.cycle, cycleId: FIXTURE_IDS.cycle,
billName: 'Electricity', billName: 'Electricity',
amountMinor: 12000n, amountMinor: 4000n,
currency: 'USD', currency: 'GEL',
source: 'manual', source: 'manual',
createdByMemberId: FIXTURE_IDS.memberAlice createdByMemberId: FIXTURE_IDS.memberDima
})
.onConflictDoNothing()
await db
.insert(presenceOverrides)
.values([
{
cycleId: FIXTURE_IDS.cycle,
memberId: FIXTURE_IDS.memberAlice,
utilityDays: 31,
reason: 'full month'
},
{
cycleId: FIXTURE_IDS.cycle,
memberId: FIXTURE_IDS.memberBob,
utilityDays: 31,
reason: 'full month'
},
{
cycleId: FIXTURE_IDS.cycle,
memberId: FIXTURE_IDS.memberCarol,
utilityDays: 20,
reason: 'partial month'
} }
]) ])
.onConflictDoNothing()
await db await db.insert(presenceOverrides).values([
.insert(purchaseEntries) {
.values({ cycleId: FIXTURE_IDS.cycle,
memberId: FIXTURE_IDS.memberDima,
utilityDays: 31,
reason: 'full month'
},
{
cycleId: FIXTURE_IDS.cycle,
memberId: FIXTURE_IDS.memberStas,
utilityDays: 31,
reason: 'full month'
},
{
cycleId: FIXTURE_IDS.cycle,
memberId: FIXTURE_IDS.memberIon,
utilityDays: 31,
reason: 'full month'
}
])
await db.insert(purchaseEntries).values({
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
cycleId: FIXTURE_IDS.cycle, cycleId: FIXTURE_IDS.cycle,
payerMemberId: FIXTURE_IDS.memberAlice, payerMemberId: FIXTURE_IDS.memberDima,
amountMinor: 3000n, amountMinor: 3000n,
currency: 'USD', currency: 'GEL',
rawText: 'Bought toilet paper 30 gel', rawText: 'Купил туалетную бумагу. 30 gel',
normalizedText: 'bought toilet paper 30 gel', normalizedText: 'купил туалетную бумагу 30 gel',
parserMode: 'rules', parserMode: 'rules',
parserConfidence: 93, parserConfidence: 93,
telegramChatId: '-100householdchat', telegramChatId: '-1003000000001',
telegramMessageId: '501', telegramMessageId: '501',
telegramThreadId: 'general-buys' telegramThreadId: '1001'
}) })
.onConflictDoNothing()
await db await db.insert(processedBotMessages).values({
.insert(processedBotMessages)
.values({
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
source: 'telegram', source: 'telegram',
sourceMessageKey: 'chat:-100householdchat:message:501', sourceMessageKey: 'chat:-1003000000001:message:501',
payloadHash: 'demo-hash' payloadHash: 'fixture-purchase-hash'
}) })
.onConflictDoNothing()
await db await db.insert(settlements).values({
.insert(settlements)
.values({
id: FIXTURE_IDS.settlement, id: FIXTURE_IDS.settlement,
householdId: FIXTURE_IDS.household, householdId: FIXTURE_IDS.household,
cycleId: FIXTURE_IDS.cycle, cycleId: FIXTURE_IDS.cycle,
inputHash: 'demo-settlement-hash', inputHash: 'fixture-settlement-hash',
totalDueMinor: 82000n, totalDueMinor: 201400n,
currency: 'USD' currency: 'GEL',
metadata: {
fixture: true,
rentSourceCurrency: 'USD',
settlementCurrency: 'GEL'
}
}) })
.onConflictDoNothing()
await db await db.insert(settlementLines).values([
.insert(settlementLines)
.values([
{ {
settlementId: FIXTURE_IDS.settlement, settlementId: FIXTURE_IDS.settlement,
memberId: FIXTURE_IDS.memberAlice, memberId: FIXTURE_IDS.memberDima,
rentShareMinor: 23334n, rentShareMinor: 64400n,
utilityShareMinor: 4000n, utilityShareMinor: 2734n,
purchaseOffsetMinor: -2000n, purchaseOffsetMinor: -2000n,
netDueMinor: 25334n, netDueMinor: 65134n,
explanations: ['rent_share_minor=23334', 'utility_share_minor=4000'] explanations: ['rent_share_minor=64400', 'utility_share_minor=2734']
}, },
{ {
settlementId: FIXTURE_IDS.settlement, settlementId: FIXTURE_IDS.settlement,
memberId: FIXTURE_IDS.memberBob, memberId: FIXTURE_IDS.memberStas,
rentShareMinor: 23333n, rentShareMinor: 64400n,
utilityShareMinor: 4000n, utilityShareMinor: 2733n,
purchaseOffsetMinor: 1000n, purchaseOffsetMinor: 1000n,
netDueMinor: 28333n, netDueMinor: 68133n,
explanations: ['rent_share_minor=23333', 'utility_share_minor=4000'] explanations: ['rent_share_minor=64400', 'utility_share_minor=2733']
}, },
{ {
settlementId: FIXTURE_IDS.settlement, settlementId: FIXTURE_IDS.settlement,
memberId: FIXTURE_IDS.memberCarol, memberId: FIXTURE_IDS.memberIon,
rentShareMinor: 23333n, rentShareMinor: 64400n,
utilityShareMinor: 4000n, utilityShareMinor: 2733n,
purchaseOffsetMinor: 1000n, purchaseOffsetMinor: 1000n,
netDueMinor: 28333n, netDueMinor: 68133n,
explanations: ['rent_share_minor=23333', 'utility_share_minor=4000'] explanations: ['rent_share_minor=64400', 'utility_share_minor=2733']
} }
]) ])
.onConflictDoNothing()
await db.insert(paymentConfirmations).values({
id: FIXTURE_IDS.paymentConfirmation,
householdId: FIXTURE_IDS.household,
cycleId: FIXTURE_IDS.cycle,
memberId: FIXTURE_IDS.memberStas,
senderTelegramUserId: '10002',
rawText: 'за жилье закинул',
normalizedText: 'за жилье закинул',
detectedKind: 'rent',
explicitAmountMinor: null,
explicitCurrency: null,
resolvedAmountMinor: 68133n,
resolvedCurrency: 'GEL',
status: 'recorded',
reviewReason: null,
attachmentCount: 1,
telegramChatId: '-1003000000001',
telegramMessageId: '601',
telegramThreadId: '1004',
telegramUpdateId: '9001'
})
await db.insert(paymentRecords).values({
householdId: FIXTURE_IDS.household,
cycleId: FIXTURE_IDS.cycle,
memberId: FIXTURE_IDS.memberStas,
kind: 'rent',
amountMinor: 68133n,
currency: 'GEL',
confirmationId: FIXTURE_IDS.paymentConfirmation,
recordedAt: new Date('2026-03-19T10:00:00.000Z')
})
const seededCycle = await db const seededCycle = await db
.select({ period: billingCycles.period, currency: billingCycles.currency }) .select({ period: billingCycles.period, currency: billingCycles.currency })
.from(billingCycles) .from(billingCycles)
.where( .where(eq(billingCycles.id, FIXTURE_IDS.cycle))
and(
eq(billingCycles.id, FIXTURE_IDS.cycle),
eq(billingCycles.householdId, FIXTURE_IDS.household)
)
)
.limit(1) .limit(1)
if (seededCycle.length === 0) { if (seededCycle.length === 0) {
throw new Error('Seed verification failed: billing cycle not found') throw new Error('Seed verification failed: billing cycle not found')
} }
const seededChat = await db const seededSettings = await db
.select({ telegramChatId: householdTelegramChats.telegramChatId }) .select({ settlementCurrency: householdBillingSettings.settlementCurrency })
.from(householdTelegramChats) .from(householdBillingSettings)
.where(eq(householdTelegramChats.householdId, FIXTURE_IDS.household)) .where(eq(householdBillingSettings.householdId, FIXTURE_IDS.household))
.limit(1) .limit(1)
if (seededChat.length === 0) { if (seededSettings.length === 0) {
throw new Error('Seed verification failed: Telegram household chat not found') throw new Error('Seed verification failed: billing settings not found')
} }
} }