This commit is contained in:
talorr
2026-03-27 03:36:08 +03:00
parent 8a97ce6d54
commit cda36918e8
225 changed files with 35641 additions and 0 deletions

11
chat-service/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run prisma:generate
RUN npm run build
EXPOSE 4050
CMD ["npm", "run", "start:with-db-url"]

38
chat-service/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "betting-signals-chat-service",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/src/index.js",
"start:with-db-url": "node scripts/with-db-url.mjs node dist/src/index.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:deploy:with-db-url": "node scripts/with-db-url.mjs npx prisma migrate deploy"
},
"dependencies": {
"@prisma/client": "^6.6.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"socket.io": "^4.8.3",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.1",
"@types/jsonwebtoken": "^9.0.9",
"@types/morgan": "^1.9.9",
"@types/node": "^22.13.13",
"prisma": "^6.6.0",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}

View File

@@ -0,0 +1,43 @@
CREATE TYPE "UserRole" AS ENUM ('admin', 'user');
CREATE TYPE "SupportConversationStatus" AS ENUM ('open', 'closed');
CREATE TABLE "SupportConversation" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"userEmail" TEXT NOT NULL,
"assignedAdminId" TEXT,
"assignedAdminEmail" TEXT,
"status" "SupportConversationStatus" NOT NULL DEFAULT 'open',
"userLastReadAt" TIMESTAMP(3),
"adminLastReadAt" TIMESTAMP(3),
"lastMessageAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUserMessageAt" TIMESTAMP(3),
"lastAdminMessageAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupportConversation_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SupportMessage" (
"id" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"authorEmail" TEXT NOT NULL,
"authorRole" "UserRole" NOT NULL,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupportMessage_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SupportConversation_userId_key" ON "SupportConversation"("userId");
CREATE INDEX "SupportConversation_status_lastMessageAt_idx" ON "SupportConversation"("status", "lastMessageAt");
CREATE INDEX "SupportConversation_assignedAdminId_idx" ON "SupportConversation"("assignedAdminId");
CREATE INDEX "SupportMessage_conversationId_createdAt_idx" ON "SupportMessage"("conversationId", "createdAt");
CREATE INDEX "SupportMessage_authorId_createdAt_idx" ON "SupportMessage"("authorId", "createdAt");
ALTER TABLE "SupportMessage"
ADD CONSTRAINT "SupportMessage_conversationId_fkey"
FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,54 @@
generator client {
provider = "prisma-client-js"
output = "../node_modules/@chat-service/prisma-client"
}
datasource db {
provider = "postgresql"
url = env("CHAT_DATABASE_URL")
}
enum UserRole {
admin
user
}
enum SupportConversationStatus {
open
closed
}
model SupportConversation {
id String @id @default(cuid())
userId String @unique
userEmail String
assignedAdminId String?
assignedAdminEmail String?
status SupportConversationStatus @default(open)
userLastReadAt DateTime?
adminLastReadAt DateTime?
lastMessageAt DateTime @default(now())
lastUserMessageAt DateTime?
lastAdminMessageAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages SupportMessage[]
@@index([status, lastMessageAt])
@@index([assignedAdminId])
}
model SupportMessage {
id String @id @default(cuid())
conversationId String
authorId String
authorEmail String
authorRole UserRole
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
conversation SupportConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@index([conversationId, createdAt])
@@index([authorId, createdAt])
}

View File

@@ -0,0 +1,51 @@
import { spawn } from "node:child_process";
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node scripts/with-db-url.mjs <command> [...args]");
process.exit(1);
}
const buildDatabaseUrl = () => {
const host = process.env.CHAT_POSTGRES_HOST?.trim() || "localhost";
const port = process.env.CHAT_POSTGRES_PORT?.trim() || "5432";
const database = process.env.CHAT_POSTGRES_DB?.trim();
const user = process.env.CHAT_POSTGRES_USER?.trim();
const password = process.env.CHAT_POSTGRES_PASSWORD ?? "";
const schema = process.env.CHAT_POSTGRES_SCHEMA?.trim() || "public";
if (database && user) {
const credentials = `${encodeURIComponent(user)}:${encodeURIComponent(password)}@`;
return `postgresql://${credentials}${host}:${port}/${database}?schema=${encodeURIComponent(schema)}`;
}
const explicitUrl = process.env.CHAT_DATABASE_URL?.trim();
if (explicitUrl) {
return explicitUrl;
}
throw new Error("CHAT_DATABASE_URL is missing and CHAT_POSTGRES_DB/CHAT_POSTGRES_USER are not fully configured");
};
try {
process.env.CHAT_DATABASE_URL = buildDatabaseUrl();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
const child = spawn(args[0], args.slice(1), {
stdio: "inherit",
shell: true,
env: process.env
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});

35
chat-service/src/app.ts Normal file
View File

@@ -0,0 +1,35 @@
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";
import helmet from "helmet";
import morgan from "morgan";
import { corsOrigins } from "./config/env.js";
import { adminSupportRouter, supportRouter } from "./modules/support/support.routes.js";
export const app = express();
app.set("trust proxy", 1);
app.use(helmet());
app.use(
cors({
origin(origin, callback) {
if (!origin || corsOrigins.includes(origin)) {
callback(null, true);
return;
}
callback(new Error(`CORS blocked for origin: ${origin}`));
},
credentials: true
})
);
app.use(express.json({ limit: "1mb" }));
app.use(cookieParser());
app.use(morgan("dev"));
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.use("/", supportRouter);
app.use("/admin", adminSupportRouter);

View File

@@ -0,0 +1,38 @@
import dotenv from "dotenv";
import { z } from "zod";
dotenv.config();
const stringWithDefault = (fallback: string) =>
z.preprocess((value) => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}, z.string().default(fallback));
const trimmedString = () =>
z.preprocess((value) => {
if (typeof value !== "string") return value;
return value.trim();
}, z.string());
const buildCorsOrigins = (rawCorsOrigin: string) =>
Array.from(
new Set(
rawCorsOrigin
.split(",")
.map((origin) => origin.trim())
.filter(Boolean)
)
);
const envSchema = z.object({
PORT: z.coerce.number().default(4050),
CHAT_DATABASE_URL: trimmedString().pipe(z.string().min(1)),
JWT_SECRET: trimmedString().pipe(z.string().min(8)),
CORS_ORIGIN: stringWithDefault("https://antigol.ru,http://localhost:3000"),
CHAT_MESSAGE_MAX_LENGTH: z.coerce.number().default(4000)
});
export const env = envSchema.parse(process.env);
export const corsOrigins = buildCorsOrigins(env.CORS_ORIGIN);

View File

@@ -0,0 +1,3 @@
import { PrismaClient } from "@chat-service/prisma-client";
export const prisma = new PrismaClient();

21
chat-service/src/index.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createServer } from "node:http";
import { env } from "./config/env.js";
import { prisma } from "./db/prisma.js";
import { app } from "./app.js";
import { initRealtimeServer } from "./realtime.js";
const server = createServer(app);
initRealtimeServer(server);
server.listen(env.PORT, () => {
console.log(`Chat service started on port ${env.PORT}`);
});
async function shutdown() {
server.close();
await prisma.$disconnect();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

View File

@@ -0,0 +1,11 @@
import jwt from "jsonwebtoken";
import { env } from "../config/env.js";
export type JwtPayload = {
userId: string;
role: "admin" | "user";
};
export function verifyToken(token: string) {
return jwt.verify(token, env.JWT_SECRET) as JwtPayload;
}

View File

@@ -0,0 +1,40 @@
import { NextFunction, Request, Response } from "express";
import { verifyToken } from "../lib/auth.js";
export type AuthenticatedRequest = Request & {
user?: {
id: string;
role: "admin" | "user";
email?: string | null;
};
};
export function requireAuth(req: AuthenticatedRequest, res: Response, next: NextFunction) {
const header = req.headers.authorization;
const bearerToken = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
const cookieToken = typeof req.cookies?.auth_token === "string" ? req.cookies.auth_token : undefined;
const token = bearerToken || cookieToken;
if (!token) {
return res.status(401).json({ message: "Требуется авторизация" });
}
try {
const payload = verifyToken(token);
req.user = {
id: payload.userId,
role: payload.role
};
next();
} catch {
return res.status(401).json({ message: "Невалидный токен" });
}
}
export function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFunction) {
if (req.user?.role !== "admin") {
return res.status(403).json({ message: "Требуются права администратора" });
}
next();
}

View File

@@ -0,0 +1,296 @@
import { Router } from "express";
import {
Prisma,
SupportConversationStatus,
UserRole,
} from "@chat-service/prisma-client";
import { prisma } from "../../db/prisma.js";
import { requireAdmin, requireAuth, type AuthenticatedRequest } from "../../middleware/auth.js";
import { emitSupportConversationUpdated, emitSupportMessageCreated } from "../../realtime.js";
import { env } from "../../config/env.js";
const conversationListInclude = {
messages: {
take: 1,
orderBy: {
createdAt: "desc"
}
}
} satisfies Prisma.SupportConversationInclude;
const conversationDetailsInclude = {
messages: {
orderBy: {
createdAt: "asc"
}
}
} satisfies Prisma.SupportConversationInclude;
type ConversationListRecord = Prisma.SupportConversationGetPayload<{ include: typeof conversationListInclude }>;
type ConversationDetailsRecord = Prisma.SupportConversationGetPayload<{ include: typeof conversationDetailsInclude }>;
type MessageRecord = ConversationDetailsRecord["messages"][number];
function hasUnreadForAdmin(conversation: { lastUserMessageAt: Date | null; adminLastReadAt: Date | null }) {
if (!conversation.lastUserMessageAt) return false;
if (!conversation.adminLastReadAt) return true;
return conversation.lastUserMessageAt > conversation.adminLastReadAt;
}
function hasUnreadForUser(conversation: { lastAdminMessageAt: Date | null; userLastReadAt: Date | null }) {
if (!conversation.lastAdminMessageAt) return false;
if (!conversation.userLastReadAt) return true;
return conversation.lastAdminMessageAt > conversation.userLastReadAt;
}
function mapMessage(message: MessageRecord) {
return {
id: message.id,
conversationId: message.conversationId,
body: message.body,
createdAt: message.createdAt,
updatedAt: message.updatedAt,
author: {
id: message.authorId,
email: message.authorEmail,
role: message.authorRole
}
};
}
function mapConversation(conversation: ConversationListRecord | ConversationDetailsRecord) {
const latestMessage = conversation.messages.at(-1) ?? null;
return {
id: conversation.id,
status: conversation.status,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
lastMessageAt: conversation.lastMessageAt,
lastUserMessageAt: conversation.lastUserMessageAt,
lastAdminMessageAt: conversation.lastAdminMessageAt,
unreadForAdmin: hasUnreadForAdmin(conversation),
unreadForUser: hasUnreadForUser(conversation),
user: {
id: conversation.userId,
email: conversation.userEmail,
role: "user" as const,
active: true
},
assignedAdmin: conversation.assignedAdminId
? {
id: conversation.assignedAdminId,
email: conversation.assignedAdminEmail ?? "admin",
role: "admin" as const
}
: null,
latestMessage: latestMessage ? mapMessage(latestMessage as MessageRecord) : null,
messages:
conversation.messages.length > 1 || (latestMessage && "body" in latestMessage)
? conversation.messages.map((message) => mapMessage(message as MessageRecord))
: undefined
};
}
async function ensureUserConversation(userId: string, userEmail: string) {
return prisma.supportConversation.upsert({
where: { userId },
update: {
userEmail
},
create: {
userId,
userEmail,
userLastReadAt: new Date()
},
include: conversationDetailsInclude
});
}
async function createSupportMessage(options: {
conversationId: string;
authorId: string;
authorEmail: string;
authorRole: UserRole;
body: string;
}) {
const now = new Date();
await prisma.supportMessage.create({
data: {
conversationId: options.conversationId,
authorId: options.authorId,
authorEmail: options.authorEmail,
authorRole: options.authorRole,
body: options.body
}
});
return prisma.supportConversation.update({
where: { id: options.conversationId },
data: {
status: SupportConversationStatus.open,
assignedAdminId: options.authorRole === UserRole.admin ? options.authorId : undefined,
assignedAdminEmail: options.authorRole === UserRole.admin ? options.authorEmail : undefined,
lastMessageAt: now,
lastUserMessageAt: options.authorRole === UserRole.user ? now : undefined,
lastAdminMessageAt: options.authorRole === UserRole.admin ? now : undefined,
userLastReadAt: options.authorRole === UserRole.user ? now : undefined,
adminLastReadAt: options.authorRole === UserRole.admin ? now : undefined
},
include: conversationDetailsInclude
});
}
function extractEmail(req: AuthenticatedRequest) {
const emailHeader = req.headers["x-user-email"];
if (typeof emailHeader === "string" && emailHeader.trim()) {
return emailHeader.trim();
}
if (Array.isArray(emailHeader)) {
const first = emailHeader.find((value) => typeof value === "string" && value.trim());
if (first) return first.trim();
}
return `${req.user!.id}@chat.local`;
}
function extractBody(req: AuthenticatedRequest) {
const body = typeof req.body?.body === "string" ? req.body.body.trim() : "";
if (!body) return null;
return body.slice(0, env.CHAT_MESSAGE_MAX_LENGTH);
}
export const supportRouter = Router();
export const adminSupportRouter = Router();
supportRouter.use(requireAuth);
adminSupportRouter.use(requireAuth, requireAdmin);
supportRouter.get("/support/conversation", async (req: AuthenticatedRequest, res) => {
if (req.user?.role === "admin") {
return res.status(403).json({ message: "Используйте админский раздел обращений" });
}
const conversation = await ensureUserConversation(req.user!.id, extractEmail(req));
await prisma.supportConversation.update({
where: { id: conversation.id },
data: {
userLastReadAt: new Date()
}
});
res.json({
...mapConversation(conversation),
unreadForUser: false
});
});
supportRouter.post("/support/conversation/messages", async (req: AuthenticatedRequest, res) => {
if (req.user?.role === "admin") {
return res.status(403).json({ message: "Администратор не может писать от имени пользователя" });
}
const body = extractBody(req);
if (!body) {
return res.status(400).json({ message: "Сообщение не должно быть пустым" });
}
const conversation = await ensureUserConversation(req.user!.id, extractEmail(req));
const updated = await createSupportMessage({
conversationId: conversation.id,
authorId: req.user!.id,
authorEmail: extractEmail(req),
authorRole: UserRole.user,
body
});
const payload = mapConversation(updated);
const message = payload.messages?.at(-1) ?? payload.latestMessage ?? undefined;
emitSupportConversationUpdated(updated.userId, { conversation: payload, message });
emitSupportMessageCreated(updated.userId, { conversation: payload, message });
res.status(201).json(payload);
});
adminSupportRouter.get("/support/conversations", async (_req, res) => {
const conversations = await prisma.supportConversation.findMany({
include: conversationListInclude,
orderBy: [{ lastMessageAt: "desc" }, { createdAt: "desc" }]
});
res.json(conversations.map((conversation) => mapConversation(conversation)));
});
adminSupportRouter.get("/support/conversations/:id", async (req, res) => {
const conversation = await prisma.supportConversation.findUnique({
where: { id: String(req.params.id) },
include: conversationDetailsInclude
});
if (!conversation) {
return res.status(404).json({ message: "Диалог не найден" });
}
await prisma.supportConversation.update({
where: { id: conversation.id },
data: {
adminLastReadAt: new Date()
}
});
res.json({
...mapConversation(conversation),
unreadForAdmin: false
});
});
adminSupportRouter.post("/support/conversations/:id/messages", async (req: AuthenticatedRequest, res) => {
const body = extractBody(req);
if (!body) {
return res.status(400).json({ message: "Сообщение не должно быть пустым" });
}
const conversation = await prisma.supportConversation.findUnique({
where: { id: String(req.params.id) }
});
if (!conversation) {
return res.status(404).json({ message: "Диалог не найден" });
}
const updated = await createSupportMessage({
conversationId: conversation.id,
authorId: req.user!.id,
authorEmail: extractEmail(req),
authorRole: UserRole.admin,
body
});
const payload = mapConversation(updated);
const message = payload.messages?.at(-1) ?? payload.latestMessage ?? undefined;
emitSupportConversationUpdated(updated.userId, { conversation: payload, message });
emitSupportMessageCreated(updated.userId, { conversation: payload, message });
res.status(201).json(payload);
});
adminSupportRouter.patch("/support/conversations/:id", async (req: AuthenticatedRequest, res) => {
const status =
req.body?.status === SupportConversationStatus.closed ? SupportConversationStatus.closed : SupportConversationStatus.open;
const updated = await prisma.supportConversation.update({
where: { id: String(req.params.id) },
data: {
status,
assignedAdminId: req.user!.id,
assignedAdminEmail: extractEmail(req)
},
include: conversationListInclude
});
const payload = mapConversation(updated);
emitSupportConversationUpdated(updated.userId, { conversation: payload });
res.json(payload);
});

View File

@@ -0,0 +1,88 @@
import type { Server as HttpServer } from "node:http";
import { Server } from "socket.io";
import { corsOrigins } from "./config/env.js";
import { verifyToken } from "./lib/auth.js";
type SocketUser = {
id: string;
role: "admin" | "user";
};
export type SupportRealtimePayload = {
conversation: Record<string, unknown>;
message?: Record<string, unknown>;
};
let io: Server | null = null;
function resolveSocketToken(authHeader?: string, authToken?: string) {
if (typeof authToken === "string" && authToken.trim()) return authToken.trim();
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) return authHeader.slice(7);
return null;
}
export function initRealtimeServer(server: HttpServer) {
io = new Server(server, {
cors: {
origin(origin, callback) {
if (!origin || corsOrigins.includes(origin)) {
callback(null, true);
return;
}
callback(new Error(`CORS blocked for origin: ${origin}`));
},
credentials: true
}
});
io.use((socket, next) => {
try {
const token = resolveSocketToken(
typeof socket.handshake.headers.authorization === "string" ? socket.handshake.headers.authorization : undefined,
typeof socket.handshake.auth?.token === "string" ? socket.handshake.auth.token : undefined
);
if (!token) {
next(new Error("Authentication required"));
return;
}
const payload = verifyToken(token);
socket.data.user = {
id: payload.userId,
role: payload.role
} satisfies SocketUser;
next();
} catch {
next(new Error("Invalid token"));
}
});
io.on("connection", (socket) => {
const user = socket.data.user as SocketUser | undefined;
if (!user) {
socket.disconnect();
return;
}
void socket.join(`support:user:${user.id}`);
if (user.role === "admin") {
void socket.join("support:admins");
}
});
return io;
}
export function emitSupportConversationUpdated(userId: string, payload: SupportRealtimePayload) {
if (!io) return;
io.to("support:admins").emit("support:conversation.updated", payload);
io.to(`support:user:${userId}`).emit("support:conversation.updated", payload);
}
export function emitSupportMessageCreated(userId: string, payload: SupportRealtimePayload) {
if (!io) return;
io.to("support:admins").emit("support:message.created", payload);
io.to(`support:user:${userId}`).emit("support:message.created", payload);
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}