Files
BarevClub/apps/api/src/routers/client.ts
T

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) };
}),
});