Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
310 lines
18 KiB
TypeScript
310 lines
18 KiB
TypeScript
import cookie from '@fastify/cookie';
|
|
import cors from '@fastify/cors';
|
|
import multipart, { type MultipartFile } from '@fastify/multipart';
|
|
import * as Sentry from '@sentry/node';
|
|
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
|
import { getFlow } from '@barev/core';
|
|
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffChecklistItem, staffSend, startTextOverrides } from '@barev/services';
|
|
import { getFile, putFile } from '@barev/services/storage';
|
|
import { TelegramSender } from '@barev/services/telegram';
|
|
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
|
import { ADMIN_COOKIE, findSession } from './auth/admin.ts';
|
|
import { validateInitData } from './auth/telegram.ts';
|
|
import { env } from './env.ts';
|
|
import { ctx as svc, db, ensureBucket, putObject } from './infra.ts';
|
|
import { appRouter, type AppRouter } from './router.ts';
|
|
import { channelsFor } from './routers/backoffice/chats.ts';
|
|
import { createContext, type StaffRole } from './trpc.ts';
|
|
|
|
/** Скачивание файлов, присланных ботам, — по их токенам */
|
|
const clientBotFiles = new TelegramSender(env.CLIENT_BOT_TOKEN, 'client-bot');
|
|
const proBotFiles = new TelegramSender(env.PRO_BOT_TOKEN, 'pro-bot');
|
|
|
|
if (env.SENTRY_DSN) Sentry.init({ dsn: env.SENTRY_DSN, environment: env.NODE_ENV });
|
|
|
|
const app = Fastify({
|
|
logger: { level: env.isProd ? 'info' : 'warn', redact: ['req.headers.authorization', 'req.headers.cookie'] },
|
|
bodyLimit: 1024 * 1024,
|
|
trustProxy: true,
|
|
// tRPC склеивает несколько запросов в один путь (/trpc/a,b,c) — по умолчанию Fastify режет параметр после 100 символов (414)
|
|
routerOptions: { maxParamLength: 5000 },
|
|
});
|
|
|
|
await app.register(cors, { origin: env.corsOrigins, credentials: true });
|
|
await app.register(cookie);
|
|
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1 } });
|
|
|
|
await app.register(fastifyTRPCPlugin, {
|
|
prefix: '/trpc',
|
|
trpcOptions: {
|
|
router: appRouter,
|
|
createContext,
|
|
onError({ error, path }) {
|
|
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
|
app.log.error({ path, err: error.cause ?? error }, 'trpc error');
|
|
if (env.SENTRY_DSN) Sentry.captureException(error.cause ?? error);
|
|
}
|
|
},
|
|
} satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'],
|
|
});
|
|
|
|
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic']);
|
|
|
|
/**
|
|
* PRO-4: фото к пункту чек-листа (фотоотчёт клинера).
|
|
* multipart: orderId, code, file. Авторизация — initData бота исполнителей.
|
|
*/
|
|
app.post('/files/pro/checklist-photo', async (req, reply) => {
|
|
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;
|
|
const init = validateInitData(auth, env.proBotToken, env.TG_INIT_DATA_MAX_AGE);
|
|
if (!init.ok) return reply.code(401).send({ error: `INIT_DATA_${init.reason}` });
|
|
const identity = await proIdentity(db, BigInt(init.user.id));
|
|
if (!identity?.provider) return reply.code(403).send({ error: 'NOT_PROVIDER' });
|
|
|
|
const part = await req.file();
|
|
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
|
|
const field = (name: string) => {
|
|
const f = part.fields[name];
|
|
return f && !Array.isArray(f) && f.type === 'field' ? String(f.value) : undefined;
|
|
};
|
|
const orderId = field('orderId');
|
|
const code = field('code');
|
|
if (!orderId || !code) return reply.code(400).send({ error: 'BAD_INPUT' });
|
|
if (!IMAGE_TYPES.has(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
|
|
|
|
const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } });
|
|
if (!order || order.providerId !== identity.provider.id) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
if (!getFlow(order.service.flow).checklistAt.includes(order.status)) return reply.code(409).send({ error: 'TRANSITION_NOT_ALLOWED' });
|
|
|
|
const body = await part.toBuffer();
|
|
const key = await putObject(`orders/${order.id}`, body, part.mimetype);
|
|
const file = await db.orderFile.create({
|
|
data: { orderId: order.id, kind: 'PHOTO_REPORT', storageKey: key, mime: part.mimetype, size: body.length, uploadedBy: 'PROVIDER', uploadedById: identity.provider.id },
|
|
});
|
|
try {
|
|
await setChecklistItem(svc, { orderId: order.id, providerId: identity.provider.id, code, checked: true, fileId: file.id });
|
|
} catch (e) {
|
|
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
|
|
throw e;
|
|
}
|
|
return { fileId: file.id };
|
|
});
|
|
|
|
// ───── файлы в чатах ─────
|
|
|
|
/** Что можно прислать в чат: картинки, PDF, офисные документы, текст, архив */
|
|
const CHAT_TYPES = /^(image\/(jpeg|png|webp|gif|heic)|application\/pdf|application\/msword|application\/vnd\.openxmlformats-officedocument\.[\w.]+|application\/vnd\.ms-excel|text\/plain|application\/zip)$/;
|
|
|
|
/** Сотрудник новой админки по cookie-сессии и каналы, которые ему доступны */
|
|
async function staffFromCookie(req: FastifyRequest) {
|
|
const session = await findSession(db, req.cookies[ADMIN_COOKIE]);
|
|
if (!session?.verified || session.adminUser.roles.length === 0) return null;
|
|
const roles = session.adminUser.roles as StaffRole[];
|
|
return { admin: session.adminUser, channels: channelsFor(roles, roles.includes('SUPERADMIN')) };
|
|
}
|
|
|
|
function multipartField(part: MultipartFile, name: string) {
|
|
const f = part.fields[name];
|
|
return f && !Array.isArray(f) && f.type === 'field' ? String(f.value) : undefined;
|
|
}
|
|
|
|
/** Имя файла как пришло, но без путей и управляющих символов */
|
|
const safeName = (name: string | undefined) => (name ?? 'file').replace(/[\\/\u0000-\u001f]/g, '_').slice(-120) || 'file';
|
|
|
|
/** Команда отправляет файл в диалог (или во внутреннюю заметку) */
|
|
app.post<{ Params: { threadId: string } }>('/files/bo/chat/:threadId', async (req, reply) => {
|
|
const staff = await staffFromCookie(req);
|
|
if (!staff) return reply.code(401).send({ error: 'ADMIN_LOGIN_REQUIRED' });
|
|
const thread = await db.chatThread.findUnique({ where: { id: req.params.threadId } });
|
|
if (!thread) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
if (!staff.channels.includes(thread.channel)) return reply.code(403).send({ error: 'ROLE_FORBIDDEN' });
|
|
const part = await req.file();
|
|
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
|
|
if (!CHAT_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
|
|
const body = await part.toBuffer();
|
|
const fileName = safeName(part.filename);
|
|
const fileKey = await putFile(`chats/${thread.id}`, body, part.mimetype, fileName);
|
|
try {
|
|
const m = await staffSend(svc, {
|
|
threadId: thread.id,
|
|
adminUserId: staff.admin.id,
|
|
text: multipartField(part, 'text') ?? '',
|
|
internal: multipartField(part, 'internal') === 'true',
|
|
file: { fileKey, fileName, mime: part.mimetype, size: body.length },
|
|
});
|
|
await db.auditLog.create({ data: { adminUserId: staff.admin.id, action: 'bo.chats.sendFile', entity: 'chats', entityId: thread.id, data: { fileName, size: body.length } } });
|
|
return { id: m.id };
|
|
} catch (e) {
|
|
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
/** Отдать файл сообщения: из нашего хранилища или из Telegram (бот скачивает по file_id) */
|
|
async function sendChatFile(reply: FastifyReply, m: { fileKey: string | null; tgFileId: string | null; tgBot: string | null; mime: string | null; fileName: string | null }) {
|
|
reply.header('cache-control', 'private, max-age=3600');
|
|
reply.header('content-type', m.mime ?? 'application/octet-stream');
|
|
reply.header('content-disposition', `inline; filename*=UTF-8''${encodeURIComponent(m.fileName ?? 'file')}`);
|
|
reply.header('x-content-type-options', 'nosniff');
|
|
if (m.fileKey) return reply.send(await getFile(m.fileKey));
|
|
if (m.tgFileId) {
|
|
const res = await (m.tgBot === 'pro' ? proBotFiles : clientBotFiles).download(m.tgFileId);
|
|
if (!res?.body) return reply.code(404).send({ error: 'FILE_UNAVAILABLE' });
|
|
return reply.send(Buffer.from(await res.arrayBuffer()));
|
|
}
|
|
return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
}
|
|
|
|
app.get<{ Params: { messageId: string } }>('/files/bo/chat-file/:messageId', async (req, reply) => {
|
|
const staff = await staffFromCookie(req);
|
|
if (!staff) return reply.code(401).send({ error: 'ADMIN_LOGIN_REQUIRED' });
|
|
const m = await db.conciergeMessage.findUnique({ where: { id: req.params.messageId }, include: { thread: { select: { channel: true } } } });
|
|
if (!m?.thread || !staff.channels.includes(m.thread.channel)) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
return sendChatFile(reply, m);
|
|
});
|
|
|
|
// ───── исполнители и заказы (этап 3) ─────
|
|
|
|
/** Заказы и исполнителей ведёт поддержка; суперадмину — всё */
|
|
const canDispatch = (staff: NonNullable<Awaited<ReturnType<typeof staffFromCookie>>>) => staff.admin.roles.includes('SUPERADMIN') || staff.admin.roles.includes('SUPPORT');
|
|
const DOC_TYPES = /^(image\/(jpeg|png|webp|heic)|application\/pdf)$/;
|
|
|
|
/** Документ для допуска исполнителя: паспорт, договор… (фото или PDF) */
|
|
app.post<{ Params: { providerId: string } }>('/files/bo/provider-doc/:providerId', async (req, reply) => {
|
|
const staff = await staffFromCookie(req);
|
|
if (!staff || !canDispatch(staff)) return reply.code(staff ? 403 : 401).send({ error: staff ? 'ROLE_FORBIDDEN' : 'ADMIN_LOGIN_REQUIRED' });
|
|
const provider = await db.provider.findUnique({ where: { id: req.params.providerId } });
|
|
if (!provider) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
const part = await req.file();
|
|
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
|
|
if (!DOC_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
|
|
const body = await part.toBuffer();
|
|
const fileName = safeName(part.filename);
|
|
const fileKey = await putFile(`providers/${provider.id}`, body, part.mimetype, fileName);
|
|
const title = (multipartField(part, 'title') ?? '').trim().slice(0, 120) || fileName;
|
|
const doc = await db.providerDoc.create({ data: { providerId: provider.id, title, fileKey, fileName, mime: part.mimetype, size: body.length, uploadedById: staff.admin.id } });
|
|
await db.auditLog.create({ data: { adminUserId: staff.admin.id, action: 'bo.providers.addDoc', entity: 'providers', entityId: provider.id, data: { title } } });
|
|
return { id: doc.id };
|
|
});
|
|
|
|
/** Команда загружает фото к пункту чек-листа за исполнителя и отмечает пункт */
|
|
app.post<{ Params: { orderId: string } }>('/files/bo/checklist-photo/:orderId', async (req, reply) => {
|
|
const staff = await staffFromCookie(req);
|
|
if (!staff || !canDispatch(staff)) return reply.code(staff ? 403 : 401).send({ error: staff ? 'ROLE_FORBIDDEN' : 'ADMIN_LOGIN_REQUIRED' });
|
|
const order = await db.order.findUnique({ where: { id: req.params.orderId } });
|
|
if (!order) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
const part = await req.file();
|
|
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
|
|
const code = multipartField(part, 'code');
|
|
if (!code) return reply.code(400).send({ error: 'BAD_INPUT' });
|
|
if (!IMAGE_TYPES.has(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
|
|
const body = await part.toBuffer();
|
|
const key = await putObject(`orders/${order.id}`, body, part.mimetype);
|
|
const file = await db.orderFile.create({ data: { orderId: order.id, kind: 'PHOTO_REPORT', storageKey: key, mime: part.mimetype, size: body.length, uploadedBy: 'ADMIN', uploadedById: staff.admin.id } });
|
|
try {
|
|
await staffChecklistItem(svc, { orderId: order.id, adminId: staff.admin.id, code, checked: true, fileId: file.id });
|
|
} catch (e) {
|
|
if (e instanceof DomainError) return reply.code(400).send({ error: e.code });
|
|
throw e;
|
|
}
|
|
return { fileId: file.id };
|
|
});
|
|
|
|
app.get<{ Params: { docId: string } }>('/files/bo/provider-doc-file/:docId', async (req, reply) => {
|
|
const staff = await staffFromCookie(req);
|
|
if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
const d = await db.providerDoc.findUnique({ where: { id: req.params.docId } });
|
|
if (!d) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
return sendChatFile(reply, { fileKey: d.fileKey, tgFileId: null, tgBot: null, mime: d.mime, fileName: d.fileName });
|
|
});
|
|
|
|
/** Фотоотчёт по заказу; каждый просмотр — в журнал доступа */
|
|
app.get<{ Params: { fileId: string } }>('/files/bo/order-file/:fileId', async (req, reply) => {
|
|
const staff = await staffFromCookie(req);
|
|
if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
const f = await db.orderFile.findUnique({ where: { id: req.params.fileId } });
|
|
if (!f || f.deletedAt) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
await db.fileAccessLog.create({ data: { fileId: f.id, actorType: 'ADMIN', actorId: staff.admin.id } });
|
|
return sendChatFile(reply, { fileKey: f.storageKey, tgFileId: null, tgBot: null, mime: f.mime, fileName: `order-photo.${f.mime.split('/')[1] ?? 'jpg'}` });
|
|
});
|
|
|
|
// ───── CRM продажника (этап 4) ─────
|
|
|
|
/** Продажник — свои лиды; суперадмин — все */
|
|
async function leadForStaff(req: FastifyRequest, leadId: string) {
|
|
const staff = await staffFromCookie(req);
|
|
if (!staff) return null;
|
|
const isSuper = staff.admin.roles.includes('SUPERADMIN');
|
|
if (!isSuper && !staff.admin.roles.includes('SALES')) return null;
|
|
const lead = await db.salesLead.findUnique({ where: { id: leadId } });
|
|
if (!lead || (!isSuper && lead.ownerId !== staff.admin.id)) return null;
|
|
return { staff, lead };
|
|
}
|
|
|
|
/** Фото витрины, меню, договор — в историю лида */
|
|
app.post<{ Params: { leadId: string } }>('/files/bo/lead/:leadId', async (req, reply) => {
|
|
const found = await leadForStaff(req, req.params.leadId);
|
|
if (!found) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
const part = await req.file();
|
|
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
|
|
if (!CHAT_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
|
|
const body = await part.toBuffer();
|
|
const fileName = safeName(part.filename);
|
|
const fileKey = await putFile(`leads/${found.lead.id}`, body, part.mimetype, fileName);
|
|
const a = await db.salesActivity.create({
|
|
data: { leadId: found.lead.id, authorId: found.staff.admin.id, type: 'FILE', text: (multipartField(part, 'text') ?? '').trim().slice(0, 2000) || null, fileKey, fileName, mime: part.mimetype, size: body.length },
|
|
});
|
|
return { id: a.id };
|
|
});
|
|
|
|
app.get<{ Params: { activityId: string } }>('/files/bo/lead-file/:activityId', async (req, reply) => {
|
|
const a = await db.salesActivity.findUnique({ where: { id: req.params.activityId } });
|
|
if (!a?.fileKey || !(await leadForStaff(req, a.leadId))) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
return sendChatFile(reply, { fileKey: a.fileKey, tgFileId: null, tgBot: null, mime: a.mime, fileName: a.fileName });
|
|
});
|
|
|
|
/** Мини-апп клиента: клиент видит только файлы своей переписки (без заметок команды) */
|
|
function clientFromInit(req: FastifyRequest) {
|
|
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;
|
|
const init = validateInitData(auth, env.clientBotToken, env.TG_INIT_DATA_MAX_AGE);
|
|
return init.ok ? getClientByTelegramId(db, BigInt(init.user.id)) : Promise.resolve(null);
|
|
}
|
|
|
|
app.get<{ Params: { messageId: string } }>('/files/client/chat-file/:messageId', async (req, reply) => {
|
|
const client = await clientFromInit(req);
|
|
if (!client) return reply.code(401).send({ error: 'UNAUTHORIZED' });
|
|
const m = await db.conciergeMessage.findUnique({ where: { id: req.params.messageId } });
|
|
if (!m || m.clientId !== client.id || m.internal) return reply.code(404).send({ error: 'NOT_FOUND' });
|
|
return sendChatFile(reply, m);
|
|
});
|
|
|
|
app.post('/files/client/chat', async (req, reply) => {
|
|
const client = await clientFromInit(req);
|
|
if (!client) return reply.code(401).send({ error: 'UNAUTHORIZED' });
|
|
if (!client.consentAt) return reply.code(412).send({ error: 'CONSENT_REQUIRED' });
|
|
const part = await req.file();
|
|
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
|
|
if (!CHAT_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
|
|
const body = await part.toBuffer();
|
|
const fileName = safeName(part.filename);
|
|
const fileKey = await putFile(`chats/client-${client.id}`, body, part.mimetype, fileName);
|
|
const res = await receiveMessage(svc, { clientId: client.id }, { text: multipartField(part, 'text') ?? '', source: 'MINIAPP', file: { fileKey, fileName, mime: part.mimetype, size: body.length } });
|
|
return { id: res?.message.id ?? null };
|
|
});
|
|
|
|
/** Ошибки из Mini App и админки (sendBeacon) — только в лог, без хранения */
|
|
app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
|
|
const b = (typeof req.body === 'object' && req.body) || {};
|
|
app.log.warn({ clientError: b, ua: req.headers['user-agent']?.slice(0, 160), host: req.headers.host }, 'client error');
|
|
return reply.code(204).send();
|
|
});
|
|
|
|
app.get('/health', async () => ({ ok: true }));
|
|
|
|
// тексты из админки поверх переводов (обновляются раз в минуту)
|
|
startTextOverrides(db);
|
|
|
|
await ensureBucket().catch((e) => app.log.warn({ err: e }, 'S3 недоступно — загрузка фото работать не будет'));
|
|
await app.listen({ port: env.API_PORT, host: '0.0.0.0' });
|
|
console.log(`API: http://localhost:${env.API_PORT}`);
|