Backoffice stage 5: products, broadcasts, activity log, settings
- Products: services with full setup — content (name, description, extras names in 5 languages via text overrides), price tiers and extras, booking window, late-cancel fee, on/off, checklist; category discounts by membership level; membership plan prices; promo codes; relocation steps and documents. - Text overrides: TextOverride table on top of the i18n files; API, bots and the worker reload them every minute, mini apps fetch public.texts at start. - Promo codes applied to orders (admin new-order form and client API): not stacked with membership discount — the larger wins; once per client; validity, limit and categories. - Broadcasts to the client bot by any client filter, per-language texts with Russian fallback, test to yourself, ~25 msg/s, per-recipient status, resume after worker restart, history with progress and stop. - Activity log (who/when/what, filters, details) and Settings page with all owner settings grouped, plus payment methods. - Tests for promo codes and text overrides. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ac786cc5cc
commit
0edc6432b7
34 files changed
+2704
-26
No files matched your search
@@ -0,0 +1,137 @@
|
||||
/** Этап 5: рассылки клиентам, журнал действий, настройки */
|
||||
import { DAY, SETTING_KEYS, settingsSchema } from '@barev/core';
|
||||
import type { Prisma } from '@barev/db';
|
||||
import { getSettings } from '@barev/services';
|
||||
import { TelegramSender } from '@barev/services/telegram';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../../env.ts';
|
||||
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
|
||||
import { clientFilters, filteredClients } from './clients.ts';
|
||||
|
||||
const LANGS = ['ru', 'en', 'zh', 'hy', 'es'] as const;
|
||||
const texts = z.object({ ru: z.string().trim().min(1).max(3500), en: z.string().max(3500), zh: z.string().max(3500), hy: z.string().max(3500), es: z.string().max(3500) });
|
||||
const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/** Рассылки делают поддержка и суперадмин */
|
||||
const bc = rolesProcedure('SUPPORT');
|
||||
|
||||
/** Получатели: клиенты по фильтрам, только давшие согласие (иначе бот им не писал) */
|
||||
async function recipients(db: import('@barev/db').Db, filters: z.infer<typeof clientFilters>, now: Date) {
|
||||
const { list } = await filteredClients(db, filters, now);
|
||||
return list.filter((c) => !!c.consentAt).map((c) => ({ id: c.id, locale: c.user.locale }));
|
||||
}
|
||||
|
||||
export const boBroadcastsRouter = router({
|
||||
/** Сколько клиентов получит и на каких языках */
|
||||
preview: bc.input(clientFilters).query(async ({ ctx, input }) => {
|
||||
const list = await recipients(ctx.db, input, ctx.svc.now());
|
||||
const byLocale = Object.fromEntries(LANGS.map((l) => [l, list.filter((c) => c.locale === l).length])) as Record<(typeof LANGS)[number], number>;
|
||||
return { total: list.length, byLocale };
|
||||
}),
|
||||
|
||||
/** Проверка: прислать себе в клиентский бот (нужно, чтобы вы хоть раз открыли его) */
|
||||
test: bc.input(z.object({ text: z.string().trim().min(1).max(3500), appButton: z.boolean() })).mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.admin.telegramId) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'TELEGRAM_NOT_LINKED' });
|
||||
const bot = new TelegramSender(env.CLIENT_BOT_TOKEN, 'client-bot');
|
||||
const app = env.CLIENT_MINIAPP_URL;
|
||||
const ok = await bot.send({
|
||||
chatId: ctx.admin.telegramId,
|
||||
text: escapeHtml(input.text),
|
||||
buttons: input.appButton && app.startsWith('https://') ? [[{ text: 'Barev Club', web_app: { url: app } }]] : [],
|
||||
});
|
||||
if (!ok) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'TEST_UNREACHABLE' });
|
||||
}),
|
||||
|
||||
/** Запустить рассылку: фиксируем получателей, воркер отправляет с ограничением скорости Telegram */
|
||||
send: bc.input(z.object({ filters: clientFilters, texts, appButton: z.boolean() })).mutation(async ({ ctx, input }) => {
|
||||
const list = await recipients(ctx.db, input.filters, ctx.svc.now());
|
||||
if (!list.length) throw new TRPCError({ code: 'BAD_REQUEST', message: 'NO_RECIPIENTS' });
|
||||
const b = await ctx.db.broadcast.create({
|
||||
data: {
|
||||
texts: input.texts,
|
||||
appButton: input.appButton,
|
||||
filters: input.filters as Prisma.InputJsonValue,
|
||||
total: list.length,
|
||||
createdById: ctx.admin.id,
|
||||
recipients: { createMany: { data: list.map((c) => ({ clientId: c.id })) } },
|
||||
},
|
||||
});
|
||||
await ctx.svc.notify.emit({ type: 'broadcast.start', broadcastId: b.id });
|
||||
return { id: b.id, total: list.length };
|
||||
}),
|
||||
|
||||
list: bc.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db.broadcast.findMany({ orderBy: { createdAt: 'desc' }, take: 50 });
|
||||
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: rows.map((r) => r.createdById).filter((x): x is string => !!x) } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
text: (r.texts as Record<string, string>).ru ?? '',
|
||||
status: r.status,
|
||||
total: r.total,
|
||||
sent: r.sent,
|
||||
blocked: r.blocked,
|
||||
failed: r.failed,
|
||||
by: r.createdById ? (admins.get(r.createdById) ?? null) : null,
|
||||
createdAt: r.createdAt,
|
||||
finishedAt: r.finishedAt,
|
||||
}));
|
||||
}),
|
||||
|
||||
cancel: bc.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.broadcast.updateMany({ where: { id: input.id, status: 'SENDING' }, data: { status: 'CANCELLED', finishedAt: ctx.svc.now() } });
|
||||
}),
|
||||
});
|
||||
|
||||
// ───── журнал действий ─────
|
||||
|
||||
export const boJournalRouter = router({
|
||||
list: superProcedure
|
||||
.input(z.object({ adminUserId: z.string().optional(), entity: z.string().max(40).optional(), days: z.number().int().min(1).max(365).default(30), cursor: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const since = new Date(ctx.svc.now().getTime() - input.days * DAY);
|
||||
const rows = await ctx.db.auditLog.findMany({
|
||||
where: { createdAt: { gte: since }, ...(input.adminUserId ? { adminUserId: input.adminUserId } : {}), ...(input.entity ? { entity: input.entity } : {}) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 101,
|
||||
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
|
||||
include: { adminUser: { select: { id: true, name: true } } },
|
||||
});
|
||||
const page = rows.slice(0, 100);
|
||||
return {
|
||||
items: page.map((r) => ({ id: r.id, at: r.createdAt, by: r.adminUser, action: r.action, entity: r.entity, entityId: r.entityId, data: r.data as Record<string, unknown> | null })),
|
||||
next: rows.length > 100 ? page.at(-1)!.id : null,
|
||||
};
|
||||
}),
|
||||
facets: superProcedure.query(async ({ ctx }) => {
|
||||
const [staff, entities] = await Promise.all([
|
||||
ctx.db.adminUser.findMany({ orderBy: { name: 'asc' }, select: { id: true, name: true } }),
|
||||
ctx.db.auditLog.groupBy({ by: ['entity'], _count: true, orderBy: { _count: { entity: 'desc' } } }),
|
||||
]);
|
||||
return { staff, entities: entities.map((e) => e.entity) };
|
||||
}),
|
||||
});
|
||||
|
||||
// ───── настройки ─────
|
||||
|
||||
export const boSettingsRouter = router({
|
||||
get: superProcedure.query(({ ctx }) => getSettings(ctx.db)),
|
||||
save: superProcedure.input(settingsSchema.partial()).mutation(async ({ ctx, input }) => {
|
||||
for (const key of SETTING_KEYS) {
|
||||
const value = input[key];
|
||||
if (value === undefined) continue;
|
||||
await ctx.db.setting.upsert({ where: { key }, create: { key, value: value as never }, update: { value: value as never } });
|
||||
}
|
||||
}),
|
||||
paymentMethods: superProcedure.query(({ ctx }) => ctx.db.paymentMethod.findMany({ orderBy: { sortOrder: 'asc' } })),
|
||||
savePaymentMethod: superProcedure
|
||||
.input(z.object({ id: z.string().optional(), code: z.string().trim().min(1).max(40), name: texts, active: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
if (id) await ctx.db.paymentMethod.update({ where: { id }, data });
|
||||
else {
|
||||
const last = await ctx.db.paymentMethod.findFirst({ orderBy: { sortOrder: 'desc' } });
|
||||
await ctx.db.paymentMethod.create({ data: { ...data, sortOrder: (last?.sortOrder ?? 0) + 1 } });
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -110,7 +110,7 @@ function baseWhere(f: ClientFilters): Prisma.ClientWhereInput {
|
||||
return and.length ? { AND: and } : {};
|
||||
}
|
||||
|
||||
async function filteredClients(db: Db, f: ClientFilters, now: Date) {
|
||||
export async function filteredClients(db: Db, f: ClientFilters, now: Date) {
|
||||
const rows = await db.client.findMany({ where: baseWhere(f), include: clientInclude, orderBy: { createdAt: 'desc' }, take: 5000 });
|
||||
const money = await revenueByClient(
|
||||
db,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { boOrdersRouter } from './orders.ts';
|
||||
import { boProvidersRouter } from './providers.ts';
|
||||
import { boCrmRouter } from './crm.ts';
|
||||
import { boPartnersRouter } from './partners.ts';
|
||||
import { boProductsRouter } from './products.ts';
|
||||
import { boBroadcastsRouter, boJournalRouter, boSettingsRouter } from './admin5.ts';
|
||||
|
||||
/** API новой админки (apps/backoffice) */
|
||||
export const backofficeRouter = router({
|
||||
@@ -20,4 +22,8 @@ export const backofficeRouter = router({
|
||||
providers: boProvidersRouter,
|
||||
crm: boCrmRouter,
|
||||
partners: boPartnersRouter,
|
||||
products: boProductsRouter,
|
||||
broadcasts: boBroadcastsRouter,
|
||||
journal: boJournalRouter,
|
||||
settings: boSettingsRouter,
|
||||
});
|
||||
@@ -337,10 +337,10 @@ export const boOrdersRouter = router({
|
||||
|
||||
/** Цена и длительность для клиента (с его скидкой по подписке) */
|
||||
quote: ordersProcedure
|
||||
.input(z.object({ clientId: z.string(), service: z.string(), params: z.record(z.string(), z.unknown()) }))
|
||||
.input(z.object({ clientId: z.string(), service: z.string(), params: z.record(z.string(), z.unknown()), promoCode: z.string().trim().max(40).optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { quote: q, membership } = await quoteForClient(ctx.svc, input.clientId, input.service, input.params);
|
||||
return { durationMinutes: q.durationMinutes, total: q.totalPrice, base: q.basePrice, discountPct: q.discountPct, level: membership.level };
|
||||
const { quote: q, membership, promo } = await quoteForClient(ctx.svc, input.clientId, input.service, input.params, input.promoCode);
|
||||
return { durationMinutes: q.durationMinutes, total: q.totalPrice, base: q.basePrice, discountPct: q.discountPct, level: membership.level, promo: promo ? promo.code : null };
|
||||
}),
|
||||
|
||||
slots: ordersProcedure
|
||||
@@ -359,10 +359,12 @@ export const boOrdersRouter = router({
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
paymentMethodId: z.string().optional(),
|
||||
allowBusySlot: z.boolean().default(false),
|
||||
promoCode: z.string().trim().max(40).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const o = await createOrder(ctx.svc, {
|
||||
promoCode: input.promoCode,
|
||||
clientId: input.clientId,
|
||||
serviceCode: input.service,
|
||||
params: input.params,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { CATEGORY_CODES, DAY, fromPrice, paramsSchemaSchema, pricingSchema, slotConfigSchema } from '@barev/core';
|
||||
import { defaultText, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { isEditableTextKey, listRelocationItems, loadTextOverrides, moveRelocationItem, normalizePromo, saveRelocationItem } from '@barev/services';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
|
||||
|
||||
/** Продукты видят поддержка и суперадмин; меняет — суперадмин (цены и скидки — деньги) */
|
||||
const view = rolesProcedure('SUPPORT');
|
||||
const edit = superProcedure;
|
||||
const localized = z.object({ ru: z.string().max(500), en: z.string().max(500), zh: z.string().max(500), hy: z.string().max(500), es: z.string().max(500) });
|
||||
|
||||
/** Текст по ключу на 5 языках: стандартный (из переводов) и изменённый в админке */
|
||||
async function textsFor(db: import('@barev/db').Db, keys: string[]) {
|
||||
const rows = await db.textOverride.findMany({ where: { key: { in: keys } } });
|
||||
const out: Record<string, Record<Locale, { default: string | null; value: string | null }>> = {};
|
||||
for (const key of keys) {
|
||||
out[key] = Object.fromEntries(LOCALES.map((l) => [l, { default: defaultText(l, key), value: rows.find((r) => r.key === key && r.locale === l)?.value ?? null }])) as Record<Locale, { default: string | null; value: string | null }>;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const boProductsRouter = router({
|
||||
/** Направления (скидки по уровням) и услуги со всеми настройками */
|
||||
catalog: view.query(async ({ ctx }) => {
|
||||
const since = new Date(ctx.svc.now().getTime() - 30 * DAY);
|
||||
const [categories, orders30] = await Promise.all([
|
||||
ctx.db.category.findMany({ orderBy: { sortOrder: 'asc' }, include: { services: { orderBy: { sortOrder: 'asc' }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } } } }),
|
||||
ctx.db.order.groupBy({ by: ['serviceId'], where: { createdAt: { gte: since }, status: { not: 'CANCELLED' } }, _count: true }),
|
||||
]);
|
||||
const count = new Map(orders30.map((o) => [o.serviceId, o._count]));
|
||||
return categories.map((c) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
barevPlusDiscountPct: c.barevPlusDiscountPct,
|
||||
eliteDiscountPct: c.eliteDiscountPct,
|
||||
services: c.services.map((s) => {
|
||||
const pricing = pricingSchema.parse(s.pricing);
|
||||
return {
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
flow: s.flow,
|
||||
providerRole: s.providerRole,
|
||||
active: s.active,
|
||||
isOnline: s.isOnline,
|
||||
lateCancelFeePct: s.lateCancelFeePct,
|
||||
pricing,
|
||||
paramsSchema: paramsSchemaSchema.parse(s.paramsSchema),
|
||||
slotConfig: slotConfigSchema.parse(s.slotConfig),
|
||||
fromPrice: fromPrice(pricing),
|
||||
orders30: count.get(s.id) ?? 0,
|
||||
checklist: s.checklist.map((x) => ({ code: x.code, requiresPhoto: x.requiresPhoto })),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}),
|
||||
|
||||
/** Тексты для формы: название/описание услуги, доп. услуги, пункты чек-листа */
|
||||
texts: view.input(z.object({ keys: z.array(z.string().max(120)).max(200) })).query(({ ctx, input }) => textsFor(ctx.db, input.keys.filter(isEditableTextKey))),
|
||||
|
||||
/** Сохранить тексты; пустое значение — вернуть стандартный перевод */
|
||||
saveTexts: edit.input(z.array(z.object({ key: z.string().max(120), locale: z.enum(LOCALES), value: z.string().max(2000) })).max(400)).mutation(async ({ ctx, input }) => {
|
||||
for (const r of input) {
|
||||
if (!isEditableTextKey(r.key)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'TEXT_KEY_FORBIDDEN' });
|
||||
const value = r.value.trim();
|
||||
const same = value === (defaultText(r.locale, r.key) ?? '');
|
||||
if (!value || same) await ctx.db.textOverride.deleteMany({ where: { key: r.key, locale: r.locale } });
|
||||
else await ctx.db.textOverride.upsert({ where: { key_locale: { key: r.key, locale: r.locale } }, create: { key: r.key, locale: r.locale, value, updatedById: ctx.admin.id }, update: { value, updatedById: ctx.admin.id } });
|
||||
}
|
||||
// этот процесс видит правки сразу; боты и воркер — в течение минуты
|
||||
await loadTextOverrides(ctx.db);
|
||||
}),
|
||||
|
||||
saveService: edit
|
||||
.input(z.object({ id: z.string(), active: z.boolean(), lateCancelFeePct: z.number().int().min(0).max(100), pricing: pricingSchema, slotConfig: slotConfigSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.slotConfig.dayEndMinute <= input.slotConfig.dayStartMinute) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SLOT_WINDOW_INVALID' });
|
||||
const { id, ...data } = input;
|
||||
await ctx.db.service.update({ where: { id }, data });
|
||||
}),
|
||||
|
||||
saveCategory: edit
|
||||
.input(z.object({ id: z.string(), barevPlusDiscountPct: z.number().int().min(0).max(100), eliteDiscountPct: z.number().int().min(0).max(100) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
await ctx.db.category.update({ where: { id }, data });
|
||||
}),
|
||||
|
||||
/** Чек-лист услуги: новые заказы получают новый список, уже созданные — без изменений */
|
||||
saveChecklist: edit
|
||||
.input(z.object({ serviceId: z.string(), items: z.array(z.object({ code: z.string().max(60).optional(), label: z.string().max(120).optional(), requiresPhoto: z.boolean() })).max(30) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const items = input.items.map((x) => ({ ...x, code: x.code || `c_${randomBytes(3).toString('hex')}` }));
|
||||
await ctx.db.$transaction([
|
||||
ctx.db.checklistItem.deleteMany({ where: { serviceId: input.serviceId } }),
|
||||
ctx.db.checklistItem.createMany({ data: items.map((x, i) => ({ serviceId: input.serviceId, code: x.code, requiresPhoto: x.requiresPhoto, sortOrder: i })) }),
|
||||
]);
|
||||
// подпись нового пункта — русский текст (переводы можно добавить в «Текстах»)
|
||||
for (const x of items) {
|
||||
if (x.label?.trim() && !defaultText('ru', `checklist.${x.code}`)) {
|
||||
await ctx.db.textOverride.upsert({ where: { key_locale: { key: `checklist.${x.code}`, locale: 'ru' } }, create: { key: `checklist.${x.code}`, locale: 'ru', value: x.label.trim(), updatedById: ctx.admin.id }, update: { value: x.label.trim() } });
|
||||
}
|
||||
}
|
||||
await loadTextOverrides(ctx.db);
|
||||
}),
|
||||
|
||||
// ───── промокоды ─────
|
||||
|
||||
promos: view.query(async ({ ctx }) => {
|
||||
const list = await ctx.db.promoCode.findMany({ where: { kind: 'DISCOUNT' }, orderBy: [{ active: 'desc' }, { createdAt: 'desc' }] });
|
||||
const now = ctx.svc.now();
|
||||
return list.map((p) => ({
|
||||
id: p.id,
|
||||
code: p.code,
|
||||
discountPct: p.discountPct ?? 0,
|
||||
maxUses: p.maxUses,
|
||||
usedCount: p.usedCount,
|
||||
validFrom: p.validFrom,
|
||||
validUntil: p.validUntil,
|
||||
categories: p.categories,
|
||||
note: p.note,
|
||||
active: p.active,
|
||||
state: !p.active ? 'off' : p.validUntil && p.validUntil < now ? 'expired' : p.maxUses != null && p.usedCount >= p.maxUses ? 'exhausted' : p.validFrom && p.validFrom > now ? 'scheduled' : 'live',
|
||||
}));
|
||||
}),
|
||||
|
||||
savePromo: edit
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
code: z.string().min(3).max(40),
|
||||
discountPct: z.number().int().min(1).max(100),
|
||||
maxUses: z.number().int().min(1).nullable(),
|
||||
validFrom: z.date().nullable(),
|
||||
validUntil: z.date().nullable(),
|
||||
categories: z.array(z.enum(CATEGORY_CODES)),
|
||||
note: z.string().max(300).nullable(),
|
||||
active: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const code = normalizePromo(input.code);
|
||||
if (!/^[A-Z0-9_-]{3,40}$/.test(code)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'PROMO_CODE_FORMAT' });
|
||||
const clash = await ctx.db.promoCode.findUnique({ where: { code } });
|
||||
if (clash && clash.id !== input.id) throw new TRPCError({ code: 'BAD_REQUEST', message: 'PROMO_CODE_TAKEN' });
|
||||
const { id, ...rest } = input;
|
||||
const data = { ...rest, code, kind: 'DISCOUNT' as const };
|
||||
if (id) await ctx.db.promoCode.update({ where: { id }, data });
|
||||
else await ctx.db.promoCode.create({ data });
|
||||
}),
|
||||
|
||||
// ───── релокация: общий список шагов и документов ─────
|
||||
|
||||
relocation: view.query(({ ctx }) => listRelocationItems(ctx.svc)),
|
||||
saveRelocation: edit
|
||||
.input(z.object({ id: z.string().optional(), kind: z.enum(['STEP', 'DOC']), name: localized, hint: localized.nullable().optional(), active: z.boolean(), inTurnkey: z.boolean().optional() }))
|
||||
.mutation(({ ctx, input }) => saveRelocationItem(ctx.svc, input).then((x) => ({ id: x.id }))),
|
||||
moveRelocation: edit.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(({ ctx, input }) => moveRelocationItem(ctx.svc, input)),
|
||||
});
|
||||
@@ -182,8 +182,11 @@ export const clientRouter = router({
|
||||
|
||||
/** ORD-4 */
|
||||
quote: clientProcedure
|
||||
.input(z.object({ serviceCode: z.string(), params: paramsInput }))
|
||||
.query(async ({ ctx, input }) => (await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params)).quote),
|
||||
.input(z.object({ serviceCode: z.string(), params: paramsInput, promoCode: z.string().trim().max(40).optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const r = await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params, input.promoCode);
|
||||
return { ...r.quote, promo: r.promo ? { code: r.promo.code, discountPct: r.promo.discountPct } : null };
|
||||
}),
|
||||
|
||||
/** ORD-3 */
|
||||
slots: clientProcedure
|
||||
@@ -207,6 +210,8 @@ export const clientRouter = router({
|
||||
note: z.string().max(1000).optional(),
|
||||
paymentMethodId: z.string().optional(),
|
||||
repeatOfId: z.string().optional(),
|
||||
/** промокод: применится, если выгоднее скидки подписки */
|
||||
promoCode: z.string().trim().max(40).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -9,6 +9,9 @@ import { publicProcedure, router } from '../trpc.ts';
|
||||
|
||||
export const publicRouter = router({
|
||||
/** LND-5: цены «от» и скидки — для лендинга */
|
||||
/** Тексты, изменённые в админке, — Mini App подставляет их поверх своих переводов */
|
||||
texts: publicProcedure.query(({ ctx }) => ctx.db.textOverride.findMany({ select: { key: true, locale: true, value: true } })),
|
||||
|
||||
catalog: publicProcedure.query(async ({ ctx }) => {
|
||||
const [categories, settings] = await Promise.all([listCatalog(ctx.db), getSettings(ctx.db)]);
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import multipart, { type MultipartFile } from '@fastify/multipart';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||
import { getFlow } from '@barev/core';
|
||||
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend } from '@barev/services';
|
||||
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend, startTextOverrides } from '@barev/services';
|
||||
import { getFile, putFile } from '@barev/services/storage';
|
||||
import { TelegramSender } from '@barev/services/telegram';
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
@@ -278,6 +278,9 @@ app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
|
||||
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
|
||||
// тексты из админки поверх переводов (обновляются раз в минуту)
|
||||
startTextOverrides(db);
|
||||
|
||||
await ensureBucket().catch((e) => app.log.warn({ err: e }, 'S3 недоступно — загрузка фото работать не будет'));
|
||||
await app.listen({ port: env.API_PORT, host: '0.0.0.0' });
|
||||
console.log(`API: http://localhost:${env.API_PORT}`);
|
||||
Reference in new issue
Block a user