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'; export 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: (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 = (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`exists (select 1 from lead_favorites lf where lf.lead_id = "leads"."id" and lf.user_id = ${ctx.user.id})`, seen: sql`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`count(*) filter (where ${leads.assigneeId} is null and ${leads.statusId} = ${fresh.id})::int`, mine: sql`count(*) filter (where ${leads.assigneeId} = ${ctx.user.id})::int`, all: sql`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 }; }); }