Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import multipart 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, proIdentity, setChecklistItem } from '@barev/services';
import Fastify from 'fastify';
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 { createContext } from './trpc.ts';
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 };
});
/** Ошибки из 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 }));
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}`);