Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
236 lines
11 KiB
TypeScript
236 lines
11 KiB
TypeScript
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) };
|
|
}),
|
|
});
|