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:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -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}`);
|
||||
Reference in new issue
Block a user