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