Lead Radar: исходный код

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuriandClaude Opus 5.5 committed 2026-10-25 23:11:04 +04:00
commit 9749da5ae7
201 files changed
+97685

No files matched your search

+611
View File
@@ -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 };
});
}
+792
View File
@@ -0,0 +1,792 @@
import { normalizePhone, phoneConfidence } from '@lr/shared';
import { and, eq, isNull, or, sql, type AnyColumn, type SQL } from 'drizzle-orm';
import type { Db, Tx } from '../db/client.ts';
import {
blacklist,
companies,
deals,
events,
leads,
listingPhones,
listings,
pipelineStatuses,
rejectReasons,
users,
} from '../db/schema.ts';
import { badRequest, conflict, forbidden, notFound } from '../lib/errors.ts';
import { dealInput, dealText, dropDealSide, moveClientToDeal, recordDeal, type DealBody, type DealInput } from '../deals/service.ts';
import { statusByCode } from '../pipelines/service.ts';
import { enqueueLead, enqueueTaken, enqueueText } from '../telegram/outbox.ts';
// Жизненный цикл лида в воронке объекта: пул → «Беру» → статусы → сделка или отказ,
// возврат в пул. Каждое действие пишет событие в историю и ставит уведомления в очередь.
export type Lead = typeof leads.$inferSelect;
export type User = typeof users.$inferSelect;
type Company = typeof companies.$inferSelect;
type Listing = typeof listings.$inferSelect;
/** Кто действует: пользователь компании с ролью в текущем кабинете. */
export interface Actor {
user: User;
role: 'manager' | 'agent' | 'superadmin';
company: Company;
}
const isManager = (a: Actor) => a.role === 'manager';
/** Один и тот же отдел: пустой отдел («без отдела») равен только пустому. */
export const sameDepartment = (a: number | null | undefined, b: number | null | undefined) => (a ?? null) === (b ?? null);
/** Условие «запись из отдела department» для запросов; null — записи без отдела. */
export const inDepartment = (column: AnyColumn, department: number | null | undefined): SQL =>
department == null ? isNull(column) : eq(column, department);
/**
* Какие отделы видит пользователь в списках: руководитель и суперадмин — все (undefined — без ограничения),
* сотрудник — только свой.
*/
export const departmentScope = (actor: Actor, column: AnyColumn): SQL | undefined =>
isManager(actor) ? undefined : inDepartment(column, actor.user.departmentId);
/**
* Объект актуален: объявление не снято с List.am (у объекта вручную объявления нет) и карточка не закрыта
* сделкой или отказом. Неактуальные объекты остаются в базе и в разделе «База», но не попадают в рабочие
* списки, поиск, фильтры и подборки.
*/
// Столбцы внешней таблицы в подзапросах — с явным именем таблицы: без него Postgres может взять столбец подзапроса.
export const listingLive = sql`not exists (select 1 from listings li where li.id = "leads"."listing_id" and not li.active)`;
export const leadOpen = sql`not exists (select 1 from pipeline_statuses ps where ps.id = "leads"."status_id" and ps.code in ('deal', 'rejected'))`;
export const leadActual = sql`(${listingLive} and ${leadOpen})`;
/**
* Цена в выбранной валюте не ниже from и не выше to. Цена объекта пересчитывается по курсам
* (сколько единиц валюты в одном долларе); объект без валюты считается в долларах.
*/
export function priceRange(
price: AnyColumn,
currency: AnyColumn,
rates: Record<string, number>,
target: string,
from?: number,
to?: number,
): SQL | undefined {
if (from === undefined && to === undefined) return undefined;
const perUsd = (code: string) => rates[code] ?? (code === 'USD' ? 1 : undefined);
const known = Object.entries({ USD: 1, ...rates }).filter(([, rate]) => rate > 0);
// Цена объекта в долларах: делим на курс его валюты.
const usd = sql`(${price}::numeric / (case coalesce(${currency}, 'USD') ${sql.join(
known.map(([code, rate]) => sql`when ${code} then ${rate}::numeric`),
sql` `,
)} else 1 end))`;
const k = perUsd(target) ?? 1;
return and(
sql`${price} is not null`,
from !== undefined ? sql`${usd} >= ${from / k}::numeric` : undefined,
to !== undefined ? sql`${usd} <= ${to / k}::numeric` : undefined,
);
}
export const activeMembers = (tx: Tx, companyId: number) =>
tx
.select()
.from(users)
.where(and(eq(users.companyId, companyId), isNull(users.removedAt)));
async function logEvent(
tx: Tx,
lead: Pick<Lead, 'id' | 'companyId'>,
userId: number | null,
type: string,
extra: { oldValue?: string | null; newValue?: string | null; text?: string | null; statusId?: number } = {},
) {
await tx.insert(events).values({ companyId: lead.companyId, leadId: lead.id, userId, type, ...extra });
}
const touchCompany = (tx: Tx, companyId: number) =>
tx.update(companies).set({ lastActivityAt: new Date() }).where(eq(companies.id, companyId));
/**
* Может ли пользователь видеть лид. Руководитель — любой. Сотрудник — только лиды своего отдела:
* свой, из пула, либо любой при открытой видимости.
*/
export function canSee(actor: Actor, lead: Lead): boolean {
if (lead.companyId !== actor.company.id) return false;
if (isManager(actor)) return true;
if (!sameDepartment(lead.departmentId, actor.user.departmentId)) return false;
if (actor.company.settings.agentsSeeAll) return true;
return lead.assigneeId === actor.user.id || lead.assigneeId === null;
}
/** Вести лид (статус, комментарий, напоминание) может ответственный и руководитель. */
function assertCanWork(actor: Actor, lead: Lead) {
if (!isManager(actor) && lead.assigneeId !== actor.user.id) throw forbidden('NOT_YOUR_LEAD');
}
async function loadLead(tx: Tx, actor: Actor, leadId: number): Promise<Lead> {
const [lead] = await tx
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id)));
if (!lead || !canSee(actor, lead)) throw notFound('LEAD_NOT_FOUND');
return lead;
}
const copyProperty = (l: Listing) => ({
title: l.title,
description: l.description,
dealType: l.dealType,
propertyType: l.propertyType,
region: l.region,
district: l.district,
address: l.address,
price: l.price,
currency: l.currency,
rooms: l.rooms,
area: l.area,
floor: l.floor,
floorsTotal: l.floorsTotal,
photos: l.photos,
authorName: l.authorName,
phone: l.phone,
features: l.features,
});
/**
* Лид из объявления: статус «Новый», в пуле, уведомления подходящим агентам.
* Возвращает null, если у компании уже есть лид по этому объявлению.
*/
export async function createLeadFromListing(
tx: Tx,
companyId: number,
listing: Listing,
recipients: User[],
/** Прошлый лид по тому же объекту: объявление сняли и опубликовали снова. */
previousLeadId: number | null = null,
/** Отдел, в который пришёл объект; null — без отдела. */
departmentId: number | null = null,
) {
const fresh = await statusByCode(tx, companyId, 'new');
const [lead] = await tx
.insert(leads)
.values({ companyId, departmentId, listingId: listing.id, source: 'listam', url: listing.url, statusId: fresh.id, previousLeadId, bumpedAt: listing.bumpedAt, ...copyProperty(listing) })
.onConflictDoNothing()
.returning();
if (!lead) return null;
await logEvent(tx, lead, null, 'created', { newValue: 'listam' });
await enqueueLead(tx, lead.id, recipients);
return lead;
}
export interface ManualLead {
title: string;
description?: string;
dealType?: string;
propertyType?: string;
region?: string;
district?: string;
address?: string;
price?: number;
currency?: string;
rooms?: number;
area?: number;
floor?: number;
floorsTotal?: number;
authorName?: string;
phone?: string;
url?: string;
}
/** Лид вручную (объект не с List.am): сразу закреплён за создателем в статусе «Взят в работу». */
export async function createManualLead(db: Db, actor: Actor, input: ManualLead) {
return db.transaction(async (tx) => {
const taken = await statusByCode(tx, actor.company.id, 'taken');
const [lead] = await tx
.insert(leads)
.values({
...input,
phone: normalizePhone(input.phone) ?? input.phone ?? null,
photos: [],
companyId: actor.company.id,
// Объект попадает в отдел того, кто его завёл.
departmentId: actor.user.departmentId,
source: 'manual',
statusId: taken.id,
assigneeId: actor.user.id,
takenAt: new Date(),
createdById: actor.user.id,
})
.returning();
await logEvent(tx, lead!, actor.user.id, 'created', { newValue: 'manual' });
await touchCompany(tx, actor.company.id);
return lead!;
});
}
/** Чем кончилось добавление объекта ссылкой на list.am. */
export type ClaimResult =
/** Объект был в пуле — теперь он закреплён за агентом. */
| { result: 'taken'; id: number }
/** Объекта в CRM не было — заведён и закреплён за агентом. */
| { result: 'created'; id: number }
/** Объект уже у этого агента. */
| { result: 'mine'; id: number }
/** Объект закреплён за другим агентом; id — если агенту можно открыть карточку. */
| { result: 'busy'; id: number | null; assigneeName: string | null };
/**
* Лид компании по объявлению list.am: последний по связи с общей базой или по ссылке у объекта,
* заведённого вручную. `department` — искать только в этом отделе (undefined — во всей компании).
*/
export async function findListingLead(tx: Tx, companyId: number, externalId: string, listingId: number | null, department?: number | null) {
const byUrl = sql`${leads.url} ~ ${`list\\.am/(?:[a-z]{2}/)?item/${externalId}([^0-9]|$)`}`;
const [row] = await tx
.select({ lead: leads, assigneeName: users.name })
.from(leads)
.leftJoin(users, eq(users.id, leads.assigneeId))
.where(
and(
eq(leads.companyId, companyId),
department === undefined ? undefined : inDepartment(leads.departmentId, department),
listingId === null ? byUrl : or(eq(leads.listingId, listingId), byUrl),
),
)
.orderBy(sql`${leads.id} desc`)
.limit(1);
return row ?? null;
}
/**
* Агент добавил ссылкой объект, который уже есть в CRM: свободный объект из пула закрепляется
* за ним, иначе он узнаёт, за кем объект закреплён.
*/
export async function claimFoundLead(db: Db, actor: Actor, found: { lead: Lead; assigneeName: string | null }): Promise<ClaimResult> {
const { lead } = found;
if (lead.assigneeId === actor.user.id) return { result: 'mine', id: lead.id };
if (lead.assigneeId === null) {
const fresh = await statusByCode(db, actor.company.id, 'new');
if (lead.statusId === fresh.id) {
try {
return { result: 'taken', id: (await takeLead(db, actor, lead.id)).id };
} catch (error) {
// Объект успели взять, пока агент вставлял ссылку: показываем, кто взял.
if (!(error instanceof Error && 'code' in error && error.code === 'ALREADY_TAKEN')) throw error;
const [again] = await db.select({ lead: leads, assigneeName: users.name }).from(leads).leftJoin(users, eq(users.id, leads.assigneeId)).where(eq(leads.id, lead.id));
return claimFoundLead(db, actor, again!);
}
}
}
return { result: 'busy', id: canSee(actor, lead) ? lead.id : null, assigneeName: found.assigneeName };
}
/** Объекта ещё нет в CRM компании: лид из объявления сразу закреплён за агентом, без пула и рассылки. */
export async function createClaimedLead(db: Db, actor: Actor, listing: Listing): Promise<ClaimResult> {
const lead = await db.transaction(async (tx) => {
const taken = await statusByCode(tx, actor.company.id, 'taken');
const [row] = await tx
.insert(leads)
.values({
companyId: actor.company.id, departmentId: actor.user.departmentId, listingId: listing.id, source: 'listam', url: listing.url, statusId: taken.id,
assigneeId: actor.user.id, takenAt: new Date(), createdById: actor.user.id, bumpedAt: listing.bumpedAt, ...copyProperty(listing),
})
.onConflictDoNothing()
.returning();
if (!row) return null;
await logEvent(tx, row, actor.user.id, 'created', { newValue: 'link' });
await touchCompany(tx, actor.company.id);
return row;
});
if (lead) return { result: 'created', id: lead.id };
// Лид по этому объявлению появился одновременно (парсер или другой агент).
const found = await findListingLead(db, actor.company.id, listing.externalId, listing.id, actor.user.departmentId);
return claimFoundLead(db, actor, found!);
}
/**
* Удаление карточки объекта вместе с историей. Удаляет руководитель (и суперадмин в кабинете
* компании); карточку со сделкой — только суперадмин: сделка уходит из аналитики, ведомости и счетов.
*/
export async function deleteLead(db: Db, actor: Actor, leadId: number) {
if (!isManager(actor)) throw forbidden();
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
const [deal] = await tx.select({ id: deals.id }).from(deals).where(eq(deals.leadId, lead.id));
if (deal) {
if (actor.user.role !== 'superadmin') throw forbidden('HAS_DEAL');
await dropDealSide(tx, 'lead', lead.id);
}
await tx.update(leads).set({ previousLeadId: null }).where(eq(leads.previousLeadId, lead.id));
await tx.delete(events).where(eq(events.leadId, lead.id));
await tx.delete(leads).where(eq(leads.id, lead.id));
await touchCompany(tx, actor.company.id);
});
}
/**
* «Беру». Лид получает ровно один агент: условие «ответственного нет» проверяется самим
* UPDATE, поэтому при одновременном нажатии второй запрос не найдёт строку и получит «Уже взят».
*/
export async function takeLead(db: Db, actor: Actor, leadId: number): Promise<Lead> {
return db.transaction(async (tx) => {
const fresh = await statusByCode(tx, actor.company.id, 'new');
const taken = await statusByCode(tx, actor.company.id, 'taken');
const [lead] = await tx
.update(leads)
.set({
assigneeId: actor.user.id,
statusId: taken.id,
takenAt: new Date(),
statusChangedAt: new Date(),
poolWarnedAt: null,
})
.where(
and(
eq(leads.id, leadId),
eq(leads.companyId, actor.company.id),
// Объект чужого отдела взять нельзя.
departmentScope(actor, leads.departmentId),
isNull(leads.assigneeId),
eq(leads.statusId, fresh.id),
),
)
.returning();
if (!lead) {
const [exists] = await tx
.select({ id: leads.id })
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id), departmentScope(actor, leads.departmentId)));
throw exists ? conflict('ALREADY_TAKEN') : notFound('LEAD_NOT_FOUND');
}
await logEvent(tx, lead, actor.user.id, 'taken');
await enqueueTaken(tx, lead.id, actor.user.id);
await touchCompany(tx, actor.company.id);
return lead;
});
}
/** Руководитель назначает или переназначает лид. Оба агента получают уведомление. */
export async function assignLead(db: Db, actor: Actor, leadId: number, toUserId: number): Promise<Lead> {
if (!isManager(actor)) throw forbidden();
return db.transaction(async (tx) => {
const [before] = await tx
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id)))
.for('update');
if (!before) throw notFound('LEAD_NOT_FOUND');
const [target] = await tx
.select()
.from(users)
.where(and(eq(users.id, toUserId), eq(users.companyId, actor.company.id), isNull(users.removedAt)));
if (!target) throw badRequest('BAD_ASSIGNEE');
if (before.assigneeId === toUserId) return before;
// Объект переходит в отдел нового ответственного — иначе тот его не увидит. Если в том отделе уже
// есть карточка по этому объявлению, вторую туда не переносим.
const moving = !sameDepartment(before.departmentId, target.departmentId);
if (moving && before.listingId && before.previousLeadId === null) {
const [twin] = await tx
.select({ id: leads.id })
.from(leads)
.where(
and(
eq(leads.companyId, actor.company.id),
eq(leads.listingId, before.listingId),
isNull(leads.previousLeadId),
inDepartment(leads.departmentId, target.departmentId),
),
);
if (twin) throw conflict('DEPARTMENT_HAS_LEAD');
}
const fresh = await statusByCode(tx, actor.company.id, 'new');
const taken = await statusByCode(tx, actor.company.id, 'taken');
const fromPool = before.assigneeId === null;
const [lead] = await tx
.update(leads)
.set({
assigneeId: toUserId,
departmentId: target.departmentId,
poolWarnedAt: null,
statusChangedAt: new Date(),
...(before.statusId === fresh.id ? { statusId: taken.id } : {}),
...(fromPool ? { takenAt: new Date() } : {}),
})
.where(eq(leads.id, leadId))
.returning();
await logEvent(tx, lead!, actor.user.id, 'assigned', {
oldValue: before.assigneeId ? String(before.assigneeId) : null,
newValue: String(toUserId),
});
if (fromPool) await enqueueTaken(tx, leadId, toUserId);
if (toUserId !== actor.user.id) await enqueueText(tx, [toUserId], 'assigned', [lead!.title], leadId);
if (before.assigneeId && before.assigneeId !== actor.user.id) {
await enqueueText(tx, [before.assigneeId], 'reassignedAway', [lead!.title, target.name]);
}
await touchCompany(tx, actor.company.id);
return lead!;
});
}
/**
* Возврат в пул: лид снова «Новый» и без ответственного, история сохраняется.
* `actor = null` — возврат по правилу «N часов без смены статуса»: прежний агент получает
* уведомление о потере. Остальные участники получают повторное уведомление с кнопкой «Беру».
*/
export async function returnToPool(tx: Tx, lead: Lead, actorUserId: number | null): Promise<void> {
const fresh = await statusByCode(tx, lead.companyId, 'new');
const previous = lead.assigneeId;
await tx
.update(leads)
.set({ assigneeId: null, statusId: fresh.id, takenAt: null, statusChangedAt: new Date(), poolWarnedAt: null })
.where(eq(leads.id, lead.id));
await logEvent(tx, lead, actorUserId, 'pool_return', { oldValue: previous ? String(previous) : null });
if (previous && actorUserId === null) await enqueueText(tx, [previous], 'poolLost', [lead.title]);
// «Беру» снова получают сотрудники отдела, которому принадлежит объект.
const others = (await activeMembers(tx, lead.companyId)).filter((u) => u.id !== previous && sameDepartment(u.departmentId, lead.departmentId));
await enqueueLead(tx, lead.id, others, true);
}
export async function releaseLead(db: Db, actor: Actor, leadId: number) {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
if (lead.assigneeId === null) throw badRequest('ALREADY_IN_POOL');
assertCanWork(actor, lead);
await returnToPool(tx, lead, actor.user.id);
await touchCompany(tx, actor.company.id);
});
}
export interface StatusChange {
statusId: number;
rejectReasonId?: number;
/** Обязательна при переводе в «Сделка». */
deal?: DealBody & { clientId?: number };
}
export async function changeStatus(db: Db, actor: Actor, leadId: number, change: StatusChange): Promise<Lead> {
return db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
// Лид в пуле сначала нужно взять.
if (lead.assigneeId === null) throw badRequest('TAKE_FIRST');
assertCanWork(actor, lead);
const statuses = await tx
.select()
.from(pipelineStatuses)
.where(and(eq(pipelineStatuses.companyId, actor.company.id), eq(pipelineStatuses.pipeline, 'object')));
const from = statuses.find((s) => s.id === lead.statusId);
const to = statuses.find((s) => s.id === change.statusId && !s.deletedAt);
if (!to) throw badRequest('BAD_STATUS');
if (to.id === lead.statusId) return lead;
// «Новый» — это пул: туда лид попадает только возвратом в пул.
if (to.code === 'new') throw badRequest('USE_RETURN_TO_POOL');
if (from?.code === 'deal' && !isManager(actor)) throw forbidden('DEAL_CLOSED');
let rejectReasonId: number | null = null;
if (to.code === 'rejected') {
const [reason] = change.rejectReasonId
? await tx
.select()
.from(rejectReasons)
.where(
and(
eq(rejectReasons.id, change.rejectReasonId),
eq(rejectReasons.companyId, actor.company.id),
eq(rejectReasons.pipeline, 'object'),
),
)
: [];
if (!reason) throw badRequest('REJECT_REASON_REQUIRED');
rejectReasonId = reason.id;
}
let deal: DealInput | null = null;
if (to.code === 'deal') {
if (!change.deal) throw badRequest('COMMISSION_REQUIRED');
deal = dealInput(change.deal, 'owner');
await recordDeal(tx, {
companyId: actor.company.id,
leadId,
clientId: change.deal.clientId ?? null,
agentId: lead.assigneeId,
deal,
});
// Сделка с клиентом из CRM закрывается один раз: клиент переходит в «Сделка» вместе с объектом.
if (change.deal.clientId) {
await moveClientToDeal(tx, actor.company.id, actor.user.id, leadId, change.deal.clientId, deal);
}
} else if (from?.code === 'deal') {
await dropDealSide(tx, 'lead', leadId);
}
const [updated] = await tx
.update(leads)
.set({ statusId: to.id, rejectReasonId, statusChangedAt: new Date(), poolWarnedAt: null })
.where(eq(leads.id, leadId))
.returning();
await logEvent(tx, lead, actor.user.id, to.code === 'deal' ? 'deal' : 'status', {
oldValue: from?.name ?? null,
newValue: to.name,
statusId: to.id,
text: deal ? dealText(deal) : null,
});
await touchCompany(tx, actor.company.id);
return updated!;
});
}
export async function addComment(db: Db, actor: Actor, leadId: number, text: string) {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
assertCanWork(actor, lead);
await logEvent(tx, lead, actor.user.id, 'comment', { text });
await touchCompany(tx, actor.company.id);
});
}
export async function setNextAction(db: Db, actor: Actor, leadId: number, at: Date | null, note: string | null) {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
assertCanWork(actor, lead);
await tx
.update(leads)
.set({ nextActionAt: at, nextActionNote: note, nextActionNotifiedAt: null })
.where(eq(leads.id, leadId));
});
}
export interface PhoneVariant {
phone: string;
/** Сколько компаний указали этот номер. */
votes: number;
/** 80 — совпал у двух, 100 — у трёх и больше, null — указал один агент. */
confidence: number | null;
}
/** Все варианты номера по объявлению из общей базы: самый подтверждённый — первым. */
export async function phoneVariants(tx: Tx, listingId: number): Promise<PhoneVariant[]> {
const rows = await tx
.select({ phone: listingPhones.phone, votes: sql<number>`count(*)::int`, first: sql<Date>`min(${listingPhones.createdAt})` })
.from(listingPhones)
.where(eq(listingPhones.listingId, listingId))
.groupBy(listingPhones.phone)
.orderBy(sql`count(*) desc`, sql`min(${listingPhones.createdAt})`);
return rows.map((r) => ({ phone: r.phone, votes: r.votes, confidence: phoneConfidence(r.votes) }));
}
/**
* Телефон собственника, который агент взял из объявления и внёс вручную. Вариант компании
* записывается в общую базу; номером объявления становится самый подтверждённый вариант — он же
* работает в правиле «больше N объявлений с номера». Пустая строка стирает номер у лида и вариант компании.
*/
export async function setLeadPhone(db: Db, actor: Actor, leadId: number, raw: string) {
const phone = raw.trim() ? normalizePhone(raw) : null;
if (raw.trim() && !phone) throw badRequest('BAD_PHONE');
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
assertCanWork(actor, lead);
if (lead.phone === phone) return;
await tx.update(leads).set({ phone }).where(eq(leads.id, leadId));
if (lead.listingId) {
const mine = and(eq(listingPhones.listingId, lead.listingId), eq(listingPhones.companyId, actor.company.id));
if (phone) {
await tx
.insert(listingPhones)
.values({ listingId: lead.listingId, companyId: actor.company.id, userId: actor.user.id, phone })
.onConflictDoUpdate({ target: [listingPhones.listingId, listingPhones.companyId], set: { phone, userId: actor.user.id, createdAt: new Date() } });
} else {
await tx.delete(listingPhones).where(mine);
}
const [best] = await phoneVariants(tx, lead.listingId);
await tx.update(listings).set({ phone: best?.phone ?? null }).where(eq(listings.id, lead.listingId));
}
await logEvent(tx, lead, actor.user.id, 'phone', { newValue: phone });
await touchCompany(tx, actor.company.id);
});
}
export type LeadEdit = Partial<
Pick<Lead, 'title' | 'description' | 'dealType' | 'propertyType' | 'region' | 'district' | 'address' | 'price' | 'currency' | 'rooms' | 'area' | 'floor' | 'floorsTotal' | 'authorName'>
>;
/**
* Правка данных объекта. Меняется только карточка компании — общая база объявлений остаётся как есть.
* Текст для клиента собирается заново из новых данных.
*/
export async function editLead(db: Db, actor: Actor, leadId: number, patch: LeadEdit) {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
assertCanWork(actor, lead);
const changed = Object.fromEntries(
Object.entries(patch).filter(([key, value]) => String(lead[key as keyof LeadEdit] ?? '') !== String(value ?? '')),
) as LeadEdit;
if (!Object.keys(changed).length) return;
await tx.update(leads).set({ ...changed, showcaseText: null }).where(eq(leads.id, leadId));
await logEvent(tx, lead, actor.user.id, 'edited');
await touchCompany(tx, actor.company.id);
});
}
/**
* «Объявления больше нет»: агент увидел, что объявление сняли, раньше суточной проверки.
* Отметка ставится в общей базе — у всех компаний с этим объектом; если объявление снова появится
* в списке list.am, парсер вернёт его на сайт сам.
*/
export async function markListingRemoved(db: Db, actor: Actor, leadId: number) {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
if (lead.assigneeId !== null) assertCanWork(actor, lead);
if (!lead.listingId) throw badRequest('NO_LISTING');
const now = new Date();
const [row] = await tx
.update(listings)
.set({ active: false, removedAt: now, lastCheckedAt: now })
.where(and(eq(listings.id, lead.listingId), eq(listings.active, true)))
.returning({ id: listings.id });
if (!row) return;
await logEvent(tx, lead, actor.user.id, 'listing_removed');
await rejectRemovedListingLeads(tx, row.id);
await touchCompany(tx, actor.company.id);
});
}
/**
* Объявление сняли с List.am: у компаний с настройкой autoRejectRemoved открытые лиды по нему
* (не «Сделка» и не «Отказ») уходят в «Отказ» с причиной «Уже продано или сдано» — в архив,
* из пула и рабочих списков. Возвращает число переведённых лидов.
*/
export async function rejectRemovedListingLeads(tx: Tx, listingId: number): Promise<number> {
const open = await tx
.select({ lead: leads, status: pipelineStatuses.name })
.from(leads)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.innerJoin(companies, eq(companies.id, leads.companyId))
.where(
and(
eq(leads.listingId, listingId),
sql`coalesce(${pipelineStatuses.code}, '') not in ('deal', 'rejected')`,
sql`(${companies.settings}->>'autoRejectRemoved')::boolean is true`,
),
);
for (const { lead, status } of open) {
const rejected = await statusByCode(tx, lead.companyId, 'rejected');
// У компаний, заведённых до кода 'gone', причина находится по названию.
const [reason] = await tx
.select({ id: rejectReasons.id })
.from(rejectReasons)
.where(
and(
eq(rejectReasons.companyId, lead.companyId),
eq(rejectReasons.pipeline, 'object'),
or(eq(rejectReasons.code, 'gone'), and(isNull(rejectReasons.code), eq(rejectReasons.name, 'Уже продано или сдано'))),
),
)
.orderBy(sql`${rejectReasons.code} nulls last`)
.limit(1);
await tx
.update(leads)
.set({ statusId: rejected.id, rejectReasonId: reason?.id ?? null, statusChangedAt: new Date(), poolWarnedAt: null, nextActionAt: null })
.where(eq(leads.id, lead.id));
await logEvent(tx, lead, null, 'status', {
oldValue: status,
newValue: rejected.name,
statusId: rejected.id,
text: 'Объявление снято с List.am',
});
}
return open.length;
}
/** «Это агент»: номер автора — в чёрный список компании (если он известен), лид — в «Отказ». */
export async function markAsAgent(db: Db, actor: Actor, leadId: number): Promise<void> {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
if (lead.assigneeId !== null) assertCanWork(actor, lead);
const rejected = await statusByCode(tx, actor.company.id, 'rejected');
const [reason] = await tx
.select()
.from(rejectReasons)
.where(
and(
eq(rejectReasons.companyId, actor.company.id),
eq(rejectReasons.pipeline, 'object'),
eq(rejectReasons.code, 'agent'),
),
);
if (lead.phone) {
await tx
.insert(blacklist)
.values({ companyId: actor.company.id, phone: lead.phone, addedById: actor.user.id })
.onConflictDoNothing();
}
const wasInPool = lead.assigneeId === null;
await tx
.update(leads)
.set({
statusId: rejected.id,
rejectReasonId: reason?.id ?? null,
assigneeId: lead.assigneeId ?? actor.user.id,
statusChangedAt: new Date(),
poolWarnedAt: null,
})
.where(eq(leads.id, leadId));
await logEvent(tx, lead, actor.user.id, 'blacklisted', { newValue: lead.phone, statusId: rejected.id });
if (wasInPool) await enqueueTaken(tx, leadId, actor.user.id);
await touchCompany(tx, actor.company.id);
});
}
/**
* Фоновые задачи по лидам, запускаются раз в минуту:
* предупреждение за 2 часа до возврата в пул, сам возврат, напоминания о следующем действии.
*/
export async function runLeadJobs(db: Db, now = new Date()): Promise<{ warned: number; returned: number; reminded: number }> {
const result = { warned: 0, returned: 0, reminded: 0 };
const due = await db
.select({ lead: leads, hours: sql<number>`(${companies.settings}->>'poolReturnHours')::int` })
.from(leads)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.innerJoin(companies, eq(companies.id, leads.companyId))
.where(
and(
sql`${leads.assigneeId} is not null`,
eq(pipelineStatuses.poolReturn, true),
// Предупреждение уходит за 2 часа до срока; при сроке меньше 2 часов — сразу.
sql`${leads.statusChangedAt} <= ${now.toISOString()}::timestamptz - make_interval(hours => greatest((${companies.settings}->>'poolReturnHours')::int - 2, 0))`,
),
);
for (const { lead, hours } of due) {
const expired = lead.statusChangedAt.getTime() <= now.getTime() - hours * 3_600_000;
await db.transaction(async (tx) => {
// Перечитываем под блокировкой: агент мог сменить статус, пока шла выборка.
const [current] = await tx.select().from(leads).where(eq(leads.id, lead.id)).for('update');
if (!current || current.assigneeId === null || current.statusChangedAt.getTime() !== lead.statusChangedAt.getTime()) return;
if (expired) {
await returnToPool(tx, current, null);
result.returned++;
} else if (!current.poolWarnedAt) {
const left = Math.max(1, Math.round((current.statusChangedAt.getTime() + hours * 3_600_000 - now.getTime()) / 3_600_000));
await enqueueText(tx, [current.assigneeId], 'poolWarn', [current.title, left], current.id);
await tx.update(leads).set({ poolWarnedAt: now }).where(eq(leads.id, current.id));
result.warned++;
}
});
}
const reminders = await db
.update(leads)
.set({ nextActionNotifiedAt: now })
.where(
and(
sql`${leads.nextActionAt} <= ${now.toISOString()}::timestamptz`,
isNull(leads.nextActionNotifiedAt),
sql`${leads.assigneeId} is not null`,
),
)
.returning();
for (const lead of reminders) {
await enqueueText(db, [lead.assigneeId!], 'reminder', [lead.title, lead.nextActionNote], lead.id);
result.reminded++;
}
return result;
}
export { loadLead, statusByCode };