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:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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,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' });
|
||||
|
||||
Reference in new issue
Block a user