init
This commit is contained in:
11
chat-service/Dockerfile
Normal file
11
chat-service/Dockerfile
Normal 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
38
chat-service/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
54
chat-service/prisma/schema.prisma
Normal file
54
chat-service/prisma/schema.prisma
Normal 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])
|
||||
}
|
||||
51
chat-service/scripts/with-db-url.mjs
Normal file
51
chat-service/scripts/with-db-url.mjs
Normal 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
35
chat-service/src/app.ts
Normal 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);
|
||||
38
chat-service/src/config/env.ts
Normal file
38
chat-service/src/config/env.ts
Normal 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);
|
||||
3
chat-service/src/db/prisma.ts
Normal file
3
chat-service/src/db/prisma.ts
Normal 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
21
chat-service/src/index.ts
Normal 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);
|
||||
11
chat-service/src/lib/auth.ts
Normal file
11
chat-service/src/lib/auth.ts
Normal 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;
|
||||
}
|
||||
40
chat-service/src/middleware/auth.ts
Normal file
40
chat-service/src/middleware/auth.ts
Normal 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();
|
||||
}
|
||||
296
chat-service/src/modules/support/support.routes.ts
Normal file
296
chat-service/src/modules/support/support.routes.ts
Normal 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);
|
||||
});
|
||||
88
chat-service/src/realtime.ts
Normal file
88
chat-service/src/realtime.ts
Normal 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);
|
||||
}
|
||||
16
chat-service/tsconfig.json
Normal file
16
chat-service/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user