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,235 @@
|
||||
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) };
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user