import { defaultParams, discountFor, LOCALES } from '@barev/core'; import { applyAgencyCode, availableSlots, cancelByClient, cancelPreview, clientOrder, createOrder, getMembership, getServiceByCode, getSettings, giveConsent, listCatalog, listClientOrders, quoteForClient, repeatDraft, rescheduleByClient, setLocale, clientRelocation, createAppLead, createClientRequest, LEAD_MOVE_WHEN, LEAD_PACKAGES, openClientRequests, relocationNames, setSegment, toggleRelocationDoc, cardQrToken, clientPartners, } from '@barev/services'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { env } from '../env.ts'; import { signedUrl } from '../infra.ts'; import { clientProcedure, consentedClientProcedure, router } from '../trpc.ts'; const paramsInput = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number().int().min(0))])); const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); export const clientRouter = router({ me: clientProcedure.query(async ({ ctx }) => { const [membership, agency, settings] = await Promise.all([ getMembership(ctx.db, ctx.client.id, ctx.svc.now()), ctx.client.agencyId ? ctx.db.agency.findUnique({ where: { id: ctx.client.agencyId } }) : null, getSettings(ctx.db), ]); return { firstName: ctx.client.user.firstName, locale: ctx.client.user.locale, consented: !!ctx.client.consentAt, membership, agencyName: agency?.name ?? null, giftUsed: !!ctx.client.giftUsedAt, cardCode: ctx.client.cardCode, barevPlusPrice: settings['barevPlus.monthlyPrice'], freeCancelHours: settings['cancel.freeHours'], /** сценарий приложения: MOVING — переезжает, LIVING — уже живёт; null — спросить */ segment: ctx.client.segment, plans: { plusPrice: settings['barevPlus.monthlyPrice'], plusMaxPct: settings['barevPlus.maxDiscountPct'], elitePrice: settings['elite.monthlyPrice'], eliteMaxPct: settings['elite.maxDiscountPct'], consultation: settings['relocation.consultation'], }, /** чат с консьержем — переписка в боте */ conciergeUrl: `https://t.me/${env.CLIENT_BOT_USERNAME}`, }; }), setLocale: clientProcedure.input(z.object({ locale: z.enum(LOCALES) })).mutation(async ({ ctx, input }) => { await setLocale(ctx.db, ctx.telegramId, input.locale); }), /** ONB-3 в Mini App — если клиент открыл приложение, минуя онбординг в боте */ consent: clientProcedure.mutation(async ({ ctx }) => giveConsent(ctx.svc, ctx.client.id)), applyAgencyCode: consentedClientProcedure .input(z.object({ code: z.string().min(2).max(32) })) .mutation(({ ctx, input }) => applyAgencyCode(ctx.svc, ctx.client.id, input.code)), catalog: clientProcedure.query(async ({ ctx }) => { const [categories, membership] = await Promise.all([listCatalog(ctx.db), getMembership(ctx.db, ctx.client.id, ctx.svc.now())]); return { categories, level: membership.level }; }), setSegment: clientProcedure.input(z.object({ segment: z.enum(['MOVING', 'LIVING']) })).mutation(async ({ ctx, input }) => { await setSegment(ctx.svc, ctx.client.id, input.segment); }), /** План переезда: шаги со статусами от оператора и чек-лист документов */ relocation: router({ /** names — названия шагов и документов из общего списка по ключу «STEP:код» / «DOC:код» */ get: clientProcedure.query(async ({ ctx }) => { const [lead, names] = await Promise.all([clientRelocation(ctx.svc, ctx.client.id), relocationNames(ctx.db)]); return { lead, names }; }), create: consentedClientProcedure .input( z.object({ moveWhen: z.enum(LEAD_MOVE_WHEN), moveDate: z.iso.date().transform((v) => new Date(`${v}T00:00:00Z`)).optional(), package: z.enum(LEAD_PACKAGES), options: z .object({ airport: z.enum(['none', 'standard', 'premium']).optional(), stay: z.enum(['none', 'flat', 'hotel']).optional(), stayBudgetFrom: z.number().int().min(0).max(10_000_000).optional(), stayBudgetTo: z.number().int().min(0).max(10_000_000).optional(), flat: z.boolean().optional(), socialCard: z.boolean().optional(), bankCard: z.boolean().optional(), registration: z.boolean().optional(), residence: z.boolean().optional(), }) .optional(), comment: z.string().max(1000).optional(), }), ) .mutation(({ ctx, input }) => createAppLead(ctx.svc, ctx.client.id, input)), toggleDoc: clientProcedure .input(z.object({ docId: z.string().max(40), checked: z.boolean() })) .mutation(({ ctx, input }) => toggleRelocationDoc(ctx.svc, ctx.client.id, input.docId, input.checked)), }), /** Заявки оператору: подписка Barev+/Elite или «перезвоните» по услуге без онлайн-записи */ requests: router({ open: clientProcedure.query(({ ctx }) => openClientRequests(ctx.svc, ctx.client.id)), create: consentedClientProcedure .input( z.object({ kind: z.enum(['SUBSCRIPTION', 'SERVICE']), plan: z.enum(['BAREV_PLUS', 'ELITE']).optional(), serviceCode: z.string().max(40).optional(), comment: z.string().max(1000).optional(), }), ) .mutation(({ ctx, input }) => createClientRequest(ctx.svc, ctx.client.id, input)), }), /** Партнёры клуба: скидки и подарки по клубной карте */ partners: clientProcedure.query(({ ctx }) => clientPartners(ctx.svc, ctx.client.id)), /** Живой QR клубной карты: ссылка на рабочий бот, меняется каждую минуту */ cardQr: consentedClientProcedure.query(({ ctx }) => { const { token, expiresAt } = cardQrToken(ctx.client.cardCode, ctx.svc.now()); return { url: `https://t.me/${env.PRO_BOT_USERNAME}?start=${token}`, expiresAt, cardCode: ctx.client.cardCode }; }), /** ORD-2: параметры услуги для формы */ service: clientProcedure.input(z.object({ code: z.string() })).query(async ({ ctx, input }) => { const s = await getServiceByCode(ctx.db, input.code); const membership = await getMembership(ctx.db, ctx.client.id, ctx.svc.now()); return { code: s.code, category: s.category, flow: s.flow, active: s.active, isOnline: s.isOnline, paramsSchema: s.paramsSchema, defaults: defaultParams(s.paramsSchema), fromPrice: s.fromPrice, discountPct: discountFor(membership.level, s.categoryDiscountPct, s.categoryEliteDiscountPct), categoryDiscountPct: s.categoryDiscountPct, categoryEliteDiscountPct: s.categoryEliteDiscountPct, horizonDays: s.slotConfig.horizonDays, lateCancelFeePct: s.lateCancelFeePct, /** цены вариантов — чтобы показать «от N ֏» на кнопках выбора */ variantPrices: s.pricing.kind === 'rooms' ? Object.fromEntries(Object.entries(s.pricing.tiers).map(([v, tier]) => [v, tier.base])) : {}, /** цены доп. услуг — показываются рядом с выбором */ extrasPrices: s.pricing.kind === 'rooms' ? s.pricing.extras : {}, }; }), districts: clientProcedure.query(({ ctx }) => ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' }, select: { id: true, code: true } })), paymentMethods: clientProcedure.query(({ ctx }) => ctx.db.paymentMethod.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' }, select: { id: true, code: true, name: true } }), ), /** 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), /** ORD-3 */ slots: clientProcedure .input(z.object({ serviceCode: z.string(), params: paramsInput, dateKey, excludeOrderId: z.string().optional() })) .query(async ({ ctx, input }) => { const slots = await availableSlots(ctx.svc, input); return slots.map((s) => ({ start: s.start, end: s.end })); }), /** ORD-5 */ createOrder: consentedClientProcedure .input( z.object({ serviceCode: z.string(), params: paramsInput, scheduledAt: z.date(), addressText: z.string().max(300).optional(), districtId: z.string().optional(), lat: z.number().min(-90).max(90).optional(), lng: z.number().min(-180).max(180).optional(), note: z.string().max(1000).optional(), paymentMethodId: z.string().optional(), repeatOfId: z.string().optional(), }), ) .mutation(async ({ ctx, input }) => { const order = await createOrder(ctx.svc, { ...input, clientId: ctx.client.id }); return { id: order.id, number: order.number }; }), orders: router({ list: clientProcedure.query(({ ctx }) => listClientOrders(ctx.db, ctx.client.id)), get: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => clientOrder(ctx.db, ctx.client.id, input.id)), cancelPreview: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => cancelPreview(ctx.svc, input.id, ctx.client.id)), cancel: clientProcedure .input(z.object({ id: z.string(), reason: z.string().max(500).optional() })) .mutation(({ ctx, input }) => cancelByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, reason: input.reason }).then((r) => ({ fee: r.fee }))), reschedule: clientProcedure .input(z.object({ id: z.string(), scheduledAt: z.date() })) .mutation(({ ctx, input }) => rescheduleByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, scheduledAt: input.scheduledAt })), /** ORD-7 */ repeat: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => repeatDraft(ctx.db, input.id, ctx.client.id)), }), /** Фотоотчёт по своему заказу — временная ссылка, доступ логируется */ fileUrl: clientProcedure.input(z.object({ fileId: z.string() })).query(async ({ ctx, input }) => { const file = await ctx.db.orderFile.findUnique({ where: { id: input.fileId }, include: { order: true } }); if (!file || file.deletedAt || file.order.clientId !== ctx.client.id || file.kind !== 'PHOTO_REPORT') throw new TRPCError({ code: 'NOT_FOUND' }); await ctx.db.fileAccessLog.create({ data: { fileId: file.id, actorType: 'CLIENT', actorId: ctx.client.id } }); return { url: await signedUrl(file.storageKey) }; }), });