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

+93
View File
@@ -0,0 +1,93 @@
import { DEAL_TYPES, PROPERTY_TYPES } from '@lr/shared';
import { and, desc, eq, gte, ilike, lte, or, sql, type SQL } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { Db } from './db/client.ts';
import { companies, leads, listings, pipelineStatuses, users } from './db/schema.ts';
import { notFound } from './lib/errors.ts';
const PAGE = 50;
/** Суперадмин: общая база объявлений сервиса — всё, что парсер собрал с list.am. */
export function registerAdminListingRoutes(app: FastifyInstance, db: Db, superadmin: (req: FastifyRequest) => unknown) {
app.get('/api/admin/listings', async (req) => {
superadmin(req);
const q = z
.object({
q: z.string().trim().max(100).optional(),
dealType: z.enum(DEAL_TYPES).optional(),
propertyType: z.enum(PROPERTY_TYPES).optional(),
region: z.string().trim().max(60).optional(),
/** active — на сайте, removed — сняты. */
state: z.enum(['active', 'removed']).optional(),
/** yes — по объявлению есть лид хотя бы у одной компании, no — ни у кого. */
leads: z.enum(['yes', 'no']).optional(),
author: z.enum(['owner', 'agency']).optional(),
priceFrom: z.coerce.number().nonnegative().optional(),
priceTo: z.coerce.number().nonnegative().optional(),
page: z.coerce.number().int().min(1).default(1),
})
.parse(req.query);
const leadCount = sql<number>`(select count(*)::int from leads l where l.listing_id = "listings"."id")`;
const where: (SQL | undefined)[] = [];
if (q.dealType) where.push(eq(listings.dealType, q.dealType));
if (q.propertyType) where.push(eq(listings.propertyType, q.propertyType));
if (q.region) where.push(eq(listings.region, q.region));
if (q.state) where.push(eq(listings.active, q.state === 'active'));
if (q.author) where.push(eq(listings.isAgency, q.author === 'agency'));
if (q.priceFrom !== undefined) where.push(gte(listings.price, q.priceFrom));
if (q.priceTo !== undefined) where.push(lte(listings.price, q.priceTo));
if (q.leads) where.push(sql`${q.leads === 'yes' ? sql`` : sql`not `}exists (select 1 from leads l where l.listing_id = "listings"."id")`);
if (q.q) {
// Поиск по номеру объявления, названию, адресу, району и телефону.
const like = `%${q.q.replace(/[%_\\]/g, '\\$&')}%`;
const digits = q.q.replace(/\D/g, '');
where.push(
or(
eq(listings.externalId, q.q),
ilike(listings.title, like),
ilike(listings.address, like),
ilike(listings.district, like),
digits.length >= 4 ? ilike(listings.phone, `%${digits}%`) : undefined,
),
);
}
const [[total], rows] = await Promise.all([
db.select({ n: sql<number>`count(*)::int` }).from(listings).where(and(...where)),
db
.select({ listing: listings, leads: leadCount })
.from(listings)
.where(and(...where))
.orderBy(desc(sql`coalesce(${listings.bumpedAt}, ${listings.publishedAt}, ${listings.createdAt})`), desc(listings.id))
.limit(PAGE)
.offset((q.page - 1) * PAGE),
]);
return {
total: total?.n ?? 0,
pageSize: PAGE,
items: rows.map(({ listing: l, leads: n }) => ({
id: l.id, externalId: l.externalId, url: l.url, title: l.title, 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, photo: l.photos[0] ?? null, isAgency: l.isAgency,
phone: l.phone, publishedAt: l.publishedAt, bumpedAt: l.bumpedAt, createdAt: l.createdAt, active: l.active, removedAt: l.removedAt, leads: n,
})),
};
});
/** Объявление целиком и то, каким компаниям оно ушло лидом. */
app.get('/api/admin/listings/:id', async (req) => {
superadmin(req);
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(req.params);
const [listing] = await db.select().from(listings).where(eq(listings.id, id));
if (!listing) throw notFound();
const sent = await db
.select({ id: leads.id, company: companies.name, status: pipelineStatuses.name, assignee: users.name, createdAt: leads.createdAt })
.from(leads)
.innerJoin(companies, eq(companies.id, leads.companyId))
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.leftJoin(users, eq(users.id, leads.assigneeId))
.where(eq(leads.listingId, id))
.orderBy(desc(leads.id));
return { ...listing, leads: sent };
});
}
+20
View File
@@ -0,0 +1,20 @@
import { hasModule, PIPELINES, type Plan } from '@lr/shared';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Db } from '../db/client.ts';
import type { Guards } from '../leads/routes.ts';
import { badRequest, forbidden } from '../lib/errors.ts';
import { buildAnalytics } from './service.ts';
export function registerAnalyticsRoutes(app: FastifyInstance, db: Db, g: Guards) {
/** Аналитика по сотрудникам: только руководителю и только в тарифах Team и Agency. */
app.get('/api/analytics', async (req) => {
const ctx = g.manager(req);
if (!hasModule(ctx.company.plan as Plan, 'analytics')) throw forbidden('MODULE_NOT_AVAILABLE');
const q = z
.object({ pipeline: z.enum(PIPELINES).default('object'), from: z.coerce.date(), to: z.coerce.date() })
.parse(req.query);
if (q.from > q.to) throw badRequest('VALIDATION');
return buildAnalytics(db, ctx.company.id, q.pipeline, q.from, q.to);
});
}
+281
View File
@@ -0,0 +1,281 @@
import type { Pipeline } from '@lr/shared';
import { and, asc, eq, gte, inArray, isNotNull, isNull, lte, sql } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { clientLeads, clients, deals, events, leads, pipelineStatuses, rejectReasons, users } from '../db/schema.ts';
// Аналитика по сотрудникам (ТЗ, раздел 6). Считается отдельно по воронке объектов и по воронке
// клиентов. Конверсия по этапам — когортная: берутся карточки, которые агент получил за период,
// и для каждого этапа считается, сколько из них до него дошли (на сегодня).
export interface AgentRow {
userId: number;
name: string;
/** Объекты: нажатия «Беру» и назначения. Клиенты: новые запросы. */
taken: number;
/** Медиана времени от уведомления до «Беру», секунды; null — нет данных. Только объекты. */
reactionSec: number | null;
/** Лиды, потерянные по правилу возврата в пул. Только объекты. */
poolReturns: number;
/** Показы объектов клиентам. Только клиенты. */
showings: number;
/** Карточек в когорте — знаменатель конверсии. */
cohort: number;
/** Сколько карточек когорты дошли до статуса: statusId → число. */
reached: Record<number, number>;
deals: number;
/** Сумма комиссий по валютам. */
commission: Record<string, number>;
/** Отказы по причинам: reasonId → число. */
reasons: Record<number, number>;
}
export interface Analytics {
pipeline: Pipeline;
statuses: { id: number; code: string | null; name: string }[];
reasons: { id: number; name: string }[];
/** Сводка по компании: пришло (создано) за период, взято, сейчас в пуле (у клиентов — в работе). */
summary: { created: number; taken: number; open: number; funnel: Record<number, number>; cohort: number };
agents: AgentRow[];
totals: AgentRow;
}
const median = (values: number[]) => {
if (!values.length) return null;
const s = [...values].sort((a, b) => a - b);
const mid = s.length >> 1;
return Math.round(s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2);
};
const emptyRow = (userId: number, name: string): AgentRow => ({
userId, name, taken: 0, reactionSec: null, poolReturns: 0, showings: 0, cohort: 0, reached: {}, deals: 0, commission: {}, reasons: {},
});
const bump = (map: Record<string | number, number>, key: string | number, by = 1) => {
map[key] = (map[key] ?? 0) + by;
};
/** Дата по Еревану (UTC+4) для сравнения с датой сделки. */
const yerevanDate = (d: Date) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10);
export async function buildAnalytics(db: Db, companyId: number, pipeline: Pipeline, from: Date, to: Date): Promise<Analytics> {
const [statuses, reasons, members] = await Promise.all([
db
.select()
.from(pipelineStatuses)
.where(and(eq(pipelineStatuses.companyId, companyId), eq(pipelineStatuses.pipeline, pipeline), isNull(pipelineStatuses.deletedAt)))
.orderBy(asc(pipelineStatuses.position)),
// Удалённые причины остаются в отчёте: по ним могли быть отказы.
db.select().from(rejectReasons).where(and(eq(rejectReasons.companyId, companyId), eq(rejectReasons.pipeline, pipeline))).orderBy(asc(rejectReasons.position)),
db.select({ id: users.id, name: users.name, removedAt: users.removedAt }).from(users).where(eq(users.companyId, companyId)).orderBy(asc(users.id)),
]);
const position = new Map(statuses.map((s, i) => [s.id, i]));
const rejectedId = statuses.find((s) => s.code === 'rejected')?.id;
const takenId = statuses.find((s) => s.code === 'taken')?.id;
const rows = new Map(members.map((m) => [m.id, emptyRow(m.id, m.name)]));
const row = (userId: number) => rows.get(userId) ?? rows.set(userId, emptyRow(userId, `#${userId}`)).get(userId)!;
const summary = { created: 0, taken: 0, open: 0, funnel: {} as Record<number, number>, cohort: 0 };
/**
* Карточка «дошла до этапа», если побывала в нём или в любом более позднем рабочем этапе.
* «Отказ» считается отдельно: до него доходят из любого этапа.
*/
const countReached = (target: Record<number, number>, visited: Set<number>) => {
let max = -1;
for (const id of visited) if (id !== rejectedId) max = Math.max(max, position.get(id) ?? -1);
for (const s of statuses) {
if (s.id === rejectedId ? visited.has(s.id) : position.get(s.id)! <= max) bump(target, s.id);
}
};
const inPeriod = (column: Parameters<typeof gte>[0]) => and(gte(column, from), lte(column, to));
let allReactions: number[] = [];
const fromDay = yerevanDate(from);
const toDay = yerevanDate(to);
if (pipeline === 'object') {
// Кто и когда получил лид за период.
const takes = await db
.select()
.from(events)
.where(and(eq(events.companyId, companyId), isNotNull(events.leadId), inArray(events.type, ['taken', 'assigned']), inPeriod(events.at)));
const [createdRows, manual] = await Promise.all([
db.select({ id: leads.id, statusId: leads.statusId }).from(leads).where(and(eq(leads.companyId, companyId), inPeriod(leads.createdAt))),
db.select().from(events).where(and(eq(events.companyId, companyId), isNotNull(events.leadId), eq(events.type, 'created'), eq(events.newValue, 'manual'), inPeriod(events.at))),
]);
const owner = (e: typeof events.$inferSelect) => (e.type === 'assigned' ? Number(e.newValue) : e.userId);
for (const e of takes) if (owner(e)) row(owner(e)!).taken++;
summary.created = createdRows.length;
summary.taken = new Set(takes.map((e) => e.leadId)).size;
// История когортных лидов: по ней восстанавливаем, при каком агенте лид дошёл до этапа.
const cohortIds = [...new Set([...takes, ...manual].map((e) => e.leadId!).concat(createdRows.map((l) => l.id)))];
const history = cohortIds.length
? await db.select().from(events).where(inArray(events.leadId, cohortIds)).orderBy(asc(events.at), asc(events.id))
: [];
const starts = new Set([...takes, ...manual].map((e) => e.id));
/** leadId → (агент → этапы, пройденные при нём); в когорту агента лид входит, если получен за период. */
const perLead = new Map<number, { all: Set<number>; byAgent: Map<number, { visited: Set<number>; inCohort: boolean }> }>();
const current = new Map<number, number | null>();
for (const e of history) {
const lead = perLead.get(e.leadId!) ?? perLead.set(e.leadId!, { all: new Set(), byAgent: new Map() }).get(e.leadId!)!;
const seg = (userId: number) => lead.byAgent.get(userId) ?? lead.byAgent.set(userId, { visited: new Set(), inCohort: false }).get(userId)!;
const visit = (statusId: number | null | undefined) => {
if (!statusId) return;
lead.all.add(statusId);
const who = current.get(e.leadId!);
if (who) seg(who).visited.add(statusId);
};
if (e.type === 'taken' || e.type === 'assigned' || (e.type === 'created' && e.newValue === 'manual')) {
const who = owner(e);
if (!who) continue;
current.set(e.leadId!, who);
if (starts.has(e.id)) seg(who).inCohort = true;
visit(takenId);
} else if (e.type === 'pool_return') {
current.set(e.leadId!, null);
} else if (e.type === 'blacklisted') {
// «Это агент» из пула закрепляет лид за нажавшим.
if (!current.get(e.leadId!) && e.userId) current.set(e.leadId!, e.userId);
visit(e.statusId ?? rejectedId);
} else if (e.type === 'status' || e.type === 'deal') {
visit(e.statusId);
}
}
for (const lead of perLead.values()) {
for (const [userId, seg] of lead.byAgent) {
if (!seg.inCohort) continue;
const r = row(userId);
r.cohort++;
countReached(r.reached, seg.visited);
}
}
// Воронка целиком — по лидам, пришедшим за период; «Новый» прошли все.
const newId = statuses.find((s) => s.code === 'new')?.id;
for (const l of createdRows) {
const visited = new Set(perLead.get(l.id)?.all ?? []);
visited.add(l.statusId);
if (newId) visited.add(newId);
countReached(summary.funnel, visited);
}
summary.cohort = createdRows.length;
const [pool, reactions, returns, rejected] = await Promise.all([
db.select({ n: sql<number>`count(*)::int` }).from(leads).where(and(eq(leads.companyId, companyId), isNull(leads.assigneeId), newId ? eq(leads.statusId, newId) : undefined)),
db
.select({
userId: events.userId,
// Имена колонок внешнего запроса указаны с таблицей: иначе внутри подзапроса они относятся к n.
sec: sql<number | null>`extract(epoch from "events"."at" - (select max(n.sent_at) from lead_notifications n where n.lead_id = "events"."lead_id" and n.user_id = "events"."user_id" and n.sent_at <= "events"."at"))::float`,
})
.from(events)
.where(and(eq(events.companyId, companyId), eq(events.type, 'taken'), isNotNull(events.leadId), inPeriod(events.at))),
db.select().from(events).where(and(eq(events.companyId, companyId), eq(events.type, 'pool_return'), isNull(events.userId), isNotNull(events.oldValue), inPeriod(events.at))),
rejectedId
? db
.select({ userId: leads.assigneeId, reasonId: leads.rejectReasonId })
.from(leads)
.where(and(eq(leads.companyId, companyId), eq(leads.statusId, rejectedId), inPeriod(leads.statusChangedAt)))
: [],
]);
summary.open = pool[0]?.n ?? 0;
const bySpeed = new Map<number, number[]>();
for (const r of reactions) if (r.userId && r.sec != null) bySpeed.set(r.userId, [...(bySpeed.get(r.userId) ?? []), r.sec]);
for (const [userId, list] of bySpeed) row(userId).reactionSec = median(list);
for (const e of returns) row(Number(e.oldValue)).poolReturns++;
for (const r of rejected) if (r.userId && r.reasonId) bump(row(r.userId).reasons, r.reasonId);
allReactions = reactions.flatMap((r) => (r.sec != null ? [r.sec] : []));
} else {
const created = await db
.select({ id: clients.id, statusId: clients.statusId, assigneeId: clients.assigneeId })
.from(clients)
.where(and(eq(clients.companyId, companyId), inPeriod(clients.createdAt)));
const ids = created.map((c) => c.id);
const history = ids.length
? await db.select({ clientId: events.clientId, statusId: events.statusId }).from(events).where(and(inArray(events.clientId, ids), isNotNull(events.statusId)))
: [];
const newId = statuses.find((s) => s.code === 'new')?.id;
for (const c of created) {
const visited = new Set(history.filter((e) => e.clientId === c.id).map((e) => e.statusId!));
visited.add(c.statusId);
if (newId) visited.add(newId);
const r = row(c.assigneeId);
r.taken++;
r.cohort++;
countReached(r.reached, visited);
countReached(summary.funnel, visited);
}
summary.created = summary.taken = summary.cohort = created.length;
const [open, shown, rejected] = await Promise.all([
db
.select({ n: sql<number>`count(*)::int` })
.from(clients)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, clients.statusId))
.where(and(eq(clients.companyId, companyId), sql`${pipelineStatuses.code} is distinct from 'deal' and ${pipelineStatuses.code} is distinct from 'rejected'`)),
db
.select({ userId: clients.assigneeId })
.from(clientLeads)
.innerJoin(clients, eq(clients.id, clientLeads.clientId))
.where(and(eq(clients.companyId, companyId), inPeriod(clientLeads.shownAt))),
rejectedId
? db
.select({ userId: clients.assigneeId, reasonId: clients.rejectReasonId })
.from(clients)
.where(and(eq(clients.companyId, companyId), eq(clients.statusId, rejectedId), inPeriod(clients.statusChangedAt)))
: [],
]);
summary.open = open[0]?.n ?? 0;
for (const s of shown) row(s.userId).showings++;
for (const r of rejected) if (r.reasonId) bump(row(r.userId).reasons, r.reasonId);
}
// Сделки за период — по дате сделки. Парная сделка одна, но видна в обеих воронках.
const closed = await db
.select()
.from(deals)
.where(
and(
eq(deals.companyId, companyId),
isNotNull(pipeline === 'object' ? deals.leadId : deals.clientId),
gte(deals.dealDate, fromDay),
lte(deals.dealDate, toDay),
),
);
// Каждому агенту — его часть комиссии: с собственника — агенту объекта, с клиента — агенту клиента.
for (const d of closed) {
for (const agentId of new Set([d.ownerAgentId, d.clientAgentId])) row(agentId).deals++;
if (d.ownerCommission) bump(row(d.ownerAgentId).commission, d.currency, d.ownerCommission);
if (d.clientCommission) bump(row(d.clientAgentId).commission, d.currency, d.clientCommission);
}
// Суперадмин, работавший в кабинете компании, в её участниках не числится — имя берём отдельно.
const outside = [...rows.keys()].filter((id) => !members.some((m) => m.id === id));
if (outside.length) {
for (const u of await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, outside))) rows.get(u.id)!.name = u.name;
}
const removed = new Set(members.filter((m) => m.removedAt).map((m) => m.id));
const isEmpty = (r: AgentRow) => !r.taken && !r.cohort && !r.deals && !r.poolReturns && !r.showings && !Object.keys(r.reasons).length;
// Удалённые агенты остаются в отчёте, только если за период по ним есть данные.
const agents = [...rows.values()].filter((r) => !(isEmpty(r) && (removed.has(r.userId) || !members.some((m) => m.id === r.userId))));
const totals = emptyRow(0, '');
for (const r of agents) {
totals.taken += r.taken;
totals.poolReturns += r.poolReturns;
totals.showings += r.showings;
totals.cohort += r.cohort;
for (const [k, v] of Object.entries(r.reached)) bump(totals.reached, k, v);
for (const [k, v] of Object.entries(r.commission)) bump(totals.commission, k, v);
for (const [k, v] of Object.entries(r.reasons)) bump(totals.reasons, k, v);
}
// Сделку двух агентов видно у обоих, в итоге она одна.
totals.deals = closed.length;
totals.reactionSec = median(allReactions);
return {
pipeline,
statuses: statuses.map((s) => ({ id: s.id, code: s.code, name: s.name })),
reasons: reasons.filter((r) => !r.deletedAt || agents.some((a) => a.reasons[r.id])).map((r) => ({ id: r.id, name: r.name })),
summary,
agents,
totals,
};
}
+253
View File
@@ -0,0 +1,253 @@
import { createHash, createHmac } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashPassword } from './auth/password.ts';
import { createSession } from './auth/sessions.ts';
import { companies, pipelineStatuses, users } from './db/schema.ts';
import { TEST_BOT_TOKEN, testApp } from './testing.ts';
let t: Awaited<ReturnType<typeof testApp>>;
beforeAll(async () => { t = await testApp(); });
afterAll(() => t.close());
const superadmin = async () => {
const [u] = await t.db
.insert(users)
.values({ name: 'Админ', role: 'superadmin', email: `sa${Date.now()}${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
.returning();
return t.client(await createSession(t.db, u!.id));
};
/** Компания, включённая суперадмином, и её руководитель. */
const company = async (plan: 'solo' | 'team' | 'agency' = 'team') => {
const sa = await superadmin();
const created = await sa.post('/api/admin/companies', {
name: `Агентство ${plan}`, plan, billingModel: 'fixed',
subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
});
expect(created.status).toBe(200);
const boss = await t.register('Руководитель');
const token = (created.body.managerUrl as string).split('/invite/')[1]!;
expect((await boss.post(`/api/invites/${token}/accept`)).status).toBe(200);
return { sa, boss, id: created.body.id as number, managerToken: token };
};
describe('свой домен компании', () => {
const HOST = 'arev.yandp.tech';
const loginOn = async (host: string | undefined, email: string) =>
t.client(undefined, host).post('/api/auth/login', { email, password: 'password-123' });
it('на домене компании входят только её сотрудники и суперадмин; на основном — все', async () => {
const own = await company();
const other = await company();
const [co] = await t.db.select().from(companies).where(eq(companies.id, own.id));
await t.db.update(companies).set({ settings: { ...co!.settings, clientHost: HOST } }).where(eq(companies.id, own.id));
const ownEmail = (await own.boss.get('/api/me')).body.user.email as string;
const otherEmail = (await other.boss.get('/api/me')).body.user.email as string;
// Конфиг подсказывает странице входа, чей это домен.
expect((await t.client(undefined, HOST).get('/api/config')).body.domain).toEqual({ company: co!.name, appUrl: 'https://app.test' });
expect((await t.client().get('/api/config')).body.domain).toBeNull();
// Сотрудник компании входит и работает на её домене.
const ok = await loginOn(HOST, ownEmail);
expect(ok.status).toBe(200);
expect((await t.client(ok.body.token, HOST).get('/api/me')).body.company.id).toBe(own.id);
// Чужой — отказ, сессия не создаётся; на основном домене он входит как обычно.
const denied = await loginOn(HOST, otherEmail);
expect(denied.body.code).toBe('WRONG_DOMAIN');
expect(denied.body.token).toBeUndefined();
const elsewhere = await loginOn(undefined, otherEmail);
expect(elsewhere.status).toBe(200);
// Его сессия, принесённая на чужой домен, не действует.
expect((await t.client(elsewhere.body.token, HOST).get('/api/me')).status).toBe(401);
expect((await t.client(elsewhere.body.token).get('/api/me')).status).toBe(200);
// Суперадмин проходит.
const [sa] = await t.db.select().from(users).where(eq(users.role, 'superadmin'));
expect((await t.client(await createSession(t.db, sa!.id), HOST).get('/api/me')).body.user.isSuperadmin).toBe(true);
// Регистрация на домене компании закрыта.
const reg = await t.client(undefined, HOST).post('/api/auth/register', { name: 'Новый', email: 'new-on-arev@test.am', password: 'password-123' });
expect(reg.body.code).toBe('WRONG_DOMAIN');
// Телеграм-вход: чужой пользователь получает отказ, новый не создаётся.
const tgUser = (id: number) => {
const data: Record<string, string> = { id: String(id), first_name: 'Тест', auth_date: String(Math.floor(Date.now() / 1000)) };
const check = Object.keys(data).sort().map((k) => `${k}=${data[k]}`).join('\n');
const secret = createHash('sha256').update(TEST_BOT_TOKEN).digest();
return { ...data, hash: createHmac('sha256', secret).update(check).digest('hex') };
};
expect((await t.client(undefined, HOST).post('/api/auth/telegram', tgUser(9_001))).body.code).toBe('WRONG_DOMAIN');
expect(await t.db.select().from(users).where(eq(users.telegramId, 9_001))).toHaveLength(0);
await t.db.update(users).set({ telegramId: 9_002 }).where(eq(users.email, otherEmail));
expect((await t.client(undefined, HOST).post('/api/auth/telegram', tgUser(9_002))).body.code).toBe('WRONG_DOMAIN');
await t.db.update(users).set({ telegramId: 9_003 }).where(eq(users.email, ownEmail));
expect((await t.client(undefined, HOST).post('/api/auth/telegram', tgUser(9_003))).status).toBe(200);
});
});
describe('вход', () => {
it('регистрация, вход, неверный пароль, выход', async () => {
const reg = await t.client().post('/api/auth/register', { name: 'Арам', email: 'Aram@Test.am', password: 'password-123' });
expect(reg.status).toBe(200);
const dup = await t.client().post('/api/auth/register', { name: 'Арам', email: 'aram@test.am', password: 'password-123' });
expect(dup.body.code).toBe('EMAIL_TAKEN');
expect((await t.client().post('/api/auth/login', { email: 'aram@test.am', password: 'wrong-password' })).status).toBe(401);
const login = await t.client().post('/api/auth/login', { email: 'aram@test.am', password: 'password-123' });
const c = t.client(login.body.token);
const me = await c.get('/api/me');
expect(me.body.user).toMatchObject({ name: 'Арам', emailVerified: false, telegramLinked: false });
expect(me.body.company).toBeNull();
await c.post('/api/auth/logout');
expect((await c.get('/api/me')).status).toBe(401);
});
it('подтверждение email и сброс пароля по ссылке из письма', async () => {
t.mails.length = 0;
await t.client().post('/api/auth/register', { name: 'Ани', email: 'ani@test.am', password: 'password-123' });
const verify = new URL(t.mails[0]!.text).searchParams.get('token');
expect((await t.client().post('/api/auth/verify-email', { token: verify })).status).toBe(200);
expect((await t.client().post('/api/auth/verify-email', { token: verify })).body.code).toBe('TOKEN_INVALID');
await t.client().post('/api/auth/forgot-password', { email: 'ani@test.am' });
const reset = new URL(t.mails[1]!.text).searchParams.get('token');
await t.client().post('/api/auth/reset-password', { token: reset, password: 'new-password-1' });
expect((await t.client().post('/api/auth/login', { email: 'ani@test.am', password: 'password-123' })).status).toBe(401);
expect((await t.client().post('/api/auth/login', { email: 'ani@test.am', password: 'new-password-1' })).status).toBe(200);
});
it('Telegram Mini App: верная подпись создаёт пользователя, подделка отклоняется', async () => {
const sign = (userId: number, token = TEST_BOT_TOKEN) => {
const p = new URLSearchParams({
auth_date: String(Math.floor(Date.now() / 1000)),
user: JSON.stringify({ id: userId, first_name: 'Гор', language_code: 'hy' }),
});
const check = [...p.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`).join('\n');
const secret = createHmac('sha256', 'WebAppData').update(token).digest();
p.set('hash', createHmac('sha256', secret).update(check).digest('hex'));
return p.toString();
};
const ok = await t.client().post('/api/auth/telegram/webapp', { initData: sign(777) });
expect(ok.status).toBe(200);
const me = await t.client(ok.body.token).get('/api/me');
expect(me.body.user).toMatchObject({ name: 'Гор', lang: 'hy', telegramLinked: true });
expect((await t.client().post('/api/auth/telegram/webapp', { initData: sign(778, 'other:token') })).status).toBe(401);
// Mini App открыл владелец Telegram, у которого уже есть аккаунт: входит он, а не тот, чья сессия осталась в браузере.
const emailUser = await t.register();
const own = await emailUser.post('/api/auth/telegram/webapp', { initData: sign(777) });
expect((await t.client(own.body.token).get('/api/me')).body.user.id).toBe(me.body.user.id);
// Новый Telegram привязывается к аккаунту с email.
expect((await emailUser.post('/api/auth/telegram/webapp', { initData: sign(779) })).status).toBe(200);
expect((await emailUser.get('/api/me')).body.user.telegramLinked).toBe(true);
});
it('Telegram Login в веб-кабинете', async () => {
const data: Record<string, string | number> = { id: 901, first_name: 'Лилит', auth_date: Math.floor(Date.now() / 1000) };
const check = Object.keys(data).sort().map((k) => `${k}=${data[k]}`).join('\n');
const hash = createHmac('sha256', createHash('sha256').update(TEST_BOT_TOKEN).digest()).update(check).digest('hex');
expect((await t.client().post('/api/auth/telegram', { ...data, hash })).status).toBe(200);
expect((await t.client().post('/api/auth/telegram', { ...data, id: 902, hash })).status).toBe(401);
});
});
describe('компании и тарифы', () => {
it('самостоятельная регистрация: компания создана, но доступ закрыт до оплаты', async () => {
const u = await t.register();
expect((await u.post('/api/company', { name: 'Соло', plan: 'solo', billingModel: 'percent' })).body.code).toBe('BILLING_MODEL_NOT_ALLOWED');
expect((await u.post('/api/company', { name: 'Соло', plan: 'solo', billingModel: 'fixed' })).status).toBe(200);
const me = (await u.get('/api/me')).body;
expect(me.user.role).toBe('manager');
expect(me.company).toMatchObject({ plan: 'solo', access: 'disabled', modules: [], maxMembers: 1, members: 1 });
// Solo работает в Telegram: из браузера рабочие разделы закрыты, из Mini App — открыты.
expect(me.webBlocked).toBe(true);
expect((await u.get('/api/leads')).body.code).toBe('WEB_NOT_AVAILABLE');
expect((await u.get('/api/billing')).status).toBe(200);
const mini = t.client(await createSession(t.db, me.user.id, true));
expect((await mini.get('/api/me')).body.webBlocked).toBe(false);
expect((await mini.get('/api/leads')).status).toBe(200);
expect((await mini.post('/api/invites')).body.code).toBe('SUBSCRIPTION_REQUIRED');
});
it('компания получает обе воронки с системными статусами', async () => {
const { id } = await company();
const rows = await t.db.select().from(pipelineStatuses).where(eq(pipelineStatuses.companyId, id));
const codes = (p: string) => rows.filter((r) => r.pipeline === p && r.code).map((r) => r.code);
expect(rows.filter((r) => r.pipeline === 'object')).toHaveLength(8);
expect(rows.filter((r) => r.pipeline === 'client')).toHaveLength(7);
expect(codes('object')).toEqual(['new', 'taken', 'deal', 'rejected']);
expect(codes('client')).toEqual(['new', 'deal', 'rejected']);
});
it('в Team нельзя добавить одиннадцатого участника, даже одновременно', async () => {
const { boss } = await company('team');
const invite = (await boss.post('/api/invites')).body.token as string;
expect((await t.client().get(`/api/invites/${invite}`)).body.companyName).toBe('Агентство team');
const agents = await Promise.all(Array.from({ length: 11 }, () => t.register('Агент')));
const results = await Promise.all(agents.map((a) => a.post(`/api/invites/${invite}/accept`)));
expect(results.filter((r) => r.status === 200)).toHaveLength(9);
expect(results.filter((r) => r.body.code === 'MEMBER_LIMIT_REACHED')).toHaveLength(2);
expect(results.filter((r) => r.body.limitReached)).toHaveLength(1);
expect((await boss.get('/api/me')).body.company.members).toBe(10);
});
it('ссылка руководителя одноразовая, отозванное приглашение не работает', async () => {
const { boss, managerToken } = await company();
expect((await (await t.register()).post(`/api/invites/${managerToken}/accept`)).body.code).toBe('INVITE_NOT_FOUND');
const invite = (await boss.post('/api/invites')).body.token as string;
await boss.del(`/api/invites/${invite}`);
expect((await (await t.register()).post(`/api/invites/${invite}/accept`)).body.code).toBe('INVITE_NOT_FOUND');
});
it('агент не управляет компанией и не видит чужую; удалённый агент теряет доступ', async () => {
const a = await company();
const b = await company();
const invite = (await a.boss.post('/api/invites')).body.token as string;
const agent = await t.register('Агент');
await agent.post(`/api/invites/${invite}/accept`);
expect((await agent.post('/api/invites')).status).toBe(403);
expect((await agent.patch('/api/company', { agentsSeeAll: true })).status).toBe(403);
expect((await agent.get('/api/admin/companies')).status).toBe(403);
const members = (await a.boss.get('/api/members')).body as { id: number; role: string }[];
const agentId = members.find((m) => m.role === 'agent')!.id;
expect((await b.boss.del(`/api/members/${agentId}`)).status).toBe(404);
expect((await a.boss.del(`/api/members/${agentId}`)).status).toBe(200);
expect((await agent.get('/api/me')).status).toBe(401);
expect((await a.boss.get('/api/members')).body).toHaveLength(1);
});
it('просрочка: 15 дней сервис работает, на 16-й — только чтение; суперадмин возвращает доступ', async () => {
const { sa, boss, id } = await company();
const daysAgo = (d: number) => new Date(Date.now() - d * 86_400_000);
await t.db.update(companies).set({ subscriptionEndsAt: daysAgo(14) }).where(eq(companies.id, id));
expect((await boss.get('/api/me')).body.company.access).toBe('overdue');
expect((await boss.patch('/api/company', { agentsSeeAll: true })).status).toBe(200);
await t.db.update(companies).set({ subscriptionEndsAt: daysAgo(16) }).where(eq(companies.id, id));
expect((await boss.get('/api/me')).body.company.access).toBe('restricted');
expect((await boss.patch('/api/company', { agentsSeeAll: false })).body.code).toBe('SUBSCRIPTION_REQUIRED');
expect((await boss.get('/api/members')).status).toBe(200);
await sa.patch(`/api/admin/companies/${id}`, { subscriptionEndsAt: new Date(Date.now() + 86_400_000).toISOString() });
expect((await boss.get('/api/me')).body.company.access).toBe('active');
expect((await boss.get('/api/me')).body.company.settings.agentsSeeAll).toBe(true);
});
it('суперадмин: список компаний, вход в кабинет для поддержки, запрет «%» на Solo', async () => {
const { sa, id } = await company('agency');
const list = (await sa.get('/api/admin/companies')).body as { id: number; members: number; maxMembers: number }[];
expect(list.find((c) => c.id === id)).toMatchObject({ members: 1, maxMembers: 35, access: 'active' });
expect((await sa.patch(`/api/admin/companies/${id}`, { plan: 'solo', billingModel: 'percent' })).body.code).toBe('BILLING_MODEL_NOT_ALLOWED');
expect((await sa.get('/api/members')).body.code).toBe('NO_COMPANY');
await sa.post('/api/admin/impersonate', { companyId: id });
const me = (await sa.get('/api/me')).body;
expect(me).toMatchObject({ impersonating: true, company: { id }, user: { role: 'manager', isSuperadmin: true } });
expect((await sa.get('/api/members')).body).toHaveLength(1);
await sa.post('/api/admin/impersonate', { companyId: null });
expect((await sa.get('/api/me')).body.company).toBeNull();
});
});
+871
View File
@@ -0,0 +1,871 @@
import cookie from '@fastify/cookie';
import {
accessState,
BILLING_MODELS,
canWrite,
CommissionSettings,
hasModule,
LANGS,
PLAN_SPECS,
PLANS,
type AccessState,
type AgentPay,
type BillingModel,
type Lang,
type Plan,
type Role,
} from '@lr/shared';
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { z } from 'zod';
import { hashPassword, verifyPassword } from './auth/password.ts';
import {
createSession,
deleteSession,
findSession,
hashToken,
newToken,
SESSION_COOKIE,
SESSION_DAYS,
} from './auth/sessions.ts';
import { verifyLoginWidget, verifyWebAppInitData, type TelegramUser } from './auth/telegram.ts';
import {
acceptInvite,
createCompany,
createInvite,
memberCount,
} from './companies/companies.service.ts';
import type { Db } from './db/client.ts';
import {
clients,
botLangs,
companies,
departments,
emailTokens,
invites,
leads,
listings,
parserRuns,
pipelineStatuses,
sessions,
users,
} from './db/schema.ts';
import type { Config } from './lib/config.ts';
import { AppError, badRequest, conflict, forbidden, notFound } from './lib/errors.ts';
import { onlySent } from './lib/patch.ts';
import { consoleMailer, type Mailer } from './lib/mailer.ts';
import { registerAdminListingRoutes } from './admin-listings.ts';
import { registerAnalyticsRoutes } from './analytics/routes.ts';
import type { PaymentProvider } from './billing/provider.ts';
import { registerBillingRoutes } from './billing/routes.ts';
import { applyPlan, issueInitialInvoices, issueSetupIfNeeded } from './billing/service.ts';
import { registerArchiveRoutes } from './archive/routes.ts';
import { registerClientRoutes } from './clients/routes.ts';
import { registerDepartmentRoutes } from './departments/routes.ts';
import type { GroupSender } from './group/service.ts';
import { registerLeadRoutes } from './leads/routes.ts';
import { LISTAM_CATEGORIES } from './parser/listam-source.ts';
import { loadParserSettings, ParserSettings, saveParserSettings } from './parser/settings.ts';
import { registerPipelineRoutes } from './pipelines/routes.ts';
import { runIngest, type PagedSource } from './ingest/service.ts';
import type { ImageFetcher } from './showcase/media.ts';
import { registerPayrollRoutes } from './payroll.ts';
import { registerShowcaseRoutes } from './showcase/routes.ts';
import { registerBarevRoutes } from './barev/routes.ts';
import { validSlug } from './showcase/service.ts';
import { registerSupportRoutes } from './support/routes.ts';
import { telegramFileSender, type FileSender } from './support/service.ts';
import { contactOf, enqueueForSuperadmins } from './telegram/outbox.ts';
import { registerBotStartRoutes } from './telegram/start.ts';
import { enqueueText } from './telegram/outbox.ts';
type User = typeof users.$inferSelect;
type Company = typeof companies.$inferSelect;
export interface Ctx {
user: User;
/** Роль в текущем кабинете: суперадмин, вошедший в компанию для поддержки, действует как руководитель. */
role: Role;
company: Company | null;
access: AccessState | null;
/** Суперадмин работает в кабинете компании. */
impersonating: boolean;
/** Тариф без веб-кабинета, а сессия открыта не из Telegram Mini App: рабочие разделы закрыты. */
webBlocked: boolean;
}
declare module 'fastify' {
interface FastifyRequest {
ctx: Ctx | null;
sessionToken: string | null;
/** Компания, за которой закреплён домен запроса; на основном домене — null. */
domain: Company | null;
}
}
export interface AppDeps {
db: Db;
config: Config;
mailer?: Mailer;
/** Источник объявлений — для ручного запуска парсера суперадмином. */
source?: PagedSource | null;
/** Платёжный провайдер. Пока не выбран — онлайн-оплата выключена, счета отмечает суперадмин. */
payments?: PaymentProvider | null;
/** Отправка файлов поддержки в Telegram; в тестах подменяется заглушкой. */
sendFile?: FileSender;
/** Скачивание фото объявлений для страницы клиента; в тестах подменяется. */
fetchImage?: ImageFetcher;
/** Отправка в группу компании в Telegram; в тестах подменяется. */
groupSender?: GroupSender;
}
const Email = z.string().trim().toLowerCase().pipe(z.email());
const Password = z.string().min(8).max(200);
const Name = z.string().trim().min(1).max(100);
const Lang_ = z.enum(LANGS);
const langOf = (code: string | undefined): Lang =>
(LANGS as readonly string[]).includes(code ?? '') ? (code as Lang) : code === 'am' ? 'hy' : 'ru';
export function buildApp({ db, config, mailer = consoleMailer, source = null, payments = null, sendFile, fetchImage, groupSender }: AppDeps) {
const app = Fastify({ logger: false });
void app.register(cookie);
// Файлы (вложения поддержки, фото клиентов) приходят телом запроса как есть; предел размера задаёт каждый маршрут.
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer', bodyLimit: 20 * 1024 * 1024 }, (_req, body, done) => done(null, body));
app.setErrorHandler((error, _req, reply) => {
if (error instanceof AppError) {
return reply.status(error.status).send({ code: error.code });
}
if (error instanceof z.ZodError) {
return reply.status(400).send({ code: 'VALIDATION', issues: error.issues });
}
const status = (error as { statusCode?: number }).statusCode;
if (status && status < 500) return reply.status(status).send({ code: 'BAD_REQUEST' });
console.error(error);
return reply.status(500).send({ code: 'INTERNAL' });
});
// --- контекст запроса: пользователь, компания, состояние подписки ---
// Свой домен компании (settings.clientHost, например arev.yandp.tech): ссылки её клиентам и вход
// только для её сотрудников и суперадмина. Сессия чужого пользователя на таком домене не действует.
const mainHost = new URL(config.appUrl).hostname;
const domainOf = async (req: FastifyRequest) => {
const host = req.hostname.toLowerCase();
if (host === mainHost) return null;
const [company] = await db.select().from(companies).where(sql`${companies.settings}->>'clientHost' = ${host}`);
return company ?? null;
};
const allowedOn = (domain: Company | null, user: User) =>
!domain || user.role === 'superadmin' || user.companyId === domain.id;
const checkDomain = (req: FastifyRequest, user: User) => {
if (!allowedOn(req.domain, user)) throw forbidden('WRONG_DOMAIN');
};
app.decorateRequest('ctx', null);
app.decorateRequest('sessionToken', null);
app.decorateRequest('domain', null);
app.addHook('preHandler', async (req) => {
req.domain = await domainOf(req);
const bearer = req.headers.authorization?.match(/^Bearer (.+)$/)?.[1];
const token = bearer ?? req.cookies[SESSION_COOKIE];
if (!token) return;
const session = await findSession(db, token);
if (!session || session.user.removedAt || !allowedOn(req.domain, session.user)) return;
const { user } = session;
const impersonating = user.role === 'superadmin' && session.actingCompanyId !== null;
const companyId = impersonating ? session.actingCompanyId : user.companyId;
const [company] = companyId
? await db.select().from(companies).where(eq(companies.id, companyId))
: [];
req.sessionToken = token;
req.ctx = {
user,
role: impersonating ? 'manager' : (user.role as Role),
company: company ?? null,
access: company ? accessState(company) : null,
impersonating,
webBlocked: Boolean(company) && !impersonating && !session.viaTelegram && !hasModule(company!.plan as Plan, 'web'),
};
});
const auth = (req: FastifyRequest): Ctx => {
if (!req.ctx) throw new AppError(401, 'UNAUTHORIZED');
return req.ctx;
};
/** Пользователь внутри компании. Все бизнес-запросы идут через эту проверку. */
const account = (req: FastifyRequest) => {
const ctx = auth(req);
if (!ctx.company) throw forbidden('NO_COMPANY');
return ctx as Ctx & { company: Company; access: AccessState };
};
const member = (req: FastifyRequest) => {
const ctx = account(req);
if (ctx.webBlocked) throw forbidden('WEB_NOT_AVAILABLE');
return ctx;
};
/** Руководитель в разделе «Подписка»: доступен с любого интерфейса и при ограниченном доступе. */
const owner = (req: FastifyRequest) => {
const ctx = account(req);
if (ctx.role !== 'manager') throw forbidden();
return ctx;
};
const manager = (req: FastifyRequest) => {
const ctx = member(req);
if (ctx.role !== 'manager') throw forbidden();
return ctx;
};
/** Изменения запрещены, когда отсрочка истекла или доступ выключен (CRM только на чтение). */
const writable = <T extends { access: AccessState }>(ctx: T): T => {
if (!canWrite(ctx.access)) throw forbidden('SUBSCRIPTION_REQUIRED');
return ctx;
};
const superadmin = (req: FastifyRequest) => {
const ctx = auth(req);
if (ctx.user.role !== 'superadmin') throw forbidden();
return ctx;
};
const startSession = async (reply: FastifyReply, userId: number, viaTelegram = false) => {
const token = await createSession(db, userId, viaTelegram);
reply.setCookie(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: config.production,
maxAge: SESSION_DAYS * 86_400,
});
return token;
};
const sendEmailToken = async (user: User, kind: 'verify' | 'reset') => {
if (!user.email) return;
const token = newToken();
await db.insert(emailTokens).values({
tokenHash: hashToken(token),
userId: user.id,
kind,
expiresAt: new Date(Date.now() + (kind === 'verify' ? 72 : 2) * 3_600_000),
});
const link = `${config.appUrl}/${kind === 'verify' ? 'verify-email' : 'reset-password'}?token=${token}`;
await mailer.send({
to: user.email,
subject: kind === 'verify' ? 'Y&P Lead Radar: подтвердите email' : 'Y&P Lead Radar: сброс пароля',
text: link,
});
};
const takeEmailToken = async (token: string, kind: 'verify' | 'reset') => {
const [row] = await db
.delete(emailTokens)
.where(and(eq(emailTokens.tokenHash, hashToken(token)), eq(emailTokens.kind, kind)))
.returning();
if (!row || row.expiresAt < new Date()) throw badRequest('TOKEN_INVALID');
return row.userId;
};
const me = async (ctx: Ctx) => {
const { user, company } = ctx;
return {
user: {
id: user.id,
name: user.name,
email: user.email,
emailVerified: Boolean(user.emailVerifiedAt),
telegramLinked: Boolean(user.telegramId),
hasPassword: Boolean(user.passwordHash),
lang: user.lang,
role: ctx.role,
isSuperadmin: user.role === 'superadmin',
quietFrom: user.quietFrom,
quietTo: user.quietTo,
notificationsPaused: user.notificationsPaused,
onboarded: Boolean(user.onboardedAt),
/** Отдел сотрудника: он видит только объекты и клиентов этого отдела. */
departmentId: user.departmentId,
departmentName: user.departmentId
? ((await db.select({ name: departments.name }).from(departments).where(eq(departments.id, user.departmentId)))[0]?.name ?? null)
: null,
},
impersonating: ctx.impersonating,
webBlocked: ctx.webBlocked,
company: company && {
id: company.id,
name: company.name,
slug: company.slug,
plan: company.plan,
billingModel: company.billingModel,
planned:
company.plannedFrom && company.plannedPlan
? {
plan: company.plannedPlan,
billingModel: company.plannedBillingModel,
from: company.plannedFrom,
}
: null,
subscriptionEndsAt: company.subscriptionEndsAt,
graceDays: company.graceDays,
access: ctx.access,
modules: PLAN_SPECS[company.plan as Plan].modules,
maxMembers: PLAN_SPECS[company.plan as Plan].maxMembers,
members: await memberCount(db, company.id),
// Токен бота группы и ключ сайта BarevClub — секреты: в кабинет они не уходят.
settings: { ...company.settings, groupBot: undefined, barevClub: undefined },
/** У компании настроен бот группы: в карточке объекта работает «Отправить в Telegram». */
groupBot: Boolean(company.settings.groupBot?.chatId),
},
};
};
// --- вход и регистрация ---
app.get('/api/health', async () => {
await db.execute(sql`select 1`);
return { status: 'ok' };
});
app.get('/api/config', async (req) => ({
telegramBotUsername: config.telegramBotToken ? (config.telegramBotUsername ?? null) : null,
onlinePayments: Boolean(payments),
/** На своём домене компании: её название и основной адрес сервиса для остальных. */
domain: req.domain && { company: req.domain.name, appUrl: config.appUrl },
}));
app.post('/api/auth/register', async (req, reply) => {
const body = z
.object({ name: Name, email: Email, password: Password, lang: Lang_.default('ru') })
.parse(req.body);
// На своём домене компании регистрации нет: сотрудников туда приглашают с основного адреса.
if (req.domain) throw forbidden('WRONG_DOMAIN');
const [existing] = await db
.select({ id: users.id })
.from(users)
.where(sql`lower(${users.email}) = ${body.email}`);
if (existing) throw conflict('EMAIL_TAKEN');
const [user] = await db
.insert(users)
.values({
name: body.name,
email: body.email,
passwordHash: await hashPassword(body.password),
lang: body.lang,
// Роль уточняется при создании компании (manager) или принятии приглашения (agent).
role: 'agent',
})
.returning();
await sendEmailToken(user!, 'verify');
await enqueueForSuperadmins(db, 'signupNew', [user!.name, contactOf(user!)]);
const token = await startSession(reply, user!.id);
return { token };
});
app.post('/api/auth/login', async (req, reply) => {
const body = z.object({ email: Email, password: z.string().max(200) }).parse(req.body);
const [user] = await db.select().from(users).where(sql`lower(${users.email}) = ${body.email}`);
// Пароль проверяется и для несуществующего пользователя — чтобы ответ не выдавал, есть ли такой email.
const ok = await verifyPassword(body.password, user?.passwordHash ?? null);
if (!user || !ok || user.removedAt) throw new AppError(401, 'BAD_CREDENTIALS');
checkDomain(req, user);
const token = await startSession(reply, user.id);
return { token };
});
app.post('/api/auth/logout', async (req, reply) => {
if (req.sessionToken) await deleteSession(db, req.sessionToken);
reply.clearCookie(SESSION_COOKIE, { path: '/' });
return { ok: true };
});
app.post('/api/auth/verify-email', async (req) => {
const { token } = z.object({ token: z.string().min(1) }).parse(req.body);
const userId = await takeEmailToken(token, 'verify');
await db.update(users).set({ emailVerifiedAt: new Date() }).where(eq(users.id, userId));
return { ok: true };
});
app.post('/api/auth/forgot-password', async (req) => {
const { email } = z.object({ email: Email }).parse(req.body);
const [user] = await db.select().from(users).where(sql`lower(${users.email}) = ${email}`);
if (user && !user.removedAt) await sendEmailToken(user, 'reset');
// Ответ одинаковый, есть такой email или нет.
return { ok: true };
});
app.post('/api/auth/reset-password', async (req) => {
const body = z.object({ token: z.string().min(1), password: Password }).parse(req.body);
const userId = await takeEmailToken(body.token, 'reset');
await db
.update(users)
.set({ passwordHash: await hashPassword(body.password), emailVerifiedAt: new Date() })
.where(eq(users.id, userId));
// Старые сессии закрываются: пароль мог сбросить не тот, кто сейчас вошёл.
await db.delete(sessions).where(eq(sessions.userId, userId));
return { ok: true };
});
/**
* Вход через Telegram. Если запрос пришёл от уже вошедшего пользователя,
* Telegram привязывается к его аккаунту; иначе — вход или регистрация.
*/
const telegramAuth = async (req: FastifyRequest, reply: FastifyReply, tg: TelegramUser | null, viaTelegram = false) => {
if (!tg) throw new AppError(401, 'TELEGRAM_AUTH_FAILED');
const [existing] = await db.select().from(users).where(eq(users.telegramId, tg.id));
// Mini App открыл владелец этого Telegram: его аккаунт важнее сессии, оставшейся в браузере Telegram.
if (existing && !existing.removedAt) checkDomain(req, existing);
else if (!existing && req.domain && !req.ctx) throw forbidden('WRONG_DOMAIN');
if (viaTelegram && existing && !existing.removedAt) {
return { token: await startSession(reply, existing.id, true) };
}
if (req.ctx) {
if (existing && existing.id !== req.ctx.user.id) throw conflict('TELEGRAM_TAKEN');
await db
.update(users)
.set({ telegramId: tg.id, telegramUsername: tg.username ?? null })
.where(eq(users.id, req.ctx.user.id));
if (viaTelegram) {
await db.update(sessions).set({ viaTelegram: true }).where(eq(sessions.tokenHash, hashToken(req.sessionToken!)));
}
return { token: req.sessionToken };
}
if (existing?.removedAt) throw new AppError(401, 'TELEGRAM_AUTH_FAILED');
const fresh = !existing;
// Язык, выбранный в боте до регистрации, важнее языка интерфейса Telegram.
const [chosen] = fresh ? await db.select().from(botLangs).where(eq(botLangs.telegramId, tg.id)) : [];
const user =
existing ??
(
await db
.insert(users)
.values({
name: [tg.firstName, tg.lastName].filter(Boolean).join(' ') || tg.username || 'Telegram',
telegramId: tg.id,
telegramUsername: tg.username ?? null,
lang: (chosen?.lang as Lang | undefined) ?? langOf(tg.languageCode),
role: 'agent',
})
.returning()
)[0]!;
if (fresh) await enqueueForSuperadmins(db, 'signupNew', [user.name, contactOf(user)]);
const token = await startSession(reply, user.id, viaTelegram);
return { token };
};
app.post('/api/auth/telegram', async (req, reply) => {
if (!config.telegramBotToken) throw badRequest('TELEGRAM_DISABLED');
const data = z.record(z.string(), z.union([z.string(), z.number()])).parse(req.body);
return telegramAuth(req, reply, verifyLoginWidget(data, config.telegramBotToken));
});
app.post('/api/auth/telegram/webapp', async (req, reply) => {
if (!config.telegramBotToken) throw badRequest('TELEGRAM_DISABLED');
const { initData } = z.object({ initData: z.string().min(1) }).parse(req.body);
return telegramAuth(req, reply, verifyWebAppInitData(initData, config.telegramBotToken), true);
});
// --- профиль ---
app.get('/api/me', async (req) => me(auth(req)));
app.patch('/api/me', async (req) => {
const ctx = auth(req);
const time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).nullable();
const body = z
.object({
name: Name,
lang: Lang_,
quietFrom: time,
quietTo: time,
notificationsPaused: z.boolean(),
/** Мастер первой настройки пройден или пропущен. */
onboarded: z.literal(true),
})
.partial()
.parse(req.body);
const { onboarded, ...fields } = body;
const patch = { ...fields, ...(onboarded ? { onboardedAt: new Date() } : {}) };
if (Object.keys(patch).length) await db.update(users).set(patch).where(eq(users.id, ctx.user.id));
return { ok: true };
});
// --- компания ---
/** Самостоятельное подключение: доступ откроется после оплаты или включения суперадмином. */
app.post('/api/company', async (req) => {
const ctx = auth(req);
if (ctx.company || ctx.user.role === 'superadmin') throw conflict('ALREADY_IN_COMPANY');
const body = z
.object({ name: Name, plan: z.enum(PLANS), billingModel: z.enum(BILLING_MODELS) })
.parse(req.body);
const company = await db.transaction(async (tx) => {
const created = await createCompany(tx, { ...body, lang: ctx.user.lang as Lang });
await tx
.update(users)
.set({ companyId: created.id, role: 'manager', removedAt: null })
.where(eq(users.id, ctx.user.id));
await issueInitialInvoices(tx, created);
await enqueueForSuperadmins(tx, 'signupCompany', [created.name, created.plan, ctx.user.name, contactOf(ctx.user)]);
return created;
});
return { id: company.id };
});
app.patch('/api/company', async (req) => {
const ctx = writable(manager(req));
const body = z
.object({
name: Name,
/** Имя компании в адресе страниц для клиентов. */
slug: z.string().trim().toLowerCase(),
agentsSeeAll: z.boolean(),
autoRejectRemoved: z.boolean(),
poolReturnHours: z.number().int().min(1).max(720),
useAgencyBadge: z.boolean(),
phoneThreshold: z.number().int().min(1).max(100),
/** Комиссия агентства по умолчанию. */
commission: CommissionSettings,
/** Отчёты руководителю в Telegram. */
reportWeekly: z.boolean(),
reportMonthly: z.boolean(),
})
.partial()
.parse(req.body);
const s = ctx.company.settings;
if (body.slug !== undefined && body.slug !== ctx.company.slug) {
if (!validSlug(body.slug)) throw badRequest('BAD_SLUG');
const [taken] = await db.select({ id: companies.id }).from(companies).where(eq(companies.slug, body.slug));
if (taken) throw conflict('SLUG_TAKEN');
}
await db
.update(companies)
.set({
name: body.name ?? ctx.company.name,
slug: body.slug ?? ctx.company.slug,
settings: {
agentsSeeAll: body.agentsSeeAll ?? s.agentsSeeAll,
autoRejectRemoved: body.autoRejectRemoved ?? s.autoRejectRemoved ?? false,
poolReturnHours: body.poolReturnHours ?? s.poolReturnHours,
agentRules: {
useAgencyBadge: body.useAgencyBadge ?? s.agentRules.useAgencyBadge,
phoneThreshold: body.phoneThreshold ?? s.agentRules.phoneThreshold,
},
commission: body.commission ?? s.commission,
reports: { ...s.reports, weekly: body.reportWeekly ?? s.reports?.weekly ?? false, monthly: body.reportMonthly ?? s.reports?.monthly ?? false },
clientHost: s.clientHost,
groupBot: s.groupBot,
barevClub: s.barevClub,
},
})
.where(eq(companies.id, ctx.company.id));
return { ok: true };
});
// --- участники и приглашения ---
app.get('/api/members', async (req) => {
const ctx = member(req);
return db
.select({
id: users.id,
name: users.name,
role: users.role,
email: users.email,
telegramUsername: users.telegramUsername,
telegramLinked: sql<boolean>`${users.telegramId} is not null`,
departmentId: users.departmentId,
// Условия оплаты видит руководитель; агент — только свои.
pay: ctx.role === 'manager' ? users.pay : sql<AgentPay | null>`case when ${users.id} = ${ctx.user.id} then ${users.pay} end`,
})
.from(users)
.where(and(eq(users.companyId, ctx.company.id), isNull(users.removedAt)))
.orderBy(users.id);
});
/** Удаление агента: его лиды уходят выбранному агенту либо возвращаются в пул. История сохраняется. */
app.delete('/api/members/:id', async (req) => {
const ctx = writable(manager(req));
const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params);
const { reassignTo } = z
.object({ reassignTo: z.coerce.number().int().optional() })
.parse(req.query);
if (id === ctx.user.id) throw badRequest('CANNOT_REMOVE_SELF');
await db.transaction(async (tx) => {
const inCompany = and(eq(users.companyId, ctx.company.id), isNull(users.removedAt));
const [target] = await tx.select().from(users).where(and(eq(users.id, id), inCompany));
if (!target || target.role !== 'agent') throw notFound();
if (reassignTo !== undefined) {
const [heir] = await tx.select().from(users).where(and(eq(users.id, reassignTo), inCompany));
if (!heir || heir.id === id) throw badRequest('BAD_REASSIGN_TARGET');
}
// Без наследника лиды возвращаются в пул в статусе «Новый».
const [fresh] = await tx
.select({ id: pipelineStatuses.id })
.from(pipelineStatuses)
.where(
and(
eq(pipelineStatuses.companyId, ctx.company.id),
eq(pipelineStatuses.pipeline, 'object'),
eq(pipelineStatuses.code, 'new'),
),
);
await tx
.update(leads)
.set(
reassignTo !== undefined
? { assigneeId: reassignTo }
: { assigneeId: null, statusId: fresh!.id, statusChangedAt: new Date(), takenAt: null },
)
.where(and(eq(leads.companyId, ctx.company.id), eq(leads.assigneeId, id)));
// Клиенты всегда закреплены за агентом: без наследника их забирает руководитель.
await tx
.update(clients)
.set({ assigneeId: reassignTo ?? ctx.user.id })
.where(and(eq(clients.companyId, ctx.company.id), eq(clients.assigneeId, id)));
await tx.update(users).set({ removedAt: new Date() }).where(eq(users.id, id));
await tx.delete(sessions).where(eq(sessions.userId, id));
});
return { ok: true };
});
app.get('/api/invites', async (req) => {
const ctx = manager(req);
const rows = await db
.select()
.from(invites)
.where(and(eq(invites.companyId, ctx.company.id), isNull(invites.revokedAt), eq(invites.role, 'agent')))
.orderBy(desc(invites.createdAt));
return rows.map((i) => ({
token: i.token,
url: `${config.appUrl}/invite/${i.token}`,
createdAt: i.createdAt,
}));
});
app.post('/api/invites', async (req) => {
const ctx = writable(manager(req));
const invite = await createInvite(db, ctx.company.id, ctx.user.id);
return { token: invite.token, url: `${config.appUrl}/invite/${invite.token}` };
});
app.delete('/api/invites/:token', async (req) => {
const ctx = manager(req);
const { token } = z.object({ token: z.string() }).parse(req.params);
await db
.update(invites)
.set({ revokedAt: new Date() })
.where(and(eq(invites.token, token), eq(invites.companyId, ctx.company.id)));
return { ok: true };
});
/** Что за приглашение — показывается до входа. */
app.get('/api/invites/:token', async (req) => {
const { token } = z.object({ token: z.string() }).parse(req.params);
const [row] = await db
.select({ name: companies.name, revokedAt: invites.revokedAt })
.from(invites)
.innerJoin(companies, eq(companies.id, invites.companyId))
.where(eq(invites.token, token));
if (!row || row.revokedAt) throw notFound('INVITE_NOT_FOUND');
return { companyName: row.name };
});
app.post('/api/invites/:token/accept', async (req) => {
const ctx = auth(req);
const { token } = z.object({ token: z.string() }).parse(req.params);
const result = await acceptInvite(db, token, ctx.user.id);
// Руководителям в бот: агент присоединился; достигнут лимит участников.
const managers = (
await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.companyId, result.companyId), eq(users.role, 'manager'), isNull(users.removedAt)))
)
.map((m) => m.id)
.filter((id) => id !== ctx.user.id);
await enqueueText(db, managers, 'joined', [ctx.user.name]);
if (result.limitReached) await enqueueText(db, managers, 'limitReached');
return result;
});
// --- суперадмин ---
app.get('/api/admin/companies', async (req) => {
superadmin(req);
const rows = await db
.select({
company: companies,
members: sql<number>`(select count(*)::int from users u where u.company_id = "companies"."id" and u.removed_at is null)`,
})
.from(companies)
.orderBy(desc(companies.id));
return rows.map(({ company: c, members }) => ({
id: c.id,
name: c.name,
plan: c.plan,
billingModel: c.billingModel,
members,
maxMembers: PLAN_SPECS[c.plan as Plan].maxMembers,
access: accessState(c),
accessEnabled: c.accessEnabled,
subscriptionEndsAt: c.subscriptionEndsAt,
graceDays: c.graceDays,
lastActivityAt: c.lastActivityAt,
createdAt: c.createdAt,
}));
});
/** Ручное подключение: компания создаётся включённой, руководитель входит по ссылке-приглашению. */
app.post('/api/admin/companies', async (req) => {
const ctx = superadmin(req);
const body = z
.object({
name: Name,
plan: z.enum(PLANS),
billingModel: z.enum(BILLING_MODELS),
subscriptionEndsAt: z.coerce.date(),
lang: Lang_.default('ru'),
})
.parse(req.body);
return db.transaction(async (tx) => {
const company = await createCompany(tx, { ...body, accessEnabled: true });
await issueSetupIfNeeded(tx, company, false);
const [invite] = await tx
.insert(invites)
.values({ token: newToken(), role: 'manager', companyId: company.id, createdById: ctx.user.id })
.returning();
return { id: company.id, managerUrl: `${config.appUrl}/invite/${invite!.token}` };
});
});
app.patch('/api/admin/companies/:id', async (req) => {
superadmin(req);
const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params);
const body = z
.object({
plan: z.enum(PLANS),
billingModel: z.enum(BILLING_MODELS),
subscriptionEndsAt: z.coerce.date().nullable(),
graceDays: z.number().int().min(0).max(365),
accessEnabled: z.boolean(),
})
.partial()
.parse(req.body);
const [company] = await db.select().from(companies).where(eq(companies.id, id));
if (!company) throw notFound();
const plan = body.plan ?? (company.plan as Plan);
const model = body.billingModel ?? company.billingModel;
if (model === 'percent' && PLAN_SPECS[plan].commissionPercent === null) {
throw badRequest('BILLING_MODEL_NOT_ALLOWED');
}
if (body.plan && (await memberCount(db, id)) > PLAN_SPECS[plan].maxMembers) {
throw conflict('MEMBER_LIMIT_REACHED');
}
await db.transaction(async (tx) => {
const { plan: _plan, billingModel: _model, ...rest } = body;
if (Object.keys(rest).length) await tx.update(companies).set(rest).where(eq(companies.id, id));
// Суперадмин меняет тариф сразу, без ожидания следующего месяца.
if (plan !== company.plan || model !== company.billingModel) {
const [fresh] = await tx.select().from(companies).where(eq(companies.id, id));
await applyPlan(tx, fresh!, plan, model as BillingModel);
}
});
return { ok: true };
});
/** Вход в кабинет компании для поддержки (id = null — выйти обратно в админку). */
app.post('/api/admin/impersonate', async (req) => {
superadmin(req);
const { companyId } = z.object({ companyId: z.number().int().nullable() }).parse(req.body);
if (companyId !== null) {
const [company] = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId));
if (!company) throw notFound();
}
await db
.update(sessions)
.set({ actingCompanyId: companyId })
.where(eq(sessions.tokenHash, hashToken(req.sessionToken!)));
return { ok: true };
});
/** Ссылка привязки Telegram: пользователь открывает бота, тот запоминает его чат. */
app.post('/api/me/telegram-link', async (req) => {
const ctx = auth(req);
if (!config.telegramBotToken || !config.telegramBotUsername) throw badRequest('TELEGRAM_DISABLED');
const code = newToken().slice(0, 32);
await db.update(users).set({ telegramLinkToken: code }).where(eq(users.id, ctx.user.id));
return { url: `https://t.me/${config.telegramBotUsername}?start=${code}` };
});
// --- суперадмин: парсер ---
app.get('/api/admin/parser', async (req) => {
superadmin(req);
const [runs, [stats]] = await Promise.all([
db.select().from(parserRuns).orderBy(desc(parserRuns.id)).limit(20),
db
.select({
lastOk: sql<Date | null>`(select max(finished_at) from parser_runs where status = 'ok')`,
listings24h: sql<number>`(select count(*)::int from ${listings} where created_at > now() - interval '24 hours')`,
listingsTotal: sql<number>`(select count(*)::int from ${listings})`,
// Сколько длится полный круг парсера: проход плюс пауза до следующего.
cycleSec: sql<number | null>`(select round(avg(sec))::int from (select extract(epoch from started_at - lag(started_at) over (order by id)) as sec from parser_runs order by id desc limit 20) x)`,
// Задержка «размещено на list.am → лид создан» за сутки. Считаются только объявления,
// пойманные в день размещения, и только если известно точное время размещения.
delayMedianSec: sql<number | null>`(select round(percentile_cont(0.5) within group (order by extract(epoch from d.created_at - l.published_at)))::int from ${leads} d join ${listings} l on l.id = d.listing_id where d.created_at > now() - interval '24 hours' and d.created_at - l.published_at < interval '6 hours' and d.created_at - l.created_at < interval '1 minute' and extract(second from l.published_at) + extract(minute from l.published_at) > 0)`,
delayCount: sql<number>`(select count(*)::int from ${leads} d join ${listings} l on l.id = d.listing_id where d.created_at > now() - interval '24 hours' and d.created_at - l.published_at < interval '6 hours' and d.created_at - l.created_at < interval '1 minute' and extract(second from l.published_at) + extract(minute from l.published_at) > 0)`,
})
.from(sql`(select 1) as one`),
]);
return { enabled: Boolean(source), source: source?.name ?? null, ...stats, runs };
});
/** Настройки парсера. Ключ ридера наружу не отдаётся — только признак и последние символы. */
const parserSettingsView = async () => {
const { jinaApiKey, ...rest } = await loadParserSettings(db);
const key = jinaApiKey || config.jinaApiKey || '';
return {
...rest,
jinaKey: { source: jinaApiKey ? 'settings' : config.jinaApiKey ? 'env' : 'none', tail: key ? key.slice(-4) : null },
allCategories: LISTAM_CATEGORIES,
};
};
app.get('/api/admin/parser/settings', async (req) => {
superadmin(req);
return parserSettingsView();
});
app.patch('/api/admin/parser/settings', async (req) => {
superadmin(req);
const patch = onlySent(req.body, ParserSettings.partial().parse(req.body));
if (patch.categories) {
const known = new Set(LISTAM_CATEGORIES.map((c) => c.id));
patch.categories = [...new Set(patch.categories)].filter((id) => known.has(id));
}
await saveParserSettings(db, patch);
return parserSettingsView();
});
app.post('/api/admin/parser/run', async (req) => {
superadmin(req);
if (!source) throw badRequest('PARSER_DISABLED');
return runIngest({ db, source });
});
registerLeadRoutes(app, db, { member, manager, writable }, config, fetchImage, source, groupSender);
registerClientRoutes(app, db, { member, manager, writable }, config);
registerDepartmentRoutes(app, db, { member, manager, writable });
registerPipelineRoutes(app, db, { member, manager, writable });
registerArchiveRoutes(app, db, { member, manager, writable });
registerAnalyticsRoutes(app, db, { member, manager, writable });
registerAdminListingRoutes(app, db, superadmin);
registerBillingRoutes(app, db, { owner, superadmin }, config, payments);
registerPayrollRoutes(app, db, { member, manager, writable });
registerShowcaseRoutes(app, db, { member, manager, writable }, config, fetchImage);
registerBarevRoutes(app, db, config, fetchImage, groupSender);
const fileSender = sendFile ?? (config.telegramBotToken ? telegramFileSender(config.telegramBotToken) : null);
registerSupportRoutes(app, db, superadmin, config, fileSender);
registerBotStartRoutes(app, db, superadmin, fileSender);
return Object.assign(app, { guards: { auth, member, manager, writable, superadmin, hasModule } });
}
+460
View File
@@ -0,0 +1,460 @@
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashPassword } from './auth/password.ts';
import { createSession } from './auth/sessions.ts';
import { events, leadNotifications, leads, listings, sessions, users } from './db/schema.ts';
import { runIngest, runRemovalCheck, type PagedSource } from './ingest/service.ts';
import { runLeadJobs } from './leads/service.ts';
import { LISTAM_CATEGORIES } from './parser/listam-source.ts';
import type { SourcedListing } from './parser/source.ts';
import { processOutbox } from './telegram/outbox.ts';
import { fakeTelegram, testApp } from './testing.ts';
// База бывших клиентов и объектов, отметка «Снято с List.am», повторная публикация, аналитика.
let t: Awaited<ReturnType<typeof testApp>>;
let tgId = 9000;
let extId = 2_000_000;
const pages = new Map<number, SourcedListing[]>();
/** Что «ответит» list.am на проверку объявления: false — снято, undefined — понять не удалось. */
const alive = new Map<string, boolean | undefined>();
const source: PagedSource = {
name: 'fake',
listPage: async (category, page = 1) => (page === 1 ? (pages.get(category.id) ?? []) : []),
inspect: async (card) => ({ ...card, publishedAt: new Date(Date.now() - 40_000) }),
isAlive: async (id) => (alive.has(id) ? alive.get(id) : true),
};
const SALE = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
const listing = (over: Partial<SourcedListing> = {}): SourcedListing => {
const id = String(++extId);
return {
externalId: id, sourceUrl: `https://www.list.am/item/${id}`, title: '3-комн. квартира', dealType: 'sale',
propertyType: 'apartment', region: 'Ереван', district: 'Кентрон', address: `ул. Абовяна ${id}`, price: 150_000, currency: 'USD',
rooms: 3, area: 80, floor: 4, floorsTotal: 9, isAgency: false, photoUrls: [], ...over,
};
};
beforeAll(async () => {
t = await testApp('archive', source);
// Категория должна быть непустой, чтобы первый проход по ней состоялся.
pages.set(SALE.id, [listing({ district: 'Нубарашен' })]);
});
afterAll(() => t.close());
const tg = fakeTelegram();
const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); };
const publish = async (...items: SourcedListing[]) => {
pages.set(SALE.id, [...items, ...(pages.get(SALE.id) ?? [])]);
const report = await runIngest({ db: t.db, source });
await flush();
return report;
};
/** Объявление сняли: его нет ни на странице объявления, ни в списке категории. */
const unpublish = (item: SourcedListing) => {
alive.set(item.externalId, false);
pages.set(SALE.id, (pages.get(SALE.id) ?? []).filter((x) => x.externalId !== item.externalId));
};
interface Member { api: ReturnType<typeof t.client>; id: number; chat: number }
const company = async (agents = 1, plan: 'solo' | 'team' = 'team') => {
// Объявления прошлых сценариев «состариваем», чтобы новый фильтр не забрал их из базы.
await t.db.update(listings).set({ publishedAt: new Date(Date.now() - 30 * 86_400_000), bumpedAt: null });
const [sa] = await t.db
.insert(users)
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
.returning();
const admin = t.client(await createSession(t.db, sa!.id));
const created = await admin.post('/api/admin/companies', {
name: 'Агентство', plan, billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
});
const join = async (name: string, token: string): Promise<Member> => {
const api = await t.register(name);
await api.post(`/api/invites/${token}/accept`);
const id = (await api.get('/api/me')).body.user.id as number;
const chat = ++tgId;
await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id));
return { api, id, chat };
};
const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!);
// Solo работает только из Mini App: помечаем сессию как открытую в Telegram.
if (plan === 'solo') await t.db.update(sessions).set({ viaTelegram: true }).where(eq(sessions.userId, boss.id));
const team: Member[] = [];
if (agents) {
const invite = (await boss.api.post('/api/invites')).body.token as string;
for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite));
}
await boss.api.post('/api/filters', { name: 'Кентрон', scope: 'company', params: { districts: ['Кентрон'] } });
await runIngest({ db: t.db, source });
await flush();
const p = async (name: 'object' | 'client') => {
const info = (await boss.api.get(`/api/pipelines/${name}`)).body;
return { ...info, status: (code: string) => info.statuses.find((s: any) => s.code === code).id as number };
};
return { boss, agents: team, id: created.body.id as number, object: await p('object'), client: await p('client') };
};
const poolOf = async (m: Member) => (await m.api.get('/api/leads?tab=pool')).body as any[];
const deal = { commission: 3000, currency: 'USD', date: new Date().toISOString().slice(0, 10) };
const day = 86_400_000;
describe('снятые и повторно опубликованные объявления', () => {
it('раз в сутки объект из CRM проверяется на list.am: снятый получает отметку и дату', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const item = listing();
await publish(item);
const [lead] = await poolOf(a);
await a.api.post(`/api/leads/${lead.id}/take`);
// Свежее объявление пока не проверяется.
expect(await runRemovalCheck({ db: t.db, source })).toEqual({ checked: 0, removed: 0 });
const tomorrow = new Date(Date.now() + day + 60_000);
alive.set(item.externalId, undefined);
expect(await runRemovalCheck({ db: t.db, source, now: tomorrow })).toMatchObject({ removed: 0 });
expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).toBeNull();
// Понять не удалось — повтор через час, а не через сутки.
unpublish(item);
expect(await runRemovalCheck({ db: t.db, source, now: new Date(tomorrow.getTime() + 30 * 60_000) })).toMatchObject({ removed: 0 });
const later = new Date(tomorrow.getTime() + 61 * 60_000);
expect(await runRemovalCheck({ db: t.db, source, now: later })).toMatchObject({ removed: 1 });
expect(new Date((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).getTime()).toBe(later.getTime());
// Снятое больше не проверяется.
expect(await runRemovalCheck({ db: t.db, source, now: new Date(later.getTime() + 2 * day) })).toMatchObject({ removed: 0 });
});
it('свежее объявление первый раз проверяется через 4 часа после находки', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const item = listing();
await publish(item);
const [lead] = await poolOf(a);
await a.api.post(`/api/leads/${lead.id}/take`);
unpublish(item);
const hour = 3_600_000;
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 3 * hour) })).toMatchObject({ removed: 0 });
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 4 * hour + 60_000) })).toMatchObject({ removed: 1 });
expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).not.toBeNull();
});
it('«Объявления больше нет»: отметка у всех компаний, объявление снова в списке — вернулось на сайт', async () => {
const first = await company(1);
const second = await company(1);
const [a] = first.agents as [Member];
const [b] = second.agents as [Member];
const item = listing();
await publish(item);
const [lead] = await poolOf(a);
const [other] = await poolOf(b);
// Отметить можно и объект из пула.
expect((await a.api.post(`/api/leads/${lead.id}/removed`)).status).toBe(200);
const card = (await a.api.get(`/api/leads/${lead.id}`)).body;
expect(card.removedAt).not.toBeNull();
expect(card.events.map((e: any) => e.type)).toContain('listing_removed');
expect((await b.api.get(`/api/leads/${other.id}`)).body.removedAt).not.toBeNull();
// Ошибочная отметка: объявление всё ещё в списке list.am, и следующий проход парсера её снимает.
await runIngest({ db: t.db, source });
expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).toBeNull();
// У объекта, заведённого вручную, объявления нет.
const manual = (await a.api.post('/api/leads', { title: 'Дом в Гарни' })).body.id;
expect((await a.api.post(`/api/leads/${manual}/removed`)).status).toBe(400);
});
it('снятое объявление: при настройке компании открытые лиды сами уходят в «Отказ», у других — без изменений', async () => {
const on = await company(1);
const off = await company(1);
expect((await on.boss.api.patch('/api/company', { autoRejectRemoved: true })).status).toBe(200);
const [a] = on.agents as [Member];
const [b] = off.agents as [Member];
const taken = listing();
const pooled = listing();
await publish(taken, pooled);
const leadOf = async (m: Member, item: SourcedListing) => (await poolOf(m)).find((l) => l.url === item.sourceUrl);
const mine = await leadOf(a, taken);
const inPool = await leadOf(a, pooled);
const other = await leadOf(b, taken);
await a.api.post(`/api/leads/${mine.id}/take`);
// Проверка list.am увидела, что объявление сняли.
unpublish(taken);
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 5 * 3_600_000) })).toMatchObject({ removed: 1 });
const card = (await a.api.get(`/api/leads/${mine.id}`)).body;
expect(card.statusId).toBe(on.object.status('rejected'));
expect(on.object.reasons.find((r: any) => r.id === card.rejectReasonId)).toMatchObject({ code: 'gone', name: 'Уже продано или сдано' });
expect(card.events.some((e: any) => e.type === 'status' && e.text === 'Объявление снято с List.am')).toBe(true);
expect((await b.api.get(`/api/leads/${other.id}`)).body.statusId).toBe(off.object.status('new'));
// Отметка агента «Объявления больше нет» — лид из пула тоже уходит в «Отказ» и пропадает из пула.
expect((await a.api.post(`/api/leads/${inPool.id}/removed`)).status).toBe(200);
expect((await a.api.get(`/api/leads/${inPool.id}`)).body.statusId).toBe(on.object.status('rejected'));
expect((await poolOf(a)).map((l) => l.id)).not.toContain(inPool.id);
await on.boss.api.patch('/api/company', { autoRejectRemoved: false });
});
it('правка объекта: ответственный и руководитель меняют данные карточки, общая база не меняется', async () => {
const c = await company(2);
const [a, b] = c.agents as [Member, Member];
const item = listing();
await publish(item);
const [lead] = await poolOf(a);
// Объект в пуле агент не правит — сначала «Беру».
expect((await a.api.patch(`/api/leads/${lead.id}`, { price: 900 })).status).toBe(403);
await a.api.post(`/api/leads/${lead.id}/take`);
// Чужой объект другой агент даже не видит.
expect((await b.api.patch(`/api/leads/${lead.id}`, { price: 900 })).status).toBe(404);
expect((await a.api.patch(`/api/leads/${lead.id}`, { title: '2-комн. квартира', price: 900, rooms: '', currency: 'AMD' })).status).toBe(200);
const card = (await a.api.get(`/api/leads/${lead.id}`)).body;
expect(card).toMatchObject({ title: '2-комн. квартира', price: 900, rooms: null, currency: 'AMD', area: 80, district: 'Кентрон' });
expect(card.events.filter((e: any) => e.type === 'edited')).toHaveLength(1);
// Текст для клиента собран из новых данных.
expect(card.clientText).not.toContain('Комнат');
// Повтор тех же значений историю не засоряет.
await a.api.patch(`/api/leads/${lead.id}`, { price: 900 });
expect((await a.api.get(`/api/leads/${lead.id}`)).body.events.filter((e: any) => e.type === 'edited')).toHaveLength(1);
expect((await c.boss.api.patch(`/api/leads/${lead.id}`, { floor: 5 })).status).toBe(200);
expect((await c.boss.api.patch(`/api/leads/${lead.id}`, { title: '' })).status).toBe(400);
expect((await c.boss.api.patch(`/api/leads/${lead.id}`, {})).status).toBe(400);
const [base] = await t.db.select().from(listings).where(eq(listings.externalId, item.externalId));
expect(base).toMatchObject({ title: '3-комн. квартира', price: 150_000, rooms: 3, floor: 4 });
});
it('снятое объявление опубликовано снова под тем же номером: новый лид со ссылкой на прошлый', async () => {
const closed = await company(1);
const open = await company(1);
const [a] = closed.agents as [Member];
const [b] = open.agents as [Member];
const item = listing();
await publish(item);
const first = (await poolOf(a))[0];
await a.api.post(`/api/leads/${first.id}/take`);
await a.api.post(`/api/leads/${first.id}/status`, { statusId: closed.object.status('rejected'), rejectReasonId: closed.object.reasons[1].id });
const kept = (await poolOf(b))[0];
await b.api.post(`/api/leads/${kept.id}/take`);
unpublish(item);
const removedAt = new Date(Date.now() + day + 60_000);
await runRemovalCheck({ db: t.db, source, now: removedAt });
// Пока объявление снято, повторный проход парсера ничего не создаёт.
expect(await runIngest({ db: t.db, source })).toMatchObject({ leads: 0 });
const before = tg.inbox(a.chat).length;
alive.delete(item.externalId);
const report = await publish(item);
// Новый лид получает только компания, у которой прошлый закрыт.
expect(report.leads).toBe(1);
expect(await poolOf(b)).toHaveLength(0);
const again = (await poolOf(a))[0];
expect(again.id).not.toBe(first.id);
expect(tg.inbox(a.chat).length).toBe(before + 1);
const card = (await a.api.get(`/api/leads/${again.id}`)).body;
expect(card.removedAt).toBeNull();
expect(card.previous).toMatchObject({ id: first.id, assigneeName: 'Агент 1', status: 'Отказ' });
expect(new Date(card.previous.removedAt).getTime()).toBe(removedAt.getTime());
// Следующие проходы лид не дублируют.
expect(await runIngest({ db: t.db, source })).toMatchObject({ leads: 0 });
expect(await poolOf(a)).toHaveLength(1);
});
it('тот же объект под новым номером: совпали адрес и характеристики, прошлое объявление снято', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const old = listing({ address: 'ул. Туманяна 12' });
await publish(old);
const first = (await poolOf(a))[0];
await a.api.post(`/api/leads/${first.id}/take`);
// Пока прошлое объявление на сайте, совпадение адреса ничего не значит (соседняя квартира).
await publish(listing({ address: 'ул. Туманяна 12' }));
const neighbour = (await poolOf(a))[0];
expect((await a.api.get(`/api/leads/${neighbour.id}`)).body.previous).toBeNull();
await a.api.post(`/api/leads/${neighbour.id}/take`);
unpublish(old);
await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + day + 60_000) });
await publish(listing({ address: ' УЛ. Туманяна 12 ', price: 140_000 }), listing({ address: 'ул. Туманяна 12', rooms: 2 }));
const pool = await poolOf(a);
expect(pool).toHaveLength(2);
const cards = await Promise.all(pool.map(async (l) => (await a.api.get(`/api/leads/${l.id}`)).body));
expect(cards.find((x) => x.rooms === 3).previous).toMatchObject({ id: first.id, status: 'Взят в работу' });
expect(cards.find((x) => x.rooms === 2).previous).toBeNull();
});
});
describe('база бывших клиентов и объектов', () => {
it('закрытые сделкой и отказом объекты и клиенты попадают в базу; Solo базы и аналитики не видит', async () => {
const c = await company(2);
const [a1, a2] = c.agents as [Member, Member];
const mk = async (m: Member, over: object) => (await m.api.post('/api/leads', { title: 'Дом в Арабкире', district: 'Арабкир', price: 300_000, ...over })).body.id as number;
const sold = await mk(a1, { phone: '091 111 222', authorName: 'Гагик' });
const lost = await mk(a1, { phone: '091 111 222', title: 'Квартира Гагика' });
const working = await mk(a1, { title: 'В работе' });
const foreign = await mk(a2, { title: 'Чужой', phone: '077 333 444' });
const buyer = (await a1.api.post('/api/clients', { name: 'Анна', phone: '055 000 111', kind: 'buyer' })).body.id as number;
await a1.api.post(`/api/clients/${buyer}/leads`, { leadId: sold });
await a1.api.post(`/api/leads/${sold}/status`, { statusId: c.object.status('deal'), deal: { ...deal, clientId: buyer } });
await a1.api.post(`/api/leads/${lost}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[2].id });
await a2.api.post(`/api/leads/${foreign}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[1].id });
const mine = (await a1.api.get('/api/archive/leads')).body;
expect(mine.map((l: any) => l.id).sort()).toEqual([sold, lost].sort());
expect(mine.find((l: any) => l.id === sold)).toMatchObject({ result: 'deal', commission: 3000, dealCurrency: 'USD', phone: '+37491111222' });
expect(mine.find((l: any) => l.id === lost)).toMatchObject({ result: 'rejected', reason: c.object.reasons[2].name, removedAt: null });
expect(mine.some((l: any) => l.id === working)).toBe(false);
expect((await a1.api.get('/api/archive/leads?result=deal')).body).toHaveLength(1);
expect((await a1.api.get('/api/archive/leads?q=111222')).body).toHaveLength(2);
expect((await c.boss.api.get('/api/archive/leads')).body).toHaveLength(3);
expect((await c.boss.api.get(`/api/archive/leads?assigneeId=${a2.id}`)).body).toMatchObject([{ id: foreign }]);
// Клиенты: только закрытые покупатели и арендаторы, собственники закрытых объектов сюда не попадают.
const contacts = (await a1.api.get('/api/archive/contacts')).body;
expect(contacts).toHaveLength(1);
expect(contacts[0]).toMatchObject({ type: 'buyer', name: 'Анна', phone: '+37455000111', result: 'deal', objects: [{ id: sold }] });
expect((await c.boss.api.get('/api/archive/contacts')).body).toHaveLength(1);
expect((await a1.api.get('/api/archive/contacts?q=Анна')).body).toHaveLength(1);
const solo = await company(0, 'solo');
expect((await solo.boss.api.get('/api/archive/leads')).body.code).toBe('MODULE_NOT_AVAILABLE');
expect((await solo.boss.api.get('/api/archive/contacts')).status).toBe(403);
expect((await solo.boss.api.get(`/api/analytics?from=${new Date(0).toISOString()}&to=${new Date().toISOString()}`)).body.code).toBe('MODULE_NOT_AVAILABLE');
});
});
describe('суперадмин: общая база объявлений', () => {
it('суперадмин видит все объявления с фильтрами и кому они ушли лидом; руководителю раздел закрыт', async () => {
const c = await company(1);
const hit = listing({ title: 'Особняк на Туманяна', price: 777_000 });
const miss = listing({ district: 'Нубарашен', isAgency: true, title: 'Склад' });
await publish(hit, miss);
const [sa] = await t.db.insert(users).values({ name: 'Владелец', role: 'superadmin', email: `own${Math.random()}@test.am`, passwordHash: 'x' }).returning();
const admin = t.client(await createSession(t.db, sa!.id));
const all = (await admin.get('/api/admin/listings')).body;
expect(all.total).toBeGreaterThanOrEqual(2);
expect(all.items.length).toBe(Math.min(all.total, 50));
const found = (await admin.get(`/api/admin/listings?q=${hit.externalId}`)).body;
expect(found).toMatchObject({ total: 1, items: [{ title: 'Особняк на Туманяна', price: 777_000, active: true }] });
// Лид получили все компании с подходящим фильтром, включая компании прошлых сценариев.
expect(found.items[0].leads).toBeGreaterThanOrEqual(1);
expect((await admin.get(`/api/admin/listings?q=${encodeURIComponent('особняк')}&leads=no`)).body.total).toBe(0);
const agencies = (await admin.get('/api/admin/listings?author=agency&leads=no')).body.items;
expect(agencies.map((l: any) => l.externalId)).toContain(miss.externalId);
expect((await admin.get('/api/admin/listings?priceFrom=700000&priceTo=800000')).body.items.map((l: any) => l.externalId)).toEqual([hit.externalId]);
const card = (await admin.get(`/api/admin/listings/${found.items[0].id}`)).body;
expect(card.leads).toHaveLength(found.items[0].leads);
expect(card.leads[0]).toMatchObject({ company: 'Агентство', status: 'Новый', assignee: null });
expect((await c.boss.api.get('/api/admin/listings')).status).toBe(403);
expect((await c.boss.api.get(`/api/admin/listings/${found.items[0].id}`)).status).toBe(403);
// Настройки парсера: ключ ридера наружу не отдаётся; правка одного поля не трогает остальные.
expect((await c.boss.api.get('/api/admin/parser/settings')).status).toBe(403);
const before = (await admin.get('/api/admin/parser/settings')).body;
expect(before).toMatchObject({ enabled: true, freshDays: 3, pauseSec: 5, maxPages: 3, removalCheck: true, jinaKey: { source: 'none', tail: null } });
expect(before.categories).toHaveLength(10);
const saved = (await admin.patch('/api/admin/parser/settings', { jinaApiKey: 'jina_secret_1234', pauseSec: 30 })).body;
expect(saved).toMatchObject({ pauseSec: 30, freshDays: 3, jinaKey: { source: 'settings', tail: '1234' } });
expect(JSON.stringify(saved)).not.toContain('jina_secret');
expect((await admin.patch('/api/admin/parser/settings', { freshDays: 0 })).status).toBe(400);
// Выключенная категория не читается; срок свежести действует на блок «Топ» — его порядок ротируется.
await admin.patch('/api/admin/parser/settings', { categories: [56], freshDays: 1 });
const skipped = listing({ promoted: true });
expect(await publish(skipped)).toMatchObject({ categories: 1, foundNew: 0 });
await admin.patch('/api/admin/parser/settings', { categories: before.categories });
source.inspect = async (card) => ({ ...card, publishedAt: new Date(Date.now() - 3 * 86_400_000) });
expect(await runIngest({ db: t.db, source })).toMatchObject({ categories: 10, foundNew: 0 });
source.inspect = async (card) => ({ ...card, publishedAt: new Date(Date.now() - 40_000) });
await admin.patch('/api/admin/parser/settings', { freshDays: 3, removalCheck: false });
// Проверка снятых выключена — ничего не проверяется.
expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 5 * day) })).toEqual({ checked: 0, removed: 0 });
await admin.patch('/api/admin/parser/settings', { removalCheck: true, jinaApiKey: '' });
expect((await admin.get('/api/admin/parser/settings')).body.jinaKey).toEqual({ source: 'none', tail: null });
});
});
describe('аналитика по сотрудникам', () => {
const range = () => `from=${new Date(Date.now() - day).toISOString()}&to=${new Date(Date.now() + 3 * day).toISOString()}`;
it('воронка объекта: взято, скорость реакции, возвраты в пул, конверсия, сделки, причины отказа', async () => {
const c = await company(2);
const [a1, a2] = c.agents as [Member, Member];
const st = c.object.statuses.map((s: any) => s.id) as number[]; // new, taken, reached, meeting, contract, sale, deal, rejected
await publish(listing(), listing(), listing(), listing());
const pool = await poolOf(a1);
expect(pool).toHaveLength(4);
const [l1, l2, l3, l4] = pool.map((l) => l.id) as [number, number, number, number];
// Агент 1 берёт три лида: один доводит до сделки, один — до встречи и отказа, один теряет по таймеру.
for (const id of [l1, l2, l3]) await a1.api.post(`/api/leads/${id}/take`);
// Скорость реакции: уведомление пришло за 60, 120 и 600 секунд до «Беру» → медиана 120.
for (const [id, sec] of [[l1, 60], [l2, 120], [l3, 600]] as const) {
const [taken] = await t.db.select().from(events).where(eq(events.leadId, id)).then((r) => r.filter((e) => e.type === 'taken'));
await t.db.update(leadNotifications).set({ sentAt: new Date(taken!.at.getTime() - sec * 1000) }).where(eq(leadNotifications.leadId, id));
}
await a1.api.post(`/api/leads/${l1}/status`, { statusId: st[4] });
await a1.api.post(`/api/leads/${l1}/status`, { statusId: c.object.status('deal'), deal });
await a1.api.post(`/api/leads/${l2}/status`, { statusId: st[3] });
await a1.api.post(`/api/leads/${l2}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[4].id });
await t.db.update(leads).set({ statusChangedAt: new Date(Date.now() - 25 * 3_600_000) }).where(eq(leads.id, l3));
expect(await runLeadJobs(t.db)).toMatchObject({ returned: 1 });
// Агент 2 подбирает потерянный лид и доводит до «Дозвонился»; руководитель назначает ему четвёртый.
await a2.api.post(`/api/leads/${l3}/take`);
await a2.api.post(`/api/leads/${l3}/status`, { statusId: st[2] });
await c.boss.api.post(`/api/leads/${l4}/assign`, { userId: a2.id });
const r = (await c.boss.api.get(`/api/analytics?pipeline=object&${range()}`)).body;
expect(r.statuses.map((s: any) => s.id)).toEqual(st);
expect(r.summary).toMatchObject({ created: 4, taken: 4, open: 0, cohort: 4 });
// Воронка целиком: все 4 взяты; до «Дозвонился» и дальше дошли 3; до встречи — 2; договор и сделка — 1; отказ — 1.
expect(st.map((id) => r.summary.funnel[id] ?? 0)).toEqual([4, 4, 3, 2, 1, 1, 1, 1]);
const row = (m: Member) => r.agents.find((x: any) => x.userId === m.id);
expect(row(a1)).toMatchObject({ taken: 3, reactionSec: 120, poolReturns: 1, cohort: 3, deals: 1, commission: { USD: 3000 } });
// Лид, потерянный агентом 1, у него остался на этапе «Взят»; дальнейшее — заслуга агента 2.
expect(st.map((id) => row(a1).reached[id] ?? 0)).toEqual([3, 3, 2, 2, 1, 1, 1, 1]);
expect(row(a1).reasons).toEqual({ [c.object.reasons[4].id]: 1 });
expect(row(a2)).toMatchObject({ taken: 2, poolReturns: 0, cohort: 2, deals: 0, commission: {} });
expect(st.map((id) => row(a2).reached[id] ?? 0)).toEqual([2, 2, 1, 0, 0, 0, 0, 0]);
expect(row(c.boss)).toMatchObject({ taken: 0, cohort: 0 });
expect(r.totals).toMatchObject({ taken: 5, poolReturns: 1, deals: 1, commission: { USD: 3000 } });
// Прошлый период пуст; агент аналитику не видит.
const past = (await c.boss.api.get(`/api/analytics?from=${new Date(Date.now() - 30 * day).toISOString()}&to=${new Date(Date.now() - 20 * day).toISOString()}`)).body;
expect(past.summary).toMatchObject({ created: 0, taken: 0 });
expect(past.totals.deals).toBe(0);
expect((await a1.api.get(`/api/analytics?${range()}`)).status).toBe(403);
});
it('воронка клиента: новые запросы, показы, конверсия, сделки, причины отказа', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const st = c.client.statuses.map((s: any) => s.id) as number[]; // new, qualification, selection, showing, negotiation, deal, rejected
const mk = async (name: string) => (await a.api.post('/api/clients', { name, kind: 'buyer' })).body.id as number;
const [won, lost] = [await mk('Анна'), await mk('Давид'), await mk('Мария')];
const leadId = (await a.api.post('/api/leads', { title: 'Квартира' })).body.id as number;
await a.api.post(`/api/clients/${won}/leads`, { leadId, state: 'shown' });
await a.api.post(`/api/clients/${won}/status`, { statusId: st[3] });
await a.api.post(`/api/clients/${won}/status`, { statusId: c.client.status('deal'), deal: { ...deal, commission: 2000, leadId } });
await a.api.post(`/api/clients/${lost}/status`, { statusId: st[1] });
await a.api.post(`/api/clients/${lost}/status`, { statusId: c.client.status('rejected'), rejectReasonId: c.client.reasons[0].id });
const r = (await c.boss.api.get(`/api/analytics?pipeline=client&${range()}`)).body;
expect(r.summary).toMatchObject({ created: 3, open: 1, cohort: 3 });
expect(st.map((id) => r.summary.funnel[id] ?? 0)).toEqual([3, 2, 1, 1, 1, 1, 1]);
const row = r.agents.find((x: any) => x.userId === a.id);
expect(row).toMatchObject({ taken: 3, showings: 1, cohort: 3, deals: 1, commission: { USD: 2000 }, reasons: { [c.client.reasons[0].id]: 1 } });
// Парная сделка одна: в аналитике объектов та же комиссия, без удвоения.
const objects = (await c.boss.api.get(`/api/analytics?pipeline=object&${range()}`)).body;
expect(objects.totals).toMatchObject({ deals: 1, commission: { USD: 2000 } });
});
});
+152
View File
@@ -0,0 +1,152 @@
import { hasModule, type Plan } from '@lr/shared';
import { and, desc, eq, gte, ilike, inArray, lte, not, or, sql, type SQL } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Db } from '../db/client.ts';
import { clientLeads, clients, deals, leads, listings, pipelineStatuses, rejectReasons, users } from '../db/schema.ts';
import type { Guards } from '../leads/routes.ts';
import { forbidden } from '../lib/errors.ts';
import { departmentScope, inDepartment, listingLive, priceRange } from '../leads/service.ts';
import { loadBillingSettings } from '../billing/service.ts';
// База бывших клиентов и объектов (тарифы Team и Agency): всё, что закрыто сделкой или отказом, и объекты,
// снятые с List.am. Попадает сюда автоматически — это те же лиды и клиенты, отобранные по итоговому статусу.
// Сотрудник видит базу своего отдела, руководитель — всех отделов.
const CLOSED = ['deal', 'rejected'];
export function registerArchiveRoutes(app: FastifyInstance, db: Db, g: Guards) {
const guard = (req: Parameters<Guards['member']>[0]) => {
const ctx = g.member(req);
if (!hasModule(ctx.company.plan as Plan, 'archive')) throw forbidden('MODULE_NOT_AVAILABLE');
return ctx;
};
const Query = z.object({
q: z.string().trim().max(100).optional(),
/** removed — объекты, снятые с List.am, в любом статусе. */
result: z.enum(['deal', 'rejected', 'removed']).optional(),
assigneeId: z.coerce.number().int().optional(),
district: z.string().trim().max(60).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(),
});
const like = (q: string) => `%${q.replace(/[%_\\]/g, '\\$&')}%`;
/** Закрытые объекты. Агент видит свои, руководитель и агенты при открытой видимости — все. */
app.get('/api/archive/leads', async (req) => {
const ctx = guard(req);
const q = Query.parse(req.query);
const isManager = ctx.role === 'manager';
const seeAll = isManager || ctx.company.settings.agentsSeeAll;
const removed = not(listingLive);
const where: (SQL | undefined)[] = [
eq(leads.companyId, ctx.company.id),
q.result === 'removed' ? removed : q.result ? inArray(pipelineStatuses.code, [q.result]) : or(inArray(pipelineStatuses.code, CLOSED), removed),
departmentScope(ctx, leads.departmentId),
seeAll ? undefined : eq(leads.assigneeId, ctx.user.id),
];
if (q.assigneeId && seeAll) where.push(eq(leads.assigneeId, q.assigneeId));
if (q.district) where.push(ilike(leads.district, q.district));
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.statusChangedAt, q.from));
if (q.to) where.push(lte(leads.statusChangedAt, q.to));
if (q.q) {
const digits = q.q.replace(/\D/g, '');
where.push(
or(
ilike(leads.address, like(q.q)),
ilike(leads.title, like(q.q)),
ilike(leads.url, like(q.q)),
digits.length >= 4
? and(ilike(leads.phone, `%${digits}%`), isManager ? undefined : eq(leads.assigneeId, ctx.user.id))
: undefined,
),
);
}
const rows = await db
.select({
lead: leads, result: pipelineStatuses.code, assigneeName: users.name, reason: rejectReasons.name,
commission: deals.commission, dealCurrency: deals.currency, active: listings.active, removedAt: listings.removedAt,
})
.from(leads)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.leftJoin(users, eq(users.id, leads.assigneeId))
.leftJoin(rejectReasons, eq(rejectReasons.id, leads.rejectReasonId))
.leftJoin(deals, eq(deals.leadId, leads.id))
.leftJoin(listings, eq(listings.id, leads.listingId))
.where(and(...where))
.orderBy(desc(leads.statusChangedAt))
.limit(300);
return rows.map(({ lead: l, ...r }) => {
const own = isManager || l.assigneeId === ctx.user.id;
return {
id: l.id, title: l.title, url: l.url, source: l.source, dealType: l.dealType, district: l.district, region: l.region,
address: l.address, price: l.price, currency: l.currency, photo: l.photos[0] ?? null,
authorName: own ? l.authorName : null, phone: own ? l.phone : null,
assigneeId: l.assigneeId, assigneeName: r.assigneeName, closedAt: l.statusChangedAt,
result: r.result, reason: r.reason, commission: r.commission, dealCurrency: r.dealCurrency,
// Отметка «Снято с List.am».
removedAt: r.active === false ? r.removedAt : null,
};
});
});
/** Клиенты: закрытые покупатели и арендаторы, у каждого — список связанных объектов. */
app.get('/api/archive/contacts', async (req) => {
const ctx = guard(req);
const q = Query.pick({ q: true, result: true, assigneeId: true }).parse(req.query);
const isManager = ctx.role === 'manager';
const seeAll = isManager || ctx.company.settings.agentsSeeAll;
const digits = q.q?.replace(/\D/g, '') ?? '';
const agent = q.assigneeId && seeAll ? q.assigneeId : null;
const closedClients = await db
.select({ client: clients, result: pipelineStatuses.code, assigneeName: users.name, reason: rejectReasons.name })
.from(clients)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, clients.statusId))
.leftJoin(users, eq(users.id, clients.assigneeId))
.leftJoin(rejectReasons, eq(rejectReasons.id, clients.rejectReasonId))
.where(
and(
eq(clients.companyId, ctx.company.id),
inArray(pipelineStatuses.code, q.result && q.result !== 'removed' ? [q.result] : CLOSED),
isManager ? undefined : or(eq(clients.assigneeId, ctx.user.id), inDepartment(clients.departmentId, ctx.user.departmentId)),
seeAll ? undefined : eq(clients.assigneeId, ctx.user.id),
agent ? eq(clients.assigneeId, agent) : undefined,
q.q
? or(
ilike(clients.name, like(q.q)),
digits.length >= 4
? and(ilike(clients.phone, `%${digits}%`), isManager ? undefined : eq(clients.assigneeId, ctx.user.id))
: undefined,
)
: undefined,
),
)
.orderBy(desc(clients.statusChangedAt))
.limit(300);
const ids = closedClients.map((r) => r.client.id);
const links = ids.length
? await db
.select({ clientId: clientLeads.clientId, id: leads.id, title: leads.title, state: clientLeads.state })
.from(clientLeads)
.innerJoin(leads, eq(leads.id, clientLeads.leadId))
.where(inArray(clientLeads.clientId, ids))
: [];
return closedClients.map(({ client: c, result, assigneeName, reason }) => {
const own = isManager || c.assigneeId === ctx.user.id;
return {
type: c.kind, key: `c${c.id}`, clientId: c.id, name: c.name, phone: own ? c.phone : null, result, reason,
closedAt: c.statusChangedAt, assigneeName,
objects: links.filter((l) => l.clientId === c.id).map((l) => ({ id: l.id, title: l.title, state: l.state })),
};
});
});
}
+18
View File
@@ -0,0 +1,18 @@
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt) as (pw: string, salt: Buffer, len: number) => Promise<Buffer>;
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const hash = await scryptAsync(password, salt, 64);
return `scrypt$${salt.toString('base64')}$${hash.toString('base64')}`;
}
export async function verifyPassword(password: string, stored: string | null): Promise<boolean> {
const [algo, salt, hash] = (stored ?? '').split('$');
if (algo !== 'scrypt' || !salt || !hash) return false;
const expected = Buffer.from(hash, 'base64');
const actual = await scryptAsync(password, Buffer.from(salt, 'base64'), expected.length);
return timingSafeEqual(actual, expected);
}
+33
View File
@@ -0,0 +1,33 @@
import { createHash, randomBytes } from 'node:crypto';
import { and, eq, gt } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { sessions, users } from '../db/schema.ts';
export const SESSION_COOKIE = 'lr_session';
export const SESSION_DAYS = 30;
export const newToken = () => randomBytes(32).toString('base64url');
export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex');
export async function createSession(db: Db, userId: number, viaTelegram = false): Promise<string> {
const token = newToken();
await db.insert(sessions).values({
tokenHash: hashToken(token),
userId,
viaTelegram,
expiresAt: new Date(Date.now() + SESSION_DAYS * 86_400_000),
});
return token;
}
export async function findSession(db: Db, token: string) {
const [row] = await db
.select({ user: users, actingCompanyId: sessions.actingCompanyId, viaTelegram: sessions.viaTelegram })
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, new Date())));
return row ?? null;
}
export const deleteSession = (db: Db, token: string) =>
db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
+82
View File
@@ -0,0 +1,82 @@
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
export interface TelegramUser {
id: number;
firstName: string;
lastName?: string;
username?: string;
languageCode?: string;
}
/** Данные входа старше суток не принимаются. */
const MAX_AGE_SEC = 86_400;
const safeEqualHex = (a: string, b: string) => {
const x = Buffer.from(a, 'hex');
const y = Buffer.from(b, 'hex');
return x.length === y.length && x.length > 0 && timingSafeEqual(x, y);
};
const checkString = (pairs: [string, string][]) =>
pairs
.filter(([k]) => k !== 'hash')
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('\n');
const fresh = (authDate: string | undefined, now: number) => {
const t = Number(authDate);
return Number.isFinite(t) && now / 1000 - t <= MAX_AGE_SEC;
};
/** Проверка подписи Telegram Login Widget (вход в веб-кабинете). */
export function verifyLoginWidget(
data: Record<string, string | number | undefined>,
botToken: string,
now = Date.now(),
): TelegramUser | null {
const pairs = Object.entries(data)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => [k, String(v)] as [string, string]);
const map = new Map(pairs);
const hash = map.get('hash');
if (!hash) return null;
const secret = createHash('sha256').update(botToken).digest();
const expected = createHmac('sha256', secret).update(checkString(pairs)).digest('hex');
if (!safeEqualHex(expected, hash) || !fresh(map.get('auth_date'), now)) return null;
const id = Number(map.get('id'));
if (!Number.isSafeInteger(id)) return null;
return {
id,
firstName: map.get('first_name') ?? '',
lastName: map.get('last_name'),
username: map.get('username'),
};
}
/** Проверка initData из Telegram Mini App. */
export function verifyWebAppInitData(
initData: string,
botToken: string,
now = Date.now(),
): TelegramUser | null {
const params = new URLSearchParams(initData);
const hash = params.get('hash');
if (!hash) return null;
const secret = createHmac('sha256', 'WebAppData').update(botToken).digest();
const expected = createHmac('sha256', secret).update(checkString([...params.entries()])).digest('hex');
if (!safeEqualHex(expected, hash) || !fresh(params.get('auth_date') ?? undefined, now)) return null;
try {
const u = JSON.parse(params.get('user') ?? '') as Record<string, unknown>;
if (typeof u.id !== 'number') return null;
return {
id: u.id,
firstName: typeof u.first_name === 'string' ? u.first_name : '',
lastName: typeof u.last_name === 'string' ? u.last_name : undefined,
username: typeof u.username === 'string' ? u.username : undefined,
languageCode: typeof u.language_code === 'string' ? u.language_code : undefined,
};
} catch {
return null;
}
}
+128
View File
@@ -0,0 +1,128 @@
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashPassword } from './auth/password.ts';
import { createSession } from './auth/sessions.ts';
import { normalizeTelegram } from './barev/service.ts';
import { clients, companies, outbox, users, viewings } from './db/schema.ts';
import { testApp } from './testing.ts';
// Сайт BarevClub: объекты «дозвонились и указали комиссию» → заявка на просмотр → клиент в CRM и сообщение в топик группы.
let t: Awaited<ReturnType<typeof testApp>>;
beforeAll(async () => {
t = await testApp('barev');
});
afterAll(() => t.close());
let keySeq = 0;
/** Запрос от сервера сайта: с ключом компании. */
const site = async (key: string, method: 'GET' | 'POST', url: string, body?: object) => {
const res = await t.app.inject({ method, url, payload: body, headers: { 'x-barev-key': key } });
return { status: res.statusCode, body: res.headers['content-type']?.toString().startsWith('application/json') ? (res.json() as any) : res.rawPayload };
};
const tomorrow = () => new Date(Date.now() + 4 * 3_600_000 + 86_400_000).toISOString().slice(0, 10);
const setup = async () => {
const [sa] = await t.db
.insert(users)
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
.returning();
const admin = t.client(await createSession(t.db, sa!.id));
const created = await admin.post('/api/admin/companies', {
name: 'AREV', plan: 'team', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
});
const boss = await t.register('Руководитель');
await boss.post(`/api/invites/${(created.body.managerUrl as string).split('/invite/')[1]}/accept`);
const agentApi = await t.register('Арам');
await agentApi.post(`/api/invites/${(await boss.post('/api/invites')).body.token}/accept`);
const agentId = (await agentApi.get('/api/me')).body.user.id as number;
const id = created.body.id as number;
const key = `barev-test-key-${++keySeq}-0123456789abcdef`;
const [co] = await t.db.select().from(companies).where(eq(companies.id, id));
await t.db
.update(companies)
.set({ settings: { ...co!.settings, clientHost: 'arev.test', groupBot: { token: 'G:T', chatId: -100500, threadId: 2 }, barevClub: { key, threadId: 442 } } })
.where(eq(companies.id, id));
return { id, boss, agentApi, agentId, key };
};
describe('витрина BarevClub', () => {
it('без ключа и с чужим ключом — 401; ключ не виден в кабинете и не стирается сохранением настроек', async () => {
const { boss, key } = await setup();
expect((await site('', 'GET', '/api/barev/objects')).status).toBe(401);
expect((await site('x'.repeat(30), 'GET', '/api/barev/objects')).status).toBe(401);
expect((await boss.get('/api/me')).body.company.settings.barevClub).toBeUndefined();
await boss.patch('/api/company', { agentsSeeAll: true });
expect((await site(key, 'GET', '/api/barev/objects')).status).toBe(200);
});
it('показывает только объекты в работе с комиссией; заявка создаёт клиента у агента объекта и пишет в топик', async () => {
const { id: companyId, agentApi, agentId, key } = await setup();
const withCommission = (await agentApi.post('/api/leads', { title: '2-комн. квартира, ул. Абовяна 5', district: 'Кентрон', dealType: 'rent', price: 350000, currency: 'AMD', rooms: 2 })).body.id as number;
const noCommission = (await agentApi.post('/api/leads', { title: 'Дом', dealType: 'rent', price: 500000, currency: 'AMD' })).body.id as number;
await agentApi.post(`/api/leads/${withCommission}/commission`, { rule: { mode: 'percent', value: 50 } });
const list = await site(key, 'GET', '/api/barev/objects');
const ids = (list.body as { id: number }[]).map((o) => o.id);
expect(ids).toContain(withCommission);
expect(ids).not.toContain(noCommission);
const obj = (list.body as any[]).find((o) => o.id === withCommission);
expect(obj).toMatchObject({ district: 'Кентрон', price: 350000, currency: 'AMD', rooms: 2, photos: 0 });
expect(typeof obj.text).toBe('string');
// Ни адреса, ни ссылки, ни комиссии наружу.
expect(JSON.stringify(obj)).not.toContain('Абовяна');
expect(obj).not.toHaveProperty('commission');
expect(obj).not.toHaveProperty('url');
// Без контакта и в прошлый день — нельзя; на объект без комиссии — нельзя.
expect((await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'evening', name: 'Анна' })).body.code).toBe('CONTACT_REQUIRED');
expect((await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: '2020-01-01', time: 'evening', name: 'Анна', phone: '+37491111111' })).body.code).toBe('BAD_DAY');
expect((await site(key, 'POST', '/api/barev/requests', { leadId: noCommission, day: tomorrow(), time: 'evening', name: 'Анна', phone: '+37491111111' })).status).toBe(404);
expect((await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'evening', name: 'Анна', telegram: 'не ник' })).body.code).toBe('BAD_TELEGRAM');
t.groupSent.length = 0;
const res = await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'evening', name: 'Анна', telegram: 'https://t.me/anna_ru' });
expect(res.status).toBe(200);
const [client] = await t.db.select().from(clients).where(eq(clients.id, res.body.clientId));
expect(client).toMatchObject({ companyId, name: 'Анна', telegram: '@anna_ru', phone: null, source: 'BarevClub', assigneeId: agentId, kind: 'tenant' });
const [viewing] = await t.db.select().from(viewings).where(eq(viewings.clientId, client!.id));
expect(viewing).toMatchObject({ leadId: withCommission, day: tomorrow() });
expect(viewing!.note).toContain('вечер');
expect(t.groupSent).toHaveLength(1);
expect(t.groupSent[0]).toMatchObject({ kind: 'text', chatId: -100500, threadId: 442 });
expect(t.groupSent[0]!.text).toContain('Клиент из BarevClub');
expect(t.groupSent[0]!.text).toContain('просьба позвонить');
expect(t.groupSent[0]!.text).toContain('@anna_ru');
expect(t.groupSent[0]!.text).toContain('Объект ведёт: <b>Арам</b>');
expect(t.groupSent[0]!.text).toContain(`https://arev.test/clients/${client!.id}`);
// Тот же человек снова — та же карточка, с телефоном; в группе пометка о повторной заявке.
const again = await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'morning', name: 'Анна К.', phone: '091 22 33 44', telegram: '@anna_ru' });
expect(again.body.clientId).toBe(client!.id);
const [updated] = await t.db.select().from(clients).where(eq(clients.id, client!.id));
expect(updated!.phone).toBeTruthy();
expect(t.groupSent.at(-1)!.text).toContain('повторная заявка');
});
it('группа недоступна — агенту объекта уходит уведомление в бот', async () => {
const { agentApi, agentId, key } = await setup();
const lead = (await agentApi.post('/api/leads', { title: 'Студия', dealType: 'rent', price: 200000, currency: 'AMD' })).body.id as number;
await agentApi.post(`/api/leads/${lead}/commission`, { rule: { mode: 'percent', value: 50 } });
const failing = { album: async () => {}, text: async () => { throw new Error('chat not found'); } };
const { createViewingRequest, companyByKey } = await import('./barev/service.ts');
const company = await companyByKey(t.db, key);
await createViewingRequest({ db: t.db, config: t.config, sender: failing }, company, { leadId: lead, day: tomorrow(), time: 'day', name: 'Пётр', phone: '+37499000000' });
const queued = await t.db.select().from(outbox).where(eq(outbox.userId, agentId));
expect(queued.some((m) => (m.payload as { key: string }).key === 'viewingBooked')).toBe(true);
});
it('ник Telegram понимается в разных видах', () => {
expect(normalizeTelegram('@ivan_p')).toBe('@ivan_p');
expect(normalizeTelegram('t.me/ivan_p')).toBe('@ivan_p');
expect(normalizeTelegram('https://t.me/ivan_p/')).toBe('@ivan_p');
expect(normalizeTelegram('иван')).toBeNull();
expect(normalizeTelegram('ab')).toBeNull();
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { Db } from '../db/client.ts';
import type { GroupSender } from '../group/service.ts';
import type { Config } from '../lib/config.ts';
import type { ImageFetcher } from '../showcase/media.ts';
import { companyByKey, createViewingRequest, listObjects, objectPhoto, VIEWING_TIMES, type ViewingTime } from './service.ts';
// API для сайта BarevClub. Вход — ключ компании в заголовке X-Barev-Key: его знает только сервер сайта,
// посетителю сайта он не виден.
export function registerBarevRoutes(app: FastifyInstance, db: Db, config: Config, fetchImage?: ImageFetcher, sender?: GroupSender) {
const company = (req: FastifyRequest) => companyByKey(db, req.headers['x-barev-key'] as string | undefined);
app.get('/api/barev/objects', async (req) => listObjects(db, await company(req)));
app.get('/api/barev/objects/:id/photos/:index', async (req, reply) => {
const { id, index } = z.object({ id: z.coerce.number().int().positive(), index: z.coerce.number().int().min(0).max(50) }).parse(req.params);
const photo = await objectPhoto(db, config, await company(req), id, index, fetchImage);
return reply.header('content-type', 'image/jpeg').header('cache-control', 'public, max-age=86400').send(photo);
});
app.post('/api/barev/requests', async (req) => {
const co = await company(req);
const body = z
.object({
leadId: z.number().int().positive(),
day: z.iso.date(),
time: z.enum(Object.keys(VIEWING_TIMES) as [ViewingTime, ...ViewingTime[]]),
name: z.string().trim().min(1).max(80),
phone: z.string().trim().max(40).optional(),
telegram: z.string().trim().max(80).optional(),
})
.parse(req.body);
return createViewingRequest({ db, config, sender }, co, body);
});
}
+260
View File
@@ -0,0 +1,260 @@
import { normalizePhone } from '@lr/shared';
import { and, desc, eq, isNotNull, or, sql } from 'drizzle-orm';
import type { Db, Tx } from '../db/client.ts';
import { clientLeads, clients, companies, events, leads, outbox, pipelineStatuses, users, viewings } from '../db/schema.ts';
import { telegramGroupSender, type GroupSender } from '../group/service.ts';
import type { Config } from '../lib/config.ts';
import { listingLive } from '../leads/service.ts';
import { AppError, badRequest, notFound } from '../lib/errors.ts';
import { statusByCode } from '../pipelines/service.ts';
import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts';
import { renderShowcaseText } from '../showcase/text.ts';
import { esc } from '../telegram/texts.ts';
// Витрина на сайте BarevClub (barevclub.com → «Найдём квартиру»).
// Сайт по ключу компании берёт её объекты — те же, что бот группы публикует в закрытую группу:
// агент дозвонился (объект в работе) и указал комиссию. Клиент выбирает объект и день просмотра,
// оставляет имя и телефон или Telegram — в CRM появляется клиент «BarevClub», закреплённый за агентом
// объекта, а бот группы пишет об этом в топик заявок.
export const BAREV_SOURCE = 'BarevClub';
/** Сколько фото объекта показываем на сайте (как на странице подборки). */
const MAX_PHOTOS = 20;
const isListAmPhoto = (url: string) => /^https:\/\/(?:img|s)\.list\.am\//.test(url);
type Company = typeof companies.$inferSelect;
type Lead = typeof leads.$inferSelect;
/** Компания по ключу сайта. Ключ сравнивается целиком; пустой или чужой — 401. */
export async function companyByKey(db: Db, key: string | undefined): Promise<Company> {
if (!key || key.length < 24) throw new AppError(401, 'BAD_KEY');
const [company] = await db.select().from(companies).where(sql`${companies.settings}->'barevClub'->>'key' = ${key}`);
if (!company) throw new AppError(401, 'BAD_KEY');
return company;
}
/** Объекты на витрине: в работе (не новый, не сделка, не отказ), с агентом и с комиссией в карточке. */
async function showcaseLeads(db: Tx, companyId: number, leadId?: number) {
return db
.select({ lead: leads })
.from(leads)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.where(
and(
eq(leads.companyId, companyId),
isNotNull(leads.assigneeId),
isNotNull(leads.commission),
sql`coalesce(${pipelineStatuses.code}, '') not in ('new', 'rejected', 'deal')`,
// Снятый с List.am объект с витрины убирается.
listingLive,
leadId ? eq(leads.id, leadId) : undefined,
),
)
.orderBy(desc(leads.takenAt), desc(leads.id));
}
/**
* Что видит посетитель сайта. Адреса, телефона собственника, ссылки на объявление и комиссии здесь нет:
* показ идёт через агента. Фото — адреса относительно сайта, сам сайт забирает их по ключу.
*/
export async function listObjects(db: Db, company: Company) {
const rows = await showcaseLeads(db, company.id);
return rows.map(({ lead: l }) => ({
id: l.id,
/** «Описание для клиента» из карточки объекта. */
text: l.showcaseText ?? renderShowcaseText(l),
dealType: l.dealType,
propertyType: l.propertyType,
district: l.district,
price: l.price,
currency: l.currency,
rooms: l.rooms,
area: l.area,
floor: l.floor,
floorsTotal: l.floorsTotal,
/** Сколько фото у объекта; само фото — GET …/objects/:id/photos/:index. */
photos: Math.min(l.photos.length, MAX_PHOTOS),
}));
}
/** Фото объекта витрины: из объявления — обрезанное, у объекта, заведённого вручную, — как есть. */
export async function objectPhoto(db: Db, config: Config, company: Company, leadId: number, index: number, fetcher?: ImageFetcher) {
const [row] = await showcaseLeads(db, company.id, leadId);
const url = row?.lead.photos[index];
if (!url || index >= MAX_PHOTOS) throw notFound();
if (!isListAmPhoto(url)) {
const res = await fetch(url, { signal: AbortSignal.timeout(20_000) }).catch(() => null);
if (!res?.ok) throw notFound();
return Buffer.from(await res.arrayBuffer());
}
return croppedPhoto(config, url, fetcher).catch(() => {
throw notFound();
});
}
export const VIEWING_TIMES = { morning: 'утро, 10:00–13:00', day: 'день, 13:00–17:00', evening: 'вечер, 17:00–20:00' } as const;
export type ViewingTime = keyof typeof VIEWING_TIMES;
export interface ViewingRequest {
leadId: number;
day: string;
time: ViewingTime;
name: string;
phone?: string;
telegram?: string;
}
/** «@ivan», «ivan», «t.me/ivan», «https://t.me/ivan» → «@ivan». null — не похоже на ник. */
export function normalizeTelegram(raw: string): string | null {
const nick = raw.trim().replace(/^(?:https?:\/\/)?(?:t\.me|telegram\.me)\//i, '').replace(/^@/, '').replace(/\/$/, '');
return /^[A-Za-z][A-Za-z0-9_]{3,31}$/.test(nick) ? `@${nick}` : null;
}
/** Дата по Еревану (UTC+4). */
const ymd = (d: Date) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10);
/** «пятница, 24 октября». */
const humanDay = (day: string) =>
new Date(`${day}T12:00:00+04:00`).toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'Asia/Yerevan' });
const SYMBOL: Record<string, string> = { USD: '$', AMD: '֏', RUB: '₽', EUR: '€' };
const priceLine = (price: number | null, currency: string | null) => {
if (!price) return null;
const n = Math.round(price).toLocaleString('ru-RU').replace(/[  ]/g, ' ');
const c = currency ?? 'AMD';
return c === 'AMD' || c === 'RUB' ? `${n} ${SYMBOL[c]}` : `${SYMBOL[c] ?? c}${n}`;
};
/** Адрес кабинета компании: свой домен, если задан, иначе адрес сервиса. */
const cabinetUrl = (config: Config, company: Company) =>
company.settings.clientHost ? `https://${company.settings.clientHost}` : config.appUrl;
/** Сообщение в топик заявок группы. */
export function requestText(input: { client: { id: number; name: string; phone: string | null; telegram: string | null }; lead: Lead; day: string; time: ViewingTime; agentName: string | null; url: string; repeat: boolean }) {
const { client, lead, day, time, agentName, url, repeat } = input;
const object = [lead.title, priceLine(lead.price, lead.currency), lead.district].filter(Boolean).join(' · ');
return [
`🆕 <b>Клиент из BarevClub${repeat ? ' (повторная заявка)' : ''}</b> — просьба позвонить`,
'',
`👤 ${esc(client.name)}`,
client.phone ? `📞 ${esc(client.phone)}` : null,
client.telegram ? `✈️ ${esc(client.telegram)}` : null,
`🏠 ${esc(object)}`,
`📅 Просмотр: <b>${esc(humanDay(day))}, ${VIEWING_TIMES[time]}</b>`,
`🧑‍💼 Объект ведёт: <b>${esc(agentName ?? '—')}</b>`,
`🔗 ${esc(url)}`,
]
.filter((line) => line !== null)
.join('\n');
}
/**
* Заявка с сайта: клиент (новый или уже приходивший с тем же телефоном или Telegram), объект в его
* подборке, запись на показ и уведомление в топик заявок группы. Не дошло до группы — агенту объекта
* в бот уходит обычное уведомление о записи на показ.
*/
export async function createViewingRequest(
{ db, config, sender = telegramGroupSender, now = new Date() }: { db: Db; config: Config; sender?: GroupSender; now?: Date },
company: Company,
input: ViewingRequest,
) {
const phone = input.phone?.trim() ? (normalizePhone(input.phone) ?? input.phone.trim()) : null;
if (phone && phone.replace(/\D/g, '').length < 6) throw badRequest('BAD_PHONE');
const telegram = input.telegram?.trim() ? normalizeTelegram(input.telegram) : null;
if (input.telegram?.trim() && !telegram) throw badRequest('BAD_TELEGRAM');
if (!phone && !telegram) throw badRequest('CONTACT_REQUIRED');
const today = ymd(now);
const last = ymd(new Date(now.getTime() + 14 * 86_400_000));
if (input.day < today || input.day > last) throw badRequest('BAD_DAY');
const name = input.name.trim().slice(0, 80);
const result = await db.transaction(async (tx) => {
const [row] = await showcaseLeads(tx, company.id, input.leadId);
if (!row) throw notFound('OBJECT_NOT_FOUND');
const lead = row.lead;
const assigneeId = lead.assigneeId!;
// Тот же человек уже оставлял заявку на сайте — дописываем к его карточке, а не заводим вторую.
const [earlier] = await tx
.select()
.from(clients)
.where(
and(
eq(clients.companyId, company.id),
eq(clients.source, BAREV_SOURCE),
or(phone ? eq(clients.phone, phone) : undefined, telegram ? eq(clients.telegram, telegram) : undefined),
),
)
.orderBy(desc(clients.id))
.limit(1);
let client = earlier;
if (client) {
await tx
.update(clients)
.set({ name, phone: phone ?? client.phone, telegram: telegram ?? client.telegram })
.where(eq(clients.id, client.id));
client = { ...client, name, phone: phone ?? client.phone, telegram: telegram ?? client.telegram };
} else {
const fresh = await statusByCode(tx, company.id, 'new', 'client');
[client] = await tx
.insert(clients)
.values({
companyId: company.id,
name,
phone,
telegram,
kind: lead.dealType === 'sale' ? 'buyer' : 'tenant',
request: {},
source: BAREV_SOURCE,
assigneeId,
statusId: fresh.id,
nextActionAt: now,
nextActionNote: 'Позвонить: заявка с сайта BarevClub',
})
.returning();
await tx.insert(events).values({ companyId: company.id, clientId: client!.id, userId: null, type: 'created' });
}
await tx.insert(clientLeads).values({ clientId: client!.id, leadId: lead.id, state: 'offered' }).onConflictDoNothing();
const note = `${VIEWING_TIMES[input.time]} · заявка с сайта BarevClub`;
await tx
.insert(viewings)
.values({ companyId: company.id, clientId: client!.id, leadId: lead.id, day: input.day, note })
.onConflictDoUpdate({ target: [viewings.clientId, viewings.leadId], set: { day: input.day, note } });
await tx.insert(events).values([
{ companyId: company.id, clientId: client!.id, userId: null, type: 'viewing', newValue: lead.title, text: input.day },
{ companyId: company.id, leadId: lead.id, userId: null, type: 'client_viewing', newValue: client!.name, text: input.day },
]);
await tx.update(companies).set({ lastActivityAt: now }).where(eq(companies.id, company.id));
const [agent] = await tx.select({ name: users.name }).from(users).where(eq(users.id, assigneeId));
return { client: client!, lead, assigneeId, agentName: agent?.name ?? null, repeat: Boolean(earlier) };
});
const text = requestText({
client: result.client,
lead: result.lead,
day: input.day,
time: input.time,
agentName: result.agentName,
url: `${cabinetUrl(config, company)}/clients/${result.client.id}`,
repeat: result.repeat,
});
const bot = company.settings.groupBot;
let notified = false;
if (bot?.chatId) {
try {
await sender.text({ token: bot.token, chatId: bot.chatId, threadId: company.settings.barevClub?.threadId }, text);
notified = true;
} catch (error) {
console.error(`[barev] заявка клиента ${result.client.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (!notified) {
await db.insert(outbox).values({
kind: 'text',
userId: result.assigneeId,
leadId: result.lead.id,
payload: { key: 'viewingBooked', args: [result.client.name, result.lead.title, input.day, `${VIEWING_TIMES[input.time]} · BarevClub`] },
});
}
return { ok: true, clientId: result.client.id };
}
+398
View File
@@ -0,0 +1,398 @@
import { and, eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashPassword } from './auth/password.ts';
import { createSession } from './auth/sessions.ts';
import type { PaymentProvider } from './billing/provider.ts';
import { addDays, addMonths, endOfDay, runBillingJobs, ymd } from './billing/service.ts';
import { companies, deals, invoices, payments, users } from './db/schema.ts';
import { processOutbox } from './telegram/outbox.ts';
import { fakeTelegram, testApp } from './testing.ts';
// Биллинг, сообщения суперадмина, мастер настройки.
let t: Awaited<ReturnType<typeof testApp>>;
let tgId = 5000;
/** Платёжный провайдер-заглушка: подпись — заголовок x-secret. */
const provider: PaymentProvider = {
name: 'fakepay',
createPayment: async (r) => ({ externalId: `pay-${r.invoiceId}`, url: `https://pay.test/${r.invoiceId}` }),
parseWebhook: async (headers, body) => (headers['x-secret'] === 'ok' ? (body as { externalId: string; status: 'succeeded' | 'failed' }) : null),
};
beforeAll(async () => { t = await testApp('billing', null, provider); });
afterAll(() => t.close());
const tg = fakeTelegram();
const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); };
const superadmin = async () => {
const [u] = await t.db
.insert(users)
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
.returning();
return t.client(await createSession(t.db, u!.id));
};
const join = async (name: string, token: string) => {
const api = await t.register(name);
await api.post(`/api/invites/${token}/accept`);
const id = (await api.get('/api/me')).body.user.id as number;
const chat = ++tgId;
await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id));
return { api, id, chat };
};
/** Компания, включённая суперадмином; подписка заканчивается через `days` дней. */
const company = async (plan: 'solo' | 'team' | 'agency' = 'team', billingModel: 'fixed' | 'percent' = 'fixed', days = 30) => {
const sa = await superadmin();
const created = await sa.post('/api/admin/companies', {
name: `Компания ${plan}`, plan, billingModel, subscriptionEndsAt: endOfDay(addDays(ymd(new Date()), days)).toISOString(),
});
expect(created.status).toBe(200);
const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!);
return { sa, boss, id: created.body.id as number };
};
const row = async (id: number) => (await t.db.select().from(companies).where(eq(companies.id, id)))[0]!;
const invoicesOf = async (id: number) => t.db.select().from(invoices).where(eq(invoices.companyId, id)).orderBy(invoices.id);
/** Полдень по Еревану: напоминания уходят только днём. */
const noon = (day: string) => new Date(`${day}T12:00:00+04:00`);
const today = () => ymd(new Date());
describe('даты', () => {
it('месяц прибавляется с учётом короткого февраля', () => {
expect(addMonths('2027-01-31', 1)).toBe('2027-02-28');
expect(addMonths('2026-12-15', 1)).toBe('2027-01-15');
expect(addDays('2026-12-31', 1)).toBe('2027-01-01');
});
});
describe('фикс', () => {
it('самостоятельное подключение: счета за установку и первый месяц, доступ открывается после оплаты', async () => {
const u = await t.register('Сам');
await u.post('/api/company', { name: 'Сама', plan: 'team', billingModel: 'fixed' });
const me = (await u.get('/api/me')).body;
const sa = await superadmin();
await t.db.update(users).set({ telegramId: ++tgId }).where(eq(users.id, me.user.id));
const bill = (await u.get('/api/billing')).body;
expect(bill.access).toBe('disabled');
expect(bill.invoices.map((i: any) => [i.kind, i.amount, i.status])).toEqual([['fixed', 350, 'pending'], ['setup', 500, 'pending']]);
const fixed = bill.invoices.find((i: any) => i.kind === 'fixed');
const paid = await sa.post(`/api/admin/invoices/${fixed.id}/pay`);
expect(paid.body).toMatchObject({ status: 'paid', paidMethod: 'manual', periodStart: today(), periodEnd: addMonths(today(), 1) });
// Повторная отметка ничего не меняет.
await sa.post(`/api/admin/invoices/${fixed.id}/pay`);
expect(await t.db.select().from(payments).where(eq(payments.invoiceId, fixed.id))).toHaveLength(1);
const after = (await u.get('/api/me')).body.company;
expect(after.access).toBe('active');
expect(ymd(new Date(after.subscriptionEndsAt))).toBe(addMonths(today(), 1));
await flush();
expect(tg.inbox(tgId).at(-1)!.text).toContain('Оплата получена');
});
it('счёт на следующий месяц и напоминания за 7, 3 и 1 день; оплата продлевает на месяц', async () => {
const c = await company('team', 'fixed', 30);
const end = addDays(today(), 30);
// Компания подключена вручную: начислена только установка.
expect((await invoicesOf(c.id)).map((i) => i.kind)).toEqual(['setup']);
await runBillingJobs(t.db, noon(addDays(end, -8)));
expect(await invoicesOf(c.id)).toHaveLength(1);
await runBillingJobs(t.db, noon(addDays(end, -7)));
await runBillingJobs(t.db, noon(addDays(end, -7)));
const list = await invoicesOf(c.id);
expect(list).toHaveLength(2);
expect(list[1]).toMatchObject({ kind: 'fixed', amount: 350, status: 'pending', periodStart: addDays(end, 1), periodEnd: addMonths(end, 1) });
await flush();
const got = () => tg.inbox(c.boss.chat).map((m) => m.text);
expect(got().filter((x) => x.includes('осталось 7 дн'))).toHaveLength(1);
expect(got().filter((x) => x.includes('Выставлен счёт на $350'))).toHaveLength(1);
// Ночью напоминание не уходит.
await runBillingJobs(t.db, new Date(`${addDays(end, -3)}T03:00:00+04:00`));
await flush();
expect(got().some((x) => x.includes('осталось 3 дн'))).toBe(false);
await runBillingJobs(t.db, noon(addDays(end, -3)));
await runBillingJobs(t.db, noon(addDays(end, -1)));
await flush();
expect(got().filter((x) => x.includes('осталось 3 дн') || x.includes('осталось 1 дн'))).toHaveLength(2);
await c.sa.post(`/api/admin/invoices/${list[1]!.id}/pay`);
expect(ymd((await row(c.id)).subscriptionEndsAt!)).toBe(addMonths(end, 1));
// Следующий счёт появится только за 7 дней до нового окончания.
await runBillingJobs(t.db, noon(end));
expect(await invoicesOf(c.id)).toHaveLength(2);
});
it('16-й день просрочки: доступ ограничен, после отметки оплаты возвращается', async () => {
const c = await company('team', 'fixed', -16);
const end = addDays(today(), -16);
expect((await c.boss.api.get('/api/me')).body.company.access).toBe('restricted');
expect((await c.boss.api.post('/api/invites')).body.code).toBe('SUBSCRIPTION_REQUIRED');
await runBillingJobs(t.db, noon(today()));
await runBillingJobs(t.db, noon(today()));
await flush();
expect(tg.inbox(c.boss.chat).filter((m) => m.text.includes('Доступ ограничен'))).toHaveLength(1);
// Раздел «Подписка» открыт и при ограниченном доступе.
const bill = (await c.boss.api.get('/api/billing')).body;
const fixed = bill.invoices.find((i: any) => i.kind === 'fixed' && i.status === 'pending');
expect(fixed).toMatchObject({ amount: 350, periodStart: addDays(end, 1) });
await c.sa.post(`/api/admin/invoices/${fixed.id}/pay`);
expect((await c.boss.api.get('/api/me')).body.company.access).toBe('active');
expect((await c.boss.api.post('/api/invites')).status).toBe(200);
});
it('просрочка: ежедневное напоминание с числом оставшихся дней', async () => {
const c = await company('solo', 'fixed', -2);
await runBillingJobs(t.db, noon(today()));
await runBillingJobs(t.db, noon(today()));
await runBillingJobs(t.db, noon(addDays(today(), 1)));
await flush();
const texts = tg.inbox(c.boss.chat).map((m) => m.text).filter((x) => x.includes('просрочена'));
expect(texts).toHaveLength(2);
expect(texts[0]).toContain('ещё 13 дн');
expect(texts[1]).toContain('ещё 12 дн');
});
});
describe('модель «%»', () => {
const lastMonthEnd = () => addDays(`${today().slice(0, 7)}-01`, -1);
it('счёт 1-го числа совпадает с ручным расчётом; оплата продлевает до 8-го числа следующего месяца', async () => {
const c = await company('agency', 'percent', 5);
const to = lastMonthEnd();
const from = `${to.slice(0, 7)}-01`;
await t.db.update(companies).set({ percentFrom: addDays(from, 2) }).where(eq(companies.id, c.id));
const deal = (commission: number, currency: string, dealDate: string) =>
t.db.insert(deals).values({ companyId: c.id, agentId: c.boss.id, ownerAgentId: c.boss.id, clientAgentId: c.boss.id, commission, ownerCommission: commission, currency, dealDate });
await deal(3000, 'USD', addDays(from, 5));
await deal(385_000, 'AMD', to);
// Не попадают: сделка до перехода на «%» и сделка текущего месяца.
await deal(9000, 'USD', from);
await deal(7000, 'USD', today());
const first = noon(`${today().slice(0, 7)}-01`);
await runBillingJobs(t.db, first);
await runBillingJobs(t.db, first);
const list = (await invoicesOf(c.id)).filter((i) => i.kind === 'percent');
expect(list).toHaveLength(1);
// Вручную: (3000 + 385000 / 385) × 7 % = 4000 × 0,07 = 280.
expect(list[0]).toMatchObject({ amount: 280, status: 'pending', periodStart: from, periodEnd: to });
expect(list[0]!.details).toMatchObject({ percent: 7, total: 4000, deals: [{ commission: 3000, usd: 3000 }, { commission: 385_000, currency: 'AMD', usd: 1000 }] });
// Клиент видит расшифровку и то, что набежало за текущий месяц.
const bill = (await c.boss.api.get('/api/billing')).body;
expect(bill.invoices.find((i: any) => i.kind === 'percent').details.deals).toHaveLength(2);
expect(bill.preview).toMatchObject({ total: 7000, amount: 490 });
await c.sa.post(`/api/admin/invoices/${list[0]!.id}/pay`);
expect(ymd((await row(c.id)).subscriptionEndsAt!)).toBe(`${addMonths(`${today().slice(0, 7)}-01`, 1).slice(0, 7)}-08`);
});
it('месяц без сделок: нулевой счёт закрывается сам и продлевает подписку', async () => {
const c = await company('team', 'percent', 5);
await t.db.update(companies).set({ percentFrom: '2020-01-01' }).where(eq(companies.id, c.id));
await runBillingJobs(t.db, noon(`${today().slice(0, 7)}-01`));
const [inv] = (await invoicesOf(c.id)).filter((i) => i.kind === 'percent');
expect(inv).toMatchObject({ amount: 0, status: 'paid', paidMethod: 'auto' });
expect(ymd((await row(c.id)).subscriptionEndsAt!).slice(8)).toBe('08');
});
it('суперадмин меняет курсы валют для счёта', async () => {
const sa = await superadmin();
expect((await sa.get('/api/admin/billing/settings')).body.rates.AMD).toBe(385);
expect((await sa.patch('/api/admin/billing/settings', { rates: { AMD: 400, RUB: 90, EUR: 0.9 } })).status).toBe(200);
expect((await sa.get('/api/admin/billing/settings')).body.rates).toEqual({ AMD: 400, RUB: 90, EUR: 0.9 });
await sa.patch('/api/admin/billing/settings', { rates: { AMD: 385, RUB: 95, EUR: 0.92 } });
});
});
describe('смена тарифа', () => {
it('из кабинета применяется только со следующего месяца', async () => {
const c = await company('team', 'fixed', 20);
const end = addDays(today(), 20);
const agentInvite = (await c.boss.api.post('/api/invites')).body.token as string;
const agent = await join('Агент', agentInvite);
expect((await agent.api.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' })).status).toBe(403);
expect((await c.boss.api.post('/api/billing/plan', { plan: 'solo', billingModel: 'percent' })).body.code).toBe('BILLING_MODEL_NOT_ALLOWED');
// В компании двое — Solo не вмещает.
expect((await c.boss.api.post('/api/billing/plan', { plan: 'solo', billingModel: 'fixed' })).body.code).toBe('PLAN_TOO_SMALL');
const res = await c.boss.api.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' });
expect(res.body.from).toBe(addDays(end, 1));
const me = (await c.boss.api.get('/api/me')).body.company;
expect(me).toMatchObject({ plan: 'team', maxMembers: 10, planned: { plan: 'agency', billingModel: 'fixed', from: addDays(end, 1) } });
// Счёт на следующий месяц уже по новому тарифу; сам тариф пока прежний.
await runBillingJobs(t.db, noon(addDays(end, -7)));
const next = (await invoicesOf(c.id)).find((i) => i.kind === 'fixed')!;
expect(next).toMatchObject({ amount: 550, periodStart: addDays(end, 1) });
expect((await row(c.id)).plan).toBe('team');
// Передумали: счёт пересоздаётся по старому тарифу.
await c.boss.api.del('/api/billing/plan');
expect((await c.boss.api.get('/api/me')).body.company.planned).toBeNull();
await c.boss.api.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' });
await runBillingJobs(t.db, noon(addDays(end, -7)));
const pending = (await invoicesOf(c.id)).filter((i) => i.kind === 'fixed' && i.status === 'pending');
expect(pending.map((i) => i.amount)).toEqual([550]);
await runBillingJobs(t.db, noon(addDays(end, 1)));
const after = await row(c.id);
expect(after).toMatchObject({ plan: 'agency', billingModel: 'fixed', plannedPlan: null, plannedFrom: null });
// Установка начислена один раз — при подключении Team.
expect((await invoicesOf(c.id)).filter((i) => i.kind === 'setup')).toHaveLength(1);
});
it('переход с фикса на «%»: сделки до перехода в счёт не попадают', async () => {
const c = await company('team', 'fixed', 3);
const from = addDays(today(), 4);
expect((await c.boss.api.post('/api/billing/plan', { plan: 'team', billingModel: 'percent' })).body.from).toBe(from);
await runBillingJobs(t.db, noon(from));
const after = await row(c.id);
expect(after).toMatchObject({ billingModel: 'percent', percentFrom: from });
expect(ymd(after.subscriptionEndsAt!)).toBe(`${addMonths(from, 1).slice(0, 7)}-08`);
// Неоплаченный счёт на фикс за следующий месяц отменён.
expect((await invoicesOf(c.id)).filter((i) => i.kind === 'fixed' && i.status === 'pending')).toHaveLength(0);
});
it('подписка ещё не начиналась: тариф меняется сразу, счета пересчитываются', async () => {
const u = await t.register('Новый');
await u.post('/api/company', { name: 'Новая', plan: 'solo', billingModel: 'fixed' });
const id = (await u.get('/api/me')).body.company.id as number;
expect((await invoicesOf(id)).map((i) => [i.kind, i.amount])).toEqual([['fixed', 40]]);
expect((await u.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' })).body.from).toBeNull();
expect((await u.get('/api/me')).body.company.plan).toBe('agency');
const open = (await invoicesOf(id)).filter((i) => i.status === 'pending').map((i) => [i.kind, i.amount]);
expect(open).toEqual([['setup', 750], ['fixed', 550]]);
});
it('уход с «%» посреди месяца: сделки с начала месяца закрываются отдельным счётом', async () => {
const c = await company('team', 'percent', -1);
const first = `${today().slice(0, 7)}-01`;
if (today() === first) return;
await t.db.update(companies).set({ percentFrom: '2020-01-01' }).where(eq(companies.id, c.id));
await t.db.insert(deals).values({ companyId: c.id, agentId: c.boss.id, ownerAgentId: c.boss.id, clientAgentId: c.boss.id, commission: 2000, ownerCommission: 2000, currency: 'USD', dealDate: first });
// Подписка закончилась — тариф меняется сразу.
expect((await c.boss.api.post('/api/billing/plan', { plan: 'team', billingModel: 'fixed' })).body.from).toBeNull();
const list = await invoicesOf(c.id);
expect(list.find((i) => i.kind === 'percent')).toMatchObject({ amount: 200, periodStart: first, periodEnd: addDays(today(), -1), status: 'pending' });
expect(list.find((i) => i.kind === 'fixed')).toMatchObject({ amount: 350, status: 'pending' });
});
it('суперадмин меняет тариф сразу', async () => {
const c = await company('team', 'fixed', 20);
expect((await c.sa.patch(`/api/admin/companies/${c.id}`, { plan: 'agency', billingModel: 'percent' })).status).toBe(200);
expect(await row(c.id)).toMatchObject({ plan: 'agency', billingModel: 'percent', percentFrom: today() });
});
});
describe('онлайн-оплата через провайдера', () => {
it('ссылка на оплату, webhook закрывает счёт; неверная подпись отклоняется', async () => {
const u = await t.register('Онлайн');
await u.post('/api/company', { name: 'Онлайн', plan: 'solo', billingModel: 'fixed' });
const id = (await u.get('/api/me')).body.company.id as number;
const [inv] = await invoicesOf(id);
expect((await t.client().get('/api/config')).body.onlinePayments).toBe(true);
expect((await u.post(`/api/invoices/${inv!.id}/pay`)).body.url).toBe(`https://pay.test/${inv!.id}`);
const hook = (secret: string, status = 'succeeded') =>
t.app.inject({ method: 'POST', url: '/api/payments/webhook/fakepay', headers: { 'x-secret': secret }, payload: { externalId: `pay-${inv!.id}`, status } });
expect((await hook('wrong')).statusCode).toBe(400);
expect((await t.app.inject({ method: 'POST', url: '/api/payments/webhook/other', payload: {} })).statusCode).toBe(404);
expect((await hook('ok')).statusCode).toBe(200);
expect((await hook('ok')).statusCode).toBe(200);
expect((await invoicesOf(id))[0]).toMatchObject({ status: 'paid', paidMethod: 'online' });
expect(await t.db.select().from(payments).where(eq(payments.invoiceId, inv!.id))).toMatchObject([{ provider: 'fakepay', status: 'succeeded' }]);
expect((await u.get('/api/me')).body.company.access).toBe('active');
// Чужой счёт оплатить нельзя.
const other = await company('solo');
expect((await other.boss.api.post(`/api/invoices/${inv!.id}/pay`)).status).toBe(404);
});
});
describe('суперадмин', () => {
it('счета: список, отмена; сводка считает выручку месяца', async () => {
const c = await company('agency', 'fixed', 30);
const before = (await c.sa.get('/api/admin/summary')).body;
const [setup] = await invoicesOf(c.id);
const list = (await c.sa.get(`/api/admin/invoices?status=pending&companyId=${c.id}`)).body;
expect(list).toMatchObject([{ id: setup!.id, kind: 'setup', amount: 750, company: 'Компания agency' }]);
expect((await c.boss.api.get('/api/admin/invoices')).status).toBe(403);
expect((await c.boss.api.post(`/api/admin/invoices/${setup!.id}/pay`)).status).toBe(403);
await c.sa.post(`/api/admin/invoices/${setup!.id}/pay`);
const after = (await c.sa.get('/api/admin/summary')).body;
expect(after.revenueMonth - before.revenueMonth).toBe(750);
expect(after.pendingAmount - before.pendingAmount).toBe(-750);
expect(after.active).toBeGreaterThan(0);
// Оплата установки дату подписки не двигает.
expect(ymd((await row(c.id)).subscriptionEndsAt!)).toBe(addDays(today(), 30));
expect((await c.sa.post(`/api/admin/invoices/${setup!.id}/cancel`)).body.code).toBe('INVOICE_PAID');
const d = await company('team');
const [s2] = await invoicesOf(d.id);
expect((await d.sa.post(`/api/admin/invoices/${s2!.id}/cancel`)).status).toBe(200);
expect((await d.sa.post(`/api/admin/invoices/${s2!.id}/pay`)).body.code).toBe('INVOICE_CANCELED');
});
it('сообщения в Telegram: всем, компании, руководителям, одному пользователю; журнал', async () => {
const a = await company('team');
const b = await company('team');
const agent = await join('Агент', (await a.boss.api.post('/api/invites')).body.token);
// Пользователь без Telegram сообщение не получит — это видно в журнале.
const silent = await join('Без телеграма', (await a.boss.api.post('/api/invites')).body.token);
await t.db.update(users).set({ telegramId: null }).where(eq(users.id, silent.id));
await flush();
const count = (text: string, chat: number) => tg.inbox(chat).filter((m) => m.text === text).length;
expect((await a.boss.api.post('/api/admin/messages', { text: 'x', audience: 'all' })).status).toBe(403);
expect((await a.sa.post('/api/admin/messages', { text: 'x', audience: 'company' })).body.code).toBe('VALIDATION');
expect((await a.sa.post('/api/admin/messages', { text: 'x', audience: 'user', userId: 999_999 })).body.code).toBe('NO_RECIPIENTS');
const toCompany = await a.sa.post('/api/admin/messages', { text: 'Компании <b>A</b>', audience: 'company', companyId: a.id });
expect(toCompany.body).toMatchObject({ recipients: 2, unreachable: 1 });
await a.sa.post('/api/admin/messages', { text: 'Руководителям', audience: 'managers' });
await a.sa.post('/api/admin/messages', { text: 'Лично', audience: 'user', userId: agent.id });
await a.sa.post('/api/admin/messages', { text: 'Всем', audience: 'all' });
await flush();
// Разметка в тексте суперадмина экранируется.
expect(count('Компании &lt;b&gt;A&lt;/b&gt;', a.boss.chat)).toBe(1);
expect(count('Компании &lt;b&gt;A&lt;/b&gt;', agent.chat)).toBe(1);
expect(count('Компании &lt;b&gt;A&lt;/b&gt;', b.boss.chat)).toBe(0);
expect([count('Руководителям', a.boss.chat), count('Руководителям', b.boss.chat), count('Руководителям', agent.chat)]).toEqual([1, 1, 0]);
expect([count('Лично', agent.chat), count('Лично', a.boss.chat)]).toEqual([1, 0]);
expect([count('Всем', a.boss.chat), count('Всем', b.boss.chat), count('Всем', agent.chat)]).toEqual([1, 1, 1]);
const journal = (await a.sa.get('/api/admin/messages')).body;
expect(journal.slice(0, 4).map((m: any) => m.audience)).toEqual(['all', 'user', 'managers', 'company']);
expect(journal[1]).toMatchObject({ text: 'Лично', user: 'Агент', recipients: 1 });
expect(journal[3]).toMatchObject({ company: 'Компания team', recipients: 2, unreachable: 1 });
const found = (await a.sa.get('/api/admin/users?q=Без')).body;
expect(found).toMatchObject([{ id: silent.id, telegramLinked: false, company: 'Компания team' }]);
});
});
describe('мастер настройки', () => {
it('показывается один раз: после отметки больше не нужен', async () => {
const c = await company('team');
expect((await c.boss.api.get('/api/me')).body.user.onboarded).toBe(false);
expect((await c.boss.api.patch('/api/me', { onboarded: true })).status).toBe(200);
expect((await c.boss.api.get('/api/me')).body.user.onboarded).toBe(true);
// Правка профиля отметку не сбрасывает.
await c.boss.api.patch('/api/me', { name: 'Новое имя' });
const [u] = await t.db.select().from(users).where(and(eq(users.id, c.boss.id)));
expect(u!.onboardedAt).not.toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
// Платёжный модуль (ТЗ, раздел 7): провайдер эквайринга ещё не выбран, поэтому биллинг знает
// только этот контракт. Чтобы подключить провайдера, нужно реализовать интерфейс и передать
// его в buildApp({ payments }) — счета, продление подписки и кабинет менять не придётся.
export interface PaymentRequest {
invoiceId: number;
amount: number;
currency: string;
description: string;
/** Куда вернуть пользователя после оплаты. */
returnUrl: string;
}
export interface PaymentEvent {
/** Идентификатор платежа у провайдера — тот, что вернул createPayment. */
externalId: string;
status: 'succeeded' | 'failed';
}
export interface PaymentProvider {
/** Короткое имя: попадает в адрес webhook (/api/payments/webhook/<name>) и в историю платежей. */
name: string;
/** Создаёт платёж у провайдера и возвращает ссылку на страницу оплаты. */
createPayment(request: PaymentRequest): Promise<{ externalId: string; url: string }>;
/** Проверяет подпись уведомления и разбирает его. null — уведомление не о платеже или подпись неверна. */
parseWebhook(headers: Record<string, string | string[] | undefined>, body: unknown): Promise<PaymentEvent | null>;
}
+297
View File
@@ -0,0 +1,297 @@
import { accessState, BILLING_MODELS, PLANS, type AccessState } from '@lr/shared';
import { and, desc, eq, ilike, isNotNull, isNull, or, sql, type SQL } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { Db } from '../db/client.ts';
import { adminMessages, companies, invoices, payments, users } from '../db/schema.ts';
import type { Config } from '../lib/config.ts';
import { badRequest, notFound } from '../lib/errors.ts';
import { enqueueRaw } from '../telegram/outbox.ts';
import type { PaymentProvider } from './provider.ts';
import {
addDays,
BillingSettings,
cancelInvoice,
loadBillingSettings,
nextPeriodStart,
percentCalc,
saveBillingSettings,
schedulePlan,
settleInvoice,
ymd,
type Invoice,
} from './service.ts';
type Company = typeof companies.$inferSelect;
type AccountCtx = { user: typeof users.$inferSelect; company: Company; access: AccessState };
export interface BillingGuards {
/** Руководитель компании. Работает и при ограниченном доступе, и вне Mini App: иначе подписку не оплатить. */
owner: (req: FastifyRequest) => AccountCtx;
superadmin: (req: FastifyRequest) => unknown;
}
const Id = z.object({ id: z.coerce.number().int().positive() });
const invoiceView = (i: Invoice) => ({
id: i.id,
kind: i.kind,
periodStart: i.periodStart,
periodEnd: i.periodEnd,
amount: i.amount,
currency: i.currency,
status: i.status,
paidAt: i.paidAt,
paidMethod: i.paidMethod,
details: i.details,
createdAt: i.createdAt,
});
export function registerBillingRoutes(
app: FastifyInstance,
db: Db,
g: BillingGuards,
config: Config,
provider: PaymentProvider | null,
) {
// --- кабинет: подписка, счета, смена тарифа ---
app.get('/api/billing', async (req) => {
const { company } = g.owner(req);
const rows = await db.select().from(invoices).where(eq(invoices.companyId, company.id)).orderBy(desc(invoices.id));
const today = ymd(new Date());
// На модели «%» видно, сколько набежало за текущий месяц.
const preview =
company.billingModel === 'percent'
? await percentCalc(db, company, `${today.slice(0, 7)}-01`, today)
: null;
return {
plan: company.plan,
billingModel: company.billingModel,
access: accessState(company),
subscriptionEndsAt: company.subscriptionEndsAt,
graceDays: company.graceDays,
planned:
company.plannedPlan && company.plannedFrom
? { plan: company.plannedPlan, billingModel: company.plannedBillingModel, from: company.plannedFrom }
: null,
/** С какой даты вступит в силу смена тарифа; null — сразу. */
changeFrom: nextPeriodStart(company),
onlinePayments: Boolean(provider),
invoices: rows.map(invoiceView),
preview: preview && { from: `${today.slice(0, 7)}-01`, to: today, amount: preview.amount, ...preview.details },
};
});
app.post('/api/billing/plan', async (req) => {
const { company } = g.owner(req);
const body = z.object({ plan: z.enum(PLANS), billingModel: z.enum(BILLING_MODELS) }).parse(req.body);
return schedulePlan(db, company.id, body.plan, body.billingModel);
});
/** Отменить запланированную смену тарифа. */
app.delete('/api/billing/plan', async (req) => {
const { company } = g.owner(req);
return schedulePlan(db, company.id, company.plan as (typeof PLANS)[number], company.billingModel as (typeof BILLING_MODELS)[number]);
});
/** Онлайн-оплата счёта: возвращает ссылку на страницу провайдера. */
app.post('/api/invoices/:id/pay', async (req) => {
const { company } = g.owner(req);
const { id } = Id.parse(req.params);
if (!provider) throw badRequest('PAYMENTS_NOT_CONFIGURED');
const [invoice] = await db.select().from(invoices).where(and(eq(invoices.id, id), eq(invoices.companyId, company.id)));
if (!invoice) throw notFound();
if (invoice.status !== 'pending') throw badRequest('INVOICE_NOT_PENDING');
const created = await provider.createPayment({
invoiceId: invoice.id,
amount: invoice.amount,
currency: invoice.currency,
description: `Y&P Lead Radar #${invoice.id}`,
returnUrl: `${config.appUrl}/billing`,
});
await db.insert(payments).values({
invoiceId: invoice.id,
provider: provider.name,
externalId: created.externalId,
amount: invoice.amount,
currency: invoice.currency,
status: 'pending',
});
return { url: created.url };
});
/** Уведомление провайдера о платеже. Подпись проверяет сам провайдер. */
app.post('/api/payments/webhook/:provider', async (req) => {
const { provider: name } = z.object({ provider: z.string() }).parse(req.params);
if (!provider || provider.name !== name) throw notFound();
const event = await provider.parseWebhook(req.headers, req.body);
if (!event) throw badRequest('BAD_WEBHOOK');
await db.transaction(async (tx) => {
const [payment] = await tx
.select()
.from(payments)
.where(and(eq(payments.provider, name), eq(payments.externalId, event.externalId)))
.for('update');
if (!payment || payment.status !== 'pending') return;
if (event.status === 'failed') {
await tx.update(payments).set({ status: 'failed' }).where(eq(payments.id, payment.id));
return;
}
// Строка платежа уже есть — отмечаем её, а не создаём вторую.
await tx.update(payments).set({ status: 'succeeded' }).where(eq(payments.id, payment.id));
await settleInvoice(tx, payment.invoiceId, 'online');
});
return { ok: true };
});
// --- суперадмин: счета, сводка, курсы ---
app.get('/api/admin/invoices', async (req) => {
g.superadmin(req);
const q = z
.object({ status: z.enum(['pending', 'paid', 'canceled']).optional(), companyId: z.coerce.number().int().optional() })
.parse(req.query);
const where: SQL[] = [];
if (q.status) where.push(eq(invoices.status, q.status));
if (q.companyId) where.push(eq(invoices.companyId, q.companyId));
const rows = await db
.select({ invoice: invoices, company: companies.name })
.from(invoices)
.innerJoin(companies, eq(companies.id, invoices.companyId))
.where(and(...where))
.orderBy(desc(invoices.id))
.limit(200);
return rows.map((r) => ({ ...invoiceView(r.invoice), companyId: r.invoice.companyId, company: r.company }));
});
/** Ручной режим: суперадмин отмечает счёт оплаченным, подписка продлевается. */
app.post('/api/admin/invoices/:id/pay', async (req) => {
g.superadmin(req);
const { id } = Id.parse(req.params);
return invoiceView(await db.transaction((tx) => settleInvoice(tx, id, 'manual')));
});
app.post('/api/admin/invoices/:id/cancel', async (req) => {
g.superadmin(req);
const { id } = Id.parse(req.params);
await db.transaction((tx) => cancelInvoice(tx, id));
return { ok: true };
});
app.get('/api/admin/summary', async (req) => {
g.superadmin(req);
const all = await db.select().from(companies);
const states = all.map((c) => accessState(c));
const month = `${ymd(new Date()).slice(0, 7)}-01`;
const next = addDays(month, 32).slice(0, 7) + '-01';
const [money] = await db
.select({
revenue: sql<number>`coalesce(sum(${invoices.amount}) filter (where ${invoices.status} = 'paid' and ${invoices.paidAt} >= ${`${month}T00:00:00+04:00`}::timestamptz and ${invoices.paidAt} < ${`${next}T00:00:00+04:00`}::timestamptz), 0)::float`,
pending: sql<number>`coalesce(sum(${invoices.amount}) filter (where ${invoices.status} = 'pending'), 0)::float`,
pendingCount: sql<number>`(count(*) filter (where ${invoices.status} = 'pending'))::int`,
})
.from(invoices);
return {
companies: all.length,
active: states.filter((s) => s === 'active').length,
overdue: states.filter((s) => s === 'overdue').length,
restricted: states.filter((s) => s === 'restricted').length,
disabled: states.filter((s) => s === 'disabled').length,
revenueMonth: money?.revenue ?? 0,
pendingAmount: money?.pending ?? 0,
pendingCount: money?.pendingCount ?? 0,
};
});
app.get('/api/admin/billing/settings', async (req) => {
g.superadmin(req);
return loadBillingSettings(db);
});
app.patch('/api/admin/billing/settings', async (req) => {
g.superadmin(req);
const value = BillingSettings.parse(req.body);
await saveBillingSettings(db, value);
return value;
});
// --- суперадмин: сообщения пользователям в Telegram ---
/** Поиск пользователя для сообщения «одному пользователю». */
app.get('/api/admin/users', async (req) => {
g.superadmin(req);
const { q } = z.object({ q: z.string().trim().max(100).default('') }).parse(req.query);
const like = `%${q}%`;
return db
.select({
id: users.id,
name: users.name,
role: users.role,
company: companies.name,
telegramLinked: sql<boolean>`${users.telegramId} is not null`,
})
.from(users)
.innerJoin(companies, eq(companies.id, users.companyId))
.where(
and(
isNull(users.removedAt),
q ? or(ilike(users.name, like), ilike(users.email, like), ilike(users.telegramUsername, like), ilike(companies.name, like)) : undefined,
),
)
.orderBy(users.name)
.limit(20);
});
app.post('/api/admin/messages', async (req) => {
g.superadmin(req);
const body = z
.object({
text: z.string().trim().min(1).max(3500),
audience: z.enum(['all', 'company', 'managers', 'user']),
companyId: z.number().int().optional(),
userId: z.number().int().optional(),
})
.parse(req.body);
if (body.audience === 'company' && !body.companyId) throw badRequest('VALIDATION');
if (body.audience === 'user' && !body.userId) throw badRequest('VALIDATION');
const where: SQL[] = [isNull(users.removedAt), isNotNull(users.companyId)];
if (body.audience === 'company') where.push(eq(users.companyId, body.companyId!));
if (body.audience === 'managers') {
where.push(eq(users.role, 'manager'));
if (body.companyId) where.push(eq(users.companyId, body.companyId));
}
if (body.audience === 'user') where.push(eq(users.id, body.userId!));
return db.transaction(async (tx) => {
const targets = await tx.select({ id: users.id, telegramId: users.telegramId }).from(users).where(and(...where));
if (!targets.length) throw badRequest('NO_RECIPIENTS');
const reachable = targets.filter((u) => u.telegramId);
await enqueueRaw(tx, reachable.map((u) => u.id), body.text);
const [message] = await tx
.insert(adminMessages)
.values({
text: body.text,
audience: body.audience,
companyId: body.audience === 'user' ? null : (body.companyId ?? null),
userId: body.audience === 'user' ? body.userId! : null,
recipients: reachable.length,
unreachable: targets.length - reachable.length,
})
.returning();
return { id: message!.id, recipients: reachable.length, unreachable: targets.length - reachable.length };
});
});
/** Журнал отправленных сообщений. */
app.get('/api/admin/messages', async (req) => {
g.superadmin(req);
const rows = await db
.select({ message: adminMessages, company: companies.name, user: users.name })
.from(adminMessages)
.leftJoin(companies, eq(companies.id, adminMessages.companyId))
.leftJoin(users, eq(users.id, adminMessages.userId))
.orderBy(desc(adminMessages.id))
.limit(100);
return rows.map((r) => ({ ...r.message, company: r.company, user: r.user }));
});
}
+479
View File
@@ -0,0 +1,479 @@
import {
accessState,
allowedBillingModels,
PLAN_SPECS,
type BillingModel,
type Plan,
} from '@lr/shared';
import { and, asc, eq, gte, isNull, lte, ne, sql } from 'drizzle-orm';
import { z } from 'zod';
import type { Db, Tx } from '../db/client.ts';
import { clients, companies, deals, invoices, leads, payments, settings, users } from '../db/schema.ts';
import { badRequest, conflict, notFound } from '../lib/errors.ts';
import { enqueueText } from '../telegram/outbox.ts';
// Биллинг (ТЗ, раздел 7).
//
// Фикс — предоплата: счёт на следующий месяц выставляется за 7 дней до окончания подписки,
// оплата продлевает подписку до конца оплаченного периода.
// «%» — постоплата по календарным месяцам: 1-го числа выставляется счёт за сделки прошлого
// месяца, оплатить его нужно до 8-го. Оплата продлевает подписку до 8-го числа следующего месяца.
// Все даты считаются по времени Еревана.
type Company = typeof companies.$inferSelect;
export type Invoice = typeof invoices.$inferSelect;
const DAY = 86_400_000;
/** За сколько дней до окончания подписки выставляется счёт на фикс и на какой день месяца срок оплаты «%». */
const INVOICE_LEAD_DAYS = 7;
const PERCENT_DUE_DAY = 8;
const REMIND_DAYS = [7, 3, 1];
/** Напоминания не уходят ночью: раньше этого часа по Еревану задача их не отправляет. */
const REMIND_FROM_HOUR = 10;
/** Дата по Еревану в виде «ГГГГ-ММ-ДД». */
export const ymd = (d: Date) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10);
/** Подписка действует до конца дня по Еревану. */
export const endOfDay = (day: string) => new Date(`${day}T23:59:59+04:00`);
const utc = (day: string) => new Date(`${day}T00:00:00Z`);
export const addDays = (day: string, n: number) => new Date(utc(day).getTime() + n * DAY).toISOString().slice(0, 10);
export const daysBetween = (from: string, to: string) => Math.round((utc(to).getTime() - utc(from).getTime()) / DAY);
/** Тот же день следующего месяца; 31 января → 28 (29) февраля. */
export function addMonths(day: string, n: number): string {
const d = utc(day);
const target = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
const last = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
target.setUTCDate(Math.min(d.getUTCDate(), last));
return target.toISOString().slice(0, 10);
}
const monthStart = (day: string) => `${day.slice(0, 7)}-01`;
const percentDue = (month: string) => `${month.slice(0, 7)}-${String(PERCENT_DUE_DAY).padStart(2, '0')}`;
const round2 = (n: number) => Math.round(n * 100) / 100;
// --- курсы валют для счёта по комиссии ---
/** Сколько единиц валюты в одном долларе. Комиссия по сделке вводится в любой валюте, счёт — в USD. */
export const BillingSettings = z.object({
rates: z
.object({
AMD: z.number().positive().default(385),
RUB: z.number().positive().default(95),
EUR: z.number().positive().default(0.92),
})
.default({ AMD: 385, RUB: 95, EUR: 0.92 }),
});
export type BillingSettings = z.infer<typeof BillingSettings>;
export async function loadBillingSettings(db: Tx): Promise<BillingSettings> {
const [row] = await db.select().from(settings).where(eq(settings.key, 'billing'));
return BillingSettings.parse(row?.value ?? {});
}
export async function saveBillingSettings(db: Tx, value: BillingSettings) {
await db
.insert(settings)
.values({ key: 'billing', value })
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date() } });
}
const toUsd = (amount: number, currency: string, rates: BillingSettings['rates']) =>
currency === 'USD' ? amount : amount / (rates[currency as keyof typeof rates] ?? 1);
// --- расчёт по модели «%» ---
export interface PercentDeal {
dealId: number;
date: string;
title: string;
agent: string;
commission: number;
currency: string;
/** Комиссия в долларах по курсу на момент счёта. */
usd: number;
}
export interface PercentDetails {
plan: Plan;
percent: number;
rates: BillingSettings['rates'];
/** Сумма комиссий в долларах. */
total: number;
deals: PercentDeal[];
}
/** Сделки компании за период и сумма к оплате: комиссии × процент тарифа. */
export async function percentCalc(tx: Tx, company: Company, from: string, to: string) {
const { rates } = await loadBillingSettings(tx);
const since = company.percentFrom && company.percentFrom > from ? company.percentFrom : from;
const rows = await tx
.select({
deal: deals,
leadTitle: leads.title,
clientName: clients.name,
agent: users.name,
})
.from(deals)
.leftJoin(leads, eq(leads.id, deals.leadId))
.leftJoin(clients, eq(clients.id, deals.clientId))
.innerJoin(users, eq(users.id, deals.agentId))
.where(and(eq(deals.companyId, company.id), gte(deals.dealDate, since), lte(deals.dealDate, to)))
.orderBy(asc(deals.dealDate), asc(deals.id));
const plan = company.plan as Plan;
const percent = PLAN_SPECS[plan].commissionPercent ?? 0;
const list: PercentDeal[] = rows.map((r) => ({
dealId: r.deal.id,
date: r.deal.dealDate,
title: [r.leadTitle, r.clientName].filter(Boolean).join(' · '),
agent: r.agent,
commission: r.deal.commission,
currency: r.deal.currency,
usd: round2(toUsd(r.deal.commission, r.deal.currency, rates)),
}));
const total = round2(list.reduce((sum, d) => sum + d.usd, 0));
const details: PercentDetails = { plan, percent, rates, total, deals: list };
return { details, amount: round2((total * percent) / 100) };
}
// --- выставление счетов ---
const managersOf = async (tx: Tx, companyId: number) =>
(
await tx
.select({ id: users.id })
.from(users)
.where(and(eq(users.companyId, companyId), eq(users.role, 'manager'), isNull(users.removedAt)))
).map((u) => u.id);
const money = (n: number) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
async function issue(
tx: Tx,
company: Company,
v: { kind: 'setup' | 'fixed' | 'percent'; amount: number; periodStart?: string | null; periodEnd?: string | null; details?: unknown },
notify = true,
): Promise<Invoice> {
const [invoice] = await tx
.insert(invoices)
.values({
companyId: company.id,
kind: v.kind,
amount: v.amount,
periodStart: v.periodStart ?? null,
periodEnd: v.periodEnd ?? null,
details: v.details ?? { plan: company.plan },
})
.returning();
if (notify && v.amount > 0) await enqueueText(tx, await managersOf(tx, company.id), 'invoiceIssued', [money(v.amount)]);
return invoice!;
}
/** Плата за установку начисляется один раз — при первом подключении Team или Agency. */
export async function issueSetupIfNeeded(tx: Tx, company: Company, notify = true) {
const fee = PLAN_SPECS[company.plan as Plan].setupFeeUsd;
if (!fee || company.setupFeeInvoiced) return;
await tx.update(companies).set({ setupFeeInvoiced: true }).where(eq(companies.id, company.id));
await issue(tx, company, { kind: 'setup', amount: fee }, notify);
}
/** Счета новой компании, которая подключается сама: установка и первый месяц фикса. */
export async function issueInitialInvoices(tx: Tx, company: Company) {
await issueSetupIfNeeded(tx, company, false);
if (company.billingModel === 'fixed') {
// Период первого счёта начинается в день оплаты.
await issue(tx, company, { kind: 'fixed', amount: PLAN_SPECS[company.plan as Plan].monthlyUsd }, false);
}
}
/** Тариф и модель, которые будут действовать у компании на указанную дату. */
function planOn(company: Company, day: string): { plan: Plan; model: BillingModel } {
if (company.plannedPlan && company.plannedFrom && company.plannedFrom <= day) {
return { plan: company.plannedPlan as Plan, model: (company.plannedBillingModel ?? company.billingModel) as BillingModel };
}
return { plan: company.plan as Plan, model: company.billingModel as BillingModel };
}
/** С какой даты вступит в силу смена тарифа; null — сразу (подписка ещё не начиналась или уже закончилась). */
export function nextPeriodStart(company: Company, now = new Date()): string | null {
if (!company.subscriptionEndsAt || company.subscriptionEndsAt < now) return null;
return company.billingModel === 'percent'
? addMonths(monthStart(ymd(now)), 1)
: addDays(ymd(company.subscriptionEndsAt), 1);
}
/** Фикс: счёт на следующий месяц за 7 дней до окончания подписки. */
async function ensureFixedInvoice(tx: Tx, company: Company, today: string) {
if (!company.subscriptionEndsAt) return;
const end = ymd(company.subscriptionEndsAt);
if (daysBetween(today, end) > INVOICE_LEAD_DAYS) return;
const periodStart = addDays(end, 1);
const next = planOn(company, periodStart);
if (next.model !== 'fixed') return;
const [exists] = await tx
.select({ id: invoices.id })
.from(invoices)
.where(
and(
eq(invoices.companyId, company.id),
eq(invoices.kind, 'fixed'),
ne(invoices.status, 'canceled'),
sql`(${invoices.periodStart} = ${periodStart} or (${invoices.periodStart} is null and ${invoices.status} = 'pending'))`,
),
);
if (exists) return;
await issue(tx, company, {
kind: 'fixed',
amount: PLAN_SPECS[next.plan].monthlyUsd,
periodStart,
periodEnd: addMonths(end, 1),
details: { plan: next.plan },
});
}
/** «%»: 1-го числа счёт за сделки прошлого месяца. Нулевой счёт закрывается сам и продлевает подписку. */
async function ensurePercentInvoice(tx: Tx, company: Company, today: string) {
const to = addDays(monthStart(today), -1);
const from = monthStart(to);
if (!company.percentFrom || company.percentFrom > to) return;
const [exists] = await tx
.select({ id: invoices.id })
.from(invoices)
.where(and(eq(invoices.companyId, company.id), eq(invoices.kind, 'percent'), eq(invoices.periodStart, from)));
if (exists) return;
const { details, amount } = await percentCalc(tx, company, from, to);
const invoice = await issue(tx, company, { kind: 'percent', amount, periodStart: from, periodEnd: to, details });
if (amount === 0) await settleInvoice(tx, invoice.id, 'auto');
}
// --- оплата ---
/**
* Отмечает счёт оплаченным и продлевает подписку. Вызывается суперадмином вручную
* и платёжным модулем после подтверждения платежа.
*/
export async function settleInvoice(
tx: Tx,
invoiceId: number,
method: 'manual' | 'online' | 'auto',
now = new Date(),
): Promise<Invoice> {
const [invoice] = await tx.select().from(invoices).where(eq(invoices.id, invoiceId)).for('update');
if (!invoice) throw notFound();
if (invoice.status === 'paid') return invoice;
if (invoice.status !== 'pending') throw conflict('INVOICE_CANCELED');
const [company] = await tx.select().from(companies).where(eq(companies.id, invoice.companyId)).for('update');
const today = ymd(now);
let { periodStart, periodEnd } = invoice;
let endsAt: Date | null = null;
if (invoice.kind === 'fixed') {
// Первый счёт или оплата после долгого простоя: месяц отсчитывается от дня оплаты.
if (!periodStart || !periodEnd || periodEnd < today) {
periodStart = today;
periodEnd = addMonths(today, 1);
}
endsAt = endOfDay(periodEnd);
} else if (invoice.kind === 'percent' && company!.billingModel === 'percent') {
endsAt = endOfDay(percentDue(addMonths(addDays(invoice.periodEnd!, 1), 1)));
} else if (invoice.kind === 'setup' && company!.billingModel === 'percent' && !company!.subscriptionEndsAt) {
// На модели «%» первого месячного счёта нет: подписка стартует с оплаты установки.
endsAt = endOfDay(percentDue(addMonths(today, 1)));
}
const [paid] = await tx
.update(invoices)
.set({ status: 'paid', paidAt: now, paidMethod: method, periodStart, periodEnd })
.where(eq(invoices.id, invoiceId))
.returning();
// У онлайн-платежа строка уже есть: её создал запрос на оплату, а подтвердил webhook.
if (method === 'manual') {
await tx.insert(payments).values({
invoiceId,
provider: 'manual',
externalId: null,
amount: invoice.amount,
currency: invoice.currency,
status: 'succeeded',
});
}
if (endsAt && (!company!.subscriptionEndsAt || endsAt > company!.subscriptionEndsAt)) {
await tx
.update(companies)
.set({
subscriptionEndsAt: endsAt,
accessEnabled: true,
billingNotice: null,
...(company!.billingModel === 'percent' && !company!.percentFrom ? { percentFrom: today } : {}),
})
.where(eq(companies.id, company!.id));
if (method !== 'auto') {
await enqueueText(tx, await managersOf(tx, company!.id), 'paymentReceived', [ymd(endsAt)]);
}
}
return paid!;
}
export async function cancelInvoice(tx: Tx, invoiceId: number) {
const [invoice] = await tx.select().from(invoices).where(eq(invoices.id, invoiceId)).for('update');
if (!invoice) throw notFound();
if (invoice.status === 'paid') throw conflict('INVOICE_PAID');
await tx.update(invoices).set({ status: 'canceled' }).where(eq(invoices.id, invoiceId));
if (invoice.kind === 'setup') {
await tx.update(companies).set({ setupFeeInvoiced: false }).where(eq(companies.id, invoice.companyId));
}
}
// --- смена тарифа ---
const cancelPendingFixed = (tx: Tx, companyId: number) =>
tx
.update(invoices)
.set({ status: 'canceled' })
.where(and(eq(invoices.companyId, companyId), eq(invoices.kind, 'fixed'), eq(invoices.status, 'pending')));
/** Применяет тариф и модель прямо сейчас (суперадмин, запланированная дата, неначатая подписка). */
export async function applyPlan(tx: Tx, company: Company, plan: Plan, model: BillingModel, now = new Date()): Promise<Company> {
const today = ymd(now);
const toPercent = model === 'percent' && company.billingModel !== 'percent';
const running = Boolean(company.subscriptionEndsAt);
// Уход с модели «%» посреди месяца: сделки с начала месяца закрываются отдельным счётом,
// иначе они не попали бы ни в один.
if (running && company.billingModel === 'percent' && model !== 'percent' && today !== monthStart(today)) {
const to = addDays(today, -1);
const { details, amount } = await percentCalc(tx, company, monthStart(today), to);
if (amount > 0) await issue(tx, company, { kind: 'percent', amount, periodStart: monthStart(today), periodEnd: to, details });
}
const [updated] = await tx
.update(companies)
.set({
plan,
billingModel: model,
plannedPlan: null,
plannedBillingModel: null,
plannedFrom: null,
...(model === 'percent' && (toPercent || !company.percentFrom) ? { percentFrom: today } : {}),
// Первый счёт по комиссии придёт 1-го числа следующего месяца, срок оплаты — 8-е.
...(toPercent && running ? { subscriptionEndsAt: endOfDay(percentDue(addMonths(today, 1))), billingNotice: null } : {}),
})
.where(eq(companies.id, company.id))
.returning();
if (plan !== company.plan || model !== company.billingModel) await cancelPendingFixed(tx, company.id);
let current = updated!;
if (!running && plan !== company.plan) {
// Подписка ещё не начиналась: неоплаченный счёт за установку пересчитывается под новый тариф.
const dropped = await tx
.update(invoices)
.set({ status: 'canceled' })
.where(and(eq(invoices.companyId, company.id), eq(invoices.kind, 'setup'), eq(invoices.status, 'pending')))
.returning({ id: invoices.id });
if (dropped.length) {
[current] = await tx.update(companies).set({ setupFeeInvoiced: false }).where(eq(companies.id, company.id)).returning() as [Company];
}
}
await issueSetupIfNeeded(tx, current, running);
if (!running && model === 'fixed') {
await issue(tx, updated!, { kind: 'fixed', amount: PLAN_SPECS[plan].monthlyUsd }, false);
}
// Подписка идёт или закончилась: счёт на следующий месяц выставляется по новому тарифу.
if (running && model === 'fixed') await ensureFixedInvoice(tx, updated!, today);
return updated!;
}
/**
* Смена тарифа из кабинета: вступает в силу со следующего расчётного месяца.
* Если подписка ещё не начиналась или уже закончилась, меняется сразу.
*/
export async function schedulePlan(db: Db, companyId: number, plan: Plan, model: BillingModel, now = new Date()) {
if (!allowedBillingModels(plan).includes(model)) throw badRequest('BILLING_MODEL_NOT_ALLOWED');
return db.transaction(async (tx) => {
const [company] = await tx.select().from(companies).where(eq(companies.id, companyId)).for('update');
if (!company) throw notFound();
const [{ n: members } = { n: 0 }] = await tx
.select({ n: sql<number>`count(*)::int` })
.from(users)
.where(and(eq(users.companyId, companyId), isNull(users.removedAt)));
if (members > PLAN_SPECS[plan].maxMembers) throw conflict('PLAN_TOO_SMALL');
const same = plan === company.plan && model === company.billingModel;
const from = nextPeriodStart(company, now);
if (from === null) {
if (!same) await applyPlan(tx, company, plan, model, now);
return { from: null };
}
await tx
.update(companies)
.set(
same
? { plannedPlan: null, plannedBillingModel: null, plannedFrom: null }
: { plannedPlan: plan, plannedBillingModel: model, plannedFrom: from },
)
.where(eq(companies.id, companyId));
// Счёт на следующий месяц мог быть уже выставлен по старому тарифу — он пересоздаётся.
await cancelPendingFixed(tx, companyId);
const [fresh] = await tx.select().from(companies).where(eq(companies.id, companyId));
if (fresh!.billingModel === 'fixed') await ensureFixedInvoice(tx, fresh!, ymd(now));
return { from: same ? null : from };
});
}
// --- ежедневные задачи ---
async function remind(tx: Tx, company: Company, now: Date) {
if (!company.subscriptionEndsAt || !company.accessEnabled) return;
const today = ymd(now);
const end = ymd(company.subscriptionEndsAt);
const state = accessState(company, now);
const left = daysBetween(today, end);
let key: string | null = null;
let text: [string, unknown[]] | null = null;
if (state === 'active' && REMIND_DAYS.includes(left)) {
key = `before:${left}:${end}`;
text = ['subEnding', [left, end]];
} else if (state === 'overdue') {
key = `overdue:${today}`;
text = ['subOverdue', [Math.max(0, company.graceDays + left)]];
} else if (state === 'restricted') {
key = `restricted:${end}`;
text = ['subRestricted', []];
}
if (!key || !text || key === company.billingNotice) return;
await tx.update(companies).set({ billingNotice: key }).where(eq(companies.id, company.id));
await enqueueText(tx, await managersOf(tx, company.id), text[0], text[1]);
}
/**
* Запускается раз в несколько минут: счета по комиссии 1-го числа, запланированная смена тарифа,
* счёт на фикс за 7 дней до окончания, напоминания о подписке. Повторный запуск ничего не дублирует.
*/
export async function runBillingJobs(db: Db, now = new Date()) {
const today = ymd(now);
const hour = new Date(now.getTime() + 4 * 3_600_000).getUTCHours();
const ids = await db.select({ id: companies.id }).from(companies).orderBy(companies.id);
for (const { id } of ids) {
await db.transaction(async (tx) => {
let [company] = await tx.select().from(companies).where(eq(companies.id, id)).for('update');
if (!company?.subscriptionEndsAt) return;
// Счёт за прошлый месяц считается по тарифу, который действовал в том месяце, — до его смены.
if (company.billingModel === 'percent') await ensurePercentInvoice(tx, company, today);
if (company.plannedPlan && company.plannedFrom && company.plannedFrom <= today) {
const [{ n: members } = { n: 0 }] = await tx
.select({ n: sql<number>`count(*)::int` })
.from(users)
.where(and(eq(users.companyId, id), isNull(users.removedAt)));
const plan = company.plannedPlan as Plan;
if (members > PLAN_SPECS[plan].maxMembers) {
// Участников стало больше, чем вмещает новый тариф: смена отменяется.
await tx
.update(companies)
.set({ plannedPlan: null, plannedBillingModel: null, plannedFrom: null })
.where(eq(companies.id, id));
await enqueueText(tx, await managersOf(tx, id), 'planChangeCanceled');
} else {
await applyPlan(tx, company, plan, (company.plannedBillingModel ?? company.billingModel) as BillingModel, now);
}
}
[company] = await tx.select().from(companies).where(eq(companies.id, id));
if (company!.billingModel === 'fixed') await ensureFixedInvoice(tx, company!, today);
[company] = await tx.select().from(companies).where(eq(companies.id, id));
if (hour >= REMIND_FROM_HOUR) await remind(tx, company!, now);
});
}
}
+25
View File
@@ -0,0 +1,25 @@
// Создаёт суперадмина (владельца сервиса) или меняет ему пароль:
// pnpm --filter @lr/api superadmin <email> "Имя"
import { randomBytes } from 'node:crypto';
import { sql } from 'drizzle-orm';
import { hashPassword } from '../auth/password.ts';
import { connect } from '../db/client.ts';
import { users } from '../db/schema.ts';
import { loadConfig } from '../lib/config.ts';
const [email, name = 'Суперадмин'] = process.argv.slice(2);
if (!email) throw new Error('Укажите email: superadmin <email> "Имя"');
const { db, close } = connect(loadConfig().databaseUrl);
const password = randomBytes(9).toString('base64url');
const passwordHash = await hashPassword(password);
const [existing] = await db.select().from(users).where(sql`lower(${users.email}) = ${email.toLowerCase()}`);
if (existing && existing.role !== 'superadmin') throw new Error('Этот email уже занят обычным пользователем');
if (existing) await db.update(users).set({ passwordHash }).where(sql`${users.id} = ${existing.id}`);
else {
await db.insert(users).values({
email: email.toLowerCase(), name, role: 'superadmin', passwordHash, emailVerifiedAt: new Date(),
});
}
await close();
console.log(`Суперадмин: ${email}\nПароль: ${password}`);
+698
View File
@@ -0,0 +1,698 @@
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashPassword } from './auth/password.ts';
import { createSession } from './auth/sessions.ts';
import { runClientJobs } from './clients/service.ts';
import { deals, leads, outbox, users } from './db/schema.ts';
import { runReportJobs } from './reports.ts';
import { processOutbox } from './telegram/outbox.ts';
import { fakeTelegram, testApp } from './testing.ts';
// Сценарии воронки клиента: карточка, видимость, связь «клиент ↔ объект», общая сделка,
// настройка воронок руководителем.
let t: Awaited<ReturnType<typeof testApp>>;
let tgId = 5000;
beforeAll(async () => { t = await testApp('clients'); });
afterAll(() => t.close());
const tg = fakeTelegram();
const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); };
interface Member { api: ReturnType<typeof t.client>; id: number; chat: number }
/** Компания Team с руководителем и агентами; у всех привязан Telegram. */
const company = async (agents = 2) => {
const [sa] = await t.db
.insert(users)
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
.returning();
const admin = t.client(await createSession(t.db, sa!.id));
const created = await admin.post('/api/admin/companies', {
name: 'Агентство', plan: 'team', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
});
const join = async (name: string, token: string): Promise<Member> => {
const api = await t.register(name);
await api.post(`/api/invites/${token}/accept`);
const id = (await api.get('/api/me')).body.user.id as number;
const chat = ++tgId;
await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id));
return { api, id, chat };
};
const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!);
const invite = (await boss.api.post('/api/invites')).body.token as string;
const team: Member[] = [];
for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite));
await flush();
return { boss, agents: team, id: created.body.id as number };
};
const pipeline = async (m: Member, name: 'object' | 'client') => {
const p = (await m.api.get(`/api/pipelines/${name}`)).body;
const status = (code: string) => p.statuses.find((s: any) => s.code === code).id as number;
return { ...p, status, names: p.statuses.map((s: any) => s.name) as string[] };
};
const newClient = async (m: Member, over: object = {}) =>
(await m.api.post('/api/clients', { name: 'Анна', phone: '091 123 456', kind: 'buyer', request: { districts: ['Кентрон'], priceTo: 200_000 }, ...over })).body.id as number;
const newLead = async (m: Member, over: object = {}) =>
(await m.api.post('/api/leads', { title: '3-комн. на Абовяна', dealType: 'sale', propertyType: 'apartment', district: 'Кентрон', price: 150_000, ...over })).body.id as number;
const deal = { commission: 3000, currency: 'USD', date: '2026-10-08' };
describe('клиенты', () => {
it('карточка клиента: создание, запрос, правка, комментарий, история', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const id = await newClient(a, { source: 'Instagram' });
const p = await pipeline(a, 'client');
let card = (await a.api.get(`/api/clients/${id}`)).body;
expect(card).toMatchObject({
name: 'Анна', phone: '+37491123456', kind: 'buyer', source: 'Instagram', assigneeId: a.id,
statusId: p.status('new'), request: { districts: ['Кентрон'], priceTo: 200_000 },
});
expect((await a.api.patch(`/api/clients/${id}`, { name: 'Анна С.', source: '', request: { rooms: [2, 3] } })).status).toBe(200);
await a.api.post(`/api/clients/${id}/comments`, { text: 'Ищет рядом с метро' });
card = (await a.api.get(`/api/clients/${id}`)).body;
expect(card).toMatchObject({ name: 'Анна С.', source: null, request: { rooms: [2, 3] } });
expect(card.events.map((e: any) => e.type)).toEqual(['comment', 'edited', 'created']);
// Правка одного поля остальные не трогает.
await a.api.patch(`/api/clients/${id}`, { phone: '093 000 111' });
expect((await a.api.get(`/api/clients/${id}`)).body).toMatchObject({ name: 'Анна С.', phone: '+37493000111', kind: 'buyer', request: { rooms: [2, 3] } });
expect((await a.api.post('/api/clients', { name: '', kind: 'buyer' })).status).toBe(400);
});
it('видимость: чужого клиента агент не видит; при «видят все» видит без телефона и не может вести', async () => {
const c = await company(2);
const [a1, a2] = c.agents as [Member, Member];
const id = await newClient(a1);
expect((await a2.api.get(`/api/clients/${id}`)).status).toBe(404);
expect((await a2.api.get('/api/clients?tab=all')).status).toBe(403);
expect((await a2.api.get('/api/clients?tab=mine')).body).toHaveLength(0);
expect((await c.boss.api.get('/api/clients?tab=all')).body).toMatchObject([{ id, phone: '+37491123456', assigneeName: 'Агент 1' }]);
// Поиск по телефону находит клиента руководителю, но не чужому агенту.
expect((await c.boss.api.get('/api/clients?tab=all&q=123456')).body).toHaveLength(1);
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
const seen = (await a2.api.get(`/api/clients/${id}`)).body;
expect(seen).toMatchObject({ name: 'Анна', phone: null, phoneHidden: true });
expect((await a2.api.get('/api/clients?tab=all&q=123456')).body).toHaveLength(0);
expect((await a2.api.post(`/api/clients/${id}/comments`, { text: 'x' })).body.code).toBe('NOT_YOUR_CLIENT');
// Клиент из другой компании недоступен.
const other = await company(0);
expect((await other.boss.api.get(`/api/clients/${id}`)).status).toBe(404);
});
it('воронка клиента: отказ требует причину, сделка — комиссию; закрытую сделку меняет руководитель', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const p = await pipeline(a, 'client');
expect(p.names).toEqual(['Новый запрос', 'Квалификация', 'Подбор объектов', 'Показ', 'Переговоры и задаток', 'Сделка', 'Отказ']);
const id = await newClient(a);
const move = (body: object, who = a) => who.api.post(`/api/clients/${id}/status`, body);
expect((await move({ statusId: p.statuses[1].id })).status).toBe(200);
expect((await move({ statusId: p.status('rejected') })).body.code).toBe('REJECT_REASON_REQUIRED');
// Причина из воронки объекта клиенту не подходит.
const objectReason = (await pipeline(a, 'object')).reasons[1].id;
expect((await move({ statusId: p.status('rejected'), rejectReasonId: objectReason })).body.code).toBe('REJECT_REASON_REQUIRED');
expect((await move({ statusId: p.status('rejected'), rejectReasonId: p.reasons[0].id })).status).toBe(200);
expect((await a.api.get(`/api/clients/${id}`)).body.rejectReasonId).toBe(p.reasons[0].id);
expect((await move({ statusId: p.status('deal') })).body.code).toBe('COMMISSION_REQUIRED');
expect((await move({ statusId: p.status('deal'), deal })).status).toBe(200);
const card = (await a.api.get(`/api/clients/${id}`)).body;
expect(card).toMatchObject({ statusId: p.status('deal'), rejectReasonId: null, deal: { commission: 3000, currency: 'USD', date: '2026-10-08', leadId: null } });
// Сумму исправляет руководитель; агент — нет.
expect((await a.api.patch(`/api/deals/${card.deal.id}`, { ...deal, commission: 1 })).status).toBe(403);
expect((await c.boss.api.patch(`/api/deals/${card.deal.id}`, { ...deal, commission: 3500 })).status).toBe(200);
expect((await a.api.get(`/api/clients/${id}`)).body.deal.commission).toBe(3500);
// Сделку отменяет только руководитель: запись сделки исчезает.
expect((await move({ statusId: p.statuses[1].id })).body.code).toBe('DEAL_CLOSED');
expect((await move({ statusId: p.statuses[1].id }, c.boss)).status).toBe(200);
expect(await t.db.select().from(deals).where(eq(deals.clientId, id))).toHaveLength(0);
});
it('руководитель переназначает клиента; напоминание о следующем действии приходит в бот', async () => {
const c = await company(2);
const [a1, a2] = c.agents as [Member, Member];
const id = await newClient(a1);
expect((await a1.api.post(`/api/clients/${id}/assign`, { userId: a2.id })).status).toBe(403);
expect((await c.boss.api.post(`/api/clients/${id}/assign`, { userId: a2.id })).status).toBe(200);
await flush();
expect(tg.inbox(a2.chat).at(-1)!.text).toBe('Вам назначен клиент: Анна');
expect(tg.inbox(a1.chat).at(-1)!.text).toBe('Клиент «Анна» передан агенту Агент 2.');
expect((await a1.api.get(`/api/clients/${id}`)).status).toBe(404);
const events = (await a2.api.get(`/api/clients/${id}`)).body.events;
expect(events[0]).toMatchObject({ type: 'assigned', oldValue: 'Агент 1', newValue: 'Агент 2' });
await a2.api.post(`/api/clients/${id}/next-action`, { at: new Date(Date.now() - 1000).toISOString(), note: 'Позвонить' });
expect(await runClientJobs(t.db)).toEqual({ reminded: 1 });
expect(await runClientJobs(t.db)).toEqual({ reminded: 0 });
await flush();
expect(tg.inbox(a2.chat).at(-1)!.text).toBe('🔔 Напоминание по клиенту «Анна»: Позвонить');
// Удалённого агента сменяет наследник; без наследника клиентов забирает руководитель.
await c.boss.api.del(`/api/members/${a2.id}`);
expect((await c.boss.api.get(`/api/clients/${id}`)).body.assigneeId).toBe(c.boss.id);
});
it('ответственный — тот, кто создал клиента, даже если руководитель указал другого', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const res = await c.boss.api.post('/api/clients', { name: 'Ольга', kind: 'tenant', assigneeId: a.id });
expect((await c.boss.api.get(`/api/clients/${res.body.id}`)).body.assigneeId).toBe(c.boss.id);
});
});
describe('связь «клиент ↔ объект» и общая сделка', () => {
it('объект прикрепляется к клиенту: «предложен» → «показан»; в карточке объекта видны клиенты', async () => {
const c = await company(2);
const [a1, a2] = c.agents as [Member, Member];
const clientId = await newClient(a1);
const leadId = await newLead(a1);
const foreign = await newLead(a2);
// Подбор: подходит объект в Кентроне до 200 000; дорогой и арендный — нет.
await newLead(a1, { title: 'Дорогая', price: 500_000 });
await newLead(a1, { title: 'Аренда', dealType: 'rent', price: 800 });
const suggested = (await a1.api.get(`/api/clients/${clientId}/suggestions`)).body;
expect(suggested.map((l: any) => l.id)).toEqual([leadId]);
expect((await a1.api.post(`/api/clients/${clientId}/leads`, { leadId })).status).toBe(200);
// Чужой объект при закрытой видимости прикрепить нельзя.
expect((await a1.api.post(`/api/clients/${clientId}/leads`, { leadId: foreign })).status).toBe(404);
expect((await a1.api.get(`/api/clients/${clientId}/suggestions`)).body).toHaveLength(0);
await a1.api.post(`/api/clients/${clientId}/leads`, { leadId, state: 'shown' });
const card = (await a1.api.get(`/api/clients/${clientId}`)).body;
expect(card.leads).toMatchObject([{ id: leadId, title: '3-комн. на Абовяна', state: 'shown' }]);
expect(card.leads[0].shownAt).toBeTruthy();
expect(card.events.slice(0, 2).map((e: any) => [e.type, e.newValue])).toEqual([['shown', '3-комн. на Абовяна'], ['offered', '3-комн. на Абовяна']]);
const lead = (await a1.api.get(`/api/leads/${leadId}`)).body;
expect(lead.clients).toMatchObject([{ id: clientId, name: 'Анна', state: 'shown' }]);
expect(lead.events[0]).toMatchObject({ type: 'client_shown', newValue: 'Анна' });
expect((await a1.api.get('/api/clients-options')).body).toMatchObject([{ id: clientId, name: 'Анна' }]);
expect((await a1.api.del(`/api/clients/${clientId}/leads/${leadId}`)).status).toBe(200);
expect((await a1.api.get(`/api/clients/${clientId}`)).body.leads).toHaveLength(0);
expect((await a1.api.get(`/api/leads/${leadId}`)).body.clients).toHaveLength(0);
});
it('сделка объекта с клиентом из CRM закрывается один раз: оба в «Сделке», комиссия одна', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const po = await pipeline(a, 'object');
const pc = await pipeline(a, 'client');
const count = async () => (await t.db.select().from(deals).where(eq(deals.companyId, c.id))).length;
// Со стороны объекта.
const client1 = await newClient(a);
const lead1 = await newLead(a);
const close = (leadId: number, clientId: number) =>
a.api.post(`/api/leads/${leadId}/status`, { statusId: po.status('deal'), deal: { ...deal, clientId } });
// Неприкреплённого клиента в пару взять нельзя — и объект при этом остаётся в прежнем статусе.
expect((await close(lead1, client1)).body.code).toBe('PAIR_NOT_LINKED');
expect((await a.api.get(`/api/leads/${lead1}`)).body.statusId).toBe(po.status('taken'));
await a.api.post(`/api/clients/${client1}/leads`, { leadId: lead1 });
expect((await close(lead1, client1)).status).toBe(200);
expect(await count()).toBe(1);
const leadCard = (await a.api.get(`/api/leads/${lead1}`)).body;
const clientCard = (await a.api.get(`/api/clients/${client1}`)).body;
expect(leadCard).toMatchObject({ statusId: po.status('deal'), deal: { commission: 3000, clientId: client1, clientName: 'Анна' } });
expect(clientCard).toMatchObject({ statusId: pc.status('deal'), deal: { id: leadCard.deal.id, commission: 3000, leadId: lead1 } });
expect(clientCard.events[0]).toMatchObject({ type: 'deal', text: '3000 USD' });
// Со стороны клиента.
const client2 = await newClient(a, { name: 'Давид' });
const lead2 = await newLead(a);
await a.api.post(`/api/clients/${client2}/leads`, { leadId: lead2, state: 'shown' });
expect((await a.api.post(`/api/clients/${client2}/status`, { statusId: pc.status('deal'), deal: { ...deal, commission: 5000, leadId: lead2 } })).status).toBe(200);
expect(await count()).toBe(2);
expect((await a.api.get(`/api/leads/${lead2}`)).body).toMatchObject({ statusId: po.status('deal'), deal: { commission: 5000, clientId: client2 } });
// Второй клиент к уже закрытому объекту не подставляется.
const client3 = await newClient(a, { name: 'Мария' });
await a.api.post(`/api/clients/${client3}/leads`, { leadId: lead2 });
expect((await a.api.post(`/api/clients/${client3}/status`, { statusId: pc.status('deal'), deal: { ...deal, leadId: lead2 } })).body.code).toBe('DEAL_HAS_PAIR');
expect((await a.api.get(`/api/clients/${client3}`)).body.statusId).toBe(pc.status('new'));
// Руководитель отменил сделку у объекта: запись остаётся за клиентом, комиссия не теряется и не двоится.
expect((await c.boss.api.post(`/api/leads/${lead2}/status`, { statusId: po.status('taken') })).status).toBe(200);
expect(await count()).toBe(2);
expect((await a.api.get(`/api/leads/${lead2}`)).body.deal).toBeNull();
expect((await a.api.get(`/api/clients/${client2}`)).body.deal).toMatchObject({ commission: 5000, leadId: null });
});
it('объект и клиент закрыты порознь, затем сведены в пару: записи сливаются в одну', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const po = await pipeline(a, 'object');
const pc = await pipeline(a, 'client');
const clientId = await newClient(a);
const leadId = await newLead(a);
await a.api.post(`/api/clients/${clientId}/leads`, { leadId });
await a.api.post(`/api/leads/${leadId}/status`, { statusId: po.status('deal'), deal });
await a.api.post(`/api/clients/${clientId}/status`, { statusId: pc.status('deal'), deal });
expect(await t.db.select().from(deals).where(eq(deals.companyId, c.id))).toHaveLength(2);
// Руководитель возвращает клиента на этап и закрывает заново — уже с объектом.
await c.boss.api.post(`/api/clients/${clientId}/status`, { statusId: pc.statuses[1].id });
expect((await c.boss.api.post(`/api/clients/${clientId}/status`, { statusId: pc.status('deal'), deal: { ...deal, commission: 4000, leadId } })).status).toBe(200);
const rows = await t.db.select().from(deals).where(eq(deals.companyId, c.id));
expect(rows).toMatchObject([{ leadId, clientId, commission: 4000 }]);
});
});
describe('настройка воронок', () => {
it('руководитель добавляет, переименовывает, переставляет и удаляет промежуточные статусы', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
expect((await a.api.post('/api/pipelines/object/statuses', { name: 'Оценка' })).status).toBe(403);
const added = (await c.boss.api.post('/api/pipelines/object/statuses', { name: 'Оценка' })).body.id as number;
let p = await pipeline(c.boss, 'object');
expect(p.names).toEqual(['Новый', 'Взят в работу', 'Дозвонился', 'Встреча с собственником', 'Договор (эксклюзив)', 'В продаже (идут показы)', 'Оценка', 'Сделка', 'Отказ']);
await c.boss.api.patch(`/api/pipeline-statuses/${added}`, { name: 'Оценка объекта', poolReturn: true });
const middle = p.statuses.filter((s: any) => !s.code).map((s: any) => s.id) as number[];
const order = [added, ...middle.filter((id) => id !== added)];
expect((await c.boss.api.post('/api/pipelines/object/order', { ids: order })).status).toBe(200);
// Неполный список и системные статусы в порядке не принимаются.
expect((await c.boss.api.post('/api/pipelines/object/order', { ids: order.slice(1) })).status).toBe(400);
expect((await c.boss.api.post('/api/pipelines/object/order', { ids: [p.status('deal'), ...order.slice(1)] })).status).toBe(400);
p = await pipeline(c.boss, 'object');
expect(p.names.slice(0, 4)).toEqual(['Новый', 'Взят в работу', 'Оценка объекта', 'Дозвонился']);
expect(p.statuses[2]).toMatchObject({ poolReturn: true, count: 0 });
// Агент видит новую воронку.
expect((await pipeline(a, 'object')).names[2]).toBe('Оценка объекта');
// Системный статус удалить нельзя; занятый — только с переводом карточек.
expect((await c.boss.api.del(`/api/pipeline-statuses/${p.status('taken')}`)).body.code).toBe('SYSTEM_STATUS');
const leadId = await newLead(a);
await a.api.post(`/api/leads/${leadId}/status`, { statusId: added });
expect((await pipeline(c.boss, 'object')).statuses[2].count).toBe(1);
expect((await c.boss.api.del(`/api/pipeline-statuses/${added}`)).body.code).toBe('STATUS_IN_USE');
expect((await c.boss.api.del(`/api/pipeline-statuses/${added}?moveTo=${p.status('deal')}`)).body.code).toBe('BAD_STATUS');
expect((await c.boss.api.del(`/api/pipeline-statuses/${added}?moveTo=${middle[0]}`)).status).toBe(200);
const card = (await a.api.get(`/api/leads/${leadId}`)).body;
expect(card.statusId).toBe(middle[0]);
expect(card.events[0]).toMatchObject({ type: 'status', oldValue: 'Оценка объекта', newValue: 'Дозвонился' });
expect((await pipeline(c.boss, 'object')).names).toHaveLength(8);
expect((await a.api.post(`/api/leads/${leadId}/status`, { statusId: added })).body.code).toBe('BAD_STATUS');
// Воронка клиента настраивается отдельно; возврата в пул у неё нет.
const cs = (await c.boss.api.post('/api/pipelines/client/statuses', { name: 'Ипотека' })).body.id as number;
expect((await pipeline(c.boss, 'client')).names.slice(-3)).toEqual(['Ипотека', 'Сделка', 'Отказ']);
expect((await c.boss.api.patch(`/api/pipeline-statuses/${cs}`, { poolReturn: true })).status).toBe(400);
});
it('причины отказа: добавить, переименовать, переставить, удалить; системную и последнюю — нельзя', async () => {
const c = await company(1);
const [a] = c.agents as [Member];
const made = (await c.boss.api.post('/api/pipelines/client/reasons', { name: 'Уехал из страны' })).body.id as number;
await c.boss.api.patch(`/api/reject-reasons/${made}`, { name: 'Уехал' });
let p = await pipeline(a, 'client');
expect(p.reasons.at(-1)).toMatchObject({ id: made, name: 'Уехал' });
const ids = [made, ...p.reasons.filter((r: any) => r.id !== made).map((r: any) => r.id)];
expect((await c.boss.api.post('/api/pipelines/client/reasons-order', { ids })).status).toBe(200);
expect((await pipeline(a, 'client')).reasons[0].id).toBe(made);
// Клиент закрыт с причиной, причину потом удалили: в карточке она остаётся.
const clientId = await newClient(a);
await a.api.post(`/api/clients/${clientId}/status`, { statusId: p.status('rejected'), rejectReasonId: made });
expect((await a.api.del(`/api/reject-reasons/${made}`)).status).toBe(403);
expect((await c.boss.api.del(`/api/reject-reasons/${made}`)).status).toBe(200);
expect((await a.api.get(`/api/clients/${clientId}`)).body.rejectReasonId).toBe(made);
p = await pipeline(a, 'client');
expect(p.reasons.some((r: any) => r.id === made)).toBe(false);
const po = await pipeline(c.boss, 'object');
const agentReason = po.reasons.find((r: any) => r.code === 'agent').id;
expect((await c.boss.api.del(`/api/reject-reasons/${agentReason}`)).body.code).toBe('SYSTEM_REASON');
const rest = po.reasons.filter((r: any) => !r.code);
for (const r of rest.slice(1)) expect((await c.boss.api.del(`/api/reject-reasons/${r.id}`)).status).toBe(200);
expect((await c.boss.api.del(`/api/reject-reasons/${rest[0].id}`)).body.code).toBe('LAST_REASON');
});
it('кто будет жить: состав, дети, животные; подбор не предлагает объект, где с ними нельзя', async () => {
const c = await company();
const agent = c.agents[0]!;
const created = await agent.api.post('/api/clients', {
name: 'Семья Саркисян', kind: 'tenant', request: {},
household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' }, pets: { count: 1, age: 'кот, 2 года' } },
});
expect(created.status).toBe(200);
const id = created.body.id as number;
expect((await agent.api.get(`/api/clients/${id}`)).body.household).toEqual({
who: 'couple', children: { count: 2, age: '3 и 7 лет' }, pets: { count: 1, age: 'кот, 2 года' },
});
expect((await agent.api.post('/api/clients', { name: 'Х', kind: 'tenant', household: { children: { count: 0 } } })).body.code).toBe('VALIDATION');
// Три объекта в аренду: с детьми нельзя; с животными нельзя; про правила ничего не сказано.
const lead = async (title: string, features: object | null) => {
const made = await agent.api.post('/api/leads', { title, dealType: 'rent', propertyType: 'apartment' });
await t.db.update(leads).set({ features: features as never }).where(eq(leads.id, made.body.id));
return title;
};
await lead('Без детей', { children: 'no', amenities: [], attrs: [] });
await lead('Без животных', { children: 'yes', pets: 'no', amenities: [], attrs: [] });
await lead('Можно всем', { children: 'yes', pets: 'negotiable', amenities: [], attrs: [] });
await lead('Не указано', null);
const titles = async () => ((await agent.api.get(`/api/clients/${id}/suggestions`)).body as any[]).map((x) => x.title).sort();
expect(await titles()).toEqual(['Можно всем', 'Не указано']);
// Правка: животных больше нет — объект «без животных» появляется; остальное в карточке не затёрто.
await agent.api.patch(`/api/clients/${id}`, { household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' } } });
expect(await titles()).toEqual(['Без животных', 'Можно всем', 'Не указано']);
await agent.api.patch(`/api/clients/${id}`, { household: null });
expect(await titles()).toHaveLength(4);
expect((await agent.api.get(`/api/clients/${id}`)).body).toMatchObject({ name: 'Семья Саркисян', household: null });
});
it('фото к клиенту: загрузка, просмотр, удаление; чужой агент и неподходящий файл получают отказ', async () => {
const c = await company();
const [agent, other] = c.agents as [typeof c.agents[0], typeof c.agents[0]];
const id = (await agent.api.post('/api/clients', { name: 'С фото', kind: 'tenant' })).body.id as number;
const login = async (m: typeof agent) => {
const email = (await m.api.get('/api/me')).body.user.email as string;
return (await t.client().post('/api/auth/login', { email, password: 'password-123' })).body.token as string;
};
const upload = async (token: string, type: string, body: Buffer) =>
t.app.inject({ method: 'POST', url: `/api/clients/${id}/photos?type=${encodeURIComponent(type)}`, payload: body, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' } });
const mine = await login(agent);
const png = Buffer.from('89504e470d0a1a0a', 'hex');
const ok = await upload(mine, 'image/png', png);
expect(ok.statusCode).toBe(200);
const photoId = ok.json().id as number;
expect((await upload(mine, 'application/pdf', png)).json().code).toBe('PHOTO_TYPE');
expect((await upload(mine, 'image/png', Buffer.alloc(0))).statusCode).toBe(400);
expect((await upload(await login(other), 'image/png', png)).statusCode).toBeGreaterThanOrEqual(403);
expect((await agent.api.get(`/api/clients/${id}`)).body.photos).toMatchObject([{ id: photoId }]);
const file = await t.app.inject({ method: 'GET', url: `/api/clients/${id}/photos/${photoId}`, headers: { authorization: `Bearer ${mine}` } });
expect(file.headers['content-type']).toBe('image/png');
expect(file.rawPayload.equals(png)).toBe(true);
// Руководитель видит фото; агент другой компании — нет.
expect((await c.boss.api.get(`/api/clients/${id}`)).body.photos).toHaveLength(1);
const stranger = await company();
expect((await t.app.inject({ method: 'GET', url: `/api/clients/${id}/photos/${photoId}`, headers: { authorization: `Bearer ${await login(stranger.boss)}` } })).statusCode).toBe(404);
expect((await agent.api.del(`/api/clients/${id}/photos/${photoId}`)).status).toBe(200);
expect((await agent.api.get(`/api/clients/${id}`)).body.photos).toEqual([]);
});
it('комиссия агентства: руководитель задаёт условия, агент меняет их в карточке', async () => {
const c = await company();
const [agent, other] = c.agents as [typeof c.agents[0], typeof c.agents[0]];
// Условия по умолчанию задаёт только руководитель.
const terms = {
objectSale: { mode: 'percent', value: 3, currency: 'USD' },
objectRent: { mode: 'percent', value: 50, currency: 'USD' },
clientBuyer: { mode: 'fixed', value: 1000, currency: 'USD' },
clientTenant: null,
};
expect((await agent.api.patch('/api/company', { commission: terms })).status).toBe(403);
expect((await c.boss.api.patch('/api/company', { commission: terms })).status).toBe(200);
expect((await c.boss.api.get('/api/me')).body.company.settings.commission).toEqual(terms);
// Остальные настройки компании при этом не тронуты.
expect((await c.boss.api.get('/api/me')).body.company.settings.poolReturnHours).toBe(24);
// Объект на продажу: 3 % от цены; в аренду — 50 % месячной платы, в валюте объекта.
const sale = await newLead(agent, { price: 150_000, currency: 'USD' });
const rent = await newLead(agent, { dealType: 'rent', price: 400_000, currency: 'AMD' });
expect((await agent.api.get(`/api/leads/${sale}`)).body.commission).toEqual({ rule: terms.objectSale, custom: false, expected: { amount: 4500, currency: 'USD' } });
expect((await agent.api.get(`/api/leads/${rent}`)).body.commission.expected).toEqual({ amount: 200_000, currency: 'AMD' });
// Агент договорился на 2,5 % по этому объекту; чужой агент условие менять не может.
expect((await other.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'percent', value: 1 } })).status).toBeGreaterThanOrEqual(403);
expect((await agent.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'percent', value: 2.5 } })).status).toBe(200);
expect((await agent.api.get(`/api/leads/${sale}`)).body.commission).toMatchObject({ custom: true, rule: { mode: 'percent', value: 2.5 }, expected: { amount: 3750, currency: 'USD' } });
expect((await agent.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'percent', value: -1 } })).body.code).toBe('VALIDATION');
// Сброс возвращает условие компании.
await agent.api.post(`/api/leads/${sale}/commission`, { rule: null });
expect((await agent.api.get(`/api/leads/${sale}`)).body.commission).toMatchObject({ custom: false, expected: { amount: 4500 } });
// Фикс + процент: $200 и 2 % от цены; в валюте цены — фикс приводится по курсу.
await agent.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'mixed', value: 2, fixed: 200, currency: 'USD' } });
expect((await agent.api.get(`/api/leads/${sale}`)).body.commission.expected).toEqual({ amount: 3200, currency: 'USD' });
await agent.api.post(`/api/leads/${rent}/commission`, { rule: { mode: 'mixed', value: 50, fixed: 100, currency: 'USD' } });
expect((await agent.api.get(`/api/leads/${rent}`)).body.commission.expected).toEqual({ amount: 238_500, currency: 'AMD' });
await agent.api.post(`/api/leads/${sale}/commission`, { rule: null });
// История объекта: кто, с какого на какое условие; сброс — возврат к условию компании. Повтор того же значения записи не даёт.
await agent.api.post(`/api/leads/${sale}/commission`, { rule: null });
const history = ((await agent.api.get(`/api/leads/${sale}`)).body.events as any[]).filter((e) => e.type === 'commission').reverse();
expect(history.map((e) => [e.userName, e.oldValue, e.newValue, e.text])).toEqual([
// 3 % → 2,5 % от той же цены: ожидаемая сумма −16,7 %; возврат к 3 %: +20 %.
['Агент 1', 'percent:3', 'percent:2.5', '|-16.7'],
['Агент 1', 'percent:2.5', null, 'percent:3|20'],
['Агент 1', 'percent:3', 'mixed:200:USD:2', '|-28.9'],
['Агент 1', 'mixed:200:USD:2', null, 'percent:3|40.6'],
]);
// Клиенты: покупателю — фикс; арендатору условие не задано, агент ставит своё — процент от бюджета.
const buyer = await newClient(agent);
expect((await agent.api.get(`/api/clients/${buyer}`)).body.commission).toEqual({ rule: terms.clientBuyer, custom: false, expected: { amount: 1000, currency: 'USD' } });
const tenant = await newClient(agent, { kind: 'tenant', request: { priceTo: 600_000, priceCurrency: 'AMD' } });
expect((await agent.api.get(`/api/clients/${tenant}`)).body.commission).toEqual({ rule: null, custom: false, expected: null });
await agent.api.post(`/api/clients/${tenant}/commission`, { rule: { mode: 'percent', value: 50 } });
expect((await agent.api.get(`/api/clients/${tenant}`)).body.commission).toMatchObject({ custom: true, expected: { amount: 300_000, currency: 'AMD' } });
expect((await other.api.post(`/api/clients/${tenant}/commission`, { rule: null })).status).toBeGreaterThanOrEqual(403);
// Руководитель поправил условие клиента — в истории клиента его имя и оба значения.
await c.boss.api.post(`/api/clients/${tenant}/commission`, { rule: { mode: 'fixed', value: 300, currency: 'USD' } });
const clientHistory = ((await agent.api.get(`/api/clients/${tenant}`)).body.events as any[]).filter((e) => e.type === 'commission').reverse();
// Было не задано — сравнивать не с чем. 50 % от 600 000 драм (≈ $779) → фикс $300: −61,5 %.
expect(clientHistory.map((e) => [e.userName, e.oldValue, e.newValue, e.text])).toEqual([
['Агент 1', null, 'percent:50', '|'],
['Руководитель', 'percent:50', 'fixed:300:USD', '|-61.5'],
]);
});
it('оплата агентов: оклад, процент со сделок или оклад + процент; руководитель видит, кому сколько платить', async () => {
const c = await company();
const [a1, a2] = c.agents as [typeof c.agents[0], typeof c.agents[0]];
const p = await pipeline(c.boss, 'object');
// Условия задаёт только руководитель.
expect((await a1.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'percent', percent: 90 } })).status).toBe(403);
expect((await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'mixed', fixed: 300, percent: 30, currency: 'USD' } })).status).toBe(200);
expect((await c.boss.api.post(`/api/members/${a2.id}/pay`, { pay: { mode: 'percent', percent: 50, currency: 'AMD' } })).status).toBe(200);
expect((await c.boss.api.post(`/api/members/${c.boss.id}/pay`, { pay: { mode: 'fixed', fixed: 1000, currency: 'USD' } })).status).toBe(200);
expect((await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'percent', percent: 150 } })).body.code).toBe('VALIDATION');
expect((await c.boss.api.post('/api/members/999999/pay', { pay: null })).status).toBe(404);
// Руководитель видит условия всех, агент — только свои.
expect(((await c.boss.api.get('/api/members')).body as any[]).map((m) => m.pay?.mode)).toEqual(['fixed', 'mixed', 'percent']);
expect(((await a1.api.get('/api/members')).body as any[]).map((m) => m.pay?.mode ?? null)).toEqual([null, 'mixed', null]);
// Сделки этого месяца: у первого агента $3000 и 385 000 драм (= $1000), у второго $2000.
const today = new Date(Date.now() + 4 * 3_600_000).toISOString().slice(0, 10);
const close = async (m: Member, commission: number, currency: string, date = today) => {
const id = await newLead(m);
await m.api.post(`/api/leads/${id}/status`, { statusId: p.status('deal'), deal: { commission, currency, date } });
};
await close(a1, 3000, 'USD');
await close(a1, 385_000, 'AMD');
await close(a2, 2000, 'USD');
// Сделка прошлого года в текущий месяц не попадает.
await close(a2, 9000, 'USD', '2020-01-15');
const month = (await c.boss.api.get('/api/payroll')).body;
expect(month.months).toBe(1);
const row = (id: number) => month.agents.find((x: any) => x.userId === id);
// Оклад $300 + 30 % от $4000 = $1500.
expect(row(a1.id)).toMatchObject({ deals: 2, commission: 4000, currency: 'USD', fixed: 300, share: 1200, total: 1500, byCurrency: { USD: 3000, AMD: 385_000 } });
// 50 % от $2000, выплата в драмах: 385 000.
expect(row(a2.id)).toMatchObject({ deals: 1, commission: 770_000, currency: 'AMD', fixed: 0, share: 385_000, total: 385_000 });
expect(row(c.boss.id)).toMatchObject({ deals: 0, fixed: 1000, share: 0, total: 1000 });
// Свой период: полгода 2020-го — шесть окладов и старая сделка.
const old = (await c.boss.api.get('/api/payroll?from=2020-01-01&to=2020-06-30')).body;
expect(old.months).toBe(6);
expect(old.agents.find((x: any) => x.userId === a1.id)).toMatchObject({ deals: 0, fixed: 1800, total: 1800 });
expect(old.agents.find((x: any) => x.userId === a2.id)).toMatchObject({ deals: 1, share: 1_732_500 });
// Агент видит свои условия и начисления, чужие выплаты — нет.
expect((await a1.api.get('/api/payroll')).status).toBe(403);
expect((await a1.api.get('/api/my-pay')).body).toMatchObject({ userId: a1.id, pay: { mode: 'mixed' }, deals: 2, total: 1500 });
// Условия сняли — начислений нет.
await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: null });
expect((await a1.api.get('/api/my-pay')).body).toMatchObject({ pay: null, total: 0, deals: 2 });
});
it('комиссия из двух частей: с собственника — агенту объекта, с клиента — агенту клиента; процент аренды свой', async () => {
const c = await company();
const [a1, a2] = c.agents as [Member, Member];
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
const po = await pipeline(c.boss, 'object');
const pc = await pipeline(c.boss, 'client');
await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'percent', percent: 40, percentRent: 50, currency: 'USD' } });
await c.boss.api.post(`/api/members/${a2.id}/pay`, { pay: { mode: 'percent', percent: 30, currency: 'USD' } });
const today = new Date(Date.now() + 4 * 3_600_000).toISOString().slice(0, 10);
// Объект в аренду ведёт первый агент, арендатора — второй. Сделку закрывает второй.
const leadId = await newLead(a1, { dealType: 'rent', price: 1000 });
const clientId = await newClient(a2, { kind: 'tenant' });
await a2.api.post(`/api/clients/${clientId}/leads`, { leadId });
const body = { statusId: pc.status('deal'), deal: { ownerCommission: 400, clientCommission: 600, currency: 'USD', date: today, leadId } };
// Сделка без комиссии не закрывается.
expect((await a2.api.post(`/api/clients/${clientId}/status`, { ...body, deal: { ...body.deal, ownerCommission: 0, clientCommission: 0 } })).body.code).toBe('COMMISSION_REQUIRED');
expect((await a2.api.post(`/api/clients/${clientId}/status`, body)).status).toBe(200);
expect((await a1.api.get(`/api/leads/${leadId}`)).body.deal).toMatchObject({
commission: 1000, ownerCommission: 400, clientCommission: 600, ownerAgentName: 'Агент 1', clientAgentName: 'Агент 2', kind: 'rent',
});
const rows = async () => (await c.boss.api.get('/api/payroll')).body.agents as any[];
const row = async (id: number) => (await rows()).find((x) => x.userId === id);
// Первому — 50 % (аренда) от $400, второму — 30 % (общий процент) от $600.
expect(await row(a1.id)).toMatchObject({ deals: 1, commission: 400, commissionRent: 400, commissionSale: 0, shareRent: 200, share: 200, total: 200 });
expect(await row(a2.id)).toMatchObject({ deals: 1, commission: 600, share: 180, total: 180 });
// Список сделок агента: руководителю — любого, агенту — только свои.
const list = (await c.boss.api.get(`/api/payroll/deals?userId=${a1.id}`)).body;
expect(list.deals).toEqual([
expect.objectContaining({ kind: 'rent', side: 'owner', partner: 'Агент 2', leadId, clientId, clientName: 'Анна', dealCommission: 1000, part: 400, percent: 50, share: 200 }),
]);
const own = (await a2.api.get(`/api/payroll/deals?userId=${a1.id}`)).body;
expect(own).toMatchObject({ userId: a2.id, deals: [{ side: 'client', partner: 'Агент 1', part: 600, percent: 30, share: 180 }] });
// В аналитике сделка одна, комиссия разнесена по агентам.
const an = (await c.boss.api.get(`/api/analytics?pipeline=object&from=${today}&to=${today}`)).body;
expect(an.totals).toMatchObject({ deals: 1, commission: { USD: 1000 } });
expect(an.agents.find((x: any) => x.userId === a1.id)).toMatchObject({ deals: 1, commission: { USD: 400 } });
expect(an.agents.find((x: any) => x.userId === a2.id)).toMatchObject({ deals: 1, commission: { USD: 600 } });
// Руководитель правит части; сумма одной строкой остаётся на прежней стороне.
const dealId = list.deals[0].id;
expect((await a1.api.patch(`/api/deals/${dealId}`, { ownerCommission: 1, clientCommission: 1, currency: 'USD', date: today })).status).toBe(403);
expect((await c.boss.api.patch(`/api/deals/${dealId}`, { ownerCommission: 500, clientCommission: 700, currency: 'USD', date: today })).status).toBe(200);
expect(await row(a1.id)).toMatchObject({ commission: 500, share: 250 });
expect(await row(a2.id)).toMatchObject({ commission: 700, share: 210 });
// Объект вышел из сделки — запись остаётся за клиентом, обе части получает его агент.
await c.boss.api.post(`/api/leads/${leadId}/status`, { statusId: po.status('taken') });
expect(await row(a1.id)).toMatchObject({ deals: 0, commission: 0, total: 0 });
expect(await row(a2.id)).toMatchObject({ deals: 1, commission: 1200, share: 360 });
// Продажа одной суммой, как раньше: вся комиссия — агенту, который закрыл свой объект.
const sale = await newLead(a1);
await a1.api.post(`/api/leads/${sale}/status`, { statusId: po.status('deal'), deal: { commission: 2000, currency: 'USD', date: today } });
expect(await row(a1.id)).toMatchObject({ deals: 1, commissionSale: 2000, commissionRent: 0, shareSale: 800, total: 800 });
});
it('план и бонус, ручные начисления, «выплачено»: закрытый месяц не пересчитывается, разница уходит в следующий', async () => {
const c = await company(1);
const [a1] = c.agents as [Member];
const po = await pipeline(c.boss, 'object');
const ym = (back: number) => {
const d = new Date(Date.now() + 4 * 3_600_000);
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - back, 1)).toISOString().slice(0, 7);
};
const [m1, m2, m3] = [ym(2), ym(1), ym(0)];
const range = (m: string) => {
const [y, mm] = m.split('-').map(Number) as [number, number];
return `from=${m}-01&to=${m}-${new Date(Date.UTC(y, mm, 0)).getUTCDate()}`;
};
const row = async (m: string) => ((await c.boss.api.get(`/api/payroll?${range(m)}`)).body.agents as any[]).find((x) => x.userId === a1.id);
const close = async (commission: number, month: string) => {
const id = await newLead(a1);
await a1.api.post(`/api/leads/${id}/status`, { statusId: po.status('deal'), deal: { commission, currency: 'USD', date: `${month}-10` } });
};
const terms = { mode: 'mixed', fixed: 300, percent: 30, currency: 'USD', planDeals: 2, planCommission: 3000, bonusMode: 'fixed', bonusValue: 200 };
await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: terms });
// Одна сделка — план не выполнен; две на $3500 — выполнен, бонус $200.
await close(2000, m1);
expect(await row(m1)).toMatchObject({ deals: 1, share: 600, planDone: false, bonus: 0, total: 900 });
await close(1500, m1);
expect(await row(m1)).toMatchObject({ deals: 2, commission: 3500, share: 1050, planDone: true, bonus: 200, accrued: 1550, total: 1550 });
// Ручные записи: премия и аванс. Агент их не ставит.
const adjust = (m: Member, amount: number, note: string, month = m1) => m.api.post('/api/payroll/adjustments', { userId: a1.id, month, amount, note });
expect((await adjust(a1, 500, 'себе')).status).toBe(403);
expect((await adjust(c.boss, 100, 'премия')).status).toBe(200);
const advance = (await adjust(c.boss, -50, 'аванс')).body.id;
expect((await adjust(c.boss, 0, 'ноль')).body.code).toBe('VALIDATION');
expect((await adjust(c.boss, 10, 'будущее', '2999-01')).body.code).toBe('VALIDATION');
expect(await row(m1)).toMatchObject({ adjustmentsTotal: 50, total: 1600, payout: null, adjustments: [{ amount: 100, note: 'премия' }, { amount: -50, note: 'аванс' }] });
// «Выплачено»: месяц закрыт, ручные записи и повторная отметка — нельзя.
const pay = (month: string, extra: object = {}) => c.boss.api.post('/api/payroll/payouts', { userId: a1.id, month, ...extra });
expect((await a1.api.post('/api/payroll/payouts', { userId: a1.id, month: m1 })).status).toBe(403);
const first = (await pay(m1, { paidAt: `${m2}-05` })).body.id;
expect(await row(m1)).toMatchObject({ total: 1600, debt: 0, payout: { id: first, amount: 1600, currency: 'USD', paidAt: `${m2}-05`, last: true } });
expect((await pay(m1)).body.code).toBe('MONTH_PAID');
expect((await adjust(c.boss, 10, 'поздно')).body.code).toBe('MONTH_PAID');
expect((await c.boss.api.del(`/api/payroll/adjustments/${advance}`)).body.code).toBe('MONTH_PAID');
// Условия поменяли — закрытый месяц считается по прежним.
await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { ...terms, percent: 50 } });
expect(await row(m1)).toMatchObject({ pay: { percent: 30 }, share: 1050, total: 1600, debt: 0 });
expect(await row(m2)).toMatchObject({ pay: { percent: 50 }, fixed: 300, recalc: [], total: 300 });
// Сделку закрытого месяца исправили: +$1000 комиссии = +$300 агенту. Разница — в следующем месяце.
const list = (await c.boss.api.get(`/api/payroll/deals?userId=${a1.id}&${range(m1)}`)).body.deals as any[];
await c.boss.api.patch(`/api/deals/${list[1].id}`, { commission: 2500, currency: 'USD', date: `${m1}-10` });
expect(await row(m1)).toMatchObject({ share: 1350, total: 1900, debt: 300, payout: { amount: 1600 } });
expect(await row(m2)).toMatchObject({ recalc: [{ month: m1, amount: 300 }], recalcTotal: 300, total: 600 });
// Заплатили меньше начисленного — остаток идёт дальше; долг первого месяца погашен.
const second = (await pay(m2, { amount: 500, note: 'частично' })).body.id;
expect(await row(m1)).toMatchObject({ debt: 0, payout: { last: false } });
expect(await row(m2)).toMatchObject({ total: 600, debt: 100, payout: { amount: 500, note: 'частично', last: true } });
expect(await row(m3)).toMatchObject({ recalc: [{ month: m2, amount: 100 }], total: 400 });
// История выплат: видна руководителю и самому агенту.
expect((await a1.api.get('/api/payroll/payouts')).body).toMatchObject([{ month: m2, amount: 500, accrued: 600 }, { month: m1, amount: 1600, accrued: 1600 }]);
expect(((await c.boss.api.get(`/api/payroll/payouts?userId=${a1.id}`)).body as any[]).length).toBe(2);
// Отменить можно только последнюю отметку; после отмены разница снова ждёт в открытом месяце.
expect((await c.boss.api.del(`/api/payroll/payouts/${first}`)).body.code).toBe('PAYOUT_NOT_LAST');
expect((await c.boss.api.del(`/api/payroll/payouts/${second}`)).status).toBe(200);
expect(await row(m2)).toMatchObject({ payout: null, recalc: [{ month: m1, amount: 300 }], total: 600 });
expect(await row(m3)).toMatchObject({ recalc: [], total: 300 });
// Надбавка к проценту за план — на всю комиссию агента за месяц: 10 % от $4000.
await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { ...terms, planDeals: 1, planCommission: null, bonusMode: 'percent', bonusValue: 10 } });
await close(4000, m3);
expect(await row(m3)).toMatchObject({ share: 1200, planDone: true, bonus: 400, total: 1900 });
// Рейтинг виден агенту: место, сделки, выполнение плана — без денег.
const board = (await a1.api.get(`/api/rating?month=${m3}`)).body;
expect(board.agents[0]).toEqual({ place: 1, userId: a1.id, name: 'Агент 1', deals: 1, planPercent: 100 });
expect(board.agents[1]).toMatchObject({ place: 2, userId: c.boss.id, deals: 0, planPercent: null });
// Динамика: руководителю — по всем, агенту — только своя; прошлые месяцы на месте.
const trend = (await c.boss.api.get('/api/payroll/trend?months=3')).body;
expect(trend.months).toEqual([m1, m2, m3]);
expect(trend.agents.find((x: any) => x.userId === a1.id).points).toEqual([{ deals: 2, commission: 4500 }, { deals: 0, commission: 0 }, { deals: 1, commission: 4000 }]);
expect(trend.totals.map((x: any) => x.deals)).toEqual([2, 0, 1]);
expect(((await a1.api.get('/api/payroll/trend?months=3')).body.agents as any[]).map((x) => x.userId)).toEqual([a1.id]);
// Отчёты в Telegram: выключены — не уходят; включены — раз за период, первого числа и в понедельник после 9 утра.
const reports = async () => (await t.db.select().from(outbox).where(eq(outbox.userId, c.boss.id))).map((j) => (j.payload as any).raw as string).filter(Boolean);
const firstDay = new Date(`${m3}-01T06:00:00Z`);
expect(await runReportJobs(t.db, t.config, firstDay)).toBe(0);
await c.boss.api.patch('/api/company', { reportMonthly: true, reportWeekly: true });
expect(await runReportJobs(t.db, t.config, new Date(`${m3}-01T03:00:00Z`))).toBe(0);
expect(await runReportJobs(t.db, t.config, firstDay)).toBeGreaterThanOrEqual(1);
expect(await runReportJobs(t.db, t.config, firstDay)).toBe(0);
const monthly = (await reports()).find((x) => x.startsWith('Итоги месяца'))!;
expect(monthly).toContain('К выплате:');
expect(monthly).toContain('• Агент 1 — $600');
expect(monthly).toContain('/team');
// Настройки компании отметку об отправке не стирают.
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
expect(await runReportJobs(t.db, t.config, firstDay)).toBe(0);
const monday = new Date(`${m3}-01T06:00:00Z`);
monday.setUTCDate(monday.getUTCDate() + ((8 - monday.getUTCDay()) % 7 || 7));
expect(await runReportJobs(t.db, t.config, monday)).toBe(1);
expect((await reports()).some((x) => x.startsWith('Итоги недели'))).toBe(true);
// Свои начисления за прошлый месяц.
expect((await a1.api.get(`/api/my-pay?month=${m1}`)).body).toMatchObject({ userId: a1.id, deals: 2, payout: { amount: 1600 } });
});
});
+427
View File
@@ -0,0 +1,427 @@
import { CLIENT_KINDS, ClientRequest, CommissionRule, CURRENCIES, fitsHousehold, Household, matchesClientRequest, RENT_TERMS, type Currency, type DealType } from '@lr/shared';
import { and, desc, eq, ilike, inArray, isNull, notInArray, or, sql, type SQL } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Db } from '../db/client.ts';
import { clientLeads, clientPages, clientPhotos, clients, companies, deals, departments, events, leads, pipelineStatuses, users } from '../db/schema.ts';
import type { Config } from '../lib/config.ts';
import { commissionDelta, commissionNote, commissionView, ruleCode } from '../lib/commission.ts';
import { clientViewings, matchingLeads, pageUrl } from '../showcase/service.ts';
import { DealBody, dealInput, dealText, dealView } from '../deals/service.ts';
import type { Guards } from '../leads/routes.ts';
import { canSee, inDepartment, type Actor } from '../leads/service.ts';
import { badRequest, conflict, forbidden, notFound } from '../lib/errors.ts';
import { onlySent } from '../lib/patch.ts';
import {
addClientComment,
assignClient,
changeClientStatus,
createClient,
deleteClient,
linkLead,
loadClient,
setClientNextAction,
unlinkLead,
updateClient,
type Client,
} from './service.ts';
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 ClientBody = z.object({
name: z.string().trim().min(1).max(120),
phone: optionalText(40),
telegram: optionalText(64),
rentTerm: z.enum(RENT_TERMS).nullable().optional(),
kind: z.enum(CLIENT_KINDS),
request: ClientRequest.partial().default({}),
household: Household.nullable().optional(),
source: optionalText(120),
});
/** Фото к карточке клиента: до 10 штук, каждое до 5 МБ. */
const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
const MAX_PHOTO = 5 * 1024 * 1024;
const MAX_PHOTOS = 10;
export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config) {
type Ctx = ReturnType<Guards['member']>;
const actor = (ctx: Ctx): Actor => ctx;
/** Телефон и Telegram клиента видны ответственному и руководителю. */
const present = (ctx: Ctx, c: Client, assigneeName?: string | null) => {
const own = ctx.role === 'manager' || c.assigneeId === ctx.user.id;
return {
...c, phone: own ? c.phone : null, telegram: own ? c.telegram : null,
phoneHidden: (Boolean(c.phone) || Boolean(c.telegram)) && !own, assigneeName: assigneeName ?? null,
};
};
app.get('/api/clients', async (req) => {
const ctx = g.member(req);
const q = z
.object({
tab: z.enum(['mine', 'all']).default('mine'),
statusId: z.coerce.number().int().optional(),
assigneeId: z.coerce.number().int().optional(),
kind: z.enum(CLIENT_KINDS).optional(),
q: z.string().trim().max(100).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 where: (SQL | undefined)[] = [
eq(clients.companyId, ctx.company.id),
// Сотрудник видит своих клиентов и клиентов своего отдела.
ctx.role === 'manager' ? undefined : or(eq(clients.assigneeId, ctx.user.id), inDepartment(clients.departmentId, ctx.user.departmentId)),
];
if (q.departmentId !== undefined && ctx.role === 'manager') where.push(inDepartment(clients.departmentId, q.departmentId === 'none' ? null : q.departmentId));
if (q.tab === 'mine') where.push(eq(clients.assigneeId, ctx.user.id));
if (q.statusId) where.push(eq(clients.statusId, q.statusId));
if (q.assigneeId && q.tab === 'all') where.push(eq(clients.assigneeId, q.assigneeId));
if (q.kind) where.push(eq(clients.kind, q.kind));
if (q.q) {
const like = `%${q.q.replace(/[%_\\]/g, '\\$&')}%`;
const digits = q.q.replace(/\D/g, '');
where.push(
or(
ilike(clients.name, like),
// По телефону агент находит только своих клиентов — чужие номера скрыты.
digits.length >= 4
? and(ilike(clients.phone, `%${digits}%`), ctx.role === 'manager' ? undefined : eq(clients.assigneeId, ctx.user.id))
: undefined,
),
);
}
const rows = await db
.select({
client: clients,
assigneeName: users.name,
departmentName: departments.name,
leadsCount: sql<number>`(select count(*)::int from client_leads cl where cl.client_id = "clients"."id")`,
})
.from(clients)
.leftJoin(users, eq(users.id, clients.assigneeId))
.leftJoin(departments, eq(departments.id, clients.departmentId))
.where(and(...where))
.orderBy(desc(clients.createdAt))
.limit(300);
return rows.map((r) => ({ ...present(ctx, r.client, r.assigneeName), departmentName: r.departmentName, leadsCount: r.leadsCount }));
});
app.get('/api/clients-summary', async (req) => {
const ctx = g.member(req);
const [row] = await db
.select({
mine: sql<number>`count(*) filter (where ${clients.assigneeId} = ${ctx.user.id})::int`,
all: sql<number>`count(*)::int`,
})
.from(clients)
.where(
and(
eq(clients.companyId, ctx.company.id),
ctx.role === 'manager' ? undefined : or(eq(clients.assigneeId, ctx.user.id), inDepartment(clients.departmentId, ctx.user.departmentId)),
),
);
return row ?? { mine: 0, all: 0 };
});
/** Объекты, прикреплённые к клиенту; чужие лиды, невидимые агенту, в список не попадают. */
const linkedLeads = async (ctx: Ctx, clientId: number) => {
const rows = await db
.select({ link: clientLeads, lead: leads, assigneeName: users.name })
.from(clientLeads)
.innerJoin(leads, eq(leads.id, clientLeads.leadId))
.leftJoin(users, eq(users.id, leads.assigneeId))
.where(eq(clientLeads.clientId, clientId))
.orderBy(desc(clientLeads.createdAt));
return rows
.filter((r) => canSee(actor(ctx), r.lead))
.map(({ link, lead, assigneeName }) => ({
id: lead.id, title: lead.title, price: lead.price, currency: lead.currency, dealType: lead.dealType,
district: lead.district, region: lead.region, address: lead.address, photo: lead.photos[0] ?? null,
statusId: lead.statusId, assigneeId: lead.assigneeId, assigneeName, state: link.state, shownAt: link.shownAt,
}));
};
app.get('/api/clients/:id', async (req) => {
const ctx = g.member(req);
const { id } = Id.parse(req.params);
const client = await loadClient(db, actor(ctx), id);
const photos = await db
.select({ id: clientPhotos.id, createdAt: clientPhotos.createdAt })
.from(clientPhotos)
.where(eq(clientPhotos.clientId, id))
.orderBy(clientPhotos.id);
const [history, assignee, linked, deal] = await Promise.all([
db
.select({ event: events, userName: users.name })
.from(events)
.leftJoin(users, eq(users.id, events.userId))
.where(eq(events.clientId, id))
.orderBy(desc(events.at), desc(events.id)),
db.select({ name: users.name }).from(users).where(eq(users.id, client.assigneeId)),
linkedLeads(ctx, id),
db
.select({ deal: deals, leadTitle: leads.title })
.from(deals)
.leftJoin(leads, eq(leads.id, deals.leadId))
.where(eq(deals.clientId, 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 d = deal[0];
const [page] = await db
.select({ token: clientPages.token, slug: companies.slug })
.from(clientPages)
.innerJoin(companies, eq(companies.id, clientPages.companyId))
.where(and(eq(clientPages.clientId, id), isNull(clientPages.revokedAt)));
return {
...present(ctx, client, assignee[0]?.name),
photos,
/** Комиссия агентства с клиента: условие и ожидаемая сумма от его бюджета. */
commission: commissionView(
ctx.company.settings.commission, client.kind === 'tenant' ? 'clientTenant' : 'clientBuyer', client.commission,
client.request.priceTo, client.request.priceCurrency ?? 'USD',
),
/** Страница подборки для клиента, если ссылка создана. */
pageUrl: page?.slug ? pageUrl(config, ctx.company, page.slug, page.token) : null,
viewings: await clientViewings(db, id),
leads: linked,
deal: d ? { ...dealView(d.deal, names), leadId: d.deal.leadId, leadTitle: d.leadTitle } : null,
events: history.map(({ event: e, userName }) => ({
id: e.id, type: e.type, at: e.at, userName,
oldValue: e.type === 'assigned' ? byId(e.oldValue) : e.oldValue,
newValue: e.type === 'assigned' ? byId(e.newValue) : e.newValue,
text: e.text,
})),
};
});
/**
* Подбор: объекты компании, подходящие под запрос клиента и ещё не прикреплённые к нему.
* Закрытые сделкой и отказом не предлагаются.
*/
app.get('/api/clients/:id/suggestions', async (req) => {
const ctx = g.member(req);
const { id } = Id.parse(req.params);
const client = await loadClient(db, actor(ctx), id);
return (await matchingLeads(db, actor(ctx), client))
.slice(0, 30)
.map(({ lead, assigneeName }) => ({
id: lead.id, title: lead.title, price: lead.price, currency: lead.currency, dealType: lead.dealType,
district: lead.district, region: lead.region, address: lead.address, photo: lead.photos[0] ?? null,
statusId: lead.statusId, assigneeId: lead.assigneeId, assigneeName,
}));
});
/** Условие комиссии для этого клиента; null возвращает условие из настроек компании. */
app.post('/api/clients/: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 client = await loadClient(db, actor(ctx), id);
if (ctx.role !== 'manager' && client.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_CLIENT');
const before = ruleCode(client.commission);
const after = ruleCode(rule);
if (before === after) return { ok: true };
await db.transaction(async (tx) => {
await tx.update(clients).set({ commission: rule }).where(eq(clients.id, id));
const settings = ctx.company.settings.commission?.[client.kind === 'tenant' ? 'clientTenant' : 'clientBuyer'];
await tx.insert(events).values({
companyId: ctx.company.id, clientId: id, userId: ctx.user.id, type: 'commission',
oldValue: before ?? ruleCode(settings), newValue: after,
text: commissionNote(
after ? null : ruleCode(settings),
commissionDelta(client.commission ?? settings, rule ?? settings, client.request.priceTo, client.request.priceCurrency ?? 'USD'),
),
});
});
return { ok: true };
});
// --- фото к карточке клиента ---
const ownClient = async (ctx: Ctx, id: number) => {
const client = await loadClient(db, actor(ctx), id);
if (ctx.role !== 'manager' && client.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_CLIENT');
return client;
};
/** Файл приходит телом запроса как есть (application/octet-stream), тип картинки — в адресе. */
app.post('/api/clients/:id/photos', { bodyLimit: MAX_PHOTO + 1024 }, async (req) => {
const ctx = g.writable(g.member(req));
const { id } = Id.parse(req.params);
const { type } = z.object({ type: z.string() }).parse(req.query);
if (!PHOTO_TYPES.includes(type)) throw badRequest('PHOTO_TYPE');
if (!Buffer.isBuffer(req.body) || req.body.length === 0) throw badRequest('VALIDATION');
if (req.body.length > MAX_PHOTO) throw badRequest('FILE_TOO_LARGE');
await ownClient(ctx, id);
const [{ n } = { n: 0 }] = await db.select({ n: sql<number>`count(*)::int` }).from(clientPhotos).where(eq(clientPhotos.clientId, id));
if (n >= MAX_PHOTOS) throw conflict('PHOTO_LIMIT');
const [photo] = await db
.insert(clientPhotos)
.values({ clientId: id, companyId: ctx.company.id, contentType: type, size: req.body.length, data: req.body.toString('base64'), uploadedById: ctx.user.id })
.returning({ id: clientPhotos.id });
return { id: photo!.id };
});
app.get('/api/clients/:id/photos/:photoId', async (req, reply) => {
const ctx = g.member(req);
const { id } = Id.parse(req.params);
const { photoId } = z.object({ photoId: z.coerce.number().int().positive() }).parse(req.params);
await loadClient(db, actor(ctx), id);
const [photo] = await db.select().from(clientPhotos).where(and(eq(clientPhotos.id, photoId), eq(clientPhotos.clientId, id)));
if (!photo) throw notFound();
return reply
.header('content-type', photo.contentType)
.header('cache-control', 'private, max-age=86400')
.send(Buffer.from(photo.data, 'base64'));
});
app.delete('/api/clients/:id/photos/:photoId', async (req) => {
const ctx = g.writable(g.member(req));
const { id } = Id.parse(req.params);
const { photoId } = z.object({ photoId: z.coerce.number().int().positive() }).parse(req.params);
await ownClient(ctx, id);
await db.delete(clientPhotos).where(and(eq(clientPhotos.id, photoId), eq(clientPhotos.clientId, id)));
return { ok: true };
});
app.post('/api/clients', async (req) => {
const ctx = g.writable(g.member(req));
// Кто создал клиента, тот и ответственный; передать другому можно в карточке.
const client = await createClient(db, actor(ctx), ClientBody.parse(req.body));
return { id: client.id };
});
app.patch('/api/clients/:id', async (req) => {
const ctx = g.writable(g.member(req));
// Пустые телефон, Telegram, срок и источник стираются, а не пропускаются.
const body = onlySent(
req.body,
ClientBody.partial()
.extend({
phone: z.string().trim().max(40).optional(),
telegram: z.string().trim().max(64).optional(),
source: z.string().trim().max(120).optional(),
})
.parse(req.body),
);
await updateClient(db, actor(ctx), Id.parse(req.params).id, {
...body,
...(body.source !== undefined ? { source: body.source || null } : {}),
});
return { ok: true };
});
/** Удалить карточку клиента: руководитель; со сделкой — только суперадмин. */
app.delete('/api/clients/:id', async (req) => {
const ctx = g.writable(g.member(req));
await deleteClient(db, actor(ctx), Id.parse(req.params).id);
return { ok: true };
});
app.post('/api/clients/: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({ leadId: z.number().int().positive().optional() }).optional(),
})
.parse(req.body);
await changeClientStatus(db, actor(ctx), Id.parse(req.params).id, body);
return { ok: true };
});
app.post('/api/clients/:id/assign', async (req) => {
const ctx = g.writable(g.manager(req));
const { userId } = z.object({ userId: z.number().int().positive() }).parse(req.body);
await assignClient(db, actor(ctx), Id.parse(req.params).id, userId);
return { ok: true };
});
app.post('/api/clients/: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 addClientComment(db, actor(ctx), Id.parse(req.params).id, text);
return { ok: true };
});
app.post('/api/clients/: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 setClientNextAction(db, actor(ctx), Id.parse(req.params).id, body.at, body.note || null);
return { ok: true };
});
app.post('/api/clients/:id/leads', async (req) => {
const ctx = g.writable(g.member(req));
const body = z
.object({ leadId: z.number().int().positive(), state: z.enum(['offered', 'shown']).default('offered') })
.parse(req.body);
await linkLead(db, actor(ctx), Id.parse(req.params).id, body.leadId, body.state);
return { ok: true };
});
app.delete('/api/clients/:id/leads/:leadId', async (req) => {
const ctx = g.writable(g.member(req));
const p = Id.extend({ leadId: z.coerce.number().int().positive() }).parse(req.params);
await unlinkLead(db, actor(ctx), p.id, p.leadId);
return { ok: true };
});
/** Части комиссии и дату сделки может исправить руководитель. */
app.patch('/api/deals/:id', async (req) => {
const ctx = g.writable(g.manager(req));
const { id } = Id.parse(req.params);
const body = DealBody.parse(req.body);
await db.transaction(async (tx) => {
const [deal] = await tx
.select()
.from(deals)
.where(and(eq(deals.id, id), eq(deals.companyId, ctx.company.id)))
.for('update');
if (!deal) throw notFound();
// Сумма без разбивки остаётся на той стороне, где была.
const next = dealInput(body, deal.ownerCommission > 0 || deal.clientCommission === 0 ? 'owner' : 'client');
await tx
.update(deals)
.set({ commission: next.commission, ownerCommission: next.ownerCommission, clientCommission: next.clientCommission, currency: next.currency, dealDate: next.date })
.where(eq(deals.id, id));
const base = { companyId: ctx.company.id, userId: ctx.user.id, type: 'deal_edited', text: dealText(next) };
if (deal.leadId) await tx.insert(events).values({ ...base, leadId: deal.leadId });
if (deal.clientId) await tx.insert(events).values({ ...base, clientId: deal.clientId });
});
return { ok: true };
});
/** Клиенты агента, к которым можно прикрепить объект: из карточки объекта. */
app.get('/api/clients-options', async (req) => {
const ctx = g.member(req);
const rows = await db
.select({ id: clients.id, name: clients.name, kind: clients.kind, code: pipelineStatuses.code })
.from(clients)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, clients.statusId))
.where(
and(
eq(clients.companyId, ctx.company.id),
ctx.role === 'manager' ? undefined : eq(clients.assigneeId, ctx.user.id),
or(isNull(pipelineStatuses.code), eq(pipelineStatuses.code, 'new')),
),
)
.orderBy(desc(clients.createdAt))
.limit(300);
return rows.map(({ id, name, kind }) => ({ id, name, kind }));
});
}
+305
View File
@@ -0,0 +1,305 @@
import { normalizePhone, type ClientKind, type ClientRequest, type Household, type RentTerm } from '@lr/shared';
import { and, eq, isNull, sql } from 'drizzle-orm';
import type { Db, Tx } from '../db/client.ts';
import { clientLeads, clients, companies, deals, events, rejectReasons, users } from '../db/schema.ts';
import { dealInput, dealText, dropDealSide, moveLeadToDeal, recordDeal, type DealBody, type DealInput } from '../deals/service.ts';
import { loadLead, sameDepartment, type Actor } from '../leads/service.ts';
import { badRequest, forbidden, notFound } from '../lib/errors.ts';
import { pipelineOf, statusByCode } from '../pipelines/service.ts';
import { refreshStaleGallery } from '../showcase/service.ts';
import { enqueueText } from '../telegram/outbox.ts';
// Воронка клиента (покупатель или арендатор). Клиента агент заводит вручную, он закреплён
// за агентом: правила пула на клиентов не действуют, переназначает только руководитель.
export type Client = typeof clients.$inferSelect;
const isManager = (a: Actor) => a.role === 'manager';
/**
* Клиента видят руководитель и ответственный; остальные агенты — при открытой видимости и только
* клиентов своего отдела.
*/
export function canSeeClient(actor: Actor, client: Client): boolean {
if (client.companyId !== actor.company.id) return false;
if (isManager(actor) || client.assigneeId === actor.user.id) return true;
return actor.company.settings.agentsSeeAll && sameDepartment(client.departmentId, actor.user.departmentId);
}
function assertCanWork(actor: Actor, client: Client) {
if (!isManager(actor) && client.assigneeId !== actor.user.id) throw forbidden('NOT_YOUR_CLIENT');
}
export async function loadClient(tx: Tx, actor: Actor, clientId: number): Promise<Client> {
const [client] = await tx
.select()
.from(clients)
.where(and(eq(clients.id, clientId), eq(clients.companyId, actor.company.id)));
if (!client || !canSeeClient(actor, client)) throw notFound('CLIENT_NOT_FOUND');
return client;
}
async function logEvent(
tx: Tx,
client: Pick<Client, '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: client.companyId, clientId: client.id, userId, type, ...extra });
}
const touchCompany = (tx: Tx, companyId: number) =>
tx.update(companies).set({ lastActivityAt: new Date() }).where(eq(companies.id, companyId));
async function member(tx: Tx, companyId: number, userId: number) {
const [user] = await tx
.select()
.from(users)
.where(and(eq(users.id, userId), eq(users.companyId, companyId), isNull(users.removedAt)));
if (!user) throw badRequest('BAD_ASSIGNEE');
return user;
}
/** Telegram клиента в одном виде: @ник; ссылка t.me и ник без @ приводятся к нему. */
export function normalizeTelegram(raw: string | null | undefined): string | null {
const v = raw?.trim();
if (!v) return null;
const nick = /^(?:https?:\/\/)?(?:t\.me|telegram\.me)\/([A-Za-z0-9_]{4,32})\/?$/.exec(v)?.[1] ?? /^@?([A-Za-z0-9_]{4,32})$/.exec(v)?.[1];
return nick ? `@${nick}` : v;
}
export interface ClientInput {
name: string;
phone?: string;
telegram?: string;
rentTerm?: RentTerm | null;
kind: ClientKind;
request: Partial<ClientRequest>;
/** Кто будет жить; null — сведения убраны. */
household?: Household | null;
source?: string;
}
export async function createClient(db: Db, actor: Actor, input: ClientInput): Promise<Client> {
return db.transaction(async (tx) => {
const fresh = await statusByCode(tx, actor.company.id, 'new', 'client');
const [client] = await tx
.insert(clients)
.values({
...input,
phone: normalizePhone(input.phone) ?? input.phone ?? null,
telegram: normalizeTelegram(input.telegram),
companyId: actor.company.id,
assigneeId: actor.user.id,
// Клиент попадает в отдел того, кто его завёл.
departmentId: actor.user.departmentId,
statusId: fresh.id,
})
.returning();
await logEvent(tx, client!, actor.user.id, 'created');
await touchCompany(tx, actor.company.id);
return client!;
});
}
export async function updateClient(
db: Db,
actor: Actor,
clientId: number,
patch: Partial<Omit<ClientInput, 'assigneeId' | 'source'>> & { source?: string | null },
) {
await db.transaction(async (tx) => {
const client = await loadClient(tx, actor, clientId);
assertCanWork(actor, client);
await tx
.update(clients)
.set({
...patch,
...(patch.phone !== undefined ? { phone: normalizePhone(patch.phone) ?? (patch.phone || null) } : {}),
...(patch.telegram !== undefined ? { telegram: normalizeTelegram(patch.telegram) } : {}),
})
.where(eq(clients.id, clientId));
await logEvent(tx, client, actor.user.id, 'edited');
await touchCompany(tx, actor.company.id);
});
}
/** Удаление карточки клиента вместе с историей и фото: как у объекта — руководитель, со сделкой — только суперадмин. */
export async function deleteClient(db: Db, actor: Actor, clientId: number) {
if (!isManager(actor)) throw forbidden();
await db.transaction(async (tx) => {
const client = await loadClient(tx, actor, clientId);
const [deal] = await tx.select({ id: deals.id }).from(deals).where(eq(deals.clientId, client.id));
if (deal) {
if (actor.user.role !== 'superadmin') throw forbidden('HAS_DEAL');
await dropDealSide(tx, 'client', client.id);
}
await tx.delete(events).where(eq(events.clientId, client.id));
await tx.delete(clients).where(eq(clients.id, client.id));
await touchCompany(tx, actor.company.id);
});
}
export interface ClientStatusChange {
statusId: number;
rejectReasonId?: number;
/** Обязательна при переводе в «Сделка». leadId — объект из CRM, с которым прошла сделка. */
deal?: DealBody & { leadId?: number };
}
export async function changeClientStatus(db: Db, actor: Actor, clientId: number, change: ClientStatusChange) {
await db.transaction(async (tx) => {
const client = await loadClient(tx, actor, clientId);
assertCanWork(actor, client);
const statuses = await pipelineOf(tx, actor.company.id, 'client');
const from = statuses.find((s) => s.id === client.statusId);
const to = statuses.find((s) => s.id === change.statusId && !s.deletedAt);
if (!to) throw badRequest('BAD_STATUS');
if (to.id === client.statusId) return;
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, 'client'),
),
)
: [];
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, 'client');
if (change.deal.leadId) {
// Объект пары должен быть виден агенту; в «Сделка» он переходит вместе с клиентом.
await loadLead(tx, actor, change.deal.leadId);
await moveLeadToDeal(tx, actor.company.id, actor.user.id, clientId, change.deal.leadId, deal);
}
await recordDeal(tx, {
companyId: actor.company.id,
leadId: change.deal.leadId ?? null,
clientId,
agentId: client.assigneeId,
deal,
});
} else if (from?.code === 'deal') {
await dropDealSide(tx, 'client', clientId);
}
await tx
.update(clients)
.set({ statusId: to.id, rejectReasonId, statusChangedAt: new Date() })
.where(eq(clients.id, clientId));
await logEvent(tx, client, 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);
});
}
/** Переназначить клиента может только руководитель. Оба агента получают уведомление. */
export async function assignClient(db: Db, actor: Actor, clientId: number, toUserId: number) {
if (!isManager(actor)) throw forbidden();
await db.transaction(async (tx) => {
const client = await loadClient(tx, actor, clientId);
if (client.assigneeId === toUserId) return;
const target = await member(tx, actor.company.id, toUserId);
// Клиент переходит в отдел нового ответственного.
await tx.update(clients).set({ assigneeId: toUserId, departmentId: target.departmentId }).where(eq(clients.id, clientId));
await logEvent(tx, client, actor.user.id, 'assigned', {
oldValue: String(client.assigneeId),
newValue: String(toUserId),
});
if (toUserId !== actor.user.id) await enqueueText(tx, [toUserId], 'clientAssigned', [client.name]);
if (client.assigneeId !== actor.user.id) {
await enqueueText(tx, [client.assigneeId], 'clientReassignedAway', [client.name, target.name]);
}
await touchCompany(tx, actor.company.id);
});
}
export async function addClientComment(db: Db, actor: Actor, clientId: number, text: string) {
await db.transaction(async (tx) => {
const client = await loadClient(tx, actor, clientId);
assertCanWork(actor, client);
await logEvent(tx, client, actor.user.id, 'comment', { text });
await touchCompany(tx, actor.company.id);
});
}
export async function setClientNextAction(db: Db, actor: Actor, clientId: number, at: Date | null, note: string | null) {
await db.transaction(async (tx) => {
const client = await loadClient(tx, actor, clientId);
assertCanWork(actor, client);
await tx
.update(clients)
.set({ nextActionAt: at, nextActionNote: note, nextActionNotifiedAt: null })
.where(eq(clients.id, clientId));
});
}
/**
* Прикрепить объект к клиенту: «предложен» или «показан». Повторный вызов меняет отметку.
* Событие пишется и в историю клиента, и в историю объекта.
*/
export async function linkLead(db: Db, actor: Actor, clientId: number, leadId: number, state: 'offered' | 'shown') {
await db.transaction(async (tx) => {
const client = await loadClient(tx, actor, clientId);
assertCanWork(actor, client);
const lead = await loadLead(tx, actor, leadId);
const [before] = await tx
.select()
.from(clientLeads)
.where(and(eq(clientLeads.clientId, clientId), eq(clientLeads.leadId, leadId)));
if (before?.state === state) return;
const shownAt = state === 'shown' ? new Date() : null;
await tx
.insert(clientLeads)
.values({ clientId, leadId, state, shownAt })
.onConflictDoUpdate({ target: [clientLeads.clientId, clientLeads.leadId], set: { state, shownAt } });
if (!before) await refreshStaleGallery(tx, [leadId]);
await logEvent(tx, client, actor.user.id, state, { newValue: lead.title });
await tx.insert(events).values({
companyId: actor.company.id, leadId, userId: actor.user.id, type: `client_${state}`, newValue: client.name,
});
await touchCompany(tx, actor.company.id);
});
}
export async function unlinkLead(db: Db, actor: Actor, clientId: number, leadId: number) {
await db.transaction(async (tx) => {
const client = await loadClient(tx, actor, clientId);
assertCanWork(actor, client);
const [gone] = await tx
.delete(clientLeads)
.where(and(eq(clientLeads.clientId, clientId), eq(clientLeads.leadId, leadId)))
.returning();
if (!gone) return;
const lead = await loadLead(tx, actor, leadId).catch(() => null);
await logEvent(tx, client, actor.user.id, 'unlinked', { newValue: lead?.title ?? null });
});
}
/** Напоминания о следующем действии по клиентам. Запускается раз в минуту вместе с задачами по лидам. */
export async function runClientJobs(db: Db, now = new Date()): Promise<{ reminded: number }> {
const due = await db
.update(clients)
.set({ nextActionNotifiedAt: now })
.where(and(sql`${clients.nextActionAt} <= ${now.toISOString()}::timestamptz`, isNull(clients.nextActionNotifiedAt)))
.returning();
for (const c of due) await enqueueText(db, [c.assigneeId], 'clientReminder', [c.name, c.nextActionNote]);
return { reminded: due.length };
}
+147
View File
@@ -0,0 +1,147 @@
import {
allowedBillingModels,
DEFAULT_GRACE_DAYS,
DEFAULT_REJECT_REASONS,
DEFAULT_STATUSES,
PIPELINES,
PLAN_SPECS,
POOL_RETURN_DEFAULT_HOURS,
type BillingModel,
type Lang,
type Plan,
} from '@lr/shared';
import { and, count, eq, isNull, sql } from 'drizzle-orm';
import type { Db, Tx } from '../db/client.ts';
import { companies, invites, pipelineStatuses, rejectReasons, users } from '../db/schema.ts';
import { newToken } from '../auth/sessions.ts';
import { badRequest, conflict, notFound } from '../lib/errors.ts';
import { freeSlug } from '../showcase/service.ts';
export interface NewCompany {
name: string;
plan: Plan;
billingModel: BillingModel;
/** Язык, на котором создаются статусы и причины отказа по умолчанию. */
lang: Lang;
/** Суперадмин включает доступ сразу; при самостоятельной регистрации — после оплаты. */
accessEnabled?: boolean;
subscriptionEndsAt?: Date | null;
}
/** Создаёт компанию с воронками и причинами отказа по умолчанию. */
export async function createCompany(tx: Tx, input: NewCompany) {
if (!allowedBillingModels(input.plan).includes(input.billingModel)) {
throw badRequest('BILLING_MODEL_NOT_ALLOWED');
}
const [company] = await tx
.insert(companies)
.values({
name: input.name,
plan: input.plan,
billingModel: input.billingModel,
graceDays: DEFAULT_GRACE_DAYS,
// Компания, включённая суперадмином, сразу на модели «%»; при самостоятельном подключении — с оплаты.
percentFrom:
input.billingModel === 'percent' && input.subscriptionEndsAt
? new Date(Date.now() + 4 * 3_600_000).toISOString().slice(0, 10)
: null,
accessEnabled: input.accessEnabled ?? false,
subscriptionEndsAt: input.subscriptionEndsAt ?? null,
settings: {
agentsSeeAll: false,
poolReturnHours: POOL_RETURN_DEFAULT_HOURS,
agentRules: { useAgencyBadge: true, phoneThreshold: 3 },
},
})
.returning();
// Адрес страниц для клиентов — из названия компании; руководитель может поменять его в настройках.
company!.slug = await freeSlug(tx, company!.name, company!.id);
await tx.update(companies).set({ slug: company!.slug }).where(eq(companies.id, company!.id));
for (const pipeline of PIPELINES) {
await tx.insert(pipelineStatuses).values(
DEFAULT_STATUSES[pipeline].map((s, i) => ({
companyId: company!.id,
pipeline,
code: s.code ?? null,
name: s.name[input.lang],
position: i,
poolReturn: s.poolReturn ?? false,
})),
);
await tx.insert(rejectReasons).values(
DEFAULT_REJECT_REASONS[pipeline].map((r, i) => ({
companyId: company!.id,
pipeline,
code: r.code ?? null,
name: r[input.lang],
position: i,
})),
);
}
return company!;
}
const activeMembers = (companyId: number) =>
and(eq(users.companyId, companyId), isNull(users.removedAt));
export async function memberCount(tx: Tx, companyId: number): Promise<number> {
const [row] = await tx.select({ n: count() }).from(users).where(activeMembers(companyId));
return row?.n ?? 0;
}
export async function createInvite(db: Db, companyId: number, createdById: number) {
const [invite] = await db
.insert(invites)
.values({ token: newToken(), companyId, createdById })
.returning();
return invite!;
}
export interface JoinResult {
companyId: number;
/** После этого вступления компания упёрлась в лимит тарифа. */
limitReached: boolean;
}
/**
* Принимает приглашение: пользователь становится агентом компании.
* Лимит участников проверяется под блокировкой строки компании, чтобы два одновременных
* вступления не превысили тариф.
*/
export async function acceptInvite(db: Db, token: string, userId: number): Promise<JoinResult> {
return db.transaction(async (tx) => {
const [invite] = await tx.select().from(invites).where(eq(invites.token, token));
if (!invite || invite.revokedAt) throw notFound('INVITE_NOT_FOUND');
const [company] = await tx
.select()
.from(companies)
.where(eq(companies.id, invite.companyId))
.for('update');
if (!company) throw notFound('INVITE_NOT_FOUND');
const [user] = await tx.select().from(users).where(eq(users.id, userId));
if (!user) throw notFound();
if (user.companyId === company.id && !user.removedAt) {
return { companyId: company.id, limitReached: false };
}
if (user.companyId && !user.removedAt) throw conflict('ALREADY_IN_COMPANY');
if (user.role === 'superadmin') throw conflict('ALREADY_IN_COMPANY');
const max = PLAN_SPECS[company.plan as Plan].maxMembers;
const current = await memberCount(tx, company.id);
if (current >= max) throw conflict('MEMBER_LIMIT_REACHED');
await tx
.update(users)
.set({ companyId: company.id, role: invite.role, removedAt: null })
.where(eq(users.id, userId));
if (invite.role === 'manager') {
await tx.update(invites).set({ revokedAt: new Date() }).where(eq(invites.token, token));
}
await tx
.update(companies)
.set({ lastActivityAt: sql`now()` })
.where(eq(companies.id, company.id));
return { companyId: company.id, limitReached: current + 1 >= max };
});
}
+12
View File
@@ -0,0 +1,12 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.ts';
export function connect(url: string) {
const sql = postgres(url, { onnotice: () => {} });
return { db: drizzle(sql, { schema }), close: () => sql.end() };
}
export type Db = ReturnType<typeof connect>['db'];
/** Транзакция или обычное подключение — сервисы принимают оба. */
export type Tx = Parameters<Parameters<Db['transaction']>[0]>[0] | Db;
+14
View File
@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { connect } from './client.ts';
export const MIGRATIONS_DIR = fileURLToPath(new URL('../../drizzle', import.meta.url));
if (import.meta.filename === process.argv[1]) {
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL не задан');
const { db, close } = connect(url);
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
await close();
console.log('Миграции применены');
}
+785
View File
@@ -0,0 +1,785 @@
import type { AgentPay, AgentRules, CommissionRule, CommissionSettings, FilterParams, Household, ListingFeatures } from '@lr/shared';
import { sql } from 'drizzle-orm';
import {
bigint,
boolean,
date,
index,
integer,
jsonb,
numeric,
pgTable,
primaryKey,
serial,
text,
timestamp,
uniqueIndex,
} from 'drizzle-orm/pg-core';
// Схема данных (ТЗ, раздел 9). Данные компаний изолированы: у каждой бизнес-таблицы есть
// company_id, и каждый запрос API фильтруется по компании пользователя.
const ts = (name: string) => timestamp(name, { withTimezone: true });
const createdAt = ts('created_at').notNull().defaultNow();
const money = (name: string) => numeric(name, { precision: 14, scale: 2, mode: 'number' });
export interface CompanySettings {
/** «Агенты видят все лиды компании». */
agentsSeeAll: boolean;
/** Объявление сняли с List.am — открытые лиды по нему сами уходят в «Отказ» («Уже продано или сдано»). */
autoRejectRemoved?: boolean;
/** Через сколько часов без смены статуса взятый лид возвращается в пул. */
poolReturnHours: number;
agentRules: AgentRules;
/** Комиссия агентства по умолчанию: для объектов и клиентов, продажи и аренды. */
commission?: CommissionSettings;
/** Отчёты руководителю в Telegram: за неделю и за месяц. sent* — за какой период отчёт уже ушёл. */
reports?: { weekly: boolean; monthly: boolean; sentWeekly?: string; sentMonthly?: string };
/** Свой домен для ссылок клиентам (например, arev.yandp.tech); пусто — адрес сервиса. Задаёт суперадмин. */
clientHost?: string;
/**
* Бот компании, который присылает в её группу объекты, взятые в работу с указанной комиссией.
* Задаёт суперадмин; токен наружу не отдаётся.
*/
groupBot?: { token: string; chatId: number; /** Топик группы («Свободные объекты»); пусто — общий чат. */ threadId?: number };
/**
* Витрина на сайте BarevClub: по ключу сайт берёт объекты компании и присылает заявки на показ.
* threadId — топик группы, куда бот группы пишет о новой заявке. Задаёт суперадмин; ключ наружу не отдаётся.
*/
barevClub?: { key: string; threadId?: number };
}
export const companies = pgTable('companies', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
/** Короткое имя компании в адресе страниц для клиентов: listcrm…/<slug>/<код>. */
slug: text('slug').unique(),
/** solo | team | agency */
plan: text('plan').notNull(),
/** fixed | percent */
billingModel: text('billing_model').notNull().default('fixed'),
/** Запланированная смена тарифа и модели: вступает в силу с plannedFrom. */
plannedPlan: text('planned_plan'),
plannedBillingModel: text('planned_billing_model'),
plannedFrom: date('planned_from'),
subscriptionEndsAt: ts('subscription_ends_at'),
graceDays: integer('grace_days').notNull().default(15),
/** Ручной выключатель суперадмина. */
accessEnabled: boolean('access_enabled').notNull().default(false),
setupFeeInvoiced: boolean('setup_fee_invoiced').notNull().default(false),
/** С какой даты компания на модели «%»: сделки до неё в счёт по комиссии не попадают. */
percentFrom: date('percent_from'),
/** Ключ последнего напоминания о подписке — чтобы не слать одно и то же дважды. */
billingNotice: text('billing_notice'),
settings: jsonb('settings').$type<CompanySettings>().notNull(),
lastActivityAt: ts('last_activity_at'),
createdAt,
});
/**
* Отдел компании: свои сотрудники, фильтры, объекты и клиенты. Сотрудник отдела видит только данные
* своего отдела; руководитель и суперадмин — все отделы. Без отделов компания работает как раньше:
* у всех данных отдел пустой.
*/
export const departments = pgTable(
'departments',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
name: text('name').notNull(),
createdAt,
},
(t) => [index('departments_company_idx').on(t.companyId)],
);
/** Отдел записи: null — «без отдела» (общие данные компании). */
const departmentId = () => integer('department_id').references(() => departments.id, { onDelete: 'set null' });
export const users = pgTable(
'users',
{
id: serial('id').primaryKey(),
/** null — суперадмин либо пользователь, ещё не создавший компанию. */
companyId: integer('company_id').references(() => companies.id),
/** superadmin | manager | agent */
role: text('role').notNull(),
name: text('name').notNull(),
telegramId: bigint('telegram_id', { mode: 'number' }),
telegramUsername: text('telegram_username'),
email: text('email'),
emailVerifiedAt: ts('email_verified_at'),
passwordHash: text('password_hash'),
lang: text('lang').notNull().default('ru'),
/** Тихие часы по времени Еревана, «HH:MM». */
quietFrom: text('quiet_from'),
quietTo: text('quiet_to'),
notificationsPaused: boolean('notifications_paused').notNull().default(false),
/** Одноразовый код привязки Telegram: t.me/<бот>?start=<код>. */
telegramLinkToken: text('telegram_link_token'),
/** Условия оплаты агента: оклад, процент с его сделок или оклад + процент; план на месяц и бонус за него. Задаёт руководитель. */
pay: jsonb('pay').$type<AgentPay>(),
/** Мастер первой настройки пройден или пропущен. */
onboardedAt: ts('onboarded_at'),
/** Удалённый из компании агент: вход закрыт, история сохраняется. */
removedAt: ts('removed_at'),
/** Отдел сотрудника; null — без отдела. Назначает руководитель. */
departmentId: departmentId(),
createdAt,
},
(t) => [
uniqueIndex('users_telegram_uq').on(t.telegramId),
uniqueIndex('users_email_uq').on(sql`lower(${t.email})`),
index('users_company_idx').on(t.companyId),
],
);
export const sessions = pgTable('sessions', {
/** SHA-256 от токена из cookie. */
tokenHash: text('token_hash').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
/** Суперадмин вошёл в кабинет компании для поддержки. */
actingCompanyId: integer('acting_company_id').references(() => companies.id),
/** Сессия открыта из Telegram Mini App: тарифу без веб-кабинета доступна только она. */
viaTelegram: boolean('via_telegram').notNull().default(false),
expiresAt: ts('expires_at').notNull(),
createdAt,
});
/** Язык, который человек выбрал в боте до регистрации: на нём бот отвечает и создаётся его аккаунт. */
export const botLangs = pgTable('bot_langs', {
telegramId: bigint('telegram_id', { mode: 'number' }).primaryKey(),
lang: text('lang').notNull(),
createdAt,
});
/** Одноразовые ссылки: подтверждение email и восстановление пароля. */
export const emailTokens = pgTable('email_tokens', {
tokenHash: text('token_hash').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
/** verify | reset */
kind: text('kind').notNull(),
expiresAt: ts('expires_at').notNull(),
});
export const invites = pgTable('invites', {
token: text('token').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
/** agent — многоразовая ссылка для агентов; manager — одноразовая ссылка руководителю от суперадмина. */
role: text('role').notNull().default('agent'),
createdById: integer('created_by_id').notNull().references(() => users.id),
revokedAt: ts('revoked_at'),
createdAt,
});
/** Поля объекта недвижимости — общие для объявления из парсера и для лида. */
const propertyColumns = {
title: text('title').notNull(),
description: text('description'),
/** sale | rent */
dealType: text('deal_type'),
propertyType: text('property_type'),
region: text('region'),
district: text('district'),
address: text('address'),
price: money('price'),
currency: text('currency'),
rooms: integer('rooms'),
area: numeric('area', { precision: 10, scale: 2, mode: 'number' }),
floor: integer('floor'),
floorsTotal: integer('floors_total'),
photos: jsonb('photos').$type<string[]>().notNull().default([]),
authorName: text('author_name'),
/** Нормализованный телефон автора (+374…). */
phone: text('phone'),
/** Удобства и правила проживания со страницы объявления. */
features: jsonb('features').$type<ListingFeatures>(),
};
/**
* Общая база объявлений сервиса: всё свежее с list.am (не старше 3 дней на момент находки).
* Объявление сохраняется один раз и уже отсюда расходится клиентам по их фильтрам.
*/
export const listings = pgTable(
'listings',
{
id: serial('id').primaryKey(),
externalId: text('external_id').notNull(),
url: text('url').notNull(),
...propertyColumns,
isAgency: boolean('is_agency').notNull().default(false),
publishedAt: ts('published_at'),
/** Когда объявление последний раз «подняли» на list.am: по этому времени объект встаёт наверх. */
bumpedAt: ts('bumped_at'),
active: boolean('active').notNull().default(true),
removedAt: ts('removed_at'),
lastCheckedAt: ts('last_checked_at'),
createdAt,
},
(t) => [
uniqueIndex('listings_external_uq').on(t.externalId),
index('listings_phone_idx').on(t.phone),
],
);
export const filters = pgTable(
'filters',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
/** null — общий фильтр компании или отдела; иначе личный фильтр агента. */
ownerUserId: integer('owner_user_id').references(() => users.id),
/** Фильтр отдела: объекты по нему приходят в отдел. У личного фильтра пусто — отдел берётся у владельца. */
departmentId: departmentId(),
name: text('name').notNull(),
enabled: boolean('enabled').notNull().default(true),
params: jsonb('params').$type<FilterParams>().notNull(),
createdAt,
},
(t) => [index('filters_company_idx').on(t.companyId)],
);
export const pipelineStatuses = pgTable(
'pipeline_statuses',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
/** object | client */
pipeline: text('pipeline').notNull(),
/** Код системного статуса (new, taken, deal, rejected); у пользовательских — null. */
code: text('code'),
name: text('name').notNull(),
position: integer('position').notNull(),
/** Взятый лид в этом статусе возвращается в пул по правилу «N часов без смены статуса». */
poolReturn: boolean('pool_return').notNull().default(false),
/** Удалённый статус скрыт из воронки, но остаётся в истории. */
deletedAt: ts('deleted_at'),
},
(t) => [index('statuses_company_idx').on(t.companyId, t.pipeline)],
);
export const rejectReasons = pgTable('reject_reasons', {
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
pipeline: text('pipeline').notNull(),
/** agent — системная причина кнопки «Это агент»; у остальных null. */
code: text('code'),
name: text('name').notNull(),
position: integer('position').notNull(),
deletedAt: ts('deleted_at'),
});
/** Лид — объект в воронке компании. Одно объявление может породить лиды в разных компаниях. */
export const leads = pgTable(
'leads',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
/** null — лид создан вручную. */
listingId: integer('listing_id').references(() => listings.id),
/** listam | manual */
source: text('source').notNull(),
url: text('url'),
...propertyColumns,
/** null — лид в пуле. */
assigneeId: integer('assignee_id').references(() => users.id),
statusId: integer('status_id').notNull().references(() => pipelineStatuses.id),
rejectReasonId: integer('reject_reason_id').references(() => rejectReasons.id),
nextActionAt: ts('next_action_at'),
nextActionNote: text('next_action_note'),
nextActionNotifiedAt: ts('next_action_notified_at'),
takenAt: ts('taken_at'),
statusChangedAt: ts('status_changed_at').notNull().defaultNow(),
/** Когда отправлено предупреждение «лид вернётся в пул». */
poolWarnedAt: ts('pool_warned_at'),
/** Условие комиссии именно для этого объекта; null — как в настройках компании. */
commission: jsonb('commission').$type<CommissionRule>(),
/** Текст объекта для страницы клиента: по шаблону или переписанный моделью. */
showcaseText: text('showcase_text'),
/** Когда для страницы клиента загружен полный набор фото; null — ещё не загружали. */
galleryAt: ts('gallery_at'),
galleryTries: integer('gallery_tries').notNull().default(0),
/** Карточку объекта открывали — нужно догрузить все фото и собрать текст. */
galleryWantedAt: ts('gallery_wanted_at'),
/** Прошлый лид по тому же объекту — блок «Уже был в работе». */
previousLeadId: integer('previous_lead_id'),
/** Объявление подняли на list.am: лид встаёт наверх списка. */
bumpedAt: ts('bumped_at'),
/** С какой комиссией объект последний раз ушёл в группу компании; новая комиссия — новая отправка. */
groupPostedCommission: jsonb('group_posted_commission').$type<CommissionRule>(),
groupPostedAt: ts('group_posted_at'),
createdById: integer('created_by_id').references(() => users.id),
/** Отдел, которому принадлежит объект; null — без отдела. */
departmentId: departmentId(),
createdAt,
},
(t) => [
index('leads_company_status_idx').on(t.companyId, t.statusId),
index('leads_company_assignee_idx').on(t.companyId, t.assigneeId),
// Одно объявление — один лид на отдел компании: у разных отделов свои объекты.
uniqueIndex('leads_company_dept_listing_uq')
.on(t.companyId, sql`coalesce(${t.departmentId}, 0)`, t.listingId)
.where(sql`${t.previousLeadId} is null and ${t.listingId} is not null`),
],
);
/** Клиент — покупатель или арендатор, которого агент заводит вручную. */
export const clients = pgTable(
'clients',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
name: text('name').notNull(),
phone: text('phone'),
/** Ник или ссылка в Telegram: связь с клиентом, если телефона нет. */
telegram: text('telegram'),
/** Срок аренды, который нужен арендатору: short | mid | long (RENT_TERMS). */
rentTerm: text('rent_term'),
/** buyer | tenant */
kind: text('kind').notNull(),
/** Запрос клиента: тип объекта, район, бюджет, комнаты, площадь. */
request: jsonb('request').$type<Partial<FilterParams>>().notNull().default({}),
source: text('source'),
/** Кто будет жить: состав, дети, животные. */
household: jsonb('household').$type<Household>(),
/** Условие комиссии именно для этого клиента; null — как в настройках компании. */
commission: jsonb('commission').$type<CommissionRule>(),
assigneeId: integer('assignee_id').notNull().references(() => users.id),
statusId: integer('status_id').notNull().references(() => pipelineStatuses.id),
rejectReasonId: integer('reject_reason_id').references(() => rejectReasons.id),
nextActionAt: ts('next_action_at'),
nextActionNote: text('next_action_note'),
nextActionNotifiedAt: ts('next_action_notified_at'),
statusChangedAt: ts('status_changed_at').notNull().defaultNow(),
/** Отдел, которому принадлежит клиент; null — без отдела. */
departmentId: departmentId(),
createdAt,
},
(t) => [index('clients_company_idx').on(t.companyId, t.statusId)],
);
/** Избранное: объекты, которые пользователь отметил звёздочкой. У каждого своё. */
export const leadFavorites = pgTable(
'lead_favorites',
{
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }),
createdAt,
},
(t) => [primaryKey({ columns: [t.userId, t.leadId] })],
);
/** Пользователь открывал карточку объекта: по этому работает фильтр «Не просмотренные». */
export const leadViews = pgTable(
'lead_views',
{
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }),
viewedAt: ts('viewed_at').notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.userId, t.leadId] })],
);
/** Фото к карточке клиента: жильцы, животные, документы. Хранятся в базе и попадают в её резервную копию. */
export const clientPhotos = pgTable(
'client_photos',
{
id: serial('id').primaryKey(),
clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
companyId: integer('company_id').notNull().references(() => companies.id),
contentType: text('content_type').notNull(),
size: integer('size').notNull(),
/** Содержимое файла в base64. */
data: text('data').notNull(),
uploadedById: integer('uploaded_by_id').references(() => users.id),
createdAt,
},
(t) => [index('client_photos_client_idx').on(t.clientId)],
);
/** Объекты, предложенные или показанные клиенту. */
export const clientLeads = pgTable(
'client_leads',
{
clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }),
/** offered | shown */
state: text('state').notNull().default('offered'),
shownAt: ts('shown_at'),
createdAt,
},
(t) => [uniqueIndex('client_leads_uq').on(t.clientId, t.leadId)],
);
/**
* Сделка. Одна запись на сделку, даже если в ней и объект, и клиент из CRM —
* так комиссия не задваивается в аналитике и биллинге.
*/
export const deals = pgTable(
'deals',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
leadId: integer('lead_id').references(() => leads.id),
clientId: integer('client_id').references(() => clients.id),
/** Кто закрыл сделку. Комиссию между агентами делят ownerAgentId и clientAgentId. */
agentId: integer('agent_id').notNull().references(() => users.id),
/** Вся комиссия агентства: ownerCommission + clientCommission. По ней считается счёт по тарифу. */
commission: money('commission').notNull(),
/** Часть с собственника — идёт агенту объекта. */
ownerCommission: money('owner_commission').notNull().default(0),
/** Часть с покупателя или арендатора — идёт агенту клиента. */
clientCommission: money('client_commission').notNull().default(0),
/** Агент объекта и агент клиента на момент сделки; если стороны в CRM нет — агент второй стороны. */
ownerAgentId: integer('owner_agent_id').notNull().references(() => users.id),
clientAgentId: integer('client_agent_id').notNull().references(() => users.id),
/** sale | rent — от вида сделки зависит процент агента. */
kind: text('kind').notNull().default('sale'),
currency: text('currency').notNull(),
dealDate: date('deal_date').notNull(),
createdAt,
},
(t) => [
index('deals_company_date_idx').on(t.companyId, t.dealDate),
uniqueIndex('deals_lead_uq').on(t.leadId),
uniqueIndex('deals_client_uq').on(t.clientId),
],
);
/**
* Ручное начисление или удержание агенту за месяц: премия, штраф, аванс. Сумма со знаком,
* в валюте условий агента на момент записи. Входит в итог к выплате за этот месяц.
*/
export const payAdjustments = pgTable(
'pay_adjustments',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
userId: integer('user_id').notNull().references(() => users.id),
/** «ГГГГ-ММ». */
month: text('month').notNull(),
amount: money('amount').notNull(),
currency: text('currency').notNull(),
note: text('note').notNull(),
createdBy: integer('created_by').notNull().references(() => users.id),
createdAt,
},
(t) => [index('pay_adjustments_user_month_idx').on(t.companyId, t.userId, t.month)],
);
/** Что зафиксировано при выплате: условия и курсы, по которым считался месяц, и перерасчёты прошлых месяцев, вошедшие в него. */
export interface PayoutSnapshot {
pay: AgentPay | null;
/** Сколько единиц валюты в одном долларе. */
rates: Record<string, number>;
/** Начислено за месяц на момент выплаты — в валюте выплаты. */
total: number;
recalc: { month: string; amount: number }[];
}
/** Отметка «выплачено» за месяц. После неё месяц закрыт: изменения его сделок уходят перерасчётом в следующий открытый месяц. */
export const payouts = pgTable(
'payouts',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
userId: integer('user_id').notNull().references(() => users.id),
/** «ГГГГ-ММ». */
month: text('month').notNull(),
amount: money('amount').notNull(),
currency: text('currency').notNull(),
paidAt: date('paid_at').notNull(),
note: text('note'),
snapshot: jsonb('snapshot').$type<PayoutSnapshot>().notNull(),
createdBy: integer('created_by').notNull().references(() => users.id),
createdAt,
},
(t) => [uniqueIndex('payouts_user_month_uq').on(t.userId, t.month), index('payouts_company_idx').on(t.companyId)],
);
/** История лида и клиента. Только дописывается: API не даёт её менять и удалять. */
export const events = pgTable(
'events',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
leadId: integer('lead_id').references(() => leads.id),
clientId: integer('client_id').references(() => clients.id),
/** null — действие системы (создание из парсера, возврат в пул). */
userId: integer('user_id').references(() => users.id),
/** created | taken | assigned | status | comment | pool_return | deal | blacklisted | edited */
type: text('type').notNull(),
oldValue: text('old_value'),
newValue: text('new_value'),
/** Статус, в который перешла карточка (события status, deal, blacklisted) — для аналитики по этапам. */
statusId: integer('status_id'),
text: text('text'),
at: ts('at').notNull().defaultNow(),
},
(t) => [index('events_lead_idx').on(t.leadId), index('events_client_idx').on(t.clientId)],
);
/** Уведомления о лиде в Telegram: нужны, чтобы обновить сообщение («Взял Имя») и считать скорость реакции. */
export const leadNotifications = pgTable(
'lead_notifications',
{
id: serial('id').primaryKey(),
leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }),
userId: integer('user_id').notNull().references(() => users.id),
chatId: bigint('chat_id', { mode: 'number' }),
messageId: integer('message_id'),
/** Сообщение с фото: текст у него правится как подпись. */
hasPhoto: boolean('has_photo').notNull().default(false),
sentAt: ts('sent_at'),
createdAt,
},
(t) => [index('lead_notifications_lead_idx').on(t.leadId)],
);
export const blacklist = pgTable(
'blacklist',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
phone: text('phone').notNull(),
addedById: integer('added_by_id').references(() => users.id),
createdAt,
},
(t) => [uniqueIndex('blacklist_uq').on(t.companyId, t.phone)],
);
export const invoices = pgTable(
'invoices',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
/** setup | fixed | percent */
kind: text('kind').notNull(),
periodStart: date('period_start'),
periodEnd: date('period_end'),
amount: money('amount').notNull(),
currency: text('currency').notNull().default('USD'),
/** Расшифровка по сделкам для модели «%». */
details: jsonb('details').$type<unknown>(),
/** pending | paid | canceled */
status: text('status').notNull().default('pending'),
paidAt: ts('paid_at'),
/** online | manual | auto (нулевой счёт по модели «%») */
paidMethod: text('paid_method'),
createdAt,
},
(t) => [index('invoices_company_idx').on(t.companyId)],
);
export const payments = pgTable('payments', {
id: serial('id').primaryKey(),
invoiceId: integer('invoice_id').notNull().references(() => invoices.id),
provider: text('provider').notNull(),
externalId: text('external_id'),
amount: money('amount').notNull(),
currency: text('currency').notNull(),
/** pending | succeeded | failed */
status: text('status').notNull(),
createdAt,
});
/** Сообщения суперадмина пользователям в Telegram. */
export const adminMessages = pgTable('admin_messages', {
id: serial('id').primaryKey(),
text: text('text').notNull(),
/** all | company | managers | user */
audience: text('audience').notNull(),
companyId: integer('company_id').references(() => companies.id),
userId: integer('user_id').references(() => users.id),
recipients: integer('recipients').notNull().default(0),
/** Сколько из получателей без привязанного Telegram — им сообщение не дойдёт. */
unreachable: integer('unreachable').notNull().default(0),
sentAt: ts('sent_at').notNull().defaultNow(),
});
export const parserRuns = pgTable('parser_runs', {
id: serial('id').primaryKey(),
startedAt: ts('started_at').notNull().defaultNow(),
finishedAt: ts('finished_at'),
/** running | ok | failed */
status: text('status').notNull().default('running'),
foundNew: integer('found_new').notNull().default(0),
errors: text('errors'),
});
/**
* Объявления, которые парсер проверил и не взял в базу (старше 3 дней). Хранится только ID,
* чтобы не перечитывать страницу объявления при каждом «поднятии» в списке.
*/
export const seenListings = pgTable('seen_listings', {
externalId: text('external_id').primaryKey(),
seenAt: ts('seen_at').notNull().defaultNow(),
});
/** Какие категории list.am уже прочитаны хотя бы раз: первый проход только запоминает объявления. */
export const parserState = pgTable('parser_state', {
categoryId: integer('category_id').primaryKey(),
baselineAt: ts('baseline_at').notNull().defaultNow(),
/** Порядок обычных объявлений в списке на прошлом проходе: по сдвигу в нём видно, что объявление подняли. */
topIds: jsonb('top_ids').$type<string[]>(),
});
/**
* Очередь сообщений Telegram. Воркер отправляет их с учётом лимитов Bot API и повторяет при ошибке.
* kind: lead — уведомление о лиде с кнопкой «Беру»; edit — обновить такое уведомление; text — обычный текст.
*/
export const outbox = pgTable(
'outbox',
{
id: serial('id').primaryKey(),
kind: text('kind').notNull(),
/** null — получатель без аккаунта (написал в поддержку, не привязав Telegram): чат указан в payload.chatId. */
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
leadId: integer('lead_id').references(() => leads.id, { onDelete: 'cascade' }),
payload: jsonb('payload').$type<Record<string, unknown>>().notNull().default({}),
attempts: integer('attempts').notNull().default(0),
runAt: ts('run_at').notNull().defaultNow(),
doneAt: ts('done_at'),
error: text('error'),
createdAt,
},
(t) => [index('outbox_due_idx').on(t.runAt).where(sql`${t.doneAt} is null`)],
);
/** Настройки сервиса, которые суперадмин меняет в админке: одна строка на раздел (key = 'parser'). */
export const settings = pgTable('settings', {
key: text('key').primaryKey(),
value: jsonb('value').$type<Record<string, unknown>>().notNull(),
updatedAt: ts('updated_at').notNull().defaultNow(),
});
/**
* Обращение в поддержку: человек написал боту. Пока обращение открыто, все его сообщения идут в него;
* суперадмин отвечает из админки и закрывает кнопкой «Проблема решена».
*/
export const supportTickets = pgTable(
'support_tickets',
{
id: serial('id').primaryKey(),
/** Чат Telegram, из которого пишут. */
chatId: bigint('chat_id', { mode: 'number' }).notNull(),
/** null — Telegram не привязан ни к одному аккаунту. */
userId: integer('user_id').references(() => users.id),
/** Имя и ник из Telegram — чтобы понимать, кто пишет, даже без аккаунта. */
tgName: text('tg_name'),
tgUsername: text('tg_username'),
/** open | closed */
status: text('status').notNull().default('open'),
/** Есть сообщения, которые поддержка ещё не открывала. */
unread: boolean('unread').notNull().default(true),
lastMessageAt: ts('last_message_at').notNull().defaultNow(),
closedAt: ts('closed_at'),
createdAt,
},
(t) => [
index('support_tickets_status_idx').on(t.status, t.lastMessageAt),
uniqueIndex('support_tickets_open_uq').on(t.chatId).where(sql`${t.status} = 'open'`),
],
);
export const supportMessages = pgTable(
'support_messages',
{
id: serial('id').primaryKey(),
ticketId: integer('ticket_id').notNull().references(() => supportTickets.id, { onDelete: 'cascade' }),
/** true — ответ поддержки, false — сообщение пользователя. */
fromSupport: boolean('from_support').notNull().default(false),
authorId: integer('author_id').references(() => users.id),
text: text('text'),
/** Вложение из Telegram: файл хранится у Telegram, здесь только его идентификатор. */
fileId: text('file_id'),
/** photo | file */
fileKind: text('file_kind'),
fileName: text('file_name'),
at: ts('at').notNull().defaultNow(),
},
(t) => [index('support_messages_ticket_idx').on(t.ticketId)],
);
/**
* Постоянная ссылка агента на анкету в клиентском боте: t.me/<бот>?start=<token>.
* Каждый, кто заполнит анкету по ней, становится клиентом этого агента.
*/
export const intakeLinks = pgTable(
'intake_links',
{
token: text('token').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
userId: integer('user_id').notNull().references(() => users.id),
createdAt,
},
(t) => [uniqueIndex('intake_links_user_uq').on(t.userId)],
);
/** Заполнение анкеты одним человеком: на каком он шаге и что уже ответил. */
export const intakeSessions = pgTable(
'intake_sessions',
{
id: serial('id').primaryKey(),
linkToken: text('link_token').notNull().references(() => intakeLinks.token),
/** Чат соискателя в клиентском боте. */
chatId: bigint('chat_id', { mode: 'number' }).notNull(),
tgName: text('tg_name'),
tgUsername: text('tg_username'),
lang: text('lang').notNull().default('ru'),
/** Текущий вопрос анкеты; done — анкета заполнена. */
step: text('step').notNull(),
answers: jsonb('answers').$type<Record<string, unknown>>().notNull().default({}),
/** Клиент, созданный по анкете. */
clientId: integer('client_id').references(() => clients.id, { onDelete: 'set null' }),
completedAt: ts('completed_at'),
createdAt,
},
(t) => [index('intake_sessions_chat_idx').on(t.chatId)],
);
/** Страница подборки для клиента: объекты на выбор и запись на показ. Открывается по секретной ссылке без входа. */
export const clientPages = pgTable(
'client_pages',
{
token: text('token').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
/** Кто создал ссылку — ему приходят уведомления о записях на показ. */
createdById: integer('created_by_id').notNull().references(() => users.id),
revokedAt: ts('revoked_at'),
createdAt,
},
(t) => [uniqueIndex('client_pages_client_uq').on(t.clientId)],
);
/** Запись клиента на показ объекта: день выбирает сам клиент на странице подборки. */
export const viewings = pgTable(
'viewings',
{
id: serial('id').primaryKey(),
companyId: integer('company_id').notNull().references(() => companies.id),
clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }),
day: date('day').notNull(),
note: text('note'),
createdAt,
},
(t) => [uniqueIndex('viewings_uq').on(t.clientId, t.leadId)],
);
/**
* Телефоны собственника, которые агенты внесли по объявлению: по одному варианту от компании.
* В общей базе хранятся все варианты; чем больше компаний указали один и тот же номер, тем он надёжнее.
*/
export const listingPhones = pgTable(
'listing_phones',
{
id: serial('id').primaryKey(),
listingId: integer('listing_id').notNull().references(() => listings.id, { onDelete: 'cascade' }),
companyId: integer('company_id').notNull().references(() => companies.id),
userId: integer('user_id').references(() => users.id),
phone: text('phone').notNull(),
createdAt,
},
(t) => [uniqueIndex('listing_phones_uq').on(t.listingId, t.companyId), index('listing_phones_phone_idx').on(t.listingId, t.phone)],
);
+197
View File
@@ -0,0 +1,197 @@
import { CURRENCIES, type DealKind } from '@lr/shared';
import { and, eq, or } from 'drizzle-orm';
import { z } from 'zod';
import type { Tx } from '../db/client.ts';
import { clientLeads, clients, deals, events, leads } from '../db/schema.ts';
import { badRequest, conflict } from '../lib/errors.ts';
import { pipelineOf } from '../pipelines/service.ts';
// Сделка — одна запись, даже когда в ней участвуют и объект, и клиент из CRM: комиссия вводится
// один раз и не задваивается в аналитике и биллинге. Комиссия состоит из двух частей: с собственника
// (идёт агенту объекта) и с покупателя или арендатора (идёт агенту клиента).
/** Сделка в запросе. `commission` — прежний формат одной суммой: она относится к стороне, с которой закрывают сделку. */
export const DealBody = z.object({
ownerCommission: z.number().min(0).optional(),
clientCommission: z.number().min(0).optional(),
commission: z.number().positive().optional(),
currency: z.enum(CURRENCIES),
date: z.iso.date(),
});
export type DealBody = z.infer<typeof DealBody>;
export interface DealInput {
ownerCommission: number;
clientCommission: number;
/** Вся комиссия агентства по сделке. */
commission: number;
currency: string;
date: string;
}
const round2 = (n: number) => Math.round(n * 100) / 100;
/** Части комиссии из запроса. `side` — с какой стороны закрывают сделку: туда идёт сумма без разбивки. */
export function dealInput(body: DealBody, side: 'owner' | 'client'): DealInput {
const split = body.ownerCommission !== undefined || body.clientCommission !== undefined;
const ownerCommission = split ? (body.ownerCommission ?? 0) : side === 'owner' ? (body.commission ?? 0) : 0;
const clientCommission = split ? (body.clientCommission ?? 0) : side === 'client' ? (body.commission ?? 0) : 0;
const commission = round2(ownerCommission + clientCommission);
if (!(commission > 0)) throw badRequest('COMMISSION_REQUIRED');
return { ownerCommission, clientCommission, commission, currency: body.currency, date: body.date };
}
export const dealText = (d: Pick<DealInput, 'commission' | 'currency'>) => `${d.commission} ${d.currency}`;
/** Сделка в карточке объекта и клиента: части комиссии и кому они идут. `names` — имена участников по ID. */
export function dealView(d: typeof deals.$inferSelect, names: Map<string, string>) {
return {
id: d.id,
commission: d.commission,
ownerCommission: d.ownerCommission,
clientCommission: d.clientCommission,
ownerAgentName: names.get(String(d.ownerAgentId)) ?? null,
clientAgentName: names.get(String(d.clientAgentId)) ?? null,
currency: d.currency,
date: d.dealDate,
kind: d.kind,
};
}
/**
* Кому идут части комиссии и вид сделки. Часть с собственника — агенту объекта, часть с клиента —
* агенту клиента; если одной из сторон в CRM нет, обе части получает агент второй стороны.
*/
async function dealSides(tx: Tx, leadId: number | null, clientId: number | null, fallbackAgentId: number) {
const [lead] = leadId !== null ? await tx.select({ assigneeId: leads.assigneeId, dealType: leads.dealType }).from(leads).where(eq(leads.id, leadId)) : [];
const [client] = clientId !== null ? await tx.select({ assigneeId: clients.assigneeId, kind: clients.kind }).from(clients).where(eq(clients.id, clientId)) : [];
const kind: DealKind = lead?.dealType ? (lead.dealType === 'rent' ? 'rent' : 'sale') : client?.kind === 'tenant' ? 'rent' : 'sale';
return {
ownerAgentId: lead?.assigneeId ?? client?.assigneeId ?? fallbackAgentId,
clientAgentId: client?.assigneeId ?? lead?.assigneeId ?? fallbackAgentId,
kind,
};
}
/**
* Записывает сделку для объекта, клиента или пары. Если у сторон уже были отдельные записи
* (каждую закрыли без пары), они сливаются в одну.
*/
export async function recordDeal(
tx: Tx,
v: { companyId: number; leadId: number | null; clientId: number | null; agentId: number; deal: DealInput },
) {
if (v.leadId === null && v.clientId === null) throw new Error('Сделка без объекта и клиента');
const rows = await tx
.select()
.from(deals)
.where(
and(
eq(deals.companyId, v.companyId),
or(
v.leadId !== null ? eq(deals.leadId, v.leadId) : undefined,
v.clientId !== null ? eq(deals.clientId, v.clientId) : undefined,
),
),
);
// Сторона уже закрыта сделкой с другой парой: сначала ту сделку нужно отменить.
for (const r of rows) {
const otherClient = v.clientId !== null && r.clientId !== null && r.clientId !== v.clientId;
const otherLead = v.leadId !== null && r.leadId !== null && r.leadId !== v.leadId;
if (otherClient || otherLead) throw conflict('DEAL_HAS_PAIR');
}
const [keep, extra] = rows;
const leadId = v.leadId ?? keep?.leadId ?? null;
const clientId = v.clientId ?? keep?.clientId ?? null;
const values = {
commission: v.deal.commission,
ownerCommission: v.deal.ownerCommission,
clientCommission: v.deal.clientCommission,
currency: v.deal.currency,
dealDate: v.deal.date,
...(await dealSides(tx, leadId, clientId, v.agentId)),
};
if (extra) await tx.delete(deals).where(eq(deals.id, extra.id));
if (keep) {
await tx.update(deals).set({ ...values, leadId, clientId }).where(eq(deals.id, keep.id));
return keep.id;
}
const [row] = await tx
.insert(deals)
.values({ companyId: v.companyId, leadId, clientId, agentId: v.agentId, ...values })
.returning({ id: deals.id });
return row!.id;
}
/**
* Сторона вышла из статуса «Сделка». Если сделка была парной, запись остаётся за второй стороной
* и её агент получает обе части комиссии; иначе запись удаляется, чтобы комиссия не попала
* в аналитику и счёт.
*/
export async function dropDealSide(tx: Tx, side: 'lead' | 'client', id: number) {
const column = side === 'lead' ? deals.leadId : deals.clientId;
const [row] = await tx.select().from(deals).where(eq(column, id));
if (!row) return;
const other = side === 'lead' ? row.clientId : row.leadId;
if (other === null) await tx.delete(deals).where(eq(deals.id, row.id));
else {
await tx
.update(deals)
.set(side === 'lead' ? { leadId: null, ownerAgentId: row.clientAgentId } : { clientId: null, clientAgentId: row.ownerAgentId })
.where(eq(deals.id, row.id));
}
}
/** Пара для сделки должна быть прикреплена: объект — к клиенту. */
async function assertLinked(tx: Tx, clientId: number, leadId: number) {
const [link] = await tx
.select()
.from(clientLeads)
.where(and(eq(clientLeads.clientId, clientId), eq(clientLeads.leadId, leadId)));
if (!link) throw badRequest('PAIR_NOT_LINKED');
}
/** Клиент из пары переходит в «Сделка» вместе с объектом. */
export async function moveClientToDeal(tx: Tx, companyId: number, userId: number, leadId: number, clientId: number, deal: DealInput) {
await assertLinked(tx, clientId, leadId);
const [client] = await tx
.select()
.from(clients)
.where(and(eq(clients.id, clientId), eq(clients.companyId, companyId)))
.for('update');
if (!client) throw badRequest('PAIR_NOT_LINKED');
const statuses = await pipelineOf(tx, companyId, 'client');
const to = statuses.find((s) => s.code === 'deal')!;
if (client.statusId === to.id) return;
await tx
.update(clients)
.set({ statusId: to.id, rejectReasonId: null, statusChangedAt: new Date() })
.where(eq(clients.id, clientId));
await tx.insert(events).values({
companyId, clientId, userId, type: 'deal',
oldValue: statuses.find((s) => s.id === client.statusId)?.name ?? null, newValue: to.name, statusId: to.id, text: dealText(deal),
});
}
/** Объект из пары переходит в «Сделка» вместе с клиентом. Объект должен быть в работе, не в пуле. */
export async function moveLeadToDeal(tx: Tx, companyId: number, userId: number, clientId: number, leadId: number, deal: DealInput) {
await assertLinked(tx, clientId, leadId);
const [lead] = await tx
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.companyId, companyId)))
.for('update');
if (!lead) throw badRequest('PAIR_NOT_LINKED');
if (lead.assigneeId === null) throw badRequest('TAKE_FIRST');
const statuses = await pipelineOf(tx, companyId, 'object');
const to = statuses.find((s) => s.code === 'deal')!;
if (lead.statusId === to.id) return;
await tx
.update(leads)
.set({ statusId: to.id, rejectReasonId: null, statusChangedAt: new Date(), poolWarnedAt: null })
.where(eq(leads.id, leadId));
await tx.insert(events).values({
companyId, leadId, userId, type: 'deal',
oldValue: statuses.find((s) => s.id === lead.statusId)?.name ?? null, newValue: to.name, statusId: to.id, text: dealText(deal),
});
}
+60
View File
@@ -0,0 +1,60 @@
import { inArray } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { listings } from './db/schema.ts';
import { runIngest, type PagedSource } from './ingest/service.ts';
import { LISTAM_CATEGORIES } from './parser/listam-source.ts';
import { loadParserSettings, saveParserSettings } from './parser/settings.ts';
import type { SourcedListing } from './parser/source.ts';
import { testApp } from './testing.ts';
// Глубокий проход: догоняем объявления, которые вышли, пока парсер стоял, и ушли со страницы 1.
const RENT = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'rent')!;
let ext = 995_000_000;
const card = (daysAgo: number): SourcedListing => {
const id = String(++ext);
return {
externalId: id, sourceUrl: `https://www.list.am/item/${id}`, title: '2-комн. квартира', dealType: 'rent',
propertyType: 'apartment', region: 'Ереван', district: 'Кентрон', price: 300_000, currency: 'AMD', rooms: 2,
area: 50, floor: 3, floorsTotal: 9, isAgency: false, photoUrls: [], publishedAt: new Date(Date.now() - daysAgo * 86_400_000),
};
};
/** Список категории постранично, по 2 объявления на странице. */
let list: SourcedListing[] = [];
const source: PagedSource = {
name: 'fake',
item: async () => null,
listPage: async (category, page = 1) => (category.id === RENT.id ? list.slice((page - 1) * 2, page * 2) : []),
inspect: async (c) => c,
};
let t: Awaited<ReturnType<typeof testApp>>;
beforeAll(async () => { t = await testApp('deep_pass', source); });
afterAll(() => t.close());
const inBase = async (items: SourcedListing[]) =>
(await t.db.select({ id: listings.externalId }).from(listings).where(inArray(listings.externalId, items.map((i) => i.externalId)))).map((r) => r.id).sort();
describe('глубокий проход парсера', () => {
it('подбирает свежие объявления из глубины списка, старые пропускает, затем выключается', async () => {
await saveParserSettings(t.db, { categories: [RENT.id], maxPages: 5, freshDays: 3 });
const known = [card(5), card(5)];
list = known;
await runIngest({ db: t.db, source }); // первый проход по категории — только запоминает
await runIngest({ db: t.db, source });
// Пока парсер стоял, вышли объявления; сверху снова известные — обычный проход дальше страницы 1 не идёт.
const missed = [card(1), card(2)];
const stale = card(10);
list = [...known, ...missed, stale];
await runIngest({ db: t.db, source });
expect(await inBase(missed)).toEqual([]);
await saveParserSettings(t.db, { deepPass: true });
await runIngest({ db: t.db, source });
expect(await inBase(missed)).toEqual(missed.map((m) => m.externalId).sort());
expect(await inBase([stale])).toEqual([]);
expect((await loadParserSettings(t.db)).deepPass).toBe(false);
});
});
+259
View File
@@ -0,0 +1,259 @@
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashPassword } from './auth/password.ts';
import { createSession } from './auth/sessions.ts';
import { leads, listings, users } from './db/schema.ts';
import { testApp } from './testing.ts';
// Неактуальные объекты, «Отправить в бот», избранное и «Не просмотренные», отделы, цена в АМД и ДОЛ.
let t: Awaited<ReturnType<typeof testApp>>;
let tgId = 50_000;
let extId = 7_000_000;
beforeAll(async () => { t = await testApp('departments'); });
afterAll(() => t.close());
type Api = ReturnType<typeof t.client>;
interface Member { api: Api; id: number; chat: number | null }
/** Компания с руководителем и агентами; Telegram привязан у всех, кроме перечисленных в noTelegram. */
const company = async (agents = 2, noTelegram: number[] = []) => {
// Объявления прошлых сценариев «состариваем»: иначе новый фильтр получил бы их из базы.
await t.db.update(listings).set({ publishedAt: new Date(Date.now() - 30 * 86_400_000), bumpedAt: null });
const [admin] = await t.db
.insert(users)
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
.returning();
const sa = t.client(await createSession(t.db, admin!.id));
const created = await sa.post('/api/admin/companies', {
name: 'Агентство', plan: 'agency', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
});
const join = async (name: string, token: string, linked = true): Promise<Member> => {
const api = await t.register(name);
await api.post(`/api/invites/${token}/accept`);
const id = (await api.get('/api/me')).body.user.id as number;
const chat = linked ? ++tgId : null;
if (chat) await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id));
return { api, id, chat };
};
const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!);
const invite = (await boss.api.post('/api/invites')).body.token as string;
const team: Member[] = [];
for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite, !noTelegram.includes(i)));
return { sa, boss, agents: team, id: created.body.id as number };
};
/** Свежее объявление в общей базе: его получит новый фильтр. */
const addListing = async (over: Partial<typeof listings.$inferInsert> = {}) => {
const id = String(++extId);
const [row] = await t.db
.insert(listings)
.values({
externalId: id, url: `https://www.list.am/item/${id}`, title: `Квартира ${id}`, dealType: 'sale', propertyType: 'apartment',
region: 'Ереван', district: 'Кентрон', price: 100_000, currency: 'USD', rooms: 2, area: 60, isAgency: false,
photos: ['https://img.list.am/f/1.webp'], publishedAt: new Date(), ...over,
})
.returning();
return row!;
};
const titles = async (api: Api, query = 'tab=all') => ((await api.get(`/api/leads?${query}`)).body as { title: string }[]).map((l) => l.title).sort();
describe('неактуальные объекты', () => {
it('снятые с List.am и закрытые объекты не попадают в списки, поиск и подбор, но остаются в «Базе»', async () => {
const c = await company(1);
const live = await addListing({ title: 'Живой объект' });
const gone = await addListing({ title: 'Снятый объект' });
await addListing({ title: 'Отказной объект' });
await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } });
expect(await titles(c.boss.api)).toEqual(['Живой объект', 'Отказной объект', 'Снятый объект']);
// Объявление сняли с List.am; второй объект закрыли отказом.
await t.db.update(listings).set({ active: false, removedAt: new Date() }).where(eq(listings.id, gone.id));
const all = (await c.boss.api.get('/api/leads?tab=all')).body as { id: number; title: string }[];
const rejectedId = all.find((l) => l.title === 'Отказной объект')!.id;
await c.boss.api.post(`/api/leads/${rejectedId}/take`);
const pipeline = (await c.boss.api.get('/api/pipelines/object')).body;
const rejected = pipeline.statuses.find((s: { code: string }) => s.code === 'rejected');
expect((await c.boss.api.post(`/api/leads/${rejectedId}/status`, { statusId: rejected.id, rejectReasonId: pipeline.reasons[0].id })).status).toBe(200);
expect(await titles(c.boss.api)).toEqual(['Живой объект']);
expect(await titles(c.boss.api, 'tab=all&q=Снятый')).toEqual([]);
expect(await titles(c.agents[0]!.api, 'tab=pool')).toEqual(['Живой объект']);
expect((await c.boss.api.get('/api/leads-summary')).body).toMatchObject({ all: 1, pool: 1 });
// В подбор клиенту снятый объект не попадает.
const client = await c.boss.api.post('/api/clients', { name: 'Покупатель', kind: 'buyer', request: {} });
const suggested = (await c.boss.api.get(`/api/clients/${client.body.id}/suggestions`)).body as { title: string }[];
expect(suggested.map((s) => s.title)).toEqual(['Живой объект']);
// Данные не удалены: снятый объект — в «Базе», карточка открывается.
const removed = (await c.boss.api.get('/api/archive/leads?result=removed')).body as { title: string }[];
expect(removed.map((l) => l.title)).toEqual(['Снятый объект']);
const archive = (await c.boss.api.get('/api/archive/leads')).body as { title: string }[];
expect(archive.map((l) => l.title).sort()).toEqual(['Отказной объект', 'Снятый объект']);
const [goneLead] = await t.db.select().from(leads).where(eq(leads.listingId, gone.id));
expect((await c.boss.api.get(`/api/leads/${goneLead!.id}`)).status).toBe(200);
void live;
});
});
describe('«Отправить в бот»', () => {
it('объект уходит в личный чат того, кто нажал; без привязки Telegram — понятная ошибка', async () => {
const c = await company(2, [1]);
await addListing({ title: 'Для бота' });
await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } });
const [lead] = (await c.agents[0]!.api.get('/api/leads?tab=pool')).body as { id: number }[];
const before = t.groupSent.length;
expect((await c.agents[0]!.api.post(`/api/leads/${lead!.id}/bot`)).status).toBe(200);
const sent = t.groupSent.slice(before);
// Фото альбомом и два текста — все в чат нажавшего, никому больше.
expect(sent.map((m) => m.kind)).toEqual(['album', 'text', 'text']);
expect(new Set(sent.map((m) => m.chatId))).toEqual(new Set([c.agents[0]!.chat]));
expect(sent[1]!.text).toContain('Для бота');
// Другой агент нажал ту же кнопку — объект пришёл ему, а не первому.
const mark = t.groupSent.length;
await c.boss.api.post(`/api/leads/${lead!.id}/bot`);
expect(new Set(t.groupSent.slice(mark).map((m) => m.chatId))).toEqual(new Set([c.boss.chat]));
// Telegram не привязан.
const res = await c.agents[1]!.api.post(`/api/leads/${lead!.id}/bot`);
expect(res).toMatchObject({ status: 400, body: { code: 'TELEGRAM_NOT_LINKED' } });
});
});
describe('избранное и «Не просмотренные»', () => {
it('фильтры работают у каждого своим и вместе с остальными фильтрами', async () => {
const c = await company(1);
await addListing({ title: 'Дешёвая', price: 50_000 });
await addListing({ title: 'Дорогая', price: 300_000 });
await addListing({ title: 'Средняя', price: 120_000 });
await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } });
const agent = c.agents[0]!.api;
const pool = (await agent.get('/api/leads?tab=pool')).body as { id: number; title: string; favorite: boolean; seen: boolean }[];
const id = (title: string) => pool.find((l) => l.title === title)!.id;
expect(pool.every((l) => !l.favorite && !l.seen)).toBe(true);
await agent.post(`/api/leads/${id('Дешёвая')}/favorite`, { on: true });
await agent.post(`/api/leads/${id('Дорогая')}/favorite`, { on: true });
expect(await titles(agent, 'tab=pool&favorite=1')).toEqual(['Дешёвая', 'Дорогая']);
// Вместе с ценой.
expect(await titles(agent, 'tab=pool&favorite=1&priceFrom=100000')).toEqual(['Дорогая']);
// Избранное своё у каждого.
expect(await titles(c.boss.api, 'tab=all&favorite=1')).toEqual([]);
// Открытая карточка больше не «непросмотренная» — только для того, кто её открыл.
expect((await agent.get(`/api/leads/${id('Средняя')}`)).body.favorite).toBe(false);
expect((await agent.get(`/api/leads/${id('Дорогая')}`)).body.favorite).toBe(true);
expect(await titles(agent, 'tab=pool&unseen=1')).toEqual(['Дешёвая']);
expect(await titles(agent, 'tab=pool&unseen=1&favorite=1')).toEqual(['Дешёвая']);
expect(await titles(c.boss.api, 'tab=all&unseen=1')).toEqual(['Дешёвая', 'Дорогая', 'Средняя']);
await agent.post(`/api/leads/${id('Дешёвая')}/favorite`, { on: false });
expect(await titles(agent, 'tab=pool&favorite=1')).toEqual(['Дорогая']);
});
});
describe('цена в АМД и ДОЛ', () => {
it('границы в выбранной валюте; цены объектов пересчитываются по курсу', async () => {
const c = await company(0);
// Курс по умолчанию: 385 драмов за доллар.
await addListing({ title: 'В долларах', price: 100_000, currency: 'USD' }); // 38 500 000 ֏
await addListing({ title: 'В драмах', price: 30_000_000, currency: 'AMD' }); // ≈ $77 922
await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } });
const api = c.boss.api;
expect(await titles(api, 'tab=all&priceFrom=80000&priceCurrency=USD')).toEqual(['В долларах']);
expect(await titles(api, 'tab=all&priceTo=80000&priceCurrency=USD')).toEqual(['В драмах']);
expect(await titles(api, 'tab=all&priceFrom=35000000&priceCurrency=AMD')).toEqual(['В долларах']);
expect(await titles(api, 'tab=all&priceFrom=29000000&priceTo=31000000&priceCurrency=AMD')).toEqual(['В драмах']);
expect(await titles(api, 'tab=all&priceFrom=1000&priceCurrency=AMD')).toEqual(['В долларах', 'В драмах']);
expect((await api.get('/api/rates')).body).toMatchObject({ USD: 1, AMD: 385 });
});
});
describe('отделы', () => {
it('у отдела свои фильтры, объекты и клиенты; сотрудник не видит данные другого отдела', async () => {
const c = await company(3);
const [a1, a2, free] = c.agents as [Member, Member, Member];
// Отделы создаёт и наполняет только руководитель.
expect((await a1.api.post('/api/departments', { name: 'Свой' })).status).toBe(403);
const sale = (await c.boss.api.post('/api/departments', { name: 'Продажа' })).body.id as number;
const rent = (await c.boss.api.post('/api/departments', { name: 'Аренда' })).body.id as number;
expect((await a1.api.put(`/api/members/${a2.id}/department`, { departmentId: sale })).status).toBe(403);
expect((await c.boss.api.put(`/api/members/${a1.id}/department`, { departmentId: sale })).status).toBe(200);
expect((await c.boss.api.put(`/api/members/${a2.id}/department`, { departmentId: rent })).status).toBe(200);
expect((await a1.api.get('/api/me')).body.user).toMatchObject({ departmentId: sale, departmentName: 'Продажа' });
const members = (await c.boss.api.get('/api/members')).body as { id: number; departmentId: number | null }[];
expect(members.find((m) => m.id === a1.id)!.departmentId).toBe(sale);
// Фильтры отделов задаёт руководитель; агент видит только фильтры своего отдела.
await addListing({ title: 'Продажа 1', dealType: 'sale' });
await addListing({ title: 'Аренда 1', dealType: 'rent', price: 800 });
expect((await a1.api.post('/api/filters', { name: 'x', scope: 'department', departmentId: sale, params: {} })).status).toBe(403);
await c.boss.api.post('/api/filters', { name: 'Продажи', scope: 'department', departmentId: sale, params: { dealType: 'sale', ownersOnly: false } });
await c.boss.api.post('/api/filters', { name: 'Аренда', scope: 'department', departmentId: rent, params: { dealType: 'rent', ownersOnly: false } });
const f1 = (await a1.api.get('/api/filters')).body as { name: string; scope: string; editable: boolean }[];
expect(f1).toEqual([expect.objectContaining({ name: 'Продажи', scope: 'department', editable: false })]);
expect(((await c.boss.api.get('/api/filters')).body as unknown[]).length).toBe(2);
// Объекты пришли каждому отделу по его фильтру и не пересекаются.
expect(await titles(a1.api, 'tab=pool')).toEqual(['Продажа 1']);
expect(await titles(a2.api, 'tab=pool')).toEqual(['Аренда 1']);
// Сотрудник без отдела объекты отделов не видит; руководитель видит все и может выбрать отдел.
expect(await titles(free.api, 'tab=pool')).toEqual([]);
expect(await titles(c.boss.api)).toEqual(['Аренда 1', 'Продажа 1']);
expect(await titles(c.boss.api, `tab=all&departmentId=${rent}`)).toEqual(['Аренда 1']);
// Чужой объект нельзя открыть, взять, отправить себе в бот или добавить в избранное.
const rentLead = ((await a2.api.get('/api/leads?tab=pool')).body as { id: number }[])[0]!.id;
expect((await a1.api.get(`/api/leads/${rentLead}`)).status).toBe(404);
expect((await a1.api.post(`/api/leads/${rentLead}/take`)).status).toBe(404);
expect((await a1.api.post(`/api/leads/${rentLead}/bot`)).status).toBe(404);
expect((await a1.api.post(`/api/leads/${rentLead}/favorite`, { on: true })).status).toBe(404);
// Одно объявление под фильтрами двух отделов — у каждого отдела своя карточка.
await c.boss.api.post('/api/filters', { name: 'Продажи 2', scope: 'department', departmentId: rent, params: { dealType: 'sale', ownersOnly: false } });
expect(await titles(a2.api, 'tab=pool')).toEqual(['Аренда 1', 'Продажа 1']);
expect(await titles(a1.api, 'tab=pool')).toEqual(['Продажа 1']);
const saleIn = async (api: Api) => ((await api.get('/api/leads?tab=pool&q=Продажа')).body as { id: number }[])[0]!.id;
expect(await saleIn(a1.api)).not.toBe(await saleIn(a2.api));
// Клиенты отдела: агент видит клиентов своего отдела, но не чужого (видимость «видят все» включена).
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
const own = await a1.api.post('/api/clients', { name: 'Клиент продажи', kind: 'buyer', request: {} });
const other = await a2.api.post('/api/clients', { name: 'Клиент аренды', kind: 'tenant', request: {} });
const clientNames = async (api: Api) => ((await api.get('/api/clients?tab=all')).body as { name: string }[]).map((x) => x.name).sort();
expect(await clientNames(a1.api)).toEqual(['Клиент продажи']);
expect(await clientNames(a2.api)).toEqual(['Клиент аренды']);
expect(await clientNames(c.boss.api)).toEqual(['Клиент аренды', 'Клиент продажи']);
expect((await a1.api.get(`/api/clients/${other.body.id}`)).status).toBe(404);
// С «видят все» агент видит все объекты, но только своего отдела.
expect(await titles(a1.api)).toEqual(['Продажа 1']);
// Подбор клиенту — только из объектов его отдела.
const sug = (await c.boss.api.get(`/api/clients/${own.body.id}/suggestions`)).body as { id: number }[];
expect(sug.map((s) => s.id)).toEqual([await saleIn(a1.api)]);
// Агента перевели в другой отдел: его объекты в работе и клиенты переходят с ним.
await a1.api.post(`/api/leads/${await saleIn(a1.api)}/take`);
const taken = ((await a1.api.get('/api/leads?tab=mine')).body as { id: number }[])[0]!.id;
const moved = await c.boss.api.put(`/api/members/${a1.id}/department`, { departmentId: null });
// В «без отдела» карточки по этому объявлению нет — объект переходит.
expect(moved.body).toMatchObject({ ok: true, kept: 0 });
expect(((await a1.api.get('/api/leads?tab=mine')).body as { id: number }[]).map((l) => l.id)).toEqual([taken]);
expect(await clientNames(a1.api)).toEqual(['Клиент продажи']);
expect(await clientNames(a2.api)).toEqual(['Клиент аренды']);
// Непустой отдел не удаляется; пустой — удаляется вместе с фильтрами.
expect((await c.boss.api.del(`/api/departments/${rent}`)).body).toMatchObject({ code: 'DEPARTMENT_NOT_EMPTY' });
const empty = (await c.boss.api.post('/api/departments', { name: 'Пустой' })).body.id as number;
await c.boss.api.post('/api/filters', { name: 'Пустой', scope: 'department', departmentId: empty, params: {} });
expect((await c.boss.api.del(`/api/departments/${empty}`)).status).toBe(200);
expect(((await c.boss.api.get('/api/filters')).body as { name: string }[]).map((f) => f.name)).not.toContain('Пустой');
const list = (await c.boss.api.get('/api/departments')).body as { name: string; members: number }[];
expect(list.map((d) => [d.name, d.members])).toEqual([['Аренда', 1], ['Продажа', 0]]);
expect(((await a2.api.get('/api/departments')).body as { name: string }[]).map((d) => d.name)).toEqual(['Аренда']);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { and, asc, eq, inArray, isNull, not, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Db } from '../db/client.ts';
import { clients, departments, events, filters, leads, users } from '../db/schema.ts';
import type { Guards } from '../leads/routes.ts';
import { badRequest, conflict, notFound } from '../lib/errors.ts';
import { inDepartment } from '../leads/service.ts';
// Отделы компании. Создаёт, переименовывает и удаляет отделы, назначает и снимает сотрудников руководитель
// (и суперадмин в кабинете компании). У отдела свои фильтры, объекты и клиенты: сотрудник видит только
// данные своего отдела, руководитель — все.
const Id = z.object({ id: z.coerce.number().int().positive() });
const Name = z.object({ name: z.string().trim().min(1).max(80) });
export function registerDepartmentRoutes(app: FastifyInstance, db: Db, g: Guards) {
type Ctx = ReturnType<Guards['member']>;
const own = async (ctx: Ctx, id: number) => {
const [d] = await db.select().from(departments).where(and(eq(departments.id, id), eq(departments.companyId, ctx.company.id)));
if (!d) throw notFound();
return d;
};
/** Руководитель видит все отделы, сотрудник — только свой. */
app.get('/api/departments', async (req) => {
const ctx = g.member(req);
const count = (table: string, column: string) =>
sql<number>`(select count(*)::int from ${sql.raw(table)} x where x.${sql.raw(column)} = "departments"."id"${sql.raw(table === 'users' ? ' and x.removed_at is null' : '')})`;
return db
.select({
id: departments.id,
name: departments.name,
members: count('users', 'department_id'),
filters: count('filters', 'department_id'),
leads: count('leads', 'department_id'),
clients: count('clients', 'department_id'),
})
.from(departments)
.where(
and(
eq(departments.companyId, ctx.company.id),
ctx.role === 'manager' ? undefined : ctx.user.departmentId ? eq(departments.id, ctx.user.departmentId) : sql`false`,
),
)
.orderBy(asc(departments.name), asc(departments.id));
});
app.post('/api/departments', async (req) => {
const ctx = g.writable(g.manager(req));
const { name } = Name.parse(req.body);
const [row] = await db.insert(departments).values({ companyId: ctx.company.id, name }).returning({ id: departments.id });
return { id: row!.id };
});
app.patch('/api/departments/:id', async (req) => {
const ctx = g.writable(g.manager(req));
const d = await own(ctx, Id.parse(req.params).id);
const { name } = Name.parse(req.body);
await db.update(departments).set({ name }).where(eq(departments.id, d.id));
return { ok: true };
});
/**
* Удалить можно отдел без сотрудников, клиентов и объектов в работе. Объекты из его пула, которые никто
* не брал, удаляются вместе с ним (объявления остаются в общей базе), как и его фильтры: данные отдела
* не смешиваются с данными компании.
*/
app.delete('/api/departments/:id', async (req) => {
const ctx = g.writable(g.manager(req));
const d = await own(ctx, Id.parse(req.params).id);
await db.transaction(async (tx) => {
const [busy] = await tx
.select({
users: sql<number>`(select count(*)::int from users u where u.department_id = ${d.id} and u.removed_at is null)`,
clients: sql<number>`(select count(*)::int from clients c where c.department_id = ${d.id})`,
worked: sql<number>`(select count(*)::int from leads l join pipeline_statuses ps on ps.id = l.status_id
where l.department_id = ${d.id} and (l.assignee_id is not null or l.taken_at is not null or ps.code is distinct from 'new'))`,
})
.from(sql`(select 1) as one`);
if (busy && (busy.users || busy.clients || busy.worked)) throw conflict('DEPARTMENT_NOT_EMPTY');
const pool = (await tx.select({ id: leads.id }).from(leads).where(eq(leads.departmentId, d.id))).map((l) => l.id);
if (pool.length) {
await tx.update(leads).set({ previousLeadId: null }).where(inArray(leads.previousLeadId, pool));
await tx.delete(events).where(inArray(events.leadId, pool));
await tx.delete(leads).where(inArray(leads.id, pool));
}
await tx.delete(filters).where(eq(filters.departmentId, d.id));
await tx.delete(departments).where(eq(departments.id, d.id));
});
return { ok: true };
});
/**
* Назначить сотрудника в отдел или снять с отдела (departmentId = null). Объекты и клиенты, которые он
* ведёт, переходят вместе с ним — иначе он перестал бы их видеть. Объект не переносится, если в новом
* отделе уже есть карточка по тому же объявлению: его можно передать другому сотруднику старого отдела.
*/
app.put('/api/members/:id/department', async (req) => {
const ctx = g.writable(g.manager(req));
const { id } = Id.parse(req.params);
const { departmentId } = z.object({ departmentId: z.number().int().positive().nullable() }).parse(req.body);
const [user] = await db
.select()
.from(users)
.where(and(eq(users.id, id), eq(users.companyId, ctx.company.id), isNull(users.removedAt)));
if (!user) throw notFound();
if (departmentId !== null) await own(ctx, departmentId).catch(() => { throw badRequest('BAD_DEPARTMENT'); });
if ((user.departmentId ?? null) === departmentId) return { ok: true, kept: 0 };
const kept = await db.transaction(async (tx) => {
await tx.update(users).set({ departmentId }).where(eq(users.id, id));
const target = departmentId ?? 0;
await tx
.update(leads)
.set({ departmentId })
.where(
and(
eq(leads.companyId, ctx.company.id),
eq(leads.assigneeId, id),
sql`not (${leads.listingId} is not null and ${leads.previousLeadId} is null and exists (
select 1 from leads o where o.company_id = ${leads.companyId} and coalesce(o.department_id, 0) = ${target}
and o.listing_id = ${leads.listingId} and o.previous_lead_id is null and o.id <> ${leads.id}))`,
),
);
const [left] = await tx
.select({ n: sql<number>`count(*)::int` })
.from(leads)
.where(and(eq(leads.companyId, ctx.company.id), eq(leads.assigneeId, id), not(inDepartment(leads.departmentId, departmentId))));
await tx.update(clients).set({ departmentId }).where(and(eq(clients.companyId, ctx.company.id), eq(clients.assigneeId, id)));
return left?.n ?? 0;
});
// kept — сколько объектов осталось в прежнем отделе (там уже есть карточка того же объявления).
return { ok: true, kept };
});
}
+222
View File
@@ -0,0 +1,222 @@
import { expectedCommission, type CommissionRule, type Currency } from '@lr/shared';
import { and, eq, isNotNull, sql } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { companies, leads, pipelineStatuses, users } from '../db/schema.ts';
import type { Config } from '../lib/config.ts';
import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts';
import { renderShowcaseText } from '../showcase/text.ts';
import { esc } from '../telegram/texts.ts';
import { badRequest } from '../lib/errors.ts';
// Группа компании в Telegram: объект, который агент взял в работу и для которого указал комиссию,
// уходит туда своим ботом компании — все фото, описание для клиента, кто ведёт объект и комиссия.
// Комиссию поменяли — объект уходит ещё раз.
/** Бот группы: токен, группа и, если в группе есть топики, номер нужного топика. */
export type GroupBot = { token: string; chatId: number; threadId?: number };
/** Отправка в группу. В тестах подменяется записывающей заглушкой. */
export interface GroupSender {
/** Фото альбомами по 10 штук. */
album(bot: GroupBot, photos: Buffer[]): Promise<void>;
text(bot: GroupBot, html: string): Promise<void>;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Вызов Bot API. В группу Telegram пускает около 20 сообщений в минуту (альбом из 10 фото — это 10):
* на «подождите N секунд» ждём и повторяем тот же вызов, чтобы фото не задвоились.
*/
async function call(token: string, method: string, body: FormData | Record<string, unknown>) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
method: 'POST',
...(body instanceof FormData ? { body } : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
signal: AbortSignal.timeout(120_000),
});
const data = (await res.json()) as { ok: boolean; description?: string; parameters?: { retry_after?: number } };
if (data.ok) return;
const wait = data.parameters?.retry_after;
if (res.status === 429 && wait && attempt < 5) {
await sleep(Math.min(wait, 120) * 1000 + 500);
continue;
}
throw new Error(`Telegram ${method}: ${data.description ?? res.status}`);
}
}
export const telegramGroupSender: GroupSender = {
async album({ token, chatId, threadId }, photos) {
for (let i = 0; i < photos.length; i += 10) {
const chunk = photos.slice(i, i + 10);
const form = new FormData();
form.set('chat_id', String(chatId));
if (threadId) form.set('message_thread_id', String(threadId));
// Одно фото альбомом не отправить — оно уходит обычным сообщением.
if (chunk.length === 1) {
form.set('photo', new Blob([new Uint8Array(chunk[0]!)], { type: 'image/jpeg' }), 'photo.jpg');
await call(token, 'sendPhoto', form);
continue;
}
form.set('media', JSON.stringify(chunk.map((_, k) => ({ type: 'photo', media: `attach://p${k}` }))));
chunk.forEach((data, k) => form.set(`p${k}`, new Blob([new Uint8Array(data)], { type: 'image/jpeg' }), `p${k}.jpg`));
await call(token, 'sendMediaGroup', form);
}
},
async text({ token, chatId, threadId }, html) {
await call(token, 'sendMessage', { chat_id: chatId, ...(threadId ? { message_thread_id: threadId } : {}), text: html, parse_mode: 'HTML', link_preview_options: { is_disabled: true } });
},
};
const SYMBOL: Record<string, string> = { USD: '$', AMD: '֏', RUB: '₽', EUR: '€' };
const money = (amount: number, currency: string) => {
// Разряды — обычным пробелом: так сумма одинаково выглядит в любом клиенте Telegram.
const n = Math.round(amount).toLocaleString('ru-RU').replace(/[\u00a0\u202f]/g, ' ');
return currency === 'AMD' || currency === 'RUB' ? `${n} ${SYMBOL[currency]}` : `${SYMBOL[currency] ?? ''}${n}`;
};
/** «50 % (≈ 175 000 ֏)», «$1 000», «$200 + 2 % (≈ $2 600)». */
export function commissionLine(rule: CommissionRule, price: number | null, currency: string | null): string {
const terms =
rule.mode === 'percent' ? `${rule.value} %` : rule.mode === 'fixed' ? money(rule.value, rule.currency) : `${money(rule.fixed ?? 0, rule.currency)} + ${rule.value} %`;
const expected = rule.mode === 'fixed' ? null : expectedCommission(rule, price, currency as Currency | null);
return expected ? `${terms} (≈ ${money(expected.amount, expected.currency)})` : terms;
}
type GroupLead = Pick<typeof leads.$inferSelect, 'title' | 'showcaseText' | 'price' | 'currency' | 'url'> & Parameters<typeof renderShowcaseText>[0];
/**
* Сообщения в группу после фото: описание объекта (название и текст для клиента) и отдельно —
* кто дозвонился, комиссия и ссылка.
*/
export function groupTexts(lead: GroupLead, agentName: string | null, commission: CommissionRule | null, resent = false): [string, string] {
const about = [
resent ? '🔁 <b>Комиссия изменилась</b>' : null,
`<b>${esc(lead.title)}</b>`,
'',
esc(lead.showcaseText ?? renderShowcaseText(lead)),
];
const info = [
`👤 Дозвонился: <b>${esc(agentName ?? '—')}</b>`,
`💰 Комиссия: <b>${commission ? esc(commissionLine(commission, lead.price, lead.currency)) : 'не указана'}</b>`,
lead.url ? `🔗 ${esc(lead.url)}` : null,
];
const join = (lines: (string | null)[]) => lines.filter((line) => line !== null).join('\n').slice(0, 4096);
return [join(about), join(info)];
}
/** Фото и текст ещё готовятся (открытие карточки ставит их в очередь) — ждём до 10 минут, потом отправляем как есть. */
const GALLERY_WAIT_MS = 10 * 60_000;
type Lead = typeof leads.$inferSelect;
type PostDeps = { db: Db; config: Config; sender?: GroupSender; fetchImage?: ImageFetcher; now?: Date };
/**
* Объект в группу: фото альбомами, описание, отдельно — кто дозвонился и комиссия. Отмечает, с какой
* комиссией объект ушёл (своей, из карточки): автоматика с той же комиссией его больше не пришлёт.
*/
async function postLead(deps: PostDeps, bot: GroupBot, lead: Lead, agentName: string | null, commission: CommissionRule | null, resent = false) {
const { db, now = new Date() } = deps;
await deliverLead(deps, bot, lead, agentName, commission, resent);
await db.update(leads).set({ groupPostedCommission: lead.commission, groupPostedAt: now }).where(eq(leads.id, lead.id));
}
/** Фото объекта альбомами и два сообщения: описание для клиента и кто ведёт объект, комиссия, ссылка. */
async function deliverLead({ config, sender = telegramGroupSender, fetchImage }: PostDeps, bot: GroupBot, lead: Lead, agentName: string | null, commission: CommissionRule | null, resent = false) {
const photos: Buffer[] = [];
for (const url of lead.photos.filter((u) => /^https:\/\/(?:img|s)\.list\.am\//.test(u))) {
const photo = await croppedPhoto(config, url, fetchImage).catch(() => null);
if (photo) photos.push(photo);
}
if (photos.length) await sender.album(bot, photos);
for (const text of groupTexts(lead, agentName, commission, resent)) await sender.text(bot, text);
}
/**
* Кнопка «Отправить в бот»: объект в том же виде, что и в группу, уходит основным ботом в личный чат
* того, кто нажал кнопку. Получатель — Telegram, привязанный к его аккаунту; выбрать другого нельзя.
*/
export async function sendLeadToUser(deps: PostDeps, company: typeof companies.$inferSelect, lead: Lead, user: Pick<typeof users.$inferSelect, 'telegramId'>) {
const token = deps.config.telegramBotToken;
if (!token) throw badRequest('TELEGRAM_DISABLED');
if (!user.telegramId) throw badRequest('TELEGRAM_NOT_LINKED');
const [agent] = lead.assigneeId ? await deps.db.select({ name: users.name }).from(users).where(eq(users.id, lead.assigneeId)) : [];
const commission = lead.commission ?? company.settings.commission?.[lead.dealType === 'rent' ? 'objectRent' : 'objectSale'] ?? null;
try {
await deliverLead(deps, { token, chatId: user.telegramId }, lead, agent?.name ?? null, commission);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[bot] объект ${lead.id}, отправка себе: ${message}`);
// Бот заблокирован или чат с ним не начат: человеку нужно открыть бота и нажать «Старт».
if (/forbidden|chat not found|bot was blocked|user is deactivated/i.test(message)) throw badRequest('BOT_CHAT_UNAVAILABLE');
throw badRequest('BOT_SEND_FAILED');
}
}
/**
* Кнопка «Отправить в Telegram» в карточке объекта: отправка сразу, с тем, что уже есть. Комиссия —
* из карточки, иначе из настроек компании.
*/
export async function sendLeadToGroup(deps: PostDeps, company: typeof companies.$inferSelect, lead: Lead) {
const bot = company.settings.groupBot;
if (!bot?.chatId) throw badRequest('GROUP_BOT_NOT_SET');
const [agent] = lead.assigneeId ? await deps.db.select({ name: users.name }).from(users).where(eq(users.id, lead.assigneeId)) : [];
const commission = lead.commission ?? company.settings.commission?.[lead.dealType === 'rent' ? 'objectRent' : 'objectSale'] ?? null;
try {
await postLead(deps, bot, lead, agent?.name ?? null, commission);
} catch (error) {
console.error(`[group] объект ${lead.id}, ручная отправка: ${error instanceof Error ? error.message : String(error)}`);
throw badRequest('GROUP_SEND_FAILED');
}
}
/** Неудачная отправка (нет доступа к группе, сбой сети) повторяется не раньше чем через 10 минут. */
const failedAt = new Map<number, number>();
/** Раз в 20 секунд: объекты в работе, чья комиссия ещё не уходила в группу компании в таком виде. */
export async function runGroupPosts({ db, config, sender = telegramGroupSender, fetchImage, now = new Date(), pause = 3_000 }: { db: Db; config: Config; sender?: GroupSender; fetchImage?: ImageFetcher; now?: Date; pause?: number }) {
const rows = await db
.select({ lead: leads, company: companies, agentName: users.name })
.from(leads)
.innerJoin(companies, eq(companies.id, leads.companyId))
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.innerJoin(users, eq(users.id, leads.assigneeId))
.where(
and(
sql`${companies.settings}->'groupBot'->>'chatId' is not null`,
isNotNull(leads.commission),
// В работе: взят и ещё не закрыт сделкой или отказом.
sql`coalesce(${pipelineStatuses.code}, '') not in ('new', 'rejected', 'deal')`,
sql`${leads.commission} is distinct from ${leads.groupPostedCommission}`,
),
)
// Очередь: раньше всех — объекты, которые ждут дольше.
.orderBy(sql`${leads.galleryWantedAt} asc nulls last`, leads.id)
.limit(5);
let sent = 0;
for (const { lead, company, agentName } of rows) {
const bot = company.settings.groupBot!;
if (now.getTime() - (failedAt.get(lead.id) ?? 0) < 10 * 60_000) continue;
const waiting = lead.listingId && !lead.galleryAt && lead.galleryTries < 3;
if (waiting) {
if (!lead.galleryWantedAt) {
await db.update(leads).set({ galleryWantedAt: now }).where(eq(leads.id, lead.id));
continue;
}
if (now.getTime() - lead.galleryWantedAt.getTime() < GALLERY_WAIT_MS) continue;
}
try {
await postLead({ db, config, sender, fetchImage, now }, bot, lead, agentName, lead.commission!, lead.groupPostedCommission !== null);
failedAt.delete(lead.id);
sent++;
// Пауза между объектами: сообщения разных объектов не идут вплотную и не упираются в лимит группы.
if (pause) await sleep(pause);
} catch (error) {
failedAt.set(lead.id, now.getTime());
// Повторим на следующем проходе.
console.error(`[group] объект ${lead.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return sent;
}
+506
View File
@@ -0,0 +1,506 @@
import {
accessState,
canWrite,
matchesFilter,
normalizePhone,
type DealType,
type MatchableListing,
type ListingFeatures,
} from '@lr/shared';
import { and, desc, eq, gte, inArray, isNull, ne, sql } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { blacklist, companies, filters, leads, listings, parserRuns, parserState, pipelineStatuses, seenListings, users } from '../db/schema.ts';
import { createLeadFromListing, inDepartment, rejectRemovedListingLeads, sameDepartment } from '../leads/service.ts';
import { LISTAM_CATEGORIES, type ListAmCard, type ListAmCategory } from '../parser/listam-source.ts';
import { loadParserSettings, saveParserSettings } from '../parser/settings.ts';
import type { SourcedListing } from '../parser/source.ts';
import { enqueueText } from '../telegram/outbox.ts';
// Конвейер: страницы категорий list.am → свежие объявления в общую базу → фильтры компаний → лиды.
/** Источник, читающий категории постранично (ListAmListingSource). */
export interface PagedSource {
readonly name: string;
listPage(category: ListAmCategory, page?: number): Promise<ListAmCard[]>;
/**
* Дата размещения и, если объявление свежее, его детали. `publishedAt` пуст, если даты на
* странице нет. Бросает ошибку, если страницу прочитать не удалось.
*/
inspect(card: ListAmCard, isFresh: (publishedAt: Date) => boolean): Promise<SourcedListing>;
/** Есть ли объявление на сайте; undefined — понять не удалось. Нужен для отметки «Снято с List.am». */
/** Все фото объявления — для страницы клиента. */
gallery?(externalId: string): Promise<{ photos: string[]; features?: ListingFeatures; description?: string; removed?: boolean }>;
isAlive?(externalId: string): Promise<boolean | undefined>;
/** Объявление целиком по номеру; null — его нет на сайте. Нужен, чтобы агент добавил объект ссылкой. */
item?(externalId: string): Promise<SourcedListing | null>;
/** Ключ ридера из настроек: undefined — ключ сервера по умолчанию, null — без ключа. */
configure?(apiKey: string | null | undefined): void;
}
type Listing = typeof listings.$inferSelect;
export interface IngestReport {
status: 'ok' | 'failed';
categories: number;
foundNew: number;
/** Объявления из базы, которые подняли на list.am. */
bumped: number;
leads: number;
errors: string[];
}
/**
* Обычный список list.am отсортирован по «поднятию»: всё, что оказалось наверху с прошлого прохода,
* только что опубликовали или подняли, и оно идёт в базу. Блок «Топ» ротируется, а первый проход
* застаёт объявления, появившиеся до подключения, — там в базу попадают только размещённые не раньше
* чем 3 дня назад (по дате «Размещено» на странице объявления).
*/
export const FRESH_DAYS = 3;
/**
* ID объявлений list.am растут по порядку, примерно на 4 000 в день (замер 23.09–08.10.2026).
* Если ID отстаёт от самого большого из виденных больше чем на 20 000 (~5 дней), объявление
* заведомо старше 3 дней — страницу объявления ради даты можно не читать.
*/
const IDS_PER_DAY = 4_000;
const oldIdGap = (freshDays: number) => (freshDays + 2) * IDS_PER_DAY;
/** У list.am две нумерации: старая (до 900 млн, закончилась в сентябре 2026) и новая «995…». */
const idRange = (n: number) => (n >= 900_000_000 ? 1 : 0);
/** Сколько раз подряд страница объявления может не отдать дату, прежде чем судить по ID. */
const DATE_ATTEMPTS = 3;
const dateMisses = new Map<string, number>();
/** Дата известна с точностью до дня (начало дня по Еревану), поэтому сравниваем по дням. */
export const isFresh = (publishedAt: Date, now = Date.now(), freshDays = FRESH_DAYS) =>
now - publishedAt.getTime() < (freshDays + 1) * 86_400_000;
/**
* Какие объявления подняли с прошлого прохода. Обычные объявления в списке отсортированы по
* «поднятию»: те, что не поднимали, стоят в прежнем порядке. Объявление, оказавшееся выше того,
* что на прошлом проходе стояло выше него, подняли. Без прошлого порядка понять это нельзя.
*/
export function bumpedIds(order: string[], previous: string[]): Set<string> {
const before = new Map(previous.map((id, i) => [id, i]));
const bumped = new Set<string>();
if (!before.size) return bumped;
let below = Infinity;
for (let i = order.length - 1; i >= 0; i--) {
const at = before.get(order[i]!) ?? Infinity;
if (at > below) bumped.add(order[i]!);
below = Math.min(below, at);
}
return bumped;
}
/** Сколько объявлений из начала списка категории помнится до следующего прохода. */
const ORDER_KEEP = 200;
interface ActiveFilter {
filter: typeof filters.$inferSelect;
company: typeof companies.$inferSelect;
}
/** Включённые фильтры компаний с действующим доступом: только по ним парсер что-то ищет. */
async function activeFilters(db: Db): Promise<ActiveFilter[]> {
const rows = await db
.select({ filter: filters, company: companies })
.from(filters)
.innerJoin(companies, eq(companies.id, filters.companyId))
.where(eq(filters.enabled, true));
return rows.filter((r) => canWrite(accessState(r.company)));
}
const toRow = (item: SourcedListing) => ({
externalId: item.externalId,
url: item.sourceUrl,
title: item.title,
description: item.description ?? null,
dealType: item.dealType ?? null,
propertyType: item.propertyType ?? null,
region: item.region ?? null,
district: item.district ?? null,
address: item.address ?? null,
price: item.price ?? null,
currency: item.currency,
rooms: item.rooms ?? null,
area: item.area ?? null,
floor: item.floor ?? null,
floorsTotal: item.floorsTotal ?? null,
photos: item.photoUrls,
authorName: item.ownerName ?? null,
phone: normalizePhone(item.phone),
isAgency: item.isAgency,
publishedAt: item.publishedAt ?? null,
features: item.features ?? null,
});
/**
* Объявление, которое агент добавил ссылкой, а в общей базе его нет: страница читается с list.am
* и объявление сохраняется в общую базу. Возвращает null, если объявления на сайте нет.
*/
export async function importListing(db: Db, source: PagedSource, externalId: string): Promise<Listing | null> {
const settings = await loadParserSettings(db);
source.configure?.(settings.useKey ? settings.jinaApiKey || undefined : null);
const item = await source.item!(externalId);
if (!item) return null;
await db.insert(listings).values(toRow(item)).onConflictDoNothing();
const [row] = await db.select().from(listings).where(eq(listings.externalId, externalId));
return row ?? null;
}
/**
* Прошлый лид компании по тому же объекту, опубликованному заново под другим номером:
* совпали адрес и телефон автора. Если телефон неизвестен — адрес и характеристики, и прошлое
* объявление при этом снято с сайта.
*/
async function similarPrevious(db: Db, companyId: number, departmentId: number | null, listing: Listing): Promise<number | null> {
if (!listing.address?.trim()) return null;
const sameSpecs = sql`(${leads.propertyType} is not distinct from ${listing.propertyType} and ${leads.dealType} is not distinct from ${listing.dealType}
and ${leads.rooms} is not distinct from ${listing.rooms} and ${leads.area} is not distinct from ${listing.area}
and ${leads.floor} is not distinct from ${listing.floor} and not ${listings.active})`;
const [row] = await db
.select({ id: leads.id })
.from(leads)
.innerJoin(listings, eq(listings.id, leads.listingId))
.where(
and(
eq(leads.companyId, companyId),
inDepartment(leads.departmentId, departmentId),
ne(leads.listingId, listing.id),
sql`lower(trim(${leads.address})) = lower(trim(${listing.address}))`,
listing.phone
? sql`(${leads.phone} = ${listing.phone} or (${leads.phone} is null and ${sameSpecs}))`
: sameSpecs,
),
)
.orderBy(desc(leads.id))
.limit(1);
return row?.id ?? null;
}
/**
* Сопоставляет объявление с фильтрами и создаёт лиды. У каждого отдела компании свой лид: объект
* приходит в отдел, чей фильтр сработал (общий фильтр компании — в «без отдела», личный фильтр — в отдел
* владельца). Уведомление получают сотрудники этого отдела, если сработал общий фильтр или фильтр отдела,
* и владелец сработавшего личного фильтра.
* `republished` — объявление сняли и опубликовали снова под тем же номером: компании, у которой
* прошлый лид закрыт сделкой или отказом, объект приходит новым лидом со ссылкой на прошлый.
*/
export async function distribute(db: Db, listing: Listing, active: ActiveFilter[], notify = true, republished = false): Promise<number> {
const matchable: MatchableListing = { ...listing, dealType: listing.dealType as DealType | null, currency: listing.currency as MatchableListing['currency'] };
const byCompany = new Map<number, ActiveFilter[]>();
for (const a of active) byCompany.set(a.company.id, [...(byCompany.get(a.company.id) ?? []), a]);
let created = 0;
for (const [companyId, list] of byCompany) {
const company = list[0]!.company;
let blacklisted = false;
let activeForPhone = 0;
if (listing.phone) {
const [b] = await db
.select({ id: blacklist.id })
.from(blacklist)
.where(and(eq(blacklist.companyId, companyId), eq(blacklist.phone, listing.phone)));
blacklisted = Boolean(b);
const [n] = await db
.select({ n: sql<number>`count(*)::int` })
.from(listings)
.where(and(eq(listings.phone, listing.phone), eq(listings.active, true)));
activeForPhone = n?.n ?? 0;
}
const ctx = { rules: company.settings.agentRules, blacklisted, activeListingsForPhone: activeForPhone };
const matched = list.filter((a) => matchesFilter(matchable, a.filter.params, ctx));
if (!matched.length) continue;
const members = await db
.select()
.from(users)
.where(and(eq(users.companyId, companyId), isNull(users.removedAt)));
const departmentOf = new Map(members.map((u) => [u.id, u.departmentId ?? null]));
/** Отдел → сработал общий фильтр (компании или отдела) и чьи личные фильтры сработали. */
const targets = new Map<number | null, { common: boolean; personal: Set<number> }>();
for (const { filter } of matched) {
// Личный фильтр удалённого сотрудника объектов не приносит.
if (filter.ownerUserId !== null && !departmentOf.has(filter.ownerUserId)) continue;
const department = filter.ownerUserId === null ? (filter.departmentId ?? null) : departmentOf.get(filter.ownerUserId)!;
const target = targets.get(department) ?? { common: false, personal: new Set<number>() };
if (filter.ownerUserId === null) target.common = true;
else target.personal.add(filter.ownerUserId);
targets.set(department, target);
}
for (const [departmentId, { common, personal }] of targets) {
const recipients = notify
? members.filter((u) => (common && sameDepartment(u.departmentId, departmentId)) || personal.has(u.id))
: [];
const [last] = await db
.select({ id: leads.id, code: pipelineStatuses.code })
.from(leads)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.where(and(eq(leads.companyId, companyId), inDepartment(leads.departmentId, departmentId), eq(leads.listingId, listing.id)))
.orderBy(desc(leads.id))
.limit(1);
// По этому объявлению у отдела уже есть лид. Новый нужен, только если объявление
// опубликовали заново, а прошлый лид закрыт.
if (last && !(republished && (last.code === 'deal' || last.code === 'rejected'))) continue;
const previousLeadId = last?.id ?? (await similarPrevious(db, companyId, departmentId, listing));
const lead = await db.transaction((tx) => createLeadFromListing(tx, companyId, listing, recipients, previousLeadId, departmentId));
if (lead) created++;
}
}
return created;
}
/** Сколько объявлений из базы просматривается при создании или изменении фильтра. */
const BACKFILL_LIMIT = 2000;
/**
* Новый или изменённый фильтр сразу получает подходящие объявления из общей базы:
* они появляются в пуле компании без уведомлений в Telegram (объявления не новые).
* Возвращает число созданных лидов.
*/
export async function backfillFilter(db: Db, filterId: number, now = Date.now()): Promise<number> {
const [row] = await db
.select({ filter: filters, company: companies })
.from(filters)
.innerJoin(companies, eq(companies.id, filters.companyId))
.where(eq(filters.id, filterId));
if (!row || !row.filter.enabled || !canWrite(accessState(row.company))) return 0;
// В базе лежат объявления не старше 3 дней на момент находки или поднятия; со временем они стареют,
// поэтому свежесть проверяется ещё раз.
const { freshDays } = await loadParserSettings(db);
const since = new Date(now - (freshDays + 1) * 86_400_000);
const candidates = await db
.select()
.from(listings)
.where(and(eq(listings.active, true), sql`coalesce(${listings.bumpedAt}, ${listings.publishedAt}, ${listings.createdAt}) >= ${since.toISOString()}::timestamptz`))
.orderBy(desc(listings.id))
.limit(BACKFILL_LIMIT);
let created = 0;
for (const listing of candidates) created += await distribute(db, listing, [row], false);
return created;
}
export interface IngestDeps {
db: Db;
source: PagedSource;
log?: (message: string) => void;
}
/**
* Один проход парсера. Читаются все категории недвижимости list.am: свежие объявления
* складываются в общую базу сервиса независимо от того, есть ли под них фильтр, и уже из базы
* расходятся клиентам по фильтрам.
*/
export async function runIngest({ db, source, log = () => {} }: IngestDeps): Promise<IngestReport> {
const active = await activeFilters(db);
const settings = await loadParserSettings(db);
source.configure?.(settings.useKey ? settings.jinaApiKey || undefined : null);
const categories = LISTAM_CATEGORIES.filter((c) => settings.categories.includes(c.id));
const isRecent = (d: Date, at: number) => isFresh(d, at, settings.freshDays);
const deep = settings.deepPass;
if (deep) log(`Глубокий проход: до ${settings.maxPages} стр., свежесть ${settings.freshDays} дн.`);
const [run] = await db.insert(parserRuns).values({}).returning();
const report: IngestReport = { status: 'ok', categories: categories.length, foundNew: 0, bumped: 0, leads: 0, errors: [] };
const state = new Map((await db.select().from(parserState)).map((s) => [s.categoryId, s]));
const [max] = await db
.select({
a: sql<string | null>`max(external_id::bigint) filter (where external_id::bigint < 900000000)`,
b: sql<string | null>`max(external_id::bigint) filter (where external_id::bigint >= 900000000)`,
})
.from(listings)
.where(sql`external_id ~ '^[0-9]{1,15}$'`);
const highest = [Number(max?.a ?? 0), Number(max?.b ?? 0)];
const now = Date.now();
const skip = (externalId: string) => db.insert(seenListings).values({ externalId }).onConflictDoNothing();
// Страницы категорий запрашиваются сразу все: источник сам выдерживает темп запросов.
const results = await Promise.allSettled(
categories.map(async (category) => {
// Первый проход по категории наполняет базу без лидов: эти объявления появились
// на сайте до подключения, клиентам о них уведомления не нужны.
const baseline = !state.has(category.id);
const previous = state.get(category.id)?.topIds ?? [];
/** Обычные объявления этого прохода в порядке списка. */
const order: string[] = [];
let read = false;
for (let page = 1; page <= (baseline ? 1 : settings.maxPages); page++) {
const cards = await source.listPage(category, page);
if (!cards.length) break;
read = true;
const ids = cards.map((c) => c.externalId);
const inBase = new Map(
(await db.select().from(listings).where(inArray(listings.externalId, ids))).map((r) => [r.externalId, r] as const),
);
const seen = new Set(
(await db.select({ id: seenListings.externalId }).from(seenListings).where(inArray(seenListings.externalId, ids))).map((r) => r.id),
);
// Снятое объявление снова в списке — его опубликовали заново.
const back = await db
.update(listings)
.set({ active: true, lastCheckedAt: new Date() })
.where(and(inArray(listings.externalId, ids), eq(listings.active, false)))
.returning();
for (const row of back) if (!baseline) report.leads += await distribute(db, row, active, true, true);
for (const n of cards.map((c) => Number(c.externalId)).filter(Number.isFinite)) {
if (n > highest[idRange(n)]!) highest[idRange(n)] = n;
}
const regular = cards.filter((c) => !c.promoted);
order.push(...regular.map((c) => c.externalId));
const bumped = baseline ? new Set<string>() : bumpedIds(order, previous);
/** Обычные объявления страницы, которые новые для списка: опубликованы или подняты с прошлого прохода. */
let moved = 0;
for (const card of cards) {
const isBumped = !card.promoted && bumped.has(card.externalId);
const row = inBase.get(card.externalId);
if (row) {
if (!isBumped) continue;
// Объявление из базы подняли: объект встаёт наверх в базе и у компаний, где по нему есть лид.
moved++;
report.bumped++;
// Время поднятия ставит база — по тем же часам, что и время создания лидов: по ним сортируется список.
const [lifted] = await db.update(listings).set({ bumpedAt: sql`now()` }).where(eq(listings.id, row.id)).returning();
await db.update(leads).set({ bumpedAt: lifted!.bumpedAt }).where(eq(leads.listingId, row.id));
// Компании, у которых лида по нему нет, а фильтр подходит, получают его как новый.
report.leads += await distribute(db, lifted!, active);
continue;
}
if (seen.has(card.externalId)) {
if (!isBumped) continue;
// Старое объявление, которое раньше пропустили, подняли — теперь оно актуально.
await db.delete(seenListings).where(eq(seenListings.externalId, card.externalId));
}
if (!card.promoted) moved++;
// Новое для списка обычное объявление после первого прохода опубликовали или подняли только что:
// оно идёт в базу, как бы давно его ни разместили. Блок «Топ» ротируется — там судим по дате.
// В глубоком проходе объявления из глубины списка — не «только что опубликованные»: судим по дате.
const anyAge = !baseline && !card.promoted && !deep;
const id = Number(card.externalId);
if (!anyAge && Number.isFinite(id) && id < highest[idRange(id)]! - oldIdGap(settings.freshDays)) {
// Заведомо старое объявление, которое «подняли» в списке.
await skip(card.externalId);
continue;
}
let full: SourcedListing;
let problem = 'на странице нет даты размещения';
try {
full = await source.inspect(card, (d) => anyAge || isRecent(d, now));
} catch (error) {
full = card;
problem = error instanceof Error ? error.message : String(error);
}
if (!full.publishedAt) {
// Страницу не удалось прочитать или она пришла без даты. Пробуем ещё на следующих
// проходах, затем берём данные из списка и судим по ID: он уже прошёл проверку
// «не старше ~5 дней».
const misses = (dateMisses.get(card.externalId) ?? 0) + 1;
if (misses < DATE_ATTEMPTS) {
dateMisses.set(card.externalId, misses);
report.errors.push(`${card.sourceUrl}: ${problem}, попытка ${misses}`);
continue;
}
} else if (!anyAge && !isRecent(full.publishedAt, now)) {
await skip(card.externalId);
continue;
}
dateMisses.delete(card.externalId);
const old = full.publishedAt && !isRecent(full.publishedAt, now);
const [created] = await db.insert(listings).values({ ...toRow(full), bumpedAt: old ? sql`now()` : null }).onConflictDoNothing().returning();
if (!created) continue;
report.foundNew++;
if (!baseline) report.leads += await distribute(db, created, active);
}
// На странице остались объявления, стоящие на прежних местах, — дальше всё уже было.
if (!regular.length || (!deep && moved < regular.length)) break;
}
if (read) {
const topIds = order.slice(0, ORDER_KEEP);
await db
.insert(parserState)
.values({ categoryId: category.id, topIds })
.onConflictDoUpdate({ target: parserState.categoryId, set: { topIds } });
}
}),
);
const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
for (const f of failures) report.errors.push(f.reason instanceof Error ? f.reason.message : String(f.reason));
if (categories.length && failures.length === categories.length) report.status = 'failed';
await db
.update(parserRuns)
.set({
finishedAt: new Date(),
status: report.status,
foundNew: report.foundNew,
errors: report.errors.length ? report.errors.slice(0, 50).join('\n') : null,
})
.where(eq(parserRuns.id, run!.id));
log(`Парсер: в базу ${report.foundNew}, лидов ${report.leads}, ошибок ${report.errors.length}`);
if (report.status === 'failed') await alertSuperadmins(db, report.errors[0] ?? 'источник недоступен');
else if (deep) await saveParserSettings(db, { deepPass: false });
return report;
}
/** Алерт суперадминам в Telegram при сбое парсера — не чаще раза в час. */
async function alertSuperadmins(db: Db, error: string) {
const [recent] = await db
.select({ n: sql<number>`count(*)::int` })
.from(parserRuns)
.where(and(eq(parserRuns.status, 'failed'), gte(parserRuns.startedAt, new Date(Date.now() - 3_600_000))));
if ((recent?.n ?? 0) > 1) return;
const admins = await db.select({ id: users.id }).from(users).where(eq(users.role, 'superadmin'));
await enqueueText(db, admins.map((a) => a.id), 'parserFailed', [error.slice(0, 300)]);
}
/** Через сколько часов после находки объявление проверяется на list.am первый раз. */
const FIRST_CHECK_HOURS = 4;
/**
* Проверка объектов из CRM на list.am: снятое объявление получает отметку и дату снятия.
* За запуск проверяется небольшая пачка — запросы идут в общей очереди с парсером; каждое
* объявление проверяется раз в сутки, первый раз — через FIRST_CHECK_HOURS после находки.
* Сначала идут объекты, которые кто-то брал в работу.
*/
export async function runRemovalCheck({ db, source, now = new Date() }: IngestDeps & { now?: Date }) {
const report = { checked: 0, removed: 0 };
const settings = await loadParserSettings(db);
if (!source.isAlive || !settings.removalCheck) return report;
const RECHECK_HOURS = settings.recheckHours;
const limit = settings.removalBatch;
const due = new Date(now.getTime() - RECHECK_HOURS * 3_600_000).toISOString();
// Свежие объявления чаще всего снимают в первые сутки, поэтому первая проверка — раньше повторных.
const firstDue = new Date(now.getTime() - Math.min(FIRST_CHECK_HOURS, RECHECK_HOURS) * 3_600_000).toISOString();
const worked = sql<boolean>`exists (select 1 from leads l where l.listing_id = "listings"."id" and (l.assignee_id is not null or l.taken_at is not null))`;
const batch = await db
.select({ id: listings.id, externalId: listings.externalId })
.from(listings)
.where(
and(
eq(listings.active, true),
sql`exists (select 1 from leads l where l.listing_id = "listings"."id")`,
sql`(${listings.lastCheckedAt} <= ${due}::timestamptz or (${listings.lastCheckedAt} is null and ${listings.createdAt} <= ${firstDue}::timestamptz))`,
),
)
// Из ни разу не проверенных первыми идут самые новые: их чаще всего уже сняли, пока очередь доходит.
.orderBy(desc(worked), sql`${listings.lastCheckedAt} nulls first`, desc(listings.id))
.limit(limit);
for (const row of batch) {
const alive = await source.isAlive(row.externalId).catch(() => undefined);
report.checked++;
if (alive === false) {
report.removed++;
await db.transaction(async (tx) => {
await tx.update(listings).set({ active: false, removedAt: now, lastCheckedAt: now }).where(eq(listings.id, row.id));
await rejectRemovedListingLeads(tx, row.id);
});
} else {
// Не удалось понять — повторим через час, а не через сутки.
const checkedAt = alive ? now : new Date(now.getTime() - (RECHECK_HOURS - 1) * 3_600_000);
await db.update(listings).set({ lastCheckedAt: checkedAt }).where(eq(listings.id, row.id));
}
}
return report;
}
+59
View File
@@ -0,0 +1,59 @@
import { and, desc, eq, isNotNull, isNull } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { parserRuns, settings, users } from '../db/schema.ts';
import { loadParserSettings } from '../parser/settings.ts';
import { enqueueRaw } from '../telegram/outbox.ts';
// Сторож парсера: если парсер выключен или давно не запускался, суперадминам приходит сообщение
// в Telegram; пока проблема не ушла — напоминание раз в несколько часов, после — «снова работает».
// 20.10 парсер простоял почти двое суток выключенным, и это заметили только по пропавшему объекту.
/** Сколько минут без нового прохода считается «стоит». Глубокий проход идёт ~10 минут. */
export const STALL_MINUTES = 20;
const REMIND_HOURS = 6;
export interface WatchdogState {
/** Когда отправлено последнее сообщение о простое; null — парсер в порядке. */
alertedAt: Date | null;
}
const ago = (from: Date, now: Date) => {
const min = Math.round((now.getTime() - from.getTime()) / 60_000);
return min < 120 ? `${min} мин` : `${Math.round(min / 60)} ч`;
};
/** Что не так с парсером, или null, если всё в порядке. */
export async function parserProblem(db: Db, now = new Date()): Promise<string | null> {
const parser = await loadParserSettings(db);
const stall = STALL_MINUTES * 60_000;
if (!parser.enabled) {
const [row] = await db.select({ at: settings.updatedAt }).from(settings).where(eq(settings.key, 'parser'));
const since = row?.at ?? now;
// Только что выключили руками — даём время включить обратно.
return now.getTime() - since.getTime() >= stall ? `выключен в настройках (уже ${ago(since, now)})` : null;
}
const [last] = await db.select({ at: parserRuns.startedAt }).from(parserRuns).orderBy(desc(parserRuns.id)).limit(1);
if (!last) return null;
return now.getTime() - last.at.getTime() >= stall ? `не запускался ${ago(last.at, now)}` : null;
}
/** Одна проверка: шлёт сообщение о простое (с напоминаниями) и о восстановлении. */
export async function runParserWatchdog(db: Db, state: WatchdogState, now = new Date()): Promise<void> {
const problem = await parserProblem(db, now);
const remind = state.alertedAt && now.getTime() - state.alertedAt.getTime() >= REMIND_HOURS * 3_600_000;
if (problem && (!state.alertedAt || remind)) {
await notify(db, `⚠️ Парсер стоит: ${problem}. Новые объекты не приходят.\nВключить — Админка → Парсер.`);
state.alertedAt = now;
} else if (!problem && state.alertedAt) {
await notify(db, '✅ Парсер снова работает.');
state.alertedAt = null;
}
}
async function notify(db: Db, text: string) {
const admins = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), isNull(users.removedAt)));
await enqueueRaw(db, admins.map((a) => a.id), text);
}
+412
View File
@@ -0,0 +1,412 @@
import { normalizePhone, RENT_TERMS, YEREVAN_DISTRICTS, type ClientRequest, type Household, type RentTerm } from '@lr/shared';
import { and, desc, eq, isNotNull, sql } from 'drizzle-orm';
import { newToken } from '../auth/sessions.ts';
import type { Db } from '../db/client.ts';
import { clientPhotos, clients, companies, events, intakeLinks, intakeSessions, outbox, users } from '../db/schema.ts';
import type { Config } from '../lib/config.ts';
import { statusByCode } from '../pipelines/service.ts';
import type { Keyboard, TelegramApi, TgUpdate } from '../telegram/client.ts';
import { enqueueRaw } from '../telegram/outbox.ts';
import { esc } from '../telegram/texts.ts';
import { intakeTexts, LANGUAGE_BUTTONS, LANGUAGE_QUESTION } from './texts.ts';
// Клиентский бот: соискатель переходит по ссылке агента и отвечает на вопросы анкеты.
// По готовой анкете в CRM появляется клиент, закреплённый за автором ссылки, а автору в основной
// бот приходит сводка с вопросом «Показать подходящие объекты?».
type User = typeof users.$inferSelect;
type Session = typeof intakeSessions.$inferSelect;
/** Ссылка агента на анкету; одна на агента, создаётся при первом запросе. null — клиентский бот не настроен. */
export async function intakeLink(db: Db, config: Config, user: User): Promise<string | null> {
if (!config.clientBotToken || !config.clientBotUsername || !user.companyId) return null;
const [link] = await db
.insert(intakeLinks)
.values({ token: newToken().slice(0, 24), companyId: user.companyId, userId: user.id })
.onConflictDoUpdate({ target: intakeLinks.userId, set: { companyId: user.companyId } })
.returning();
return `https://t.me/${config.clientBotUsername}?start=${link!.token}`;
}
/** Скачивает фото, присланное в клиентский бот. null — не получилось или файл слишком большой. */
export type FileDownloader = (fileId: string) => Promise<{ data: Buffer; type: string } | null>;
export const telegramDownloader = (token: string): FileDownloader => async (fileId) => {
const info = (await (await fetch(`https://api.telegram.org/bot${token}/getFile?file_id=${encodeURIComponent(fileId)}`)).json()) as {
ok: boolean;
result?: { file_path?: string; file_size?: number };
};
if (!info.ok || !info.result?.file_path || (info.result.file_size ?? 0) > 5 * 1024 * 1024) return null;
const res = await fetch(`https://api.telegram.org/file/bot${token}/${info.result.file_path}`);
if (!res.ok) return null;
return { data: Buffer.from(await res.arrayBuffer()), type: 'image/jpeg' };
};
export interface IntakeDeps {
db: Db;
/** Клиентский бот. */
tg: TelegramApi;
config: Config;
download?: FileDownloader;
}
interface Answers {
deal?: 'rent' | 'sale';
type?: string;
place?: string;
budget?: { amount: number; currency: 'USD' | 'AMD' | 'RUB' | 'EUR' } | null;
rooms?: string;
/** Срок аренды: short | mid | long. */
term?: RentTerm;
who?: 'alone' | 'friend' | 'couple';
children?: number;
childrenAge?: string;
pets?: number;
petsAge?: string;
name?: string;
phone?: string | null;
}
const STEPS = ['deal', 'type', 'place', 'budget', 'rooms', 'term', 'who', 'children', 'childrenAge', 'pets', 'petsAge', 'name', 'phone'] as const;
type Step = (typeof STEPS)[number];
/** Следующий вопрос: жильцов спрашиваем только у арендаторов, возраст — только если дети или животные есть. */
function nextStep(after: Step | null, a: Answers): Step | 'done' {
for (const step of STEPS.slice(after ? STEPS.indexOf(after) + 1 : 0)) {
if (a.deal === 'sale' && ['term', 'who', 'children', 'childrenAge', 'pets', 'petsAge'].includes(step)) continue;
if (step === 'childrenAge' && !a.children) continue;
if (step === 'petsAge' && !a.pets) continue;
return step;
}
return 'done';
}
/** «600 000 драм», «1500$», «2000 usd» → сумма и валюта. */
export function parseBudget(text: string, deal: 'rent' | 'sale' = 'rent'): Answers['budget'] | undefined {
const digits = text.replace(/[\s.,'’]/g, '').match(/\d{2,12}/)?.[0];
if (!digits) return undefined;
const amount = Number(digits);
const t = text.toLowerCase();
const currency = /usd|\$|долл|դոլար/.test(t)
? 'USD'
: /amd|драм|֏|դրամ/.test(t)
? 'AMD'
: /rub|руб|₽/.test(t)
? 'RUB'
: /eur|евро|€/.test(t)
? 'EUR'
: // Валюта не названа: по величине суммы. Аренда от 20 000 и покупка от 5 млн — это драмы.
amount >= (deal === 'rent' ? 20_000 : 5_000_000)
? 'AMD'
: 'USD';
return { amount, currency };
}
function toRequest(a: Answers): Partial<ClientRequest> {
const rooms = a.rooms === '4' ? [4, 5, 6] : a.rooms && a.rooms !== 'any' ? [Number(a.rooms)] : [];
const district = a.place && a.place !== 'any' && a.place !== 'yerevan' ? a.place : null;
return {
propertyTypes: a.type && a.type !== 'any' ? [a.type as 'apartment'] : [],
...(a.place && a.place !== 'any' ? { region: 'Ереван' } : {}),
districts: district ? [district] : [],
...(a.budget ? { priceTo: a.budget.amount, priceCurrency: a.budget.currency } : {}),
rooms,
};
}
function toHousehold(a: Answers): Household | null {
if (a.deal !== 'rent') return null;
const h: Household = {
...(a.who ? { who: a.who } : {}),
...(a.children ? { children: { count: a.children, age: a.childrenAge ?? '' } } : {}),
...(a.pets ? { pets: { count: a.pets, age: a.petsAge ?? '' } } : {}),
};
return Object.keys(h).length ? h : null;
}
const btn = (text: string, step: Step, value: string) => ({ text, callback: `q:${step}:${value}` });
function question(step: Step, s: Session): { text: string; keyboard?: Keyboard } {
const t = intakeTexts(s.lang);
switch (step) {
case 'deal':
return { text: t.deal, keyboard: [[btn(t.rent, step, 'rent'), btn(t.sale, step, 'sale')]] };
case 'type':
return { text: t.type, keyboard: [[btn(t.apartment, step, 'apartment'), btn(t.house, step, 'house')], [btn(t.commercial, step, 'commercial'), btn(t.any, step, 'any')]] };
case 'place': {
const rows: Keyboard = [[btn(t.yerevan, step, 'yerevan'), btn(t.anywhere, step, 'any')]];
for (let i = 0; i < YEREVAN_DISTRICTS.length; i += 2) {
rows.push(YEREVAN_DISTRICTS.slice(i, i + 2).map((d) => btn(d, step, d)));
}
return { text: t.place, keyboard: rows };
}
case 'budget':
return { text: t.budget, keyboard: [[btn(t.skip, step, 'skip')]] };
case 'rooms':
return { text: t.rooms, keyboard: [[btn('1', step, '1'), btn('2', step, '2'), btn('3', step, '3')], [btn(t.rooms4, step, '4'), btn(t.any, step, 'any')]] };
case 'term':
return { text: t.term, keyboard: [[btn(t.termShort, step, 'short'), btn(t.termMid, step, 'mid')], [btn(t.termLong, step, 'long')]] };
case 'who':
return { text: t.who, keyboard: [[btn(t.alone, step, 'alone'), btn(t.friend, step, 'friend')], [btn(t.couple, step, 'couple')]] };
case 'children':
return { text: t.children, keyboard: [[btn(t.none, step, '0'), btn('1', step, '1'), btn('2', step, '2'), btn(t.more3, step, '3')]] };
case 'childrenAge':
return { text: t.childrenAge };
case 'pets':
return { text: t.pets, keyboard: [[btn(t.none, step, '0'), btn('1', step, '1'), btn(t.more2, step, '2')]] };
case 'petsAge':
return { text: t.petsAge };
case 'name':
return { text: t.name, keyboard: s.tgName ? [[btn(t.keepName(s.tgName.slice(0, 30)), step, 'keep')]] : undefined };
case 'phone':
return { text: t.phone, keyboard: [[btn(t.skip, step, 'skip')]] };
}
}
/** Ответ на текущий вопрос. undefined — ответ не подошёл (вторым значением — что сказать). */
function accept(step: Step, s: Session, value: string, viaButton: boolean): { patch: Partial<Answers> } | { error: string } {
const t = intakeTexts(s.lang);
const a = s.answers as Answers;
const buttonOnly = { error: t.useButtons };
switch (step) {
case 'deal':
return viaButton && (value === 'rent' || value === 'sale') ? { patch: { deal: value } } : buttonOnly;
case 'type':
return viaButton ? { patch: { type: value } } : buttonOnly;
case 'place':
return viaButton ? { patch: { place: value } } : buttonOnly;
case 'budget': {
if (viaButton) return { patch: { budget: null } };
const budget = parseBudget(value, a.deal);
return budget ? { patch: { budget } } : { error: t.badBudget };
}
case 'rooms':
return viaButton ? { patch: { rooms: value } } : buttonOnly;
case 'term':
return viaButton && (RENT_TERMS as readonly string[]).includes(value) ? { patch: { term: value as RentTerm } } : buttonOnly;
case 'who':
return viaButton ? { patch: { who: value as 'alone' } } : buttonOnly;
case 'children':
return viaButton ? { patch: { children: Number(value) || 0 } } : buttonOnly;
case 'childrenAge':
return { patch: { childrenAge: value.slice(0, 100) } };
case 'pets':
return viaButton ? { patch: { pets: Number(value) || 0 } } : buttonOnly;
case 'petsAge':
return { patch: { petsAge: value.slice(0, 100) } };
case 'name':
return { patch: { name: (viaButton ? (s.tgName ?? 'Telegram') : value).slice(0, 120) } };
case 'phone': {
if (viaButton) return { patch: { phone: null } };
return value.replace(/\D/g, '').length >= 6 ? { patch: { phone: value.slice(0, 40) } } : { error: t.badPhone };
}
}
}
/** Анкета заполнена: клиент в CRM и сводка автору ссылки в основной бот. */
async function complete(db: Db, s: Session, a: Answers) {
return db.transaction(async (tx) => {
const [link] = await tx.select().from(intakeLinks).where(eq(intakeLinks.token, s.linkToken));
const [owner] = await tx.select().from(users).where(eq(users.id, link!.userId));
// Тот же человек уже заполнял анкету этого агента — обновляем его карточку, а не плодим вторую.
const [earlier] = await tx
.select({ clientId: intakeSessions.clientId })
.from(intakeSessions)
.where(and(eq(intakeSessions.chatId, s.chatId), eq(intakeSessions.linkToken, s.linkToken), isNotNull(intakeSessions.clientId)))
.orderBy(desc(intakeSessions.id))
.limit(1);
const fields = {
name: a.name ?? s.tgName ?? 'Telegram',
phone: a.phone ? (normalizePhone(a.phone) ?? a.phone) : null,
// Telegram — из чата анкеты: связаться с клиентом можно и без телефона.
telegram: s.tgUsername ? `@${s.tgUsername}` : null,
rentTerm: a.deal === 'sale' ? null : (a.term ?? null),
kind: a.deal === 'sale' ? 'buyer' : 'tenant',
request: toRequest(a),
household: toHousehold(a),
};
let clientId = earlier?.clientId ?? null;
if (clientId) {
await tx.update(clients).set(fields).where(eq(clients.id, clientId));
await tx.insert(events).values({ companyId: link!.companyId, clientId, userId: null, type: 'edited' });
} else {
const fresh = await statusByCode(tx, link!.companyId, 'new', 'client');
const [client] = await tx
.insert(clients)
.values({
...fields, companyId: link!.companyId, assigneeId: link!.userId, statusId: fresh.id, source: 'Анкета в Telegram',
// Клиент попадает в отдел агента, чья это ссылка.
departmentId: owner?.departmentId ?? null,
})
.returning();
clientId = client!.id;
await tx.insert(events).values({ companyId: link!.companyId, clientId, userId: null, type: 'created' });
}
await tx.update(intakeSessions).set({ step: 'done', answers: a as Record<string, unknown>, clientId, completedAt: new Date() }).where(eq(intakeSessions.id, s.id));
await tx.update(companies).set({ lastActivityAt: new Date() }).where(eq(companies.id, link!.companyId));
// Сводка автору ссылки с вопросом, показать ли клиенту подходящие объекты.
await tx.insert(outbox).values({
kind: 'text',
userId: link!.userId,
payload: { key: 'intakeDone', args: [fields.name, fields.kind, clientId], buttons: [{ key: 'intakeMatch', callback: `match:${clientId}` }, { key: 'intakeLater', callback: `nomatch:${clientId}` }] },
});
return { clientId, owner: owner! };
});
}
/**
* Клиент написал в бот после анкеты (например, прислал телефон по просьбе агента). Сообщение — в историю
* карточки, номер — в карточку, если его там нет; агенту и суперадминам — уведомление в основной бот.
*/
async function saveReply(db: Db, clientId: number, reply: string): Promise<boolean> {
return db.transaction(async (tx) => {
const [client] = await tx.select().from(clients).where(eq(clients.id, clientId));
if (!client) return false;
const message = reply.slice(0, 1000);
const phone = message.replace(/\D/g, '').length >= 6 ? normalizePhone(message) : null;
const phoneSaved = Boolean(phone && !client.phone);
if (phoneSaved) await tx.update(clients).set({ phone }).where(eq(clients.id, clientId));
await tx.insert(events).values({ companyId: client.companyId, clientId, userId: null, type: 'comment', text: `Клиент написал в бот анкеты: ${message}` });
const admins = await tx.select({ id: users.id }).from(users).where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), sql`${users.removedAt} is null`));
const note = [`💬 ${client.name} (клиент #${clientId}) написал(а) в бот анкеты:`, message, phoneSaved ? `📞 Телефон ${phone} сохранён в карточку.` : ''].filter(Boolean).join('\n');
await enqueueRaw(tx, [...new Set([client.assigneeId, ...admins.map((a) => a.id)])], note);
return true;
});
}
/** Обрабатывает одно обновление клиентского бота. */
export async function handleIntakeUpdate({ db, tg, download }: IntakeDeps, update: TgUpdate): Promise<void> {
const from = update.message?.from ?? update.callback_query?.from;
if (!from) return;
const chatId = update.message?.chat.id ?? update.callback_query?.message?.chat.id ?? from.id;
if (chatId !== from.id) return;
const lang = from.language_code === 'hy' ? 'hy' : from.language_code === 'en' ? 'en' : 'ru';
const t = intakeTexts(lang);
const text = update.message?.text?.trim() ?? '';
const ask = async (s: Session, step: Step) => {
const q = question(step, s);
await tg.sendMessage(chatId, esc(q.text), q.keyboard);
};
const askLanguage = () =>
tg.sendMessage(chatId, LANGUAGE_QUESTION, [LANGUAGE_BUTTONS.map((b) => ({ text: b.text, callback: `q:lang:${b.lang}` }))]);
// Переход по ссылке агента: начинаем анкету заново.
const start = /^\/start(?:\s+(\S+))?$/.exec(text);
if (start) {
if (!start[1]) return void (await tg.sendMessage(chatId, t.noLink));
const [row] = await db
.select({ link: intakeLinks, agent: users.name, company: companies.name })
.from(intakeLinks)
.innerJoin(users, eq(users.id, intakeLinks.userId))
.innerJoin(companies, eq(companies.id, intakeLinks.companyId))
.where(and(eq(intakeLinks.token, start[1]), sql`${users.removedAt} is null`));
if (!row) return void (await tg.sendMessage(chatId, t.badLink));
const [session] = await db
.insert(intakeSessions)
.values({
linkToken: row.link.token,
chatId,
tgName: [from.first_name, from.last_name].filter(Boolean).join(' ') || null,
tgUsername: from.username ?? null,
lang,
// Первый вопрос — язык: по настройке Telegram его не угадать надёжно.
step: 'lang',
})
.returning();
void session;
return void (await askLanguage());
}
const [session] = await db.select().from(intakeSessions).where(eq(intakeSessions.chatId, chatId)).orderBy(desc(intakeSessions.id)).limit(1);
if (!session) {
if (update.callback_query) await tg.answerCallback(update.callback_query.id);
return void (await tg.sendMessage(chatId, t.noLink));
}
// Выбор языка: первым шагом анкеты или позже командой /lang — тогда анкета продолжается с того же вопроса.
if (text === '/lang') return void (await askLanguage());
const picked = /^q:lang:(ru|hy|en)$/.exec(update.callback_query?.data ?? '')?.[1];
if (picked) {
await tg.answerCallback(update.callback_query!.id);
const first = session.step === 'lang';
const [updated] = await db
.update(intakeSessions)
.set({ lang: picked, ...(first ? { step: 'deal' } : {}) })
.where(eq(intakeSessions.id, session.id))
.returning();
const lt = intakeTexts(picked);
if (first) {
const [row] = await db
.select({ agent: users.name, company: companies.name })
.from(intakeLinks)
.innerJoin(users, eq(users.id, intakeLinks.userId))
.innerJoin(companies, eq(companies.id, intakeLinks.companyId))
.where(eq(intakeLinks.token, session.linkToken));
await tg.sendMessage(chatId, esc(lt.hello(row?.company ?? '', row?.agent ?? '')));
return ask(updated!, 'deal');
}
await tg.sendMessage(chatId, esc(lt.langSet));
return updated!.step === 'done' ? undefined : ask(updated!, updated!.step as Step);
}
if (session.step === 'lang') {
if (update.callback_query) await tg.answerCallback(update.callback_query.id);
return void (await askLanguage());
}
const st = intakeTexts(session.lang);
// После анкеты: фото добавляются к карточке клиента.
if (session.step === 'done') {
if (update.callback_query) return tg.answerCallback(update.callback_query.id);
const photo = update.message?.photo?.at(-1)?.file_id;
const reply = update.message?.contact?.phone_number ?? text;
if (!photo && reply && session.clientId) {
return void (await tg.sendMessage(chatId, (await saveReply(db, session.clientId, reply)) ? st.replySaved : st.after));
}
if (!photo || !session.clientId) return void (await tg.sendMessage(chatId, st.after));
const [client] = await db.select().from(clients).where(eq(clients.id, session.clientId));
if (!client) return void (await tg.sendMessage(chatId, st.after));
const [{ n } = { n: 0 }] = await db.select({ n: sql<number>`count(*)::int` }).from(clientPhotos).where(eq(clientPhotos.clientId, client.id));
if (n >= 10) return void (await tg.sendMessage(chatId, st.photoLimit));
const file = await download?.(photo).catch(() => null);
if (!file || file.data.length > 5 * 1024 * 1024) return void (await tg.sendMessage(chatId, st.photoFail));
await db.insert(clientPhotos).values({
clientId: client.id, companyId: client.companyId, contentType: file.type, size: file.data.length, data: file.data.toString('base64'),
});
return void (await tg.sendMessage(chatId, st.photoSaved));
}
const step = session.step as Step;
let value = text;
let viaButton = false;
if (update.callback_query) {
const m = /^q:([a-zA-Z]+):(.+)$/.exec(update.callback_query.data ?? '');
await tg.answerCallback(update.callback_query.id);
// Нажата кнопка старого вопроса — молча игнорируем.
if (!m || m[1] !== step) return;
value = m[2]!;
viaButton = true;
} else if (!value) {
return ask(session, step);
}
const result = accept(step, session, value, viaButton);
if ('error' in result) return void (await tg.sendMessage(chatId, esc(result.error)));
const answers = { ...(session.answers as Answers), ...result.patch };
const next = nextStep(step, answers);
if (next !== 'done') {
const [updated] = await db.update(intakeSessions).set({ step: next, answers: answers as Record<string, unknown> }).where(eq(intakeSessions.id, session.id)).returning();
return ask(updated!, next);
}
const { owner } = await complete(db, session, answers);
await tg.sendMessage(chatId, esc(st.done(owner.name)));
}
/** Чат соискателя в клиентском боте — чтобы отправить ему ссылку на подборку. */
export async function seekerChat(db: Db, clientId: number): Promise<{ chatId: number; lang: string } | null> {
const [s] = await db
.select({ chatId: intakeSessions.chatId, lang: intakeSessions.lang })
.from(intakeSessions)
.where(eq(intakeSessions.clientId, clientId))
.orderBy(desc(intakeSessions.id))
.limit(1);
return s ?? null;
}
+97
View File
@@ -0,0 +1,97 @@
import type { Lang } from '@lr/shared';
// Тексты клиентского бота: анкета соискателя жилья.
const ru = {
hello: (company: string, agent: string) => `Здравствуйте! Это анкета агентства «${company}». Ответьте на несколько вопросов — и агент ${agent} подберёт для вас варианты.`,
noLink: 'Этот бот работает по ссылке от вашего агента. Попросите агента прислать ссылку на анкету.',
badLink: 'Ссылка на анкету устарела. Попросите агента прислать новую.',
deal: 'Что вы ищете?', rent: 'Снять', sale: 'Купить',
type: 'Какой объект нужен?', apartment: 'Квартира', house: 'Дом', commercial: 'Коммерческая', any: 'Не важно',
place: 'Где ищете?', yerevan: 'Весь Ереван', anywhere: 'Вся Армения',
budget: 'Какой максимальный бюджет? Напишите сумму и валюту, например: 600000 AMD или 1500 USD.', badBudget: 'Не понял сумму. Напишите число и валюту, например: 1500 USD.',
rooms: 'Сколько комнат?', rooms4: '4 и больше',
term: 'На какой срок нужна аренда?', termShort: 'До 6 месяцев', termMid: '6–12 месяцев', termLong: 'От года',
who: 'Кто будет жить?', alone: 'Один', friend: 'С другом', couple: 'С партнёром (пара)',
children: 'Будут ли жить дети?', none: 'Нет', more3: '3 и больше',
childrenAge: 'Сколько лет детям? Например: 3 и 7 лет.',
pets: 'Есть ли домашние животные?', more2: '2 и больше',
petsAge: 'Кто это и сколько лет? Например: кот, 2 года.',
name: 'Как вас зовут?', keepName: (n: string) => `Оставить «${n}»`,
phone: 'Оставьте номер телефона, чтобы агент мог с вами связаться.', badPhone: 'Похоже, это не номер телефона. Напишите его цифрами, например: 091 123 456.',
skip: 'Пропустить',
done: (agent: string) => `Спасибо, анкета отправлена. Агент ${agent} свяжется с вами.\n\nЕсли хотите, пришлите сюда фото — себя, питомца — они добавятся к анкете.`,
photoSaved: 'Фото добавлено к анкете.', photoLimit: 'К анкете уже приложено 10 фото — больше добавить нельзя.', photoFail: 'Не получилось сохранить фото. Попробуйте другое, до 5 МБ.',
replySaved: 'Спасибо, передали агенту — он скоро свяжется с вами.',
after: 'Анкета уже у агента — он свяжется с вами. Чтобы заполнить её заново, откройте ссылку от агента ещё раз.',
useButtons: 'Выберите вариант кнопкой под сообщением.',
langSet: 'Язык: русский. Сменить язык — команда /lang.',
selection: (n: number, agent: string, url: string) => `Агент ${agent} подобрал для вас варианты: ${n}. Посмотрите и запишитесь на показ:\n${url}`,
};
type Texts = typeof ru;
const hy: Texts = {
hello: (company, agent) => `Բարև Ձեզ։ Սա «${company}» գործակալության հարցաթերթիկն է։ Պատասխանեք մի քանի հարցի, և գործակալ ${agent}-ը կընտրի Ձեզ համար տարբերակներ։`,
noLink: 'Այս բոտն աշխատում է Ձեր գործակալի հղումով։ Խնդրեք գործակալին ուղարկել հարցաթերթիկի հղումը։',
badLink: 'Հարցաթերթիկի հղումը հնացել է։ Խնդրեք գործակալին ուղարկել նորը։',
deal: 'Ի՞նչ եք փնտրում', rent: 'Վարձել', sale: 'Գնել',
type: 'Ի՞նչ օբյեկտ է պետք', apartment: 'Բնակարան', house: 'Տուն', commercial: 'Կոմերցիոն', any: 'Կարևոր չէ',
place: 'Որտե՞ղ եք փնտրում', yerevan: 'Ամբողջ Երևանը', anywhere: 'Ամբողջ Հայաստանը',
budget: 'Ո՞րն է առավելագույն բյուջեն։ Գրեք գումարը և արժույթը, օրինակ՝ 600000 AMD կամ 1500 USD։', badBudget: 'Գումարը չհասկացա։ Գրեք թիվը և արժույթը, օրինակ՝ 1500 USD։',
rooms: 'Քանի՞ սենյակ', rooms4: '4 և ավելի',
term: 'Ի՞նչ ժամկետով է պետք վարձակալությունը', termShort: 'Մինչև 6 ամիս', termMid: '6–12 ամիս', termLong: 'Մեկ տարուց ավելի',
who: 'Ո՞վ է ապրելու', alone: 'Մենակ', friend: 'Ընկերոջ հետ', couple: 'Զուգընկերոջ հետ (զույգ)',
children: 'Երեխաներ ապրելո՞ւ են', none: 'Ոչ', more3: '3 և ավելի',
childrenAge: 'Քանի՞ տարեկան են երեխաները։ Օրինակ՝ 3 և 7 տարեկան։',
pets: 'Ընտանի կենդանիներ ունե՞ք', more2: '2 և ավելի',
petsAge: 'Ո՞վ է և քանի՞ տարեկան։ Օրինակ՝ կատու, 2 տարեկան։',
name: 'Ինչպե՞ս է Ձեր անունը', keepName: (n) => `Թողնել «${n}»`,
phone: 'Թողեք հեռախոսահամարը, որպեսզի գործակալը կապվի Ձեզ հետ։', badPhone: 'Կարծես սա հեռախոսահամար չէ։ Գրեք թվերով, օրինակ՝ 091 123 456։',
skip: 'Բաց թողնել',
done: (agent) => `Շնորհակալություն, հարցաթերթիկն ուղարկված է։ Գործակալ ${agent}-ը կկապվի Ձեզ հետ։\n\nԵթե ցանկանում եք, ուղարկեք այստեղ լուսանկար՝ Ձեր կամ ընտանի կենդանու, այն կավելացվի հարցաթերթիկին։`,
photoSaved: 'Լուսանկարն ավելացվեց հարցաթերթիկին։', photoLimit: 'Հարցաթերթիկին արդեն կցված է 10 լուսանկար։', photoFail: 'Չհաջողվեց պահպանել լուսանկարը։ Փորձեք մեկ այլը՝ մինչև 5 ՄԲ։',
replySaved: 'Շնորհակալություն, փոխանցեցինք գործակալին — նա շուտով կկապվի Ձեզ հետ։',
after: 'Հարցաթերթիկն արդեն գործակալի մոտ է, նա կկապվի Ձեզ հետ։ Նորից լրացնելու համար կրկին բացեք գործակալի հղումը։',
useButtons: 'Ընտրեք տարբերակը հաղորդագրության տակի կոճակով։',
langSet: 'Լեզուն՝ հայերեն։ Լեզուն փոխելու համար՝ /lang հրամանը։',
selection: (n, agent, url) => `Գործակալ ${agent}-ը Ձեզ համար ընտրել է տարբերակներ՝ ${n}։ Դիտեք և գրանցվեք ցուցադրության.\n${url}`,
};
const en: Texts = {
hello: (company, agent) => `Hello! This is the questionnaire of “${company}”. Answer a few questions and agent ${agent} will pick options for you.`,
noLink: 'This bot works by a link from your agent. Ask the agent to send you the questionnaire link.',
badLink: 'The questionnaire link has expired. Ask the agent for a new one.',
deal: 'What are you looking for?', rent: 'Rent', sale: 'Buy',
type: 'What kind of property?', apartment: 'Apartment', house: 'House', commercial: 'Commercial', any: 'Any',
place: 'Where are you looking?', yerevan: 'All of Yerevan', anywhere: 'All of Armenia',
budget: 'What is your maximum budget? Write the amount and currency, e.g. 600000 AMD or 1500 USD.', badBudget: 'I did not get the amount. Write a number and currency, e.g. 1500 USD.',
rooms: 'How many rooms?', rooms4: '4 or more',
term: 'How long do you need the rental for?', termShort: 'Up to 6 months', termMid: '6–12 months', termLong: 'A year or more',
who: 'Who will live there?', alone: 'Alone', friend: 'With a friend', couple: 'With a partner (couple)',
children: 'Will children live there?', none: 'No', more3: '3 or more',
childrenAge: 'How old are the children? E.g. 3 and 7 years.',
pets: 'Do you have pets?', more2: '2 or more',
petsAge: 'Who is it and how old? E.g. cat, 2 years.',
name: 'What is your name?', keepName: (n) => `Keep “${n}”`,
phone: 'Leave a phone number so the agent can contact you.', badPhone: 'That does not look like a phone number. Write it in digits, e.g. 091 123 456.',
skip: 'Skip',
done: (agent) => `Thank you, the questionnaire has been sent. Agent ${agent} will contact you.\n\nIf you like, send a photo here — of yourself or your pet — it will be added to the questionnaire.`,
photoSaved: 'The photo was added to the questionnaire.', photoLimit: 'There are already 10 photos attached.', photoFail: 'Could not save the photo. Try another one, up to 5 MB.',
replySaved: 'Thank you, we have passed it to the agent — they will contact you soon.',
after: 'The agent already has your questionnaire and will contact you. To fill it in again, open the agent’s link once more.',
useButtons: 'Choose an option with a button under the message.',
langSet: 'Language: English. To change it, use /lang.',
selection: (n, agent, url) => `Agent ${agent} has picked options for you: ${n}. Have a look and book a viewing:\n${url}`,
};
/** Вопрос о языке задаётся до того, как язык известен, — поэтому сразу на трёх. */
export const LANGUAGE_QUESTION = 'Выберите язык · Ընտրեք լեզուն · Choose your language';
export const LANGUAGE_BUTTONS: { lang: Lang; text: string }[] = [
{ lang: 'ru', text: 'Русский' },
{ lang: 'hy', text: 'Հայերեն' },
{ lang: 'en', text: 'English' },
];
const all: Record<Lang, Texts> = { ru, hy, en };
export const intakeTexts = (lang: string | null | undefined): Texts => all[(lang as Lang) ?? 'ru'] ?? ru;
+715
View File
@@ -0,0 +1,715 @@
import { eq, gt, sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashPassword } from './auth/password.ts';
import { createSession } from './auth/sessions.ts';
import { blacklist, clients, companies, deals, events, leads, listings, outbox, parserRuns, seenListings, sessions, users } from './db/schema.ts';
import { bumpedIds, isFresh, runIngest, type PagedSource } from './ingest/service.ts';
import { runLeadJobs } from './leads/service.ts';
import { runGroupPosts, type GroupSender } from './group/service.ts';
import { testImage } from './testing.ts';
import { LISTAM_CATEGORIES } from './parser/listam-source.ts';
import type { SourcedListing } from './parser/source.ts';
import { handleUpdate } from './telegram/bot.ts';
import { TelegramError } from './telegram/client.ts';
import { inQuietHours, processOutbox } from './telegram/outbox.ts';
import { fakeTelegram, testApp } from './testing.ts';
// Сценарии второго этапа: парсер → фильтры → лиды, уведомления в Telegram, «Беру», пул.
let t: Awaited<ReturnType<typeof testApp>>;
let tgId = 1000;
let extId = 1_000_000;
/** Управляемый источник: что лежит в `pages`, то и «опубликовано» на list.am. */
const pages = new Map<number, SourcedListing[]>();
let sourceDown = false;
let detailsDown = false;
/** Объявления, которые «есть на list.am», но ещё не попали в общую базу: их агент добавляет ссылкой. */
const items = new Map<string, SourcedListing>();
const source: PagedSource = {
name: 'fake',
item: async (externalId) => items.get(externalId) ?? null,
listPage: async (category, page = 1) => {
if (sourceDown) throw new Error('Ридер ответил 503');
return page === 1 ? (pages.get(category.id) ?? []) : [];
},
inspect: async (card) => {
if (detailsDown) throw new Error('Ридер ответил 429');
return { ...card, description: 'Светлая квартира', publishedAt: card.publishedAt ?? new Date(Date.now() - 40_000) };
},
};
const SALE = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
const listing = (over: Partial<SourcedListing> = {}): SourcedListing => {
const id = String(++extId);
return {
externalId: id, sourceUrl: `https://www.list.am/item/${id}`, title: '3-комн. квартира', dealType: 'sale',
propertyType: 'apartment', region: 'Ереван', district: 'Кентрон', price: 150_000, currency: 'USD', rooms: 3,
area: 80, floor: 4, floorsTotal: 9, isAgency: false, photoUrls: ['https://img.list.am/f/1.webp'], ...over,
};
};
beforeAll(async () => { t = await testApp('leads', source); });
afterAll(() => t.close());
const tg = fakeTelegram();
const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); };
const publish = async (...items: SourcedListing[]) => {
pages.set(SALE.id, [...items, ...(pages.get(SALE.id) ?? [])]);
const report = await runIngest({ db: t.db, source });
await flush();
return report!;
};
interface Member { api: ReturnType<typeof t.client>; id: number; chat: number; name: string }
const superadmin = async () => {
const [u] = await t.db
.insert(users)
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x'), telegramId: ++tgId })
.returning();
return { api: t.client(await createSession(t.db, u!.id)), chat: u!.telegramId! };
};
/** Компания с руководителем и агентами; у всех привязан Telegram. */
const company = async (agents = 2, plan: 'solo' | 'team' = 'team', keepBase = false) => {
// Объявления из прошлых сценариев «состариваем»: иначе новый фильтр получил бы их из базы.
if (!keepBase) await t.db.update(listings).set({ publishedAt: new Date(Date.now() - 30 * 86_400_000), bumpedAt: null });
const sa = await superadmin();
const created = await sa.api.post('/api/admin/companies', {
name: 'Агентство', plan, billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
});
const join = async (name: string, token: string): Promise<Member> => {
const api = await t.register(name);
await api.post(`/api/invites/${token}/accept`);
const id = (await api.get('/api/me')).body.user.id as number;
const chat = ++tgId;
await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id));
return { api, id, chat, name };
};
const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!);
const invite = agents ? ((await boss.api.post('/api/invites')).body.token as string) : '';
const team: Member[] = [];
for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite));
await flush();
return { sa, boss, agents: team, id: created.body.id as number };
};
const commonFilter = (m: Member, params: object = {}) =>
m.api.post('/api/filters', { name: 'Кентрон', scope: 'company', params: { districts: ['Кентрон'], ...params } });
/** Первый проход по категории только запоминает объявления — делаем его заранее. */
const baseline = () => runIngest({ db: t.db, source });
describe('парсер → фильтры → лиды', () => {
it('первый проход наполняет базу без лидов; новое объявление становится лидом в пуле', async () => {
const c = await company(2);
await commonFilter(c.boss, { dealType: 'sale', propertyTypes: ['apartment'] });
const old = listing({ publishedAt: new Date(Date.now() - 10 * 86_400_000) });
pages.set(SALE.id, [listing(), listing(), old]);
const first = await runIngest({ db: t.db, source });
await flush();
// Читаются все 10 категорий; в базу попали два свежих объявления, старое — нет.
expect(first).toMatchObject({ status: 'ok', categories: 10, foundNew: 2, leads: 0 });
expect(await t.db.select().from(listings).where(gt(listings.publishedAt, new Date(Date.now() - 86_400_000)))).toHaveLength(2);
expect((await t.db.select().from(seenListings)).map((r) => r.externalId)).toEqual([old.externalId]);
expect((await c.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(0);
const before = tg.messages.length;
const report = await publish(listing({ title: 'Новая квартира на Абовяна', address: 'ул. Абовяна 10' }));
expect(report).toMatchObject({ foundNew: 1, leads: 1 });
const pool = (await c.agents[0]!.api.get('/api/leads?tab=pool')).body;
expect(pool).toHaveLength(1);
expect(pool[0]).toMatchObject({ title: 'Новая квартира на Абовяна', assigneeId: null, source: 'listam', description: 'Светлая квартира' });
// Общий фильтр: уведомление всем участникам, с фото, ценой, районом и кнопкой «Беру».
const sent = tg.messages.slice(before);
expect(sent.map((m) => m.chatId).sort()).toEqual([c.boss.chat, ...c.agents.map((a) => a.chat)].sort());
const m = sent[0]!;
expect(m.photo).toBe('https://img.list.am/f/1.webp');
expect(m.text).toContain('$150 000');
expect(m.text).toContain('Кентрон, ул. Абовяна 10');
expect(m.text).toContain('3 комн. · 80 м² · этаж 4/9');
expect(m.text).toContain('https://www.list.am/item/');
expect(tg.hasButton(m, 'take:')).toBe(true);
// Повторный проход того же объявления лид не дублирует.
expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 0, leads: 0 });
});
it('объявление попадает в общую базу, даже если под него нет ни одного фильтра', async () => {
await baseline();
const before = (await t.db.select().from(listings)).length;
const report = await publish(listing({ district: 'Нубарашен', price: 9_999_999 }));
expect(report.foundNew).toBe(1);
expect((await t.db.select().from(listings)).length).toBe(before + 1);
});
it('новый фильтр сразу получает подходящие объявления из базы — в пул, без уведомлений', async () => {
await baseline();
await publish(listing({ district: 'Шенгавит', price: 70_000 }), listing({ district: 'Шенгавит', price: 900_000 }));
const c = await company(1, 'team', true);
const before = tg.messages.length;
const made = await c.boss.api.post('/api/filters', { name: 'Шенгавит', scope: 'company', params: { districts: ['Шенгавит'], priceTo: 100_000 } });
expect(made.body.fromBase).toBe(1);
await flush();
expect(tg.messages.length).toBe(before);
const pool = (await c.agents[0]!.api.get('/api/leads?tab=pool')).body;
expect(pool).toMatchObject([{ district: 'Шенгавит', price: 70_000, assigneeId: null }]);
// Фильтр расширили — догружается остальное; уже созданный лид не дублируется.
const wider = await c.boss.api.patch(`/api/filters/${made.body.id}`, { params: { districts: ['Шенгавит'] } });
expect(wider.body.fromBase).toBe(1);
expect((await c.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(2);
// Выключенный фильтр ничего не догружает — и правка названия его не включает обратно.
expect((await c.boss.api.patch(`/api/filters/${made.body.id}`, { enabled: false })).body.fromBase).toBe(0);
await c.boss.api.patch(`/api/filters/${made.body.id}`, { name: 'Шенгавит, все' });
expect((await c.boss.api.get('/api/filters')).body).toMatchObject([{ name: 'Шенгавит, все', enabled: false }]);
});
it('свежесть: в базу идут объявления, размещённые не раньше чем 3 дня назад', () => {
const now = Date.UTC(2026, 9, 8, 12); // 8 октября, 16:00 по Еревану
const day = (d: number) => new Date(Date.UTC(2026, 9, d) - 4 * 3_600_000); // «Размещено d.10.2026»
expect(isFresh(day(8), now)).toBe(true);
expect(isFresh(day(5), now)).toBe(true);
expect(isFresh(day(4), now)).toBe(false);
});
it('порядок списка: поднятое объявление встаёт наверх, по нему нет лишних лидов', () => {
expect(bumpedIds(['5', '1', '2', '3', '4'], ['1', '2', '3', '4', '5'])).toEqual(new Set(['5']));
// Сдвиг вниз — не поднятие. Незнакомое сверху тоже помечается: новое оно или поднятое, решает база.
expect(bumpedIds(['9', '1', '2', '3'], ['1', '2', '3', '4'])).toEqual(new Set(['9']));
expect(bumpedIds(['1', '2', '3'], ['0', '1', '2', '3'])).toEqual(new Set());
// Объявление, которого на прошлом проходе в начале списка не было, оказалось выше прежних.
expect(bumpedIds(['7', '1', '2'], ['1', '2'])).toEqual(new Set(['7']));
// Без прошлого порядка поднятие не определить.
expect(bumpedIds(['2', '1'], [])).toEqual(new Set());
});
it('поднятое объявление: старое идёт в базу лидом, знакомое встаёт наверх; «Топ» — по дате', async () => {
const c = await company(0);
await commonFilter(c.boss);
await baseline();
const list = async () => (await c.boss.api.get('/api/leads?tab=all')).body as { id: number; url: string; bumpedAt: string | null }[];
// Объявление недельной давности, которое автор поднял: в базе его не было — теперь это лид.
const old = listing({ publishedAt: new Date(Date.now() - 7 * 86_400_000) });
// Лиды получают и компании из других сценариев, поэтому смотрим свой список.
expect(await publish(old)).toMatchObject({ foundNew: 1 });
expect((await list())[0]).toMatchObject({ url: old.sourceUrl, bumpedAt: expect.any(String) });
// Даже с ID далеко ниже последних.
expect(await publish(listing({ externalId: '10', sourceUrl: 'https://www.list.am/item/10' }))).toMatchObject({ foundNew: 1 });
// Объявление из базы ушло вниз, затем его подняли: лид встаёт наверх, второго лида нет.
const known = listing();
await publish(known);
await publish(listing());
expect((await list())[0]!.url).not.toBe(known.sourceUrl);
pages.set(SALE.id, [known, ...pages.get(SALE.id)!.filter((x) => x.externalId !== known.externalId)]);
expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 0, bumped: 1, leads: 0 });
expect((await list())[0]).toMatchObject({ url: known.sourceUrl, bumpedAt: expect.any(String) });
// Следующий проход то же поднятие повторно не считает.
expect(await runIngest({ db: t.db, source })).toMatchObject({ bumped: 0 });
// Блок «Топ» ротируется: старое объявление оттуда в базу не идёт.
const top = listing({ promoted: true, publishedAt: new Date(Date.now() - 7 * 86_400_000) });
pages.set(SALE.id, [...pages.get(SALE.id)!, top]);
expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 0 });
expect((await t.db.select().from(seenListings)).map((r) => r.externalId)).toContain(top.externalId);
// А поднятое в обычном списке ранее пропущенное объявление — идёт.
pages.set(SALE.id, [{ ...top, promoted: false }, ...pages.get(SALE.id)!.filter((x) => x.externalId !== top.externalId)]);
expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 1 });
});
it('страницу объявления не удалось прочитать — оно повторяется на следующем проходе', async () => {
const c = await company(0);
await commonFilter(c.boss);
await baseline();
const count = async () => (await c.boss.api.get('/api/leads?tab=all')).body.length as number;
detailsDown = true;
const failed = await publish(listing());
expect(failed).toMatchObject({ foundNew: 0 });
expect(failed.errors[0]).toContain('429');
detailsDown = false;
// Лиды получают и компании из других сценариев, поэтому считаем только свою.
expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 1 });
expect(await count()).toBe(1);
});
it('личный фильтр: уведомление получает только его владелец; пауза и тихие часы', async () => {
const c = await company(3);
const [a1, a2, a3] = c.agents as [Member, Member, Member];
await baseline();
await a1.api.post('/api/filters', { name: 'Мой', scope: 'personal', params: { districts: ['Арабкир'] } });
await a2.api.post('/api/filters', { name: 'Мой', scope: 'personal', params: { districts: ['Арабкир'] } });
await a2.api.patch('/api/me', { notificationsPaused: true });
expect((await a3.api.post('/api/filters', { name: 'Общий', scope: 'company', params: {} })).status).toBe(403);
const before = tg.messages.length;
await publish(listing({ district: 'Арабкир' }));
expect(tg.messages.slice(before).map((m) => m.chatId)).toEqual([a1.chat]);
// Лид в пуле виден всем, даже тем, кто не получил уведомление.
expect((await a3.api.get('/api/leads?tab=pool')).body).toHaveLength(1);
const at = (h: number) => new Date(Date.UTC(2026, 9, 8, h - 4, 30)); // h — час по Еревану
expect(inQuietHours({ quietFrom: '22:00', quietTo: '08:00' }, at(23))).toBe(true);
expect(inQuietHours({ quietFrom: '22:00', quietTo: '08:00' }, at(7))).toBe(true);
expect(inQuietHours({ quietFrom: '22:00', quietTo: '08:00' }, at(12))).toBe(false);
expect(inQuietHours({ quietFrom: '13:00', quietTo: '14:00' }, at(13))).toBe(true);
expect(inQuietHours({ quietFrom: null, quietTo: null }, at(13))).toBe(false);
});
it('«только собственники»: метка агентства, порог N и чёрный список не создают лид', async () => {
const c = await company(0);
await commonFilter(c.boss);
await baseline();
const count = async () => (await c.boss.api.get('/api/leads?tab=all')).body.length as number;
await publish(listing({ isAgency: true }));
expect(await count()).toBe(0);
// Порог N = 3: четвёртое активное объявление с одного номера — уже агент.
const phone = '+37491000111';
await publish(listing({ phone }), listing({ phone }), listing({ phone }));
expect(await count()).toBe(3);
await publish(listing({ phone }));
expect(await count()).toBe(3);
await t.db.insert(blacklist).values({ companyId: c.id, phone: '+37477000222' });
await publish(listing({ phone: '077 000 222' }));
expect(await count()).toBe(3);
// «Все объявления» — агентские тоже приходят.
// Такой фильтр сразу забирает из базы три отсеянных выше объявления.
const all = await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { districts: ['Кентрон'], ownersOnly: false } });
expect(all.body.fromBase).toBe(3);
await publish(listing({ isAgency: true }));
expect(await count()).toBe(7);
});
it('одно объявление порождает лиды в разных компаниях; компания без доступа лидов не получает', async () => {
const a = await company(0);
const b = await company(0);
const off = await company(0);
for (const c of [a, b, off]) await commonFilter(c.boss);
await t.db.update(companies).set({ subscriptionEndsAt: new Date(Date.now() - 16 * 86_400_000) }).where(eq(companies.id, off.id));
await baseline();
await publish(listing());
expect((await a.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(1);
expect((await b.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(1);
expect((await off.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(0);
// Чужой лид недоступен по ID.
const idA = (await a.boss.api.get('/api/leads?tab=pool')).body[0].id;
expect((await b.boss.api.get(`/api/leads/${idA}`)).status).toBe(404);
expect((await b.boss.api.post(`/api/leads/${idA}/take`)).status).toBe(404);
});
it('сбой источника: запуск помечен ошибкой, суперадмин получает алерт в Telegram', async () => {
const c = await company(0);
await commonFilter(c.boss);
sourceDown = true;
const report = await runIngest({ db: t.db, source });
sourceDown = false;
await flush();
expect(report).toMatchObject({ status: 'failed' });
expect(tg.inbox(c.sa.chat).at(-1)!.text).toContain('Ридер ответил 503');
const [run] = await t.db.select().from(parserRuns).where(eq(parserRuns.status, 'failed'));
expect(run!.errors).toContain('503');
expect((await c.sa.api.get('/api/admin/parser')).body).toMatchObject({ enabled: true, source: 'fake' });
});
});
/** У каждого лида свой номер: иначе сработал бы порог «больше 3 объявлений — агент». */
let phoneSeq = 100000;
let lastPhone = '';
const OWNER_PHONE = () => (lastPhone = `+37493${++phoneSeq}`);
describe('«Беру» и пул', () => {
const withLead = async (agents = 2) => {
const c = await company(agents);
await commonFilter(c.boss);
await baseline();
await publish(listing({ phone: OWNER_PHONE() }));
const leadId = (await c.boss.api.get('/api/leads?tab=pool')).body[0].id as number;
return { ...c, leadId };
};
it('при одновременном нажатии лид получает один агент, второй видит «Уже взят»', async () => {
const c = await withLead(2);
const [a1, a2] = c.agents as [Member, Member];
const results = await Promise.all([a1.api.post(`/api/leads/${c.leadId}/take`), a2.api.post(`/api/leads/${c.leadId}/take`)]);
expect(results.map((r) => r.status).sort()).toEqual([200, 409]);
expect(results.find((r) => r.status === 409)!.body.code).toBe('ALREADY_TAKEN');
const winner = results[0]!.status === 200 ? a1 : a2;
const loser = winner === a1 ? a2 : a1;
await flush();
// У победителя — «Вы взяли», у остальных — «Взял(а) Имя», кнопка «Беру» исчезла у всех.
const msg = (m: Member) => tg.inbox(m.chat).at(-1)!;
expect(msg(winner).text).toContain('Вы взяли');
expect(msg(loser).text).toContain(`Взял(а) ${winner.name}`);
expect(msg(c.boss).text).toContain(`Взял(а) ${winner.name}`);
for (const m of [winner, loser, c.boss]) expect(tg.hasButton(msg(m), 'take:')).toBe(false);
const card = (await winner.api.get(`/api/leads/${c.leadId}`)).body;
expect(card).toMatchObject({ assigneeId: winner.id, assigneeName: winner.name, phone: lastPhone });
expect(card.events.map((e: { type: string }) => e.type)).toEqual(['taken', 'created']);
expect((await winner.api.get('/api/leads?tab=mine')).body).toHaveLength(1);
expect((await loser.api.get('/api/leads?tab=pool')).body).toHaveLength(0);
});
it('кнопка «Беру» в боте; чужой Telegram и взятый лид получают отказ', async () => {
const c = await withLead(2);
const [a1, a2] = c.agents as [Member, Member];
const press = (chat: number, id: string) =>
handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { update_id: 1, callback_query: { id, from: { id: chat }, data: `take:${c.leadId}` } });
await press(999_999, 'cb-stranger');
await press(a1.chat, 'cb-1');
await press(a2.chat, 'cb-2');
const answer = (id: string) => tg.answers.find((a) => a.id === id)!.text;
expect(answer('cb-stranger')).toContain('привяжите Telegram');
expect(answer('cb-1')).toBe('Объект ваш');
expect(answer('cb-2')).toBe('Уже взят');
expect((await a1.api.get(`/api/leads/${c.leadId}`)).body.assigneeId).toBe(a1.id);
});
it('видимость: агент не видит чужие лиды, пока руководитель не включит «видят все»', async () => {
const c = await withLead(2);
const [a1, a2] = c.agents as [Member, Member];
await a1.api.post(`/api/leads/${c.leadId}/take`);
expect((await a2.api.get(`/api/leads/${c.leadId}`)).status).toBe(404);
expect((await a2.api.get('/api/leads?tab=all')).status).toBe(403);
expect((await c.boss.api.get('/api/leads?tab=all')).body).toHaveLength(1);
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
const seen = (await a2.api.get(`/api/leads/${c.leadId}`)).body;
expect(seen).toMatchObject({ assigneeId: a1.id, phone: null, phoneHidden: true });
expect((await a2.api.get('/api/leads?tab=all')).body).toHaveLength(1);
// Видеть — не значит вести: статус чужого лида агент не меняет.
expect((await a2.api.post(`/api/leads/${c.leadId}/comments`, { text: 'мой' })).body.code).toBe('NOT_YOUR_LEAD');
});
it('через 24 часа без смены статуса лид возвращается в пул: предупреждение, потеря, повторное «Беру»', async () => {
const c = await withLead(2);
const [a1, a2] = c.agents as [Member, Member];
await a1.api.post(`/api/leads/${c.leadId}/take`);
await flush();
const hoursAgo = (h: number) => t.db.update(leads).set({ statusChangedAt: new Date(Date.now() - h * 3_600_000) }).where(eq(leads.id, c.leadId));
await hoursAgo(21);
expect(await runLeadJobs(t.db)).toMatchObject({ warned: 0, returned: 0 });
await hoursAgo(22.01);
expect(await runLeadJobs(t.db)).toMatchObject({ warned: 1, returned: 0 });
expect(await runLeadJobs(t.db)).toMatchObject({ warned: 0 }); // предупреждение одно
await flush();
expect(tg.inbox(a1.chat).at(-1)!.text).toContain('вернётся в пул через 2 ч');
await hoursAgo(24.1);
const before = tg.messages.length;
expect(await runLeadJobs(t.db)).toMatchObject({ returned: 1 });
await flush();
const after = tg.messages.slice(before);
expect(after.find((m) => m.chatId === a1.chat)!.text).toContain('вернулся в пул');
const again = after.filter((m) => tg.hasButton(m, 'take:'));
expect(again.map((m) => m.chatId).sort()).toEqual([c.boss.chat, a2.chat].sort());
expect(again[0]!.text).toContain('снова в пуле');
const card = (await a2.api.get(`/api/leads/${c.leadId}`)).body;
expect(card.assigneeId).toBeNull();
expect(card.events.map((e: { type: string }) => e.type)).toEqual(['pool_return', 'taken', 'created']);
expect(card.events[0]).toMatchObject({ userName: null, oldValue: a1.name });
expect((await a2.api.post(`/api/leads/${c.leadId}/take`)).status).toBe(200);
});
it('смена статуса сбрасывает таймер; после «Договор» лид в пул не возвращается', async () => {
const c = await withLead(1);
const a1 = c.agents[0]!;
await a1.api.post(`/api/leads/${c.leadId}/take`);
const statuses = (await a1.api.get('/api/pipelines/object')).body.statuses as { id: number; name: string; code: string | null; poolReturn: boolean }[];
expect(statuses.filter((s) => s.poolReturn).map((s) => s.name)).toEqual(['Взят в работу', 'Дозвонился', 'Встреча с собственником']);
const contract = statuses.find((s) => s.name.startsWith('Договор'))!;
await a1.api.post(`/api/leads/${c.leadId}/status`, { statusId: contract.id });
await t.db.update(leads).set({ statusChangedAt: new Date(Date.now() - 100 * 3_600_000) }).where(eq(leads.id, c.leadId));
expect(await runLeadJobs(t.db)).toMatchObject({ warned: 0, returned: 0 });
expect((await a1.api.get(`/api/leads/${c.leadId}`)).body.assigneeId).toBe(a1.id);
});
it('руководитель переназначает лид: оба агента получают уведомление', async () => {
const c = await withLead(2);
const [a1, a2] = c.agents as [Member, Member];
expect((await a1.api.post(`/api/leads/${c.leadId}/assign`, { userId: a1.id })).status).toBe(403);
await c.boss.api.post(`/api/leads/${c.leadId}/assign`, { userId: a1.id });
await flush();
expect(tg.inbox(a1.chat).at(-1)!.text).toContain('Вам назначен объект');
expect(tg.inbox(a2.chat).at(-1)!.text).toContain(`Взял(а) ${a1.name}`);
await c.boss.api.post(`/api/leads/${c.leadId}/assign`, { userId: a2.id });
await flush();
expect(tg.inbox(a2.chat).at(-1)!.text).toContain('Вам назначен объект');
expect(tg.inbox(a1.chat).at(-1)!.text).toContain(`передан агенту ${a2.name}`);
const card = (await c.boss.api.get(`/api/leads/${c.leadId}`)).body;
expect(card.events[0]).toMatchObject({ type: 'assigned', oldValue: a1.name, newValue: a2.name });
});
it('отказ требует причину, сделка — комиссию; «Это агент» заносит номер в чёрный список', async () => {
const c = await withLead(1);
const a1 = c.agents[0]!;
const { statuses, reasons } = (await a1.api.get('/api/pipelines/object')).body as {
statuses: { id: number; code: string | null }[]; reasons: { id: number; code: string | null }[];
};
const st = (code: string) => statuses.find((s) => s.code === code)!.id;
const move = (body: object) => a1.api.post(`/api/leads/${c.leadId}/status`, body);
expect((await move({ statusId: st('taken') })).body.code).toBe('TAKE_FIRST');
await a1.api.post(`/api/leads/${c.leadId}/take`);
expect((await move({ statusId: st('new') })).body.code).toBe('USE_RETURN_TO_POOL');
expect((await move({ statusId: st('rejected') })).body.code).toBe('REJECT_REASON_REQUIRED');
expect((await move({ statusId: st('deal') })).body.code).toBe('COMMISSION_REQUIRED');
expect((await move({ statusId: st('deal'), deal: { commission: 2500, currency: 'USD', date: '2026-10-08' } })).status).toBe(200);
const card = (await a1.api.get(`/api/leads/${c.leadId}`)).body;
expect(card.events[0]).toMatchObject({ type: 'deal', newValue: 'Сделка', text: '2500 USD' });
// Отдельный лид: «Это агент».
await publish(listing({ phone: '+37455999888' }));
const second = (await a1.api.get('/api/leads?tab=pool')).body[0].id as number;
expect((await a1.api.post(`/api/leads/${second}/agent`)).status).toBe(200);
const rejected = (await a1.api.get(`/api/leads/${second}`)).body;
expect(rejected.statusId).toBe(st('rejected'));
expect(rejected.rejectReasonId).toBe(reasons.find((r) => r.code === 'agent')!.id);
expect(await t.db.select().from(blacklist).where(eq(blacklist.companyId, c.id))).toMatchObject([{ phone: '+37455999888' }]);
// Следующее объявление с этого номера лид уже не создаёт.
await publish(listing({ phone: '+37455999888' }));
expect((await a1.api.get('/api/leads?tab=pool')).body).toHaveLength(0);
});
it('лид вручную, комментарий, напоминание, возврат в пул; при ограниченном доступе — только чтение', async () => {
const c = await company(1);
const a1 = c.agents[0]!;
const made = await a1.api.post('/api/leads', { title: 'Дом в Абовяне', phone: '091 22 33 44', price: 90000, region: 'Котайк' });
expect(made.status).toBe(200);
const id = made.body.id as number;
expect((await a1.api.get(`/api/leads/${id}`)).body).toMatchObject({ source: 'manual', assigneeId: a1.id, phone: '+37491223344' });
await a1.api.post(`/api/leads/${id}/comments`, { text: 'Перезвонить вечером' });
await a1.api.post(`/api/leads/${id}/next-action`, { at: new Date(Date.now() - 1000).toISOString(), note: 'Звонок' });
expect(await runLeadJobs(t.db)).toMatchObject({ reminded: 1 });
expect(await runLeadJobs(t.db)).toMatchObject({ reminded: 0 });
await flush();
expect(tg.inbox(a1.chat).at(-1)!.text).toContain('Напоминание по объекту «Дом в Абовяне»: Звонок');
expect((await a1.api.get('/api/leads?tab=mine&q=223344')).body).toHaveLength(1);
expect((await a1.api.get('/api/leads?tab=mine&q=Абовян')).body).toHaveLength(1);
expect((await a1.api.get('/api/leads?tab=mine&q=Гюмри')).body).toHaveLength(0);
await a1.api.post(`/api/leads/${id}/release`);
expect((await a1.api.get('/api/leads-summary')).body).toMatchObject({ pool: 1, mine: 0 });
await t.db.update(companies).set({ subscriptionEndsAt: new Date(Date.now() - 16 * 86_400_000) }).where(eq(companies.id, c.id));
expect((await a1.api.post(`/api/leads/${id}/take`)).body.code).toBe('SUBSCRIPTION_REQUIRED');
expect((await a1.api.get(`/api/leads/${id}`)).status).toBe(200);
});
});
describe('очередь Telegram и бот', () => {
it('при ошибке отправки сообщение повторяется; заблокированный бот не повторяется', async () => {
const c = await company(0);
await flush();
const send = async () => {
await t.db.insert(outbox).values({ kind: 'text', userId: c.boss.id, payload: { raw: 'Привет <всем>' } });
const [job] = await t.db.select().from(outbox).where(eq(outbox.userId, c.boss.id)).orderBy(outbox.id).then((r) => r.slice(-1));
return job!.id;
};
const job = (id: number) => t.db.select().from(outbox).where(eq(outbox.id, id)).then((r) => r[0]!);
const first = await send();
tg.failOnce(new Error('network down'));
await flush();
expect(await job(first)).toMatchObject({ doneAt: null, attempts: 1, error: 'network down' });
await t.db.update(outbox).set({ runAt: sql`now() - interval '1 second'` }).where(eq(outbox.id, first));
await flush();
expect((await job(first)).doneAt).not.toBeNull();
expect(tg.inbox(c.boss.chat).at(-1)!.text).toBe('Привет &lt;всем&gt;');
const second = await send();
tg.failOnce(new TelegramError(403, 'Forbidden: bot was blocked by the user'));
await flush();
expect((await job(second)).error).toContain('failed: Forbidden');
expect((await job(second)).doneAt).not.toBeNull();
});
it('привязка Telegram по ссылке из кабинета, /pause и /resume', async () => {
const api = await t.register('Нарек');
const url = (await api.post('/api/me/telegram-link')).body.url as string;
expect(url).toMatch(/^https:\/\/t\.me\/test_bot\?start=/);
const code = url.split('start=')[1]!;
const chat = ++tgId;
const say = (text: string, from = chat) =>
handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { update_id: 1, message: { chat: { id: from }, from: { id: from, username: 'narek' }, text } });
await say('/start wrong-code');
expect(tg.inbox(chat).at(-1)!.text).toContain('устарела');
await say(`/start ${code}`);
expect(tg.inbox(chat).at(-1)!.text).toContain('Готово, Нарек');
expect((await api.get('/api/me')).body.user.telegramLinked).toBe(true);
await say(`/start ${code}`, ++tgId); // код одноразовый
expect(tg.inbox(tgId).at(-1)!.text).toContain('устарела');
await say('/pause');
expect((await api.get('/api/me')).body.user.notificationsPaused).toBe(true);
await say('/resume');
expect((await api.get('/api/me')).body.user.notificationsPaused).toBe(false);
});
it('руководитель узнаёт в боте о новом агенте и о достижении лимита', async () => {
const c = await company(0, 'team');
const invite = (await c.boss.api.post('/api/invites')).body.token as string;
for (let i = 0; i < 9; i++) await (await t.register(`Новичок ${i}`)).post(`/api/invites/${invite}/accept`);
await flush();
const inbox = tg.inbox(c.boss.chat).map((m) => m.text);
expect(inbox.filter((x) => x.includes('присоединился к компании'))).toHaveLength(9);
expect(inbox.at(-1)).toContain('Достигнут лимит участников');
});
});
describe('объект по ссылке на list.am, районы, формулировки и удаление', () => {
it('свободный объект закрепляется за агентом, занятый — показывает чей, нового — скачивается', async () => {
const c = await company(2);
await commonFilter(c.boss, { dealType: 'sale' });
await baseline();
const [a1, a2] = c.agents as [Member, Member];
const free = listing();
await publish(free);
const url = `https://www.list.am/ru/item/${free.externalId}?w=1`;
const taken = await a1.api.post('/api/leads/by-url', { url });
expect(taken.body).toMatchObject({ result: 'taken' });
expect((await a1.api.get(`/api/leads/${taken.body.id}`)).body).toMatchObject({ assigneeId: a1.id });
expect((await a1.api.post('/api/leads/by-url', { url })).body).toEqual({ result: 'mine', id: taken.body.id });
// Чужой лид агенту без общей видимости не открыть — только имя.
expect((await a2.api.post('/api/leads/by-url', { url })).body).toEqual({ result: 'busy', id: null, assigneeName: 'Агент 1' });
// Объявления нет в CRM и в общей базе: скачивается с list.am и сразу закрепляется за агентом.
const fresh = listing({ title: '1-комн. квартира у метро', district: 'Арабкир' });
items.set(fresh.externalId, fresh);
const created = await a2.api.post('/api/leads/by-url', { url: `list.am/item/${fresh.externalId}` });
expect(created.body).toMatchObject({ result: 'created' });
const card = (await a2.api.get(`/api/leads/${created.body.id}`)).body;
expect(card).toMatchObject({ title: '1-комн. квартира у метро', assigneeId: a2.id, district: 'Арабкир', source: 'listam' });
// Тот же объект ссылкой у первого агента — занят вторым.
expect((await a1.api.post('/api/leads/by-url', { url: fresh.sourceUrl })).body).toMatchObject({ result: 'busy', assigneeName: 'Агент 2' });
expect((await a1.api.post('/api/leads/by-url', { url: 'https://www.list.am/item/99999999' })).body.code).toBe('LISTAM_NOT_FOUND');
expect((await a1.api.post('/api/leads/by-url', { url: 'https://example.com/1' })).body.code).toBe('BAD_LISTAM_URL');
// Фильтр по нескольким районам.
const ids = async (districts: string) => ((await c.boss.api.get(`/api/leads?tab=all&districts=${encodeURIComponent(districts)}`)).body as { id: number }[]).map((l) => l.id);
expect(await ids('Кентрон,Арабкир')).toEqual(expect.arrayContaining([taken.body.id, created.body.id]));
expect(await ids('арабкир')).toEqual([created.body.id]);
expect(await ids('Шенгавит')).toEqual([]);
});
it('объявление посредника или собственника против агентов не приходит лидом', async () => {
const c = await company(1);
await commonFilter(c.boss, { dealType: 'sale', ownersOnly: true });
await baseline();
await publish(listing({ title: '2-комн. квартира, агентам не беспокоить' }), listing({ title: '2-комн. квартира' }));
const pool = (await c.boss.api.get('/api/leads?tab=pool')).body as { title: string }[];
expect(pool.map((l) => l.title)).toEqual(['2-комн. квартира']);
});
it('карточки удаляет руководитель; карточку со сделкой — только суперадмин', async () => {
const c = await company(1);
const [agent] = c.agents as [Member];
const id = (await agent.api.post('/api/leads', { title: 'Дом' })).body.id as number;
await t.db.insert(events).values({ companyId: c.id, leadId: id, type: 'comment', text: 'x' });
expect((await agent.api.del(`/api/leads/${id}`)).status).toBe(403);
expect((await c.boss.api.del(`/api/leads/${id}`)).status).toBe(200);
expect(await t.db.select().from(leads).where(eq(leads.id, id))).toHaveLength(0);
const client = (await agent.api.post('/api/clients', { name: 'Гор', kind: 'tenant', telegram: 'https://t.me/gor_k', rentTerm: 'long' })).body.id as number;
expect((await agent.api.get(`/api/clients/${client}`)).body).toMatchObject({ telegram: '@gor_k', rentTerm: 'long' });
const withDeal = (await agent.api.post('/api/leads', { title: 'Квартира' })).body.id as number;
await t.db.insert(deals).values({
companyId: c.id, leadId: withDeal, clientId: client, agentId: agent.id, commission: 100, ownerCommission: 50, clientCommission: 50,
ownerAgentId: agent.id, clientAgentId: agent.id, currency: 'USD', dealDate: '2026-10-01',
});
expect((await c.boss.api.del(`/api/leads/${withDeal}`)).body.code).toBe('HAS_DEAL');
expect((await c.boss.api.del(`/api/clients/${client}`)).body.code).toBe('HAS_DEAL');
// Суперадмин в кабинете компании: объект удаляется, сделка остаётся за клиентом; потом и клиент.
const [sa] = await t.db.select().from(users).where(eq(users.role, 'superadmin'));
const token = await createSession(t.db, sa!.id);
await t.db.update(sessions).set({ actingCompanyId: c.id });
const admin = t.client(token);
expect((await admin.del(`/api/leads/${withDeal}`)).status).toBe(200);
expect(await t.db.select().from(deals).where(eq(deals.clientId, client))).toMatchObject([{ leadId: null }]);
expect((await admin.del(`/api/clients/${client}`)).status).toBe(200);
expect(await t.db.select().from(clients).where(eq(clients.id, client))).toHaveLength(0);
expect(await t.db.select().from(deals).where(eq(deals.clientId, client))).toHaveLength(0);
});
});
describe('группа компании в Telegram', () => {
it('объект в работе с комиссией уходит в группу; новая комиссия — снова; токен в кабинет не попадает', async () => {
const c = await company(1);
const [agent] = c.agents as [Member];
const [co] = await t.db.select().from(companies).where(eq(companies.id, c.id));
await t.db.update(companies).set({ settings: { ...co!.settings, groupBot: { token: 'GROUP:TOKEN', chatId: -100500, threadId: 7 } } }).where(eq(companies.id, c.id));
expect((await c.boss.api.get('/api/me')).body.company.settings.groupBot).toBeUndefined();
// Сохранение настроек руководителем бот группы не стирает.
await c.boss.api.patch('/api/company', { agentsSeeAll: true });
const sent: { kind: string; chatId: number; token: string; threadId?: number; text?: string; photos?: number }[] = [];
const sender: GroupSender = {
album: async (bot, photos) => void sent.push({ kind: 'album', ...bot, photos: photos.length }),
text: async (bot, text) => void sent.push({ kind: 'text', ...bot, text }),
};
const run = () => runGroupPosts({ db: t.db, config: t.config, sender, fetchImage: async () => testImage, pause: 0 });
const id = (await agent.api.post('/api/leads', { title: '2-комн. квартира', district: 'Кентрон', dealType: 'rent', price: 350000, currency: 'AMD' })).body.id as number;
// Без комиссии в карточке — не отправляется.
expect(await run()).toBe(0);
await agent.api.post(`/api/leads/${id}/commission`, { rule: { mode: 'percent', value: 50 } });
expect(await run()).toBe(1);
// Описание и отдельным сообщением — кто дозвонился и комиссия.
expect(sent).toMatchObject([{ kind: 'text', token: 'GROUP:TOKEN', chatId: -100500, threadId: 7 }, { kind: 'text' }]);
expect(sent[0]!.text).toContain('<b>2-комн. квартира</b>');
expect(sent[0]!.text).toContain('🏡 Кентрон');
expect(sent[0]!.text).not.toContain('Дозвонился');
expect(sent[1]!.text).toBe('👤 Дозвонился: <b>Агент 1</b>\n💰 Комиссия: <b>50 % (≈ 175 000 ֏)</b>');
expect(await run()).toBe(0);
await agent.api.post(`/api/leads/${id}/commission`, { rule: { mode: 'fixed', value: 300, currency: 'USD' } });
expect(await run()).toBe(1);
expect(sent.at(-2)!.text).toContain('Комиссия изменилась');
expect(sent.at(-1)!.text).toContain('Комиссия: <b>$300</b>');
// Объект с фото из объявления: фото уходят альбомом перед текстом.
const photoLead = (await agent.api.post('/api/leads', { title: 'Дом' })).body.id as number;
await t.db.update(leads).set({ photos: ['https://img.list.am/f/1/1.webp', 'https://img.list.am/f/1/2.webp'] }).where(eq(leads.id, photoLead));
await agent.api.post(`/api/leads/${photoLead}/commission`, { rule: { mode: 'percent', value: 100 } });
await run();
expect(sent.slice(-3).map((m) => m.kind)).toEqual(['album', 'text', 'text']);
expect(sent.at(-3)!.photos).toBe(2);
// Кнопка «Отправить в Telegram»: сразу, комиссия — из настроек компании, если в карточке её нет.
const manual = (await agent.api.post('/api/leads', { title: 'Офис', dealType: 'sale', price: 100000, currency: 'USD' })).body.id as number;
t.groupSent.length = 0;
expect((await c.boss.api.get('/api/me')).body.company.groupBot).toBe(true);
expect((await agent.api.post(`/api/leads/${manual}/group`)).status).toBe(200);
expect(t.groupSent).toMatchObject([{ kind: 'text', chatId: -100500, threadId: 7 }, { kind: 'text' }]);
expect(t.groupSent[0]!.text).toContain('<b>Офис</b>');
expect(t.groupSent[1]!.text).toContain('Дозвонился: <b>Агент 1</b>');
// Без бота у компании — понятная ошибка.
const other = await company(0);
const foreign = (await other.boss.api.post('/api/leads', { title: 'Дом' })).body.id as number;
expect((await other.boss.api.get('/api/me')).body.company.groupBot).toBe(false);
expect((await other.boss.api.post(`/api/leads/${foreign}/group`)).body.code).toBe('GROUP_BOT_NOT_SET');
// Отказ: смена комиссии в группу уже не уходит.
const rejected = (await c.boss.api.get('/api/pipelines/object')).body;
const status = rejected.statuses.find((x: { code: string }) => x.code === 'rejected');
await agent.api.post(`/api/leads/${id}/status`, { statusId: status.id, rejectReasonId: rejected.reasons[0].id });
await agent.api.post(`/api/leads/${id}/commission`, { rule: { mode: 'percent', value: 30 } });
expect(await run()).toBe(0);
});
});
+611
View File
@@ -0,0 +1,611 @@
import { agentPhrase, CommissionRule, CURRENCIES, DEAL_TYPES, FilterParams, PROPERTY_TYPES, type AccessState, type Role } from '@lr/shared';
import { commissionDelta, commissionNote, commissionView, ruleCode } from '../lib/commission.ts';
import { and, asc, desc, eq, gte, ilike, inArray, isNull, lte, or, sql, type SQL } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { DealBody, dealView } from '../deals/service.ts';
import type { Db } from '../db/client.ts';
import { clientLeads, clients, companies, deals, departments, events, filters, leadFavorites, leads, leadViews, listings, pipelineStatuses, users } from '../db/schema.ts';
import { loadBillingSettings } from '../billing/service.ts';
import { backfillFilter, importListing, type PagedSource } from '../ingest/service.ts';
import { sendLeadToGroup, sendLeadToUser, type GroupSender } from '../group/service.ts';
import { badRequest, forbidden, notFound } from '../lib/errors.ts';
import { onlySent } from '../lib/patch.ts';
import type { Config } from '../lib/config.ts';
import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts';
import { renderShowcaseText } from '../showcase/text.ts';
import {
addComment,
assignLead,
canSee,
changeStatus,
claimFoundLead,
createClaimedLead,
sameDepartment,
departmentScope,
inDepartment,
leadActual,
priceRange,
createManualLead,
deleteLead,
findListingLead,
editLead,
loadLead,
markAsAgent,
markListingRemoved,
releaseLead,
phoneVariants,
setLeadPhone,
setNextAction,
statusByCode,
takeLead,
type Actor,
type Lead,
} from './service.ts';
type MemberCtx = { user: typeof users.$inferSelect; role: Role; company: typeof companies.$inferSelect; access: AccessState };
export interface Guards {
member: (req: FastifyRequest) => MemberCtx;
manager: (req: FastifyRequest) => MemberCtx;
writable: <T extends { access: AccessState }>(ctx: T) => T;
}
const Id = z.object({ id: z.coerce.number().int().positive() });
const optionalText = (max: number) => z.string().trim().max(max).optional().transform((v) => v || undefined);
const optionalNumber = z.coerce.number().nonnegative().optional();
const ManualLeadBody = z.object({
title: z.string().trim().min(1).max(200),
description: optionalText(10_000),
dealType: z.enum(DEAL_TYPES).optional(),
propertyType: z.enum(PROPERTY_TYPES).optional(),
region: optionalText(60),
district: optionalText(60),
address: optionalText(300),
price: optionalNumber,
currency: z.enum(CURRENCIES).default('USD'),
rooms: z.coerce.number().int().min(0).max(50).optional(),
area: optionalNumber,
floor: z.coerce.number().int().min(-5).max(200).optional(),
floorsTotal: z.coerce.number().int().min(0).max(200).optional(),
authorName: optionalText(100),
phone: optionalText(40),
url: z.url().max(1000).optional().or(z.literal('').transform(() => undefined)),
});
/** Пустое поле в правке стирает значение. */
const clearable = <T extends z.ZodType>(schema: T) => z.preprocess((v) => (v === '' ? null : v), schema.nullable());
const LeadEditBody = z.object({
title: z.string().trim().min(1).max(200),
description: clearable(z.string().trim().max(10_000)),
dealType: clearable(z.enum(DEAL_TYPES)),
propertyType: clearable(z.enum(PROPERTY_TYPES)),
region: clearable(z.string().trim().max(60)),
district: clearable(z.string().trim().max(60)),
address: clearable(z.string().trim().max(300)),
price: clearable(z.coerce.number().nonnegative()),
currency: z.enum(CURRENCIES),
rooms: clearable(z.coerce.number().int().min(0).max(50)),
area: clearable(z.coerce.number().nonnegative()),
floor: clearable(z.coerce.number().int().min(-5).max(200)),
floorsTotal: clearable(z.coerce.number().int().min(0).max(200)),
authorName: clearable(z.string().trim().max(100)),
}).partial();
export function registerLeadRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config, fetchImage?: ImageFetcher, source?: PagedSource | null, groupSender?: GroupSender) {
const actor = (ctx: MemberCtx): Actor => ctx;
/** Телефон автора виден ответственному и руководителю; у лида в пуле и у чужого лида он скрыт. */
const present = (ctx: MemberCtx, lead: Lead, assigneeName?: string | null) => ({
...lead,
phone: ctx.role === 'manager' || lead.assigneeId === ctx.user.id ? lead.phone : null,
phoneHidden: Boolean(lead.phone) && ctx.role !== 'manager' && lead.assigneeId !== ctx.user.id,
assigneeName: assigneeName ?? null,
});
// --- список лидов ---
app.get('/api/leads', async (req) => {
const ctx = g.member(req);
const q = z
.object({
tab: z.enum(['pool', 'mine', 'all']).default('pool'),
statusId: z.coerce.number().int().optional(),
assigneeId: z.coerce.number().int().optional(),
q: z.string().trim().max(100).optional(),
district: z.string().trim().max(60).optional(),
/** Несколько районов через запятую. */
districts: z.string().max(1000).optional(),
priceFrom: z.coerce.number().nonnegative().optional(),
priceTo: z.coerce.number().nonnegative().optional(),
/** В какой валюте заданы границы цены: цены объектов пересчитываются по курсу. */
priceCurrency: z.enum(['AMD', 'USD']).default('USD'),
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
/** Только избранные объекты пользователя. */
favorite: z.literal('1').optional(),
/** Только объекты, карточку которых пользователь ещё не открывал. */
unseen: z.literal('1').optional(),
/** Руководителю — один отдел; none — объекты без отдела. */
departmentId: z.union([z.literal('none'), z.coerce.number().int().positive()]).optional(),
})
.parse(req.query);
const seeAll = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll;
if (q.tab === 'all' && !seeAll) throw forbidden();
const fresh = await statusByCode(db, ctx.company.id, 'new');
const where: (SQL | undefined)[] = [
eq(leads.companyId, ctx.company.id),
// Сотрудник видит только объекты своего отдела.
departmentScope(actor(ctx), leads.departmentId),
// Снятые с List.am и закрытые сделкой или отказом объекты в рабочие списки не попадают — они в «Базе».
leadActual,
];
if (q.departmentId !== undefined && ctx.role === 'manager') where.push(inDepartment(leads.departmentId, q.departmentId === 'none' ? null : q.departmentId));
if (q.favorite) where.push(sql`exists (select 1 from lead_favorites lf where lf.lead_id = "leads"."id" and lf.user_id = ${ctx.user.id})`);
if (q.unseen) where.push(sql`not exists (select 1 from lead_views lv where lv.lead_id = "leads"."id" and lv.user_id = ${ctx.user.id})`);
if (q.tab === 'pool') where.push(isNull(leads.assigneeId), eq(leads.statusId, fresh.id));
if (q.tab === 'mine') where.push(eq(leads.assigneeId, ctx.user.id));
if (q.statusId) where.push(eq(leads.statusId, q.statusId));
if (q.assigneeId && q.tab === 'all') where.push(eq(leads.assigneeId, q.assigneeId));
if (q.district) where.push(ilike(leads.district, q.district));
const districts = q.districts?.split(',').map((d) => d.trim().toLowerCase()).filter(Boolean) ?? [];
if (districts.length) where.push(inArray(sql`lower(${leads.district})`, districts));
if (q.priceFrom !== undefined || q.priceTo !== undefined) {
const { rates } = await loadBillingSettings(db);
where.push(priceRange(leads.price, leads.currency, rates, q.priceCurrency, q.priceFrom, q.priceTo));
}
if (q.from) where.push(gte(leads.createdAt, q.from));
if (q.to) where.push(lte(leads.createdAt, q.to));
if (q.q) {
// Поиск по телефону, адресу, названию и ID объявления.
const like = `%${q.q.replace(/[%_\\]/g, '\\$&')}%`;
const digits = q.q.replace(/\D/g, '');
where.push(
or(
ilike(leads.address, like),
ilike(leads.title, like),
ilike(leads.url, like),
// По телефону агент находит только свои лиды — чужие номера скрыты.
digits.length >= 4
? and(ilike(leads.phone, `%${digits}%`), ctx.role === 'manager' ? undefined : eq(leads.assigneeId, ctx.user.id))
: undefined,
),
);
}
const rows = await db
.select({
lead: leads,
assigneeName: users.name,
departmentName: departments.name,
favorite: sql<boolean>`exists (select 1 from lead_favorites lf where lf.lead_id = "leads"."id" and lf.user_id = ${ctx.user.id})`,
seen: sql<boolean>`exists (select 1 from lead_views lv where lv.lead_id = "leads"."id" and lv.user_id = ${ctx.user.id})`,
})
.from(leads)
.leftJoin(users, eq(users.id, leads.assigneeId))
.leftJoin(departments, eq(departments.id, leads.departmentId))
.where(and(...where))
// Лид, чьё объявление подняли на list.am, встаёт наверх.
.orderBy(desc(sql`coalesce(${leads.bumpedAt}, ${leads.createdAt})`), desc(leads.id))
.limit(300);
return rows.map((r) => ({ ...present(ctx, r.lead, r.assigneeName), departmentName: r.departmentName, favorite: r.favorite, seen: r.seen }));
});
/** Курсы валют (сколько единиц в одном долларе): фильтр цены пересчитывает границы при смене валюты. */
app.get('/api/rates', async (req) => {
g.member(req);
const { rates } = await loadBillingSettings(db);
return { USD: 1, ...rates };
});
app.get('/api/leads/:id', async (req) => {
const ctx = g.member(req);
const { id } = Id.parse(req.params);
const lead = await loadLead(db, actor(ctx), id);
// Карточку открыли — объект больше не «непросмотренный» для этого пользователя.
await db
.insert(leadViews)
.values({ userId: ctx.user.id, leadId: id })
.onConflictDoUpdate({ target: [leadViews.userId, leadViews.leadId], set: { viewedAt: new Date() } });
const [[favorite], [department]] = await Promise.all([
db.select({ id: leadFavorites.leadId }).from(leadFavorites).where(and(eq(leadFavorites.userId, ctx.user.id), eq(leadFavorites.leadId, id))),
lead.departmentId ? db.select({ name: departments.name }).from(departments).where(eq(departments.id, lead.departmentId)) : [],
]);
const [history, assignee, previous, interested, deal, listing] = await Promise.all([
db
.select({ event: events, userName: users.name })
.from(events)
.leftJoin(users, eq(users.id, events.userId))
.where(eq(events.leadId, id))
.orderBy(desc(events.at), desc(events.id)),
lead.assigneeId ? db.select({ name: users.name }).from(users).where(eq(users.id, lead.assigneeId)) : [],
lead.previousLeadId
? db
.select({ lead: leads, assigneeName: users.name, status: pipelineStatuses.name, removedAt: listings.removedAt })
.from(leads)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.leftJoin(users, eq(users.id, leads.assigneeId))
.leftJoin(listings, eq(listings.id, leads.listingId))
.where(and(eq(leads.id, lead.previousLeadId), eq(leads.companyId, ctx.company.id)))
: [],
db
.select({ client: clients, state: clientLeads.state, assigneeName: users.name })
.from(clientLeads)
.innerJoin(clients, eq(clients.id, clientLeads.clientId))
.leftJoin(users, eq(users.id, clients.assigneeId))
.where(eq(clientLeads.leadId, id))
.orderBy(desc(clientLeads.createdAt)),
db
.select({ deal: deals, clientName: clients.name })
.from(deals)
.leftJoin(clients, eq(clients.id, deals.clientId))
.where(eq(deals.leadId, id)),
lead.listingId ? db.select({ active: listings.active, removedAt: listings.removedAt }).from(listings).where(eq(listings.id, lead.listingId)) : [],
]);
const prev = previous[0];
const seesClients = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll;
const d = deal[0];
// В событиях назначения хранятся ID агентов — подставляем имена.
const names = new Map(
(await db.select({ id: users.id, name: users.name }).from(users).where(eq(users.companyId, ctx.company.id))).map(
(u) => [String(u.id), u.name] as const,
),
);
const byId = (v: string | null) => (v ? (names.get(v) ?? v) : null);
// Полный набор фото и текст для клиента готовятся в фоне: открытие карточки ставит объект в очередь.
const fromListing = Boolean(lead.listingId);
if (fromListing && !lead.galleryAt && !lead.galleryWantedAt && lead.galleryTries < 3) {
await db.update(leads).set({ galleryWantedAt: new Date() }).where(eq(leads.id, id));
}
return {
...present(ctx, lead, assignee[0]?.name),
favorite: Boolean(favorite),
departmentName: department?.name ?? null,
// Фото с List.am отдаёт сам сервис — обрезанными сверху, как на странице клиента.
photos: lead.photos.slice(0, 20).map((src, i) => (/^https:\/\/(?:img|s)\.list\.am\//.test(src) ? `/api/leads/${id}/photos/${i}` : src)),
/** ready — все фото загружены; pending — догружаются; none — объект заведён вручную или загрузить не удалось. */
gallery: lead.galleryAt ? 'ready' : fromListing && lead.galleryTries < 3 ? 'pending' : 'none',
/** Комиссия агентства с собственника: условие и ожидаемая сумма от цены объекта. */
commission: commissionView(ctx.company.settings.commission, lead.dealType === 'rent' ? 'objectRent' : 'objectSale', lead.commission, lead.price, lead.currency),
/** Формулировка в объявлении, по которой видно посредника или собственника против агентов. */
agentPhrase: agentPhrase(`${lead.title}\n${lead.description ?? ''}`),
/** Текст для клиента: карточка по шаблону, без адреса. */
clientText: lead.showcaseText ?? renderShowcaseText(lead),
// Варианты номера из общей базы видит тот же, кто видит сам телефон: ответственный и руководитель.
phoneVariants: lead.listingId && (ctx.role === 'manager' || lead.assigneeId === ctx.user.id) ? await phoneVariants(db, lead.listingId) : [],
// Заинтересованные клиенты: агент видит своих (и клиентов своего отдела при открытой видимости), руководитель — всех.
clients: interested
.filter((r) => ctx.role === 'manager' || r.client.assigneeId === ctx.user.id || (seesClients && sameDepartment(r.client.departmentId, ctx.user.departmentId)))
.map((r) => ({ id: r.client.id, name: r.client.name, kind: r.client.kind, statusId: r.client.statusId, state: r.state, assigneeName: r.assigneeName })),
deal: d ? { ...dealView(d.deal, names), clientId: d.deal.clientId, clientName: d.clientName } : null,
// Объявление сняли с List.am (проверка раз в сутки).
removedAt: listing[0]?.active === false ? listing[0].removedAt : null,
// «Уже был в работе»: итог прошлой карточки виден всем, ссылка на неё — тем, кому она доступна.
previous: prev
? {
id: canSee(actor(ctx), prev.lead) ? prev.lead.id : null,
title: prev.lead.title, assigneeName: prev.assigneeName, status: prev.status, removedAt: prev.removedAt,
}
: null,
events: history.map(({ event: e, userName }) => ({
id: e.id,
type: e.type,
at: e.at,
userName,
oldValue: e.type === 'assigned' || e.type === 'pool_return' ? byId(e.oldValue) : e.oldValue,
newValue: e.type === 'assigned' ? byId(e.newValue) : e.newValue,
text: e.text,
})),
};
});
// --- действия с лидом ---
app.post('/api/leads', async (req) => {
const ctx = g.writable(g.member(req));
const lead = await createManualLead(db, actor(ctx), ManualLeadBody.parse(req.body));
return { id: lead.id };
});
/**
* Объект по ссылке на list.am: есть в CRM и свободен — закрепляется за агентом; закреплён за
* другим — ответ с его именем; нет в CRM — объявление скачивается с list.am и заводится на агента.
*/
app.post('/api/leads/by-url', async (req) => {
const ctx = g.writable(g.member(req));
const { url } = z.object({ url: z.string().trim().max(1000) }).parse(req.body);
const externalId = /list\.am\/(?:[a-z]{2}\/)?item\/(\d{4,15})/i.exec(url)?.[1];
if (!externalId) throw badRequest('BAD_LISTAM_URL');
const [known] = await db.select().from(listings).where(eq(listings.externalId, externalId));
// Сотрудник ищет объект в своём отделе: в другом отделе у объекта своя карточка.
const found = await findListingLead(db, ctx.company.id, externalId, known?.id ?? null, ctx.role === 'manager' ? undefined : ctx.user.departmentId);
if (found) return claimFoundLead(db, actor(ctx), found);
let listing = known;
if (!listing) {
if (!source?.item) throw badRequest('PARSER_DISABLED');
const fetched = await importListing(db, source, externalId).catch((error: unknown) => {
console.error('[by-url]', error);
throw badRequest('LISTAM_UNAVAILABLE');
});
if (!fetched) throw notFound('LISTAM_NOT_FOUND');
listing = fetched;
}
return createClaimedLead(db, actor(ctx), listing);
});
/** «Отправить в Telegram»: объект сразу уходит в группу компании её ботом. */
app.post('/api/leads/:id/group', async (req) => {
const ctx = g.writable(g.member(req));
const lead = await loadLead(db, actor(ctx), Id.parse(req.params).id);
await sendLeadToGroup({ db, config, sender: groupSender, fetchImage }, ctx.company, lead);
return { ok: true };
});
/** «Отправить в бот»: объект уходит основным ботом в личный чат того, кто нажал кнопку. */
app.post('/api/leads/:id/bot', async (req) => {
const ctx = g.member(req);
const lead = await loadLead(db, actor(ctx), Id.parse(req.params).id);
await sendLeadToUser({ db, config, sender: groupSender, fetchImage }, ctx.company, lead, ctx.user);
return { ok: true };
});
/** Избранное: у каждого пользователя своё. */
app.post('/api/leads/:id/favorite', async (req) => {
const ctx = g.member(req);
const { id } = Id.parse(req.params);
const { on } = z.object({ on: z.boolean() }).parse(req.body);
await loadLead(db, actor(ctx), id);
if (on) await db.insert(leadFavorites).values({ userId: ctx.user.id, leadId: id }).onConflictDoNothing();
else await db.delete(leadFavorites).where(and(eq(leadFavorites.userId, ctx.user.id), eq(leadFavorites.leadId, id)));
return { ok: true, favorite: on };
});
/** Удалить карточку объекта: руководитель; со сделкой — только суперадмин. */
app.delete('/api/leads/:id', async (req) => {
const ctx = g.writable(g.member(req));
await deleteLead(db, actor(ctx), Id.parse(req.params).id);
return { ok: true };
});
app.post('/api/leads/:id/take', async (req) => {
const ctx = g.writable(g.member(req));
const lead = await takeLead(db, actor(ctx), Id.parse(req.params).id);
return present(ctx, lead, ctx.user.name);
});
app.post('/api/leads/:id/assign', async (req) => {
const ctx = g.writable(g.manager(req));
const { userId } = z.object({ userId: z.number().int().positive() }).parse(req.body);
await assignLead(db, actor(ctx), Id.parse(req.params).id, userId);
return { ok: true };
});
app.post('/api/leads/:id/release', async (req) => {
const ctx = g.writable(g.member(req));
await releaseLead(db, actor(ctx), Id.parse(req.params).id);
return { ok: true };
});
app.post('/api/leads/:id/status', async (req) => {
const ctx = g.writable(g.member(req));
const body = z
.object({
statusId: z.number().int().positive(),
rejectReasonId: z.number().int().positive().optional(),
deal: DealBody.extend({ clientId: z.number().int().positive().optional() }).optional(),
})
.parse(req.body);
await changeStatus(db, actor(ctx), Id.parse(req.params).id, body);
return { ok: true };
});
app.post('/api/leads/:id/comments', async (req) => {
const ctx = g.writable(g.member(req));
const { text } = z.object({ text: z.string().trim().min(1).max(5000) }).parse(req.body);
await addComment(db, actor(ctx), Id.parse(req.params).id, text);
return { ok: true };
});
app.post('/api/leads/:id/next-action', async (req) => {
const ctx = g.writable(g.member(req));
const body = z
.object({ at: z.coerce.date().nullable(), note: z.string().trim().max(500).nullable().default(null) })
.parse(req.body);
await setNextAction(db, actor(ctx), Id.parse(req.params).id, body.at, body.note || null);
return { ok: true };
});
/** Условие комиссии для этого объекта; null возвращает условие из настроек компании. */
app.post('/api/leads/:id/commission', async (req) => {
const ctx = g.writable(g.member(req));
const { id } = Id.parse(req.params);
const { rule } = z.object({ rule: CommissionRule.nullable() }).parse(req.body);
const lead = await loadLead(db, actor(ctx), id);
if (ctx.role !== 'manager' && lead.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_LEAD');
const before = ruleCode(lead.commission);
const after = ruleCode(rule);
if (before === after) return { ok: true };
await db.transaction(async (tx) => {
await tx.update(leads).set({ commission: rule }).where(eq(leads.id, id));
// В истории видно, кто и какое условие поставил; «было» — то, что действовало до правки, включая условие компании.
const settings = ctx.company.settings.commission?.[lead.dealType === 'rent' ? 'objectRent' : 'objectSale'];
await tx.insert(events).values({
companyId: ctx.company.id, leadId: id, userId: ctx.user.id, type: 'commission',
oldValue: before ?? ruleCode(settings), newValue: after,
// Вместе с условием сохраняется, на сколько процентов изменилась ожидаемая сумма.
text: commissionNote(after ? null : ruleCode(settings), commissionDelta(lead.commission ?? settings, rule ?? settings, lead.price, lead.currency)),
});
});
return { ok: true };
});
/** Телефон собственника: агент берёт его из объявления и вносит сам. */
app.post('/api/leads/:id/phone', async (req) => {
const ctx = g.writable(g.member(req));
const { phone } = z.object({ phone: z.string().trim().max(40) }).parse(req.body);
await setLeadPhone(db, actor(ctx), Id.parse(req.params).id, phone);
return { ok: true };
});
/** Фото объекта, обрезанное сверху. */
app.get('/api/leads/:id/photos/:index', async (req, reply) => {
const ctx = g.member(req);
const { id } = Id.parse(req.params);
const { index } = z.object({ index: z.coerce.number().int().min(0).max(50) }).parse(req.params);
const lead = await loadLead(db, actor(ctx), id);
const url = lead.photos[index];
if (!url || !/^https:\/\/(?:img|s)\.list\.am\//.test(url)) throw notFound();
const photo = await croppedPhoto(config, url, fetchImage).catch(() => {
throw notFound();
});
return reply.header('content-type', 'image/jpeg').header('cache-control', 'private, max-age=604800').send(photo);
});
app.post('/api/leads/:id/agent', async (req) => {
const ctx = g.writable(g.member(req));
await markAsAgent(db, actor(ctx), Id.parse(req.params).id);
return { ok: true };
});
/** Правка данных объекта: те же поля, что у объекта вручную, кроме телефона и ссылки. */
app.patch('/api/leads/:id', async (req) => {
const ctx = g.writable(g.member(req));
const body = onlySent(req.body, LeadEditBody.parse(req.body));
if (!Object.keys(body).length) throw badRequest('VALIDATION');
await editLead(db, actor(ctx), Id.parse(req.params).id, body);
return { ok: true };
});
app.post('/api/leads/:id/removed', async (req) => {
const ctx = g.writable(g.member(req));
await markListingRemoved(db, actor(ctx), Id.parse(req.params).id);
return { ok: true };
});
// --- фильтры ---
/**
* Агент видит фильтры своего отдела (без отдела — общие фильтры компании) и свои личные;
* руководитель — все фильтры: компании, отделов и личные фильтры агентов.
*/
app.get('/api/filters', async (req) => {
const ctx = g.member(req);
const rows = await db
.select({ filter: filters, ownerName: users.name, departmentName: departments.name })
.from(filters)
.leftJoin(users, eq(users.id, filters.ownerUserId))
.leftJoin(departments, eq(departments.id, filters.departmentId))
.where(
and(
eq(filters.companyId, ctx.company.id),
ctx.role === 'manager'
? undefined
: or(and(isNull(filters.ownerUserId), inDepartment(filters.departmentId, ctx.user.departmentId)), eq(filters.ownerUserId, ctx.user.id)),
),
)
.orderBy(asc(filters.id));
return rows.map(({ filter: f, ownerName, departmentName }) => ({
id: f.id,
name: f.name,
enabled: f.enabled,
params: f.params,
scope: f.ownerUserId !== null ? 'personal' : f.departmentId !== null ? 'department' : 'company',
ownerUserId: f.ownerUserId,
ownerName,
departmentId: f.departmentId,
departmentName,
// Фильтр компании и отдела меняет руководитель, личный — его владелец.
editable: f.ownerUserId === null ? ctx.role === 'manager' : f.ownerUserId === ctx.user.id,
}));
});
const FilterBody = z.object({
name: z.string().trim().min(1).max(80),
enabled: z.boolean().default(true),
params: FilterParams,
});
/** Отдел компании по ID; чужой или несуществующий — ошибка. */
const companyDepartment = async (ctx: MemberCtx, id: number) => {
const [d] = await db.select({ id: departments.id }).from(departments).where(and(eq(departments.id, id), eq(departments.companyId, ctx.company.id)));
if (!d) throw badRequest('BAD_DEPARTMENT');
return d.id;
};
app.post('/api/filters', async (req) => {
const ctx = g.writable(g.member(req));
const body = FilterBody.extend({
scope: z.enum(['company', 'department', 'personal']),
departmentId: z.number().int().positive().optional(),
}).parse(req.body);
// Фильтры компании и отделов задаёт руководитель.
if (body.scope !== 'personal' && ctx.role !== 'manager') throw forbidden();
if (body.scope === 'department' && !body.departmentId) throw badRequest('BAD_DEPARTMENT');
const departmentId = body.scope === 'department' ? await companyDepartment(ctx, body.departmentId!) : null;
const [row] = await db
.insert(filters)
.values({
companyId: ctx.company.id,
ownerUserId: body.scope === 'personal' ? ctx.user.id : null,
departmentId,
name: body.name,
enabled: body.enabled,
params: body.params,
})
.returning();
// Подходящие объявления из общей базы сразу попадают в пул (без уведомлений).
return { id: row!.id, fromBase: await backfillFilter(db, row!.id) };
});
const ownFilter = async (ctx: MemberCtx, id: number) => {
const [f] = await db.select().from(filters).where(and(eq(filters.id, id), eq(filters.companyId, ctx.company.id)));
if (!f) throw notFound();
if (f.ownerUserId === null ? ctx.role !== 'manager' : f.ownerUserId !== ctx.user.id) throw forbidden();
return f;
};
app.patch('/api/filters/:id', async (req) => {
const ctx = g.writable(g.member(req));
const { id } = Id.parse(req.params);
const f = await ownFilter(ctx, id);
const { departmentId, ...body } = onlySent(
req.body,
FilterBody.partial().extend({ departmentId: z.number().int().positive().nullable() }).partial().parse(req.body),
);
// Руководитель переносит фильтр компании в отдел и обратно; у личного фильтра отдела нет.
const patch = {
...body,
...(departmentId !== undefined && f.ownerUserId === null
? { departmentId: departmentId === null ? null : await companyDepartment(ctx, departmentId) }
: {}),
};
if (!Object.keys(patch).length) throw badRequest('VALIDATION');
await db.update(filters).set(patch).where(eq(filters.id, id));
return { ok: true, fromBase: await backfillFilter(db, id) };
});
app.delete('/api/filters/:id', async (req) => {
const ctx = g.writable(g.member(req));
const { id } = Id.parse(req.params);
await ownFilter(ctx, id);
await db.delete(filters).where(eq(filters.id, id));
return { ok: true };
});
/** Счётчики для вкладок списка лидов. */
app.get('/api/leads-summary', async (req) => {
const ctx = g.member(req);
const fresh = await statusByCode(db, ctx.company.id, 'new');
const [row] = await db
.select({
pool: sql<number>`count(*) filter (where ${leads.assigneeId} is null and ${leads.statusId} = ${fresh.id})::int`,
mine: sql<number>`count(*) filter (where ${leads.assigneeId} = ${ctx.user.id})::int`,
all: sql<number>`count(*)::int`,
})
.from(leads)
.where(and(eq(leads.companyId, ctx.company.id), departmentScope(actor(ctx), leads.departmentId), leadActual));
return row ?? { pool: 0, mine: 0, all: 0 };
});
}
+792
View File
@@ -0,0 +1,792 @@
import { normalizePhone, phoneConfidence } from '@lr/shared';
import { and, eq, isNull, or, sql, type AnyColumn, type SQL } from 'drizzle-orm';
import type { Db, Tx } from '../db/client.ts';
import {
blacklist,
companies,
deals,
events,
leads,
listingPhones,
listings,
pipelineStatuses,
rejectReasons,
users,
} from '../db/schema.ts';
import { badRequest, conflict, forbidden, notFound } from '../lib/errors.ts';
import { dealInput, dealText, dropDealSide, moveClientToDeal, recordDeal, type DealBody, type DealInput } from '../deals/service.ts';
import { statusByCode } from '../pipelines/service.ts';
import { enqueueLead, enqueueTaken, enqueueText } from '../telegram/outbox.ts';
// Жизненный цикл лида в воронке объекта: пул → «Беру» → статусы → сделка или отказ,
// возврат в пул. Каждое действие пишет событие в историю и ставит уведомления в очередь.
export type Lead = typeof leads.$inferSelect;
export type User = typeof users.$inferSelect;
type Company = typeof companies.$inferSelect;
type Listing = typeof listings.$inferSelect;
/** Кто действует: пользователь компании с ролью в текущем кабинете. */
export interface Actor {
user: User;
role: 'manager' | 'agent' | 'superadmin';
company: Company;
}
const isManager = (a: Actor) => a.role === 'manager';
/** Один и тот же отдел: пустой отдел («без отдела») равен только пустому. */
export const sameDepartment = (a: number | null | undefined, b: number | null | undefined) => (a ?? null) === (b ?? null);
/** Условие «запись из отдела department» для запросов; null — записи без отдела. */
export const inDepartment = (column: AnyColumn, department: number | null | undefined): SQL =>
department == null ? isNull(column) : eq(column, department);
/**
* Какие отделы видит пользователь в списках: руководитель и суперадмин — все (undefined — без ограничения),
* сотрудник — только свой.
*/
export const departmentScope = (actor: Actor, column: AnyColumn): SQL | undefined =>
isManager(actor) ? undefined : inDepartment(column, actor.user.departmentId);
/**
* Объект актуален: объявление не снято с List.am (у объекта вручную объявления нет) и карточка не закрыта
* сделкой или отказом. Неактуальные объекты остаются в базе и в разделе «База», но не попадают в рабочие
* списки, поиск, фильтры и подборки.
*/
// Столбцы внешней таблицы в подзапросах — с явным именем таблицы: без него Postgres может взять столбец подзапроса.
export const listingLive = sql`not exists (select 1 from listings li where li.id = "leads"."listing_id" and not li.active)`;
export const leadOpen = sql`not exists (select 1 from pipeline_statuses ps where ps.id = "leads"."status_id" and ps.code in ('deal', 'rejected'))`;
export const leadActual = sql`(${listingLive} and ${leadOpen})`;
/**
* Цена в выбранной валюте не ниже from и не выше to. Цена объекта пересчитывается по курсам
* (сколько единиц валюты в одном долларе); объект без валюты считается в долларах.
*/
export function priceRange(
price: AnyColumn,
currency: AnyColumn,
rates: Record<string, number>,
target: string,
from?: number,
to?: number,
): SQL | undefined {
if (from === undefined && to === undefined) return undefined;
const perUsd = (code: string) => rates[code] ?? (code === 'USD' ? 1 : undefined);
const known = Object.entries({ USD: 1, ...rates }).filter(([, rate]) => rate > 0);
// Цена объекта в долларах: делим на курс его валюты.
const usd = sql`(${price}::numeric / (case coalesce(${currency}, 'USD') ${sql.join(
known.map(([code, rate]) => sql`when ${code} then ${rate}::numeric`),
sql` `,
)} else 1 end))`;
const k = perUsd(target) ?? 1;
return and(
sql`${price} is not null`,
from !== undefined ? sql`${usd} >= ${from / k}::numeric` : undefined,
to !== undefined ? sql`${usd} <= ${to / k}::numeric` : undefined,
);
}
export const activeMembers = (tx: Tx, companyId: number) =>
tx
.select()
.from(users)
.where(and(eq(users.companyId, companyId), isNull(users.removedAt)));
async function logEvent(
tx: Tx,
lead: Pick<Lead, 'id' | 'companyId'>,
userId: number | null,
type: string,
extra: { oldValue?: string | null; newValue?: string | null; text?: string | null; statusId?: number } = {},
) {
await tx.insert(events).values({ companyId: lead.companyId, leadId: lead.id, userId, type, ...extra });
}
const touchCompany = (tx: Tx, companyId: number) =>
tx.update(companies).set({ lastActivityAt: new Date() }).where(eq(companies.id, companyId));
/**
* Может ли пользователь видеть лид. Руководитель — любой. Сотрудник — только лиды своего отдела:
* свой, из пула, либо любой при открытой видимости.
*/
export function canSee(actor: Actor, lead: Lead): boolean {
if (lead.companyId !== actor.company.id) return false;
if (isManager(actor)) return true;
if (!sameDepartment(lead.departmentId, actor.user.departmentId)) return false;
if (actor.company.settings.agentsSeeAll) return true;
return lead.assigneeId === actor.user.id || lead.assigneeId === null;
}
/** Вести лид (статус, комментарий, напоминание) может ответственный и руководитель. */
function assertCanWork(actor: Actor, lead: Lead) {
if (!isManager(actor) && lead.assigneeId !== actor.user.id) throw forbidden('NOT_YOUR_LEAD');
}
async function loadLead(tx: Tx, actor: Actor, leadId: number): Promise<Lead> {
const [lead] = await tx
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id)));
if (!lead || !canSee(actor, lead)) throw notFound('LEAD_NOT_FOUND');
return lead;
}
const copyProperty = (l: Listing) => ({
title: l.title,
description: l.description,
dealType: l.dealType,
propertyType: l.propertyType,
region: l.region,
district: l.district,
address: l.address,
price: l.price,
currency: l.currency,
rooms: l.rooms,
area: l.area,
floor: l.floor,
floorsTotal: l.floorsTotal,
photos: l.photos,
authorName: l.authorName,
phone: l.phone,
features: l.features,
});
/**
* Лид из объявления: статус «Новый», в пуле, уведомления подходящим агентам.
* Возвращает null, если у компании уже есть лид по этому объявлению.
*/
export async function createLeadFromListing(
tx: Tx,
companyId: number,
listing: Listing,
recipients: User[],
/** Прошлый лид по тому же объекту: объявление сняли и опубликовали снова. */
previousLeadId: number | null = null,
/** Отдел, в который пришёл объект; null — без отдела. */
departmentId: number | null = null,
) {
const fresh = await statusByCode(tx, companyId, 'new');
const [lead] = await tx
.insert(leads)
.values({ companyId, departmentId, listingId: listing.id, source: 'listam', url: listing.url, statusId: fresh.id, previousLeadId, bumpedAt: listing.bumpedAt, ...copyProperty(listing) })
.onConflictDoNothing()
.returning();
if (!lead) return null;
await logEvent(tx, lead, null, 'created', { newValue: 'listam' });
await enqueueLead(tx, lead.id, recipients);
return lead;
}
export interface ManualLead {
title: string;
description?: string;
dealType?: string;
propertyType?: string;
region?: string;
district?: string;
address?: string;
price?: number;
currency?: string;
rooms?: number;
area?: number;
floor?: number;
floorsTotal?: number;
authorName?: string;
phone?: string;
url?: string;
}
/** Лид вручную (объект не с List.am): сразу закреплён за создателем в статусе «Взят в работу». */
export async function createManualLead(db: Db, actor: Actor, input: ManualLead) {
return db.transaction(async (tx) => {
const taken = await statusByCode(tx, actor.company.id, 'taken');
const [lead] = await tx
.insert(leads)
.values({
...input,
phone: normalizePhone(input.phone) ?? input.phone ?? null,
photos: [],
companyId: actor.company.id,
// Объект попадает в отдел того, кто его завёл.
departmentId: actor.user.departmentId,
source: 'manual',
statusId: taken.id,
assigneeId: actor.user.id,
takenAt: new Date(),
createdById: actor.user.id,
})
.returning();
await logEvent(tx, lead!, actor.user.id, 'created', { newValue: 'manual' });
await touchCompany(tx, actor.company.id);
return lead!;
});
}
/** Чем кончилось добавление объекта ссылкой на list.am. */
export type ClaimResult =
/** Объект был в пуле — теперь он закреплён за агентом. */
| { result: 'taken'; id: number }
/** Объекта в CRM не было — заведён и закреплён за агентом. */
| { result: 'created'; id: number }
/** Объект уже у этого агента. */
| { result: 'mine'; id: number }
/** Объект закреплён за другим агентом; id — если агенту можно открыть карточку. */
| { result: 'busy'; id: number | null; assigneeName: string | null };
/**
* Лид компании по объявлению list.am: последний по связи с общей базой или по ссылке у объекта,
* заведённого вручную. `department` — искать только в этом отделе (undefined — во всей компании).
*/
export async function findListingLead(tx: Tx, companyId: number, externalId: string, listingId: number | null, department?: number | null) {
const byUrl = sql`${leads.url} ~ ${`list\\.am/(?:[a-z]{2}/)?item/${externalId}([^0-9]|$)`}`;
const [row] = await tx
.select({ lead: leads, assigneeName: users.name })
.from(leads)
.leftJoin(users, eq(users.id, leads.assigneeId))
.where(
and(
eq(leads.companyId, companyId),
department === undefined ? undefined : inDepartment(leads.departmentId, department),
listingId === null ? byUrl : or(eq(leads.listingId, listingId), byUrl),
),
)
.orderBy(sql`${leads.id} desc`)
.limit(1);
return row ?? null;
}
/**
* Агент добавил ссылкой объект, который уже есть в CRM: свободный объект из пула закрепляется
* за ним, иначе он узнаёт, за кем объект закреплён.
*/
export async function claimFoundLead(db: Db, actor: Actor, found: { lead: Lead; assigneeName: string | null }): Promise<ClaimResult> {
const { lead } = found;
if (lead.assigneeId === actor.user.id) return { result: 'mine', id: lead.id };
if (lead.assigneeId === null) {
const fresh = await statusByCode(db, actor.company.id, 'new');
if (lead.statusId === fresh.id) {
try {
return { result: 'taken', id: (await takeLead(db, actor, lead.id)).id };
} catch (error) {
// Объект успели взять, пока агент вставлял ссылку: показываем, кто взял.
if (!(error instanceof Error && 'code' in error && error.code === 'ALREADY_TAKEN')) throw error;
const [again] = await db.select({ lead: leads, assigneeName: users.name }).from(leads).leftJoin(users, eq(users.id, leads.assigneeId)).where(eq(leads.id, lead.id));
return claimFoundLead(db, actor, again!);
}
}
}
return { result: 'busy', id: canSee(actor, lead) ? lead.id : null, assigneeName: found.assigneeName };
}
/** Объекта ещё нет в CRM компании: лид из объявления сразу закреплён за агентом, без пула и рассылки. */
export async function createClaimedLead(db: Db, actor: Actor, listing: Listing): Promise<ClaimResult> {
const lead = await db.transaction(async (tx) => {
const taken = await statusByCode(tx, actor.company.id, 'taken');
const [row] = await tx
.insert(leads)
.values({
companyId: actor.company.id, departmentId: actor.user.departmentId, listingId: listing.id, source: 'listam', url: listing.url, statusId: taken.id,
assigneeId: actor.user.id, takenAt: new Date(), createdById: actor.user.id, bumpedAt: listing.bumpedAt, ...copyProperty(listing),
})
.onConflictDoNothing()
.returning();
if (!row) return null;
await logEvent(tx, row, actor.user.id, 'created', { newValue: 'link' });
await touchCompany(tx, actor.company.id);
return row;
});
if (lead) return { result: 'created', id: lead.id };
// Лид по этому объявлению появился одновременно (парсер или другой агент).
const found = await findListingLead(db, actor.company.id, listing.externalId, listing.id, actor.user.departmentId);
return claimFoundLead(db, actor, found!);
}
/**
* Удаление карточки объекта вместе с историей. Удаляет руководитель (и суперадмин в кабинете
* компании); карточку со сделкой — только суперадмин: сделка уходит из аналитики, ведомости и счетов.
*/
export async function deleteLead(db: Db, actor: Actor, leadId: number) {
if (!isManager(actor)) throw forbidden();
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
const [deal] = await tx.select({ id: deals.id }).from(deals).where(eq(deals.leadId, lead.id));
if (deal) {
if (actor.user.role !== 'superadmin') throw forbidden('HAS_DEAL');
await dropDealSide(tx, 'lead', lead.id);
}
await tx.update(leads).set({ previousLeadId: null }).where(eq(leads.previousLeadId, lead.id));
await tx.delete(events).where(eq(events.leadId, lead.id));
await tx.delete(leads).where(eq(leads.id, lead.id));
await touchCompany(tx, actor.company.id);
});
}
/**
* «Беру». Лид получает ровно один агент: условие «ответственного нет» проверяется самим
* UPDATE, поэтому при одновременном нажатии второй запрос не найдёт строку и получит «Уже взят».
*/
export async function takeLead(db: Db, actor: Actor, leadId: number): Promise<Lead> {
return db.transaction(async (tx) => {
const fresh = await statusByCode(tx, actor.company.id, 'new');
const taken = await statusByCode(tx, actor.company.id, 'taken');
const [lead] = await tx
.update(leads)
.set({
assigneeId: actor.user.id,
statusId: taken.id,
takenAt: new Date(),
statusChangedAt: new Date(),
poolWarnedAt: null,
})
.where(
and(
eq(leads.id, leadId),
eq(leads.companyId, actor.company.id),
// Объект чужого отдела взять нельзя.
departmentScope(actor, leads.departmentId),
isNull(leads.assigneeId),
eq(leads.statusId, fresh.id),
),
)
.returning();
if (!lead) {
const [exists] = await tx
.select({ id: leads.id })
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id), departmentScope(actor, leads.departmentId)));
throw exists ? conflict('ALREADY_TAKEN') : notFound('LEAD_NOT_FOUND');
}
await logEvent(tx, lead, actor.user.id, 'taken');
await enqueueTaken(tx, lead.id, actor.user.id);
await touchCompany(tx, actor.company.id);
return lead;
});
}
/** Руководитель назначает или переназначает лид. Оба агента получают уведомление. */
export async function assignLead(db: Db, actor: Actor, leadId: number, toUserId: number): Promise<Lead> {
if (!isManager(actor)) throw forbidden();
return db.transaction(async (tx) => {
const [before] = await tx
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id)))
.for('update');
if (!before) throw notFound('LEAD_NOT_FOUND');
const [target] = await tx
.select()
.from(users)
.where(and(eq(users.id, toUserId), eq(users.companyId, actor.company.id), isNull(users.removedAt)));
if (!target) throw badRequest('BAD_ASSIGNEE');
if (before.assigneeId === toUserId) return before;
// Объект переходит в отдел нового ответственного — иначе тот его не увидит. Если в том отделе уже
// есть карточка по этому объявлению, вторую туда не переносим.
const moving = !sameDepartment(before.departmentId, target.departmentId);
if (moving && before.listingId && before.previousLeadId === null) {
const [twin] = await tx
.select({ id: leads.id })
.from(leads)
.where(
and(
eq(leads.companyId, actor.company.id),
eq(leads.listingId, before.listingId),
isNull(leads.previousLeadId),
inDepartment(leads.departmentId, target.departmentId),
),
);
if (twin) throw conflict('DEPARTMENT_HAS_LEAD');
}
const fresh = await statusByCode(tx, actor.company.id, 'new');
const taken = await statusByCode(tx, actor.company.id, 'taken');
const fromPool = before.assigneeId === null;
const [lead] = await tx
.update(leads)
.set({
assigneeId: toUserId,
departmentId: target.departmentId,
poolWarnedAt: null,
statusChangedAt: new Date(),
...(before.statusId === fresh.id ? { statusId: taken.id } : {}),
...(fromPool ? { takenAt: new Date() } : {}),
})
.where(eq(leads.id, leadId))
.returning();
await logEvent(tx, lead!, actor.user.id, 'assigned', {
oldValue: before.assigneeId ? String(before.assigneeId) : null,
newValue: String(toUserId),
});
if (fromPool) await enqueueTaken(tx, leadId, toUserId);
if (toUserId !== actor.user.id) await enqueueText(tx, [toUserId], 'assigned', [lead!.title], leadId);
if (before.assigneeId && before.assigneeId !== actor.user.id) {
await enqueueText(tx, [before.assigneeId], 'reassignedAway', [lead!.title, target.name]);
}
await touchCompany(tx, actor.company.id);
return lead!;
});
}
/**
* Возврат в пул: лид снова «Новый» и без ответственного, история сохраняется.
* `actor = null` — возврат по правилу «N часов без смены статуса»: прежний агент получает
* уведомление о потере. Остальные участники получают повторное уведомление с кнопкой «Беру».
*/
export async function returnToPool(tx: Tx, lead: Lead, actorUserId: number | null): Promise<void> {
const fresh = await statusByCode(tx, lead.companyId, 'new');
const previous = lead.assigneeId;
await tx
.update(leads)
.set({ assigneeId: null, statusId: fresh.id, takenAt: null, statusChangedAt: new Date(), poolWarnedAt: null })
.where(eq(leads.id, lead.id));
await logEvent(tx, lead, actorUserId, 'pool_return', { oldValue: previous ? String(previous) : null });
if (previous && actorUserId === null) await enqueueText(tx, [previous], 'poolLost', [lead.title]);
// «Беру» снова получают сотрудники отдела, которому принадлежит объект.
const others = (await activeMembers(tx, lead.companyId)).filter((u) => u.id !== previous && sameDepartment(u.departmentId, lead.departmentId));
await enqueueLead(tx, lead.id, others, true);
}
export async function releaseLead(db: Db, actor: Actor, leadId: number) {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
if (lead.assigneeId === null) throw badRequest('ALREADY_IN_POOL');
assertCanWork(actor, lead);
await returnToPool(tx, lead, actor.user.id);
await touchCompany(tx, actor.company.id);
});
}
export interface StatusChange {
statusId: number;
rejectReasonId?: number;
/** Обязательна при переводе в «Сделка». */
deal?: DealBody & { clientId?: number };
}
export async function changeStatus(db: Db, actor: Actor, leadId: number, change: StatusChange): Promise<Lead> {
return db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
// Лид в пуле сначала нужно взять.
if (lead.assigneeId === null) throw badRequest('TAKE_FIRST');
assertCanWork(actor, lead);
const statuses = await tx
.select()
.from(pipelineStatuses)
.where(and(eq(pipelineStatuses.companyId, actor.company.id), eq(pipelineStatuses.pipeline, 'object')));
const from = statuses.find((s) => s.id === lead.statusId);
const to = statuses.find((s) => s.id === change.statusId && !s.deletedAt);
if (!to) throw badRequest('BAD_STATUS');
if (to.id === lead.statusId) return lead;
// «Новый» — это пул: туда лид попадает только возвратом в пул.
if (to.code === 'new') throw badRequest('USE_RETURN_TO_POOL');
if (from?.code === 'deal' && !isManager(actor)) throw forbidden('DEAL_CLOSED');
let rejectReasonId: number | null = null;
if (to.code === 'rejected') {
const [reason] = change.rejectReasonId
? await tx
.select()
.from(rejectReasons)
.where(
and(
eq(rejectReasons.id, change.rejectReasonId),
eq(rejectReasons.companyId, actor.company.id),
eq(rejectReasons.pipeline, 'object'),
),
)
: [];
if (!reason) throw badRequest('REJECT_REASON_REQUIRED');
rejectReasonId = reason.id;
}
let deal: DealInput | null = null;
if (to.code === 'deal') {
if (!change.deal) throw badRequest('COMMISSION_REQUIRED');
deal = dealInput(change.deal, 'owner');
await recordDeal(tx, {
companyId: actor.company.id,
leadId,
clientId: change.deal.clientId ?? null,
agentId: lead.assigneeId,
deal,
});
// Сделка с клиентом из CRM закрывается один раз: клиент переходит в «Сделка» вместе с объектом.
if (change.deal.clientId) {
await moveClientToDeal(tx, actor.company.id, actor.user.id, leadId, change.deal.clientId, deal);
}
} else if (from?.code === 'deal') {
await dropDealSide(tx, 'lead', leadId);
}
const [updated] = await tx
.update(leads)
.set({ statusId: to.id, rejectReasonId, statusChangedAt: new Date(), poolWarnedAt: null })
.where(eq(leads.id, leadId))
.returning();
await logEvent(tx, lead, actor.user.id, to.code === 'deal' ? 'deal' : 'status', {
oldValue: from?.name ?? null,
newValue: to.name,
statusId: to.id,
text: deal ? dealText(deal) : null,
});
await touchCompany(tx, actor.company.id);
return updated!;
});
}
export async function addComment(db: Db, actor: Actor, leadId: number, text: string) {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
assertCanWork(actor, lead);
await logEvent(tx, lead, actor.user.id, 'comment', { text });
await touchCompany(tx, actor.company.id);
});
}
export async function setNextAction(db: Db, actor: Actor, leadId: number, at: Date | null, note: string | null) {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
assertCanWork(actor, lead);
await tx
.update(leads)
.set({ nextActionAt: at, nextActionNote: note, nextActionNotifiedAt: null })
.where(eq(leads.id, leadId));
});
}
export interface PhoneVariant {
phone: string;
/** Сколько компаний указали этот номер. */
votes: number;
/** 80 — совпал у двух, 100 — у трёх и больше, null — указал один агент. */
confidence: number | null;
}
/** Все варианты номера по объявлению из общей базы: самый подтверждённый — первым. */
export async function phoneVariants(tx: Tx, listingId: number): Promise<PhoneVariant[]> {
const rows = await tx
.select({ phone: listingPhones.phone, votes: sql<number>`count(*)::int`, first: sql<Date>`min(${listingPhones.createdAt})` })
.from(listingPhones)
.where(eq(listingPhones.listingId, listingId))
.groupBy(listingPhones.phone)
.orderBy(sql`count(*) desc`, sql`min(${listingPhones.createdAt})`);
return rows.map((r) => ({ phone: r.phone, votes: r.votes, confidence: phoneConfidence(r.votes) }));
}
/**
* Телефон собственника, который агент взял из объявления и внёс вручную. Вариант компании
* записывается в общую базу; номером объявления становится самый подтверждённый вариант — он же
* работает в правиле «больше N объявлений с номера». Пустая строка стирает номер у лида и вариант компании.
*/
export async function setLeadPhone(db: Db, actor: Actor, leadId: number, raw: string) {
const phone = raw.trim() ? normalizePhone(raw) : null;
if (raw.trim() && !phone) throw badRequest('BAD_PHONE');
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
assertCanWork(actor, lead);
if (lead.phone === phone) return;
await tx.update(leads).set({ phone }).where(eq(leads.id, leadId));
if (lead.listingId) {
const mine = and(eq(listingPhones.listingId, lead.listingId), eq(listingPhones.companyId, actor.company.id));
if (phone) {
await tx
.insert(listingPhones)
.values({ listingId: lead.listingId, companyId: actor.company.id, userId: actor.user.id, phone })
.onConflictDoUpdate({ target: [listingPhones.listingId, listingPhones.companyId], set: { phone, userId: actor.user.id, createdAt: new Date() } });
} else {
await tx.delete(listingPhones).where(mine);
}
const [best] = await phoneVariants(tx, lead.listingId);
await tx.update(listings).set({ phone: best?.phone ?? null }).where(eq(listings.id, lead.listingId));
}
await logEvent(tx, lead, actor.user.id, 'phone', { newValue: phone });
await touchCompany(tx, actor.company.id);
});
}
export type LeadEdit = Partial<
Pick<Lead, 'title' | 'description' | 'dealType' | 'propertyType' | 'region' | 'district' | 'address' | 'price' | 'currency' | 'rooms' | 'area' | 'floor' | 'floorsTotal' | 'authorName'>
>;
/**
* Правка данных объекта. Меняется только карточка компании — общая база объявлений остаётся как есть.
* Текст для клиента собирается заново из новых данных.
*/
export async function editLead(db: Db, actor: Actor, leadId: number, patch: LeadEdit) {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
assertCanWork(actor, lead);
const changed = Object.fromEntries(
Object.entries(patch).filter(([key, value]) => String(lead[key as keyof LeadEdit] ?? '') !== String(value ?? '')),
) as LeadEdit;
if (!Object.keys(changed).length) return;
await tx.update(leads).set({ ...changed, showcaseText: null }).where(eq(leads.id, leadId));
await logEvent(tx, lead, actor.user.id, 'edited');
await touchCompany(tx, actor.company.id);
});
}
/**
* «Объявления больше нет»: агент увидел, что объявление сняли, раньше суточной проверки.
* Отметка ставится в общей базе — у всех компаний с этим объектом; если объявление снова появится
* в списке list.am, парсер вернёт его на сайт сам.
*/
export async function markListingRemoved(db: Db, actor: Actor, leadId: number) {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
if (lead.assigneeId !== null) assertCanWork(actor, lead);
if (!lead.listingId) throw badRequest('NO_LISTING');
const now = new Date();
const [row] = await tx
.update(listings)
.set({ active: false, removedAt: now, lastCheckedAt: now })
.where(and(eq(listings.id, lead.listingId), eq(listings.active, true)))
.returning({ id: listings.id });
if (!row) return;
await logEvent(tx, lead, actor.user.id, 'listing_removed');
await rejectRemovedListingLeads(tx, row.id);
await touchCompany(tx, actor.company.id);
});
}
/**
* Объявление сняли с List.am: у компаний с настройкой autoRejectRemoved открытые лиды по нему
* (не «Сделка» и не «Отказ») уходят в «Отказ» с причиной «Уже продано или сдано» — в архив,
* из пула и рабочих списков. Возвращает число переведённых лидов.
*/
export async function rejectRemovedListingLeads(tx: Tx, listingId: number): Promise<number> {
const open = await tx
.select({ lead: leads, status: pipelineStatuses.name })
.from(leads)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.innerJoin(companies, eq(companies.id, leads.companyId))
.where(
and(
eq(leads.listingId, listingId),
sql`coalesce(${pipelineStatuses.code}, '') not in ('deal', 'rejected')`,
sql`(${companies.settings}->>'autoRejectRemoved')::boolean is true`,
),
);
for (const { lead, status } of open) {
const rejected = await statusByCode(tx, lead.companyId, 'rejected');
// У компаний, заведённых до кода 'gone', причина находится по названию.
const [reason] = await tx
.select({ id: rejectReasons.id })
.from(rejectReasons)
.where(
and(
eq(rejectReasons.companyId, lead.companyId),
eq(rejectReasons.pipeline, 'object'),
or(eq(rejectReasons.code, 'gone'), and(isNull(rejectReasons.code), eq(rejectReasons.name, 'Уже продано или сдано'))),
),
)
.orderBy(sql`${rejectReasons.code} nulls last`)
.limit(1);
await tx
.update(leads)
.set({ statusId: rejected.id, rejectReasonId: reason?.id ?? null, statusChangedAt: new Date(), poolWarnedAt: null, nextActionAt: null })
.where(eq(leads.id, lead.id));
await logEvent(tx, lead, null, 'status', {
oldValue: status,
newValue: rejected.name,
statusId: rejected.id,
text: 'Объявление снято с List.am',
});
}
return open.length;
}
/** «Это агент»: номер автора — в чёрный список компании (если он известен), лид — в «Отказ». */
export async function markAsAgent(db: Db, actor: Actor, leadId: number): Promise<void> {
await db.transaction(async (tx) => {
const lead = await loadLead(tx, actor, leadId);
if (lead.assigneeId !== null) assertCanWork(actor, lead);
const rejected = await statusByCode(tx, actor.company.id, 'rejected');
const [reason] = await tx
.select()
.from(rejectReasons)
.where(
and(
eq(rejectReasons.companyId, actor.company.id),
eq(rejectReasons.pipeline, 'object'),
eq(rejectReasons.code, 'agent'),
),
);
if (lead.phone) {
await tx
.insert(blacklist)
.values({ companyId: actor.company.id, phone: lead.phone, addedById: actor.user.id })
.onConflictDoNothing();
}
const wasInPool = lead.assigneeId === null;
await tx
.update(leads)
.set({
statusId: rejected.id,
rejectReasonId: reason?.id ?? null,
assigneeId: lead.assigneeId ?? actor.user.id,
statusChangedAt: new Date(),
poolWarnedAt: null,
})
.where(eq(leads.id, leadId));
await logEvent(tx, lead, actor.user.id, 'blacklisted', { newValue: lead.phone, statusId: rejected.id });
if (wasInPool) await enqueueTaken(tx, leadId, actor.user.id);
await touchCompany(tx, actor.company.id);
});
}
/**
* Фоновые задачи по лидам, запускаются раз в минуту:
* предупреждение за 2 часа до возврата в пул, сам возврат, напоминания о следующем действии.
*/
export async function runLeadJobs(db: Db, now = new Date()): Promise<{ warned: number; returned: number; reminded: number }> {
const result = { warned: 0, returned: 0, reminded: 0 };
const due = await db
.select({ lead: leads, hours: sql<number>`(${companies.settings}->>'poolReturnHours')::int` })
.from(leads)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.innerJoin(companies, eq(companies.id, leads.companyId))
.where(
and(
sql`${leads.assigneeId} is not null`,
eq(pipelineStatuses.poolReturn, true),
// Предупреждение уходит за 2 часа до срока; при сроке меньше 2 часов — сразу.
sql`${leads.statusChangedAt} <= ${now.toISOString()}::timestamptz - make_interval(hours => greatest((${companies.settings}->>'poolReturnHours')::int - 2, 0))`,
),
);
for (const { lead, hours } of due) {
const expired = lead.statusChangedAt.getTime() <= now.getTime() - hours * 3_600_000;
await db.transaction(async (tx) => {
// Перечитываем под блокировкой: агент мог сменить статус, пока шла выборка.
const [current] = await tx.select().from(leads).where(eq(leads.id, lead.id)).for('update');
if (!current || current.assigneeId === null || current.statusChangedAt.getTime() !== lead.statusChangedAt.getTime()) return;
if (expired) {
await returnToPool(tx, current, null);
result.returned++;
} else if (!current.poolWarnedAt) {
const left = Math.max(1, Math.round((current.statusChangedAt.getTime() + hours * 3_600_000 - now.getTime()) / 3_600_000));
await enqueueText(tx, [current.assigneeId], 'poolWarn', [current.title, left], current.id);
await tx.update(leads).set({ poolWarnedAt: now }).where(eq(leads.id, current.id));
result.warned++;
}
});
}
const reminders = await db
.update(leads)
.set({ nextActionNotifiedAt: now })
.where(
and(
sql`${leads.nextActionAt} <= ${now.toISOString()}::timestamptz`,
isNull(leads.nextActionNotifiedAt),
sql`${leads.assigneeId} is not null`,
),
)
.returning();
for (const lead of reminders) {
await enqueueText(db, [lead.assigneeId!], 'reminder', [lead.title, lead.nextActionNote], lead.id);
result.reminded++;
}
return result;
}
export { loadLead, statusByCode };
+51
View File
@@ -0,0 +1,51 @@
import { expectedCommission, toUsd, type CommissionKey, type CommissionRule, type CommissionSettings, type Currency } from '@lr/shared';
/**
* Комиссия для карточки: условие (своё или из настроек компании) и ожидаемая сумма.
* `custom` — агент поменял условие в этой карточке.
*/
export function commissionView(
settings: CommissionSettings | undefined,
key: CommissionKey,
own: CommissionRule | null,
price: number | null | undefined,
currency: string | null | undefined,
) {
const rule = own ?? settings?.[key] ?? null;
return { rule, custom: Boolean(own), expected: expectedCommission(rule, price, currency as Currency | null) };
}
/** Условие в записи истории: «percent:2.5», «fixed:1000:USD», «mixed:200:USD:2»; null — как в настройках компании. */
export const ruleCode = (rule: CommissionRule | null | undefined): string | null =>
!rule
? null
: rule.mode === 'percent'
? `percent:${rule.value}`
: rule.mode === 'fixed'
? `fixed:${rule.value}:${rule.currency}`
: `mixed:${rule.fixed ?? 0}:${rule.currency}:${rule.value}`;
/**
* На сколько процентов изменилась ожидаемая сумма комиссии: 50 % → 35 % от той же цены — это −30 %.
* Если цены нет, но оба условия — проценты, считаем по самим процентам. null — сравнить нечего.
*/
export function commissionDelta(
before: CommissionRule | null | undefined,
after: CommissionRule | null | undefined,
price: number | null | undefined,
currency: string | null | undefined,
): number | null {
const a = expectedCommission(before, price, currency as Currency | null);
const b = expectedCommission(after, price, currency as Currency | null);
let from: number | null = a ? toUsd(a.amount, a.currency) : null;
let to: number | null = b ? toUsd(b.amount, b.currency) : null;
if ((from === null || to === null) && before?.mode === 'percent' && after?.mode === 'percent') {
from = before.value;
to = after.value;
}
if (from === null || to === null || !(from > 0)) return null;
return Math.round(((to - from) / from) * 1000) / 10;
}
/** Поле text записи истории: «<условие компании при сбросе>|<изменение суммы в процентах>». */
export const commissionNote = (settingsCode: string | null, delta: number | null) => `${settingsCode ?? ''}|${delta ?? ''}`;
+41
View File
@@ -0,0 +1,41 @@
export interface Config {
databaseUrl: string;
port: number;
appUrl: string;
production: boolean;
telegramBotToken?: string;
telegramBotUsername?: string;
/** listam | feed | off */
listingSource: string;
jinaApiKey?: string;
/** Клиентский бот: анкета соискателя. Без токена анкета выключена. */
clientBotToken?: string;
clientBotUsername?: string;
/** Сколько процентов высоты срезать сверху у фото на странице клиента. */
cropTopPercent: number;
/** Куда складывать обработанные фото. */
mediaDir: string;
/** Переписывание текста объекта моделью через OpenRouter. Без ключа текст собирается по шаблону. */
openRouterKey?: string;
openRouterModel: string;
}
export function loadConfig(env = process.env): Config {
if (!env.DATABASE_URL) throw new Error('DATABASE_URL не задан');
return {
databaseUrl: env.DATABASE_URL,
port: Number(env.API_PORT ?? 3100),
appUrl: (env.APP_URL ?? 'http://localhost:5174').replace(/\/$/, ''),
production: env.NODE_ENV === 'production',
telegramBotToken: env.TELEGRAM_BOT_TOKEN || undefined,
telegramBotUsername: env.TELEGRAM_BOT_USERNAME || undefined,
listingSource: env.LISTING_SOURCE || 'listam',
jinaApiKey: env.JINA_API_KEY || undefined,
clientBotToken: env.CLIENT_BOT_TOKEN || undefined,
clientBotUsername: env.CLIENT_BOT_USERNAME || undefined,
cropTopPercent: Math.max(0, Math.min(40, Number(env.CROP_TOP_PERCENT ?? 8) || 0)),
mediaDir: env.MEDIA_DIR || './data/media',
openRouterKey: env.OPENROUTER_API_KEY || undefined,
openRouterModel: env.OPENROUTER_MODEL || 'anthropic/claude-haiku-4.5',
};
}
+15
View File
@@ -0,0 +1,15 @@
/** Ошибка с кодом: интерфейс переводит код на язык пользователя. */
export class AppError extends Error {
constructor(
readonly status: number,
readonly code: string,
message = code,
) {
super(message);
}
}
export const forbidden = (code = 'FORBIDDEN') => new AppError(403, code);
export const notFound = (code = 'NOT_FOUND') => new AppError(404, code);
export const badRequest = (code: string) => new AppError(400, code);
export const conflict = (code: string) => new AppError(409, code);
+16
View File
@@ -0,0 +1,16 @@
export interface Mail {
to: string;
subject: string;
text: string;
}
/** Отправка писем. Почтовый сервис ещё не выбран — пока письма пишутся в лог. */
export interface Mailer {
send(mail: Mail): Promise<void>;
}
export const consoleMailer: Mailer = {
async send(mail) {
console.log(`[mail → ${mail.to}] ${mail.subject}\n${mail.text}`);
},
};
+9
View File
@@ -0,0 +1,9 @@
/**
* Для PATCH-запросов: оставляет из разобранного тела только поля, которые клиент действительно
* прислал. Схема с `.partial()` подставляет значения по умолчанию и в пропущенные поля — без
* этой фильтрации правка одного поля сбрасывала бы остальные.
*/
export function onlySent<T extends object>(body: unknown, parsed: T): Partial<T> {
const sent = body !== null && typeof body === 'object' ? body : {};
return Object.fromEntries(Object.entries(parsed).filter(([key]) => key in sent)) as Partial<T>;
}
+99
View File
@@ -0,0 +1,99 @@
import { buildApp } from './app.ts';
import { runBillingJobs } from './billing/service.ts';
import { runClientJobs } from './clients/service.ts';
import { connect } from './db/client.ts';
import { runIngest, runRemovalCheck } from './ingest/service.ts';
import { runParserWatchdog, type WatchdogState } from './ingest/watchdog.ts';
import { handleIntakeUpdate, telegramDownloader } from './intake/service.ts';
import { runReportJobs } from './reports.ts';
import { runShowcaseJobs } from './showcase/service.ts';
import { runLeadJobs } from './leads/service.ts';
import { loadConfig } from './lib/config.ts';
import { runGroupPosts } from './group/service.ts';
import { ListAmListingSource } from './parser/listam-source.ts';
import { loadParserSettings } from './parser/settings.ts';
import { startPolling } from './telegram/bot.ts';
import { HttpTelegram } from './telegram/client.ts';
import { processOutbox } from './telegram/outbox.ts';
const config = loadConfig();
const { db } = connect(config.databaseUrl);
const source = config.listingSource === 'listam' ? new ListAmListingSource({ apiKey: config.jinaApiKey }) : null;
const app = buildApp({ db, config, source });
await app.listen({ port: config.port, host: '0.0.0.0' });
console.log(`API слушает порт ${config.port}`);
/** Повторяет задачу бесконечно: следующий запуск — через паузу после окончания предыдущего. */
function loop(name: string, pauseMs: number | (() => Promise<number>), task: () => Promise<unknown>) {
void (async () => {
for (;;) {
try {
await task();
} catch (e) {
console.error(`[${name}]`, e);
}
const ms = typeof pauseMs === 'number' ? pauseMs : await pauseMs().catch(() => 5_000);
await new Promise((r) => setTimeout(r, ms));
}
})();
}
// Клиентский бот — анкета соискателя. Без токена анкета выключена, остальное работает.
const clientTg = config.clientBotToken ? new HttpTelegram(config.clientBotToken) : null;
if (clientTg) {
const download = telegramDownloader(config.clientBotToken!);
startPolling({ tg: clientTg }, console.error, (update) => handleIntakeUpdate({ db, tg: clientTg, config, download }, update));
console.log(`Клиентский бот @${config.clientBotUsername} запущен`);
}
if (config.telegramBotToken) {
const tg = new HttpTelegram(config.telegramBotToken);
startPolling({ db, tg, config, clientTg });
// Очередь сообщений: пока есть что отправлять, пачки идут подряд.
loop('outbox', 500, async () => {
while ((await processOutbox({ db, tg, config })) > 0);
});
// Mini App открывается кнопкой меню бота; Telegram принимает для неё только https-адрес.
if (config.appUrl.startsWith('https://')) {
tg.setMenuButton('Lead Radar', config.appUrl).catch((e) => console.error('[menu-button]', e));
}
console.log(`Бот @${config.telegramBotUsername} запущен`);
} else {
console.log('TELEGRAM_BOT_TOKEN не задан: бот и уведомления выключены');
}
// Возврат в пул, предупреждения и напоминания.
loop('lead-jobs', 60_000, async () => {
await runLeadJobs(db);
await runClientJobs(db);
});
// Объекты, которые агент показывает клиенту: полный набор фото и текст для страницы подборки.
loop('showcase', 20_000, () => runShowcaseJobs({ db, config, source }));
// Объекты в работе с указанной комиссией — в группу компании её ботом.
loop('group', 20_000, () => runGroupPosts({ db, config }));
// Отчёты руководителю в Telegram: за неделю и за месяц.
loop('reports', 600_000, () => runReportJobs(db, config));
// Счета, запланированная смена тарифа и напоминания о подписке.
loop('billing', 600_000, () => runBillingJobs(db));
if (source) {
// Парсер ходит по кругу почти без паузы: скорость «публикация → лид» определяется длительностью
// прохода, а темп запросов к ридеру выдерживает сам источник.
// Включение и пауза между проходами берутся из настроек парсера в админке.
loop(
'parser',
async () => Math.max(1, (await loadParserSettings(db)).pauseSec) * 1000,
async () => {
if ((await loadParserSettings(db)).enabled) await runIngest({ db, source, log: console.log });
},
);
// Отметка «Снято с List.am»: пачка объявлений раз в 10 минут, каждое — раз в сутки.
loop('removal-check', 600_000, () => runRemovalCheck({ db, source }));
// Сторож: парсер выключен или стоит дольше 20 минут — сообщение суперадминам в Telegram.
const watchdog: WatchdogState = { alertedAt: null };
loop('parser-watchdog', 300_000, () => runParserWatchdog(db, watchdog));
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest';
import type { SourcedListing } from './source.ts';
import { FeedListingSource } from './feed-source.ts';
const filter = { city: 'yerevan', category: 'apartments', ownerOnly: true };
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
async function collect(source: FeedListingSource, report = vi.fn()) {
const items: SourcedListing[] = [];
for await (const item of source.fetchLatest(filter, report)) items.push(item);
return items;
}
describe('FeedListingSource', () => {
it('maps feed items, follows the cursor and sends the token', async () => {
const fetchFn = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
json({
items: [
{
id: 123,
url: 'https://feed.example/items/123',
title: '2-комн., Арабкир',
price: '120,000',
currency: 'usd',
district: 'Arabkir',
rooms: '2',
photos: ['https://img.example/1.jpg'],
},
],
next: 'c2',
}),
)
.mockResolvedValueOnce(json({ items: [{ id: 'x9', url: 'https://feed.example/x9' }] }));
const items = await collect(
new FeedListingSource('https://feed.example/api', 'secret', fetchFn),
);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
externalId: '123',
sourceUrl: 'https://feed.example/items/123',
price: 120000,
currency: 'USD',
district: 'Arabkir',
rooms: 2,
photoUrls: ['https://img.example/1.jpg'],
});
expect(items[1]).toMatchObject({ externalId: 'x9', title: 'Объявление x9', currency: 'USD' });
const [firstUrl, init] = fetchFn.mock.calls[0]!;
expect(String(firstUrl)).toBe(
'https://feed.example/api?city=yerevan&category=apartments&ownerOnly=true',
);
expect((init?.headers as Record<string, string>).authorization).toBe('Bearer secret');
expect(String(fetchFn.mock.calls[1]![0])).toContain('cursor=c2');
});
it('skips malformed items and reports them', async () => {
const fetchFn = vi.fn<typeof fetch>().mockResolvedValue(
json({
items: [
{ id: 1, url: 'not a url' },
{ id: 2, url: 'https://a.b/2' },
],
}),
);
const report = vi.fn();
const items = await collect(
new FeedListingSource('https://feed.example', undefined, fetchFn),
report,
);
expect(items.map((i) => i.externalId)).toEqual(['2']);
expect(report).toHaveBeenCalledOnce();
});
it('fails the run on HTTP errors', async () => {
const fetchFn = vi.fn<typeof fetch>().mockResolvedValue(json({}, 503));
await expect(
collect(new FeedListingSource('https://feed.example', undefined, fetchFn)),
).rejects.toThrow('Фид ответил 503');
});
});
+141
View File
@@ -0,0 +1,141 @@
import { CURRENCIES } from '@lr/shared';
import { z } from 'zod';
import type {
ListingSource,
ListingSourceFilter,
SourcedListing,
} from './source.ts';
const text = z
.union([z.string(), z.number()])
.transform((v) => String(v).trim())
.optional()
.nullable()
.transform((v) => v || undefined);
const num = z
.union([z.number(), z.string()])
.optional()
.nullable()
.transform((v) => {
if (v === null || v === undefined || v === '') return undefined;
const n = typeof v === 'number' ? v : Number(v.replace(/[\s,]/g, ''));
return Number.isFinite(n) ? n : undefined;
});
/** Объявление в фиде. Обязательны только ID и ссылка, остальное — по возможности. */
const FeedItem = z.object({
id: z.union([z.string().min(1), z.number()]).transform(String),
url: z.url(),
title: text,
description: text,
price: num,
currency: text,
district: text,
propertyType: text,
rooms: num,
floor: num,
floorsTotal: num,
area: num,
address: text,
ownerName: text,
phone: text,
dealType: z.enum(['sale', 'rent']).optional().nullable(),
region: text,
isAgency: z.boolean().optional().nullable(),
publishedAt: z.iso.datetime({ offset: true }).optional().nullable(),
photos: z.array(z.string()).optional().default([]),
});
type FeedItem = z.infer<typeof FeedItem>;
const FeedPage = z.object({
items: z.array(z.unknown()),
/** Курсор следующей страницы; нет или null — страниц больше нет. */
next: z.string().nullish(),
});
const MAX_PAGES = 20;
/**
* Источник объявлений — JSON-фид по HTTP (формат описан в docs/PARSER.md).
* Подходит для официального API/фида list.am или провайдера данных: адаптер не ходит
* на сайт list.am сам, а читает то, что отдаёт настроенный фид.
*/
export class FeedListingSource implements ListingSource {
readonly name = 'feed';
constructor(
private readonly url: string,
private readonly token: string | undefined,
private readonly fetchFn: typeof fetch = fetch,
) {}
async *fetchLatest(
filter: ListingSourceFilter,
report: (problem: string) => void,
): AsyncIterable<SourcedListing> {
let cursor: string | undefined;
for (let page = 0; page < MAX_PAGES; page++) {
const body = await this.request(filter, cursor);
for (const [i, raw] of body.items.entries()) {
const item = FeedItem.safeParse(raw);
if (!item.success) {
report(
`Запись ${i + 1} на странице ${page + 1} пропущена: ${item.error.issues[0]?.message}`,
);
continue;
}
yield toSourced(item.data);
}
if (!body.next) return;
cursor = body.next;
}
}
private async request(filter: ListingSourceFilter, cursor?: string) {
const url = new URL(this.url);
url.searchParams.set('city', filter.city);
url.searchParams.set('category', filter.category);
url.searchParams.set('ownerOnly', String(filter.ownerOnly));
if (cursor) url.searchParams.set('cursor', cursor);
const res = await this.fetchFn(url, {
headers: {
accept: 'application/json',
...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
},
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`Фид ответил ${res.status} ${res.statusText}`.trim());
const parsed = FeedPage.safeParse(await res.json());
if (!parsed.success) throw new Error('Фид вернул данные не в ожидаемом формате');
return parsed.data;
}
}
function toSourced(item: FeedItem): SourcedListing {
const currency = item.currency?.toUpperCase();
return {
externalId: item.id,
sourceUrl: item.url,
title: item.title ?? `Объявление ${item.id}`,
description: item.description,
price: item.price,
currency: (CURRENCIES as readonly string[]).includes(currency ?? '')
? (currency as SourcedListing['currency'])
: 'USD',
district: item.district,
propertyType: item.propertyType,
rooms: item.rooms,
floor: item.floor,
floorsTotal: item.floorsTotal,
area: item.area,
address: item.address,
ownerName: item.ownerName,
phone: item.phone,
dealType: item.dealType ?? undefined,
region: item.region,
isAgency: item.isAgency ?? false,
publishedAt: item.publishedAt ? new Date(item.publishedAt) : undefined,
photoUrls: item.photos,
};
}
+465
View File
@@ -0,0 +1,465 @@
import { describe, expect, it, vi } from 'vitest';
import type { SourcedListing } from './source.ts';
import { LISTAM_CATEGORIES, ListAmListingSource } from './listam-source.ts';
const filter = { city: 'yerevan', category: 'apartments', ownerOnly: true };
const noSleep = () => Promise.resolve();
const CATEGORY_PAGE = `
## Недвижимость
Топ объявления
[![Image 2](https://img.list.am/g/263/101571263.webp) $1,600,000 Склад в Каракерте, 20,000 кв.м. Монолит, 20,000 кв.м. Каракерт](https://www.list.am/ru/item/24154105?ld_src=2)
Обычные объявления
[![Image 26](https://img.list.am/g/032/100081032.webp) $299,000 4-комн. квартира на ул. Налбандяна в центре, 155 кв.м., 3/9 этаж 4 ком., 155 кв.м., 3/9 этаж Кентрон](https://www.list.am/ru/item/14290392?ld_src=2)
[![Image 28](https://img.list.am/g/914/102407914.webp) 650,000֏в месяц 2-комн. квартира в новостройке на пр. Аршакуняц в центре, 56 кв.м., 2/18 этаж 2 ком., 56 кв.м., 2/18 этаж ![Image 29](https://www.list.am/img/redesign/icons/info_gray.svg)Недвижимость не проверена Агентство Кентрон](https://www.list.am/ru/item/23836325?ld_src=2)
[![Image 30](https://img.list.am/g/212/100036212.webp) 28,750,000֏ 2-комн. квартира в новостройке, 41-я улица в Мецаморе, 87 кв.м. 2 ком., 87 кв.м. Мецамор](https://www.list.am/ru/item/995117414?ld_src=2)
[![Image 32](https://img.list.am/g/466/102314466.webp) €90 Участок общего назначения в Зовуни, 3,100 кв.м. 3100 кв.м. Зовуни](https://www.list.am/ru/item/24005094?ld_src=2)
`;
/** Страница объявления, как её отдаёт ридер с x-target-selector (снято с list.am в октябре 2026). */
const itemPage = (address: string, description: string, extra = '') => `Title: 2-комн. квартира - Продажа квартир
URL Source: https://www.list.am/ru/item/14290392
Markdown Content:
[${address}](https://www.list.am/ru/item/14290392)
${extra}
37 кв.м.
Общая площадь
3
Этаж
9
Этажей в доме
2.8 м
Высота потолков
4
Количество комнат
1
Количество санузлов
Регион
О здании
Тип здания
Монолит
Описание
${description}
Номер объявления 14290392 Размещено 08.10.2026 Обновлено 08.10.2026, 19:34
Links/Buttons:
This page does not seem to contain any buttons/links.
`;
const ITEM_PAGE = itemPage('Nalbandyan street 5, Երևան', 'Светлая квартира в центре.\n\nТорг & ипотека');
function fakeFetch(pages: Record<string, string>) {
return vi.fn<typeof fetch>().mockImplementation(async (input) => {
const url = String(input);
for (const [needle, body] of Object.entries(pages)) {
if (url.endsWith(needle)) return new Response(body, { status: 200 });
}
return new Response('not found', { status: 404 });
});
}
async function collect(source: ListAmListingSource, report = vi.fn()) {
const items: SourcedListing[] = [];
for await (const item of source.fetchLatest(filter, report)) items.push(item);
return items;
}
const source = (fetchFn: typeof fetch, fetchDetails = false) =>
new ListAmListingSource({ fetchFn, fetchDetails, sleep: noSleep });
describe('ListAmListingSource', () => {
it('parses cards: regular first, promoted last, canonical url, parsed fields', async () => {
const items = await collect(source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '' })));
// Мецамор и Зовуни — не Ереван, отфильтрованы; топ-склад — тоже. Агентство — ownerOnly.
expect(items.map((i) => i.externalId)).toEqual(['14290392']);
expect(items[0]).toMatchObject({
sourceUrl: 'https://www.list.am/item/14290392',
title: '4-комн. квартира на ул. Налбандяна в центре, 155 кв.м., 3/9 этаж 4 ком., 155 кв.м., 3/9 этаж',
price: 299_000,
currency: 'USD',
district: 'Кентрон',
propertyType: 'apartment',
rooms: 4,
floor: 3,
floorsTotal: 9,
area: 155,
photoUrls: ['https://img.list.am/f/032/100081032.webp'],
});
});
it('yields promoted cards after regular ones', async () => {
const page = `
Топ объявления
[![Image 2](https://img.list.am/g/1/1.webp) $50,000 1-комн. квартира, 30 кв.м. Арабкир](https://www.list.am/ru/item/111?ld_src=2)
Обычные объявления
[![Image 3](https://img.list.am/g/2/2.webp) $60,000 2-комн. квартира, 40 кв.м. Арабкир](https://www.list.am/ru/item/222?ld_src=2)
`;
const items = await collect(source(fakeFetch({ '/ru/category/60': page, '/ru/category/56': '' })));
expect(items.map((i) => i.externalId)).toEqual(['222', '111']);
});
it('keeps agency cards when ownerOnly is off', async () => {
const items: SourcedListing[] = [];
const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '' }));
for await (const item of src.fetchLatest({ ...filter, ownerOnly: false }, vi.fn()))
items.push(item);
expect(items.map((i) => i.externalId)).toContain('23836325');
const agency = items.find((i) => i.externalId === '23836325')!;
expect(agency).toMatchObject({ price: 650_000, currency: 'AMD', rooms: 2, floor: 2, floorsTotal: 18 });
expect(agency.title).not.toContain('Недвижимость не проверена');
});
it('imports non-Yerevan cards when city filter is off', async () => {
const items: SourcedListing[] = [];
const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '' }));
for await (const item of src.fetchLatest({ ...filter, city: 'armenia' }, vi.fn()))
items.push(item);
expect(items.map((i) => i.externalId)).toContain('24005094');
expect(items.find((i) => i.externalId === '24005094')).toMatchObject({
price: 90,
currency: 'EUR',
// Тип объекта задаёт категория list.am, а не текст карточки.
propertyType: 'apartment',
dealType: 'sale',
});
});
it('название города, оканчивающееся как район Еревана, районом не считается', async () => {
const page = `Обычные объявления
[![Image 1](https://img.list.am/g/1/1.webp) $90,000 Дом на ул. Самвела Варданяна, 120 кв.м. 3 ком., 120 кв.м. Бюрегаван](https://www.list.am/ru/item/995000001?ld_src=2)
[![Image 2](https://img.list.am/g/1/2.webp) $95,000 Дом в Аване, 100 кв.м. 3 ком., 100 кв.м. Аван](https://www.list.am/ru/item/995000002?ld_src=2)
`;
const house = LISTAM_CATEGORIES.find((c) => c.propertyType === 'house' && c.dealType === 'sale')!;
const cards = await source(fakeFetch({ [`/ru/category/${house.id}`]: page })).listPage(house);
expect(cards[0]).toMatchObject({ region: 'Котайк', district: 'Бюрегаван' });
expect(cards[1]).toMatchObject({ region: 'Ереван', district: 'Аван' });
});
it('listPage: один запрос, тип сделки и метка агентства из карточки', async () => {
const fetchFn = fakeFetch({ '/ru/category/56': CATEGORY_PAGE });
const rent = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'rent')!;
const cards = await source(fetchFn, true).listPage(rent);
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(cards.every((c) => c.dealType === 'rent')).toBe(true);
expect(cards.find((c) => c.externalId === '23836325')).toMatchObject({ isAgency: true, region: 'Ереван', district: 'Кентрон' });
expect(cards.find((c) => c.externalId === '14290392')).toMatchObject({ isAgency: false });
// Обычные объявления идут раньше «Топ».
expect(cards.at(-1)!.externalId).toBe('24154105');
});
it('enrich: вне Еревана населённый пункт и область берутся из адреса', async () => {
const page = itemPage('41-я улица 5, Мецамор', 'Дом.', 'Агентство');
const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/995117414': page }), true);
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
const card = (await src.listPage(sale)).find((c) => c.externalId === '995117414')!;
// Область и населённый пункт известны уже по карточке — без запроса страницы объявления.
expect(card).toMatchObject({ region: 'Армавир', district: 'Мецамор' });
expect(card.isAgency).toBe(false);
// Метка агентства бывает только на странице объявления — она тоже учитывается.
expect(await src.enrich(card)).toMatchObject({ region: 'Армавир', district: 'Мецамор', address: '41-я улица 5, Мецамор', isAgency: true });
});
it('enriches from the item page when fetchDetails is on', async () => {
const items = await collect(
source(
fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '', '/ru/item/14290392': ITEM_PAGE }),
true,
),
);
expect(items[0]).toMatchObject({
description: 'Светлая квартира в центре.\nТорг & ипотека',
address: 'Nalbandyan street 5, Երևան',
area: 37,
floor: 3,
floorsTotal: 9,
rooms: 4,
photoUrls: ['https://img.list.am/f/032/100081032.webp'],
// «Размещено 08.10.2026» — начало этого дня по Еревану.
publishedAt: new Date('2026-10-07T20:00:00Z'),
isAgency: false,
});
});
it('ридер заблокировал запросы без ключа — страницы категорий читаются с ключом', async () => {
const fetchFn = vi.fn<typeof fetch>().mockImplementation(async (_input, init) =>
new Headers(init?.headers).get('authorization')
? new Response(CATEGORY_PAGE, { status: 200 })
: new Response('AuthenticationRequiredError: blocked from performing anonymous queries', { status: 401 }),
);
const src = new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' });
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
expect((await src.listPage(sale)).length).toBeGreaterThan(0);
expect(fetchFn).toHaveBeenCalledTimes(2);
// Следующий запрос сразу идёт с ключом, без лишнего отказа.
expect((await src.listPage(sale)).length).toBeGreaterThan(0);
expect(fetchFn).toHaveBeenCalledTimes(3);
});
it('publishedAt: дата из подвала страницы, отдельным запросом', async () => {
const footer = 'Markdown Content:\nНомер объявления 14290392Размещено 05.10.2026Обновлено 08.10.2026, 20:01\n';
const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': footer });
const src = source(fetchFn, true);
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!;
expect(await src.publishedAt(card)).toEqual(new Date('2026-10-04T20:00:00Z'));
expect(new Headers(fetchFn.mock.calls.at(-1)![1]!.headers).get('x-target-selector')).toBe('.footer');
expect(await source(fakeFetch({ '/ru/item/14290392': 'Markdown Content:\n' }), true).publishedAt(card)).toBeUndefined();
await expect(source(fakeFetch({ '/ru/item/14290392': 'Just a moment...' }), true).publishedAt(card)).rejects.toThrow('Cloudflare');
});
it('inspect с ключом: один запрос HTML — точное время размещения и все детали', async () => {
const attr = (value: string, label: string) =>
`<div class="at3"><p class="te13 text-primary">${value}</p><p class="te13 text-light-grey ellipsis">${label}</p></div>`;
const html = `<title>Дом</title><div class="loc"><a href="">улица Маргаряна 17, Ереван</a></div>
<div class="po79"><span class="ge4">Код 3Y</span><span class="ge4">Агентство</span></div>
<div class="attr">${attr('77.3 кв.м.', 'Общая площадь')}${attr('2', 'Этаж')}${attr('4', 'Этажей в доме')}${attr('3', 'Количество комнат')}</div>
<div class="gt">Описание</div><div class="body" itemprop="description">Первая строка<br>Вторая &amp; третья</div>
<div class="footer"><span>Номер объявления 14290392</span><span itemprop="datePosted" content="2026-10-08T15:38:04+00:00">Размещено 08.10.2026</span></div>`;
const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': html });
const src = new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' });
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!;
const headers = (n: number) => new Headers(fetchFn.mock.calls.at(n)![1]!.headers);
// Большая страница категории читается без ключа: ключ оплачивается по объёму ответа.
expect(headers(0).get('authorization')).toBeNull();
// Старое объявление: один маленький запрос подвала.
const old = await src.inspect(card, () => false);
expect(old.publishedAt).toEqual(new Date('2026-10-08T15:38:04Z'));
expect(old.description).toBeUndefined();
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(headers(-1).get('x-target-selector')).toBe('.footer');
expect(headers(-1).get('authorization')).toBe('Bearer jina_key');
expect(headers(-1).get('x-return-format')).toBe('html');
expect(headers(-1).get('x-engine')).toBe('browser');
// Свежее: подвал и блоки с деталями.
const full = await src.inspect(card, () => true);
expect(fetchFn).toHaveBeenCalledTimes(4);
expect(full).toMatchObject({
publishedAt: new Date('2026-10-08T15:38:04Z'), address: 'улица Маргаряна 17, Ереван', area: 77.3, floor: 2,
floorsTotal: 4, rooms: 3, description: 'Первая строка\nВторая & третья', isAgency: true,
});
});
it('ключ отклонён (кончился баланс): объявление проверяется без ключа', async () => {
const footer = 'Markdown Content:\nНомер объявления 14290392Размещено 05.10.2026\n';
const fetchFn = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (new Headers(init!.headers).get('authorization')) return new Response('no balance', { status: 402 });
return new Response(String(url).includes('/category/') ? CATEGORY_PAGE : footer);
});
const src = new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' });
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!;
expect((await src.inspect(card, () => false)).publishedAt).toEqual(new Date('2026-10-04T20:00:00Z'));
const calls = fetchFn.mock.calls.length;
// Дальше ключ час не используется: лишних отклонённых запросов нет.
await src.inspect(card, () => false);
expect(fetchFn.mock.calls.length).toBe(calls + 1);
});
it('проверка «объявление ещё на сайте»: есть подвал — да; нет подвала и «не существует» — снято; иначе неизвестно', async () => {
const reader = (pages: Record<string, { footer?: string; h1?: string }>) =>
vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
const page = pages[String(url).split('/item/')[1]!] ?? {};
const text = new Headers(init!.headers).get('x-target-selector') === '.footer' ? page.footer : page.h1;
return text === undefined ? new Response('No content available', { status: 422 }) : new Response(text);
});
const src = new ListAmListingSource({
sleep: noSleep,
fetchFn: reader({
'100': { footer: 'Номер объявления 100Размещено 04.10.2026', h1: 'Квартира' },
'200': { h1: 'Данного объявления не существует' },
'300': { h1: 'Квартира' },
'400': { footer: 'Just a moment...' },
}),
});
expect(await src.isAlive('100')).toBe(true);
expect(await src.isAlive('200')).toBe(false);
// Подвал не пришёл, но и страницы «не существует» нет: ридер ошибся — решать рано.
expect(await src.isAlive('300')).toBeUndefined();
expect(await src.isAlive('400')).toBeUndefined();
expect(await src.isAlive('500')).toBeUndefined();
});
it('страница категории: при ответе «слишком много запросов» чтение повторяется с ключом', async () => {
const fetchFn = vi.fn<typeof fetch>().mockImplementation(async (_url, init) =>
new Headers(init!.headers).get('authorization') ? new Response(CATEGORY_PAGE) : new Response('rate limit', { status: 429 }),
);
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
expect((await new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' }).listPage(sale)).length).toBeGreaterThan(0);
await expect(new ListAmListingSource({ fetchFn, sleep: noSleep }).listPage(sale)).rejects.toThrow('429');
});
it('inspect без ключа: сначала дата, детали — только для свежего объявления', async () => {
const footer = 'Markdown Content:\nНомер объявления 14290392Размещено 05.10.2026\n';
const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': footer });
const src = source(fetchFn, true);
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!;
expect((await src.inspect(card, () => false)).publishedAt).toEqual(new Date('2026-10-04T20:00:00Z'));
expect(fetchFn).toHaveBeenCalledTimes(2); // список и подвал; за деталями старого объявления не ходим
await src.inspect(card, () => true);
expect(fetchFn).toHaveBeenCalledTimes(4);
});
it('страница без подвала с датой и страница с проверкой Cloudflare', async () => {
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
const noFooter = ITEM_PAGE.replace(/Номер объявления.*\n/, '');
const a = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': noFooter }), true);
const card = (await a.listPage(sale)).find((c) => c.externalId === '14290392')!;
expect(await a.details(card)).toMatchObject({ publishedAt: undefined, description: 'Светлая квартира в центре.\nТорг & ипотека' });
const b = source(fakeFetch({ '/ru/item/14290392': '<title>Just a moment...</title>' }), true);
await expect(b.details(card)).rejects.toThrow('Cloudflare');
});
it('details() бросает ошибку, если страницу объявления прочитать не удалось', async () => {
const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE }), true);
const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!;
const [card] = await src.listPage(sale);
await expect(src.details(card!)).rejects.toThrow('Ридер ответил 404');
});
it('параллельные запросы идут по одному с паузой: лимит ридера не превышается', async () => {
const sleeps: number[] = [];
const src = new ListAmListingSource({
fetchFn: fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': CATEGORY_PAGE }),
sleep: async (ms) => void sleeps.push(ms),
});
await Promise.all(LISTAM_CATEGORIES.slice(0, 2).map((c) => src.listPage(c)));
// Первый запрос уходит сразу, второй ждёт свою очередь около 3 секунд.
expect(sleeps).toHaveLength(1);
expect(sleeps[0]).toBeGreaterThan(3000);
});
it('keeps card data when the item page fails', async () => {
const report = vi.fn();
const items = await collect(
source(
fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/60/2': '', '/ru/category/56': '' }),
true,
),
report,
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ externalId: '14290392', area: 155 });
expect(report).toHaveBeenCalledOnce();
});
it('paginates a category until an empty page', async () => {
const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/60/2': '', '/ru/category/56': '' });
await collect(source(fetchFn));
const urls = fetchFn.mock.calls.map((c) => String(c[0]));
expect(urls.some((u) => u.endsWith('/ru/category/60'))).toBe(true);
expect(urls.some((u) => u.endsWith('/ru/category/60/2'))).toBe(true);
expect(urls.some((u) => u.endsWith('/ru/category/60/3'))).toBe(false);
});
it('fails on an unknown category', async () => {
const src = source(fakeFetch({}));
await expect(async () => {
for await (const _ of src.fetchLatest({ ...filter, category: 'boats' }, vi.fn())) void _;
}).rejects.toThrow('Неизвестная категория');
});
it('accepts dashed category names like apartments-rent', async () => {
const fetchFn = fakeFetch({ '/ru/category/56': CATEGORY_PAGE });
const src = new ListAmListingSource({ fetchFn, sleep: noSleep, fetchDetails: false });
const items = [];
for await (const item of src.fetchLatest({ ...filter, category: 'apartments-rent' }, vi.fn()))
items.push(item);
expect(items.length).toBeGreaterThan(0);
const urls = fetchFn.mock.calls.map((c) => String(c[0]));
expect(urls.some((u) => u.endsWith('/ru/category/56'))).toBe(true);
expect(urls.some((u) => u.endsWith('/ru/category/60'))).toBe(false);
});
it('fails the run when the reader errors', async () => {
const src = source(fakeFetch({}));
await expect(collect(src)).rejects.toThrow('Ридер ответил 404');
});
it('страницы категорий читаются без ключа ридера', async () => {
const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/60/2': '', '/ru/category/56': '' });
await collect(new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key', fetchDetails: false }));
for (const call of fetchFn.mock.calls) expect(new Headers(call[1]!.headers).get('authorization')).toBeNull();
});
});
describe('характеристики объявления', () => {
it('удобства, правила проживания и остальные поля; отсутствующие удобства не попадают', async () => {
const { parseFeatures } = await import('./listam-source.ts');
const item = (main: string, sub?: string, off = false) =>
`<div class="at3${off ? ' disabled' : ''}"><div class="attr-icon"></div><div class="bo67"><p class="te13 text-primary attr-value">${main}</p>${
sub ? `<p class="te13 text-light-grey">${sub}</p>` : ''
}</div></div>`;
const html = [
`<div class="attr g new bordered">${item('105 кв.м.', 'Общая площадь')}${item('1', 'Количество санузлов')}</div>`,
`<div class="gt">Бытовая техника</div><div class="attr g new">${item('Холодильник')}${item('Плита', undefined, true)}${item('Стиральная машина')}</div>`,
`<div class="gt">Удобства</div><div class="attr g new">${item('Мебель', 'С мебелью')}${item('Кондиционер', undefined, true)}${item('Интернет')}</div>`,
`<div class="gt">Правила проживания</div><div class="attr g new">${item('Можно с детьми', 'Да')}${item('Можно с животными', 'По договоренности')}</div>`,
`<div class="gt">Условия</div><div class="attr g new">${item('600,000 ֏', 'В месяц')}${item('Предоплата', '1 месяц')}${item('50%', 'Комиссия с арендатора')}</div>`,
].join('');
expect(parseFeatures(html)).toEqual({
children: 'yes',
pets: 'negotiable',
amenities: [
{ group: 'Бытовая техника', items: ['Холодильник', 'Стиральная машина'] },
{ group: 'Удобства', items: ['Интернет'] },
],
attrs: [
{ label: 'Количество санузлов', value: '1' },
{ label: 'Мебель', value: 'С мебелью' },
{ label: 'Можно с детьми', value: 'Да' },
{ label: 'Можно с животными', value: 'По договоренности' },
{ label: 'Предоплата', value: '1 месяц' },
{ label: 'Комиссия с арендатора', value: '50%' },
],
});
expect(parseFeatures('<div class="loc">адрес</div>')).toBeUndefined();
});
});
describe('галерея объявления', () => {
it('ссылки на все фото берутся из скрипта галереи, без повторов', async () => {
const { parseGallery } = await import('./listam-source.ts');
const html = `<img src="//img.list.am/f/571/1.webp"><script>recview(1,56);po114.init('#po114', {img:["//img.list.am/f/571/1.webp","//img.list.am/f/551/2.webp","//s.list.am/f/9/3.jpg","//img.list.am/f/571/1.webp"],w:740});</script>`;
expect(parseGallery(html)).toEqual(['https://img.list.am/f/571/1.webp', 'https://img.list.am/f/551/2.webp', 'https://s.list.am/f/9/3.jpg']);
expect(parseGallery('<div>нет галереи</div>')).toEqual([]);
});
});
+733
View File
@@ -0,0 +1,733 @@
import { regionOfLocality, trailingLocality, type Allowance, type DealType, type ListingFeatures, type PropertyType } from '@lr/shared';
import type {
ListingSource,
ListingSourceFilter,
SourcedListing,
} from './source.ts';
export interface ListAmSourceOptions {
/** База ридера, через который читаются страницы list.am. */
readerBase?: string;
/** Ключ Jina Reader (бесплатный): без него лимит ~20 запросов/мин и чтение идёт медленнее. */
apiKey?: string;
/** Догружать страницу объявления (описание, адрес, обложка фото). По умолчанию true. */
fetchDetails?: boolean;
fetchFn?: typeof fetch;
/** Пауза между запросами; в тестах подменяется. */
sleep?: (ms: number) => Promise<void>;
}
const LISTAM = 'https://www.list.am';
const READER = 'https://r.jina.ai/';
/** За один запуск обходим не больше стольких страниц каждой категории. */
const MAX_PAGES = 10;
/** Анонимный лимит ридера ~20/мин; с ключом — на порядок больше. */
const DELAY_ANON_MS = 3_100;
const DELAY_KEY_MS = 600;
/** Блоки страницы объявления: адрес, метка агентства, характеристики, заголовки разделов, описание, подвал с датой. */
const ITEM_SELECTOR = '.loc, .po79, .attr, .gt, [itemprop=description], .footer';
const ITEM_SELECTOR_HTML = '.loc, .po79, .gt, .attr, [itemprop=description]';
/** Категории list.am по типу объекта: ID взяты со страниц раздела «Недвижимость» (/ru/category/54). */
const CATEGORY_IDS = {
apartment: { sale: 60, rent: 56 },
house: { sale: 1386, rent: 1377 },
commercial: { sale: 199, rent: 59 },
land: { sale: 55, rent: 270 },
garage: { sale: 173, rent: 175 },
} as const satisfies Record<PropertyType, Record<DealType, number>>;
export interface ListAmCategory {
id: number;
propertyType: PropertyType;
dealType: DealType;
}
/** Все категории list.am, которые умеет читать парсер. */
export const LISTAM_CATEGORIES: ListAmCategory[] = Object.entries(CATEGORY_IDS).flatMap(
([propertyType, ids]) =>
(['sale', 'rent'] as const).map((dealType) => ({
id: ids[dealType],
propertyType: propertyType as PropertyType,
dealType,
})),
);
/** Имена наборов категорий для fetchLatest: `apartments`, `apartments-sale`, `houses`, … */
const PLURAL: Record<PropertyType, string> = {
apartment: 'apartments', house: 'houses', commercial: 'commercial', land: 'land', garage: 'garages',
};
const CATEGORIES: Record<string, ListAmCategory[]> = {};
for (const c of LISTAM_CATEGORIES) {
const name = PLURAL[c.propertyType];
(CATEGORIES[name] ??= []).push(c);
CATEGORIES[`${name}-${c.dealType}`] = [c];
}
/** Районы Еревана, как их пишет list.am → название в справочнике CRM. */
const DISTRICT_ALIASES: Record<string, string> = {
арабкир: 'Арабкир',
ачапняк: 'Аджапняк',
аджапняк: 'Аджапняк',
аван: 'Аван',
давташен: 'Давташен',
эребуни: 'Эребуни',
'канакер зейтун': 'Канакер-Зейтун',
кентрон: 'Кентрон',
'малатия себастия': 'Малатия-Себастия',
'нор норк': 'Нор-Норк',
'норк мараш': 'Норк-Мараш',
нубарашен: 'Нубарашен',
шенгавит: 'Шенгавит',
};
const DISTRICT_KEYS = Object.keys(DISTRICT_ALIASES).sort((a, b) => b.length - a.length);
const CURRENCY_SYMBOLS: Record<string, SourcedListing['currency']> = {
$: 'USD',
'€': 'EUR',
'₽': 'RUB',
'֏': 'AMD',
};
/** Служебные плашки внутри текста карточки — в название не входят. */
const BADGE_PHRASES = ['Недвижимость не проверена', 'Проверено по кадастру', 'Срочно!', 'Эксклюзив'];
interface Card {
id: string;
text: string;
thumbUrl?: string;
agency: boolean;
}
const normalize = (v: string) =>
v
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, ' ');
/** Ключи категорий нормализованы так же, как ввод пользователя («apartments-rent» → «apartments rent»). */
const CATEGORY_LOOKUP = new Map(
Object.entries(CATEGORIES).map(([key, ids]) => [normalize(key), ids] as const),
);
/**
* Источник объявлений — list.am, читаемый через публичный ридер (по умолчанию Jina Reader).
* Сам адаптер на list.am не ходит и никакой защиты не обходит: читает markdown-снимок
* страницы, который отдаёт ридер (docs/PARSER.md).
*
* Ограничения источника: телефон на list.am открывается отдельным запросом по нажатию
* и не импортируется. Карточка всегда ведёт на оригинал по sourceUrl.
*/
export class ListAmListingSource implements ListingSource {
readonly name = 'list.am';
private readonly readerBase: string;
private apiKey?: string;
private readonly defaultKey?: string;
private readonly fetchDetails: boolean;
private readonly fetchFn: typeof fetch;
private readonly sleep: (ms: number) => Promise<void>;
constructor(options: ListAmSourceOptions = {}) {
this.readerBase = options.readerBase ?? READER;
this.apiKey = this.defaultKey = options.apiKey;
this.fetchDetails = options.fetchDetails ?? true;
this.fetchFn = options.fetchFn ?? fetch;
this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
}
/**
* Ключ ридера из настроек админки: строка — использовать этот ключ, undefined — ключ из
* окружения сервера, null — работать без ключа.
*/
configure(apiKey: string | null | undefined) {
const next = apiKey === null ? undefined : apiKey || this.defaultKey;
// У нового ключа свой баланс: прошлый отказ к нему не относится.
if (next !== this.apiKey) this.keyOffUntil = 0;
this.apiKey = next;
}
async *fetchLatest(
filter: ListingSourceFilter,
report: (problem: string) => void,
): AsyncIterable<SourcedListing> {
const categories = CATEGORY_LOOKUP.get(normalize(filter.category));
if (!categories) {
throw new Error(
`Неизвестная категория «${filter.category}». Допустимые: ${Object.keys(CATEGORIES).join(', ')}`,
);
}
const yerevanOnly = ['yerevan', 'ереван'].includes(normalize(filter.city));
for (const category of categories) {
for (let page = 1; page <= MAX_PAGES; page++) {
let cards: ListAmCard[];
try {
cards = await this.listPage(category, page);
} catch (error) {
// Ошибка первой страницы — сбой источника; дальнейших страниц просто нет.
if (page === 1) throw error;
report(
`Категория ${category.id}, страница ${page}: ${error instanceof Error ? error.message : String(error)}`,
);
break;
}
if (cards.length === 0) break;
for (const card of cards) {
if (filter.ownerOnly && card.isAgency) continue;
// Городской фильтр: Ереван — только карточки с районом Еревана.
if (yerevanOnly && card.region !== 'Ереван') continue;
yield await this.enrich(card, report);
}
}
}
}
/**
* Карточки одной страницы категории — один запрос к ридеру, без захода в объявления.
* Сначала обычные объявления (от новых к старым), потом «Топ»: старые топ-дубли в начале
* выдачи иначе остановили бы обход раньше времени.
*/
async listPage(category: ListAmCategory, page = 1): Promise<ListAmCard[]> {
// Страницы категорий читаем без кэша ридера, чтобы не терять свежесть.
const markdown = await this.read(`${LISTAM}/ru/category/${category.id}${page > 1 ? `/${page}` : ''}`, { fresh: true });
const { regular, promoted } = parseCards(markdown);
return [...regular.map((card) => ({ card, promoted: false })), ...promoted.map((card) => ({ card, promoted: true }))].map(({ card, promoted }) => {
const parsed = parseCard(card);
const districtKey = parsed.district ? normalize(parsed.district) : undefined;
const inYerevan = Boolean(districtKey && DISTRICT_ALIASES[districtKey]);
if (inYerevan) parsed.district = DISTRICT_ALIASES[districtKey!];
const sourced = toSourced(card, parsed, {}, category, inYerevan);
if (!inYerevan) {
// Вне Еревана карточка заканчивается названием города или села: область — по справочнику.
const place = trailingLocality(card.text.replace(/\s*Агентство\s*$/i, ''));
if (place) Object.assign(sourced, { region: place.region, district: place.locality });
}
return promoted ? { ...sourced, promoted } : sourced;
});
}
/**
* Дата размещения объявления — из подвала страницы («Номер объявления … Размещено ДД.ММ.ГГГГ»).
* Подвал запрашивается отдельно: в общем снимке страницы ридер его чаще всего отбрасывает
* (замер 08.10.2026: 1 из 12 против 13 из 13 при отдельном запросе).
* Возвращает undefined, если даты в ответе нет; бросает ошибку, если страницу прочитать не удалось.
*/
async publishedAt(card: ListAmCard): Promise<Date | undefined> {
const markdown = await this.read(`${LISTAM}/ru/item/${card.externalId}`, { selector: '.footer' });
if (/Just a moment/i.test(markdown)) throw new Error('страница закрыта проверкой Cloudflare');
return parsePostedDate(markdown);
}
/**
* Догружает страницу объявления: описание, адрес и область, точные площадь/этаж/комнаты.
* Ридеру передаётся список нужных блоков страницы (x-target-selector).
* Бросает ошибку, если страницу прочитать не удалось.
*/
async details(card: ListAmCard): Promise<SourcedListing> {
const markdown = await this.read(`${LISTAM}/ru/item/${card.externalId}`, { selector: ITEM_SELECTOR });
if (/Just a moment/i.test(markdown)) throw new Error('страница закрыта проверкой Cloudflare');
return applyDetails(card, parseDetails(markdown));
}
/**
* Проверка незнакомого объявления для конвейера: дата размещения и, если объявление свежее, детали.
* - с ключом ридера — HTML-вырезки страницы: подвал с точным временем размещения и, для
* свежего объявления, блоки с деталями;
* - без ключа — запрос подвала с датой (с точностью до дня) и, только для свежего, запрос деталей.
* `publishedAt` в ответе пуст, если даты на странице не оказалось. Бросает ошибку, если страницу
* прочитать не удалось.
*/
async inspect(card: ListAmCard, isFresh: (publishedAt: Date) => boolean): Promise<SourcedListing> {
if (this.apiKey && Date.now() >= this.keyOffUntil) {
try {
return await this.inspectWithKey(card, isFresh);
} catch (error) {
// Ключ отклонён или кончился баланс: час работаем без ключа (медленнее, дата без времени).
if (!/Ридер ответил 40[123] /.test(error instanceof Error ? error.message : '')) throw error;
this.keyOffUntil = Date.now() + 3_600_000;
}
}
const publishedAt = await this.publishedAt(card);
if (!publishedAt || !isFresh(publishedAt)) return { ...card, publishedAt };
// Детали не прочитались — данных из списка достаточно для лида.
const full = await this.details(card).catch(() => card);
return { ...full, publishedAt };
}
/**
* Есть ли объявление на сайте. У снятого объявления нет подвала с номером (ридер отвечает 422),
* а заголовок страницы — «Данного объявления не существует» (замер 08.10.2026).
* undefined — понять не удалось: спросим в следующий раз.
*/
async isAlive(externalId: string): Promise<boolean | undefined> {
const url = `${LISTAM}/ru/item/${externalId}`;
try {
// Без обхода кеша ридера: с ним выборка подвала у ридера ломается (422), а суточной проверке
// снимок часовой давности не мешает.
const footer = await this.read(url, { selector: '.footer' });
if (/Just a moment/i.test(footer)) return undefined;
if (footer.includes(externalId)) return true;
} catch (error) {
if (!/Ридер ответил 422 /.test(error instanceof Error ? error.message : '')) return undefined;
}
const title = await this.read(url, { selector: 'h1' }).catch(() => '');
return /объявления не существует/i.test(title) ? false : undefined;
}
/**
* Все фото объявления и его характеристики. Полный список есть только в скрипте галереи на странице, поэтому страница
* читается целиком — это дороже обычного чтения, и вызывается только для объектов, которые агент
* показывает клиенту.
*/
async gallery(externalId: string): Promise<{ photos: string[]; features?: ListingFeatures; description?: string; removed?: boolean }> {
const html = await this.read(`${LISTAM}/ru/item/${externalId}`, { html: true, keyed: true });
if (/Just a moment/i.test(html)) throw new Error('страница закрыта проверкой Cloudflare');
// Снятое объявление list.am перенаправляет на главную: на ней нет номера объявления (замер 15.10.2026).
if (!html.includes(externalId)) return { photos: [], removed: true };
// Страница уже прочитана целиком — заодно берём характеристики (у старых объектов их в базе нет)
// и описание: у объявлений, найденных без ключа ридера, его может не быть.
return { photos: parseGallery(html), features: parseFeatures(html), description: parseDetailsHtml(html).description };
}
/**
* Объявление целиком по его номеру — для объекта, который агент добавляет ссылкой. Страница
* читается так же, как для галереи. null — объявления на сайте нет.
*/
async item(externalId: string): Promise<SourcedListing | null> {
const html = await this.read(`${LISTAM}/ru/item/${externalId}`, { html: true, keyed: true });
if (/Just a moment/i.test(html)) throw new Error('страница закрыта проверкой Cloudflare');
return parseItemPage(html, externalId);
}
/** До этого момента ключ ридера не используется: его отклонили. */
private keyOffUntil = 0;
/** До этого момента запросы без ключа не делаются: ридер заблокировал их для нашего IP. */
private anonOffUntil = 0;
private async inspectWithKey(card: ListAmCard, isFresh: (publishedAt: Date) => boolean): Promise<SourcedListing> {
const url = `${LISTAM}/ru/item/${card.externalId}`;
const check = (html: string) => {
if (/Just a moment/i.test(html)) throw new Error('страница закрыта проверкой Cloudflare');
return html;
};
// Сначала только подвал (ответ в несколько строк): у «поднятого» старого объявления
// остальное читать незачем.
const footer = check(await this.read(url, { html: true, selector: '.footer', keyed: true }));
const publishedAt = parseDetailsHtml(footer).publishedAt;
if (!publishedAt || !isFresh(publishedAt)) return { ...card, publishedAt };
const html = await this.read(url, { html: true, selector: ITEM_SELECTOR_HTML, keyed: true }).then(check, () => '');
return { ...applyDetails(card, parseDetailsHtml(html)), publishedAt };
}
/** То же, что details(), но при ошибке возвращает карточку как есть и сообщает о проблеме. */
async enrich(card: ListAmCard, report: (problem: string) => void = () => {}): Promise<SourcedListing> {
if (!this.fetchDetails) return card;
try {
return await this.details(card);
} catch (error) {
report(
`${LISTAM}/item/${card.externalId}: карточка создана без деталей (${error instanceof Error ? error.message : String(error)})`,
);
return card;
}
}
/**
* Момент, раньше которого нельзя начинать следующий запрос. Очередей две: запросы без ключа
* (лимит ридера ~20 в минуту) и запросы с ключом (лимит на порядок выше).
*/
private nextSlot = { anon: 0, keyed: 0 };
/**
* Читает страницу через ридер. Запросы одной очереди идут по одному с паузой между ними, даже
* если их запросили параллельно, — иначе упрёмся в лимит ридера.
*
* Ключ ридера оплачивается по объёму ответа, поэтому с ключом (`keyed`) читаются только
* маленькие вырезки страниц объявлений. Большие страницы категорий читаются без ключа;
* ключ для них используется, только если ридер ответил «слишком много запросов».
*/
private async read(
url: string,
opts: { fresh?: boolean; selector?: string; html?: boolean; keyed?: boolean } = {},
): Promise<string> {
// Анонимные запросы с нашего IP ридер может заблокировать — тогда на время читаем всё с ключом.
const keyed = Boolean((opts.keyed || Date.now() < this.anonOffUntil) && this.apiKey);
const lane = keyed ? 'keyed' : 'anon';
const now = Date.now();
const startAt = Math.max(now, this.nextSlot[lane]);
this.nextSlot[lane] = startAt + (keyed ? DELAY_KEY_MS : DELAY_ANON_MS);
if (startAt > now) await this.sleep(startAt - now);
const res = await this.fetchFn(this.readerBase + url, {
headers: {
accept: 'text/plain',
...(keyed ? { authorization: `Bearer ${this.apiKey}` } : {}),
...(opts.fresh ? { 'x-no-cache': 'true' } : {}),
...(opts.selector ? { 'x-target-selector': opts.selector } : {}),
// Готовый HTML страницы после проверки Cloudflare ридер отдаёт только с ключом.
...(opts.html ? { 'x-return-format': 'html', 'x-engine': 'browser', 'x-no-cache': 'true' } : {}),
},
signal: AbortSignal.timeout(60_000),
});
if (res.status === 401 && !keyed && this.apiKey) {
// «Blocked from anonymous queries due to bad IP reputation» (22.10.2026): без ключа ридер не пускает.
this.anonOffUntil = Date.now() + 3_600_000;
return this.read(url, { ...opts, keyed: true });
}
if (res.status === 429 && !keyed && this.apiKey) return this.read(url, { ...opts, keyed: true });
if (!res.ok) throw new Error(`Ридер ответил ${res.status} на ${url}`);
return res.text();
}
}
/** Карточки страницы категории: «Топ объявления» идёт блоком перед «Обычные объявления». */
function parseCards(markdown: string): { regular: Card[]; promoted: Card[] } {
const cards: { marker: 'top' | 'regular' | undefined; card: Card }[] = [];
let marker: 'top' | 'regular' | undefined;
for (const line of markdown.split('\n')) {
if (line.includes('Топ объявления')) marker = 'top';
else if (line.includes('Обычные объявления')) marker = 'regular';
const card = matchCard(line);
if (card) cards.push({ marker, card });
}
return {
regular: cards.filter((c) => c.marker === 'regular').map((c) => c.card),
promoted: cards.filter((c) => c.marker !== 'regular').map((c) => c.card),
};
}
const CARD_RE =
/\[!\[Image[^\]]*\]\((https:\/\/img\.list\.am\/[^)\s]+)\)\s*(.+?)\]\(https:\/\/www\.list\.am\/(?:ru\/)?item\/(\d+)[^)\s]*\)/;
function matchCard(line: string): Card | null {
const m = CARD_RE.exec(line);
if (!m) return null;
const text = m[2]!.replace(/!\[Image[^\]]*\]\([^)]*\)/g, ' ').replace(/\s+/g, ' ').trim();
return { id: m[3]!, text, thumbUrl: m[1], agency: /Агентство/.test(text) };
}
interface ParsedCard {
price?: number;
currency: SourcedListing['currency'];
title: string;
rooms?: number;
floor?: number;
floorsTotal?: number;
area?: number;
district?: string;
propertyType?: string;
}
function parseCard(card: Card): ParsedCard {
let rest = card.text;
let currency: SourcedListing['currency'] = 'AMD';
let price: number | undefined;
const priceM = /^(?:([$€₽])\s*)?([\d][\d\s,.]*?)\s*(֏|₽)?\s*(?:в\s*месяц)?\s+/.exec(rest);
if (priceM && priceM[2]!.replace(/\D/g, '').length > 0) {
const symbol = priceM[1] ?? priceM[3];
if (symbol) currency = CURRENCY_SYMBOLS[symbol]!;
const n = Number(priceM[2]!.replace(/[\s,]/g, '').replace(',', '.'));
if (Number.isFinite(n) && n > 0) price = n;
rest = rest.slice(priceM[0].length);
}
const rooms = numberMatch(rest, /(\d{1,2})\s*[-–]?\s*комн\.|(\d{1,2})\s*ком\./);
const floorM = /(\d{1,3})\s*\/\s*(\d{1,3})\s*этаж/.exec(rest);
const area = numberMatch(rest, /([\d]+(?:[.,]\d+)?)\s*кв\.?\s*м/);
let title = rest;
for (const phrase of BADGE_PHRASES) title = title.replaceAll(phrase, ' ');
// Район — последнее слово/словосочетание карточки (после плашки «Агентство» или без неё).
let district: string | undefined;
for (const key of DISTRICT_KEYS) {
// Район — отдельное слово в конце: «Бюрегаван» не должен распознаваться как «Аван».
const tail = new RegExp(`(?:^|\\s)${key.replace(/ /g, '[\\s_-]+')}\\s*$`, 'i');
if (tail.test(title)) {
district = key;
title = title.replace(tail, '');
break;
}
}
title = title
.replace(/\s*агентство\s*$/i, '')
.replace(/[ ,.;-]+$/, '')
.replace(/\s+/g, ' ')
.trim();
return {
price,
currency,
title: title || `Объявление ${card.id}`,
rooms,
floor: floorM ? Number(floorM[1]) : undefined,
floorsTotal: floorM ? Number(floorM[2]) : undefined,
area,
district,
propertyType: guessPropertyType(rest),
};
}
function numberMatch(text: string, re: RegExp): number | undefined {
const m = re.exec(text);
if (!m) return undefined;
const raw = m[1] ?? m[2];
const n = Number(raw?.replace(',', '.'));
return Number.isFinite(n) ? n : undefined;
}
function guessPropertyType(text: string): string | undefined {
const t = text.toLowerCase();
if (/квартир/.test(t)) return 'apartment';
if (/(?:^|[^а-яё])дом(?:[^а-яё]|$)|коттедж/.test(t)) return 'house';
if (/офис|помещени|коммерч|склад|торгов/.test(t)) return 'commercial';
if (/участок|земл/.test(t)) return 'land';
if (/гараж|паркинг|машиномест/.test(t)) return 'garage';
return undefined;
}
interface ItemDetails {
description?: string;
address?: string;
rooms?: number;
floor?: number;
floorsTotal?: number;
area?: number;
publishedAt?: Date;
isAgency?: boolean;
features?: ListingFeatures;
}
/**
* Детали со страницы объявления. Характеристики идут парами «значение, подпись»; описание —
* между заголовком «Описание» и подвалом «Номер объявления … Размещено ДД.ММ.ГГГГ».
*/
function parseDetails(markdown: string): ItemDetails {
const details: ItemDetails = {};
const text = markdown.replace(/\r/g, '');
const address = /^\[([^\]\n]+)\]\(https:\/\/www\.list\.am\/[^)\s]*item\/\d+[^)\s]*\)\s*$/m.exec(text);
if (address && !/скрыт/i.test(address[1]!)) details.address = address[1]!.trim().slice(0, 300);
details.rooms = numberMatch(text, /(\d{1,2})\n+Количество комнат/);
details.floor = numberMatch(text, /(-?\d{1,3})\n+Этаж\n/);
details.floorsTotal = numberMatch(text, /(\d{1,3})\n+Этажей в доме/);
details.area = numberMatch(text, /([\d]+(?:[.,]\d+)?)\s*кв\.?\s*м\.?\n+Общая площадь/);
const description = /\nОписание\n+([\s\S]*?)(?:\n+Номер объявления|\n+Links\/Buttons:|$)/.exec(text);
if (description) {
const body = description[1]!
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join('\n');
if (body) details.description = body.slice(0, 10_000);
}
details.publishedAt = parsePostedDate(text);
if (/^Агентство\s*$/m.test(text.split('\nОписание\n')[0]!)) details.isAgency = true;
return details;
}
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
const decode = (v: string) =>
v
.replace(/&#(\d+);/g, (_, n: string) => String.fromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n: string) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&([a-z]+);/g, (m, name: string) => ENTITIES[name] ?? m);
/** Детали из HTML страницы объявления: характеристики идут парами «значение, подпись». */
function parseDetailsHtml(html: string): ItemDetails {
const details: ItemDetails = {};
const attrs = new Map<string, string>();
for (const m of html.matchAll(/>([^<>]{1,60})<\/p><p class="[^"]*text-light-grey[^"]*">([^<>]{1,60})<\/p>/g)) {
attrs.set(decode(m[2]!).trim(), decode(m[1]!).trim());
}
const attr = (label: string) => {
const n = Number(attrs.get(label)?.replace(',', '.').match(/-?[\d.]+/)?.[0]);
return Number.isFinite(n) ? n : undefined;
};
details.rooms = attr('Количество комнат');
details.floor = attr('Этаж');
details.floorsTotal = attr('Этажей в доме');
// У домов «Общая площадь» — площадь дома; площадь участка идёт отдельным полем.
details.area = attr('Общая площадь') ?? attr('Площадь дома');
const address =
/class="post-location-title">(?:<span[^>]*><\/span>)?<p[^>]*>([^<]+)<\/p>/.exec(html) ??
/class="loc"><a[^>]*>([^<]+)<\/a>/.exec(html);
if (address && !/скрыт/i.test(address[1]!)) details.address = decode(address[1]!).trim().slice(0, 300);
const description = /itemprop="description"[^>]*>([\s\S]*?)<\/div>/.exec(html);
if (description) {
const text = decode(description[1]!.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ''))
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join('\n');
if (text) details.description = text.slice(0, 10_000);
}
// Точное время размещения есть только в разметке: <span itemprop="datePosted" content="…">.
const posted = /itemprop="datePosted" content="([^"]+)"/.exec(html);
if (posted) {
const d = new Date(posted[1]!);
if (!Number.isNaN(d.getTime())) details.publishedAt = d;
}
if (/<span class="ge4">Агентство<\/span>/.test(html)) details.isAgency = true;
details.features = parseFeatures(html);
return details;
}
/**
* Объявление со страницы list.am целиком: категория (тип сделки и объекта) — из разметки страницы,
* название — из заголовка, цена — из микроразметки, остальное — как в деталях. Район Еревана на
* странице отдельно не указан: ищем его название в заголовке и адресе («в Ачапняке»).
* null — номера объявления на странице нет: его сняли, и list.am перенаправил на главную.
*/
export function parseItemPage(html: string, externalId: string): SourcedListing | null {
if (!html.includes(externalId)) return null;
const categoryId = Number(/"category_id":(\d+)/.exec(html)?.[1] ?? [...html.matchAll(/href="\/(?:ru\/)?category\/(\d+)"/g)].at(-1)?.[1]);
const category = LISTAM_CATEGORIES.find((c) => c.id === categoryId);
const title = decode(/<h1[^>]*itemprop="name"[^>]*>([^<]+)<\/h1>/.exec(html)?.[1] ?? '').replace(/\s+/g, ' ').trim();
const price = Number(/itemprop="price" content="([\d.]+)"/.exec(html)?.[1]);
const currency = /itemprop="priceCurrency" content="([A-Z]{3})"/.exec(html)?.[1];
const details = parseDetailsHtml(html);
const text = `${title} ${details.address ?? ''}`.toLowerCase();
// «в центре» в заголовках list.am — это Кентрон.
const district =
DISTRICT_KEYS.find((key) => new RegExp(`(?:^|[\\s,])${key.replace(/ /g, '[\\s_-]+')}`, 'i').test(text)) ??
(/(?:^|\s)в центре(?:[\s,.]|$)/.test(text) && /ереван/.test(text) ? 'кентрон' : undefined);
const base: SourcedListing = {
externalId,
sourceUrl: `${LISTAM}/item/${externalId}`,
title: (title || `Объявление ${externalId}`).slice(0, 200),
dealType: category?.dealType,
propertyType: category?.propertyType ?? guessPropertyType(title),
isAgency: false,
price: Number.isFinite(price) && price > 0 ? price : undefined,
currency: currency && Object.values(CURRENCY_SYMBOLS).includes(currency as SourcedListing['currency']) ? (currency as SourcedListing['currency']) : 'AMD',
rooms: numberMatch(title, /(\d{1,2})\s*[-–]?\s*комн\./),
region: district ? 'Ереван' : undefined,
district: district ? DISTRICT_ALIASES[district] : undefined,
photoUrls: parseGallery(html),
};
return applyDetails(base, details);
}
/** Ссылки на фото из скрипта галереи: po114.init('#po114', {img:["//img.list.am/f/…", …]}). */
export function parseGallery(html: string): string[] {
const list = /po\d+\.init\([^)]*?\{\s*img:\s*(\[[^\]]*\])/.exec(html)?.[1] ?? '';
const urls = [...list.matchAll(/["'](\/\/(?:img|s)\.list\.am\/f\/\d+\/\d+\.(?:webp|jpe?g|png))["']/g)].map((m) => `https:${m[1]}`);
return [...new Set(urls)];
}
/** Уже разобраны отдельными полями либо дублируют цену — в список характеристик не идут. */
const SKIP_LABELS = new Set(['Общая площадь', 'Площадь дома', 'Этаж', 'Этажей в доме', 'Количество комнат', 'В месяц', 'В сутки', 'Цена']);
const ALLOWANCE: Record<string, Allowance> = { да: 'yes', нет: 'no', 'по договоренности': 'negotiable', 'по договорённости': 'negotiable' };
/**
* Характеристики объявления. Страница состоит из блоков: первый (без заголовка) — «значение, подпись»;
* дальше идут разделы с заголовком («Бытовая техника», «Правила проживания»), в них либо пары
* «подпись, значение», либо отметки-удобства. Удобство, которого в квартире нет, помечено классом disabled.
*/
export function parseFeatures(html: string): ListingFeatures | undefined {
const features: ListingFeatures = { amenities: [], attrs: [] };
let group = '';
let head = false;
const token =
/<div class="gt">([^<]{1,80})<\/div>|<div class="attr g new( bordered)?"|<div class="at3( disabled)?">[\s\S]*?<p class="[^"]*(?:attr-value|text-primary)[^"]*">([^<]{1,80})<\/p>(?:<p class="[^"]*text-light-grey[^"]*">([^<]{1,80})<\/p>)?/g;
for (const m of html.matchAll(token)) {
if (m[1] !== undefined) {
group = decode(m[1]).trim();
continue;
}
if (m[4] === undefined) {
head = Boolean(m[2]);
if (head) group = '';
continue;
}
const main = decode(m[4]).trim();
const sub = m[5] === undefined ? undefined : decode(m[5]).trim();
if (sub === undefined) {
// Отметка-удобство: учитываем только те, что в квартире есть.
if (m[3] || !main) continue;
const bucket = features.amenities.find((a) => a.group === group) ?? features.amenities[features.amenities.push({ group, items: [] }) - 1]!;
bucket.items.push(main);
continue;
}
// В первом блоке сверху значение, снизу подпись; в разделах — наоборот, кроме строк вида «50% — Комиссия».
const valueFirst = head || (/\d/.test(main) && !/\d/.test(sub));
const [label, value] = valueFirst ? [sub, main] : [main, sub];
if (label === 'Можно с детьми') features.children = ALLOWANCE[value.toLowerCase()];
else if (label === 'Можно с животными') features.pets = ALLOWANCE[value.toLowerCase()];
if (!SKIP_LABELS.has(label)) features.attrs.push({ label, value });
}
return features.amenities.length || features.attrs.length ? features : undefined;
}
/** «Размещено ДД.ММ.ГГГГ» → дата. Времени на странице нет: берём начало этого дня по Еревану (UTC+4). */
function parsePostedDate(text: string): Date | undefined {
const m = /Размещено\s*(\d{2})\.(\d{2})\.(\d{4})/.exec(text);
if (!m) return undefined;
return new Date(Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1])) - 4 * 3_600_000);
}
/** Карточка со страницы категории: то же объявление, но без деталей со страницы объявления. */
export type ListAmCard = SourcedListing;
function toSourced(
card: Card,
parsed: ParsedCard,
details: ItemDetails,
category: ListAmCategory,
inYerevan: boolean,
): SourcedListing {
const base: SourcedListing = {
externalId: card.id,
sourceUrl: `${LISTAM}/item/${card.id}`,
title: parsed.title.slice(0, 200),
dealType: category.dealType,
// Тип объекта задаёт категория; угадывание по тексту — запасной вариант.
propertyType: category.propertyType ?? parsed.propertyType,
isAgency: card.agency,
price: parsed.price,
currency: parsed.currency,
region: inYerevan ? 'Ереван' : undefined,
district: parsed.district,
rooms: parsed.rooms,
floor: parsed.floor,
floorsTotal: parsed.floorsTotal,
area: parsed.area,
photoUrls: card.thumbUrl ? [card.thumbUrl.replace('/g/', '/f/')] : [],
};
return applyDetails(base, details);
}
function applyDetails(card: SourcedListing, details: ItemDetails): SourcedListing {
const out: SourcedListing = {
...card,
description: details.description ?? card.description,
address: details.address ?? card.address,
rooms: details.rooms ?? card.rooms,
floor: details.floor ?? card.floor,
floorsTotal: details.floorsTotal ?? card.floorsTotal,
area: details.area ?? card.area,
publishedAt: details.publishedAt ?? card.publishedAt,
isAgency: card.isAgency || Boolean(details.isAgency),
features: details.features ?? card.features,
};
// Вне Еревана район в карточке не распознаётся: населённый пункт берём из адреса
// («улица Минска 30, Ереван» → последняя часть), область — по справочнику.
if (!out.region && details.address) {
const locality = details.address.split(',').at(-1)?.trim();
const region = regionOfLocality(locality);
if (region) out.region = region;
if (locality && region !== 'Ереван') out.district = locality;
}
return out;
}
+54
View File
@@ -0,0 +1,54 @@
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import type { Db } from '../db/client.ts';
import { settings } from '../db/schema.ts';
import { LISTAM_CATEGORIES } from './listam-source.ts';
/** Настройки парсера, которые суперадмин меняет в админке без перезапуска сервиса. */
export const ParserSettings = z.object({
/** Выключенный парсер не ходит на list.am сам; кнопка «Запустить сейчас» работает всегда. */
enabled: z.boolean().default(true),
/**
* Блок «Топ» и первый проход по категории: в базу идут объявления, размещённые не раньше чем столько дней назад.
* Новые для обычного списка (опубликованные или поднятые) идут в базу всегда.
*/
freshDays: z.number().int().min(1).max(30).default(3),
/** Пауза между проходами, секунд. */
pauseSec: z.number().int().min(0).max(3600).default(5),
/** Сколько страниц категории читать за проход, пока на них есть незнакомые объявления. */
maxPages: z.number().int().min(1).max(10).default(3),
/**
* Разовый глубокий проход — догнать то, что вышло, пока парсер стоял: страницы читаются до maxPages
* без остановки на уже известных, новые берутся только по дате (freshDays). После прохода выключается сам.
*/
deepPass: z.boolean().default(false),
/** ID категорий list.am, которые читает парсер. */
categories: z.array(z.number().int()).default(LISTAM_CATEGORIES.map((c) => c.id)),
/** Ключ ридера Jina; пусто — берётся из окружения сервера (JINA_API_KEY). */
jinaApiKey: z.string().trim().max(200).default(''),
/** Использовать ключ: с ним быстрее и известно точное время размещения, но он платный. */
useKey: z.boolean().default(true),
/** Проверять, не сняты ли с сайта объявления, по которым есть лиды. */
removalCheck: z.boolean().default(true),
/** Сколько объявлений проверять за один заход (раз в 10 минут). */
removalBatch: z.number().int().min(1).max(100).default(20),
/** Как часто перепроверять одно объявление, часов. */
recheckHours: z.number().int().min(1).max(720).default(24),
});
export type ParserSettings = z.infer<typeof ParserSettings>;
const KEY = 'parser';
export async function loadParserSettings(db: Db): Promise<ParserSettings> {
const [row] = await db.select().from(settings).where(eq(settings.key, KEY));
return ParserSettings.parse(row?.value ?? {});
}
export async function saveParserSettings(db: Db, patch: Partial<ParserSettings>): Promise<ParserSettings> {
const value = ParserSettings.parse({ ...(await loadParserSettings(db)), ...patch });
await db
.insert(settings)
.values({ key: KEY, value })
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date() } });
return value;
}
+60
View File
@@ -0,0 +1,60 @@
import type { Currency, DealType, ListingFeatures } from '@lr/shared';
// Контракт источника объявлений. Модуль перенесён из прежней CRM (crmyerevan) и расширен
// полями из ТЗ: тип сделки, метка «агентство», имя и телефон автора, регион, дата публикации.
//
// - ListAmListingSource — снимки страниц list.am через публичный ридер (Jina Reader);
// сам сервис на list.am не ходит. Телефон автора этим путём недоступен.
// - FeedListingSource — JSON-фид по LISTING_FEED_URL (официальный API или провайдер данных).
/** Объявление из внешнего источника. */
export interface SourcedListing {
/** ID объявления у источника (item_id на list.am). */
externalId: string;
sourceUrl: string;
title: string;
description?: string;
dealType?: DealType;
/** Тип объекта, как его называет источник (apartment, house, …). */
propertyType?: string;
region?: string;
district?: string;
address?: string;
price?: number;
currency: Currency;
rooms?: number;
floor?: number;
floorsTotal?: number;
area?: number;
ownerName?: string;
phone?: string;
/** Метка list.am «Агентство». */
isAgency: boolean;
publishedAt?: Date;
/** Ссылки на оригинальные фото. */
photoUrls: string[];
/** Удобства и правила проживания (дети, животные) со страницы объявления. */
features?: ListingFeatures;
/** Карточка из блока «Топ объявления» на странице категории: его порядок ротируется. */
promoted?: boolean;
}
export interface ListingSourceFilter {
/** `yerevan` — только объявления с районом Еревана; иначе все. */
city: string;
category: string;
/** true — источник сам отбрасывает объявления с меткой «Агентство». */
ownerOnly: boolean;
}
export interface ListingSource {
readonly name: string;
/**
* Свежие объявления, начиная с самых новых; итерацию можно прервать в любой момент.
* Некорректные записи источник пропускает и сообщает о них через `report`.
*/
fetchLatest(
filter: ListingSourceFilter,
report: (problem: string) => void,
): AsyncIterable<SourcedListing>;
}
+411
View File
@@ -0,0 +1,411 @@
import { AgentPay, agentPayout, monthsBetween, payPercent, type AccessState, type Currency, type DealKind } from '@lr/shared';
import { and, asc, desc, eq, gte, inArray, isNull, lte, or } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { loadBillingSettings } from './billing/service.ts';
import type { Db } from './db/client.ts';
import { clients, companies, deals, leads, payAdjustments, payouts, users } from './db/schema.ts';
import { badRequest, conflict, notFound } from './lib/errors.ts';
// Выплаты агентам: по условиям, которые задал руководитель, считается, кому сколько платить за период.
type MemberCtx = { user: typeof users.$inferSelect; role: string; company: typeof companies.$inferSelect; access: AccessState };
interface Guards {
member: (req: FastifyRequest) => MemberCtx;
manager: (req: FastifyRequest) => MemberCtx;
writable: <T extends { access: AccessState }>(ctx: T) => T;
}
const Period = z.object({ from: z.iso.date(), to: z.iso.date() });
/** Текущий календарный месяц по Еревану: оклад считается за месяц целиком. */
export function currentMonth(now = new Date()) {
const today = new Date(now.getTime() + 4 * 3_600_000).toISOString().slice(0, 10);
const [y, m] = today.split('-').map(Number) as [number, number];
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
return { from: `${today.slice(0, 7)}-01`, to: `${today.slice(0, 7)}-${String(last).padStart(2, '0')}`, today };
}
type Deal = typeof deals.$inferSelect;
/** Часть комиссии сделки, которая идёт агенту: с собственника — агенту объекта, с клиента — агенту клиента. */
const partOf = (d: Deal, userId: number) => (d.ownerAgentId === userId ? d.ownerCommission : 0) + (d.clientAgentId === userId ? d.clientCommission : 0);
const takesPart = (userId: number) => or(eq(deals.ownerAgentId, userId), eq(deals.clientAgentId, userId));
const round2 = (n: number) => Math.round(n * 100) / 100;
type Rates = Record<string, number>;
type Payout = typeof payouts.$inferSelect;
/** Курсы те же, что в счетах: сколько единиц валюты в одном долларе. */
async function loadRates(db: Db): Promise<Rates> {
const { rates } = await loadBillingSettings(db);
return { USD: 1, ...rates };
}
const converterOf = (perUsd: Rates) => (amount: number, fromCur: string, toCur: string) => (amount / (perUsd[fromCur] ?? 1)) * (perUsd[toCur] ?? 1);
const converter = async (db: Db) => converterOf(await loadRates(db));
const Month = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/);
const monthRange = (ym: string) => {
const [y, m] = ym.split('-').map(Number) as [number, number];
return { from: `${ym}-01`, to: `${ym}-${String(new Date(Date.UTC(y, m, 0)).getUTCDate()).padStart(2, '0')}` };
};
const nextMonth = (ym: string) => {
const [y, m] = ym.split('-').map(Number) as [number, number];
return m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`;
};
/** «ГГГГ-ММ», если период — ровно один календарный месяц: только для него ведутся начисления и выплаты. */
const monthOf = (from: string, to: string) => {
const ym = from.slice(0, 7);
const r = monthRange(ym);
return r.from === from && r.to === to ? ym : null;
};
/** Начислено агенту по условиям `pay` за сделки `mine`: оклад, процент с его части комиссии, бонус за план. */
function accrue(userId: number, pay: AgentPay | null, rates: Rates, mine: Deal[], months: number) {
const convert = converterOf(rates);
const currency: Currency = pay?.currency ?? 'USD';
// Часть комиссии агента по его сделкам — в валюте его условий (отдельно продажа и аренда) и по валютам как есть.
const byKind: Record<DealKind, number> = { sale: 0, rent: 0 };
const byCurrency: Record<string, number> = {};
for (const d of mine) {
const part = partOf(d, userId);
if (!part) continue;
byKind[d.kind === 'rent' ? 'rent' : 'sale'] += convert(part, d.currency, currency);
byCurrency[d.currency] = round2((byCurrency[d.currency] ?? 0) + part);
}
const commissionSale = round2(byKind.sale);
const commissionRent = round2(byKind.rent);
return {
pay, deals: mine.length,
commission: round2(commissionSale + commissionRent), commissionSale, commissionRent, byCurrency, currency,
...agentPayout(pay, { sale: commissionSale, rent: commissionRent }, months, mine.length),
};
}
/**
* Кому сколько платить за период. Для календарного месяца к начисленному добавляются ручные записи
* (премии, удержания, авансы) и перерасчёты закрытых месяцев. Месяц закрывает отметка «выплачено»:
* она фиксирует условия и курсы; если сделки закрытого месяца потом меняются, разница (как и
* недоплата) переходит в ближайший следующий месяц без выплаты.
*/
export async function payroll(db: Db, companyId: number, from: string, to: string, onlyUserId?: number) {
const perUsd = await loadRates(db);
const month = monthOf(from, to);
const members = await db
.select()
.from(users)
.where(and(eq(users.companyId, companyId), isNull(users.removedAt), onlyUserId ? eq(users.id, onlyUserId) : undefined))
.orderBy(users.id);
const [paid, manual] = month
? await Promise.all([
db.select().from(payouts).where(and(eq(payouts.companyId, companyId), onlyUserId ? eq(payouts.userId, onlyUserId) : undefined)).orderBy(payouts.month),
db.select().from(payAdjustments).where(and(eq(payAdjustments.companyId, companyId), onlyUserId ? eq(payAdjustments.userId, onlyUserId) : undefined)).orderBy(payAdjustments.id),
])
: [[], []];
// Закрытые месяцы пересчитываются заново — нужны и их сделки.
const since = paid.reduce((min, p) => (`${p.month}-01` < min ? `${p.month}-01` : min), from);
const rows = await db
.select()
.from(deals)
.where(and(eq(deals.companyId, companyId), gte(deals.dealDate, since), lte(deals.dealDate, to), onlyUserId ? takesPart(onlyUserId) : undefined));
const months = monthsBetween(from, to);
const dealsOf = (userId: number, test: (day: string) => boolean) => rows.filter((d) => (d.ownerAgentId === userId || d.clientAgentId === userId) && test(d.dealDate));
return {
from,
to,
months,
month,
agents: members.map((u) => {
const base = { userId: u.id, name: u.name, role: u.role };
if (!month) {
const row = accrue(u.id, u.pay, perUsd, dealsOf(u.id, (day) => day >= from && day <= to), months);
return { ...base, ...row, accrued: row.total, adjustments: [], adjustmentsTotal: 0, recalc: [], recalcTotal: 0, payout: null, debt: null };
}
const mine = paid.filter((p) => p.userId === u.id);
const byMonth = new Map(mine.map((p) => [p.month, p] as const));
const manualOf = (ym: string) => manual.filter((a) => a.userId === u.id && a.month === ym);
const inMonth = (ym: string) => dealsOf(u.id, (day) => day.slice(0, 7) === ym);
/** Сколько по закрытому месяцу осталось доплатить (или удержать) с учётом изменений после выплаты — в валюте той выплаты. */
const remaining = (p: Payout) => {
const conv = converterOf(p.snapshot.rates);
const accrued = accrue(u.id, p.snapshot.pay, p.snapshot.rates, inMonth(p.month), 1).total;
const extra = manualOf(p.month).reduce((sum, a) => sum + conv(a.amount, a.currency, p.currency), 0);
const carriedIn = p.snapshot.recalc.reduce((sum, r) => sum + r.amount, 0);
const settled = mine
.filter((q) => q.month > p.month)
.reduce((sum, q) => sum + q.snapshot.recalc.filter((r) => r.month === p.month).reduce((n, r) => n + converterOf(perUsd)(r.amount, q.currency, p.currency), 0), 0);
return round2(accrued + extra + carriedIn - p.amount - settled);
};
/** Месяц, в который переходит остаток закрытого месяца: ближайший следующий без выплаты. */
const carryTo = (ym: string) => {
let next = nextMonth(ym);
while (byMonth.has(next)) next = nextMonth(next);
return next;
};
const own = byMonth.get(month);
const rates = own ? own.snapshot.rates : perUsd;
const row = accrue(u.id, own ? own.snapshot.pay : u.pay, rates, inMonth(month), 1);
const convert = converterOf(rates);
const adjustments = manualOf(month).map((a) => ({ id: a.id, amount: a.amount, currency: a.currency, note: a.note }));
const adjustmentsTotal = round2(adjustments.reduce((sum, a) => sum + convert(a.amount, a.currency, row.currency), 0));
const recalc = own
? own.snapshot.recalc
: mine
.filter((p) => p.month < month && carryTo(p.month) === month)
.map((p) => ({ month: p.month, amount: round2(convert(remaining(p), p.currency, row.currency)) }))
.filter((r) => Math.abs(r.amount) >= 0.01);
const recalcTotal = round2(recalc.reduce((sum, r) => sum + r.amount, 0));
const total = round2(row.total + adjustmentsTotal + recalcTotal);
return {
...base, ...row,
/** Оклад, процент и бонус за план — без ручных записей и перерасчётов. */
accrued: row.total,
adjustments, adjustmentsTotal, recalc, recalcTotal, total,
payout: own
? { id: own.id, amount: own.amount, currency: own.currency, paidAt: own.paidAt, note: own.note, last: mine[mine.length - 1]!.id === own.id }
: null,
/** Остаток по закрытому месяцу: уйдёт перерасчётом в следующий открытый. */
debt: own ? remaining(own) : null,
};
}),
};
}
/** Сделки агента за период: его часть комиссии и сколько ему с неё начислено. */
export async function agentDeals(db: Db, companyId: number, userId: number, from: string, to: string) {
const convert = await converter(db);
const [agent] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.companyId, companyId)));
if (!agent) throw notFound();
const currency: Currency = agent.pay?.currency ?? 'USD';
const rows = await db
.select({ deal: deals, leadTitle: leads.title, clientName: clients.name })
.from(deals)
.leftJoin(leads, eq(leads.id, deals.leadId))
.leftJoin(clients, eq(clients.id, deals.clientId))
.where(and(eq(deals.companyId, companyId), gte(deals.dealDate, from), lte(deals.dealDate, to), takesPart(userId)))
.orderBy(asc(deals.dealDate), asc(deals.id));
const partnerIds = [...new Set(rows.flatMap((r) => [r.deal.ownerAgentId, r.deal.clientAgentId]).filter((id) => id !== userId))];
const partners = new Map(
(partnerIds.length ? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, partnerIds)) : []).map((u) => [u.id, u.name] as const),
);
return {
userId,
currency,
deals: rows.map(({ deal: d, leadTitle, clientName }) => {
const kind: DealKind = d.kind === 'rent' ? 'rent' : 'sale';
const part = partOf(d, userId);
const percent = payPercent(agent.pay, kind);
const owner = d.ownerAgentId === userId;
const client = d.clientAgentId === userId;
return {
id: d.id, date: d.dealDate, kind,
leadId: d.leadId, leadTitle, clientId: d.clientId, clientName,
/** С какой стороны агент в сделке: объект, клиент или обе. */
side: owner && client ? ('both' as const) : owner ? ('owner' as const) : ('client' as const),
/** Второй агент сделки, если стороны вели разные люди. */
partner: owner && client ? null : (partners.get(owner ? d.clientAgentId : d.ownerAgentId) ?? null),
dealCommission: d.commission, part, dealCurrency: d.currency,
percent,
/** Начислено агенту с этой сделки — в валюте его условий. */
share: round2((convert(part, d.currency, currency) * percent) / 100),
};
}),
};
}
/**
* Динамика по месяцам: сделки и часть комиссии каждого агента за последние `count` месяцев, включая
* текущий. Комиссия — в долларах по текущему курсу, чтобы месяцы и агенты сравнивались между собой.
*/
export async function payTrend(db: Db, companyId: number, count: number, onlyUserId?: number) {
const convert = await converter(db);
const months: string[] = [];
const [y, m] = currentMonth().from.split('-').map(Number) as [number, number];
for (let i = count - 1; i >= 0; i--) months.push(new Date(Date.UTC(y, m - 1 - i, 1)).toISOString().slice(0, 7));
const members = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(and(eq(users.companyId, companyId), isNull(users.removedAt), onlyUserId ? eq(users.id, onlyUserId) : undefined))
.orderBy(users.id);
const rows = await db
.select()
.from(deals)
.where(and(eq(deals.companyId, companyId), gte(deals.dealDate, `${months[0]}-01`), onlyUserId ? takesPart(onlyUserId) : undefined));
const point = (list: Deal[], part: (d: Deal) => number) => ({
deals: list.length,
commission: round2(list.reduce((sum, d) => sum + convert(part(d), d.currency, 'USD'), 0)),
});
const of = (ym: string) => rows.filter((d) => d.dealDate.slice(0, 7) === ym);
return {
months,
agents: members.map((u) => ({
userId: u.id,
name: u.name,
points: months.map((ym) => point(of(ym).filter((d) => d.ownerAgentId === u.id || d.clientAgentId === u.id), (d) => partOf(d, u.id))),
})),
/** По компании: сделка двух агентов считается один раз. Агенту — только его собственные цифры. */
totals: months.map((ym) => point(of(ym), (d) => (onlyUserId ? partOf(d, onlyUserId) : d.commission))),
};
}
/**
* Рейтинг за месяц — виден всем участникам: место, число сделок, выполнение плана в процентах.
* Сумм комиссии и выплат в нём нет; при равном числе сделок выше тот, у кого комиссия больше.
*/
export async function rating(db: Db, companyId: number, month: string) {
const convert = await converter(db);
const { from, to } = monthRange(month);
const { agents } = await payroll(db, companyId, from, to);
return agents
.map((a) => {
const parts = [
a.pay?.planDeals != null ? a.deals / a.pay.planDeals : null,
a.pay?.planCommission != null ? a.commission / a.pay.planCommission : null,
].filter((x): x is number => x !== null);
return {
userId: a.userId, name: a.name, deals: a.deals,
/** Выполнение плана: по показателю, который отстаёт сильнее; null — плана нет. */
planPercent: parts.length ? Math.round(Math.min(...parts) * 100) : null,
usd: convert(a.commission, a.currency, 'USD'),
};
})
.sort((a, b) => b.deals - a.deals || b.usd - a.usd || a.userId - b.userId)
.map(({ usd: _usd, ...row }, i) => ({ place: i + 1, ...row }));
}
export function registerPayrollRoutes(app: FastifyInstance, db: Db, g: Guards) {
/** Условия оплаты агента задаёт руководитель; null — условий нет. */
app.post('/api/members/:id/pay', async (req) => {
const ctx = g.writable(g.manager(req));
const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params);
const { pay } = z.object({ pay: AgentPay.nullable() }).parse(req.body);
const [updated] = await db
.update(users)
.set({ pay })
.where(and(eq(users.id, id), eq(users.companyId, ctx.company.id), isNull(users.removedAt)))
.returning({ id: users.id });
if (!updated) throw notFound();
return { ok: true };
});
/** Кому сколько платить за период — руководителю. По умолчанию текущий месяц. */
app.get('/api/payroll', async (req) => {
const ctx = g.manager(req);
const q = Period.partial().parse(req.query);
const period = q.from && q.to ? { from: q.from, to: q.to } : currentMonth();
return payroll(db, ctx.company.id, period.from, period.to);
});
/** Сделки агента за период с его частью комиссии: руководителю — по любому агенту, агенту — только свои. */
app.get('/api/payroll/deals', async (req) => {
const ctx = g.member(req);
const q = Period.partial().extend({ userId: z.coerce.number().int().optional() }).parse(req.query);
const period = q.from && q.to ? { from: q.from, to: q.to } : currentMonth();
const userId = ctx.role === 'manager' ? (q.userId ?? ctx.user.id) : ctx.user.id;
return agentDeals(db, ctx.company.id, userId, period.from, period.to);
});
/** Участник компании, которому начисляют; месяц не из будущего. */
const target = async (companyId: number, userId: number, month: string) => {
if (month > currentMonth().from.slice(0, 7)) throw badRequest('VALIDATION');
const [user] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.companyId, companyId), isNull(users.removedAt)));
if (!user) throw notFound();
return user;
};
const isPaid = async (userId: number, month: string) =>
(await db.select({ id: payouts.id }).from(payouts).where(and(eq(payouts.userId, userId), eq(payouts.month, month)))).length > 0;
/** Ручная запись за месяц: премия, штраф, аванс. Сумма со знаком, в валюте условий агента. */
app.post('/api/payroll/adjustments', async (req) => {
const ctx = g.writable(g.manager(req));
const body = z
.object({ userId: z.number().int(), month: Month, amount: z.number().min(-100_000_000).max(100_000_000).refine((n) => n !== 0), note: z.string().trim().min(1).max(200) })
.parse(req.body);
const user = await target(ctx.company.id, body.userId, body.month);
if (await isPaid(user.id, body.month)) throw conflict('MONTH_PAID');
const [row] = await db
.insert(payAdjustments)
.values({ companyId: ctx.company.id, userId: user.id, month: body.month, amount: body.amount, currency: user.pay?.currency ?? 'USD', note: body.note, createdBy: ctx.user.id })
.returning({ id: payAdjustments.id });
return { id: row!.id };
});
app.delete('/api/payroll/adjustments/:id', async (req) => {
const ctx = g.writable(g.manager(req));
const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params);
const [row] = await db.select().from(payAdjustments).where(and(eq(payAdjustments.id, id), eq(payAdjustments.companyId, ctx.company.id)));
if (!row) throw notFound();
if (await isPaid(row.userId, row.month)) throw conflict('MONTH_PAID');
await db.delete(payAdjustments).where(eq(payAdjustments.id, id));
return { ok: true };
});
/** «Выплачено» за месяц. Сумма по умолчанию — всё начисленное; недоплата перейдёт в следующий открытый месяц. */
app.post('/api/payroll/payouts', async (req) => {
const ctx = g.writable(g.manager(req));
const body = z
.object({ userId: z.number().int(), month: Month, amount: z.number().min(0).max(100_000_000).optional(), paidAt: z.iso.date().optional(), note: z.string().trim().max(200).optional() })
.parse(req.body);
const user = await target(ctx.company.id, body.userId, body.month);
if (await isPaid(user.id, body.month)) throw conflict('MONTH_PAID');
const { from, to } = monthRange(body.month);
const row = (await payroll(db, ctx.company.id, from, to, user.id)).agents[0]!;
const [created] = await db
.insert(payouts)
.values({
companyId: ctx.company.id, userId: user.id, month: body.month,
amount: body.amount ?? Math.max(0, row.total), currency: row.currency,
paidAt: body.paidAt ?? currentMonth().today, note: body.note || null,
snapshot: { pay: user.pay, rates: await loadRates(db), total: row.total, recalc: row.recalc },
createdBy: ctx.user.id,
})
.returning({ id: payouts.id });
return { id: created!.id };
});
/** Отмена отметки — только последней у агента: более поздние выплаты могли учесть её остаток. */
app.delete('/api/payroll/payouts/:id', async (req) => {
const ctx = g.writable(g.manager(req));
const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params);
const [row] = await db.select().from(payouts).where(and(eq(payouts.id, id), eq(payouts.companyId, ctx.company.id)));
if (!row) throw notFound();
const [last] = await db.select({ id: payouts.id }).from(payouts).where(eq(payouts.userId, row.userId)).orderBy(desc(payouts.month)).limit(1);
if (last!.id !== id) throw conflict('PAYOUT_NOT_LAST');
await db.delete(payouts).where(eq(payouts.id, id));
return { ok: true };
});
/** История выплат агента: руководителю — любого, агенту — своя. */
app.get('/api/payroll/payouts', async (req) => {
const ctx = g.member(req);
const q = z.object({ userId: z.coerce.number().int().optional() }).parse(req.query);
const userId = ctx.role === 'manager' ? (q.userId ?? ctx.user.id) : ctx.user.id;
const rows = await db.select().from(payouts).where(and(eq(payouts.companyId, ctx.company.id), eq(payouts.userId, userId))).orderBy(desc(payouts.month));
return rows.map((p) => ({ id: p.id, month: p.month, amount: p.amount, currency: p.currency, paidAt: p.paidAt, note: p.note, accrued: p.snapshot.total }));
});
/** Динамика по месяцам: руководителю — по всем, агенту — своя. */
app.get('/api/payroll/trend', async (req) => {
const ctx = g.member(req);
const { months } = z.object({ months: z.coerce.number().int().min(2).max(24).default(6) }).parse(req.query);
return payTrend(db, ctx.company.id, months, ctx.role === 'manager' ? undefined : ctx.user.id);
});
/** Рейтинг агентов за месяц — всем участникам, без сумм. */
app.get('/api/rating', async (req) => {
const ctx = g.member(req);
const q = z.object({ month: Month.optional() }).parse(req.query);
const month = q.month ?? currentMonth().from.slice(0, 7);
return { month, agents: await rating(db, ctx.company.id, month) };
});
/** Свои условия и начисления за месяц (по умолчанию текущий) — агенту. */
app.get('/api/my-pay', async (req) => {
const ctx = g.member(req);
const q = z.object({ month: Month.optional() }).parse(req.query);
const { from, to } = q.month ? monthRange(q.month) : currentMonth();
const result = await payroll(db, ctx.company.id, from, to, ctx.user.id);
return { from, to, ...(result.agents[0] ?? null) };
});
}
+224
View File
@@ -0,0 +1,224 @@
import { PIPELINES, SYSTEM_CODES, type Pipeline } from '@lr/shared';
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Db, Tx } from '../db/client.ts';
import { clients, events, leads, pipelineStatuses, rejectReasons } from '../db/schema.ts';
import type { Guards } from '../leads/routes.ts';
import { badRequest, conflict, notFound } from '../lib/errors.ts';
// Настройка воронок руководителем: промежуточные статусы и причины отказа можно переименовывать,
// добавлять, удалять и переставлять. Системные статусы удалить и передвинуть нельзя — на них
// завязаны пул, аналитика и биллинг.
const PipelineParam = z.object({ pipeline: z.enum(PIPELINES) });
const Id = z.object({ id: z.coerce.number().int().positive() });
const Name = z.string().trim().min(1).max(60);
const Order = z.object({ ids: z.array(z.number().int().positive()).max(100) });
/** Системные статусы стоят на своих местах: «Новый», «Взят в работу» — в начале, «Сделка», «Отказ» — в конце. */
const HEAD: Record<Pipeline, readonly string[]> = { object: ['new', 'taken'], client: ['new'] };
const TAIL = ['deal', 'rejected'];
async function activeStatuses(tx: Tx, companyId: number, pipeline: Pipeline) {
return tx
.select()
.from(pipelineStatuses)
.where(and(eq(pipelineStatuses.companyId, companyId), eq(pipelineStatuses.pipeline, pipeline), isNull(pipelineStatuses.deletedAt)))
.orderBy(asc(pipelineStatuses.position));
}
/** Расставляет позиции: системные в начале, промежуточные в заданном порядке, «Сделка» и «Отказ» в конце. */
async function reposition(tx: Tx, companyId: number, pipeline: Pipeline, middleIds?: number[]) {
const all = await activeStatuses(tx, companyId, pipeline);
const byCode = (code: string) => all.find((s) => s.code === code)!;
const middle = all.filter((s) => !s.code);
const ordered = middleIds ? middleIds.map((id) => middle.find((s) => s.id === id)) : middle;
if (ordered.length !== middle.length || ordered.some((s) => !s) || new Set(middleIds ?? []).size !== (middleIds?.length ?? 0)) {
throw badRequest('VALIDATION');
}
const next = [...HEAD[pipeline].map(byCode), ...(ordered as typeof middle), ...TAIL.map(byCode)];
for (const [position, s] of next.entries()) {
if (s.position !== position) await tx.update(pipelineStatuses).set({ position }).where(eq(pipelineStatuses.id, s.id));
}
}
export function registerPipelineRoutes(app: FastifyInstance, db: Db, g: Guards) {
app.get('/api/pipelines/:pipeline', async (req) => {
const ctx = g.member(req);
const { pipeline } = PipelineParam.parse(req.params);
const table = pipeline === 'object' ? leads : clients;
const [statuses, reasons, counts] = await Promise.all([
activeStatuses(db, ctx.company.id, pipeline),
db
.select()
.from(rejectReasons)
.where(and(eq(rejectReasons.companyId, ctx.company.id), eq(rejectReasons.pipeline, pipeline), isNull(rejectReasons.deletedAt)))
.orderBy(asc(rejectReasons.position)),
// Сколько карточек в каждом статусе — руководителю, чтобы решить, куда перевести их при удалении статуса.
ctx.role === 'manager'
? db
.select({ statusId: table.statusId, n: sql<number>`count(*)::int` })
.from(table)
.where(eq(table.companyId, ctx.company.id))
.groupBy(table.statusId)
: [],
]);
const used = new Map(counts.map((c) => [c.statusId, c.n]));
return {
statuses: statuses.map((s) => ({ id: s.id, code: s.code, name: s.name, poolReturn: s.poolReturn, count: used.get(s.id) ?? 0 })),
reasons: reasons.map((r) => ({ id: r.id, code: r.code, name: r.name })),
};
});
const ownStatus = async (tx: Tx, companyId: number, id: number) => {
const [s] = await tx
.select()
.from(pipelineStatuses)
.where(and(eq(pipelineStatuses.id, id), eq(pipelineStatuses.companyId, companyId), isNull(pipelineStatuses.deletedAt)));
if (!s) throw notFound();
return s;
};
/** Новый статус встаёт последним среди промежуточных — перед «Сделкой». */
app.post('/api/pipelines/:pipeline/statuses', async (req) => {
const ctx = g.writable(g.manager(req));
const { pipeline } = PipelineParam.parse(req.params);
const { name } = z.object({ name: Name }).parse(req.body);
return db.transaction(async (tx) => {
const [row] = await tx
.insert(pipelineStatuses)
// Позиция 9000 — временная: reposition поставит статус после остальных промежуточных.
.values({ companyId: ctx.company.id, pipeline, name, position: 9000 })
.returning();
const middle = (await activeStatuses(tx, ctx.company.id, pipeline)).filter((s) => !s.code).map((s) => s.id);
await reposition(tx, ctx.company.id, pipeline, [...middle.filter((id) => id !== row!.id), row!.id]);
return { id: row!.id };
});
});
app.patch('/api/pipeline-statuses/:id', async (req) => {
const ctx = g.writable(g.manager(req));
const { id } = Id.parse(req.params);
const body = z.object({ name: Name.optional(), poolReturn: z.boolean().optional() }).parse(req.body);
const s = await ownStatus(db, ctx.company.id, id);
// Возврат в пул бывает только у объектов и только у статусов «в работе».
if (body.poolReturn !== undefined && (s.pipeline !== 'object' || ['new', 'deal', 'rejected'].includes(s.code ?? ''))) {
throw badRequest('VALIDATION');
}
if (body.name === undefined && body.poolReturn === undefined) throw badRequest('VALIDATION');
await db.update(pipelineStatuses).set(body).where(eq(pipelineStatuses.id, id));
return { ok: true };
});
/**
* Удаление промежуточного статуса. Если в нём есть карточки, нужно указать `moveTo` —
* другой промежуточный статус (или «Взят в работу»), куда они перейдут.
*/
app.delete('/api/pipeline-statuses/:id', async (req) => {
const ctx = g.writable(g.manager(req));
const { id } = Id.parse(req.params);
const { moveTo } = z.object({ moveTo: z.coerce.number().int().positive().optional() }).parse(req.query);
await db.transaction(async (tx) => {
const s = await ownStatus(tx, ctx.company.id, id);
const pipeline = s.pipeline as Pipeline;
if (s.code && (SYSTEM_CODES[pipeline] as readonly string[]).includes(s.code)) throw badRequest('SYSTEM_STATUS');
const table = pipeline === 'object' ? leads : clients;
const used = await tx.select({ id: table.id }).from(table).where(eq(table.statusId, id));
if (used.length) {
if (!moveTo) throw conflict('STATUS_IN_USE');
const target = await ownStatus(tx, ctx.company.id, moveTo);
const allowed = target.pipeline === pipeline && target.id !== id && (!target.code || target.code === 'taken');
if (!allowed) throw badRequest('BAD_STATUS');
await tx.update(table).set({ statusId: target.id }).where(eq(table.statusId, id));
const ids = used.map((u) => u.id);
await tx.insert(events).values(
ids.map((entityId) => ({
companyId: ctx.company.id, userId: ctx.user.id, type: 'status', oldValue: s.name, newValue: target.name, statusId: target.id,
...(pipeline === 'object' ? { leadId: entityId } : { clientId: entityId }),
})),
);
}
await tx.update(pipelineStatuses).set({ deletedAt: new Date() }).where(eq(pipelineStatuses.id, id));
await reposition(tx, ctx.company.id, pipeline);
});
return { ok: true };
});
/** Новый порядок промежуточных статусов: `ids` — все они, слева направо. */
app.post('/api/pipelines/:pipeline/order', async (req) => {
const ctx = g.writable(g.manager(req));
const { pipeline } = PipelineParam.parse(req.params);
const { ids } = Order.parse(req.body);
await db.transaction((tx) => reposition(tx, ctx.company.id, pipeline, ids));
return { ok: true };
});
// --- причины отказа ---
const activeReasons = (tx: Tx, companyId: number, pipeline: Pipeline) =>
tx
.select()
.from(rejectReasons)
.where(and(eq(rejectReasons.companyId, companyId), eq(rejectReasons.pipeline, pipeline), isNull(rejectReasons.deletedAt)))
.orderBy(asc(rejectReasons.position));
const ownReason = async (tx: Tx, companyId: number, id: number) => {
const [r] = await tx
.select()
.from(rejectReasons)
.where(and(eq(rejectReasons.id, id), eq(rejectReasons.companyId, companyId), isNull(rejectReasons.deletedAt)));
if (!r) throw notFound();
return r;
};
app.post('/api/pipelines/:pipeline/reasons', async (req) => {
const ctx = g.writable(g.manager(req));
const { pipeline } = PipelineParam.parse(req.params);
const { name } = z.object({ name: Name }).parse(req.body);
const last = (await activeReasons(db, ctx.company.id, pipeline)).at(-1);
const [row] = await db
.insert(rejectReasons)
.values({ companyId: ctx.company.id, pipeline, name, position: (last?.position ?? -1) + 1 })
.returning();
return { id: row!.id };
});
app.patch('/api/reject-reasons/:id', async (req) => {
const ctx = g.writable(g.manager(req));
const { id } = Id.parse(req.params);
const { name } = z.object({ name: Name }).parse(req.body);
await ownReason(db, ctx.company.id, id);
await db.update(rejectReasons).set({ name }).where(eq(rejectReasons.id, id));
return { ok: true };
});
/** Удалённая причина пропадает из списка выбора, но остаётся в уже закрытых карточках и в аналитике. */
app.delete('/api/reject-reasons/:id', async (req) => {
const ctx = g.writable(g.manager(req));
const { id } = Id.parse(req.params);
await db.transaction(async (tx) => {
const r = await ownReason(tx, ctx.company.id, id);
// «Это агент» ставит кнопка в карточке объекта.
if (r.code) throw badRequest('SYSTEM_REASON');
const rest = (await activeReasons(tx, ctx.company.id, r.pipeline as Pipeline)).filter((x) => x.id !== id);
if (!rest.some((x) => !x.code)) throw badRequest('LAST_REASON');
await tx.update(rejectReasons).set({ deletedAt: new Date() }).where(eq(rejectReasons.id, id));
});
return { ok: true };
});
app.post('/api/pipelines/:pipeline/reasons-order', async (req) => {
const ctx = g.writable(g.manager(req));
const { pipeline } = PipelineParam.parse(req.params);
const { ids } = Order.parse(req.body);
await db.transaction(async (tx) => {
const all = await activeReasons(tx, ctx.company.id, pipeline);
if (ids.length !== all.length || new Set(ids).size !== ids.length || ids.some((id) => !all.some((r) => r.id === id))) {
throw badRequest('VALIDATION');
}
for (const [position, id] of ids.entries()) await tx.update(rejectReasons).set({ position }).where(eq(rejectReasons.id, id));
});
return { ok: true };
});
}
+29
View File
@@ -0,0 +1,29 @@
import type { Pipeline } from '@lr/shared';
import { and, eq } from 'drizzle-orm';
import type { Tx } from '../db/client.ts';
import { pipelineStatuses } from '../db/schema.ts';
export type Status = typeof pipelineStatuses.$inferSelect;
/** Системный статус воронки по коду (new, taken, deal, rejected). */
export async function statusByCode(tx: Tx, companyId: number, code: string, pipeline: Pipeline = 'object'): Promise<Status> {
const [row] = await tx
.select()
.from(pipelineStatuses)
.where(
and(
eq(pipelineStatuses.companyId, companyId),
eq(pipelineStatuses.pipeline, pipeline),
eq(pipelineStatuses.code, code),
),
);
if (!row) throw new Error(`У компании ${companyId} нет системного статуса ${pipeline}/${code}`);
return row;
}
/** Все статусы воронки компании, включая удалённые (они нужны для истории). */
export const pipelineOf = (tx: Tx, companyId: number, pipeline: Pipeline) =>
tx
.select()
.from(pipelineStatuses)
.where(and(eq(pipelineStatuses.companyId, companyId), eq(pipelineStatuses.pipeline, pipeline)));
+113
View File
@@ -0,0 +1,113 @@
import { and, eq, gte, isNull, lte } from 'drizzle-orm';
import type { Db } from './db/client.ts';
import { companies, deals, users } from './db/schema.ts';
import type { Config } from './lib/config.ts';
import { payroll } from './payroll.ts';
import { enqueueRaw } from './telegram/outbox.ts';
import { formatPrice } from './telegram/texts.ts';
// Отчёты руководителю в Telegram: сводка за неделю по понедельникам и итог месяца с ведомостью
// первого числа. Включаются в настройках компании. Каждый отчёт уходит один раз — по отметке
// в настройках.
const L = {
ru: {
week: 'Итоги недели', month: 'Итоги месяца', deals: 'Сделок', commission: 'комиссия', none: 'Сделок не было.',
toPay: 'К выплате', total: 'Всего', planDone: 'план выполнен', planNot: 'план не выполнен', sheet: 'Ведомость', locale: 'ru-RU',
},
hy: {
week: 'Շաբաթվա արդյունքները', month: 'Ամսվա արդյունքները', deals: 'Գործարքներ', commission: 'միջնորդավճար', none: 'Գործարքներ չեն եղել։',
toPay: 'Վճարման ենթակա', total: 'Ընդամենը', planDone: 'պլանը կատարված է', planNot: 'պլանը կատարված չէ', sheet: 'Տեղեկագիր', locale: 'hy-AM',
},
en: {
week: 'Week summary', month: 'Month summary', deals: 'Deals', commission: 'commission', none: 'No deals.',
toPay: 'To pay', total: 'Total', planDone: 'plan met', planNot: 'plan not met', sheet: 'Payroll', locale: 'en-GB',
},
};
type Lang = keyof typeof L;
const dict = (lang: string | null) => L[(lang as Lang) in L ? (lang as Lang) : 'ru'];
const iso = (d: Date) => d.toISOString().slice(0, 10);
const addDays = (day: string, n: number) => iso(new Date(Date.parse(`${day}T00:00:00Z`) + n * 86_400_000));
const sums = (byCurrency: Record<string, number>) =>
Object.entries(byCurrency).filter(([, n]) => n).map(([cur, n]) => formatPrice(n, cur)).join(' + ') || '—';
/** Сводка за период: сколько сделок и комиссии у компании и у каждого агента (его часть). */
async function summary(db: Db, companyId: number, from: string, to: string, t: (typeof L)[Lang]) {
const rows = await db.select().from(deals).where(and(eq(deals.companyId, companyId), gte(deals.dealDate, from), lte(deals.dealDate, to)));
if (!rows.length) return { lines: [t.none], agents: [] as Awaited<ReturnType<typeof payroll>>['agents'] };
const total: Record<string, number> = {};
for (const d of rows) total[d.currency] = (total[d.currency] ?? 0) + d.commission;
const { agents } = await payroll(db, companyId, from, to);
const active = agents.filter((a) => a.deals).sort((a, b) => b.deals - a.deals);
return {
lines: [`${t.deals}: ${rows.length} · ${t.commission}: ${sums(total)}`, '', ...active.map((a, i) => `${i + 1}. ${a.name} — ${a.deals} · ${sums(a.byCurrency)}`)],
agents,
};
}
export async function weeklyReport(db: Db, companyId: number, monday: string, lang: string | null) {
const t = dict(lang);
const from = addDays(monday, -7);
const to = addDays(monday, -1);
const day = (s: string) => new Date(`${s}T00:00:00Z`).toLocaleDateString(t.locale, { day: 'numeric', month: 'short', timeZone: 'UTC' });
return [`${t.week}: ${day(from)} — ${day(to)}`, ...(await summary(db, companyId, from, to, t)).lines].join('\n');
}
export async function monthlyReport(db: Db, config: Config, companyId: number, month: string, lang: string | null) {
const t = dict(lang);
const [y, m] = month.split('-').map(Number) as [number, number];
const from = `${month}-01`;
const to = `${month}-${String(new Date(Date.UTC(y, m, 0)).getUTCDate()).padStart(2, '0')}`;
const title = new Date(`${from}T00:00:00Z`).toLocaleDateString(t.locale, { month: 'long', year: 'numeric', timeZone: 'UTC' });
const { lines } = await summary(db, companyId, from, to, t);
// Ведомость — по месяцу целиком: с ручными записями, бонусами и перерасчётами.
const { agents } = await payroll(db, companyId, from, to);
const due = agents.filter((a) => a.total);
const total: Record<string, number> = {};
for (const a of due) total[a.currency] = (total[a.currency] ?? 0) + a.total;
return [
`${t.month}: ${title}`,
...lines,
'',
`${t.toPay}:`,
...due.map((a) => `• ${a.name} — ${formatPrice(a.total, a.currency)}${a.planDone === null ? '' : ` (${a.planDone ? t.planDone : t.planNot})`}`),
`${t.total}: ${sums(total)}`,
'',
`${t.sheet}: ${config.appUrl}/team`,
].join('\n');
}
/** Рассылает положенные отчёты. Время — по Еревану: неделя закрывается в понедельник, месяц — первого числа, с 9 утра. */
export async function runReportJobs(db: Db, config: Config, now = new Date()) {
const local = new Date(now.getTime() + 4 * 3_600_000);
if (local.getUTCHours() < 9) return 0;
const today = iso(local);
const isMonday = local.getUTCDay() === 1;
const isFirst = local.getUTCDate() === 1;
if (!isMonday && !isFirst) return 0;
const lastMonth = iso(new Date(Date.UTC(local.getUTCFullYear(), local.getUTCMonth() - 1, 1))).slice(0, 7);
let sent = 0;
for (const company of await db.select().from(companies)) {
const r = company.settings.reports;
const weekly = Boolean(r?.weekly && isMonday && r.sentWeekly !== today);
const monthly = Boolean(r?.monthly && isFirst && r.sentMonthly !== lastMonth);
if (!weekly && !monthly) continue;
const managers = await db
.select({ id: users.id, lang: users.lang })
.from(users)
.where(and(eq(users.companyId, company.id), eq(users.role, 'manager'), isNull(users.removedAt)));
await db.transaction(async (tx) => {
for (const m of managers) {
if (weekly) await enqueueRaw(tx, [m.id], await weeklyReport(db, company.id, today, m.lang));
if (monthly) await enqueueRaw(tx, [m.id], await monthlyReport(db, config, company.id, lastMonth, m.lang));
}
await tx
.update(companies)
.set({ settings: { ...company.settings, reports: { weekly: Boolean(r?.weekly), monthly: Boolean(r?.monthly), sentWeekly: weekly ? today : r?.sentWeekly, sentMonthly: monthly ? lastMonth : r?.sentMonthly } } })
.where(eq(companies.id, company.id));
});
sent += Number(weekly) + Number(monthly);
}
return sent;
}
+419
View File
@@ -0,0 +1,419 @@
import { and, eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashPassword } from './auth/password.ts';
import { createSession } from './auth/sessions.ts';
import sharp from 'sharp';
import { clientPhotos, clients, companies, events, leads, listings, users } from './db/schema.ts';
import { handleIntakeUpdate, parseBudget } from './intake/service.ts';
import { runShowcaseJobs, slugify, validSlug } from './showcase/service.ts';
import { renderShowcaseText, showcaseText } from './showcase/text.ts';
import { handleUpdate } from './telegram/bot.ts';
import { processOutbox } from './telegram/outbox.ts';
import { fakeTelegram, testApp } from './testing.ts';
// Анкета соискателя в клиентском боте → клиент в CRM → страница подборки → запись на показ.
let t: Awaited<ReturnType<typeof testApp>>;
let config: Awaited<ReturnType<typeof testApp>>['config'] & { clientBotToken: string; clientBotUsername: string };
beforeAll(async () => {
t = await testApp('showcase');
// Приложение и обработчики ботов смотрят в один и тот же объект настроек.
config = Object.assign(t.config, { clientBotToken: 'client:token', clientBotUsername: 'client_bot' });
});
afterAll(() => t.close());
/** Основной бот (агенты) и клиентский бот (соискатели) — две разные заглушки. */
const main = fakeTelegram();
const seekerBot = fakeTelegram();
const flush = async () => { while ((await processOutbox({ db: t.db, tg: main.tg, config, sleep: async () => {} })) > 0); };
let tgId = 9000;
let updateId = 0;
const company = async (name = 'Арарат Недвижимость') => {
const [sa] = await t.db
.insert(users)
.values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') })
.returning();
const admin = t.client(await createSession(t.db, sa!.id));
const created = await admin.post('/api/admin/companies', {
name, plan: 'team', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
});
const api = await t.register('Арам');
await api.post(`/api/invites/${(created.body.managerUrl as string).split('/invite/')[1]}/accept`);
const me = (await api.get('/api/me')).body;
const chat = ++tgId;
await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, me.user.id));
return { api, id: me.user.id as number, chat, slug: me.company.slug as string };
};
const fromSeeker = (chat: number, part: object) => ({ update_id: ++updateId, ...part }) as never;
const say = (chat: number, text: string) =>
handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config, download: async () => ({ data: Buffer.from('JPEG'), type: 'image/jpeg' }) },
fromSeeker(chat, { message: { chat: { id: chat }, from: { id: chat, first_name: 'Анна', username: 'anna', language_code: 'ru' }, text } }));
const press = (chat: number, data: string) =>
handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config },
fromSeeker(chat, { callback_query: { id: `cb${updateId}`, from: { id: chat, first_name: 'Анна', language_code: 'ru' }, data, message: { chat: { id: chat }, message_id: 1 } } }));
const last = (chat: number) => seekerBot.inbox(chat).at(-1)!;
describe('адрес компании', () => {
it('строится из названия; служебные адреса и мусор не проходят', () => {
expect(slugify('Арарат Недвижимость')).toBe('ararat-nedvizhimost');
expect(slugify('Հայ Տուն')).toBe('hay-tun');
expect(validSlug('ararat')).toBe(true);
expect([validSlug('leads'), validSlug('api'), validSlug('a'), validSlug('Арарат'), validSlug('-x-')]).toEqual([false, false, false, false, false]);
});
it('у новой компании адрес из названия; руководитель меняет его, занятый и служебный — нельзя', async () => {
const a = await company('Арарат Недвижимость');
const b = await company('Арарат Недвижимость');
expect(a.slug).toBe('ararat-nedvizhimost');
expect(b.slug).toMatch(/^ararat-nedvizhimost-\d+$/);
expect((await b.api.patch('/api/company', { slug: 'ararat-nedvizhimost' })).body.code).toBe('SLUG_TAKEN');
expect((await b.api.patch('/api/company', { slug: 'leads' })).body.code).toBe('BAD_SLUG');
expect((await b.api.patch('/api/company', { slug: 'Yerevan-Home' })).status).toBe(200);
expect((await b.api.get('/api/me')).body.company.slug).toBe('yerevan-home');
});
});
describe('анкета в клиентском боте', () => {
it('сумма бюджета понимается с валютой и без', () => {
expect(parseBudget('600 000 драм')).toEqual({ amount: 600000, currency: 'AMD' });
expect(parseBudget('1500$')).toEqual({ amount: 1500, currency: 'USD' });
expect(parseBudget('350000')).toEqual({ amount: 350000, currency: 'AMD' });
expect(parseBudget('120000', 'sale')).toEqual({ amount: 120000, currency: 'USD' });
expect(parseBudget('дорого')).toBeUndefined();
});
it('соискатель отвечает на вопросы — клиент появляется в CRM у автора ссылки; затем подборка и запись на показ', async () => {
const agent = await company('Дом Ереван');
// Объекты агента: подходящий, «с детьми нельзя» и из другого района.
const lead = async (title: string, over: object, features: object | null = null) => {
const id = (await agent.api.post('/api/leads', { title, dealType: 'rent', propertyType: 'apartment', district: 'Кентрон', region: 'Ереван', price: 500_000, currency: 'AMD', rooms: 2, address: 'ул. Абовяна 10', url: 'https://www.list.am/item/1', ...over })).body.id as number;
if (features) await t.db.update(leads).set({ features: features as never }).where(eq(leads.id, id));
return id;
};
const good = await lead('Подходящая квартира', {}, {
children: 'yes', pets: 'negotiable', amenities: [],
attrs: [{ label: 'Можно с детьми', value: 'Да' }, { label: 'Можно с животными', value: 'По договоренности' }],
});
await lead('Без детей', {}, { children: 'no', amenities: [], attrs: [] });
await lead('Другой район', { district: 'Аван' });
// Ссылку на анкету агент получает командой в основном боте.
await handleUpdate({ db: t.db, tg: main.tg, config }, { update_id: ++updateId, message: { chat: { id: agent.chat }, from: { id: agent.chat }, text: '/anketa' } });
const link = /https:\/\/t\.me\/client_bot\?start=(\S+)/.exec(main.inbox(agent.chat).at(-1)!.text)!;
expect((await agent.api.get('/api/intake-link')).body.url).toBe(link[0]);
const seeker = 9500;
await say(seeker, '/start');
expect(last(seeker).text).toContain('по ссылке от вашего агента');
await say(seeker, '/start wrong-token');
expect(last(seeker).text).toContain('устарела');
// Первым делом бот спрашивает язык — сразу на трёх; пока язык не выбран, анкета не идёт.
await say(seeker, `/start ${link[1]}`);
expect(last(seeker).text).toBe('Выберите язык · Ընտրեք լեզուն · Choose your language');
expect(last(seeker).keyboard.flat().map((b) => b.text)).toEqual(['Русский', 'Հայերեն', 'English']);
await say(seeker, 'привет');
expect(last(seeker).text).toContain('Choose your language');
await press(seeker, 'q:lang:hy');
expect(seekerBot.inbox(seeker).at(-2)!.text).toContain('հարցաթերթիկն է');
expect(last(seeker).text).toBe('Ի՞նչ եք փնտրում');
// Язык можно сменить по ходу командой /lang: анкета продолжается с того же вопроса.
await say(seeker, '/lang');
await press(seeker, 'q:lang:ru');
expect(seekerBot.inbox(seeker).at(-2)!.text).toContain('Язык: русский');
expect(last(seeker).text).toBe('Что вы ищете?');
// Ответ текстом там, где нужны кнопки, анкету не двигает.
await say(seeker, 'квартиру');
expect(last(seeker).text).toContain('кнопкой');
await press(seeker, 'q:deal:rent');
await press(seeker, 'q:type:apartment');
await press(seeker, 'q:place:Кентрон');
expect(last(seeker).text).toContain('бюджет');
await say(seeker, 'много');
expect(last(seeker).text).toContain('Не понял сумму');
await say(seeker, '600 000 драм');
await press(seeker, 'q:rooms:2');
expect(last(seeker).text).toBe('На какой срок нужна аренда?');
await press(seeker, 'q:term:mid');
await press(seeker, 'q:who:couple');
await press(seeker, 'q:children:2');
expect(last(seeker).text).toContain('Сколько лет детям');
await say(seeker, '3 и 7 лет');
// Животных нет — вопрос про их возраст пропускается.
await press(seeker, 'q:pets:0');
expect(last(seeker).text).toBe('Как вас зовут?');
await press(seeker, 'q:name:keep');
await say(seeker, 'abc');
expect(last(seeker).text).toContain('не номер телефона');
await say(seeker, '091 123 456');
expect(last(seeker).text).toContain('анкета отправлена');
const [client] = await t.db.select().from(clients).where(eq(clients.assigneeId, agent.id));
expect(client).toMatchObject({
name: 'Анна', phone: '+37491123456', kind: 'tenant', source: 'Анкета в Telegram', rentTerm: 'mid', telegram: '@anna',
request: { propertyTypes: ['apartment'], region: 'Ереван', districts: ['Кентрон'], priceTo: 600000, priceCurrency: 'AMD', rooms: [2] },
household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' } },
});
// Фото после анкеты добавляется к карточке клиента.
await handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config, download: async () => ({ data: Buffer.from('JPEG'), type: 'image/jpeg' }) },
fromSeeker(seeker, { message: { chat: { id: seeker }, from: { id: seeker, first_name: 'Анна' }, photo: [{ file_id: 's' }, { file_id: 'big' }] } }));
expect(last(seeker).text).toContain('Фото добавлено');
expect(await t.db.select().from(clientPhotos).where(eq(clientPhotos.clientId, client!.id))).toHaveLength(1);
// Текст после анкеты не теряется: попадает в историю карточки, агенту приходит уведомление.
await say(seeker, 'Удобно звонить после 18:00');
expect(last(seeker).text).toContain('передали агенту');
const notes = await t.db.select().from(events).where(and(eq(events.clientId, client!.id), eq(events.type, 'comment')));
expect(notes.at(-1)!.text).toContain('после 18:00');
await flush();
expect(main.inbox(agent.chat).some((m) => m.text.includes('Удобно звонить после 18:00'))).toBe(true);
// Автору ссылки в основной бот: сводка и вопрос с кнопками.
await flush();
const prompt = main.inbox(agent.chat).find((m) => m.text.includes('Новая анкета'))!;
expect(prompt.text).toContain('Новая анкета: Анна — арендатор');
expect(prompt.keyboard.flat().map((b) => ('callback' in b ? b.callback : ''))).toEqual([`match:${client!.id}`, `nomatch:${client!.id}`]);
// «Показать объекты»: подходящий объект прикреплён, клиенту в его бот ушла ссылка на подборку.
await handleUpdate({ db: t.db, tg: main.tg, config, clientTg: seekerBot.tg },
{ update_id: ++updateId, callback_query: { id: 'm1', from: { id: agent.chat }, data: `match:${client!.id}`, message: { chat: { id: agent.chat }, message_id: 5 } } });
expect(main.inbox(agent.chat).at(-1)!.text).toContain('Подобрано объектов: 1');
expect(last(seeker).text).toContain('?lang=ru');
const url = /https:\/\/app\.test\/([^\s/]+)\/([^\s?]+)/.exec(last(seeker).text)!;
expect(url[1]).toBe(agent.slug);
// Страница клиента открывается без входа и не выдаёт адрес, телефон и ссылку на объявление.
const page = await t.client().get(`/api/public/pages/${url[1]}/${url[2]}`);
expect(page.status).toBe(200);
expect(page.body).toMatchObject({ company: 'Дом Ереван', agent: 'Арам', client: 'Анна' });
expect(page.body.objects).toMatchObject([{ id: good, district: 'Кентрон', price: 500000, viewingDay: null }]);
// Текст — карточка по шаблону из характеристик объекта.
expect(page.body.objects[0].text).toBe('🏡 Кентрон\n\n🛏 Комнат: 2\n\n🧑‍🧑‍🧒 Дети: Да\n🐶 Животные: По договоренности\n\n💵 Цена: 500.000 драм');
expect(Object.keys(page.body.objects[0])).not.toEqual(expect.arrayContaining(['address', 'url', 'phone', 'description', 'title']));
expect(JSON.stringify(page.body)).not.toContain('Подходящая квартира');
expect(JSON.stringify(page.body)).not.toContain('Абовяна');
expect((await t.client().get(`/api/public/pages/${url[1]}/wrong-token-123`)).body.code).toBe('PAGE_NOT_FOUND');
expect((await t.client().get(`/api/public/pages/other-company/${url[2]}`)).status).toBe(404);
// Запись на показ: агент получает уведомление, запись видна в карточке клиента.
const day = new Date(Date.now() + 3 * 86_400_000 + 4 * 3_600_000).toISOString().slice(0, 10);
const book = (body: object) => t.client().post(`/api/public/pages/${url[1]}/${url[2]}/viewings`, body);
expect((await book({ leadId: good, day: '2020-01-01' })).body.code).toBe('BAD_DAY');
expect((await book({ leadId: 999_999, day })).body.code).toBe('OBJECT_NOT_FOUND');
expect((await book({ leadId: good, day, note: 'После 18:00' })).body).toEqual({ day });
await flush();
expect(main.inbox(agent.chat).at(-1)!.text).toBe(`📅 Анна записался на показ ${day}: Подходящая квартира\nКомментарий: После 18:00`);
// Повторная запись на тот же день уведомление не дублирует.
const before = main.inbox(agent.chat).length;
await book({ leadId: good, day, note: 'После 18:00' });
await flush();
expect(main.inbox(agent.chat)).toHaveLength(before);
const card = (await agent.api.get(`/api/clients/${client!.id}`)).body;
expect(card.viewings).toMatchObject([{ leadId: good, day, title: 'Подходящая квартира', note: 'После 18:00' }]);
expect(card.pageUrl).toBe(url[0]);
// Свой домен компании: ссылка уходит на него, путь тот же; сохранение настроек руководителем домен не теряет.
const [co] = await t.db.select().from(companies).where(eq(companies.slug, url[1]!));
await t.db.update(companies).set({ settings: { ...co!.settings, clientHost: 'arev.yandp.tech' } }).where(eq(companies.id, co!.id));
await agent.api.patch('/api/company', { agentsSeeAll: true });
expect((await agent.api.get(`/api/clients/${client!.id}`)).body.pageUrl).toBe(`https://arev.yandp.tech/${url[1]}/${url[2]}`);
await t.db.update(companies).set({ settings: co!.settings }).where(eq(companies.id, co!.id));
expect(new Date(card.nextActionAt).toISOString()).toBe(new Date(`${day}T09:00:00+04:00`).toISOString());
expect(card.events.map((e: any) => e.type)).toContain('viewing');
expect((await t.client().get(`/api/public/pages/${url[1]}/${url[2]}`)).body.objects[0].viewingDay).toBe(day);
// Агент закрывает страницу — ссылка больше не открывается.
expect((await agent.api.del(`/api/clients/${client!.id}/page`)).status).toBe(200);
expect((await t.client().get(`/api/public/pages/${url[1]}/${url[2]}`)).status).toBe(404);
});
it('страница из кабинета: кнопка с подбором прикрепляет подходящие объекты; чужой агент ссылку создать не может', async () => {
const agent = await company('Кабинет');
const stranger = await company('Чужие');
const id = (await agent.api.post('/api/clients', { name: 'Гор', kind: 'buyer', request: { districts: ['Арабкир'] } })).body.id as number;
await agent.api.post('/api/leads', { title: 'Дом в Арабкире', dealType: 'sale', propertyType: 'house', district: 'Арабкир' });
expect((await stranger.api.post(`/api/clients/${id}/page`, {})).status).toBe(404);
const made = (await agent.api.post(`/api/clients/${id}/page`, { match: true })).body;
expect(made.added).toBe(1);
// Повторный запрос отдаёт ту же ссылку.
expect((await agent.api.post(`/api/clients/${id}/page`, {})).body.url).toBe(made.url);
const [, slug, token] = /app\.test\/(\S+)\/(\S+)/.exec(made.url)!;
expect((await t.client().get(`/api/public/pages/${slug}/${token}`)).body.objects).toHaveLength(1);
});
it('фото на странице клиента: полный набор из объявления, обрезаны сверху и отдаются самим сервисом', async () => {
const agent = await company('Фото');
const id = (await agent.api.post('/api/clients', { name: 'Гор', kind: 'tenant' })).body.id as number;
const leadId = (await agent.api.post('/api/leads', { title: 'Квартира', dealType: 'rent', propertyType: 'apartment', district: 'Кентрон' })).body.id as number;
// Объект из объявления: в базе парсера у него только обложка.
const [listing] = await t.db.insert(listings).values({ externalId: 'x777', url: 'https://www.list.am/item/777', title: 'Квартира', photos: ['https://img.list.am/f/1/1.webp'] }).returning();
await t.db.update(leads).set({ listingId: listing!.id, photos: ['https://img.list.am/f/1/1.webp'], description: 'Рядом парк.' }).where(eq(leads.id, leadId));
await agent.api.post(`/api/clients/${id}/leads`, { leadId, state: 'offered' });
const { url } = (await agent.api.post(`/api/clients/${id}/page`, {})).body;
const [, slug, token] = /app\.test\/(\S+)\/(\S+)/.exec(url)!;
const objects = async () => (await t.client().get(`/api/public/pages/${slug}/${token}`)).body.objects;
expect((await objects())[0].photos).toEqual([`/api/public/pages/${slug}/${token}/photos/${leadId}/0`]);
// Фоновая задача догружает все фото объявления и сохраняет текст; второй запуск ничего не делает.
const gallery = ['https://img.list.am/f/1/1.webp', 'https://img.list.am/f/2/2.webp', 'https://img.list.am/f/3/3.webp'];
let calls = 0;
const source = { gallery: async (ext: string) => { calls += 1; expect(ext).toBe('x777'); return { photos: gallery, features: { amenities: [], attrs: [{ label: 'Ремонт', value: 'Евроремонт' }] } }; } };
// Вместе с ним готовятся объекты прошлых сценариев (у них объявления нет — только текст).
expect(await runShowcaseJobs({ db: t.db, config, source, limit: 50 })).toBeGreaterThanOrEqual(1);
expect(await runShowcaseJobs({ db: t.db, config, source })).toBe(0);
expect(calls).toBe(1);
const [object] = await objects();
expect(object.photos).toHaveLength(3);
// Характеристики, которых не было в базе, взяты со страницы и попали в текст.
expect(object.text).toContain('🆕 Состояние: Евроремонт');
expect(JSON.stringify(object)).not.toContain('list.am');
expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.photos).toEqual(gallery);
// Фото: JPEG, сверху срезано 8 % высоты (200 → 184).
const photo = await t.app.inject({ method: 'GET', url: object.photos[2] });
expect(photo.statusCode).toBe(200);
expect(photo.headers['content-type']).toBe('image/jpeg');
expect(await sharp(photo.rawPayload).metadata()).toMatchObject({ format: 'jpeg', width: 100, height: 184 });
expect((await t.app.inject({ method: 'GET', url: `/api/public/pages/${slug}/${token}/photos/${leadId}/9` })).statusCode).toBe(404);
expect((await t.app.inject({ method: 'GET', url: `/api/public/pages/${slug}/wrong-token-12/photos/${leadId}/0` })).statusCode).toBe(404);
});
it('фото: снятое объявление сразу получает отметку; устаревшие фото загружаются заново при добавлении клиенту', async () => {
const agent = await company('Обновление фото');
const id = (await agent.api.post('/api/clients', { name: 'Ани', kind: 'tenant' })).body.id as number;
const make = async (ext: string) => {
const leadId = (await agent.api.post('/api/leads', { title: 'Квартира', dealType: 'rent', propertyType: 'apartment' })).body.id as number;
const [listing] = await t.db.insert(listings).values({ externalId: ext, url: `https://www.list.am/item/${ext}`, title: 'Квартира', photos: ['https://img.list.am/f/1/1.webp'] }).returning();
await t.db.update(leads).set({ listingId: listing!.id, photos: listing!.photos }).where(eq(leads.id, leadId));
return { leadId, listingId: listing!.id };
};
const gone = await make('x901');
const old = await make('x902');
await t.db.update(leads).set({ galleryWantedAt: new Date() }).where(eq(leads.id, gone.leadId));
const source = { gallery: async (ext: string) => (ext === 'x901' ? { photos: [], removed: true } : { photos: ['https://img.list.am/f/9/9.webp'] }) };
await runShowcaseJobs({ db: t.db, config, source, limit: 50 });
const [listing] = await t.db.select().from(listings).where(eq(listings.id, gone.listingId));
expect(listing!.active).toBe(false);
expect(listing!.removedAt).not.toBeNull();
// Фото загружены двое суток назад — при добавлении клиенту загружаются заново.
await t.db.update(leads).set({ galleryAt: new Date(Date.now() - 2 * 86_400_000) }).where(eq(leads.id, old.leadId));
await agent.api.post(`/api/clients/${id}/leads`, { leadId: old.leadId, state: 'offered' });
expect((await t.db.select().from(leads).where(eq(leads.id, old.leadId)))[0]!.galleryAt).toBeNull();
await runShowcaseJobs({ db: t.db, config, source, limit: 50 });
expect((await t.db.select().from(leads).where(eq(leads.id, old.leadId)))[0]!.photos).toEqual(['https://img.list.am/f/9/9.webp']);
});
it('карточка объекта: телефон собственника вносит агент; фото и текст из той же логики', async () => {
const agent = await company('Карточка');
const [listing] = await t.db.insert(listings).values({ externalId: 'x888', url: 'https://www.list.am/item/888', title: 'Квартира', photos: ['https://img.list.am/f/8/8.webp'] }).returning();
const leadId = (await agent.api.post('/api/leads', { title: 'Квартира', dealType: 'rent', propertyType: 'apartment', district: 'Аван', price: 900, currency: 'USD', rooms: 1 })).body.id as number;
await t.db.update(leads).set({ listingId: listing!.id, photos: ['https://img.list.am/f/8/8.webp'] }).where(eq(leads.id, leadId));
// Телефон: приводится к общему виду, попадает и в общую базу объявлений; мусор не принимается.
expect((await agent.api.post(`/api/leads/${leadId}/phone`, { phone: 'позвоните' })).body.code).toBe('BAD_PHONE');
expect((await agent.api.post(`/api/leads/${leadId}/phone`, { phone: '091 55-66-77' })).status).toBe(200);
let card = (await agent.api.get(`/api/leads/${leadId}`)).body;
expect(card.phone).toBe('+37491556677');
expect(card.events.some((e: any) => e.type === 'phone' && e.newValue === '+37491556677')).toBe(true);
expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone).toBe('+37491556677');
// Компания поправила свой вариант — в общей базе он заменился; пустая строка убирает и вариант, и номер.
await agent.api.post(`/api/leads/${leadId}/phone`, { phone: '077 000 111' });
expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone).toBe('+37477000111');
await agent.api.post(`/api/leads/${leadId}/phone`, { phone: '' });
expect((await agent.api.get(`/api/leads/${leadId}`)).body).toMatchObject({ phone: null, phoneVariants: [] });
expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone).toBeNull();
// Другая компания номер этому лиду поставить не может.
const stranger = await company('Посторонние');
expect((await stranger.api.post(`/api/leads/${leadId}/phone`, { phone: '091 55-66-77' })).status).toBe(404);
// Карточка: текст для клиента есть сразу; открытие ставит объект в очередь на догрузку фото.
expect(card.clientText).toBe('🏡 Аван\n\n🛏 Комнат: 1\n\n💵 Цена: $900');
expect(card).toMatchObject({ gallery: 'pending', photos: [`/api/leads/${leadId}/photos/0`] });
const source = { gallery: async () => ({ photos: ['https://img.list.am/f/8/8.webp', 'https://img.list.am/f/9/9.webp'] }) };
expect(await runShowcaseJobs({ db: t.db, config, source, limit: 50 })).toBeGreaterThanOrEqual(1);
card = (await agent.api.get(`/api/leads/${leadId}`)).body;
expect(card).toMatchObject({ gallery: 'ready', photos: [`/api/leads/${leadId}/photos/0`, `/api/leads/${leadId}/photos/1`] });
// Фото в карточке — обрезанное; без входа и из чужой компании оно недоступно.
const token = (await t.client().post('/api/auth/login', { email: (await agent.api.get('/api/me')).body.user.email, password: 'password-123' })).body.token as string;
const photo = await t.app.inject({ method: 'GET', url: card.photos[1], headers: { authorization: `Bearer ${token}` } });
expect(await sharp(photo.rawPayload).metadata()).toMatchObject({ format: 'jpeg', height: 184 });
expect((await t.app.inject({ method: 'GET', url: card.photos[1] })).statusCode).toBe(401);
expect((await stranger.api.get(card.photos[1])).status).toBe(404);
});
it('в общей базе все варианты номера: совпал у двух компаний — 80 %, у трёх — 100 %', async () => {
const [listing] = await t.db.insert(listings).values({ externalId: 'x999', url: 'https://www.list.am/item/999', title: 'Дом' }).returning();
const agents = [];
for (const name of ['Первые', 'Вторые', 'Третьи', 'Четвёртые']) {
const a = await company(name);
const leadId = (await a.api.post('/api/leads', { title: 'Дом', dealType: 'sale', propertyType: 'house' })).body.id as number;
await t.db.update(leads).set({ listingId: listing!.id }).where(eq(leads.id, leadId));
agents.push({ ...a, leadId });
}
const [a, b, c, d] = agents as [typeof agents[0], typeof agents[0], typeof agents[0], typeof agents[0]];
const set = (x: typeof a, phone: string) => x.api.post(`/api/leads/${x.leadId}/phone`, { phone });
const variants = async (x: typeof a) => (await x.api.get(`/api/leads/${x.leadId}`)).body.phoneVariants;
const base = async () => (await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone;
// Первый агент ошибся в цифре: номер один, никем не подтверждён.
await set(a, '091 111 110');
expect(await variants(a)).toEqual([{ phone: '+37491111110', votes: 1, confidence: null }]);
// Двое указали верный — в базе оба варианта, верный впереди с точностью 80 %.
await set(b, '091 111 111');
await set(c, '+374 91 111111');
expect(await variants(d)).toEqual([
{ phone: '+37491111111', votes: 2, confidence: 80 },
{ phone: '+37491111110', votes: 1, confidence: null },
]);
expect(await base()).toBe('+37491111111');
// Третье совпадение — 100 %. Четвёртая компания видит варианты ещё до того, как внесла свой.
await set(d, '091111111');
expect((await variants(a))[0]).toEqual({ phone: '+37491111111', votes: 3, confidence: 100 });
// Первый исправил свой вариант: ошибочный номер из базы исчез.
await set(a, '091 111 111');
expect(await variants(a)).toEqual([{ phone: '+37491111111', votes: 4, confidence: 100 }]);
// Агент, который объект не ведёт, вариантов не видит — как и самого телефона.
const invite = (await a.api.post('/api/invites')).body.token as string;
const other = await t.register('Коллега');
await other.post(`/api/invites/${invite}/accept`);
await a.api.patch('/api/company', { agentsSeeAll: true });
expect((await other.get(`/api/leads/${a.leadId}`)).body).toMatchObject({ phone: null, phoneVariants: [] });
});
it('текст по шаблону и дополнение моделью; при сбое модели остаётся шаблон', async () => {
const lead = {
title: 'Квартира', description: 'Тихий двор, рядом метро.', dealType: 'rent', district: 'Арабкир', region: 'Ереван', price: 3000, currency: 'USD',
rooms: 3, area: 84, floor: 2, floorsTotal: 9,
features: {
amenities: [{ group: 'Бытовая техника', items: ['Утюг', 'Холодильник'] }, { group: 'Удобства', items: ['Кондиционер'] }, { group: 'Виды из окон', items: ['Вид на двор'] }],
attrs: [{ label: 'Новостройка', value: 'Да' }, { label: 'Тип здания', value: 'Монолит' }, { label: 'Ремонт', value: 'Евроремонт' }, { label: 'Количество санузлов', value: '2' }, { label: 'Статус доступности', value: 'Свободно сейчас' }],
},
};
const card = renderShowcaseText(lead);
expect(card).toBe(['🏡 Арабкир', '', '🏢 Тип дома: Новостройка, Монолит', '🆕 Состояние: Евроремонт', '', '📐 Площадь: 84 м²', '🛏 Комнат: 3', '🚿 Санузлов: 2', '🏙 Этаж: 2/9', '',
'⚙️ Оснащение:', '✅ Кондиционер', '✅ Холодильник', '✅ Утюг', '', '💵 Цена: $3.000', '', '📅 Готова к заселению'].join('\n'));
expect(renderShowcaseText({ ...lead, features: null, price: null, district: null })).toBe('🏡 Ереван\n\n📐 Площадь: 84 м²\n🛏 Комнат: 3\n🏙 Этаж: 2/9');
const keyed = { ...config, openRouterKey: 'key' };
const reply = (content: string) => (async () => new Response(JSON.stringify({ choices: [{ message: { content } }] }))) as unknown as typeof fetch;
expect(await showcaseText(keyed, lead, reply(`${card}\n\nТихий двор, метро рядом.`))).toBe(`${card}\n\nТихий двор, метро рядом.`);
// Модель вернула не карточку или упала — берём шаблон.
expect(await showcaseText(keyed, lead, reply('Вот ваш текст: ...'))).toBe(card);
expect(await showcaseText(keyed, lead, (async () => new Response('', { status: 500 })) as unknown as typeof fetch)).toBe(card);
// Без ключа модель не вызывается вовсе.
expect(await showcaseText(config, lead, (async () => { throw new Error('не должен вызываться'); }) as unknown as typeof fetch)).toBe(card);
});
it('без клиентского бота ссылка на анкету не выдаётся', async () => {
const agent = await company('Без бота');
Object.assign(t.config, { clientBotToken: undefined });
expect((await agent.api.get('/api/intake-link')).body.url).toBeNull();
Object.assign(t.config, { clientBotToken: 'client:token' });
});
});
+47
View File
@@ -0,0 +1,47 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import sharp from 'sharp';
import type { Config } from '../lib/config.ts';
// Фото для страницы клиента (логика из бота list-am-bot, image/crop.py): фото скачивается,
// сверху срезается полоса в процентах от высоты, результат отдаётся в JPEG и кладётся на диск,
// чтобы второй раз не скачивать и не обрезать.
export type ImageFetcher = (url: string) => Promise<Buffer>;
export const fetchImage: ImageFetcher = async (url) => {
const res = await fetch(url, {
headers: { referer: 'https://www.list.am/', 'user-agent': 'Mozilla/5.0 (compatible; LeadRadar/1.0)' },
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`Фото не скачалось: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
if (data.length > 15 * 1024 * 1024) throw new Error('Фото слишком большое');
return data;
};
/** Срезать сверху `percent` % высоты и вернуть JPEG. */
export async function cropTop(data: Buffer, percent: number): Promise<Buffer> {
const image = sharp(data).rotate();
const { width, height } = await image.metadata();
const cut = width && height ? Math.floor((height * percent) / 100) : 0;
const cropped = cut > 0 && cut < height! ? image.extract({ left: 0, top: cut, width: width!, height: height! - cut }) : image;
return cropped.jpeg({ quality: 88, mozjpeg: true }).toBuffer();
}
/** Обработанное фото: с диска, если уже готово, иначе скачать, обрезать и сохранить. */
export async function croppedPhoto(config: Config, url: string, fetcher: ImageFetcher = fetchImage): Promise<Buffer> {
const name = `${createHash('sha256').update(`${url}|${config.cropTopPercent}`).digest('hex').slice(0, 32)}.jpg`;
const dir = join(config.mediaDir, name.slice(0, 2));
const file = join(dir, name);
const cached = await readFile(file).catch(() => null);
if (cached) return cached;
const result = await cropTop(await fetcher(url), config.cropTopPercent);
await mkdir(dir, { recursive: true });
// Пишем во временный файл и переименовываем: параллельный запрос не увидит половину файла.
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, result);
await rename(tmp, file);
return result;
}
+77
View File
@@ -0,0 +1,77 @@
import { and, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Db } from '../db/client.ts';
import { clientPages, clients } from '../db/schema.ts';
import { intakeLink } from '../intake/service.ts';
import type { Guards } from '../leads/routes.ts';
import type { Config } from '../lib/config.ts';
import { forbidden, notFound } from '../lib/errors.ts';
import type { ImageFetcher } from './media.ts';
import { bookViewing, ensureClientPage, loadPage, offerMatching, pagePhoto } from './service.ts';
const Id = z.object({ id: z.coerce.number().int().positive() });
const PageRef = z.object({ slug: z.string().min(1).max(60), token: z.string().min(8).max(60) });
export function registerShowcaseRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config, fetchImage?: ImageFetcher) {
type Ctx = ReturnType<Guards['member']>;
const ownClient = async (ctx: Ctx, id: number) => {
const [client] = await db.select().from(clients).where(and(eq(clients.id, id), eq(clients.companyId, ctx.company.id)));
if (!client) throw notFound('CLIENT_NOT_FOUND');
if (ctx.role !== 'manager' && client.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_CLIENT');
return client;
};
/** Ссылка агента на анкету в клиентском боте. url = null — клиентский бот не настроен. */
app.get('/api/intake-link', async (req) => {
const ctx = g.member(req);
return { url: await intakeLink(db, config, ctx.user) };
});
/**
* Страница подборки для клиента. С `match: true` перед этим к клиенту прикрепляются подходящие
* объекты — столько, сколько нашлось (до 10).
*/
app.post('/api/clients/:id/page', async (req) => {
const ctx = g.writable(g.member(req));
const { id } = Id.parse(req.params);
const { match } = z.object({ match: z.boolean().default(false) }).parse(req.body ?? {});
const client = await ownClient(ctx, id);
return db.transaction(async (tx) => {
const added = match ? await offerMatching(tx, ctx, client) : 0;
return { url: await ensureClientPage(tx, config, ctx.company, client, ctx.user.id), added };
});
});
/** Закрыть страницу: ссылка перестаёт открываться. */
app.delete('/api/clients/:id/page', async (req) => {
const ctx = g.writable(g.member(req));
const { id } = Id.parse(req.params);
await ownClient(ctx, id);
await db.update(clientPages).set({ revokedAt: new Date() }).where(eq(clientPages.clientId, id));
return { ok: true };
});
// --- без входа: страница, которую открывает клиент ---
app.get('/api/public/pages/:slug/:token', async (req) => {
const { slug, token } = PageRef.parse(req.params);
return loadPage(db, slug, token);
});
/** Фото объекта: обрезанное, отдаётся самим сервисом. */
app.get('/api/public/pages/:slug/:token/photos/:leadId/:index', async (req, reply) => {
const { slug, token } = PageRef.parse(req.params);
const { leadId, index } = z.object({ leadId: z.coerce.number().int().positive(), index: z.coerce.number().int().min(0).max(50) }).parse(req.params);
const photo = await pagePhoto(db, config, slug, token, leadId, index, fetchImage);
return reply.header('content-type', 'image/jpeg').header('cache-control', 'public, max-age=604800').send(photo);
});
app.post('/api/public/pages/:slug/:token/viewings', async (req) => {
const { slug, token } = PageRef.parse(req.params);
const body = z
.object({ leadId: z.number().int().positive(), day: z.iso.date(), note: z.string().trim().max(300).optional() })
.parse(req.body);
return bookViewing(db, slug, token, { leadId: body.leadId, day: body.day, note: body.note || undefined });
});
}
+361
View File
@@ -0,0 +1,361 @@
import { fitsHousehold, matchesClientRequest, type ClientKind, type Currency, type DealType, type ListingFeatures } from '@lr/shared';
import { and, desc, eq, inArray, isNull, notInArray, sql } from 'drizzle-orm';
import { newToken } from '../auth/sessions.ts';
import type { Db, Tx } from '../db/client.ts';
import { clientLeads, clientPages, clients, companies, events, leads, listings, pipelineStatuses, users, viewings } from '../db/schema.ts';
import { loadParserSettings } from '../parser/settings.ts';
import { croppedPhoto, type ImageFetcher } from './media.ts';
import { renderShowcaseText, showcaseText } from './text.ts';
import { canSee, inDepartment, listingLive, type Actor } from '../leads/service.ts';
import type { Config } from '../lib/config.ts';
import { badRequest, notFound } from '../lib/errors.ts';
import { outbox } from '../db/schema.ts';
import { enqueueText } from '../telegram/outbox.ts';
// Страница подборки для клиента: объекты, которые агент ему предложил, и запись на показ.
// Открывается без входа по адресу <кабинет>/<компания>/<код>; код знает только клиент.
/** Сколько фото показываем клиенту (как в боте list-am-bot). */
const MAX_PHOTOS = 20;
const isListAmPhoto = (url: string) => /^https:\/\/(?:img|s)\.list\.am\//.test(url);
type Company = typeof companies.$inferSelect;
type Client = typeof clients.$inferSelect;
type Lead = typeof leads.$inferSelect;
const TRANSLIT: Record<string, string> = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o',
п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
ա: 'a', բ: 'b', գ: 'g', դ: 'd', ե: 'e', զ: 'z', է: 'e', ը: 'y', թ: 't', ժ: 'zh', ի: 'i', լ: 'l', խ: 'kh', ծ: 'ts', կ: 'k', հ: 'h',
ձ: 'dz', ղ: 'gh', ճ: 'ch', մ: 'm', յ: 'y', ն: 'n', շ: 'sh', ո: 'o', չ: 'ch', պ: 'p', ջ: 'j', ռ: 'r', ս: 's', վ: 'v', տ: 't', ր: 'r',
ց: 'ts', ւ: 'v', փ: 'p', ք: 'k', օ: 'o', ֆ: 'f', և: 'ev',
};
/** Адреса, занятые разделами кабинета: компании их взять не могут. */
const RESERVED = new Set([
'api', 'assets', 'login', 'register', 'forgot-password', 'reset-password', 'verify-email', 'invite', 'leads', 'clients', 'archive',
'analytics', 'filters', 'team', 'billing', 'settings', 'admin', 'invoices', 'messages', 'support', 'help', 'listings', 'parser', 'telegram',
]);
/** «Арарат Недвижимость» → «ararat-nedvizhimost». */
export function slugify(name: string): string {
// Армянское «ու» — один звук «у», а не «о» + «в».
return [...name.toLowerCase().replaceAll('ու', 'u')]
.map((ch) => TRANSLIT[ch] ?? ch)
.join('')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
.replace(/-+$/, '');
}
export const validSlug = (slug: string) => /^[a-z0-9][a-z0-9-]{1,39}$/.test(slug) && !RESERVED.has(slug);
/** Свободный адрес для компании: из названия, при совпадении — с номером. */
export async function freeSlug(tx: Tx, name: string, companyId: number): Promise<string> {
const base = slugify(name);
const candidates = validSlug(base) ? [base, `${base}-${companyId}`] : [];
for (const slug of [...candidates, `c${companyId}`]) {
const [taken] = await tx.select({ id: companies.id }).from(companies).where(eq(companies.slug, slug));
if (!taken || taken.id === companyId) return slug;
}
return `c${companyId}`;
}
export async function ensureSlug(tx: Tx, company: Company): Promise<string> {
if (company.slug) return company.slug;
const slug = await freeSlug(tx, company.name, company.id);
await tx.update(companies).set({ slug }).where(eq(companies.id, company.id));
return slug;
}
/** Объекты компании, подходящие под запрос клиента и его жильцов, которые агент вправе видеть. */
export async function matchingLeads(tx: Tx, actor: Actor, client: Client): Promise<{ lead: Lead; assigneeName: string | null }[]> {
const closed = tx
.select({ id: pipelineStatuses.id })
.from(pipelineStatuses)
.where(
and(
eq(pipelineStatuses.companyId, actor.company.id),
eq(pipelineStatuses.pipeline, 'object'),
inArray(pipelineStatuses.code, ['deal', 'rejected']),
),
);
const linked = tx.select({ id: clientLeads.leadId }).from(clientLeads).where(eq(clientLeads.clientId, client.id));
const rows = await tx
.select({ lead: leads, assigneeName: users.name })
.from(leads)
.leftJoin(users, eq(users.id, leads.assigneeId))
.where(
and(
eq(leads.companyId, actor.company.id),
// Подбор — из объектов отдела клиента; снятые с List.am не предлагаются.
inDepartment(leads.departmentId, client.departmentId),
listingLive,
notInArray(leads.statusId, closed),
notInArray(leads.id, linked),
),
)
.orderBy(desc(leads.createdAt))
.limit(1000);
return rows.filter(
({ lead }) =>
canSee(actor, lead) &&
matchesClientRequest(
{ ...lead, dealType: lead.dealType as DealType | null, currency: lead.currency as Currency | null, isAgency: false },
client.kind as ClientKind,
client.request,
) &&
// Объявление, где прямо сказано «с детьми нельзя» или «с животными нельзя», таким жильцам не предлагается.
fitsHousehold(lead.features, client.household),
);
}
/** Прикрепляет к клиенту подходящие объекты отметкой «предложен». Возвращает, сколько прикреплено. */
export async function offerMatching(tx: Tx, actor: Actor, client: Client, limit = 10): Promise<number> {
const picked = (await matchingLeads(tx, actor, client)).slice(0, limit);
await refreshStaleGallery(tx, picked.map(({ lead }) => lead.id));
for (const { lead } of picked) {
await tx.insert(clientLeads).values({ clientId: client.id, leadId: lead.id, state: 'offered' }).onConflictDoNothing();
await tx.insert(events).values([
{ companyId: client.companyId, clientId: client.id, userId: actor.user.id, type: 'offered', newValue: lead.title },
{ companyId: client.companyId, leadId: lead.id, userId: actor.user.id, type: 'client_offered', newValue: client.name },
]);
}
return picked.length;
}
/** Ссылка на страницу клиента: на своём домене компании, если он задан, иначе на адресе сервиса. */
export const pageUrl = (config: Config, company: Pick<Company, 'settings'>, slug: string, token: string) =>
`${company.settings.clientHost ? `https://${company.settings.clientHost}` : config.appUrl}/${slug}/${token}`;
/** Ссылка на страницу подборки клиента: одна на клиента, создаётся при первом запросе. */
export async function ensureClientPage(tx: Tx, config: Config, company: Company, client: Client, createdById: number): Promise<string> {
const slug = await ensureSlug(tx, company);
const [page] = await tx
.insert(clientPages)
.values({ token: newToken().slice(0, 22), companyId: company.id, clientId: client.id, createdById })
.onConflictDoUpdate({ target: clientPages.clientId, set: { revokedAt: null } })
.returning();
return pageUrl(config, company, slug, page!.token);
}
async function openPage(tx: Tx, slug: string, token: string) {
const [row] = await tx
.select({ page: clientPages, company: companies, client: clients, agent: users })
.from(clientPages)
.innerJoin(companies, eq(companies.id, clientPages.companyId))
.innerJoin(clients, eq(clients.id, clientPages.clientId))
.innerJoin(users, eq(users.id, clientPages.createdById))
.where(and(eq(clientPages.token, token), eq(companies.slug, slug), isNull(clientPages.revokedAt)));
if (!row) throw notFound('PAGE_NOT_FOUND');
return row;
}
/** Объекты страницы — те, что прикреплены к клиенту и ещё не закрыты сделкой или отказом. */
async function pageLeads(tx: Tx, companyId: number, clientId: number) {
return tx
.select({ lead: leads, day: viewings.day })
.from(clientLeads)
.innerJoin(leads, eq(leads.id, clientLeads.leadId))
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.leftJoin(viewings, and(eq(viewings.leadId, leads.id), eq(viewings.clientId, clientId)))
.where(
and(
eq(clientLeads.clientId, clientId),
eq(leads.companyId, companyId),
sql`${pipelineStatuses.code} is distinct from 'deal' and ${pipelineStatuses.code} is distinct from 'rejected'`,
// Снятый с List.am объект клиенту больше не показывается.
listingLive,
),
)
.orderBy(desc(clientLeads.createdAt));
}
/**
* Что видит клиент. Точного адреса, телефона и ссылки на объявление здесь нет намеренно:
* показ и контакт с собственником идут через агента.
*/
export async function loadPage(db: Db, slug: string, token: string) {
const { company, client, agent } = await openPage(db, slug, token);
const rows = await pageLeads(db, company.id, client.id);
const photoBase = `/api/public/pages/${slug}/${token}/photos`;
return {
company: company.name,
agent: agent.name,
client: client.name,
objects: rows.map(({ lead: l, day }) => ({
id: l.id,
// Названия объявления здесь нет: на List.am в нём обычно стоит улица.
/** Текст объекта по шаблону; если его дополнила модель — сохранённый вариант. */
text: l.showcaseText ?? renderShowcaseText(l),
dealType: l.dealType,
propertyType: l.propertyType,
region: l.region,
district: l.district,
price: l.price,
currency: l.currency,
rooms: l.rooms,
area: l.area,
floor: l.floor,
floorsTotal: l.floorsTotal,
/** Фото отдаёт сам сервис — уже обрезанными; у объекта, заведённого вручную, фото остаются как есть. */
photos: l.photos.slice(0, MAX_PHOTOS).map((src, i) => (isListAmPhoto(src) ? `${photoBase}/${l.id}/${i}` : src)),
/** День, на который клиент уже записался на показ этого объекта. */
viewingDay: day,
})),
};
}
/** Обрезанное фото объекта со страницы клиента. */
export async function pagePhoto(db: Db, config: Config, slug: string, token: string, leadId: number, index: number, fetcher?: ImageFetcher) {
const { company, client } = await openPage(db, slug, token);
const row = (await pageLeads(db, company.id, client.id)).find((r) => r.lead.id === leadId);
const url = row?.lead.photos[index];
if (!url || index >= MAX_PHOTOS || !isListAmPhoto(url)) throw notFound();
// Фото могли убрать с сайта вместе с объявлением — для клиента это просто «нет фото».
return croppedPhoto(config, url, fetcher).catch(() => {
throw notFound();
});
}
/**
* Готовит объекты к показу клиенту: загружает полный набор фото объявления (в базе парсера только
* обложка) и собирает текст.
*/
export async function runShowcaseJobs({ db, config, source, limit = 3 }: { db: Db; config: Config; source: { gallery?(externalId: string): Promise<{ photos: string[]; features?: ListingFeatures; description?: string; removed?: boolean }>; configure?(apiKey: string | null | undefined): void } | null; limit?: number }) {
// В очереди: объекты на открытых страницах клиентов и те, чью карточку открывал агент.
const rows = await db
.select({ lead: leads, externalId: listings.externalId })
.from(leads)
.leftJoin(listings, eq(listings.id, leads.listingId))
.where(
and(
isNull(leads.galleryAt),
sql`${leads.galleryTries} < 3`,
sql`(${leads.galleryWantedAt} is not null or exists (
select 1 from client_leads cl join client_pages p on p.client_id = cl.client_id
where cl.lead_id = "leads"."id" and p.revoked_at is null))`,
),
)
.orderBy(sql`${leads.galleryWantedAt} desc nulls last`, leads.id)
.limit(limit);
if (!rows.length) return 0;
// Ключ ридера берётся из настроек парсера — как при обычном проходе.
const settings = await loadParserSettings(db);
source?.configure?.(settings.useKey ? settings.jinaApiKey || undefined : null);
let done = 0;
for (const { lead, externalId } of rows) {
try {
// У объекта, заведённого вручную, объявления нет: готовим только текст.
const page: { photos: string[]; features?: ListingFeatures; description?: string; removed?: boolean } =
externalId && source?.gallery ? await source.gallery(externalId) : { photos: [] };
const photos = page.photos.length ? page.photos.slice(0, MAX_PHOTOS) : lead.photos;
const features = lead.features ?? page.features ?? null;
const description = lead.description?.trim() ? lead.description : (page.description ?? lead.description);
const text = await showcaseText(config, { ...lead, features, description });
await db.update(leads).set({ photos, features, description, showcaseText: text, galleryAt: new Date() }).where(eq(leads.id, lead.id));
if (page.photos.length && lead.listingId) {
await db.update(listings).set({ photos, ...(page.features ? { features: page.features } : {}) }).where(eq(listings.id, lead.listingId));
}
if (page.removed && lead.listingId) {
// Объявление сняли — отметка «Снято с List.am» ставится сразу, не дожидаясь суточной проверки.
const now = new Date();
await db.update(listings).set({ active: false, removedAt: now, lastCheckedAt: now }).where(and(eq(listings.id, lead.listingId), eq(listings.active, true)));
}
if (page.description && lead.listingId) {
await db
.update(listings)
.set({ description: page.description })
.where(and(eq(listings.id, lead.listingId), sql`coalesce(trim(${listings.description}), '') = ''`));
}
done += 1;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[showcase] объект ${lead.id}: ${message}`);
await db.update(leads).set({ galleryTries: sql`${leads.galleryTries} + 1` }).where(eq(leads.id, lead.id));
if (/Ридер ответил 40[123] /.test(message)) await alertReaderKey(db, message);
// Фото не загрузились — текст от модели всё равно нужен клиенту; фото догрузятся при следующей попытке.
if (!lead.showcaseText) {
const text = await showcaseText(config, lead);
if (text !== renderShowcaseText(lead)) await db.update(leads).set({ showcaseText: text }).where(eq(leads.id, lead.id));
}
}
}
return done;
}
/**
* Объект добавили на страницу клиента: если фото загружались больше суток назад, они загружаются
* заново — владелец мог их заменить. До новой загрузки клиент видит прежние фото и текст.
*/
export async function refreshStaleGallery(tx: Tx, leadIds: number[], now = new Date()) {
if (!leadIds.length) return;
await tx
.update(leads)
.set({ galleryAt: null, galleryTries: 0, galleryWantedAt: now })
.where(and(inArray(leads.id, leadIds), sql`${leads.listingId} is not null`, sql`${leads.galleryAt} < ${new Date(now.getTime() - 86_400_000).toISOString()}::timestamptz`));
}
/** Когда суперадмины последний раз получили алерт об отклонённом ключе ридера. */
let readerKeyAlertAt = 0;
/** Ключ ридера отклонён (кончился баланс или ключ отозван): фото для клиентов не загружаются. Алерт не чаще раза в 6 часов. */
async function alertReaderKey(db: Db, error: string) {
if (Date.now() - readerKeyAlertAt < 6 * 3_600_000) return;
readerKeyAlertAt = Date.now();
const admins = await db.select({ id: users.id }).from(users).where(eq(users.role, 'superadmin'));
await enqueueText(db, admins.map((a) => a.id), 'parserFailed', [`ключ ридера Jina отклонён — фото для клиентов не загружаются. Проверьте баланс и ключ в настройках парсера. (${error.slice(0, 120)})`]);
}
const ymd = (d: Date) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10);
/**
* Клиент записался на показ. Создателю ссылки уходит уведомление в бот, запись появляется в карточках
* клиента и объекта; если у клиента не стояло напоминание — оно ставится на утро дня показа.
*/
export async function bookViewing(db: Db, slug: string, token: string, input: { leadId: number; day: string; note?: string }, now = new Date()) {
const today = ymd(now);
const last = ymd(new Date(now.getTime() + 90 * 86_400_000));
if (input.day < today || input.day > last) throw badRequest('BAD_DAY');
return db.transaction(async (tx) => {
const { page, company, client } = await openPage(tx, slug, token);
const row = (await pageLeads(tx, company.id, client.id)).find((r) => r.lead.id === input.leadId);
if (!row) throw notFound('OBJECT_NOT_FOUND');
const changed = row.day !== input.day;
await tx
.insert(viewings)
.values({ companyId: company.id, clientId: client.id, leadId: input.leadId, day: input.day, note: input.note ?? null })
.onConflictDoUpdate({ target: [viewings.clientId, viewings.leadId], set: { day: input.day, note: input.note ?? null } });
if (!changed) return { day: input.day };
await tx.insert(events).values([
{ companyId: company.id, clientId: client.id, userId: null, type: 'viewing', newValue: row.lead.title, text: input.day },
{ companyId: company.id, leadId: input.leadId, userId: null, type: 'client_viewing', newValue: client.name, text: input.day },
]);
if (!client.nextActionAt || client.nextActionAt < now) {
await tx
.update(clients)
.set({ nextActionAt: new Date(`${input.day}T09:00:00+04:00`), nextActionNote: row.lead.title.slice(0, 200), nextActionNotifiedAt: null })
.where(eq(clients.id, client.id));
}
await tx.update(companies).set({ lastActivityAt: now }).where(eq(companies.id, company.id));
// Уведомление — тому, кто создал ссылку; если его уже нет в компании, ответственному за клиента.
const [creator] = await tx.select().from(users).where(and(eq(users.id, page.createdById), isNull(users.removedAt)));
await tx.insert(outbox).values({
kind: 'text',
userId: creator?.id ?? client.assigneeId,
leadId: input.leadId,
payload: { key: 'viewingBooked', args: [client.name, row.lead.title, input.day, input.note ?? ''] },
});
return { day: input.day };
});
}
/** Записи на показ в карточке клиента. */
export const clientViewings = (tx: Tx, clientId: number) =>
tx
.select({ leadId: viewings.leadId, day: viewings.day, note: viewings.note, title: leads.title })
.from(viewings)
.innerJoin(leads, eq(leads.id, viewings.leadId))
.where(eq(viewings.clientId, clientId))
.orderBy(viewings.day);
+129
View File
@@ -0,0 +1,129 @@
import type { ListingFeatures } from '@lr/shared';
import type { Config } from '../lib/config.ts';
// Текст объекта для страницы клиента. Логика перенесена из бота list-am-bot (template/render.py и
// llm/openrouter_client.py): карточка по фиксированному шаблону; если задан ключ OpenRouter — тот же
// шаблон заполняет модель и добавляет абзац из описания, переписанный нейтральным тоном.
// Отличие от бота: адреса в тексте нет — показ и контакт с собственником идут через агента.
export interface ShowcaseSource {
title: string;
description: string | null;
dealType: string | null;
district: string | null;
region: string | null;
price: number | null;
currency: string | null;
rooms: number | null;
area: number | null;
floor: number | null;
floorsTotal: number | null;
features: ListingFeatures | null;
}
/** Приоритетные удобства — первыми в списке оснащения. */
const AMENITY_PRIORITY = ['кондиционер', 'стиральная машина', 'посудомоечная машина', 'интернет', 'телевизор', 'холодильник', 'лифт', 'домофон', 'консьерж'];
const CURRENCY: Record<string, string> = { AMD: 'драм', USD: '$', EUR: '€', RUB: '₽' };
/** 600000 AMD → «600.000 драм»; 3000 USD → «$3.000». */
export function formatPrice(price: number | null, currency: string | null): string | null {
if (price == null) return null;
const amount = Math.round(price).toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.');
const cur = CURRENCY[currency ?? ''] ?? currency ?? '';
return cur === '$' || cur === '€' ? `${cur}${amount}` : `${amount} ${cur}`.trim();
}
const attr = (f: ListingFeatures | null, label: string) => f?.attrs.find((a) => a.label === label)?.value;
function amenities(f: ListingFeatures | null): string[] {
// Виды из окон и парковка — не оснащение квартиры.
const items = (f?.amenities ?? []).filter((g) => !/вид|парковк/i.test(g.group)).flatMap((g) => g.items);
const rank = (name: string) => {
const i = AMENITY_PRIORITY.findIndex((key) => name.toLowerCase().includes(key));
return i === -1 ? AMENITY_PRIORITY.length : i;
};
return [...new Set(items)].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b, 'ru')).slice(0, 12);
}
/** Карточка по шаблону. Полей, которых нет в объявлении, в тексте тоже нет. */
export function renderShowcaseText(l: ShowcaseSource): string {
const f = l.features;
const lines: string[] = [`🏡 ${l.district ?? l.region ?? l.title}`, ''];
const building = [/^(да|есть)$/i.test(attr(f, 'Новостройка') ?? '') ? 'Новостройка' : null, attr(f, 'Тип здания')].filter(Boolean).join(', ');
const condition = attr(f, 'Ремонт');
if (building) lines.push(`🏢 Тип дома: ${building}`);
if (condition) lines.push(`🆕 Состояние: ${condition}`);
if (building || condition) lines.push('');
const specs = [
l.area != null ? `📐 Площадь: ${l.area} м²` : null,
l.rooms != null ? `🛏 Комнат: ${l.rooms}` : null,
attr(f, 'Количество санузлов') ? `🚿 Санузлов: ${attr(f, 'Количество санузлов')}` : null,
l.floor != null ? `🏙 Этаж: ${l.floor}${l.floorsTotal ? `/${l.floorsTotal}` : ''}` : null,
].filter((x): x is string => Boolean(x));
if (specs.length) lines.push(...specs, '');
const equipment = amenities(f);
if (equipment.length) lines.push('⚙️ Оснащение:', ...equipment.map((item) => `✅ ${item}`), '');
const children = attr(f, 'Можно с детьми');
const pets = attr(f, 'Можно с животными');
if (children) lines.push(`🧑‍🧑‍🧒 Дети: ${children}`);
if (pets) lines.push(`🐶 Животные: ${pets}`);
if (children || pets) lines.push('');
const price = formatPrice(l.price, l.currency);
if (price) lines.push(`💵 Цена: ${price}`, '');
const availability = attr(f, 'Статус доступности');
if (availability) lines.push(`📅 ${/^свободно сейчас$/i.test(availability) ? 'Готова к заселению' : availability}`);
while (lines.at(-1) === '') lines.pop();
return lines.join('\n');
}
const SYSTEM_PROMPT = `Ты — редактор объявлений о недвижимости в Армении.
Тебе дают готовую карточку объекта и исходное описание с сайта объявлений.
Верни карточку БЕЗ ИЗМЕНЕНИЙ и, если в описании есть 1–2 предложения полезной информации
(вид, инфраструктура, особенности), добавь после карточки пустую строку и короткий абзац,
переписанный нейтрально-деловым тоном на русском языке.
Правила:
1. Карточку не меняй: ни строки, ни цифры, ни значки.
2. Ничего не выдумывай. Не повторяй то, что уже есть в карточке.
3. Не указывай адрес, улицу, номер дома, телефоны, имена, ссылки и название сайта.
4. Если в описании нет ничего полезного — верни только карточку.
5. Ответ — только готовый текст, без пояснений и без Markdown.`;
/** Тот же текст, дополненный абзацем из описания. Бросает ошибку, если модель недоступна. */
async function rewrite(config: Config, card: string, description: string, fetchFn: typeof fetch): Promise<string> {
const res = await fetchFn('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: { authorization: `Bearer ${config.openRouterKey}`, 'content-type': 'application/json', 'http-referer': config.appUrl, 'x-title': 'Y&P Lead Radar' },
body: JSON.stringify({
model: config.openRouterModel,
temperature: 0.3,
max_tokens: 700,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `Карточка:\n${card}\n\nОписание с сайта:\n${description.slice(0, 3000)}` },
],
}),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`OpenRouter ответил ${res.status}`);
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const text = data.choices?.[0]?.message?.content?.trim();
// Модель обязана сохранить карточку: если шапки нет, ответу не доверяем.
if (!text || !text.startsWith(card.split('\n')[0]!)) throw new Error('модель вернула не карточку');
return text.slice(0, 4000);
}
/** Текст для страницы клиента: с ключом OpenRouter — с абзацем от модели, иначе (и при сбое) — по шаблону. */
export async function showcaseText(config: Config, lead: ShowcaseSource, fetchFn: typeof fetch = fetch): Promise<string> {
const card = renderShowcaseText(lead);
if (!config.openRouterKey || !lead.description?.trim()) return card;
return rewrite(config, card, lead.description, fetchFn).catch(() => card);
}
+213
View File
@@ -0,0 +1,213 @@
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashPassword } from './auth/password.ts';
import { createSession } from './auth/sessions.ts';
import { users } from './db/schema.ts';
import { handleUpdate } from './telegram/bot.ts';
import { processOutbox } from './telegram/outbox.ts';
import { fakeTelegram, testApp } from './testing.ts';
// Поддержка: сообщение боту → обращение → переписка из админки → «Проблема решена».
let t: Awaited<ReturnType<typeof testApp>>;
beforeAll(async () => { t = await testApp('support'); });
afterAll(() => t.close());
const tg = fakeTelegram();
const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); };
let updateId = 0;
const write = async (chat: number, message: Record<string, unknown>, name = 'Гость') => {
await handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, {
update_id: ++updateId,
message: { chat: { id: chat }, from: { id: chat, first_name: name, username: `u${chat}`, language_code: 'ru' }, ...message },
});
await flush();
};
const texts = (chat: number) => tg.inbox(chat).map((m) => m.text);
const ADMIN_CHAT = 7001;
let admin: ReturnType<typeof t.client> | null = null;
/** Суперадмин с привязанным Telegram — один на все сценарии. */
const superadmin = async () => {
if (admin) return admin;
const [u] = await t.db
.insert(users)
.values({ name: 'Админ', role: 'superadmin', email: 'sa@test.am', passwordHash: await hashPassword('x'), telegramId: ADMIN_CHAT })
.returning();
return (admin = t.client(await createSession(t.db, u!.id)));
};
describe('поддержка', () => {
it('сообщение боту открывает обращение; переписка идёт до «Проблема решена»', async () => {
const sa = await superadmin();
// Пользователь с аккаунтом и привязанным Telegram.
const member = await t.register('Арам');
const me = (await member.get('/api/me')).body.user;
await t.db.update(users).set({ telegramId: 7100 }).where(eq(users.id, me.id));
await write(7100, { text: 'Не приходят объекты' }, 'Арам');
expect(texts(7100)).toEqual(['Сообщение передано в поддержку. Ответим здесь, в этом чате.']);
// Суперадмин узнаёт в боте о новом обращении.
expect(texts(ADMIN_CHAT).at(-1)).toContain('Обращение в поддержку от Арам @u7100');
expect((await sa.get('/api/admin/support/unread')).body.count).toBe(1);
// Второе сообщение идёт в то же обращение, без повторных подтверждений.
await write(7100, { text: 'Фильтр создан вчера' }, 'Арам');
expect(texts(7100)).toHaveLength(1);
expect(texts(ADMIN_CHAT).filter((x) => x.includes('Обращение в поддержку'))).toHaveLength(1);
const list = (await sa.get('/api/admin/support')).body;
expect(list).toMatchObject([{ name: 'Арам', hasAccount: true, unread: true, last: 'Фильтр создан вчера', status: 'open' }]);
const id = list[0].id as number;
const card = (await sa.get(`/api/admin/support/${id}`)).body;
expect(card.messages.map((m: any) => [m.fromSupport, m.text])).toEqual([[false, 'Не приходят объекты'], [false, 'Фильтр создан вчера']]);
// Открытие отмечает обращение прочитанным.
expect((await sa.get('/api/admin/support/unread')).body.count).toBe(0);
expect((await sa.post(`/api/admin/support/${id}/reply`, { text: 'Проверьте район в <фильтре>' })).status).toBe(200);
await flush();
expect(texts(7100).at(-1)).toBe('💬 <b>Поддержка</b>\nПроверьте район в &lt;фильтре&gt;');
// Ответ пользователя снова поднимает обращение и уведомляет суперадмина.
await write(7100, { text: 'Спасибо, заработало' }, 'Арам');
expect((await sa.get('/api/admin/support/unread')).body.count).toBe(1);
expect(texts(ADMIN_CHAT).filter((x) => x.includes('Обращение в поддержку'))).toHaveLength(2);
expect((await sa.post(`/api/admin/support/${id}/close`)).status).toBe(200);
await flush();
expect(texts(7100).at(-1)).toContain('Обращение закрыто');
expect((await sa.get('/api/admin/support')).body).toHaveLength(0);
expect((await sa.get('/api/admin/support?status=closed')).body).toMatchObject([{ id, status: 'closed' }]);
expect((await sa.post(`/api/admin/support/${id}/reply`, { text: 'ещё' })).body.code).toBe('TICKET_CLOSED');
// Новое сообщение после закрытия — новое обращение.
await write(7100, { text: 'Ещё вопрос' }, 'Арам');
const again = (await sa.get('/api/admin/support')).body;
expect(again).toHaveLength(1);
expect(again[0].id).not.toBe(id);
// Раздел только для суперадмина.
expect((await member.get('/api/admin/support')).status).toBe(403);
expect((await member.post(`/api/admin/support/${again[0].id}/reply`, { text: 'x' })).status).toBe(403);
});
it('пишет человек без аккаунта: обращение создаётся, ответ доходит в его чат', async () => {
const sa = await superadmin();
await write(7200, { text: 'Сколько стоит?' }, 'Гость');
expect(texts(7200)).toEqual(['Сообщение передано в поддержку. Ответим здесь, в этом чате.']);
const ticket = (await sa.get('/api/admin/support')).body.find((x: any) => x.tgUsername === 'u7200');
expect(ticket).toMatchObject({ name: 'Гость', hasAccount: false, company: null });
await sa.post(`/api/admin/support/${ticket.id}/reply`, { text: 'Solo — 40 $ в месяц' });
await flush();
expect(texts(7200).at(-1)).toContain('Solo — 40 $ в месяц');
});
it('суперадмин отвечает файлом: он уходит в чат и виден в переписке', async () => {
const sa = await superadmin();
await write(7500, { text: 'Пришлите договор' });
const ticket = (await sa.get('/api/admin/support')).body.find((x: any) => x.tgUsername === 'u7500');
const token = (await t.client().post('/api/auth/login', { email: 'sa@test.am', password: 'x' })).body.token as string;
const upload = (path: string, body: Buffer, auth = token) =>
t.app.inject({ method: 'POST', url: path, payload: body, headers: { authorization: `Bearer ${auth}`, 'content-type': 'application/octet-stream' } });
const url = `/api/admin/support/${ticket.id}/file?name=${encodeURIComponent('договор.pdf')}&type=application/pdf&text=${encodeURIComponent('Вот <договор>')}`;
const res = await upload(url, Buffer.from('PDF-DATA'));
expect(res.statusCode).toBe(200);
expect(t.sentFiles.at(-1)).toEqual({ chatId: 7500, name: 'договор.pdf', type: 'application/pdf', size: 8, caption: '💬 <b>Поддержка</b>\nВот &lt;договор&gt;' });
await upload(`/api/admin/support/${ticket.id}/file?name=shot.png&type=image/png`, Buffer.from('PNG'));
expect(t.sentFiles.at(-1)!.caption).toBe('💬 <b>Поддержка</b>');
const card = (await sa.get(`/api/admin/support/${ticket.id}`)).body;
expect(card.messages.slice(1).map((m: any) => [m.fromSupport, m.text, m.fileKind, m.fileName])).toEqual([
[true, 'Вот <договор>', 'file', 'договор.pdf'],
[true, null, 'photo', 'shot.png'],
]);
// Пустой файл и чужой доступ отклоняются; в закрытое обращение файл не уходит.
expect((await upload(url, Buffer.alloc(0))).statusCode).toBe(400);
expect((await upload(url, Buffer.from('x'), 'wrong-token')).statusCode).toBe(401);
await sa.post(`/api/admin/support/${ticket.id}/close`);
expect((await upload(url, Buffer.from('x'))).json().code).toBe('TICKET_CLOSED');
});
it('фото и файл попадают в обращение; команды обращением не становятся', async () => {
const sa = await superadmin();
await write(7300, { photo: [{ file_id: 'small' }, { file_id: 'big' }], caption: 'Вот экран' });
await write(7300, { document: { file_id: 'doc1', file_name: 'договор.pdf' } });
await write(7300, { text: '/pause' });
await write(7300, { text: '/start' });
const ticket = (await sa.get('/api/admin/support')).body.find((x: any) => x.tgUsername === 'u7300');
const card = (await sa.get(`/api/admin/support/${ticket.id}`)).body;
expect(card.messages.map((m: any) => [m.text, m.fileKind, m.fileName])).toEqual([
['Вот экран', 'photo', null],
[null, 'file', 'договор.pdf'],
]);
// Сообщения в группе, куда добавили бота, игнорируются.
await handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, {
update_id: ++updateId, message: { chat: { id: -500 }, from: { id: 7400, first_name: 'Группа' }, text: 'привет' },
});
expect((await sa.get('/api/admin/support')).body.some((x: any) => x.name === 'Группа')).toBe(false);
});
it('приветствие бота: суперадмин задаёт текст и файлы для /start; регистрация и новая компания приходят ему в Telegram', async () => {
const sa = await superadmin();
// По умолчанию — стандартный текст.
await write(7600, { text: '/start' });
expect(texts(7600).at(-2)).toContain('Y&P Lead Radar');
expect((await sa.get('/api/admin/bot-start')).body).toEqual({ text: { ru: '', hy: '', en: '' }, files: [] });
// Свой текст и два файла: файл сначала приходит суперадмину, у Telegram остаётся его идентификатор.
expect((await sa.patch('/api/admin/bot-start', { text: { ru: 'Здравствуйте! Вот <презентация>', hy: '', en: 'Hello!' } })).status).toBe(200);
const token = (await t.client().post('/api/auth/login', { email: 'sa@test.am', password: 'x' })).body.token as string;
const upload = (name: string, type: string, auth = token) =>
t.app.inject({ method: 'POST', url: `/api/admin/bot-start/files?name=${encodeURIComponent(name)}&type=${type}`, payload: Buffer.from('DATA'), headers: { authorization: `Bearer ${auth}`, 'content-type': 'application/octet-stream' } });
expect((await upload('презентация.pdf', 'application/pdf')).statusCode).toBe(200);
expect(t.sentFiles.at(-1)).toMatchObject({ chatId: ADMIN_CHAT, name: 'презентация.pdf', caption: '/start' });
const saved = (await upload('logo.png', 'image/png')).json();
expect(saved.files.map((f: any) => [f.kind, f.name])).toEqual([['file', 'презентация.pdf'], ['photo', 'logo.png']]);
expect((await upload('x.pdf', 'application/pdf', 'wrong-token')).statusCode).toBe(401);
await write(7601, { text: '/start' });
expect(texts(7601)).toEqual(['Здравствуйте! Вот &lt;презентация&gt;', `[file] ${saved.files[0].fileId}`, `[photo] ${saved.files[1].fileId}`, 'Выберите язык · Ընտրեք լեզուն · Choose a language']);
expect(tg.hasButton(tg.inbox(7601).at(-1)!, 'lang:')).toBe(true);
// Выбрал английский — бот отвечает на нём и запоминает выбор; армянского текста нет — уходит стандартный армянский.
const pick = (chat: number, code: string) =>
handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { update_id: ++updateId, callback_query: { id: `l${updateId}`, from: { id: chat, first_name: 'Гость', language_code: 'ru' }, data: `lang:${code}`, message: { chat: { id: chat }, message_id: 1 } } });
await pick(7601, 'en');
expect(texts(7601).at(-1)).toBe('Hello!');
await write(7601, { text: '/start' });
expect(texts(7601).at(-4)).toBe('Hello!');
await pick(7601, 'hy');
expect(texts(7601).at(-1)).toContain('Lead Radar բոտն է');
await pick(7601, 'xx');
expect(texts(7601).at(-1)).toContain('Lead Radar բոտն է');
// Армянского текста нет — уходит русский; английский — свой.
await handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { update_id: ++updateId, message: { chat: { id: 7602 }, from: { id: 7602, first_name: 'Guest', language_code: 'en' }, text: '/start' } });
expect(texts(7602)[0]).toBe('Hello!');
// Файл убрали, текст очистили — снова стандартное приветствие.
expect((await sa.del('/api/admin/bot-start/files/0')).body.files).toHaveLength(1);
expect((await sa.del('/api/admin/bot-start/files/5')).status).toBe(404);
await sa.del('/api/admin/bot-start/files/0');
await sa.patch('/api/admin/bot-start', { text: { ru: '', hy: '', en: '' } });
await write(7603, { text: '/start' });
expect(texts(7603)).toHaveLength(2);
expect(texts(7603)[0]).toContain('Это бот Y&P Lead Radar');
// Настройки бота — только суперадмину.
const guest = await t.register('Гость');
expect((await guest.get('/api/admin/bot-start')).status).toBe(403);
// Регистрация и создание компании: суперадмин узнаёт, кто это и как с ним связаться.
await flush();
const before = texts(ADMIN_CHAT).length;
const reg = await t.client().post('/api/auth/register', { name: 'Карен <Новый>', email: 'karen@test.am', password: 'password-123' });
expect(reg.status).toBe(200);
const karen = t.client(reg.body.token);
await karen.post('/api/company', { name: 'Агентство Карена', plan: 'team', billingModel: 'fixed' });
await flush();
expect(texts(ADMIN_CHAT).slice(before)).toEqual([
'🆕 Новая регистрация: Карен &lt;Новый&gt;\nkaren@test.am',
'🏢 Новая компания «Агентство Карена», тариф team\nРуководитель: Карен &lt;Новый&gt;\nkaren@test.am',
]);
});
});
+150
View File
@@ -0,0 +1,150 @@
import { and, asc, desc, eq, sql } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { Db } from '../db/client.ts';
import { companies, supportMessages, supportTickets, users } from '../db/schema.ts';
import type { Config } from '../lib/config.ts';
import { badRequest, notFound } from '../lib/errors.ts';
import { closeTicket, replyToTicket, replyWithFile, type FileSender } from './service.ts';
const Id = z.object({ id: z.coerce.number().int().positive() });
/** Раздел «Поддержка» суперадмина: обращения из бота, переписка, «Проблема решена». */
export function registerSupportRoutes(
app: FastifyInstance,
db: Db,
superadmin: (req: FastifyRequest) => { user: typeof users.$inferSelect },
config: Config,
sendFile: FileSender | null,
) {
// Файл приходит телом запроса как есть; Telegram принимает от бота до 50 МБ, здесь предел — 20.
const MAX_FILE = 20 * 1024 * 1024;
const ticketRows = (where: ReturnType<typeof and>) =>
db
.select({
ticket: supportTickets,
userName: users.name,
userRole: users.role,
company: companies.name,
last: sql<string | null>`(select coalesce(m.text, '📎') from support_messages m where m.ticket_id = "support_tickets"."id" order by m.id desc limit 1)`,
lastFromSupport: sql<boolean | null>`(select m.from_support from support_messages m where m.ticket_id = "support_tickets"."id" order by m.id desc limit 1)`,
})
.from(supportTickets)
.leftJoin(users, eq(users.id, supportTickets.userId))
.leftJoin(companies, eq(companies.id, users.companyId))
.where(where)
.orderBy(desc(supportTickets.lastMessageAt))
.limit(100);
type Row = Awaited<ReturnType<typeof ticketRows>>[number];
const view = (r: Row) => ({
id: r.ticket.id,
status: r.ticket.status,
unread: r.ticket.unread,
name: r.userName ?? r.ticket.tgName ?? 'Telegram',
tgUsername: r.ticket.tgUsername,
/** Есть ли у написавшего аккаунт в сервисе. */
hasAccount: r.ticket.userId !== null,
role: r.userRole,
company: r.company,
last: r.last,
lastFromSupport: Boolean(r.lastFromSupport),
lastMessageAt: r.ticket.lastMessageAt,
createdAt: r.ticket.createdAt,
closedAt: r.ticket.closedAt,
});
app.get('/api/admin/support', async (req) => {
superadmin(req);
const { status } = z.object({ status: z.enum(['open', 'closed']).default('open') }).parse(req.query);
return (await ticketRows(and(eq(supportTickets.status, status)))).map(view);
});
/** Счётчик для меню: открытые обращения с непрочитанными сообщениями. */
app.get('/api/admin/support/unread', async (req) => {
superadmin(req);
const [row] = await db
.select({ n: sql<number>`count(*)::int` })
.from(supportTickets)
.where(and(eq(supportTickets.status, 'open'), eq(supportTickets.unread, true)));
return { count: row?.n ?? 0 };
});
/** Переписка по обращению. Открытие отмечает его прочитанным. */
app.get('/api/admin/support/:id', async (req) => {
superadmin(req);
const { id } = Id.parse(req.params);
const [row] = await ticketRows(and(eq(supportTickets.id, id)));
if (!row) throw notFound();
if (row.ticket.unread) await db.update(supportTickets).set({ unread: false }).where(eq(supportTickets.id, id));
const messages = await db
.select({
id: supportMessages.id,
fromSupport: supportMessages.fromSupport,
text: supportMessages.text,
fileKind: supportMessages.fileKind,
fileName: supportMessages.fileName,
at: supportMessages.at,
})
.from(supportMessages)
.where(eq(supportMessages.ticketId, id))
.orderBy(asc(supportMessages.id));
return { ...view(row), unread: false, messages };
});
app.post('/api/admin/support/:id/reply', async (req) => {
const ctx = superadmin(req);
const { id } = Id.parse(req.params);
const { text } = z.object({ text: z.string().trim().min(1).max(3500) }).parse(req.body);
const message = await replyToTicket(db, id, ctx.user.id, text);
return { id: message.id };
});
/** Ответ с файлом: имя, тип и подпись — в адресе, сам файл — в теле запроса. */
app.post('/api/admin/support/:id/file', { bodyLimit: MAX_FILE }, async (req) => {
const ctx = superadmin(req);
const { id } = Id.parse(req.params);
const q = z
.object({
name: z.string().trim().min(1).max(200),
type: z.string().trim().max(100).default('application/octet-stream'),
text: z.string().trim().max(900).optional(),
})
.parse(req.query);
if (!sendFile) throw badRequest('TELEGRAM_DISABLED');
if (!Buffer.isBuffer(req.body) || req.body.length === 0) throw badRequest('VALIDATION');
const message = await replyWithFile(db, sendFile, id, ctx.user.id, { name: q.name, type: q.type, data: req.body }, q.text || null);
return { id: message.id };
});
app.post('/api/admin/support/:id/close', async (req) => {
superadmin(req);
const { id } = Id.parse(req.params);
await closeTicket(db, id);
return { ok: true };
});
/** Вложение из Telegram: файл лежит у Telegram, сервис отдаёт его суперадмину, не раскрывая токен бота. */
app.get('/api/admin/support/messages/:id/file', async (req, reply) => {
superadmin(req);
const { id } = Id.parse(req.params);
const [message] = await db.select().from(supportMessages).where(eq(supportMessages.id, id));
if (!message?.fileId) throw notFound();
if (!config.telegramBotToken) throw badRequest('TELEGRAM_DISABLED');
const base = `https://api.telegram.org`;
const info = (await (
await fetch(`${base}/bot${config.telegramBotToken}/getFile?file_id=${encodeURIComponent(message.fileId)}`)
).json()) as { ok: boolean; result?: { file_path?: string } };
if (!info.ok || !info.result?.file_path) throw notFound();
const file = await fetch(`${base}/file/bot${config.telegramBotToken}/${info.result.file_path}`);
if (!file.ok) throw notFound();
const name = message.fileName ?? info.result.file_path.split('/').pop() ?? 'file';
const type = message.fileKind === 'photo' ? 'image/jpeg' : (file.headers.get('content-type') ?? 'application/octet-stream');
return reply
.header('content-type', type)
.header('cache-control', 'private, max-age=3600')
.header('content-disposition', `${message.fileKind === 'photo' ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(name)}`)
.send(Buffer.from(await file.arrayBuffer()));
});
}
+169
View File
@@ -0,0 +1,169 @@
import { and, eq, isNotNull, isNull } from 'drizzle-orm';
import type { Db, Tx } from '../db/client.ts';
import { outbox, supportMessages, supportTickets, users } from '../db/schema.ts';
import { badRequest, conflict, notFound } from '../lib/errors.ts';
import { enqueueText } from '../telegram/outbox.ts';
import { esc, texts } from '../telegram/texts.ts';
// Поддержка: всё, что человек пишет боту (кроме команд), попадает в обращение. Суперадмин отвечает
// из админки — ответ уходит в тот же чат. Обращение живёт, пока суперадмин не нажмёт «Проблема решена».
type User = typeof users.$inferSelect;
export interface IncomingFile {
id: string;
kind: 'photo' | 'file';
name?: string;
}
export interface Incoming {
chatId: number;
/** Аккаунт, к которому привязан этот Telegram; null — не привязан. */
user: User | null;
tgName: string | null;
tgUsername: string | null;
lang: string;
text: string | null;
file?: IncomingFile;
}
/** Сообщение в чат Telegram по его номеру — для тех, у кого нет аккаунта. */
const enqueueChat = (tx: Tx, chatId: number, userId: number | null, lang: string, key: string, args: unknown[] = []) =>
tx.insert(outbox).values({ kind: 'text', userId, payload: { key, args, chatId, lang } });
/** Сообщение от пользователя: дописывается в открытое обращение или открывает новое. */
export async function receiveSupportMessage(db: Db, m: Incoming): Promise<{ ticketId: number; opened: boolean }> {
return db.transaction(async (tx) => {
const [open] = await tx
.select()
.from(supportTickets)
.where(and(eq(supportTickets.chatId, m.chatId), eq(supportTickets.status, 'open')))
.for('update');
const ticket =
open ??
(
await tx
.insert(supportTickets)
.values({ chatId: m.chatId, userId: m.user?.id ?? null, tgName: m.tgName, tgUsername: m.tgUsername })
.returning()
)[0]!;
await tx.insert(supportMessages).values({
ticketId: ticket.id,
text: m.text,
fileId: m.file?.id ?? null,
fileKind: m.file?.kind ?? null,
fileName: m.file?.name ?? null,
});
await tx
.update(supportTickets)
.set({
unread: true,
lastMessageAt: new Date(),
// Человек мог привязать Telegram уже после первого сообщения.
userId: m.user?.id ?? ticket.userId,
tgName: m.tgName ?? ticket.tgName,
tgUsername: m.tgUsername ?? ticket.tgUsername,
})
.where(eq(supportTickets.id, ticket.id));
if (!open) await enqueueChat(tx, m.chatId, m.user?.id ?? null, m.lang, 'supportOpened');
// Суперадминам в бот — о новом обращении и о первом непрочитанном сообщении, а не о каждом.
if (!open || !open.unread) {
const admins = await tx
.select({ id: users.id })
.from(users)
.where(and(eq(users.role, 'superadmin'), isNull(users.removedAt), isNotNull(users.telegramId)));
const who = [m.user?.name ?? m.tgName ?? 'Telegram', m.tgUsername ? `@${m.tgUsername}` : null].filter(Boolean).join(' ');
const snippet = (m.text ?? '📎').slice(0, 300);
await enqueueText(tx, admins.map((a) => a.id).filter((id) => id !== m.user?.id), 'supportNew', [who, snippet]);
}
return { ticketId: ticket.id, opened: !open };
});
}
const openTicket = async (tx: Tx, id: number) => {
const [ticket] = await tx.select().from(supportTickets).where(eq(supportTickets.id, id)).for('update');
if (!ticket) throw notFound();
if (ticket.status !== 'open') throw conflict('TICKET_CLOSED');
return ticket;
};
const langOf = async (tx: Tx, userId: number | null) =>
userId ? ((await tx.select({ lang: users.lang }).from(users).where(eq(users.id, userId)))[0]?.lang ?? 'ru') : 'ru';
/** Ответ поддержки: сохраняется в обращении и уходит пользователю в бот. */
export async function replyToTicket(db: Db, ticketId: number, authorId: number, text: string) {
return db.transaction(async (tx) => {
const ticket = await openTicket(tx, ticketId);
const [message] = await tx
.insert(supportMessages)
.values({ ticketId, fromSupport: true, authorId, text })
.returning();
await tx.update(supportTickets).set({ unread: false, lastMessageAt: new Date() }).where(eq(supportTickets.id, ticketId));
await enqueueChat(tx, ticket.chatId, ticket.userId, await langOf(tx, ticket.userId), 'supportReply', [text]);
return message!;
});
}
export interface OutgoingFile {
name: string;
type: string;
data: Buffer;
}
/** Отправка файла в чат Telegram. Возвращает идентификатор файла у Telegram — по нему админка показывает вложение. */
export type FileSender = (chatId: number, file: OutgoingFile, captionHtml: string) => Promise<{ fileId: string; kind: 'photo' | 'file' }>;
/** Отправка через Bot API: картинки до 10 МБ уходят как фото, всё остальное — как документ. */
export const telegramFileSender = (token: string): FileSender => async (chatId, file, captionHtml) => {
const asPhoto = /^image\/(jpeg|png|webp)$/.test(file.type) && file.data.length < 10 * 1024 * 1024;
const form = new FormData();
form.set('chat_id', String(chatId));
form.set('caption', captionHtml);
form.set('parse_mode', 'HTML');
form.set(asPhoto ? 'photo' : 'document', new Blob([new Uint8Array(file.data)], { type: file.type }), file.name);
const res = await fetch(`https://api.telegram.org/bot${token}/${asPhoto ? 'sendPhoto' : 'sendDocument'}`, {
method: 'POST',
body: form,
signal: AbortSignal.timeout(120_000),
});
const data = (await res.json()) as {
ok: boolean;
description?: string;
result?: { photo?: { file_id: string }[]; document?: { file_id: string }; animation?: { file_id: string }; video?: { file_id: string } };
};
const r = data.result;
const fileId = r?.photo?.at(-1)?.file_id ?? r?.document?.file_id ?? r?.animation?.file_id ?? r?.video?.file_id;
if (!data.ok || !fileId) throw badRequest('FILE_SEND_FAILED');
return { fileId, kind: r?.photo ? 'photo' : 'file' };
};
/**
* Ответ поддержки с файлом. Файл уходит в Telegram сразу, без очереди: так суперадмин сразу видит,
* дошёл ли он, а большой файл не лежит в базе.
*/
export async function replyWithFile(db: Db, send: FileSender, ticketId: number, authorId: number, file: OutgoingFile, text: string | null) {
const [ticket] = await db.select().from(supportTickets).where(eq(supportTickets.id, ticketId));
if (!ticket) throw notFound();
if (ticket.status !== 'open') throw conflict('TICKET_CLOSED');
const t = texts(await langOf(db, ticket.userId));
const sent = await send(ticket.chatId, file, t.supportReply(esc(text ?? '')).trim());
const [message] = await db
.insert(supportMessages)
.values({ ticketId, fromSupport: true, authorId, text, fileId: sent.fileId, fileKind: sent.kind, fileName: file.name })
.returning();
await db.update(supportTickets).set({ unread: false, lastMessageAt: new Date() }).where(eq(supportTickets.id, ticketId));
return message!;
}
/** «Проблема решена»: обращение закрывается, пользователь получает об этом сообщение. */
export async function closeTicket(db: Db, ticketId: number) {
await db.transaction(async (tx) => {
const ticket = await openTicket(tx, ticketId);
await tx
.update(supportTickets)
.set({ status: 'closed', unread: false, closedAt: new Date() })
.where(eq(supportTickets.id, ticketId));
await enqueueChat(tx, ticket.chatId, ticket.userId, await langOf(tx, ticket.userId), 'supportClosed');
});
}
+188
View File
@@ -0,0 +1,188 @@
import { loadBotStart, startText } from './start.ts';
import { accessState, canWrite, type Role } from '@lr/shared';
import { and, eq } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { botLangs, clients, companies, users } from '../db/schema.ts';
import { intakeLink, seekerChat } from '../intake/service.ts';
import { intakeTexts } from '../intake/texts.ts';
import { ensureClientPage, offerMatching } from '../showcase/service.ts';
import { AppError } from '../lib/errors.ts';
import type { Config } from '../lib/config.ts';
import { takeLead } from '../leads/service.ts';
import { receiveSupportMessage } from '../support/service.ts';
import type { Keyboard, TelegramApi, TgFrom, TgUpdate } from './client.ts';
import { esc, texts } from './texts.ts';
export interface BotDeps {
db: Db;
tg: TelegramApi;
config: Config;
/** Клиентский бот: через него соискателю уходит ссылка на подборку. */
clientTg?: TelegramApi | null;
}
/** Вопрос о языке после приветствия: понятен на всех трёх языках, ответ — кнопкой. */
const LANG_QUESTION = 'Выберите язык · Ընտրեք լեզուն · Choose a language';
const LANG_KEYBOARD: Keyboard = [[
{ text: 'Русский', callback: 'lang:ru' },
{ text: 'Հայերեն', callback: 'lang:hy' },
{ text: 'English', callback: 'lang:en' },
]];
const appKeyboard = (config: Config, lang: string): Keyboard | undefined =>
config.appUrl.startsWith('https://') ? [[{ text: texts(lang).openApp, webApp: config.appUrl }]] : undefined;
/** Обрабатывает одно обновление от Telegram: команды и нажатие «Беру». */
export async function handleUpdate({ db, tg, config, clientTg }: BotDeps, update: TgUpdate): Promise<void> {
const from: TgFrom | undefined = update.message?.from ?? update.callback_query?.from;
if (!from) return;
const [user] = await db.select().from(users).where(eq(users.telegramId, from.id));
// Незарегистрированному бот отвечает на языке, который тот выбрал кнопкой; иначе — на языке его Telegram.
const [chosen] = user ? [] : await db.select().from(botLangs).where(eq(botLangs.telegramId, from.id));
const lang = user?.lang ?? chosen?.lang ?? (from.language_code === 'hy' ? 'hy' : from.language_code === 'en' ? 'en' : 'ru');
const t = texts(lang);
if (update.callback_query) {
const cb = update.callback_query;
// Выбор языка после приветствия: запоминается и сразу применяется.
const picked = /^lang:(ru|hy|en)$/.exec(cb.data ?? '')?.[1];
if (picked) {
if (user) await db.update(users).set({ lang: picked }).where(eq(users.id, user.id));
else await db.insert(botLangs).values({ telegramId: from.id, lang: picked }).onConflictDoUpdate({ target: botLangs.telegramId, set: { lang: picked } });
await tg.answerCallback(cb.id);
const chat = cb.message?.chat.id ?? from.id;
const own = user && !user.removedAt ? null : startText(await loadBotStart(db), picked, false);
const reply = user && !user.removedAt ? texts(picked).hello(esc(user.name)) : own ? esc(own) : texts(picked).start;
return void (await tg.sendMessage(chat, reply, appKeyboard(config, picked)));
}
// Ответ на анкету клиента: показать ему подходящие объекты или нет.
const match = /^(match|nomatch):(\d+)$/.exec(cb.data ?? '');
if (match) {
if (!user || user.removedAt || !user.companyId) return tg.answerCallback(cb.id, t.unknownUser, true);
await tg.answerCallback(cb.id);
const chat = cb.message?.chat.id ?? from.id;
if (match[1] === 'nomatch') return void (await tg.sendMessage(chat, t.intakeOk));
const [company] = await db.select().from(companies).where(eq(companies.id, user.companyId));
if (!company || !canWrite(accessState(company))) return void (await tg.sendMessage(chat, t.readOnly));
const actor = { user, role: user.role as Role, company };
const [client] = await db.select().from(clients).where(and(eq(clients.id, Number(match[2])), eq(clients.companyId, company.id)));
if (!client || (user.role !== 'manager' && client.assigneeId !== user.id)) return void (await tg.sendMessage(chat, t.unavailable));
const { count, url } = await db.transaction(async (tx) => ({
count: await offerMatching(tx, actor, client),
url: await ensureClientPage(tx, config, company, client, user.id),
}));
if (!count) return void (await tg.sendMessage(chat, t.matchNone));
// Ссылку клиенту отправляет клиентский бот — в тот чат, где он заполнял анкету.
const seeker = clientTg ? await seekerChat(db, client.id) : null;
let delivered = false;
if (seeker && clientTg) {
delivered = await clientTg
.sendMessage(seeker.chatId, esc(intakeTexts(seeker.lang).selection(count, user.name, `${url}?lang=${seeker.lang}`)))
.then(() => true, () => false);
}
return void (await tg.sendMessage(chat, esc(t.matchSent(count, url, delivered))));
}
const leadId = Number(cb.data?.match(/^take:(\d+)$/)?.[1]);
if (!Number.isInteger(leadId)) return tg.answerCallback(cb.id);
if (!user || user.removedAt || !user.companyId) return tg.answerCallback(cb.id, t.unknownUser, true);
const [company] = await db.select().from(companies).where(eq(companies.id, user.companyId));
if (!company || !canWrite(accessState(company))) return tg.answerCallback(cb.id, t.readOnly, true);
try {
await takeLead(db, { user, role: user.role as Role, company }, leadId);
return await tg.answerCallback(cb.id, t.taken);
} catch (e) {
if (e instanceof AppError) {
return tg.answerCallback(cb.id, e.code === 'ALREADY_TAKEN' ? t.alreadyTaken : t.unavailable, true);
}
throw e;
}
}
const msg = update.message!;
const chatId = msg.chat.id;
// Бот работает в личных чатах: в группах он молчит.
if (chatId !== from.id) return;
const text = msg.text?.trim() ?? '';
// Всё, что не команда, — обращение в поддержку: текст, фото, файл.
const file = msg.photo?.length
? { id: msg.photo.at(-1)!.file_id, kind: 'photo' as const }
: msg.document
? { id: msg.document.file_id, kind: 'file' as const, name: msg.document.file_name }
: undefined;
const body = text || msg.caption?.trim() || '';
if (!body.startsWith('/') && (body || file)) {
await receiveSupportMessage(db, {
chatId,
user: user && !user.removedAt ? user : null,
tgName: [from.first_name, from.last_name].filter(Boolean).join(' ') || null,
tgUsername: from.username ?? null,
lang,
text: body || null,
file,
});
return;
}
const [command, arg] = text.split(/\s+/, 2);
if (command === '/start' && arg) {
// Привязка Telegram к аккаунту по одноразовому коду из настроек кабинета.
const [target] = await db.select().from(users).where(eq(users.telegramLinkToken, arg));
if (!target) return void (await tg.sendMessage(chatId, t.linkBad));
if (user && user.id !== target.id) return void (await tg.sendMessage(chatId, t.linkUsed));
await db
.update(users)
.set({ telegramId: from.id, telegramUsername: from.username ?? null, telegramLinkToken: null })
.where(eq(users.id, target.id));
return void (await tg.sendMessage(chatId, texts(target.lang).linked(esc(target.name)), appKeyboard(config, target.lang)));
}
if (!user || user.removedAt) {
// Приветствие для тех, кто ещё не в кабинете: текст и файлы задаёт суперадмин.
const custom = await loadBotStart(db);
const own = startText(custom, lang);
await tg.sendMessage(chatId, own ? esc(own) : t.start, appKeyboard(config, lang));
for (const f of custom.files) await tg.sendFileById(chatId, f.fileId, f.kind).catch((e) => console.error('[start-file]', e));
await tg.sendMessage(chatId, LANG_QUESTION, LANG_KEYBOARD);
return;
}
if (command === '/anketa') {
const url = await intakeLink(db, config, user);
return void (await tg.sendMessage(chatId, url ? esc(t.anketa(url)) : t.anketaOff));
}
if (command === '/pause' || command === '/resume') {
await db.update(users).set({ notificationsPaused: command === '/pause' }).where(eq(users.id, user.id));
return void (await tg.sendMessage(chatId, command === '/pause' ? t.paused : t.resumed));
}
await tg.sendMessage(chatId, t.hello(esc(user.name)), appKeyboard(config, lang));
}
/** Длинный опрос Bot API. Возвращает функцию остановки. */
export function startPolling(
deps: BotDeps | { tg: TelegramApi },
onError: (e: unknown) => void = console.error,
/** Обработчик обновлений: у основного и клиентского бота он свой. */
handle: (update: TgUpdate) => Promise<void> = (update) => handleUpdate(deps as BotDeps, update),
): () => void {
let stopped = false;
void (async () => {
let offset = 0;
while (!stopped) {
try {
const updates = await deps.tg.getUpdates(offset, 25);
for (const update of updates) {
offset = update.update_id + 1;
await handle(update).catch(onError);
}
} catch (e) {
if (stopped) break;
onError(e);
await new Promise((r) => setTimeout(r, 3000));
}
}
})();
return () => {
stopped = true;
};
}
+153
View File
@@ -0,0 +1,153 @@
/** Кнопка под сообщением: callback — нажатие обрабатывает бот; url — ссылка; webApp — открыть Mini App. */
export type Button = { text: string } & ({ callback: string } | { url: string } | { webApp: string });
export type Keyboard = Button[][];
export interface SentMessage {
chatId: number;
messageId: number;
}
export class TelegramError extends Error {
constructor(
readonly code: number,
description: string,
/** Сколько секунд Telegram просит подождать (ошибка 429). */
readonly retryAfter?: number,
) {
super(description);
}
/** Повторять бессмысленно: бот заблокирован, чат не найден, неверный запрос. */
get permanent() {
return this.code === 400 || this.code === 403;
}
}
export interface TgUpdate {
update_id: number;
message?: {
chat: { id: number };
from?: TgFrom;
text?: string;
/** Подпись к фото или файлу. */
caption?: string;
/** Фото в нескольких размерах, последнее — самое большое. */
photo?: { file_id: string }[];
document?: { file_id: string; file_name?: string };
/** Контакт, отправленный кнопкой «Поделиться контактом». */
contact?: { phone_number: string; first_name?: string };
};
callback_query?: { id: string; from: TgFrom; data?: string; message?: { chat: { id: number }; message_id: number } };
}
export interface TgFrom {
id: number;
first_name?: string;
last_name?: string;
username?: string;
language_code?: string;
}
/** То, что сервису нужно от Bot API. В тестах подменяется записывающей заглушкой. */
export interface TelegramApi {
sendMessage(chatId: number, text: string, keyboard?: Keyboard): Promise<SentMessage>;
sendPhoto(chatId: number, photoUrl: string, caption: string, keyboard?: Keyboard): Promise<SentMessage>;
/** Файл, который уже лежит у Telegram, — по его идентификатору. */
sendFileById(chatId: number, fileId: string, kind: 'photo' | 'file'): Promise<void>;
/** Меняет текст (или подпись к фото) и кнопки уже отправленного сообщения. */
editMessage(msg: SentMessage, hasPhoto: boolean, text: string, keyboard?: Keyboard): Promise<void>;
answerCallback(callbackId: string, text?: string, alert?: boolean): Promise<void>;
getUpdates(offset: number, timeoutSec: number): Promise<TgUpdate[]>;
}
const markup = (keyboard?: Keyboard) => ({
inline_keyboard: (keyboard ?? []).map((row) =>
row.map((b) =>
'callback' in b
? { text: b.text, callback_data: b.callback }
: 'url' in b
? { text: b.text, url: b.url }
: { text: b.text, web_app: { url: b.webApp } },
),
),
});
export class HttpTelegram implements TelegramApi {
constructor(
private readonly token: string,
private readonly fetchFn: typeof fetch = fetch,
) {}
private async call<T>(method: string, body: Record<string, unknown>, timeoutMs = 20_000): Promise<T> {
const res = await this.fetchFn(`https://api.telegram.org/bot${this.token}/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const data = (await res.json()) as {
ok: boolean;
result?: T;
error_code?: number;
description?: string;
parameters?: { retry_after?: number };
};
if (!data.ok) {
throw new TelegramError(data.error_code ?? res.status, data.description ?? method, data.parameters?.retry_after);
}
return data.result as T;
}
async sendMessage(chatId: number, text: string, keyboard?: Keyboard) {
const m = await this.call<{ message_id: number }>('sendMessage', {
chat_id: chatId,
text,
parse_mode: 'HTML',
link_preview_options: { is_disabled: true },
reply_markup: markup(keyboard),
});
return { chatId, messageId: m.message_id };
}
async sendPhoto(chatId: number, photoUrl: string, caption: string, keyboard?: Keyboard) {
const m = await this.call<{ message_id: number }>('sendPhoto', {
chat_id: chatId,
photo: photoUrl,
caption,
parse_mode: 'HTML',
reply_markup: markup(keyboard),
});
return { chatId, messageId: m.message_id };
}
async sendFileById(chatId: number, fileId: string, kind: 'photo' | 'file') {
await this.call(kind === 'photo' ? 'sendPhoto' : 'sendDocument', { chat_id: chatId, [kind === 'photo' ? 'photo' : 'document']: fileId });
}
async editMessage(msg: SentMessage, hasPhoto: boolean, text: string, keyboard?: Keyboard) {
const base = { chat_id: msg.chatId, message_id: msg.messageId, parse_mode: 'HTML', reply_markup: markup(keyboard) };
try {
if (hasPhoto) await this.call('editMessageCaption', { ...base, caption: text });
else await this.call('editMessageText', { ...base, text, link_preview_options: { is_disabled: true } });
} catch (e) {
// Текст не изменился — для нас это успех.
if (e instanceof TelegramError && /not modified/i.test(e.message)) return;
throw e;
}
}
async answerCallback(callbackId: string, text?: string, alert = false) {
await this.call('answerCallbackQuery', { callback_query_id: callbackId, text, show_alert: alert });
}
/** Кнопка меню бота открывает Mini App. Вызывается при старте, когда кабинет доступен по https. */
async setMenuButton(text: string, url: string) {
await this.call('setChatMenuButton', { menu_button: { type: 'web_app', text, web_app: { url } } });
}
getUpdates(offset: number, timeoutSec: number) {
return this.call<TgUpdate[]>(
'getUpdates',
{ offset, timeout: timeoutSec, allowed_updates: ['message', 'callback_query'] },
(timeoutSec + 10) * 1000,
);
}
}
+207
View File
@@ -0,0 +1,207 @@
import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
import type { Db, Tx } from '../db/client.ts';
import { leadNotifications, leads, outbox, pipelineStatuses, users } from '../db/schema.ts';
import type { Config } from '../lib/config.ts';
import { TelegramError, type Keyboard, type TelegramApi } from './client.ts';
import { esc, leadMessage, texts } from './texts.ts';
// Очередь сообщений Telegram (ТЗ: «очередь для уведомлений», лимиты Bot API, повтор при ошибке).
// Сообщение сначала записывается в таблицу outbox в той же транзакции, что и изменение лида,
// поэтому не теряется при сбое; воркер отправляет его отдельно.
type User = typeof users.$inferSelect;
/** Сейчас у пользователя тихие часы (время Еревана, UTC+4). */
export function inQuietHours(user: Pick<User, 'quietFrom' | 'quietTo'>, now = new Date()): boolean {
if (!user.quietFrom || !user.quietTo || user.quietFrom === user.quietTo) return false;
const d = new Date(now.getTime() + 4 * 3_600_000);
const hm = `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
return user.quietFrom < user.quietTo
? hm >= user.quietFrom && hm < user.quietTo
: hm >= user.quietFrom || hm < user.quietTo;
}
/** Уведомление о лиде с кнопкой «Беру». Пауза и тихие часы пользователя учитываются здесь. */
export async function enqueueLead(tx: Tx, leadId: number, recipients: User[], again = false, now = new Date()) {
const rows = recipients
.filter((u) => u.telegramId && !u.removedAt && !u.notificationsPaused && !inQuietHours(u, now))
.map((u) => ({ kind: 'lead', userId: u.id, leadId, payload: { again } }));
if (rows.length) await tx.insert(outbox).values(rows);
return rows.length;
}
/** Обычное сообщение пользователю. `key` — ключ текста из texts(), `args` — его параметры. */
export async function enqueueText(tx: Tx, userIds: number[], key: string, args: unknown[] = [], leadId?: number) {
if (!userIds.length) return;
await tx.insert(outbox).values(
[...new Set(userIds)].map((userId) => ({ kind: 'text', userId, leadId: leadId ?? null, payload: { key, args } })),
);
}
/** Сообщение всем суперадминам с привязанным Telegram: новые регистрации и компании. */
export async function enqueueForSuperadmins(tx: Tx, key: string, args: unknown[] = []) {
const admins = await tx.select({ id: users.id }).from(users).where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), isNull(users.removedAt)));
await enqueueText(tx, admins.map((u) => u.id), key, args);
}
/** Как связаться с человеком: email и Telegram. */
export const contactOf = (u: { email: string | null; telegramUsername: string | null; telegramId: number | null }) =>
[u.email, u.telegramUsername ? `@${u.telegramUsername}` : u.telegramId ? `Telegram ID ${u.telegramId}` : null].filter(Boolean).join(' · ') || '—';
/** Произвольный текст (сообщения суперадмина). */
export async function enqueueRaw(tx: Tx, userIds: number[], text: string) {
if (!userIds.length) return;
await tx.insert(outbox).values(userIds.map((userId) => ({ kind: 'text', userId, payload: { raw: text } })));
}
/** Лид взяли: у всех получателей обновить сообщение и убрать «Беру»; неотправленные уведомления отменить. */
export async function enqueueTaken(tx: Tx, leadId: number, takenById: number) {
await tx
.update(outbox)
.set({ doneAt: new Date(), error: 'canceled: lead taken' })
.where(and(eq(outbox.leadId, leadId), eq(outbox.kind, 'lead'), isNull(outbox.doneAt)));
const sent = await tx.select().from(leadNotifications).where(eq(leadNotifications.leadId, leadId));
if (!sent.length) return;
await tx.insert(outbox).values(
sent.map((n) => ({ kind: 'edit', userId: n.userId, leadId, payload: { notificationId: n.id, takenById } })),
);
}
const BATCH = 20;
/** Не больше ~25 сообщений в секунду на бота (лимит Bot API — 30). */
const GAP_MS = 40;
const MAX_ATTEMPTS = 6;
export interface OutboxDeps {
db: Db;
tg: TelegramApi;
config: Config;
sleep?: (ms: number) => Promise<void>;
}
const openButton = (config: Config, lang: string, leadId: number): Keyboard[number] => {
// Telegram принимает в кнопках только https-адреса; на локальном стенде кнопки «Открыть» нет.
if (!config.appUrl.startsWith('https://')) return [];
return [{ text: texts(lang).open, webApp: `${config.appUrl}/leads/${leadId}` }];
};
/** Отправляет одну пачку сообщений из очереди. Возвращает число обработанных. */
export async function processOutbox({ db, tg, config, sleep }: OutboxDeps): Promise<number> {
const wait = sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
// Забираем пачку и сразу отодвигаем run_at: если воркер упадёт, сообщения вернутся в очередь сами.
const jobs = await db
.update(outbox)
.set({ runAt: sql`now() + interval '2 minutes'`, attempts: sql`${outbox.attempts} + 1` })
.where(
inArray(
outbox.id,
sql`(select id from outbox where done_at is null and run_at <= now() order by id limit ${BATCH} for update skip locked)`,
),
)
.returning();
jobs.sort((a, b) => a.id - b.id);
for (const job of jobs) {
const finish = (error: string | null = null) =>
db.update(outbox).set({ doneAt: new Date(), error }).where(eq(outbox.id, job.id));
try {
const [found] = job.userId ? await db.select().from(users).where(eq(users.id, job.userId)) : [];
// Сообщения поддержки идут прямо в чат: у написавшего может не быть аккаунта.
const direct = job.payload as { chatId?: number; lang?: string };
if (!direct.chatId && (!found?.telegramId || found.removedAt)) {
await finish('skipped: no telegram');
continue;
}
const user = found ?? { id: 0, lang: direct.lang ?? 'ru' };
const chatId = direct.chatId ?? found!.telegramId!;
const t = texts(user.lang);
if (job.kind === 'text') {
const p = job.payload as { raw?: string; key?: string; args?: unknown[]; buttons?: { key: string; callback: string }[] };
// Кнопки под сообщением: подпись берётся из текстов бота на языке получателя.
const buttons: Keyboard | undefined = p.buttons?.length
? [p.buttons.map((b) => ({ text: String((t as unknown as Record<string, unknown>)[b.key] ?? b.key), callback: b.callback }))]
: undefined;
const fn = p.key ? (t as unknown as Record<string, unknown>)[p.key] : undefined;
const text =
p.raw !== undefined
? esc(p.raw)
: typeof fn === 'function'
? (fn as (...a: unknown[]) => string)(...(p.args ?? []).map((a) => (typeof a === 'string' ? esc(a) : a)))
: String(fn ?? '');
if (text) await tg.sendMessage(chatId, text, buttons ?? (job.leadId ? [openButton(config, user.lang, job.leadId)].filter((r) => r.length) : undefined));
await finish();
} else if (job.kind === 'lead') {
const [row] = await db
.select({ lead: leads, code: pipelineStatuses.code })
.from(leads)
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
.where(eq(leads.id, job.leadId!));
// Пока сообщение ждало в очереди, лид могли взять.
if (!row || row.lead.assigneeId !== null || row.code !== 'new') {
await finish('skipped: lead not in pool');
continue;
}
const { lead } = row;
const again = Boolean((job.payload as { again?: boolean }).again);
const text = leadMessage(lead, user.lang, again ? t.backInPool : t.newLead);
const keyboard: Keyboard = [
[{ text: t.take, callback: `take:${lead.id}` }, ...openButton(config, user.lang, lead.id)],
];
const photo = lead.photos[0];
let sent;
let hasPhoto = false;
if (photo) {
try {
sent = await tg.sendPhoto(chatId, photo, text, keyboard);
hasPhoto = true;
} catch (e) {
// Telegram не смог скачать фото — отправляем без него. Остальные ошибки обрабатываются ниже.
if (!(e instanceof TelegramError) || e.code !== 400) throw e;
}
}
sent ??= await tg.sendMessage(chatId, text, keyboard);
await db.insert(leadNotifications).values({
leadId: lead.id,
userId: user.id,
chatId: sent.chatId,
messageId: sent.messageId,
hasPhoto,
sentAt: new Date(),
});
await finish();
} else if (job.kind === 'edit') {
const p = job.payload as { notificationId: number; takenById: number };
const [n] = await db.select().from(leadNotifications).where(eq(leadNotifications.id, p.notificationId));
const [lead] = await db.select().from(leads).where(eq(leads.id, job.leadId!));
if (n?.chatId && n.messageId && lead) {
const [taker] = await db.select({ name: users.name }).from(users).where(eq(users.id, p.takenById));
const footer = p.takenById === user.id ? t.youTook : `<i>${t.takenBy(esc(taker?.name ?? '—'))}</i>`;
const mine = p.takenById === user.id ? [openButton(config, user.lang, lead.id)].filter((r) => r.length) : [];
await tg.editMessage({ chatId: n.chatId, messageId: n.messageId }, n.hasPhoto, leadMessage(lead, user.lang, t.newLead, footer), mine);
}
await finish();
} else {
await finish(`unknown kind ${job.kind}`);
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (e instanceof TelegramError && e.code === 429) {
// Telegram просит подождать: попытку не считаем, всю пачку откладываем.
const until = sql`now() + make_interval(secs => ${(e.retryAfter ?? 5) + 1})`;
await db.update(outbox).set({ runAt: until, attempts: sql`${outbox.attempts} - 1`, error: message }).where(eq(outbox.id, job.id));
await wait(((e.retryAfter ?? 5) + 1) * 1000);
} else if ((e instanceof TelegramError && e.permanent) || job.attempts >= MAX_ATTEMPTS) {
await finish(`failed: ${message}`);
} else {
// Повтор с нарастающей паузой: 10 с, 40 с, 90 с…
await db
.update(outbox)
.set({ runAt: sql`now() + make_interval(secs => ${job.attempts * job.attempts * 10})`, error: message })
.where(eq(outbox.id, job.id));
}
}
await wait(GAP_MS);
}
return jobs.length;
}
+89
View File
@@ -0,0 +1,89 @@
import { eq } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { Db } from '../db/client.ts';
import { settings, users } from '../db/schema.ts';
import { badRequest, notFound } from '../lib/errors.ts';
import type { FileSender } from '../support/service.ts';
// Приветствие бота: что он пишет в ответ на /start тому, кто ещё не привязал Telegram к аккаунту,
// и какие файлы присылает следом. Суперадмин меняет это в админке без перезапуска сервиса.
const Text = z.string().trim().max(3500).default('');
export const BotStart = z.object({
/** Текст по языкам; пустой — стандартный текст бота на этом языке. */
text: z.object({ ru: Text, hy: Text, en: Text }).default({ ru: '', hy: '', en: '' }),
/** Файлы следом за текстом. Хранится идентификатор файла у Telegram: сам файл лежит у него. */
files: z
.array(z.object({ fileId: z.string(), kind: z.enum(['photo', 'file']), name: z.string().max(200) }))
.max(10)
.default([]),
});
export type BotStart = z.infer<typeof BotStart>;
const KEY = 'bot_start';
const MAX_FILES = 10;
const MAX_FILE = 20 * 1024 * 1024;
export async function loadBotStart(db: Db): Promise<BotStart> {
const [row] = await db.select().from(settings).where(eq(settings.key, KEY));
return BotStart.parse(row?.value ?? {});
}
async function saveBotStart(db: Db, patch: Partial<BotStart>): Promise<BotStart> {
const value = BotStart.parse({ ...(await loadBotStart(db)), ...patch });
await db.insert(settings).values({ key: KEY, value }).onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date() } });
return value;
}
/**
* Текст приветствия на языке собеседника; null — стандартный текст бота. Пока человек язык не выбирал,
* вместо незаполненного языка уходит русский; после выбора — только текст на выбранном языке.
*/
export const startText = (s: BotStart, lang: string, fallback = true): string | null =>
s.text[lang as keyof BotStart['text']] || (fallback ? s.text.ru : '') || null;
export function registerBotStartRoutes(
app: FastifyInstance,
db: Db,
superadmin: (req: FastifyRequest) => { user: typeof users.$inferSelect },
sendFile: FileSender | null,
) {
app.get('/api/admin/bot-start', async (req) => {
superadmin(req);
return loadBotStart(db);
});
app.patch('/api/admin/bot-start', async (req) => {
superadmin(req);
const { text } = BotStart.pick({ text: true }).parse(req.body);
return saveBotStart(db, { text });
});
/**
* Новый файл: имя и тип — в адресе, сам файл — в теле запроса. Чтобы получить идентификатор файла,
* бот присылает его суперадмину в Telegram — заодно видно, как файл выглядит у получателя.
*/
app.post('/api/admin/bot-start/files', { bodyLimit: MAX_FILE }, async (req) => {
const ctx = superadmin(req);
const q = z
.object({ name: z.string().trim().min(1).max(200), type: z.string().trim().max(100).default('application/octet-stream') })
.parse(req.query);
if (!sendFile) throw badRequest('TELEGRAM_DISABLED');
if (!ctx.user.telegramId) throw badRequest('LINK_TELEGRAM_FIRST');
if (!Buffer.isBuffer(req.body) || req.body.length === 0) throw badRequest('VALIDATION');
const current = await loadBotStart(db);
if (current.files.length >= MAX_FILES) throw badRequest('TOO_MANY_FILES');
const sent = await sendFile(ctx.user.telegramId, { name: q.name, type: q.type, data: req.body }, '/start');
return saveBotStart(db, { files: [...current.files, { fileId: sent.fileId, kind: sent.kind, name: q.name }] });
});
app.delete('/api/admin/bot-start/files/:index', async (req) => {
superadmin(req);
const { index } = z.object({ index: z.coerce.number().int().min(0) }).parse(req.params);
const current = await loadBotStart(db);
if (!current.files[index]) throw notFound();
return saveBotStart(db, { files: current.files.filter((_, i) => i !== index) });
});
}
+247
View File
@@ -0,0 +1,247 @@
import type { Lang } from '@lr/shared';
/** Тексты бота. Сообщения уходят в HTML-разметке, поэтому пользовательские строки экранируются. */
export const esc = (v: string) => v.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const ru = {
take: 'Беру',
open: 'Открыть',
openApp: 'Открыть Lead Radar',
newLead: 'Новый объект',
backInPool: 'Объект снова в пуле',
youTook: '✅ Вы взяли этот объект',
takenBy: (name: string) => `Взял(а) ${name}`,
taken: 'Объект ваш',
alreadyTaken: 'Уже взят',
unavailable: 'Объект недоступен',
readOnly: 'Доступ ограничен: оплатите подписку',
unknownUser: 'Сначала войдите в Lead Radar и привяжите Telegram.',
rooms: (n: number) => `${n} комн.`,
area: (n: number) => `${n} м²`,
floor: (a: number, b?: number | null) => (b ? `этаж ${a}/${b}` : `этаж ${a}`),
sale: 'Продажа',
rent: 'Аренда',
perMonth: '/мес',
agency: 'агентство',
onListAm: 'Объявление на List.am',
linked: (name: string) => `Готово, ${name}. Telegram привязан — сюда будут приходить новые объекты.`,
linkUsed: 'Этот Telegram уже привязан к другому аккаунту.',
linkBad: 'Ссылка привязки устарела. Создайте новую в настройках кабинета.',
hello: (name: string) => `Здравствуйте, ${name}. Новые объекты будут приходить в этот чат.\n\n/anketa — ссылка на анкету для клиента\n/pause — пауза уведомлений\n/resume — включить снова\n\nНужна помощь? Просто напишите сюда — подключится поддержка.`,
signupNew: (name: string, contact: string) => `🆕 Новая регистрация: ${name}\n${contact}`,
signupCompany: (company: string, plan: string, name: string, contact: string) => `🏢 Новая компания «${company}», тариф ${plan}\nРуководитель: ${name}\n${contact}`,
start: 'Это бот Y&P Lead Radar: новые объявления List.am по вашим фильтрам. Зарегистрируйтесь в кабинете и привяжите Telegram в настройках.\n\nНужна помощь? Просто напишите сюда — подключится поддержка.',
supportOpened: 'Сообщение передано в поддержку. Ответим здесь, в этом чате.',
supportReply: (text: string) => `💬 <b>Поддержка</b>\n${text}`,
supportClosed: 'Обращение закрыто. Если вопрос остался — просто напишите сюда.',
supportNew: (who: string, text: string) => `🆘 Обращение в поддержку от ${who}:\n${text}`,
paused: 'Уведомления о новых объектах на паузе. /resume — включить.',
resumed: 'Уведомления о новых объектах включены.',
assigned: (title: string) => `Вам назначен объект: ${title}`,
reassignedAway: (title: string, to: string) => `Объект «${title}» передан агенту ${to}.`,
poolWarn: (title: string, hours: number) => `⏳ Объект «${title}» вернётся в пул через ${hours} ч. Обновите статус, чтобы оставить его за собой.`,
poolLost: (title: string) => `Объект «${title}» вернулся в пул: статус долго не менялся.`,
reminder: (title: string, note: string | null) => `🔔 Напоминание по объекту «${title}»${note ? `: ${note}` : ''}`,
clientAssigned: (name: string) => `Вам назначен клиент: ${name}`,
clientReassignedAway: (name: string, to: string) => `Клиент «${name}» передан агенту ${to}.`,
clientReminder: (name: string, note: string | null) => `🔔 Напоминание по клиенту «${name}»${note ? `: ${note}` : ''}`,
joined: (name: string) => `${name} присоединился к компании по приглашению.`,
limitReached: 'Достигнут лимит участников тарифа. Новые приглашения не сработают.',
parserFailed: (error: string) => `⚠️ Парсер List.am: ${error}`,
intakeDone: (name: string, kind: string) => `📝 Новая анкета: ${name} — ${kind === 'buyer' ? 'покупатель' : 'арендатор'}. Клиент добавлен в CRM и закреплён за вами.\n\nПоказать ему подходящие объекты?`,
intakeMatch: 'Показать объекты', intakeLater: 'Не сейчас',
intakeOk: 'Хорошо. Подобрать объекты можно позже в карточке клиента.',
matchSent: (n: number, url: string, delivered: boolean) => `Подобрано объектов: ${n}. Страница подборки: ${url}\n${delivered ? 'Ссылка отправлена клиенту в бот.' : 'Отправьте ссылку клиенту — в бот она ему не ушла.'}`,
matchNone: 'Подходящих объектов пока нет. Когда появятся, подберите их в карточке клиента.',
viewingBooked: (client: string, title: string, day: string, note: string) => `📅 ${client} записался на показ ${day}: ${title}${note ? `\nКомментарий: ${note}` : ''}`,
anketa: (url: string) => `Ваша ссылка на анкету:\n${url}\n\nОтправьте её клиенту или разместите в объявлении. Каждый, кто заполнит анкету, попадёт в CRM как ваш клиент.`,
anketaOff: 'Анкета пока не подключена: клиентский бот не настроен.',
allow: { children: 'С детьми', pets: 'С животными', yes: 'можно', no: 'нельзя', negotiable: 'по договорённости' },
invoiceIssued: (amount: string) => `Выставлен счёт на ${amount}. Посмотреть и оплатить можно в разделе «Подписка».`,
paymentReceived: (until: string) => `Оплата получена. Подписка действует до ${until}.`,
subEnding: (days: number, until: string) => `Подписка заканчивается ${until} — осталось ${days} дн. Оплатите счёт в разделе «Подписка», чтобы сервис работал без перерыва.`,
subOverdue: (days: number) => `Подписка просрочена. Сервис работает ещё ${days} дн., затем уведомления о новых объектах остановятся, а CRM станет доступна только на чтение.`,
subRestricted: 'Доступ ограничен: уведомления о новых объектах не приходят, CRM работает только на чтение. После оплаты доступ вернётся сразу, данные сохранены.',
planChangeCanceled: 'Смена тарифа отменена: в компании больше участников, чем вмещает выбранный тариф.',
};
type Texts = typeof ru;
const hy: Texts = {
take: 'Վերցնում եմ',
open: 'Բացել',
openApp: 'Բացել Lead Radar-ը',
newLead: 'Նոր օբյեկտ',
backInPool: 'Օբյեկտը կրկին ընդհանուր ցուցակում է',
youTook: '✅ Դուք վերցրել եք այս օբյեկտը',
takenBy: (name) => `Վերցրել է ${name}`,
taken: 'Օբյեկտը ձերն է',
alreadyTaken: 'Արդեն վերցված է',
unavailable: 'Օբյեկտը հասանելի չէ',
readOnly: 'Մուտքը սահմանափակված է. վճարեք բաժանորդագրությունը',
unknownUser: 'Նախ մուտք գործեք Lead Radar և կապեք Telegram-ը։',
rooms: (n) => `${n} սենյակ`,
area: (n) => `${n} մ²`,
floor: (a, b) => (b ? `հարկ ${a}/${b}` : `հարկ ${a}`),
sale: 'Վաճառք',
rent: 'Վարձակալություն',
perMonth: '/ամիս',
agency: 'գործակալություն',
onListAm: 'Հայտարարությունը List.am-ում',
linked: (name) => `Պատրաստ է, ${name}։ Telegram-ը կապված է. նոր օբյեկտները կգան այստեղ։`,
linkUsed: 'Այս Telegram-ն արդեն կապված է այլ հաշվի։',
linkBad: 'Կապման հղումը ժամկետանց է։ Ստեղծեք նորը կաբինետի կարգավորումներում։',
hello: (name) => `Բարև, ${name}։ Նոր օբյեկտները կգան այս չատ։\n\n/anketa — հարցաթերթիկի հղում հաճախորդի համար\n/pause — դադարեցնել ծանուցումները\n/resume — կրկին միացնել\n\nՕգնությո՞ւն է պետք։ Պարզապես գրեք այստեղ՝ աջակցությունը կմիանա։`,
signupNew: (name, contact) => `🆕 Նոր գրանցում՝ ${name}\n${contact}`,
signupCompany: (company, plan, name, contact) => `🏢 Նոր ընկերություն «${company}», սակագին՝ ${plan}\nՂեկավար՝ ${name}\n${contact}`,
start: 'Սա Y&P Lead Radar բոտն է. List.am-ի նոր հայտարարությունները՝ ըստ ձեր ֆիլտրերի։ Գրանցվեք կաբինետում և կապեք Telegram-ը կարգավորումներում։\n\nՕգնությո՞ւն է պետք։ Պարզապես գրեք այստեղ՝ աջակցությունը կմիանա։',
supportOpened: 'Հաղորդագրությունը փոխանցվել է աջակցությանը։ Կպատասխանենք այստեղ՝ այս չատում։',
supportReply: (text) => `💬 <b>Աջակցություն</b>\n${text}`,
supportClosed: 'Դիմումը փակված է։ Եթե հարցը մնացել է՝ պարզապես գրեք այստեղ։',
supportNew: (who, text) => `🆘 Դիմում աջակցությանը՝ ${who}.\n${text}`,
paused: 'Նոր օբյեկտների ծանուցումները դադարեցված են։ /resume — միացնել։',
resumed: 'Նոր օբյեկտների ծանուցումները միացված են։',
assigned: (title) => `Ձեզ նշանակվել է օբյեկտ՝ ${title}`,
reassignedAway: (title, to) => `«${title}» օբյեկտը փոխանցվել է գործակալ ${to}-ին։`,
poolWarn: (title, hours) => `⏳ «${title}» օբյեկտը ${hours} ժամից կվերադառնա ընդհանուր ցուցակ։ Թարմացրեք կարգավիճակը՝ այն պահելու համար։`,
poolLost: (title) => `«${title}» օբյեկտը վերադարձել է ընդհանուր ցուցակ. կարգավիճակը երկար ժամանակ չի փոխվել։`,
reminder: (title, note) => `🔔 Հիշեցում «${title}» օբյեկտի վերաբերյալ${note ? `՝ ${note}` : ''}`,
clientAssigned: (name) => `Ձեզ նշանակվել է հաճախորդ՝ ${name}`,
clientReassignedAway: (name, to) => `«${name}» հաճախորդը փոխանցվել է գործակալ ${to}-ին։`,
clientReminder: (name, note) => `🔔 Հիշեցում «${name}» հաճախորդի վերաբերյալ${note ? `՝ ${note}` : ''}`,
joined: (name) => `${name}-ը միացել է ընկերությանը հրավերով։`,
limitReached: 'Սակագնի մասնակիցների սահմանաչափը լրացել է։ Նոր հրավերները չեն աշխատի։',
parserFailed: (error) => `⚠️ List.am պարսեր՝ ${error}`,
intakeDone: (name, kind) => `📝 Նոր հարցաթերթիկ՝ ${name} — ${kind === 'buyer' ? 'գնորդ' : 'վարձակալ'}։ Հաճախորդն ավելացվել է CRM և ամրագրված է Ձեզ։\n\nՑույց տա՞լ նրան համապատասխան օբյեկտները։`,
intakeMatch: 'Ցույց տալ օբյեկտները', intakeLater: 'Ոչ հիմա',
intakeOk: 'Լավ։ Օբյեկտները կարող եք ընտրել ավելի ուշ՝ հաճախորդի քարտում։',
matchSent: (n, url, delivered) => `Ընտրված օբյեկտներ՝ ${n}։ Ընտրանու էջը՝ ${url}\n${delivered ? 'Հղումն ուղարկվել է հաճախորդին բոտում։' : 'Ուղարկեք հղումը հաճախորդին. բոտով այն չի գնացել։'}`,
matchNone: 'Համապատասխան օբյեկտներ դեռ չկան։ Երբ հայտնվեն, ընտրեք դրանք հաճախորդի քարտում։',
viewingBooked: (client, title, day, note) => `📅 ${client}-ը գրանցվել է ցուցադրության ${day}-ին՝ ${title}${note ? `\nՄեկնաբանություն՝ ${note}` : ''}`,
anketa: (url) => `Ձեր հարցաթերթիկի հղումը.\n${url}\n\nՈւղարկեք այն հաճախորդին կամ տեղադրեք հայտարարության մեջ։ Յուրաքանչյուրը, ով կլրացնի հարցաթերթիկը, կհայտնվի CRM-ում որպես Ձեր հաճախորդ։`,
anketaOff: 'Հարցաթերթիկը դեռ միացված չէ. հաճախորդների բոտը կարգավորված չէ։',
allow: { children: 'Երեխաներով', pets: 'Կենդանիներով', yes: 'կարելի է', no: 'չի կարելի', negotiable: 'պայմանավորվածությամբ' },
invoiceIssued: (amount) => `Դուրս է գրվել հաշիվ՝ ${amount}։ Դիտել և վճարել կարող եք «Բաժանորդագրություն» բաժնում։`,
paymentReceived: (until) => `Վճարումը ստացված է։ Բաժանորդագրությունը գործում է մինչև ${until}։`,
subEnding: (days, until) => `Բաժանորդագրությունն ավարտվում է ${until}-ին՝ մնացել է ${days} օր։ Վճարեք հաշիվը «Բաժանորդագրություն» բաժնում, որպեսզի ծառայությունն աշխատի առանց ընդհատման։`,
subOverdue: (days) => `Բաժանորդագրությունը ժամկետանց է։ Ծառայությունը կաշխատի ևս ${days} օր, ապա նոր օբյեկտների ծանուցումները կդադարեն, իսկ CRM-ը կլինի միայն ընթերցման համար։`,
subRestricted: 'Մուտքը սահմանափակված է. նոր օբյեկտների ծանուցումներ չեն գալիս, CRM-ը միայն ընթերցման համար է։ Վճարումից հետո մուտքը կվերականգնվի անմիջապես, տվյալները պահպանված են։',
planChangeCanceled: 'Սակագնի փոփոխությունը չեղարկվել է. ընկերությունում ավելի շատ մասնակից կա, քան թույլ է տալիս ընտրված սակագինը։',
};
const en: Texts = {
take: 'Take',
open: 'Open',
openApp: 'Open Lead Radar',
newLead: 'New property',
backInPool: 'Property is back in the pool',
youTook: '✅ You took this property',
takenBy: (name) => `Taken by ${name}`,
taken: 'The property is yours',
alreadyTaken: 'Already taken',
unavailable: 'Property is unavailable',
readOnly: 'Access is restricted: pay the subscription',
unknownUser: 'Log in to Lead Radar and link your Telegram first.',
rooms: (n) => `${n} rooms`,
area: (n) => `${n} m²`,
floor: (a, b) => (b ? `floor ${a}/${b}` : `floor ${a}`),
sale: 'Sale',
rent: 'Rent',
perMonth: '/mo',
agency: 'agency',
onListAm: 'Listing on List.am',
linked: (name) => `Done, ${name}. Telegram is linked — new properties will arrive here.`,
linkUsed: 'This Telegram is already linked to another account.',
linkBad: 'The link has expired. Create a new one in the app settings.',
hello: (name) => `Hello, ${name}. New properties will arrive in this chat.\n\n/anketa — questionnaire link for a client\n/pause — pause notifications\n/resume — turn them back on\n\nNeed help? Just write here — support will join.`,
signupNew: (name, contact) => `🆕 New sign-up: ${name}\n${contact}`,
signupCompany: (company, plan, name, contact) => `🏢 New company “${company}”, plan ${plan}\nManager: ${name}\n${contact}`,
start: 'This is the Y&P Lead Radar bot: new List.am listings matching your filters. Sign up in the app and link Telegram in settings.\n\nNeed help? Just write here — support will join.',
supportOpened: 'Your message has been passed to support. We will reply here, in this chat.',
supportReply: (text) => `💬 <b>Support</b>\n${text}`,
supportClosed: 'The request is closed. If the question remains, just write here.',
supportNew: (who, text) => `🆘 Support request from ${who}:\n${text}`,
paused: 'New-property notifications are paused. /resume to turn on.',
resumed: 'New-property notifications are on.',
assigned: (title) => `A property was assigned to you: ${title}`,
reassignedAway: (title, to) => `“${title}” was reassigned to ${to}.`,
poolWarn: (title, hours) => `⏳ “${title}” returns to the pool in ${hours} h. Update the status to keep it.`,
poolLost: (title) => `“${title}” returned to the pool: the status had not changed for too long.`,
reminder: (title, note) => `🔔 Reminder for “${title}”${note ? `: ${note}` : ''}`,
clientAssigned: (name) => `A client was assigned to you: ${name}`,
clientReassignedAway: (name, to) => `Client “${name}” was reassigned to ${to}.`,
clientReminder: (name, note) => `🔔 Reminder for client “${name}”${note ? `: ${note}` : ''}`,
joined: (name) => `${name} joined the company via invitation.`,
limitReached: 'The plan member limit is reached. New invitations will not work.',
parserFailed: (error) => `⚠️ List.am parser: ${error}`,
intakeDone: (name, kind) => `📝 New questionnaire: ${name} — ${kind === 'buyer' ? 'buyer' : 'tenant'}. The client was added to the CRM and assigned to you.\n\nShow them matching properties?`,
intakeMatch: 'Show properties', intakeLater: 'Not now',
intakeOk: 'OK. You can pick properties later in the client card.',
matchSent: (n, url, delivered) => `Properties picked: ${n}. Selection page: ${url}\n${delivered ? 'The link was sent to the client in the bot.' : 'Send the link to the client — it was not delivered in the bot.'}`,
matchNone: 'No matching properties yet. When they appear, pick them in the client card.',
viewingBooked: (client, title, day, note) => `📅 ${client} booked a viewing on ${day}: ${title}${note ? `\nComment: ${note}` : ''}`,
anketa: (url) => `Your questionnaire link:\n${url}\n\nSend it to a client or put it in your ad. Everyone who fills it in appears in the CRM as your client.`,
anketaOff: 'The questionnaire is not connected yet: the client bot is not configured.',
allow: { children: 'Children', pets: 'Pets', yes: 'allowed', no: 'not allowed', negotiable: 'negotiable' },
invoiceIssued: (amount) => `An invoice for ${amount} has been issued. You can view and pay it in “Subscription”.`,
paymentReceived: (until) => `Payment received. The subscription is valid until ${until}.`,
subEnding: (days, until) => `The subscription ends on ${until} — ${days} day(s) left. Pay the invoice in “Subscription” to keep the service running.`,
subOverdue: (days) => `The subscription is overdue. The service keeps working for ${days} more day(s); after that new-property notifications stop and the CRM becomes read-only.`,
subRestricted: 'Access is restricted: no new-property notifications, the CRM is read-only. Access returns right after payment, your data is kept.',
planChangeCanceled: 'The plan change was canceled: the company has more members than the selected plan allows.',
};
const all: Record<Lang, Texts> = { ru, hy, en };
export const texts = (lang: string | null | undefined): Texts => all[(lang as Lang) ?? 'ru'] ?? ru;
const SYMBOL: Record<string, string> = { USD: '$', EUR: '€', AMD: '֏', RUB: '₽' };
export function formatPrice(price: number | null, currency: string | null): string | null {
if (price == null) return null;
const n = Math.round(price).toLocaleString('en-US').replaceAll(',', ' ');
const sym = SYMBOL[currency ?? 'USD'] ?? '';
return currency === 'AMD' || currency === 'RUB' ? `${n} ${sym}` : `${sym}${n}`;
}
export interface LeadCard {
title: string;
description: string | null;
dealType: string | null;
region: string | null;
district: string | null;
address: string | null;
price: number | null;
currency: string | null;
rooms: number | null;
area: number | null;
floor: number | null;
floorsTotal: number | null;
url: string | null;
features?: { children?: 'yes' | 'no' | 'negotiable'; pets?: 'yes' | 'no' | 'negotiable' } | null;
}
/** Текст уведомления о лиде: цена, район и адрес, комнаты, площадь, этаж, начало описания, ссылка. */
export function leadMessage(lead: LeadCard, lang: string, header: string, footer?: string): string {
const t = texts(lang);
const price = formatPrice(lead.price, lead.currency);
const lines: string[] = [`<b>${esc(header)}</b>`];
if (price) lines.push(`<b>${price}${lead.dealType === 'rent' ? t.perMonth : ''}</b> · ${lead.dealType === 'rent' ? t.rent : t.sale}`);
const place = [lead.district ?? lead.region, lead.address].filter(Boolean).join(', ');
if (place) lines.push(`📍 ${esc(place)}`);
const specs = [
lead.rooms != null ? t.rooms(lead.rooms) : null,
lead.area != null ? t.area(lead.area) : null,
lead.floor != null ? t.floor(lead.floor, lead.floorsTotal) : null,
].filter(Boolean);
if (specs.length) lines.push(specs.join(' · '));
// Правила проживания из объявления: агенту сразу видно, подойдёт ли объект семье или хозяину животного.
const rules = (['children', 'pets'] as const)
.filter((k) => lead.features?.[k])
.map((k) => `${t.allow[k]}: ${t.allow[lead.features![k]!]}`);
if (rules.length) lines.push(rules.join(' · '));
const text = (lead.description ?? lead.title).replace(/\s+/g, ' ').trim();
if (text) lines.push('', esc(text.length > 220 ? `${text.slice(0, 220)}…` : text));
if (lead.url) lines.push('', `<a href="${esc(lead.url)}">${t.onListAm}</a>`);
if (footer) lines.push('', footer);
return lines.join('\n');
}
+138
View File
@@ -0,0 +1,138 @@
import { sql } from 'drizzle-orm';
import sharp from 'sharp';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { buildApp } from './app.ts';
import { connect } from './db/client.ts';
import { MIGRATIONS_DIR } from './db/migrate.ts';
import type { PaymentProvider } from './billing/provider.ts';
import type { PagedSource } from './ingest/service.ts';
import type { Mail } from './lib/mailer.ts';
import type { FileSender } from './support/service.ts';
import type { GroupSender } from './group/service.ts';
import type { Keyboard, SentMessage, TelegramApi } from './telegram/client.ts';
export const TEST_BOT_TOKEN = '123456:TEST-TOKEN';
/** Картинка 100 × 200, которую «скачивает» страница клиента в тестах. */
export const testImage = await sharp({ create: { width: 100, height: 200, channels: 3, background: '#336699' } }).png().toBuffer();
/**
* Приложение на чистой тестовой базе. Нужен запущенный Postgres (`pnpm db:up`).
* У каждого файла тестов своя база (`name`): файлы выполняются параллельно.
*/
export async function testApp(name = 'app', source: PagedSource | null = null, payments: PaymentProvider | null = null) {
const base = process.env.TEST_DATABASE_URL ?? 'postgres://leadradar:dev-local-only@localhost:5433/postgres';
const dbName = `leadradar_test_${name}`;
const admin = connect(base);
await admin.db.execute(sql.raw(`drop database if exists ${dbName} with (force)`));
await admin.db.execute(sql.raw(`create database ${dbName}`));
await admin.close();
const url = base.replace(/\/[^/]*$/, `/${dbName}`);
const { db, close } = connect(url);
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
const mails: Mail[] = [];
const config = {
databaseUrl: url,
port: 0,
appUrl: 'https://app.test',
production: false,
telegramBotToken: TEST_BOT_TOKEN,
telegramBotUsername: 'test_bot',
listingSource: 'off',
cropTopPercent: 8,
mediaDir: `/tmp/leadradar-test-media-${name}`,
openRouterModel: 'test-model',
};
/** Файлы, которые поддержка «отправила» в Telegram. */
const sentFiles: { chatId: number; name: string; type: string; size: number; caption: string }[] = [];
const sendFile: FileSender = async (chatId, file, caption) => {
sentFiles.push({ chatId, name: file.name, type: file.type, size: file.data.length, caption });
return { fileId: `sent-${sentFiles.length}`, kind: file.type.startsWith('image/') ? 'photo' : 'file' };
};
/** Что «ушло» в группу компании в Telegram. */
const groupSent: { kind: 'album' | 'text'; chatId: number; threadId?: number; text?: string; photos?: number }[] = [];
const groupSender: GroupSender = {
album: async (bot, photos) => void groupSent.push({ kind: 'album', chatId: bot.chatId, threadId: bot.threadId, photos: photos.length }),
text: async (bot, text) => void groupSent.push({ kind: 'text', chatId: bot.chatId, threadId: bot.threadId, text }),
};
const app = buildApp({ db, config, source, payments, sendFile, groupSender, fetchImage: async () => testImage, mailer: { send: async (m) => void mails.push(m) } });
/** Клиент с собственной сессией. */
const client = (token?: string, host?: string) => {
const call = async (method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', path: string, body?: unknown) => {
const res = await app.inject({
method,
url: path,
payload: body as object | undefined,
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(host ? { host } : {}) },
});
return { status: res.statusCode, body: res.json() as any };
};
return {
get: (p: string) => call('GET', p),
post: (p: string, b: unknown = {}) => call('POST', p, b),
patch: (p: string, b: unknown) => call('PATCH', p, b),
put: (p: string, b: unknown) => call('PUT', p, b),
del: (p: string) => call('DELETE', p),
};
};
let n = 0;
const register = async (name = 'Пользователь') => {
const res = await client().post('/api/auth/register', {
name,
email: `u${++n}@test.am`,
password: 'password-123',
});
return client(res.body.token as string);
};
return { app, db, config, mails, sentFiles, groupSent, client, register, close: async () => { await app.close(); await close(); } };
}
export interface FakeMessage extends SentMessage {
text: string;
photo?: string;
keyboard: Keyboard;
edited: boolean;
}
/** Заглушка Bot API: запоминает отправленные сообщения и ответы на нажатия кнопок. */
export function fakeTelegram() {
const messages: FakeMessage[] = [];
const answers: { id: string; text?: string }[] = [];
let nextId = 1;
/** Ошибка для следующего вызова отправки — чтобы проверить повтор. */
let failNext: Error | null = null;
const send = (chatId: number, text: string, keyboard: Keyboard = [], photo?: string) => {
if (failNext) {
const e = failNext;
failNext = null;
throw e;
}
const m = { chatId, messageId: nextId++, text, photo, keyboard, edited: false };
messages.push(m);
return { chatId, messageId: m.messageId };
};
const tg: TelegramApi = {
sendMessage: async (chatId, text, keyboard) => send(chatId, text, keyboard),
sendPhoto: async (chatId, photo, caption, keyboard) => send(chatId, caption, keyboard, photo),
sendFileById: async (chatId, fileId, kind) => void send(chatId, `[${kind}] ${fileId}`),
editMessage: async (msg, _hasPhoto, text, keyboard = []) => {
const m = messages.find((x) => x.chatId === msg.chatId && x.messageId === msg.messageId);
if (m) Object.assign(m, { text, keyboard, edited: true });
},
answerCallback: async (id, text) => void answers.push({ id, text }),
getUpdates: async () => [],
};
return {
tg,
messages,
answers,
failOnce: (e: Error) => { failNext = e; },
/** Сообщения, полученные чатом. */
inbox: (chatId: number) => messages.filter((m) => m.chatId === chatId),
hasButton: (m: FakeMessage, callbackPrefix: string) =>
m.keyboard.flat().some((b) => 'callback' in b && b.callback.startsWith(callbackPrefix)),
};
}
+51
View File
@@ -0,0 +1,51 @@
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { outbox, parserRuns, settings, users } from './db/schema.ts';
import { runParserWatchdog, type WatchdogState } from './ingest/watchdog.ts';
import { saveParserSettings } from './parser/settings.ts';
import { testApp } from './testing.ts';
let t: Awaited<ReturnType<typeof testApp>>;
beforeAll(async () => { t = await testApp('watchdog'); });
afterAll(() => t.close());
const MIN = 60_000;
const sent = async (adminId: number) =>
(await t.db.select().from(outbox).where(eq(outbox.userId, adminId))).map((m) => (m.payload as { raw: string }).raw);
describe('сторож парсера', () => {
it('пишет суперадмину о простое, напоминает раз в 6 часов и сообщает о восстановлении', async () => {
const [admin] = await t.db.insert(users).values({ name: 'Юрий', role: 'superadmin', telegramId: 777 }).returning();
const state: WatchdogState = { alertedAt: null };
const now = new Date();
// Проход был 5 минут назад — всё в порядке.
await t.db.insert(parserRuns).values({ startedAt: new Date(now.getTime() - 5 * MIN), status: 'ok' });
await runParserWatchdog(t.db, state, now);
expect(await sent(admin!.id)).toEqual([]);
// 25 минут без прохода — сообщение; через час повторно не пишем, через 6 часов напоминаем.
const later = new Date(now.getTime() + 20 * MIN);
await runParserWatchdog(t.db, state, later);
expect((await sent(admin!.id)).at(-1)).toContain('Парсер стоит: не запускался 25 мин');
await runParserWatchdog(t.db, state, new Date(later.getTime() + 60 * MIN));
expect(await sent(admin!.id)).toHaveLength(1);
await runParserWatchdog(t.db, state, new Date(later.getTime() + 6 * 60 * MIN));
expect(await sent(admin!.id)).toHaveLength(2);
// Парсер снова прошёл — «снова работает».
const back = new Date(later.getTime() + 7 * 60 * MIN);
await t.db.insert(parserRuns).values({ startedAt: back, status: 'ok' });
await runParserWatchdog(t.db, state, back);
expect((await sent(admin!.id)).at(-1)).toContain('снова работает');
// Выключили в настройках: первые 20 минут молчим, потом пишем.
await saveParserSettings(t.db, { enabled: false });
await t.db.update(settings).set({ updatedAt: back }).where(eq(settings.key, 'parser'));
await t.db.insert(parserRuns).values({ startedAt: new Date(back.getTime() + 10 * MIN), status: 'ok' });
await runParserWatchdog(t.db, state, new Date(back.getTime() + 10 * MIN));
expect(await sent(admin!.id)).toHaveLength(3);
await runParserWatchdog(t.db, state, new Date(back.getTime() + 30 * MIN));
expect((await sent(admin!.id)).at(-1)).toContain('выключен в настройках (уже 30 мин)');
});
});