import 'dotenv/config'; import { readFileSync } from 'node:fs'; import bodyParser from 'body-parser'; import cors from 'cors'; import express from 'express'; import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@as-integrations/express5'; import { createMessengerStartSession, consumeMessengerStartSession, extractAuthTokenFromRequest, hasMessengerStartSession, issueTemporaryLoginToken, verifyAccessToken, } from './auth.js'; import { buildContext } from './context.js'; import { sendMessengerMessage } from './messenger.js'; import { prisma } from './prisma-client.js'; import { resolvers } from './resolvers.js'; const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8'); const server = new ApolloServer({ typeDefs, resolvers, }); await server.start(); const app = express(); app.use(cors()); app.use(bodyParser.json({ limit: '1mb' })); app.get('/healthz', (_, res) => { res.json({ status: 'ok' }); }); function buildDefaultFullName(email) { const localPart = email.split('@')[0]?.trim(); if (!localPart) { return 'Новый пользователь'; } return localPart .replace(/[._-]+/g, ' ') .split(' ') .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } async function resolveUserForMessenger({ userId, email }) { if (userId) { const byId = await prisma.user.findUnique({ where: { id: userId } }); if (byId) { return byId; } } if (!email) { return null; } const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail) { return null; } return prisma.user.upsert({ where: { email: normalizedEmail }, update: {}, create: { email: normalizedEmail, fullName: buildDefaultFullName(normalizedEmail), role: 'CLIENT', }, }); } function normalizeRedirectPath(value) { const redirectPath = String(value || '').trim(); if (!redirectPath.startsWith('/')) { return ''; } return redirectPath; } async function resolveAuthenticatedUserFromRequest(req) { const authToken = extractAuthTokenFromRequest(req); const auth = verifyAccessToken(authToken); if (!auth?.userId) { return null; } return prisma.user.findUnique({ where: { id: auth.userId }, }); } app.post('/auth/messenger-start', async (req, res) => { const channel = String(req.body?.channel || '').toUpperCase(); if (channel !== 'TELEGRAM' && channel !== 'MAX') { res.status(400).json({ error: 'Unsupported channel.' }); return; } const authenticatedUser = await resolveAuthenticatedUserFromRequest(req); const providedEmail = String(req.body?.email || '').trim().toLowerCase(); const email = authenticatedUser?.email?.trim().toLowerCase() || providedEmail; const userId = authenticatedUser?.id ?? null; const redirectPath = normalizeRedirectPath(req.body?.redirectPath); if (!userId && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { res.status(400).json({ error: 'A valid email is required.' }); return; } const session = createMessengerStartSession({ channel, email, userId, redirectPath, }); res.json({ ok: true, startToken: session.startToken, expiresAt: session.expiresAt.toISOString(), mode: userId ? 'connect' : 'login', }); }); app.post('/bot/messenger-login', async (req, res) => { const webhookToken = process.env.BOT_LOGIN_WEBHOOK_TOKEN; const providedToken = req.body?.token; if (!webhookToken || providedToken !== webhookToken) { res.status(401).json({ error: 'Unauthorized bot request.' }); return; } const channel = String(req.body?.channel || '').toUpperCase(); if (channel !== 'TELEGRAM' && channel !== 'MAX') { res.status(400).json({ error: 'Unsupported channel.' }); return; } const startToken = String(req.body?.startToken || '').trim(); const channelId = String(req.body?.channelId || '').trim(); const skipDispatch = req.body?.skipDispatch === true; if (!channelId || !startToken) { res.status(400).json({ error: 'channelId and startToken are required.' }); return; } if (!hasMessengerStartSession(startToken)) { res.status(400).json({ error: 'Messenger start token is invalid or expired.' }); return; } const startSession = consumeMessengerStartSession(startToken); if (startSession.channel !== channel) { res.status(400).json({ error: 'Start token channel mismatch.' }); return; } const user = await resolveUserForMessenger({ userId: startSession.userId, email: startSession.email, }); if (!user) { res.status(404).json({ error: 'User not found.' }); return; } const login = issueTemporaryLoginToken({ userId: user.id, messengerConnection: { type: channel, channelId, }, }); const frontendUrl = ( process.env.WEB_FRONTEND_URL || process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000' ).replace(/\/$/, ''); const nextPath = startSession.redirectPath || ( startSession.userId ? `/profile/notifications?status=success&connected=${channel.toLowerCase()}` : '' ); const loginQuery = new URLSearchParams({ login_token: login.loginToken, }); if (nextPath) { loginQuery.set('next', nextPath); } const loginUrl = `${frontendUrl}/login?${loginQuery.toString()}`; if (!skipDispatch) { const dispatch = await sendMessengerMessage({ type: channel, channelId, message: 'Вход подтвержден. Нажмите кнопку, чтобы открыть личный кабинет.', buttonUrl: loginUrl, buttonText: 'Открыть кабинет', }); if (!dispatch.success) { res.status(502).json({ error: dispatch.detail }); return; } } res.json({ ok: true, mode: startSession.userId ? 'connect' : 'login', loginUrl, expiresAt: login.expiresAt.toISOString(), }); }); app.use( '/graphql', expressMiddleware(server, { context: async ({ req }) => buildContext(req), }), ); const port = Number(process.env.PORT ?? 4000); app.listen(port, () => { console.log(`apollo-backend running at http://localhost:${port}/graphql`); }); async function shutdown() { await server.stop(); await prisma.$disconnect(); } process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);