Files
apollo-backend/src/server.js
2026-04-03 18:25:12 +07:00

247 lines
6.2 KiB
JavaScript

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);