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