Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9749da5ae7
201 files changed
+97685
No files matched your search
@@ -0,0 +1,611 @@
|
||||
import { agentPhrase, CommissionRule, CURRENCIES, DEAL_TYPES, FilterParams, PROPERTY_TYPES, type AccessState, type Role } from '@lr/shared';
|
||||
import { commissionDelta, commissionNote, commissionView, ruleCode } from '../lib/commission.ts';
|
||||
import { and, asc, desc, eq, gte, ilike, inArray, isNull, lte, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { DealBody, dealView } from '../deals/service.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { clientLeads, clients, companies, deals, departments, events, filters, leadFavorites, leads, leadViews, listings, pipelineStatuses, users } from '../db/schema.ts';
|
||||
import { loadBillingSettings } from '../billing/service.ts';
|
||||
import { backfillFilter, importListing, type PagedSource } from '../ingest/service.ts';
|
||||
import { sendLeadToGroup, sendLeadToUser, type GroupSender } from '../group/service.ts';
|
||||
import { badRequest, forbidden, notFound } from '../lib/errors.ts';
|
||||
import { onlySent } from '../lib/patch.ts';
|
||||
import type { Config } from '../lib/config.ts';
|
||||
import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts';
|
||||
import { renderShowcaseText } from '../showcase/text.ts';
|
||||
import {
|
||||
addComment,
|
||||
assignLead,
|
||||
canSee,
|
||||
changeStatus,
|
||||
claimFoundLead,
|
||||
createClaimedLead,
|
||||
sameDepartment,
|
||||
departmentScope,
|
||||
inDepartment,
|
||||
leadActual,
|
||||
priceRange,
|
||||
createManualLead,
|
||||
deleteLead,
|
||||
findListingLead,
|
||||
editLead,
|
||||
loadLead,
|
||||
markAsAgent,
|
||||
markListingRemoved,
|
||||
releaseLead,
|
||||
phoneVariants,
|
||||
setLeadPhone,
|
||||
setNextAction,
|
||||
statusByCode,
|
||||
takeLead,
|
||||
type Actor,
|
||||
type Lead,
|
||||
} from './service.ts';
|
||||
|
||||
type MemberCtx = { user: typeof users.$inferSelect; role: Role; company: typeof companies.$inferSelect; access: AccessState };
|
||||
|
||||
export interface Guards {
|
||||
member: (req: FastifyRequest) => MemberCtx;
|
||||
manager: (req: FastifyRequest) => MemberCtx;
|
||||
writable: <T extends { access: AccessState }>(ctx: T) => T;
|
||||
}
|
||||
|
||||
const Id = z.object({ id: z.coerce.number().int().positive() });
|
||||
const optionalText = (max: number) => z.string().trim().max(max).optional().transform((v) => v || undefined);
|
||||
const optionalNumber = z.coerce.number().nonnegative().optional();
|
||||
|
||||
const ManualLeadBody = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: optionalText(10_000),
|
||||
dealType: z.enum(DEAL_TYPES).optional(),
|
||||
propertyType: z.enum(PROPERTY_TYPES).optional(),
|
||||
region: optionalText(60),
|
||||
district: optionalText(60),
|
||||
address: optionalText(300),
|
||||
price: optionalNumber,
|
||||
currency: z.enum(CURRENCIES).default('USD'),
|
||||
rooms: z.coerce.number().int().min(0).max(50).optional(),
|
||||
area: optionalNumber,
|
||||
floor: z.coerce.number().int().min(-5).max(200).optional(),
|
||||
floorsTotal: z.coerce.number().int().min(0).max(200).optional(),
|
||||
authorName: optionalText(100),
|
||||
phone: optionalText(40),
|
||||
url: z.url().max(1000).optional().or(z.literal('').transform(() => undefined)),
|
||||
});
|
||||
|
||||
/** Пустое поле в правке стирает значение. */
|
||||
const clearable = <T extends z.ZodType>(schema: T) => z.preprocess((v) => (v === '' ? null : v), schema.nullable());
|
||||
|
||||
const LeadEditBody = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: clearable(z.string().trim().max(10_000)),
|
||||
dealType: clearable(z.enum(DEAL_TYPES)),
|
||||
propertyType: clearable(z.enum(PROPERTY_TYPES)),
|
||||
region: clearable(z.string().trim().max(60)),
|
||||
district: clearable(z.string().trim().max(60)),
|
||||
address: clearable(z.string().trim().max(300)),
|
||||
price: clearable(z.coerce.number().nonnegative()),
|
||||
currency: z.enum(CURRENCIES),
|
||||
rooms: clearable(z.coerce.number().int().min(0).max(50)),
|
||||
area: clearable(z.coerce.number().nonnegative()),
|
||||
floor: clearable(z.coerce.number().int().min(-5).max(200)),
|
||||
floorsTotal: clearable(z.coerce.number().int().min(0).max(200)),
|
||||
authorName: clearable(z.string().trim().max(100)),
|
||||
}).partial();
|
||||
|
||||
export function registerLeadRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config, fetchImage?: ImageFetcher, source?: PagedSource | null, groupSender?: GroupSender) {
|
||||
const actor = (ctx: MemberCtx): Actor => ctx;
|
||||
|
||||
/** Телефон автора виден ответственному и руководителю; у лида в пуле и у чужого лида он скрыт. */
|
||||
const present = (ctx: MemberCtx, lead: Lead, assigneeName?: string | null) => ({
|
||||
...lead,
|
||||
phone: ctx.role === 'manager' || lead.assigneeId === ctx.user.id ? lead.phone : null,
|
||||
phoneHidden: Boolean(lead.phone) && ctx.role !== 'manager' && lead.assigneeId !== ctx.user.id,
|
||||
assigneeName: assigneeName ?? null,
|
||||
});
|
||||
|
||||
// --- список лидов ---
|
||||
|
||||
app.get('/api/leads', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const q = z
|
||||
.object({
|
||||
tab: z.enum(['pool', 'mine', 'all']).default('pool'),
|
||||
statusId: z.coerce.number().int().optional(),
|
||||
assigneeId: z.coerce.number().int().optional(),
|
||||
q: z.string().trim().max(100).optional(),
|
||||
district: z.string().trim().max(60).optional(),
|
||||
/** Несколько районов через запятую. */
|
||||
districts: z.string().max(1000).optional(),
|
||||
priceFrom: z.coerce.number().nonnegative().optional(),
|
||||
priceTo: z.coerce.number().nonnegative().optional(),
|
||||
/** В какой валюте заданы границы цены: цены объектов пересчитываются по курсу. */
|
||||
priceCurrency: z.enum(['AMD', 'USD']).default('USD'),
|
||||
from: z.coerce.date().optional(),
|
||||
to: z.coerce.date().optional(),
|
||||
/** Только избранные объекты пользователя. */
|
||||
favorite: z.literal('1').optional(),
|
||||
/** Только объекты, карточку которых пользователь ещё не открывал. */
|
||||
unseen: z.literal('1').optional(),
|
||||
/** Руководителю — один отдел; none — объекты без отдела. */
|
||||
departmentId: z.union([z.literal('none'), z.coerce.number().int().positive()]).optional(),
|
||||
})
|
||||
.parse(req.query);
|
||||
const seeAll = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll;
|
||||
if (q.tab === 'all' && !seeAll) throw forbidden();
|
||||
|
||||
const fresh = await statusByCode(db, ctx.company.id, 'new');
|
||||
const where: (SQL | undefined)[] = [
|
||||
eq(leads.companyId, ctx.company.id),
|
||||
// Сотрудник видит только объекты своего отдела.
|
||||
departmentScope(actor(ctx), leads.departmentId),
|
||||
// Снятые с List.am и закрытые сделкой или отказом объекты в рабочие списки не попадают — они в «Базе».
|
||||
leadActual,
|
||||
];
|
||||
if (q.departmentId !== undefined && ctx.role === 'manager') where.push(inDepartment(leads.departmentId, q.departmentId === 'none' ? null : q.departmentId));
|
||||
if (q.favorite) where.push(sql`exists (select 1 from lead_favorites lf where lf.lead_id = "leads"."id" and lf.user_id = ${ctx.user.id})`);
|
||||
if (q.unseen) where.push(sql`not exists (select 1 from lead_views lv where lv.lead_id = "leads"."id" and lv.user_id = ${ctx.user.id})`);
|
||||
if (q.tab === 'pool') where.push(isNull(leads.assigneeId), eq(leads.statusId, fresh.id));
|
||||
if (q.tab === 'mine') where.push(eq(leads.assigneeId, ctx.user.id));
|
||||
if (q.statusId) where.push(eq(leads.statusId, q.statusId));
|
||||
if (q.assigneeId && q.tab === 'all') where.push(eq(leads.assigneeId, q.assigneeId));
|
||||
if (q.district) where.push(ilike(leads.district, q.district));
|
||||
const districts = q.districts?.split(',').map((d) => d.trim().toLowerCase()).filter(Boolean) ?? [];
|
||||
if (districts.length) where.push(inArray(sql`lower(${leads.district})`, districts));
|
||||
if (q.priceFrom !== undefined || q.priceTo !== undefined) {
|
||||
const { rates } = await loadBillingSettings(db);
|
||||
where.push(priceRange(leads.price, leads.currency, rates, q.priceCurrency, q.priceFrom, q.priceTo));
|
||||
}
|
||||
if (q.from) where.push(gte(leads.createdAt, q.from));
|
||||
if (q.to) where.push(lte(leads.createdAt, q.to));
|
||||
if (q.q) {
|
||||
// Поиск по телефону, адресу, названию и ID объявления.
|
||||
const like = `%${q.q.replace(/[%_\\]/g, '\\$&')}%`;
|
||||
const digits = q.q.replace(/\D/g, '');
|
||||
where.push(
|
||||
or(
|
||||
ilike(leads.address, like),
|
||||
ilike(leads.title, like),
|
||||
ilike(leads.url, like),
|
||||
// По телефону агент находит только свои лиды — чужие номера скрыты.
|
||||
digits.length >= 4
|
||||
? and(ilike(leads.phone, `%${digits}%`), ctx.role === 'manager' ? undefined : eq(leads.assigneeId, ctx.user.id))
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
lead: leads,
|
||||
assigneeName: users.name,
|
||||
departmentName: departments.name,
|
||||
favorite: sql<boolean>`exists (select 1 from lead_favorites lf where lf.lead_id = "leads"."id" and lf.user_id = ${ctx.user.id})`,
|
||||
seen: sql<boolean>`exists (select 1 from lead_views lv where lv.lead_id = "leads"."id" and lv.user_id = ${ctx.user.id})`,
|
||||
})
|
||||
.from(leads)
|
||||
.leftJoin(users, eq(users.id, leads.assigneeId))
|
||||
.leftJoin(departments, eq(departments.id, leads.departmentId))
|
||||
.where(and(...where))
|
||||
// Лид, чьё объявление подняли на list.am, встаёт наверх.
|
||||
.orderBy(desc(sql`coalesce(${leads.bumpedAt}, ${leads.createdAt})`), desc(leads.id))
|
||||
.limit(300);
|
||||
return rows.map((r) => ({ ...present(ctx, r.lead, r.assigneeName), departmentName: r.departmentName, favorite: r.favorite, seen: r.seen }));
|
||||
});
|
||||
|
||||
/** Курсы валют (сколько единиц в одном долларе): фильтр цены пересчитывает границы при смене валюты. */
|
||||
app.get('/api/rates', async (req) => {
|
||||
g.member(req);
|
||||
const { rates } = await loadBillingSettings(db);
|
||||
return { USD: 1, ...rates };
|
||||
});
|
||||
|
||||
app.get('/api/leads/:id', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const lead = await loadLead(db, actor(ctx), id);
|
||||
// Карточку открыли — объект больше не «непросмотренный» для этого пользователя.
|
||||
await db
|
||||
.insert(leadViews)
|
||||
.values({ userId: ctx.user.id, leadId: id })
|
||||
.onConflictDoUpdate({ target: [leadViews.userId, leadViews.leadId], set: { viewedAt: new Date() } });
|
||||
const [[favorite], [department]] = await Promise.all([
|
||||
db.select({ id: leadFavorites.leadId }).from(leadFavorites).where(and(eq(leadFavorites.userId, ctx.user.id), eq(leadFavorites.leadId, id))),
|
||||
lead.departmentId ? db.select({ name: departments.name }).from(departments).where(eq(departments.id, lead.departmentId)) : [],
|
||||
]);
|
||||
const [history, assignee, previous, interested, deal, listing] = await Promise.all([
|
||||
db
|
||||
.select({ event: events, userName: users.name })
|
||||
.from(events)
|
||||
.leftJoin(users, eq(users.id, events.userId))
|
||||
.where(eq(events.leadId, id))
|
||||
.orderBy(desc(events.at), desc(events.id)),
|
||||
lead.assigneeId ? db.select({ name: users.name }).from(users).where(eq(users.id, lead.assigneeId)) : [],
|
||||
lead.previousLeadId
|
||||
? db
|
||||
.select({ lead: leads, assigneeName: users.name, status: pipelineStatuses.name, removedAt: listings.removedAt })
|
||||
.from(leads)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.leftJoin(users, eq(users.id, leads.assigneeId))
|
||||
.leftJoin(listings, eq(listings.id, leads.listingId))
|
||||
.where(and(eq(leads.id, lead.previousLeadId), eq(leads.companyId, ctx.company.id)))
|
||||
: [],
|
||||
db
|
||||
.select({ client: clients, state: clientLeads.state, assigneeName: users.name })
|
||||
.from(clientLeads)
|
||||
.innerJoin(clients, eq(clients.id, clientLeads.clientId))
|
||||
.leftJoin(users, eq(users.id, clients.assigneeId))
|
||||
.where(eq(clientLeads.leadId, id))
|
||||
.orderBy(desc(clientLeads.createdAt)),
|
||||
db
|
||||
.select({ deal: deals, clientName: clients.name })
|
||||
.from(deals)
|
||||
.leftJoin(clients, eq(clients.id, deals.clientId))
|
||||
.where(eq(deals.leadId, id)),
|
||||
lead.listingId ? db.select({ active: listings.active, removedAt: listings.removedAt }).from(listings).where(eq(listings.id, lead.listingId)) : [],
|
||||
]);
|
||||
const prev = previous[0];
|
||||
const seesClients = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll;
|
||||
const d = deal[0];
|
||||
// В событиях назначения хранятся ID агентов — подставляем имена.
|
||||
const names = new Map(
|
||||
(await db.select({ id: users.id, name: users.name }).from(users).where(eq(users.companyId, ctx.company.id))).map(
|
||||
(u) => [String(u.id), u.name] as const,
|
||||
),
|
||||
);
|
||||
const byId = (v: string | null) => (v ? (names.get(v) ?? v) : null);
|
||||
// Полный набор фото и текст для клиента готовятся в фоне: открытие карточки ставит объект в очередь.
|
||||
const fromListing = Boolean(lead.listingId);
|
||||
if (fromListing && !lead.galleryAt && !lead.galleryWantedAt && lead.galleryTries < 3) {
|
||||
await db.update(leads).set({ galleryWantedAt: new Date() }).where(eq(leads.id, id));
|
||||
}
|
||||
return {
|
||||
...present(ctx, lead, assignee[0]?.name),
|
||||
favorite: Boolean(favorite),
|
||||
departmentName: department?.name ?? null,
|
||||
// Фото с List.am отдаёт сам сервис — обрезанными сверху, как на странице клиента.
|
||||
photos: lead.photos.slice(0, 20).map((src, i) => (/^https:\/\/(?:img|s)\.list\.am\//.test(src) ? `/api/leads/${id}/photos/${i}` : src)),
|
||||
/** ready — все фото загружены; pending — догружаются; none — объект заведён вручную или загрузить не удалось. */
|
||||
gallery: lead.galleryAt ? 'ready' : fromListing && lead.galleryTries < 3 ? 'pending' : 'none',
|
||||
/** Комиссия агентства с собственника: условие и ожидаемая сумма от цены объекта. */
|
||||
commission: commissionView(ctx.company.settings.commission, lead.dealType === 'rent' ? 'objectRent' : 'objectSale', lead.commission, lead.price, lead.currency),
|
||||
/** Формулировка в объявлении, по которой видно посредника или собственника против агентов. */
|
||||
agentPhrase: agentPhrase(`${lead.title}\n${lead.description ?? ''}`),
|
||||
/** Текст для клиента: карточка по шаблону, без адреса. */
|
||||
clientText: lead.showcaseText ?? renderShowcaseText(lead),
|
||||
// Варианты номера из общей базы видит тот же, кто видит сам телефон: ответственный и руководитель.
|
||||
phoneVariants: lead.listingId && (ctx.role === 'manager' || lead.assigneeId === ctx.user.id) ? await phoneVariants(db, lead.listingId) : [],
|
||||
// Заинтересованные клиенты: агент видит своих (и клиентов своего отдела при открытой видимости), руководитель — всех.
|
||||
clients: interested
|
||||
.filter((r) => ctx.role === 'manager' || r.client.assigneeId === ctx.user.id || (seesClients && sameDepartment(r.client.departmentId, ctx.user.departmentId)))
|
||||
.map((r) => ({ id: r.client.id, name: r.client.name, kind: r.client.kind, statusId: r.client.statusId, state: r.state, assigneeName: r.assigneeName })),
|
||||
deal: d ? { ...dealView(d.deal, names), clientId: d.deal.clientId, clientName: d.clientName } : null,
|
||||
// Объявление сняли с List.am (проверка раз в сутки).
|
||||
removedAt: listing[0]?.active === false ? listing[0].removedAt : null,
|
||||
// «Уже был в работе»: итог прошлой карточки виден всем, ссылка на неё — тем, кому она доступна.
|
||||
previous: prev
|
||||
? {
|
||||
id: canSee(actor(ctx), prev.lead) ? prev.lead.id : null,
|
||||
title: prev.lead.title, assigneeName: prev.assigneeName, status: prev.status, removedAt: prev.removedAt,
|
||||
}
|
||||
: null,
|
||||
events: history.map(({ event: e, userName }) => ({
|
||||
id: e.id,
|
||||
type: e.type,
|
||||
at: e.at,
|
||||
userName,
|
||||
oldValue: e.type === 'assigned' || e.type === 'pool_return' ? byId(e.oldValue) : e.oldValue,
|
||||
newValue: e.type === 'assigned' ? byId(e.newValue) : e.newValue,
|
||||
text: e.text,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// --- действия с лидом ---
|
||||
|
||||
app.post('/api/leads', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const lead = await createManualLead(db, actor(ctx), ManualLeadBody.parse(req.body));
|
||||
return { id: lead.id };
|
||||
});
|
||||
|
||||
/**
|
||||
* Объект по ссылке на list.am: есть в CRM и свободен — закрепляется за агентом; закреплён за
|
||||
* другим — ответ с его именем; нет в CRM — объявление скачивается с list.am и заводится на агента.
|
||||
*/
|
||||
app.post('/api/leads/by-url', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { url } = z.object({ url: z.string().trim().max(1000) }).parse(req.body);
|
||||
const externalId = /list\.am\/(?:[a-z]{2}\/)?item\/(\d{4,15})/i.exec(url)?.[1];
|
||||
if (!externalId) throw badRequest('BAD_LISTAM_URL');
|
||||
const [known] = await db.select().from(listings).where(eq(listings.externalId, externalId));
|
||||
// Сотрудник ищет объект в своём отделе: в другом отделе у объекта своя карточка.
|
||||
const found = await findListingLead(db, ctx.company.id, externalId, known?.id ?? null, ctx.role === 'manager' ? undefined : ctx.user.departmentId);
|
||||
if (found) return claimFoundLead(db, actor(ctx), found);
|
||||
let listing = known;
|
||||
if (!listing) {
|
||||
if (!source?.item) throw badRequest('PARSER_DISABLED');
|
||||
const fetched = await importListing(db, source, externalId).catch((error: unknown) => {
|
||||
console.error('[by-url]', error);
|
||||
throw badRequest('LISTAM_UNAVAILABLE');
|
||||
});
|
||||
if (!fetched) throw notFound('LISTAM_NOT_FOUND');
|
||||
listing = fetched;
|
||||
}
|
||||
return createClaimedLead(db, actor(ctx), listing);
|
||||
});
|
||||
|
||||
/** «Отправить в Telegram»: объект сразу уходит в группу компании её ботом. */
|
||||
app.post('/api/leads/:id/group', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const lead = await loadLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
await sendLeadToGroup({ db, config, sender: groupSender, fetchImage }, ctx.company, lead);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** «Отправить в бот»: объект уходит основным ботом в личный чат того, кто нажал кнопку. */
|
||||
app.post('/api/leads/:id/bot', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const lead = await loadLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
await sendLeadToUser({ db, config, sender: groupSender, fetchImage }, ctx.company, lead, ctx.user);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Избранное: у каждого пользователя своё. */
|
||||
app.post('/api/leads/:id/favorite', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const { on } = z.object({ on: z.boolean() }).parse(req.body);
|
||||
await loadLead(db, actor(ctx), id);
|
||||
if (on) await db.insert(leadFavorites).values({ userId: ctx.user.id, leadId: id }).onConflictDoNothing();
|
||||
else await db.delete(leadFavorites).where(and(eq(leadFavorites.userId, ctx.user.id), eq(leadFavorites.leadId, id)));
|
||||
return { ok: true, favorite: on };
|
||||
});
|
||||
|
||||
/** Удалить карточку объекта: руководитель; со сделкой — только суперадмин. */
|
||||
app.delete('/api/leads/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await deleteLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/take', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const lead = await takeLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
return present(ctx, lead, ctx.user.name);
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/assign', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { userId } = z.object({ userId: z.number().int().positive() }).parse(req.body);
|
||||
await assignLead(db, actor(ctx), Id.parse(req.params).id, userId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/release', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await releaseLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/status', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = z
|
||||
.object({
|
||||
statusId: z.number().int().positive(),
|
||||
rejectReasonId: z.number().int().positive().optional(),
|
||||
deal: DealBody.extend({ clientId: z.number().int().positive().optional() }).optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
await changeStatus(db, actor(ctx), Id.parse(req.params).id, body);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/comments', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { text } = z.object({ text: z.string().trim().min(1).max(5000) }).parse(req.body);
|
||||
await addComment(db, actor(ctx), Id.parse(req.params).id, text);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/next-action', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = z
|
||||
.object({ at: z.coerce.date().nullable(), note: z.string().trim().max(500).nullable().default(null) })
|
||||
.parse(req.body);
|
||||
await setNextAction(db, actor(ctx), Id.parse(req.params).id, body.at, body.note || null);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Условие комиссии для этого объекта; null возвращает условие из настроек компании. */
|
||||
app.post('/api/leads/:id/commission', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const { rule } = z.object({ rule: CommissionRule.nullable() }).parse(req.body);
|
||||
const lead = await loadLead(db, actor(ctx), id);
|
||||
if (ctx.role !== 'manager' && lead.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_LEAD');
|
||||
const before = ruleCode(lead.commission);
|
||||
const after = ruleCode(rule);
|
||||
if (before === after) return { ok: true };
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(leads).set({ commission: rule }).where(eq(leads.id, id));
|
||||
// В истории видно, кто и какое условие поставил; «было» — то, что действовало до правки, включая условие компании.
|
||||
const settings = ctx.company.settings.commission?.[lead.dealType === 'rent' ? 'objectRent' : 'objectSale'];
|
||||
await tx.insert(events).values({
|
||||
companyId: ctx.company.id, leadId: id, userId: ctx.user.id, type: 'commission',
|
||||
oldValue: before ?? ruleCode(settings), newValue: after,
|
||||
// Вместе с условием сохраняется, на сколько процентов изменилась ожидаемая сумма.
|
||||
text: commissionNote(after ? null : ruleCode(settings), commissionDelta(lead.commission ?? settings, rule ?? settings, lead.price, lead.currency)),
|
||||
});
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Телефон собственника: агент берёт его из объявления и вносит сам. */
|
||||
app.post('/api/leads/:id/phone', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { phone } = z.object({ phone: z.string().trim().max(40) }).parse(req.body);
|
||||
await setLeadPhone(db, actor(ctx), Id.parse(req.params).id, phone);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Фото объекта, обрезанное сверху. */
|
||||
app.get('/api/leads/:id/photos/:index', async (req, reply) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const { index } = z.object({ index: z.coerce.number().int().min(0).max(50) }).parse(req.params);
|
||||
const lead = await loadLead(db, actor(ctx), id);
|
||||
const url = lead.photos[index];
|
||||
if (!url || !/^https:\/\/(?:img|s)\.list\.am\//.test(url)) throw notFound();
|
||||
const photo = await croppedPhoto(config, url, fetchImage).catch(() => {
|
||||
throw notFound();
|
||||
});
|
||||
return reply.header('content-type', 'image/jpeg').header('cache-control', 'private, max-age=604800').send(photo);
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/agent', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await markAsAgent(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Правка данных объекта: те же поля, что у объекта вручную, кроме телефона и ссылки. */
|
||||
app.patch('/api/leads/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = onlySent(req.body, LeadEditBody.parse(req.body));
|
||||
if (!Object.keys(body).length) throw badRequest('VALIDATION');
|
||||
await editLead(db, actor(ctx), Id.parse(req.params).id, body);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/removed', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await markListingRemoved(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// --- фильтры ---
|
||||
|
||||
/**
|
||||
* Агент видит фильтры своего отдела (без отдела — общие фильтры компании) и свои личные;
|
||||
* руководитель — все фильтры: компании, отделов и личные фильтры агентов.
|
||||
*/
|
||||
app.get('/api/filters', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const rows = await db
|
||||
.select({ filter: filters, ownerName: users.name, departmentName: departments.name })
|
||||
.from(filters)
|
||||
.leftJoin(users, eq(users.id, filters.ownerUserId))
|
||||
.leftJoin(departments, eq(departments.id, filters.departmentId))
|
||||
.where(
|
||||
and(
|
||||
eq(filters.companyId, ctx.company.id),
|
||||
ctx.role === 'manager'
|
||||
? undefined
|
||||
: or(and(isNull(filters.ownerUserId), inDepartment(filters.departmentId, ctx.user.departmentId)), eq(filters.ownerUserId, ctx.user.id)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(filters.id));
|
||||
return rows.map(({ filter: f, ownerName, departmentName }) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
enabled: f.enabled,
|
||||
params: f.params,
|
||||
scope: f.ownerUserId !== null ? 'personal' : f.departmentId !== null ? 'department' : 'company',
|
||||
ownerUserId: f.ownerUserId,
|
||||
ownerName,
|
||||
departmentId: f.departmentId,
|
||||
departmentName,
|
||||
// Фильтр компании и отдела меняет руководитель, личный — его владелец.
|
||||
editable: f.ownerUserId === null ? ctx.role === 'manager' : f.ownerUserId === ctx.user.id,
|
||||
}));
|
||||
});
|
||||
|
||||
const FilterBody = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
enabled: z.boolean().default(true),
|
||||
params: FilterParams,
|
||||
});
|
||||
|
||||
/** Отдел компании по ID; чужой или несуществующий — ошибка. */
|
||||
const companyDepartment = async (ctx: MemberCtx, id: number) => {
|
||||
const [d] = await db.select({ id: departments.id }).from(departments).where(and(eq(departments.id, id), eq(departments.companyId, ctx.company.id)));
|
||||
if (!d) throw badRequest('BAD_DEPARTMENT');
|
||||
return d.id;
|
||||
};
|
||||
|
||||
app.post('/api/filters', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = FilterBody.extend({
|
||||
scope: z.enum(['company', 'department', 'personal']),
|
||||
departmentId: z.number().int().positive().optional(),
|
||||
}).parse(req.body);
|
||||
// Фильтры компании и отделов задаёт руководитель.
|
||||
if (body.scope !== 'personal' && ctx.role !== 'manager') throw forbidden();
|
||||
if (body.scope === 'department' && !body.departmentId) throw badRequest('BAD_DEPARTMENT');
|
||||
const departmentId = body.scope === 'department' ? await companyDepartment(ctx, body.departmentId!) : null;
|
||||
const [row] = await db
|
||||
.insert(filters)
|
||||
.values({
|
||||
companyId: ctx.company.id,
|
||||
ownerUserId: body.scope === 'personal' ? ctx.user.id : null,
|
||||
departmentId,
|
||||
name: body.name,
|
||||
enabled: body.enabled,
|
||||
params: body.params,
|
||||
})
|
||||
.returning();
|
||||
// Подходящие объявления из общей базы сразу попадают в пул (без уведомлений).
|
||||
return { id: row!.id, fromBase: await backfillFilter(db, row!.id) };
|
||||
});
|
||||
|
||||
const ownFilter = async (ctx: MemberCtx, id: number) => {
|
||||
const [f] = await db.select().from(filters).where(and(eq(filters.id, id), eq(filters.companyId, ctx.company.id)));
|
||||
if (!f) throw notFound();
|
||||
if (f.ownerUserId === null ? ctx.role !== 'manager' : f.ownerUserId !== ctx.user.id) throw forbidden();
|
||||
return f;
|
||||
};
|
||||
|
||||
app.patch('/api/filters/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const f = await ownFilter(ctx, id);
|
||||
const { departmentId, ...body } = onlySent(
|
||||
req.body,
|
||||
FilterBody.partial().extend({ departmentId: z.number().int().positive().nullable() }).partial().parse(req.body),
|
||||
);
|
||||
// Руководитель переносит фильтр компании в отдел и обратно; у личного фильтра отдела нет.
|
||||
const patch = {
|
||||
...body,
|
||||
...(departmentId !== undefined && f.ownerUserId === null
|
||||
? { departmentId: departmentId === null ? null : await companyDepartment(ctx, departmentId) }
|
||||
: {}),
|
||||
};
|
||||
if (!Object.keys(patch).length) throw badRequest('VALIDATION');
|
||||
await db.update(filters).set(patch).where(eq(filters.id, id));
|
||||
return { ok: true, fromBase: await backfillFilter(db, id) };
|
||||
});
|
||||
|
||||
app.delete('/api/filters/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
await ownFilter(ctx, id);
|
||||
await db.delete(filters).where(eq(filters.id, id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Счётчики для вкладок списка лидов. */
|
||||
app.get('/api/leads-summary', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const fresh = await statusByCode(db, ctx.company.id, 'new');
|
||||
const [row] = await db
|
||||
.select({
|
||||
pool: sql<number>`count(*) filter (where ${leads.assigneeId} is null and ${leads.statusId} = ${fresh.id})::int`,
|
||||
mine: sql<number>`count(*) filter (where ${leads.assigneeId} = ${ctx.user.id})::int`,
|
||||
all: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(leads)
|
||||
.where(and(eq(leads.companyId, ctx.company.id), departmentScope(actor(ctx), leads.departmentId), leadActual));
|
||||
return row ?? { pool: 0, mine: 0, all: 0 };
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user