Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
/**
* Создать или обновить администратора (пароль — из переменной ADMIN_PASSWORD, чтобы не светить его в истории команд).
*
* ADMIN_PASSWORD=… pnpm --filter @barev/db create-admin <логин> [OWNER|OPERATOR] [имя]
*/
import path from 'node:path';
import { config } from 'dotenv';
import { hash } from '@node-rs/argon2';
import { createDb } from '../index.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
const [login, role = 'OWNER', name] = process.argv.slice(2);
const password = process.env.ADMIN_PASSWORD;
if (!login || !password || !['OWNER', 'OPERATOR'].includes(role)) {
console.error('ADMIN_PASSWORD=… create-admin <логин> [OWNER|OPERATOR] [имя]');
process.exit(1);
}
const db = createDb();
const email = login.toLowerCase();
const passwordHash = await hash(password);
await db.adminUser.upsert({
where: { email },
create: { email, name: name ?? login, role: role as 'OWNER' | 'OPERATOR', roles: role === 'OWNER' ? ['SUPERADMIN'] : ['SUPPORT', 'CONCIERGE'], passwordHash },
update: { passwordHash, role: role as 'OWNER' | 'OPERATOR', active: true },
});
console.log(`Администратор ${email} (${role}) готов. 2FA настраивается при первом входе.`);
await db.$disconnect();
+26
View File
@@ -0,0 +1,26 @@
/**
* Сброс 2FA администратора (потерян телефон и т.п.). При следующем входе админка покажет QR заново.
*
* pnpm --filter @barev/db reset-2fa owner@barev.local
*/
import path from 'node:path';
import { config } from 'dotenv';
import { createDb } from '../index.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
const email = process.argv[2]?.toLowerCase();
if (!email) {
console.error('Укажите email: pnpm --filter @barev/db reset-2fa <email>');
process.exit(1);
}
const db = createDb();
const admin = await db.adminUser.findUnique({ where: { email } });
if (!admin) {
console.error(`Администратор ${email} не найден`);
process.exit(1);
}
await db.adminUser.update({ where: { id: admin.id }, data: { totpEnabled: false, totpSecret: null } });
await db.adminSession.deleteMany({ where: { adminUserId: admin.id } });
console.log(`2FA для ${email} сброшена. При входе появится QR-код для настройки.`);
await db.$disconnect();
+117
View File
@@ -0,0 +1,117 @@
/**
* Синхронизация каталога услуг с кодом (packages/core/src/catalog.ts) без потери настроек владельца.
* Запускается при каждом старте контейнера, идемпотентна:
* - форма заказа (paramsSchema) и чек-лист берутся из кода — они связаны с переводами и логикой;
* - цены: значения, уже заданные в админке, сохраняются; новые поля и доп. услуги добавляются из кода;
* - новые услуги каталога создаются; существующие направления не трогаются;
* - районы Еревана создаются, если их ещё нет (без них нельзя оформить заказ с адресом).
*
* pnpm --filter @barev/db sync-catalog
*/
import path from 'node:path';
import { config } from 'dotenv';
import { CATALOG, DEFAULT_CATEGORY_DISCOUNTS, DEFAULT_ELITE_DISCOUNTS, flatPercentRules, pricingSchema, YEREVAN_DISTRICTS, type Pricing } from '@barev/core';
import type { Db } from '../index.ts';
import { createDb } from '../index.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
/** Цены из кода, поверх — сохранённые владельцем значения для тех же ключей */
export function mergePricing(fromCode: Pricing, stored: unknown): Pricing {
const parsed = pricingSchema.safeParse(stored);
if (!parsed.success || parsed.data.kind !== fromCode.kind) return fromCode;
const old = parsed.data;
if (fromCode.kind === 'fixed' || old.kind === 'fixed') return { ...fromCode, ...old } as Pricing;
const oldRaw = (stored ?? {}) as { tiers?: Record<string, Record<string, unknown>>; extras?: Record<string, unknown> };
const tiers = Object.fromEntries(
Object.entries(fromCode.tiers).map(([variant, tier]) => {
const saved = oldRaw.tiers?.[variant] ?? {};
// поля, которых в БД ещё не было, берём из кода (а не дефолт схемы 0)
const merged = { ...tier };
for (const k of Object.keys(tier) as (keyof typeof tier)[]) if (typeof saved[k] === 'number') merged[k] = saved[k] as number;
return [variant, merged];
}),
);
const extras = Object.fromEntries(
Object.entries(fromCode.extras).map(([code, e]) => [code, oldRaw.extras?.[code] ? { ...e, ...old.extras[code], unit: e.unit } : e]),
);
return { ...fromCode, tiers, extras };
}
/** Пресеты ставок клинеров по умолчанию (заглушки — владелец меняет в админке). Создаются, только если пресетов ещё нет. */
export async function ensureDefaultPresets(db: Db) {
if ((await db.payoutPreset.count({ where: { role: 'CLEANER' } })) > 0) return 0;
const cleaning = CATALOG.find((s) => s.code === 'cleaning')!;
const variants = cleaning.pricing.kind === 'rooms' ? Object.keys(cleaning.pricing.tiers) : [];
const extras = cleaning.pricing.kind === 'rooms' ? Object.keys(cleaning.pricing.extras) : [];
const presets = [
{ name: 'Джун', pct: 50 },
{ name: 'Мидл', pct: 60 },
{ name: 'Прайват', pct: 70 },
];
for (const [i, p] of presets.entries()) {
await db.payoutPreset.create({ data: { name: p.name, role: 'CLEANER', sortOrder: i, rules: flatPercentRules(p.pct, variants, extras) } });
}
return presets.length;
}
/** Районы из кода; порядок сортировки не перезаписывается, если район уже есть */
export async function ensureDistricts(db: Db) {
const existing = new Set((await db.district.findMany({ select: { code: true } })).map((d) => d.code));
const missing = YEREVAN_DISTRICTS.map((code, i) => ({ code, sortOrder: i })).filter((d) => !existing.has(d.code));
if (missing.length) await db.district.createMany({ data: missing });
return missing.length;
}
async function main() {
const db = createDb();
let created = 0;
let updated = 0;
for (const [i, s] of CATALOG.entries()) {
const category = await db.category.upsert({
where: { code: s.category },
create: { code: s.category, barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[s.category], eliteDiscountPct: DEFAULT_ELITE_DISCOUNTS[s.category] },
update: {},
});
const existing = await db.service.findUnique({ where: { code: s.code } });
if (!existing) {
await db.service.create({
data: {
code: s.code,
categoryId: category.id,
flow: s.flow,
providerRole: s.providerRole,
pricing: s.pricing,
paramsSchema: s.paramsSchema,
slotConfig: s.slotConfig,
isOnline: s.isOnline,
active: s.stage === 1,
sortOrder: i,
},
});
created++;
} else {
await db.service.update({
where: { id: existing.id },
data: { paramsSchema: s.paramsSchema, pricing: mergePricing(s.pricing, existing.pricing) },
});
updated++;
}
const service = await db.service.findUniqueOrThrow({ where: { code: s.code } });
for (const [j, c] of s.checklist.entries()) {
await db.checklistItem.upsert({
where: { serviceId_code: { serviceId: service.id, code: c.code } },
create: { serviceId: service.id, code: c.code, requiresPhoto: !!c.requiresPhoto, sortOrder: j },
update: { requiresPhoto: !!c.requiresPhoto, sortOrder: j },
});
}
}
const presets = await ensureDefaultPresets(db);
const districts = await ensureDistricts(db);
console.log(
`Каталог синхронизирован: создано ${created}, обновлено ${updated}${presets ? `, пресетов ставок: ${presets}` : ''}${districts ? `, районов: ${districts}` : ''}`,
);
await db.$disconnect();
}
if (import.meta.url === `file://${process.argv[1]}`) await main();
+10 -2
View File
@@ -10,6 +10,7 @@ import { hash } from '@node-rs/argon2';
import {
CATALOG,
DEFAULT_CATEGORY_DISCOUNTS,
DEFAULT_ELITE_DISCOUNTS,
DEFAULT_SETTINGS,
YEREVAN_DISTRICTS,
agencyCommission,
@@ -20,6 +21,7 @@ import {
type CategoryCode,
} from '@barev/core';
import { createDb, type Locale, type OrderStatus } from '../index.ts';
import { ensureDefaultPresets } from '../scripts/sync-catalog.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
@@ -71,7 +73,7 @@ async function main() {
const categoryOrder: CategoryCode[] = ['HOME', 'DOCS', 'LEARNING', 'PERKS'];
const categories: Record<string, string> = {};
for (const [i, code] of categoryOrder.entries()) {
const c = await db.category.create({ data: { code, sortOrder: i, barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[code] } });
const c = await db.category.create({ data: { code, sortOrder: i, barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[code], eliteDiscountPct: DEFAULT_ELITE_DISCOUNTS[code] } });
categories[code] = c.id;
}
const services: Record<string, { id: string }> = {};
@@ -102,6 +104,10 @@ async function main() {
data: { code: 'transfer', sortOrder: 1, name: { ru: 'Перевод на карту', en: 'Card transfer', zh: '银行卡转账', hy: 'Փոխանցում քարտին', es: 'Transferencia a tarjeta' } },
});
// ── пресеты ставок клинеров ──
await ensureDefaultPresets(db);
const middle = await db.payoutPreset.findFirstOrThrow({ where: { name: 'Мидл' } });
// ── агентства ──
const domik = await db.agency.create({ data: { code: 'DOMIK', name: 'Domik Realty', commissionPct: 10, contact: '+374 00 000000' } });
await db.agency.create({ data: { code: 'YERHOME', name: 'Yerevan Home', commissionPct: 10 } });
@@ -135,6 +141,7 @@ async function main() {
districts: (keyof typeof districts)[];
companyId?: string;
rate?: { type: 'FIXED' | 'PERCENT'; value: number };
presetId?: string;
onShift?: boolean;
rating?: [number, number];
schedule?: typeof workweek;
@@ -150,6 +157,7 @@ async function main() {
languages: p.languages,
rateType: p.rate?.type ?? 'PERCENT',
rateValue: p.rate?.value ?? 70,
payoutPresetId: p.presetId,
onShift: p.onShift ?? true,
ratingSum: p.rating?.[0] ?? 0,
ratingCount: p.rating?.[1] ?? 0,
@@ -160,7 +168,7 @@ async function main() {
});
}
const maria = await provider({ tg: SEED_TG.cleanerMaria, name: 'Мария Петросян', role: 'CLEANER', languages: ['ru', 'hy', 'en'], districts: ['kentron', 'arabkir'], rating: [47, 10], invite: 'PRO-MARIA' });
const maria = await provider({ tg: SEED_TG.cleanerMaria, name: 'Мария Петросян', role: 'CLEANER', languages: ['ru', 'hy', 'en'], districts: ['kentron', 'arabkir'], rating: [47, 10], presetId: middle.id, invite: 'PRO-MARIA' });
const ani = await provider({ tg: SEED_TG.cleanerAni, name: 'Ани Саргсян', role: 'CLEANER', languages: ['hy', 'ru'], districts: ['shengavit', 'erebuni', 'malatia_sebastia'], rate: { type: 'FIXED', value: 9000 }, rating: [22, 5], invite: 'PRO-ANI' });
const elena = await provider({ tg: SEED_TG.cleanerElena, name: 'Elena Gómez', role: 'CLEANER', languages: ['es', 'en', 'ru'], districts: ['kentron', 'arabkir', 'nor_nork'], schedule: everyday, invite: 'PRO-ELENA' });
await provider({ tg: SEED_TG.driverArman, name: 'Арман Мкртчян', role: 'DRIVER', languages: ['ru', 'hy', 'en'], districts: ['kentron'], schedule: everyday, rate: { type: 'FIXED', value: 7000 }, invite: 'PRO-ARMAN' });