Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9749da5ae7
201 files changed
+97685
No files matched your search
@@ -0,0 +1,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 };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
@@ -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 })),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)));
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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('Компании <b>A</b>', a.boss.chat)).toBe(1);
|
||||
expect(count('Компании <b>A</b>', agent.chat)).toBe(1);
|
||||
expect(count('Компании <b>A</b>', 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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 }));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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('Миграции применены');
|
||||
}
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(['Аренда']);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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('Привет <всем>');
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,611 @@
|
||||
import { agentPhrase, CommissionRule, CURRENCIES, DEAL_TYPES, FilterParams, PROPERTY_TYPES, type AccessState, type Role } from '@lr/shared';
|
||||
import { commissionDelta, commissionNote, commissionView, ruleCode } from '../lib/commission.ts';
|
||||
import { and, asc, desc, eq, gte, ilike, inArray, isNull, lte, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { DealBody, dealView } from '../deals/service.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { clientLeads, clients, companies, deals, departments, events, filters, leadFavorites, leads, leadViews, listings, pipelineStatuses, users } from '../db/schema.ts';
|
||||
import { loadBillingSettings } from '../billing/service.ts';
|
||||
import { backfillFilter, importListing, type PagedSource } from '../ingest/service.ts';
|
||||
import { sendLeadToGroup, sendLeadToUser, type GroupSender } from '../group/service.ts';
|
||||
import { badRequest, forbidden, notFound } from '../lib/errors.ts';
|
||||
import { onlySent } from '../lib/patch.ts';
|
||||
import type { Config } from '../lib/config.ts';
|
||||
import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts';
|
||||
import { renderShowcaseText } from '../showcase/text.ts';
|
||||
import {
|
||||
addComment,
|
||||
assignLead,
|
||||
canSee,
|
||||
changeStatus,
|
||||
claimFoundLead,
|
||||
createClaimedLead,
|
||||
sameDepartment,
|
||||
departmentScope,
|
||||
inDepartment,
|
||||
leadActual,
|
||||
priceRange,
|
||||
createManualLead,
|
||||
deleteLead,
|
||||
findListingLead,
|
||||
editLead,
|
||||
loadLead,
|
||||
markAsAgent,
|
||||
markListingRemoved,
|
||||
releaseLead,
|
||||
phoneVariants,
|
||||
setLeadPhone,
|
||||
setNextAction,
|
||||
statusByCode,
|
||||
takeLead,
|
||||
type Actor,
|
||||
type Lead,
|
||||
} from './service.ts';
|
||||
|
||||
type MemberCtx = { user: typeof users.$inferSelect; role: Role; company: typeof companies.$inferSelect; access: AccessState };
|
||||
|
||||
export interface Guards {
|
||||
member: (req: FastifyRequest) => MemberCtx;
|
||||
manager: (req: FastifyRequest) => MemberCtx;
|
||||
writable: <T extends { access: AccessState }>(ctx: T) => T;
|
||||
}
|
||||
|
||||
const Id = z.object({ id: z.coerce.number().int().positive() });
|
||||
const optionalText = (max: number) => z.string().trim().max(max).optional().transform((v) => v || undefined);
|
||||
const optionalNumber = z.coerce.number().nonnegative().optional();
|
||||
|
||||
const ManualLeadBody = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: optionalText(10_000),
|
||||
dealType: z.enum(DEAL_TYPES).optional(),
|
||||
propertyType: z.enum(PROPERTY_TYPES).optional(),
|
||||
region: optionalText(60),
|
||||
district: optionalText(60),
|
||||
address: optionalText(300),
|
||||
price: optionalNumber,
|
||||
currency: z.enum(CURRENCIES).default('USD'),
|
||||
rooms: z.coerce.number().int().min(0).max(50).optional(),
|
||||
area: optionalNumber,
|
||||
floor: z.coerce.number().int().min(-5).max(200).optional(),
|
||||
floorsTotal: z.coerce.number().int().min(0).max(200).optional(),
|
||||
authorName: optionalText(100),
|
||||
phone: optionalText(40),
|
||||
url: z.url().max(1000).optional().or(z.literal('').transform(() => undefined)),
|
||||
});
|
||||
|
||||
/** Пустое поле в правке стирает значение. */
|
||||
const clearable = <T extends z.ZodType>(schema: T) => z.preprocess((v) => (v === '' ? null : v), schema.nullable());
|
||||
|
||||
const LeadEditBody = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: clearable(z.string().trim().max(10_000)),
|
||||
dealType: clearable(z.enum(DEAL_TYPES)),
|
||||
propertyType: clearable(z.enum(PROPERTY_TYPES)),
|
||||
region: clearable(z.string().trim().max(60)),
|
||||
district: clearable(z.string().trim().max(60)),
|
||||
address: clearable(z.string().trim().max(300)),
|
||||
price: clearable(z.coerce.number().nonnegative()),
|
||||
currency: z.enum(CURRENCIES),
|
||||
rooms: clearable(z.coerce.number().int().min(0).max(50)),
|
||||
area: clearable(z.coerce.number().nonnegative()),
|
||||
floor: clearable(z.coerce.number().int().min(-5).max(200)),
|
||||
floorsTotal: clearable(z.coerce.number().int().min(0).max(200)),
|
||||
authorName: clearable(z.string().trim().max(100)),
|
||||
}).partial();
|
||||
|
||||
export function registerLeadRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config, fetchImage?: ImageFetcher, source?: PagedSource | null, groupSender?: GroupSender) {
|
||||
const actor = (ctx: MemberCtx): Actor => ctx;
|
||||
|
||||
/** Телефон автора виден ответственному и руководителю; у лида в пуле и у чужого лида он скрыт. */
|
||||
const present = (ctx: MemberCtx, lead: Lead, assigneeName?: string | null) => ({
|
||||
...lead,
|
||||
phone: ctx.role === 'manager' || lead.assigneeId === ctx.user.id ? lead.phone : null,
|
||||
phoneHidden: Boolean(lead.phone) && ctx.role !== 'manager' && lead.assigneeId !== ctx.user.id,
|
||||
assigneeName: assigneeName ?? null,
|
||||
});
|
||||
|
||||
// --- список лидов ---
|
||||
|
||||
app.get('/api/leads', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const q = z
|
||||
.object({
|
||||
tab: z.enum(['pool', 'mine', 'all']).default('pool'),
|
||||
statusId: z.coerce.number().int().optional(),
|
||||
assigneeId: z.coerce.number().int().optional(),
|
||||
q: z.string().trim().max(100).optional(),
|
||||
district: z.string().trim().max(60).optional(),
|
||||
/** Несколько районов через запятую. */
|
||||
districts: z.string().max(1000).optional(),
|
||||
priceFrom: z.coerce.number().nonnegative().optional(),
|
||||
priceTo: z.coerce.number().nonnegative().optional(),
|
||||
/** В какой валюте заданы границы цены: цены объектов пересчитываются по курсу. */
|
||||
priceCurrency: z.enum(['AMD', 'USD']).default('USD'),
|
||||
from: z.coerce.date().optional(),
|
||||
to: z.coerce.date().optional(),
|
||||
/** Только избранные объекты пользователя. */
|
||||
favorite: z.literal('1').optional(),
|
||||
/** Только объекты, карточку которых пользователь ещё не открывал. */
|
||||
unseen: z.literal('1').optional(),
|
||||
/** Руководителю — один отдел; none — объекты без отдела. */
|
||||
departmentId: z.union([z.literal('none'), z.coerce.number().int().positive()]).optional(),
|
||||
})
|
||||
.parse(req.query);
|
||||
const seeAll = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll;
|
||||
if (q.tab === 'all' && !seeAll) throw forbidden();
|
||||
|
||||
const fresh = await statusByCode(db, ctx.company.id, 'new');
|
||||
const where: (SQL | undefined)[] = [
|
||||
eq(leads.companyId, ctx.company.id),
|
||||
// Сотрудник видит только объекты своего отдела.
|
||||
departmentScope(actor(ctx), leads.departmentId),
|
||||
// Снятые с List.am и закрытые сделкой или отказом объекты в рабочие списки не попадают — они в «Базе».
|
||||
leadActual,
|
||||
];
|
||||
if (q.departmentId !== undefined && ctx.role === 'manager') where.push(inDepartment(leads.departmentId, q.departmentId === 'none' ? null : q.departmentId));
|
||||
if (q.favorite) where.push(sql`exists (select 1 from lead_favorites lf where lf.lead_id = "leads"."id" and lf.user_id = ${ctx.user.id})`);
|
||||
if (q.unseen) where.push(sql`not exists (select 1 from lead_views lv where lv.lead_id = "leads"."id" and lv.user_id = ${ctx.user.id})`);
|
||||
if (q.tab === 'pool') where.push(isNull(leads.assigneeId), eq(leads.statusId, fresh.id));
|
||||
if (q.tab === 'mine') where.push(eq(leads.assigneeId, ctx.user.id));
|
||||
if (q.statusId) where.push(eq(leads.statusId, q.statusId));
|
||||
if (q.assigneeId && q.tab === 'all') where.push(eq(leads.assigneeId, q.assigneeId));
|
||||
if (q.district) where.push(ilike(leads.district, q.district));
|
||||
const districts = q.districts?.split(',').map((d) => d.trim().toLowerCase()).filter(Boolean) ?? [];
|
||||
if (districts.length) where.push(inArray(sql`lower(${leads.district})`, districts));
|
||||
if (q.priceFrom !== undefined || q.priceTo !== undefined) {
|
||||
const { rates } = await loadBillingSettings(db);
|
||||
where.push(priceRange(leads.price, leads.currency, rates, q.priceCurrency, q.priceFrom, q.priceTo));
|
||||
}
|
||||
if (q.from) where.push(gte(leads.createdAt, q.from));
|
||||
if (q.to) where.push(lte(leads.createdAt, q.to));
|
||||
if (q.q) {
|
||||
// Поиск по телефону, адресу, названию и ID объявления.
|
||||
const like = `%${q.q.replace(/[%_\\]/g, '\\$&')}%`;
|
||||
const digits = q.q.replace(/\D/g, '');
|
||||
where.push(
|
||||
or(
|
||||
ilike(leads.address, like),
|
||||
ilike(leads.title, like),
|
||||
ilike(leads.url, like),
|
||||
// По телефону агент находит только свои лиды — чужие номера скрыты.
|
||||
digits.length >= 4
|
||||
? and(ilike(leads.phone, `%${digits}%`), ctx.role === 'manager' ? undefined : eq(leads.assigneeId, ctx.user.id))
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
lead: leads,
|
||||
assigneeName: users.name,
|
||||
departmentName: departments.name,
|
||||
favorite: sql<boolean>`exists (select 1 from lead_favorites lf where lf.lead_id = "leads"."id" and lf.user_id = ${ctx.user.id})`,
|
||||
seen: sql<boolean>`exists (select 1 from lead_views lv where lv.lead_id = "leads"."id" and lv.user_id = ${ctx.user.id})`,
|
||||
})
|
||||
.from(leads)
|
||||
.leftJoin(users, eq(users.id, leads.assigneeId))
|
||||
.leftJoin(departments, eq(departments.id, leads.departmentId))
|
||||
.where(and(...where))
|
||||
// Лид, чьё объявление подняли на list.am, встаёт наверх.
|
||||
.orderBy(desc(sql`coalesce(${leads.bumpedAt}, ${leads.createdAt})`), desc(leads.id))
|
||||
.limit(300);
|
||||
return rows.map((r) => ({ ...present(ctx, r.lead, r.assigneeName), departmentName: r.departmentName, favorite: r.favorite, seen: r.seen }));
|
||||
});
|
||||
|
||||
/** Курсы валют (сколько единиц в одном долларе): фильтр цены пересчитывает границы при смене валюты. */
|
||||
app.get('/api/rates', async (req) => {
|
||||
g.member(req);
|
||||
const { rates } = await loadBillingSettings(db);
|
||||
return { USD: 1, ...rates };
|
||||
});
|
||||
|
||||
app.get('/api/leads/:id', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const lead = await loadLead(db, actor(ctx), id);
|
||||
// Карточку открыли — объект больше не «непросмотренный» для этого пользователя.
|
||||
await db
|
||||
.insert(leadViews)
|
||||
.values({ userId: ctx.user.id, leadId: id })
|
||||
.onConflictDoUpdate({ target: [leadViews.userId, leadViews.leadId], set: { viewedAt: new Date() } });
|
||||
const [[favorite], [department]] = await Promise.all([
|
||||
db.select({ id: leadFavorites.leadId }).from(leadFavorites).where(and(eq(leadFavorites.userId, ctx.user.id), eq(leadFavorites.leadId, id))),
|
||||
lead.departmentId ? db.select({ name: departments.name }).from(departments).where(eq(departments.id, lead.departmentId)) : [],
|
||||
]);
|
||||
const [history, assignee, previous, interested, deal, listing] = await Promise.all([
|
||||
db
|
||||
.select({ event: events, userName: users.name })
|
||||
.from(events)
|
||||
.leftJoin(users, eq(users.id, events.userId))
|
||||
.where(eq(events.leadId, id))
|
||||
.orderBy(desc(events.at), desc(events.id)),
|
||||
lead.assigneeId ? db.select({ name: users.name }).from(users).where(eq(users.id, lead.assigneeId)) : [],
|
||||
lead.previousLeadId
|
||||
? db
|
||||
.select({ lead: leads, assigneeName: users.name, status: pipelineStatuses.name, removedAt: listings.removedAt })
|
||||
.from(leads)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.leftJoin(users, eq(users.id, leads.assigneeId))
|
||||
.leftJoin(listings, eq(listings.id, leads.listingId))
|
||||
.where(and(eq(leads.id, lead.previousLeadId), eq(leads.companyId, ctx.company.id)))
|
||||
: [],
|
||||
db
|
||||
.select({ client: clients, state: clientLeads.state, assigneeName: users.name })
|
||||
.from(clientLeads)
|
||||
.innerJoin(clients, eq(clients.id, clientLeads.clientId))
|
||||
.leftJoin(users, eq(users.id, clients.assigneeId))
|
||||
.where(eq(clientLeads.leadId, id))
|
||||
.orderBy(desc(clientLeads.createdAt)),
|
||||
db
|
||||
.select({ deal: deals, clientName: clients.name })
|
||||
.from(deals)
|
||||
.leftJoin(clients, eq(clients.id, deals.clientId))
|
||||
.where(eq(deals.leadId, id)),
|
||||
lead.listingId ? db.select({ active: listings.active, removedAt: listings.removedAt }).from(listings).where(eq(listings.id, lead.listingId)) : [],
|
||||
]);
|
||||
const prev = previous[0];
|
||||
const seesClients = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll;
|
||||
const d = deal[0];
|
||||
// В событиях назначения хранятся ID агентов — подставляем имена.
|
||||
const names = new Map(
|
||||
(await db.select({ id: users.id, name: users.name }).from(users).where(eq(users.companyId, ctx.company.id))).map(
|
||||
(u) => [String(u.id), u.name] as const,
|
||||
),
|
||||
);
|
||||
const byId = (v: string | null) => (v ? (names.get(v) ?? v) : null);
|
||||
// Полный набор фото и текст для клиента готовятся в фоне: открытие карточки ставит объект в очередь.
|
||||
const fromListing = Boolean(lead.listingId);
|
||||
if (fromListing && !lead.galleryAt && !lead.galleryWantedAt && lead.galleryTries < 3) {
|
||||
await db.update(leads).set({ galleryWantedAt: new Date() }).where(eq(leads.id, id));
|
||||
}
|
||||
return {
|
||||
...present(ctx, lead, assignee[0]?.name),
|
||||
favorite: Boolean(favorite),
|
||||
departmentName: department?.name ?? null,
|
||||
// Фото с List.am отдаёт сам сервис — обрезанными сверху, как на странице клиента.
|
||||
photos: lead.photos.slice(0, 20).map((src, i) => (/^https:\/\/(?:img|s)\.list\.am\//.test(src) ? `/api/leads/${id}/photos/${i}` : src)),
|
||||
/** ready — все фото загружены; pending — догружаются; none — объект заведён вручную или загрузить не удалось. */
|
||||
gallery: lead.galleryAt ? 'ready' : fromListing && lead.galleryTries < 3 ? 'pending' : 'none',
|
||||
/** Комиссия агентства с собственника: условие и ожидаемая сумма от цены объекта. */
|
||||
commission: commissionView(ctx.company.settings.commission, lead.dealType === 'rent' ? 'objectRent' : 'objectSale', lead.commission, lead.price, lead.currency),
|
||||
/** Формулировка в объявлении, по которой видно посредника или собственника против агентов. */
|
||||
agentPhrase: agentPhrase(`${lead.title}\n${lead.description ?? ''}`),
|
||||
/** Текст для клиента: карточка по шаблону, без адреса. */
|
||||
clientText: lead.showcaseText ?? renderShowcaseText(lead),
|
||||
// Варианты номера из общей базы видит тот же, кто видит сам телефон: ответственный и руководитель.
|
||||
phoneVariants: lead.listingId && (ctx.role === 'manager' || lead.assigneeId === ctx.user.id) ? await phoneVariants(db, lead.listingId) : [],
|
||||
// Заинтересованные клиенты: агент видит своих (и клиентов своего отдела при открытой видимости), руководитель — всех.
|
||||
clients: interested
|
||||
.filter((r) => ctx.role === 'manager' || r.client.assigneeId === ctx.user.id || (seesClients && sameDepartment(r.client.departmentId, ctx.user.departmentId)))
|
||||
.map((r) => ({ id: r.client.id, name: r.client.name, kind: r.client.kind, statusId: r.client.statusId, state: r.state, assigneeName: r.assigneeName })),
|
||||
deal: d ? { ...dealView(d.deal, names), clientId: d.deal.clientId, clientName: d.clientName } : null,
|
||||
// Объявление сняли с List.am (проверка раз в сутки).
|
||||
removedAt: listing[0]?.active === false ? listing[0].removedAt : null,
|
||||
// «Уже был в работе»: итог прошлой карточки виден всем, ссылка на неё — тем, кому она доступна.
|
||||
previous: prev
|
||||
? {
|
||||
id: canSee(actor(ctx), prev.lead) ? prev.lead.id : null,
|
||||
title: prev.lead.title, assigneeName: prev.assigneeName, status: prev.status, removedAt: prev.removedAt,
|
||||
}
|
||||
: null,
|
||||
events: history.map(({ event: e, userName }) => ({
|
||||
id: e.id,
|
||||
type: e.type,
|
||||
at: e.at,
|
||||
userName,
|
||||
oldValue: e.type === 'assigned' || e.type === 'pool_return' ? byId(e.oldValue) : e.oldValue,
|
||||
newValue: e.type === 'assigned' ? byId(e.newValue) : e.newValue,
|
||||
text: e.text,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// --- действия с лидом ---
|
||||
|
||||
app.post('/api/leads', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const lead = await createManualLead(db, actor(ctx), ManualLeadBody.parse(req.body));
|
||||
return { id: lead.id };
|
||||
});
|
||||
|
||||
/**
|
||||
* Объект по ссылке на list.am: есть в CRM и свободен — закрепляется за агентом; закреплён за
|
||||
* другим — ответ с его именем; нет в CRM — объявление скачивается с list.am и заводится на агента.
|
||||
*/
|
||||
app.post('/api/leads/by-url', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { url } = z.object({ url: z.string().trim().max(1000) }).parse(req.body);
|
||||
const externalId = /list\.am\/(?:[a-z]{2}\/)?item\/(\d{4,15})/i.exec(url)?.[1];
|
||||
if (!externalId) throw badRequest('BAD_LISTAM_URL');
|
||||
const [known] = await db.select().from(listings).where(eq(listings.externalId, externalId));
|
||||
// Сотрудник ищет объект в своём отделе: в другом отделе у объекта своя карточка.
|
||||
const found = await findListingLead(db, ctx.company.id, externalId, known?.id ?? null, ctx.role === 'manager' ? undefined : ctx.user.departmentId);
|
||||
if (found) return claimFoundLead(db, actor(ctx), found);
|
||||
let listing = known;
|
||||
if (!listing) {
|
||||
if (!source?.item) throw badRequest('PARSER_DISABLED');
|
||||
const fetched = await importListing(db, source, externalId).catch((error: unknown) => {
|
||||
console.error('[by-url]', error);
|
||||
throw badRequest('LISTAM_UNAVAILABLE');
|
||||
});
|
||||
if (!fetched) throw notFound('LISTAM_NOT_FOUND');
|
||||
listing = fetched;
|
||||
}
|
||||
return createClaimedLead(db, actor(ctx), listing);
|
||||
});
|
||||
|
||||
/** «Отправить в Telegram»: объект сразу уходит в группу компании её ботом. */
|
||||
app.post('/api/leads/:id/group', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const lead = await loadLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
await sendLeadToGroup({ db, config, sender: groupSender, fetchImage }, ctx.company, lead);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** «Отправить в бот»: объект уходит основным ботом в личный чат того, кто нажал кнопку. */
|
||||
app.post('/api/leads/:id/bot', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const lead = await loadLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
await sendLeadToUser({ db, config, sender: groupSender, fetchImage }, ctx.company, lead, ctx.user);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Избранное: у каждого пользователя своё. */
|
||||
app.post('/api/leads/:id/favorite', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const { on } = z.object({ on: z.boolean() }).parse(req.body);
|
||||
await loadLead(db, actor(ctx), id);
|
||||
if (on) await db.insert(leadFavorites).values({ userId: ctx.user.id, leadId: id }).onConflictDoNothing();
|
||||
else await db.delete(leadFavorites).where(and(eq(leadFavorites.userId, ctx.user.id), eq(leadFavorites.leadId, id)));
|
||||
return { ok: true, favorite: on };
|
||||
});
|
||||
|
||||
/** Удалить карточку объекта: руководитель; со сделкой — только суперадмин. */
|
||||
app.delete('/api/leads/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await deleteLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/take', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const lead = await takeLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
return present(ctx, lead, ctx.user.name);
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/assign', async (req) => {
|
||||
const ctx = g.writable(g.manager(req));
|
||||
const { userId } = z.object({ userId: z.number().int().positive() }).parse(req.body);
|
||||
await assignLead(db, actor(ctx), Id.parse(req.params).id, userId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/release', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await releaseLead(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/status', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = z
|
||||
.object({
|
||||
statusId: z.number().int().positive(),
|
||||
rejectReasonId: z.number().int().positive().optional(),
|
||||
deal: DealBody.extend({ clientId: z.number().int().positive().optional() }).optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
await changeStatus(db, actor(ctx), Id.parse(req.params).id, body);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/comments', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { text } = z.object({ text: z.string().trim().min(1).max(5000) }).parse(req.body);
|
||||
await addComment(db, actor(ctx), Id.parse(req.params).id, text);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/next-action', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = z
|
||||
.object({ at: z.coerce.date().nullable(), note: z.string().trim().max(500).nullable().default(null) })
|
||||
.parse(req.body);
|
||||
await setNextAction(db, actor(ctx), Id.parse(req.params).id, body.at, body.note || null);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Условие комиссии для этого объекта; null возвращает условие из настроек компании. */
|
||||
app.post('/api/leads/:id/commission', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const { rule } = z.object({ rule: CommissionRule.nullable() }).parse(req.body);
|
||||
const lead = await loadLead(db, actor(ctx), id);
|
||||
if (ctx.role !== 'manager' && lead.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_LEAD');
|
||||
const before = ruleCode(lead.commission);
|
||||
const after = ruleCode(rule);
|
||||
if (before === after) return { ok: true };
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(leads).set({ commission: rule }).where(eq(leads.id, id));
|
||||
// В истории видно, кто и какое условие поставил; «было» — то, что действовало до правки, включая условие компании.
|
||||
const settings = ctx.company.settings.commission?.[lead.dealType === 'rent' ? 'objectRent' : 'objectSale'];
|
||||
await tx.insert(events).values({
|
||||
companyId: ctx.company.id, leadId: id, userId: ctx.user.id, type: 'commission',
|
||||
oldValue: before ?? ruleCode(settings), newValue: after,
|
||||
// Вместе с условием сохраняется, на сколько процентов изменилась ожидаемая сумма.
|
||||
text: commissionNote(after ? null : ruleCode(settings), commissionDelta(lead.commission ?? settings, rule ?? settings, lead.price, lead.currency)),
|
||||
});
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Телефон собственника: агент берёт его из объявления и вносит сам. */
|
||||
app.post('/api/leads/:id/phone', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { phone } = z.object({ phone: z.string().trim().max(40) }).parse(req.body);
|
||||
await setLeadPhone(db, actor(ctx), Id.parse(req.params).id, phone);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Фото объекта, обрезанное сверху. */
|
||||
app.get('/api/leads/:id/photos/:index', async (req, reply) => {
|
||||
const ctx = g.member(req);
|
||||
const { id } = Id.parse(req.params);
|
||||
const { index } = z.object({ index: z.coerce.number().int().min(0).max(50) }).parse(req.params);
|
||||
const lead = await loadLead(db, actor(ctx), id);
|
||||
const url = lead.photos[index];
|
||||
if (!url || !/^https:\/\/(?:img|s)\.list\.am\//.test(url)) throw notFound();
|
||||
const photo = await croppedPhoto(config, url, fetchImage).catch(() => {
|
||||
throw notFound();
|
||||
});
|
||||
return reply.header('content-type', 'image/jpeg').header('cache-control', 'private, max-age=604800').send(photo);
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/agent', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await markAsAgent(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Правка данных объекта: те же поля, что у объекта вручную, кроме телефона и ссылки. */
|
||||
app.patch('/api/leads/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = onlySent(req.body, LeadEditBody.parse(req.body));
|
||||
if (!Object.keys(body).length) throw badRequest('VALIDATION');
|
||||
await editLead(db, actor(ctx), Id.parse(req.params).id, body);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/leads/:id/removed', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
await markListingRemoved(db, actor(ctx), Id.parse(req.params).id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// --- фильтры ---
|
||||
|
||||
/**
|
||||
* Агент видит фильтры своего отдела (без отдела — общие фильтры компании) и свои личные;
|
||||
* руководитель — все фильтры: компании, отделов и личные фильтры агентов.
|
||||
*/
|
||||
app.get('/api/filters', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const rows = await db
|
||||
.select({ filter: filters, ownerName: users.name, departmentName: departments.name })
|
||||
.from(filters)
|
||||
.leftJoin(users, eq(users.id, filters.ownerUserId))
|
||||
.leftJoin(departments, eq(departments.id, filters.departmentId))
|
||||
.where(
|
||||
and(
|
||||
eq(filters.companyId, ctx.company.id),
|
||||
ctx.role === 'manager'
|
||||
? undefined
|
||||
: or(and(isNull(filters.ownerUserId), inDepartment(filters.departmentId, ctx.user.departmentId)), eq(filters.ownerUserId, ctx.user.id)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(filters.id));
|
||||
return rows.map(({ filter: f, ownerName, departmentName }) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
enabled: f.enabled,
|
||||
params: f.params,
|
||||
scope: f.ownerUserId !== null ? 'personal' : f.departmentId !== null ? 'department' : 'company',
|
||||
ownerUserId: f.ownerUserId,
|
||||
ownerName,
|
||||
departmentId: f.departmentId,
|
||||
departmentName,
|
||||
// Фильтр компании и отдела меняет руководитель, личный — его владелец.
|
||||
editable: f.ownerUserId === null ? ctx.role === 'manager' : f.ownerUserId === ctx.user.id,
|
||||
}));
|
||||
});
|
||||
|
||||
const FilterBody = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
enabled: z.boolean().default(true),
|
||||
params: FilterParams,
|
||||
});
|
||||
|
||||
/** Отдел компании по ID; чужой или несуществующий — ошибка. */
|
||||
const companyDepartment = async (ctx: MemberCtx, id: number) => {
|
||||
const [d] = await db.select({ id: departments.id }).from(departments).where(and(eq(departments.id, id), eq(departments.companyId, ctx.company.id)));
|
||||
if (!d) throw badRequest('BAD_DEPARTMENT');
|
||||
return d.id;
|
||||
};
|
||||
|
||||
app.post('/api/filters', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const body = FilterBody.extend({
|
||||
scope: z.enum(['company', 'department', 'personal']),
|
||||
departmentId: z.number().int().positive().optional(),
|
||||
}).parse(req.body);
|
||||
// Фильтры компании и отделов задаёт руководитель.
|
||||
if (body.scope !== 'personal' && ctx.role !== 'manager') throw forbidden();
|
||||
if (body.scope === 'department' && !body.departmentId) throw badRequest('BAD_DEPARTMENT');
|
||||
const departmentId = body.scope === 'department' ? await companyDepartment(ctx, body.departmentId!) : null;
|
||||
const [row] = await db
|
||||
.insert(filters)
|
||||
.values({
|
||||
companyId: ctx.company.id,
|
||||
ownerUserId: body.scope === 'personal' ? ctx.user.id : null,
|
||||
departmentId,
|
||||
name: body.name,
|
||||
enabled: body.enabled,
|
||||
params: body.params,
|
||||
})
|
||||
.returning();
|
||||
// Подходящие объявления из общей базы сразу попадают в пул (без уведомлений).
|
||||
return { id: row!.id, fromBase: await backfillFilter(db, row!.id) };
|
||||
});
|
||||
|
||||
const ownFilter = async (ctx: MemberCtx, id: number) => {
|
||||
const [f] = await db.select().from(filters).where(and(eq(filters.id, id), eq(filters.companyId, ctx.company.id)));
|
||||
if (!f) throw notFound();
|
||||
if (f.ownerUserId === null ? ctx.role !== 'manager' : f.ownerUserId !== ctx.user.id) throw forbidden();
|
||||
return f;
|
||||
};
|
||||
|
||||
app.patch('/api/filters/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
const f = await ownFilter(ctx, id);
|
||||
const { departmentId, ...body } = onlySent(
|
||||
req.body,
|
||||
FilterBody.partial().extend({ departmentId: z.number().int().positive().nullable() }).partial().parse(req.body),
|
||||
);
|
||||
// Руководитель переносит фильтр компании в отдел и обратно; у личного фильтра отдела нет.
|
||||
const patch = {
|
||||
...body,
|
||||
...(departmentId !== undefined && f.ownerUserId === null
|
||||
? { departmentId: departmentId === null ? null : await companyDepartment(ctx, departmentId) }
|
||||
: {}),
|
||||
};
|
||||
if (!Object.keys(patch).length) throw badRequest('VALIDATION');
|
||||
await db.update(filters).set(patch).where(eq(filters.id, id));
|
||||
return { ok: true, fromBase: await backfillFilter(db, id) };
|
||||
});
|
||||
|
||||
app.delete('/api/filters/:id', async (req) => {
|
||||
const ctx = g.writable(g.member(req));
|
||||
const { id } = Id.parse(req.params);
|
||||
await ownFilter(ctx, id);
|
||||
await db.delete(filters).where(eq(filters.id, id));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Счётчики для вкладок списка лидов. */
|
||||
app.get('/api/leads-summary', async (req) => {
|
||||
const ctx = g.member(req);
|
||||
const fresh = await statusByCode(db, ctx.company.id, 'new');
|
||||
const [row] = await db
|
||||
.select({
|
||||
pool: sql<number>`count(*) filter (where ${leads.assigneeId} is null and ${leads.statusId} = ${fresh.id})::int`,
|
||||
mine: sql<number>`count(*) filter (where ${leads.assigneeId} = ${ctx.user.id})::int`,
|
||||
all: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(leads)
|
||||
.where(and(eq(leads.companyId, ctx.company.id), departmentScope(actor(ctx), leads.departmentId), leadActual));
|
||||
return row ?? { pool: 0, mine: 0, all: 0 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
import { normalizePhone, phoneConfidence } from '@lr/shared';
|
||||
import { and, eq, isNull, or, sql, type AnyColumn, type SQL } from 'drizzle-orm';
|
||||
import type { Db, Tx } from '../db/client.ts';
|
||||
import {
|
||||
blacklist,
|
||||
companies,
|
||||
deals,
|
||||
events,
|
||||
leads,
|
||||
listingPhones,
|
||||
listings,
|
||||
pipelineStatuses,
|
||||
rejectReasons,
|
||||
users,
|
||||
} from '../db/schema.ts';
|
||||
import { badRequest, conflict, forbidden, notFound } from '../lib/errors.ts';
|
||||
import { dealInput, dealText, dropDealSide, moveClientToDeal, recordDeal, type DealBody, type DealInput } from '../deals/service.ts';
|
||||
import { statusByCode } from '../pipelines/service.ts';
|
||||
import { enqueueLead, enqueueTaken, enqueueText } from '../telegram/outbox.ts';
|
||||
|
||||
// Жизненный цикл лида в воронке объекта: пул → «Беру» → статусы → сделка или отказ,
|
||||
// возврат в пул. Каждое действие пишет событие в историю и ставит уведомления в очередь.
|
||||
|
||||
export type Lead = typeof leads.$inferSelect;
|
||||
export type User = typeof users.$inferSelect;
|
||||
type Company = typeof companies.$inferSelect;
|
||||
type Listing = typeof listings.$inferSelect;
|
||||
|
||||
/** Кто действует: пользователь компании с ролью в текущем кабинете. */
|
||||
export interface Actor {
|
||||
user: User;
|
||||
role: 'manager' | 'agent' | 'superadmin';
|
||||
company: Company;
|
||||
}
|
||||
|
||||
const isManager = (a: Actor) => a.role === 'manager';
|
||||
|
||||
/** Один и тот же отдел: пустой отдел («без отдела») равен только пустому. */
|
||||
export const sameDepartment = (a: number | null | undefined, b: number | null | undefined) => (a ?? null) === (b ?? null);
|
||||
|
||||
/** Условие «запись из отдела department» для запросов; null — записи без отдела. */
|
||||
export const inDepartment = (column: AnyColumn, department: number | null | undefined): SQL =>
|
||||
department == null ? isNull(column) : eq(column, department);
|
||||
|
||||
/**
|
||||
* Какие отделы видит пользователь в списках: руководитель и суперадмин — все (undefined — без ограничения),
|
||||
* сотрудник — только свой.
|
||||
*/
|
||||
export const departmentScope = (actor: Actor, column: AnyColumn): SQL | undefined =>
|
||||
isManager(actor) ? undefined : inDepartment(column, actor.user.departmentId);
|
||||
|
||||
/**
|
||||
* Объект актуален: объявление не снято с List.am (у объекта вручную объявления нет) и карточка не закрыта
|
||||
* сделкой или отказом. Неактуальные объекты остаются в базе и в разделе «База», но не попадают в рабочие
|
||||
* списки, поиск, фильтры и подборки.
|
||||
*/
|
||||
// Столбцы внешней таблицы в подзапросах — с явным именем таблицы: без него Postgres может взять столбец подзапроса.
|
||||
export const listingLive = sql`not exists (select 1 from listings li where li.id = "leads"."listing_id" and not li.active)`;
|
||||
export const leadOpen = sql`not exists (select 1 from pipeline_statuses ps where ps.id = "leads"."status_id" and ps.code in ('deal', 'rejected'))`;
|
||||
export const leadActual = sql`(${listingLive} and ${leadOpen})`;
|
||||
|
||||
/**
|
||||
* Цена в выбранной валюте не ниже from и не выше to. Цена объекта пересчитывается по курсам
|
||||
* (сколько единиц валюты в одном долларе); объект без валюты считается в долларах.
|
||||
*/
|
||||
export function priceRange(
|
||||
price: AnyColumn,
|
||||
currency: AnyColumn,
|
||||
rates: Record<string, number>,
|
||||
target: string,
|
||||
from?: number,
|
||||
to?: number,
|
||||
): SQL | undefined {
|
||||
if (from === undefined && to === undefined) return undefined;
|
||||
const perUsd = (code: string) => rates[code] ?? (code === 'USD' ? 1 : undefined);
|
||||
const known = Object.entries({ USD: 1, ...rates }).filter(([, rate]) => rate > 0);
|
||||
// Цена объекта в долларах: делим на курс его валюты.
|
||||
const usd = sql`(${price}::numeric / (case coalesce(${currency}, 'USD') ${sql.join(
|
||||
known.map(([code, rate]) => sql`when ${code} then ${rate}::numeric`),
|
||||
sql` `,
|
||||
)} else 1 end))`;
|
||||
const k = perUsd(target) ?? 1;
|
||||
return and(
|
||||
sql`${price} is not null`,
|
||||
from !== undefined ? sql`${usd} >= ${from / k}::numeric` : undefined,
|
||||
to !== undefined ? sql`${usd} <= ${to / k}::numeric` : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export const activeMembers = (tx: Tx, companyId: number) =>
|
||||
tx
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.companyId, companyId), isNull(users.removedAt)));
|
||||
|
||||
async function logEvent(
|
||||
tx: Tx,
|
||||
lead: Pick<Lead, 'id' | 'companyId'>,
|
||||
userId: number | null,
|
||||
type: string,
|
||||
extra: { oldValue?: string | null; newValue?: string | null; text?: string | null; statusId?: number } = {},
|
||||
) {
|
||||
await tx.insert(events).values({ companyId: lead.companyId, leadId: lead.id, userId, type, ...extra });
|
||||
}
|
||||
|
||||
const touchCompany = (tx: Tx, companyId: number) =>
|
||||
tx.update(companies).set({ lastActivityAt: new Date() }).where(eq(companies.id, companyId));
|
||||
|
||||
/**
|
||||
* Может ли пользователь видеть лид. Руководитель — любой. Сотрудник — только лиды своего отдела:
|
||||
* свой, из пула, либо любой при открытой видимости.
|
||||
*/
|
||||
export function canSee(actor: Actor, lead: Lead): boolean {
|
||||
if (lead.companyId !== actor.company.id) return false;
|
||||
if (isManager(actor)) return true;
|
||||
if (!sameDepartment(lead.departmentId, actor.user.departmentId)) return false;
|
||||
if (actor.company.settings.agentsSeeAll) return true;
|
||||
return lead.assigneeId === actor.user.id || lead.assigneeId === null;
|
||||
}
|
||||
|
||||
/** Вести лид (статус, комментарий, напоминание) может ответственный и руководитель. */
|
||||
function assertCanWork(actor: Actor, lead: Lead) {
|
||||
if (!isManager(actor) && lead.assigneeId !== actor.user.id) throw forbidden('NOT_YOUR_LEAD');
|
||||
}
|
||||
|
||||
async function loadLead(tx: Tx, actor: Actor, leadId: number): Promise<Lead> {
|
||||
const [lead] = await tx
|
||||
.select()
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id)));
|
||||
if (!lead || !canSee(actor, lead)) throw notFound('LEAD_NOT_FOUND');
|
||||
return lead;
|
||||
}
|
||||
|
||||
const copyProperty = (l: Listing) => ({
|
||||
title: l.title,
|
||||
description: l.description,
|
||||
dealType: l.dealType,
|
||||
propertyType: l.propertyType,
|
||||
region: l.region,
|
||||
district: l.district,
|
||||
address: l.address,
|
||||
price: l.price,
|
||||
currency: l.currency,
|
||||
rooms: l.rooms,
|
||||
area: l.area,
|
||||
floor: l.floor,
|
||||
floorsTotal: l.floorsTotal,
|
||||
photos: l.photos,
|
||||
authorName: l.authorName,
|
||||
phone: l.phone,
|
||||
features: l.features,
|
||||
});
|
||||
|
||||
/**
|
||||
* Лид из объявления: статус «Новый», в пуле, уведомления подходящим агентам.
|
||||
* Возвращает null, если у компании уже есть лид по этому объявлению.
|
||||
*/
|
||||
export async function createLeadFromListing(
|
||||
tx: Tx,
|
||||
companyId: number,
|
||||
listing: Listing,
|
||||
recipients: User[],
|
||||
/** Прошлый лид по тому же объекту: объявление сняли и опубликовали снова. */
|
||||
previousLeadId: number | null = null,
|
||||
/** Отдел, в который пришёл объект; null — без отдела. */
|
||||
departmentId: number | null = null,
|
||||
) {
|
||||
const fresh = await statusByCode(tx, companyId, 'new');
|
||||
const [lead] = await tx
|
||||
.insert(leads)
|
||||
.values({ companyId, departmentId, listingId: listing.id, source: 'listam', url: listing.url, statusId: fresh.id, previousLeadId, bumpedAt: listing.bumpedAt, ...copyProperty(listing) })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
if (!lead) return null;
|
||||
await logEvent(tx, lead, null, 'created', { newValue: 'listam' });
|
||||
await enqueueLead(tx, lead.id, recipients);
|
||||
return lead;
|
||||
}
|
||||
|
||||
export interface ManualLead {
|
||||
title: string;
|
||||
description?: string;
|
||||
dealType?: string;
|
||||
propertyType?: string;
|
||||
region?: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
rooms?: number;
|
||||
area?: number;
|
||||
floor?: number;
|
||||
floorsTotal?: number;
|
||||
authorName?: string;
|
||||
phone?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/** Лид вручную (объект не с List.am): сразу закреплён за создателем в статусе «Взят в работу». */
|
||||
export async function createManualLead(db: Db, actor: Actor, input: ManualLead) {
|
||||
return db.transaction(async (tx) => {
|
||||
const taken = await statusByCode(tx, actor.company.id, 'taken');
|
||||
const [lead] = await tx
|
||||
.insert(leads)
|
||||
.values({
|
||||
...input,
|
||||
phone: normalizePhone(input.phone) ?? input.phone ?? null,
|
||||
photos: [],
|
||||
companyId: actor.company.id,
|
||||
// Объект попадает в отдел того, кто его завёл.
|
||||
departmentId: actor.user.departmentId,
|
||||
source: 'manual',
|
||||
statusId: taken.id,
|
||||
assigneeId: actor.user.id,
|
||||
takenAt: new Date(),
|
||||
createdById: actor.user.id,
|
||||
})
|
||||
.returning();
|
||||
await logEvent(tx, lead!, actor.user.id, 'created', { newValue: 'manual' });
|
||||
await touchCompany(tx, actor.company.id);
|
||||
return lead!;
|
||||
});
|
||||
}
|
||||
|
||||
/** Чем кончилось добавление объекта ссылкой на list.am. */
|
||||
export type ClaimResult =
|
||||
/** Объект был в пуле — теперь он закреплён за агентом. */
|
||||
| { result: 'taken'; id: number }
|
||||
/** Объекта в CRM не было — заведён и закреплён за агентом. */
|
||||
| { result: 'created'; id: number }
|
||||
/** Объект уже у этого агента. */
|
||||
| { result: 'mine'; id: number }
|
||||
/** Объект закреплён за другим агентом; id — если агенту можно открыть карточку. */
|
||||
| { result: 'busy'; id: number | null; assigneeName: string | null };
|
||||
|
||||
/**
|
||||
* Лид компании по объявлению list.am: последний по связи с общей базой или по ссылке у объекта,
|
||||
* заведённого вручную. `department` — искать только в этом отделе (undefined — во всей компании).
|
||||
*/
|
||||
export async function findListingLead(tx: Tx, companyId: number, externalId: string, listingId: number | null, department?: number | null) {
|
||||
const byUrl = sql`${leads.url} ~ ${`list\\.am/(?:[a-z]{2}/)?item/${externalId}([^0-9]|$)`}`;
|
||||
const [row] = await tx
|
||||
.select({ lead: leads, assigneeName: users.name })
|
||||
.from(leads)
|
||||
.leftJoin(users, eq(users.id, leads.assigneeId))
|
||||
.where(
|
||||
and(
|
||||
eq(leads.companyId, companyId),
|
||||
department === undefined ? undefined : inDepartment(leads.departmentId, department),
|
||||
listingId === null ? byUrl : or(eq(leads.listingId, listingId), byUrl),
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${leads.id} desc`)
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Агент добавил ссылкой объект, который уже есть в CRM: свободный объект из пула закрепляется
|
||||
* за ним, иначе он узнаёт, за кем объект закреплён.
|
||||
*/
|
||||
export async function claimFoundLead(db: Db, actor: Actor, found: { lead: Lead; assigneeName: string | null }): Promise<ClaimResult> {
|
||||
const { lead } = found;
|
||||
if (lead.assigneeId === actor.user.id) return { result: 'mine', id: lead.id };
|
||||
if (lead.assigneeId === null) {
|
||||
const fresh = await statusByCode(db, actor.company.id, 'new');
|
||||
if (lead.statusId === fresh.id) {
|
||||
try {
|
||||
return { result: 'taken', id: (await takeLead(db, actor, lead.id)).id };
|
||||
} catch (error) {
|
||||
// Объект успели взять, пока агент вставлял ссылку: показываем, кто взял.
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ALREADY_TAKEN')) throw error;
|
||||
const [again] = await db.select({ lead: leads, assigneeName: users.name }).from(leads).leftJoin(users, eq(users.id, leads.assigneeId)).where(eq(leads.id, lead.id));
|
||||
return claimFoundLead(db, actor, again!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { result: 'busy', id: canSee(actor, lead) ? lead.id : null, assigneeName: found.assigneeName };
|
||||
}
|
||||
|
||||
/** Объекта ещё нет в CRM компании: лид из объявления сразу закреплён за агентом, без пула и рассылки. */
|
||||
export async function createClaimedLead(db: Db, actor: Actor, listing: Listing): Promise<ClaimResult> {
|
||||
const lead = await db.transaction(async (tx) => {
|
||||
const taken = await statusByCode(tx, actor.company.id, 'taken');
|
||||
const [row] = await tx
|
||||
.insert(leads)
|
||||
.values({
|
||||
companyId: actor.company.id, departmentId: actor.user.departmentId, listingId: listing.id, source: 'listam', url: listing.url, statusId: taken.id,
|
||||
assigneeId: actor.user.id, takenAt: new Date(), createdById: actor.user.id, bumpedAt: listing.bumpedAt, ...copyProperty(listing),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
if (!row) return null;
|
||||
await logEvent(tx, row, actor.user.id, 'created', { newValue: 'link' });
|
||||
await touchCompany(tx, actor.company.id);
|
||||
return row;
|
||||
});
|
||||
if (lead) return { result: 'created', id: lead.id };
|
||||
// Лид по этому объявлению появился одновременно (парсер или другой агент).
|
||||
const found = await findListingLead(db, actor.company.id, listing.externalId, listing.id, actor.user.departmentId);
|
||||
return claimFoundLead(db, actor, found!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление карточки объекта вместе с историей. Удаляет руководитель (и суперадмин в кабинете
|
||||
* компании); карточку со сделкой — только суперадмин: сделка уходит из аналитики, ведомости и счетов.
|
||||
*/
|
||||
export async function deleteLead(db: Db, actor: Actor, leadId: number) {
|
||||
if (!isManager(actor)) throw forbidden();
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
const [deal] = await tx.select({ id: deals.id }).from(deals).where(eq(deals.leadId, lead.id));
|
||||
if (deal) {
|
||||
if (actor.user.role !== 'superadmin') throw forbidden('HAS_DEAL');
|
||||
await dropDealSide(tx, 'lead', lead.id);
|
||||
}
|
||||
await tx.update(leads).set({ previousLeadId: null }).where(eq(leads.previousLeadId, lead.id));
|
||||
await tx.delete(events).where(eq(events.leadId, lead.id));
|
||||
await tx.delete(leads).where(eq(leads.id, lead.id));
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* «Беру». Лид получает ровно один агент: условие «ответственного нет» проверяется самим
|
||||
* UPDATE, поэтому при одновременном нажатии второй запрос не найдёт строку и получит «Уже взят».
|
||||
*/
|
||||
export async function takeLead(db: Db, actor: Actor, leadId: number): Promise<Lead> {
|
||||
return db.transaction(async (tx) => {
|
||||
const fresh = await statusByCode(tx, actor.company.id, 'new');
|
||||
const taken = await statusByCode(tx, actor.company.id, 'taken');
|
||||
const [lead] = await tx
|
||||
.update(leads)
|
||||
.set({
|
||||
assigneeId: actor.user.id,
|
||||
statusId: taken.id,
|
||||
takenAt: new Date(),
|
||||
statusChangedAt: new Date(),
|
||||
poolWarnedAt: null,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(leads.id, leadId),
|
||||
eq(leads.companyId, actor.company.id),
|
||||
// Объект чужого отдела взять нельзя.
|
||||
departmentScope(actor, leads.departmentId),
|
||||
isNull(leads.assigneeId),
|
||||
eq(leads.statusId, fresh.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!lead) {
|
||||
const [exists] = await tx
|
||||
.select({ id: leads.id })
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id), departmentScope(actor, leads.departmentId)));
|
||||
throw exists ? conflict('ALREADY_TAKEN') : notFound('LEAD_NOT_FOUND');
|
||||
}
|
||||
await logEvent(tx, lead, actor.user.id, 'taken');
|
||||
await enqueueTaken(tx, lead.id, actor.user.id);
|
||||
await touchCompany(tx, actor.company.id);
|
||||
return lead;
|
||||
});
|
||||
}
|
||||
|
||||
/** Руководитель назначает или переназначает лид. Оба агента получают уведомление. */
|
||||
export async function assignLead(db: Db, actor: Actor, leadId: number, toUserId: number): Promise<Lead> {
|
||||
if (!isManager(actor)) throw forbidden();
|
||||
return db.transaction(async (tx) => {
|
||||
const [before] = await tx
|
||||
.select()
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id)))
|
||||
.for('update');
|
||||
if (!before) throw notFound('LEAD_NOT_FOUND');
|
||||
const [target] = await tx
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.id, toUserId), eq(users.companyId, actor.company.id), isNull(users.removedAt)));
|
||||
if (!target) throw badRequest('BAD_ASSIGNEE');
|
||||
if (before.assigneeId === toUserId) return before;
|
||||
// Объект переходит в отдел нового ответственного — иначе тот его не увидит. Если в том отделе уже
|
||||
// есть карточка по этому объявлению, вторую туда не переносим.
|
||||
const moving = !sameDepartment(before.departmentId, target.departmentId);
|
||||
if (moving && before.listingId && before.previousLeadId === null) {
|
||||
const [twin] = await tx
|
||||
.select({ id: leads.id })
|
||||
.from(leads)
|
||||
.where(
|
||||
and(
|
||||
eq(leads.companyId, actor.company.id),
|
||||
eq(leads.listingId, before.listingId),
|
||||
isNull(leads.previousLeadId),
|
||||
inDepartment(leads.departmentId, target.departmentId),
|
||||
),
|
||||
);
|
||||
if (twin) throw conflict('DEPARTMENT_HAS_LEAD');
|
||||
}
|
||||
|
||||
const fresh = await statusByCode(tx, actor.company.id, 'new');
|
||||
const taken = await statusByCode(tx, actor.company.id, 'taken');
|
||||
const fromPool = before.assigneeId === null;
|
||||
const [lead] = await tx
|
||||
.update(leads)
|
||||
.set({
|
||||
assigneeId: toUserId,
|
||||
departmentId: target.departmentId,
|
||||
poolWarnedAt: null,
|
||||
statusChangedAt: new Date(),
|
||||
...(before.statusId === fresh.id ? { statusId: taken.id } : {}),
|
||||
...(fromPool ? { takenAt: new Date() } : {}),
|
||||
})
|
||||
.where(eq(leads.id, leadId))
|
||||
.returning();
|
||||
await logEvent(tx, lead!, actor.user.id, 'assigned', {
|
||||
oldValue: before.assigneeId ? String(before.assigneeId) : null,
|
||||
newValue: String(toUserId),
|
||||
});
|
||||
if (fromPool) await enqueueTaken(tx, leadId, toUserId);
|
||||
if (toUserId !== actor.user.id) await enqueueText(tx, [toUserId], 'assigned', [lead!.title], leadId);
|
||||
if (before.assigneeId && before.assigneeId !== actor.user.id) {
|
||||
await enqueueText(tx, [before.assigneeId], 'reassignedAway', [lead!.title, target.name]);
|
||||
}
|
||||
await touchCompany(tx, actor.company.id);
|
||||
return lead!;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Возврат в пул: лид снова «Новый» и без ответственного, история сохраняется.
|
||||
* `actor = null` — возврат по правилу «N часов без смены статуса»: прежний агент получает
|
||||
* уведомление о потере. Остальные участники получают повторное уведомление с кнопкой «Беру».
|
||||
*/
|
||||
export async function returnToPool(tx: Tx, lead: Lead, actorUserId: number | null): Promise<void> {
|
||||
const fresh = await statusByCode(tx, lead.companyId, 'new');
|
||||
const previous = lead.assigneeId;
|
||||
await tx
|
||||
.update(leads)
|
||||
.set({ assigneeId: null, statusId: fresh.id, takenAt: null, statusChangedAt: new Date(), poolWarnedAt: null })
|
||||
.where(eq(leads.id, lead.id));
|
||||
await logEvent(tx, lead, actorUserId, 'pool_return', { oldValue: previous ? String(previous) : null });
|
||||
if (previous && actorUserId === null) await enqueueText(tx, [previous], 'poolLost', [lead.title]);
|
||||
// «Беру» снова получают сотрудники отдела, которому принадлежит объект.
|
||||
const others = (await activeMembers(tx, lead.companyId)).filter((u) => u.id !== previous && sameDepartment(u.departmentId, lead.departmentId));
|
||||
await enqueueLead(tx, lead.id, others, true);
|
||||
}
|
||||
|
||||
export async function releaseLead(db: Db, actor: Actor, leadId: number) {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
if (lead.assigneeId === null) throw badRequest('ALREADY_IN_POOL');
|
||||
assertCanWork(actor, lead);
|
||||
await returnToPool(tx, lead, actor.user.id);
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
export interface StatusChange {
|
||||
statusId: number;
|
||||
rejectReasonId?: number;
|
||||
/** Обязательна при переводе в «Сделка». */
|
||||
deal?: DealBody & { clientId?: number };
|
||||
}
|
||||
|
||||
export async function changeStatus(db: Db, actor: Actor, leadId: number, change: StatusChange): Promise<Lead> {
|
||||
return db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
// Лид в пуле сначала нужно взять.
|
||||
if (lead.assigneeId === null) throw badRequest('TAKE_FIRST');
|
||||
assertCanWork(actor, lead);
|
||||
const statuses = await tx
|
||||
.select()
|
||||
.from(pipelineStatuses)
|
||||
.where(and(eq(pipelineStatuses.companyId, actor.company.id), eq(pipelineStatuses.pipeline, 'object')));
|
||||
const from = statuses.find((s) => s.id === lead.statusId);
|
||||
const to = statuses.find((s) => s.id === change.statusId && !s.deletedAt);
|
||||
if (!to) throw badRequest('BAD_STATUS');
|
||||
if (to.id === lead.statusId) return lead;
|
||||
// «Новый» — это пул: туда лид попадает только возвратом в пул.
|
||||
if (to.code === 'new') throw badRequest('USE_RETURN_TO_POOL');
|
||||
if (from?.code === 'deal' && !isManager(actor)) throw forbidden('DEAL_CLOSED');
|
||||
|
||||
let rejectReasonId: number | null = null;
|
||||
if (to.code === 'rejected') {
|
||||
const [reason] = change.rejectReasonId
|
||||
? await tx
|
||||
.select()
|
||||
.from(rejectReasons)
|
||||
.where(
|
||||
and(
|
||||
eq(rejectReasons.id, change.rejectReasonId),
|
||||
eq(rejectReasons.companyId, actor.company.id),
|
||||
eq(rejectReasons.pipeline, 'object'),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
if (!reason) throw badRequest('REJECT_REASON_REQUIRED');
|
||||
rejectReasonId = reason.id;
|
||||
}
|
||||
let deal: DealInput | null = null;
|
||||
if (to.code === 'deal') {
|
||||
if (!change.deal) throw badRequest('COMMISSION_REQUIRED');
|
||||
deal = dealInput(change.deal, 'owner');
|
||||
await recordDeal(tx, {
|
||||
companyId: actor.company.id,
|
||||
leadId,
|
||||
clientId: change.deal.clientId ?? null,
|
||||
agentId: lead.assigneeId,
|
||||
deal,
|
||||
});
|
||||
// Сделка с клиентом из CRM закрывается один раз: клиент переходит в «Сделка» вместе с объектом.
|
||||
if (change.deal.clientId) {
|
||||
await moveClientToDeal(tx, actor.company.id, actor.user.id, leadId, change.deal.clientId, deal);
|
||||
}
|
||||
} else if (from?.code === 'deal') {
|
||||
await dropDealSide(tx, 'lead', leadId);
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(leads)
|
||||
.set({ statusId: to.id, rejectReasonId, statusChangedAt: new Date(), poolWarnedAt: null })
|
||||
.where(eq(leads.id, leadId))
|
||||
.returning();
|
||||
await logEvent(tx, lead, actor.user.id, to.code === 'deal' ? 'deal' : 'status', {
|
||||
oldValue: from?.name ?? null,
|
||||
newValue: to.name,
|
||||
statusId: to.id,
|
||||
text: deal ? dealText(deal) : null,
|
||||
});
|
||||
await touchCompany(tx, actor.company.id);
|
||||
return updated!;
|
||||
});
|
||||
}
|
||||
|
||||
export async function addComment(db: Db, actor: Actor, leadId: number, text: string) {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
assertCanWork(actor, lead);
|
||||
await logEvent(tx, lead, actor.user.id, 'comment', { text });
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function setNextAction(db: Db, actor: Actor, leadId: number, at: Date | null, note: string | null) {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
assertCanWork(actor, lead);
|
||||
await tx
|
||||
.update(leads)
|
||||
.set({ nextActionAt: at, nextActionNote: note, nextActionNotifiedAt: null })
|
||||
.where(eq(leads.id, leadId));
|
||||
});
|
||||
}
|
||||
|
||||
export interface PhoneVariant {
|
||||
phone: string;
|
||||
/** Сколько компаний указали этот номер. */
|
||||
votes: number;
|
||||
/** 80 — совпал у двух, 100 — у трёх и больше, null — указал один агент. */
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
/** Все варианты номера по объявлению из общей базы: самый подтверждённый — первым. */
|
||||
export async function phoneVariants(tx: Tx, listingId: number): Promise<PhoneVariant[]> {
|
||||
const rows = await tx
|
||||
.select({ phone: listingPhones.phone, votes: sql<number>`count(*)::int`, first: sql<Date>`min(${listingPhones.createdAt})` })
|
||||
.from(listingPhones)
|
||||
.where(eq(listingPhones.listingId, listingId))
|
||||
.groupBy(listingPhones.phone)
|
||||
.orderBy(sql`count(*) desc`, sql`min(${listingPhones.createdAt})`);
|
||||
return rows.map((r) => ({ phone: r.phone, votes: r.votes, confidence: phoneConfidence(r.votes) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Телефон собственника, который агент взял из объявления и внёс вручную. Вариант компании
|
||||
* записывается в общую базу; номером объявления становится самый подтверждённый вариант — он же
|
||||
* работает в правиле «больше N объявлений с номера». Пустая строка стирает номер у лида и вариант компании.
|
||||
*/
|
||||
export async function setLeadPhone(db: Db, actor: Actor, leadId: number, raw: string) {
|
||||
const phone = raw.trim() ? normalizePhone(raw) : null;
|
||||
if (raw.trim() && !phone) throw badRequest('BAD_PHONE');
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
assertCanWork(actor, lead);
|
||||
if (lead.phone === phone) return;
|
||||
await tx.update(leads).set({ phone }).where(eq(leads.id, leadId));
|
||||
if (lead.listingId) {
|
||||
const mine = and(eq(listingPhones.listingId, lead.listingId), eq(listingPhones.companyId, actor.company.id));
|
||||
if (phone) {
|
||||
await tx
|
||||
.insert(listingPhones)
|
||||
.values({ listingId: lead.listingId, companyId: actor.company.id, userId: actor.user.id, phone })
|
||||
.onConflictDoUpdate({ target: [listingPhones.listingId, listingPhones.companyId], set: { phone, userId: actor.user.id, createdAt: new Date() } });
|
||||
} else {
|
||||
await tx.delete(listingPhones).where(mine);
|
||||
}
|
||||
const [best] = await phoneVariants(tx, lead.listingId);
|
||||
await tx.update(listings).set({ phone: best?.phone ?? null }).where(eq(listings.id, lead.listingId));
|
||||
}
|
||||
await logEvent(tx, lead, actor.user.id, 'phone', { newValue: phone });
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
export type LeadEdit = Partial<
|
||||
Pick<Lead, 'title' | 'description' | 'dealType' | 'propertyType' | 'region' | 'district' | 'address' | 'price' | 'currency' | 'rooms' | 'area' | 'floor' | 'floorsTotal' | 'authorName'>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Правка данных объекта. Меняется только карточка компании — общая база объявлений остаётся как есть.
|
||||
* Текст для клиента собирается заново из новых данных.
|
||||
*/
|
||||
export async function editLead(db: Db, actor: Actor, leadId: number, patch: LeadEdit) {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
assertCanWork(actor, lead);
|
||||
const changed = Object.fromEntries(
|
||||
Object.entries(patch).filter(([key, value]) => String(lead[key as keyof LeadEdit] ?? '') !== String(value ?? '')),
|
||||
) as LeadEdit;
|
||||
if (!Object.keys(changed).length) return;
|
||||
await tx.update(leads).set({ ...changed, showcaseText: null }).where(eq(leads.id, leadId));
|
||||
await logEvent(tx, lead, actor.user.id, 'edited');
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* «Объявления больше нет»: агент увидел, что объявление сняли, раньше суточной проверки.
|
||||
* Отметка ставится в общей базе — у всех компаний с этим объектом; если объявление снова появится
|
||||
* в списке list.am, парсер вернёт его на сайт сам.
|
||||
*/
|
||||
export async function markListingRemoved(db: Db, actor: Actor, leadId: number) {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
if (lead.assigneeId !== null) assertCanWork(actor, lead);
|
||||
if (!lead.listingId) throw badRequest('NO_LISTING');
|
||||
const now = new Date();
|
||||
const [row] = await tx
|
||||
.update(listings)
|
||||
.set({ active: false, removedAt: now, lastCheckedAt: now })
|
||||
.where(and(eq(listings.id, lead.listingId), eq(listings.active, true)))
|
||||
.returning({ id: listings.id });
|
||||
if (!row) return;
|
||||
await logEvent(tx, lead, actor.user.id, 'listing_removed');
|
||||
await rejectRemovedListingLeads(tx, row.id);
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Объявление сняли с List.am: у компаний с настройкой autoRejectRemoved открытые лиды по нему
|
||||
* (не «Сделка» и не «Отказ») уходят в «Отказ» с причиной «Уже продано или сдано» — в архив,
|
||||
* из пула и рабочих списков. Возвращает число переведённых лидов.
|
||||
*/
|
||||
export async function rejectRemovedListingLeads(tx: Tx, listingId: number): Promise<number> {
|
||||
const open = await tx
|
||||
.select({ lead: leads, status: pipelineStatuses.name })
|
||||
.from(leads)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.innerJoin(companies, eq(companies.id, leads.companyId))
|
||||
.where(
|
||||
and(
|
||||
eq(leads.listingId, listingId),
|
||||
sql`coalesce(${pipelineStatuses.code}, '') not in ('deal', 'rejected')`,
|
||||
sql`(${companies.settings}->>'autoRejectRemoved')::boolean is true`,
|
||||
),
|
||||
);
|
||||
for (const { lead, status } of open) {
|
||||
const rejected = await statusByCode(tx, lead.companyId, 'rejected');
|
||||
// У компаний, заведённых до кода 'gone', причина находится по названию.
|
||||
const [reason] = await tx
|
||||
.select({ id: rejectReasons.id })
|
||||
.from(rejectReasons)
|
||||
.where(
|
||||
and(
|
||||
eq(rejectReasons.companyId, lead.companyId),
|
||||
eq(rejectReasons.pipeline, 'object'),
|
||||
or(eq(rejectReasons.code, 'gone'), and(isNull(rejectReasons.code), eq(rejectReasons.name, 'Уже продано или сдано'))),
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${rejectReasons.code} nulls last`)
|
||||
.limit(1);
|
||||
await tx
|
||||
.update(leads)
|
||||
.set({ statusId: rejected.id, rejectReasonId: reason?.id ?? null, statusChangedAt: new Date(), poolWarnedAt: null, nextActionAt: null })
|
||||
.where(eq(leads.id, lead.id));
|
||||
await logEvent(tx, lead, null, 'status', {
|
||||
oldValue: status,
|
||||
newValue: rejected.name,
|
||||
statusId: rejected.id,
|
||||
text: 'Объявление снято с List.am',
|
||||
});
|
||||
}
|
||||
return open.length;
|
||||
}
|
||||
|
||||
/** «Это агент»: номер автора — в чёрный список компании (если он известен), лид — в «Отказ». */
|
||||
export async function markAsAgent(db: Db, actor: Actor, leadId: number): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
const lead = await loadLead(tx, actor, leadId);
|
||||
if (lead.assigneeId !== null) assertCanWork(actor, lead);
|
||||
const rejected = await statusByCode(tx, actor.company.id, 'rejected');
|
||||
const [reason] = await tx
|
||||
.select()
|
||||
.from(rejectReasons)
|
||||
.where(
|
||||
and(
|
||||
eq(rejectReasons.companyId, actor.company.id),
|
||||
eq(rejectReasons.pipeline, 'object'),
|
||||
eq(rejectReasons.code, 'agent'),
|
||||
),
|
||||
);
|
||||
if (lead.phone) {
|
||||
await tx
|
||||
.insert(blacklist)
|
||||
.values({ companyId: actor.company.id, phone: lead.phone, addedById: actor.user.id })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
const wasInPool = lead.assigneeId === null;
|
||||
await tx
|
||||
.update(leads)
|
||||
.set({
|
||||
statusId: rejected.id,
|
||||
rejectReasonId: reason?.id ?? null,
|
||||
assigneeId: lead.assigneeId ?? actor.user.id,
|
||||
statusChangedAt: new Date(),
|
||||
poolWarnedAt: null,
|
||||
})
|
||||
.where(eq(leads.id, leadId));
|
||||
await logEvent(tx, lead, actor.user.id, 'blacklisted', { newValue: lead.phone, statusId: rejected.id });
|
||||
if (wasInPool) await enqueueTaken(tx, leadId, actor.user.id);
|
||||
await touchCompany(tx, actor.company.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Фоновые задачи по лидам, запускаются раз в минуту:
|
||||
* предупреждение за 2 часа до возврата в пул, сам возврат, напоминания о следующем действии.
|
||||
*/
|
||||
export async function runLeadJobs(db: Db, now = new Date()): Promise<{ warned: number; returned: number; reminded: number }> {
|
||||
const result = { warned: 0, returned: 0, reminded: 0 };
|
||||
const due = await db
|
||||
.select({ lead: leads, hours: sql<number>`(${companies.settings}->>'poolReturnHours')::int` })
|
||||
.from(leads)
|
||||
.innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId))
|
||||
.innerJoin(companies, eq(companies.id, leads.companyId))
|
||||
.where(
|
||||
and(
|
||||
sql`${leads.assigneeId} is not null`,
|
||||
eq(pipelineStatuses.poolReturn, true),
|
||||
// Предупреждение уходит за 2 часа до срока; при сроке меньше 2 часов — сразу.
|
||||
sql`${leads.statusChangedAt} <= ${now.toISOString()}::timestamptz - make_interval(hours => greatest((${companies.settings}->>'poolReturnHours')::int - 2, 0))`,
|
||||
),
|
||||
);
|
||||
for (const { lead, hours } of due) {
|
||||
const expired = lead.statusChangedAt.getTime() <= now.getTime() - hours * 3_600_000;
|
||||
await db.transaction(async (tx) => {
|
||||
// Перечитываем под блокировкой: агент мог сменить статус, пока шла выборка.
|
||||
const [current] = await tx.select().from(leads).where(eq(leads.id, lead.id)).for('update');
|
||||
if (!current || current.assigneeId === null || current.statusChangedAt.getTime() !== lead.statusChangedAt.getTime()) return;
|
||||
if (expired) {
|
||||
await returnToPool(tx, current, null);
|
||||
result.returned++;
|
||||
} else if (!current.poolWarnedAt) {
|
||||
const left = Math.max(1, Math.round((current.statusChangedAt.getTime() + hours * 3_600_000 - now.getTime()) / 3_600_000));
|
||||
await enqueueText(tx, [current.assigneeId], 'poolWarn', [current.title, left], current.id);
|
||||
await tx.update(leads).set({ poolWarnedAt: now }).where(eq(leads.id, current.id));
|
||||
result.warned++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const reminders = await db
|
||||
.update(leads)
|
||||
.set({ nextActionNotifiedAt: now })
|
||||
.where(
|
||||
and(
|
||||
sql`${leads.nextActionAt} <= ${now.toISOString()}::timestamptz`,
|
||||
isNull(leads.nextActionNotifiedAt),
|
||||
sql`${leads.assigneeId} is not null`,
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
for (const lead of reminders) {
|
||||
await enqueueText(db, [lead.assigneeId!], 'reminder', [lead.title, lead.nextActionNote], lead.id);
|
||||
result.reminded++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export { loadLead, statusByCode };
|
||||
@@ -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 ?? ''}`;
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 = `
|
||||
## Недвижимость
|
||||
|
||||
Топ объявления
|
||||
|
||||
[ $1,600,000 Склад в Каракерте, 20,000 кв.м. Монолит, 20,000 кв.м. Каракерт](https://www.list.am/ru/item/24154105?ld_src=2)
|
||||
|
||||
Обычные объявления
|
||||
|
||||
[ $299,000 4-комн. квартира на ул. Налбандяна в центре, 155 кв.м., 3/9 этаж 4 ком., 155 кв.м., 3/9 этаж Кентрон](https://www.list.am/ru/item/14290392?ld_src=2)
|
||||
|
||||
[ 650,000֏в месяц 2-комн. квартира в новостройке на пр. Аршакуняц в центре, 56 кв.м., 2/18 этаж 2 ком., 56 кв.м., 2/18 этаж Недвижимость не проверена Агентство Кентрон](https://www.list.am/ru/item/23836325?ld_src=2)
|
||||
|
||||
[ 28,750,000֏ 2-комн. квартира в новостройке, 41-я улица в Мецаморе, 87 кв.м. 2 ком., 87 кв.м. Мецамор](https://www.list.am/ru/item/995117414?ld_src=2)
|
||||
|
||||
[ €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 = `
|
||||
Топ объявления
|
||||
|
||||
[ $50,000 1-комн. квартира, 30 кв.м. Арабкир](https://www.list.am/ru/item/111?ld_src=2)
|
||||
|
||||
Обычные объявления
|
||||
|
||||
[ $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 = `Обычные объявления
|
||||
|
||||
[ $90,000 Дом на ул. Самвела Варданяна, 120 кв.м. 3 ком., 120 кв.м. Бюрегаван](https://www.list.am/ru/item/995000001?ld_src=2)
|
||||
|
||||
[ $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>Вторая & третья</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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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) };
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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Проверьте район в <фильтре>');
|
||||
|
||||
// Ответ пользователя снова поднимает обращение и уведомляет суперадмина.
|
||||
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Вот <договор>' });
|
||||
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(['Здравствуйте! Вот <презентация>', `[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([
|
||||
'🆕 Новая регистрация: Карен <Новый>\nkaren@test.am',
|
||||
'🏢 Новая компания «Агентство Карена», тариф team\nРуководитель: Карен <Новый>\nkaren@test.am',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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()));
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { Lang } from '@lr/shared';
|
||||
|
||||
/** Тексты бота. Сообщения уходят в HTML-разметке, поэтому пользовательские строки экранируются. */
|
||||
export const esc = (v: string) => v.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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 мин)');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user