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

44
.env.example Normal file
View File

@@ -0,0 +1,44 @@
NUXT_PUBLIC_API_BASE=https://api.antigol.ru
NUXT_PUBLIC_CHAT_API_BASE=https://chat.antigol.ru
APP_PUBLIC_URL=https://antigol.ru
CORS_ORIGIN=https://antigol.ru,https://localhost
JWT_SECRET=change_me_to_a_long_random_secret
PARSER_INTERNAL_SECRET=change_me_to_a_long_random_internal_secret
REDIS_URL=redis://redis:6379
APP_LATEST_VERSION=1.0.1
APP_MIN_SUPPORTED_VERSION=1.0.0
APP_UPDATE_URL=https://files.antigol.ru/downloads/alpinbet.apk
APP_UPDATE_MESSAGE=Доступна новая версия приложения
POSTGRES_DB=betting_signals
POSTGRES_USER=change_me_user
POSTGRES_PASSWORD=change_me_password
CHAT_POSTGRES_DB=betting_signals_chat
CHAT_POSTGRES_USER=change_me_chat_user
CHAT_POSTGRES_PASSWORD=change_me_chat_password
BACKUP_TZ=Europe/Moscow
BACKUP_INTERVAL_SECONDS=86400
BACKUP_RETENTION_DAYS=7
BACKUP_GZIP_LEVEL=6
BACKUP_INCLUDE_CHAT_DB=true
SMTP_HOST=
SMTP_PORT=2525
SMTP_SECURE=false
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Alpinbet
PASSWORD_RESET_TTL_MINUTES=60
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@example.com
FIREBASE_PROJECT_ID=
FIREBASE_CLIENT_EMAIL=
FIREBASE_PRIVATE_KEY=
FIREBASE_SERVICE_ACCOUNT_JSON=
SETTLEMENT_INTERVAL_MS=60000

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
node_modules
dist
.DS_Store
coverage
.env
.env.*
!.env.example
backend/.env
backend/.env.*
!backend/.env.example
parser/.env
frontend/.env
frontend/android/keystore/*.jks
frontend/android/app/google-services.json
traefik/secrets/dashboard-users
backend/prisma/dev.db
backend/prisma/dev.db-journal
frontend/dist
frontend/.nuxt
frontend/.output
frontend/node_modules
parser/node_modules
parser/data
backend/node_modules
/backups
/.envfrontend/public/icons/app-icon-source.png

30
backend/.env.example Normal file
View File

@@ -0,0 +1,30 @@
PORT=4000
REDIS_URL=redis://127.0.0.1:6379
DATABASE_URL=postgresql://change_me_user:change_me_password@localhost:5432/betting_signals?schema=public
JWT_SECRET=change_me_to_a_long_random_secret
JWT_EXPIRES_IN=7d
APP_PUBLIC_URL=https://antigol.ru
CORS_ORIGIN=https://antigol.ru,https://localhost
APP_LATEST_VERSION=1.0.1
APP_MIN_SUPPORTED_VERSION=1.0.0
APP_UPDATE_URL=https://files.antigol.ru/downloads/alpinbet.apk
APP_UPDATE_MESSAGE=Доступна новая версия приложения
VAPID_PUBLIC_KEY=replace_with_vapid_public_key
VAPID_PRIVATE_KEY=replace_with_vapid_private_key
VAPID_SUBJECT=mailto:admin@example.com
FIREBASE_PROJECT_ID=replace_with_firebase_project_id
FIREBASE_CLIENT_EMAIL=replace_with_firebase_client_email
FIREBASE_PRIVATE_KEY=replace_with_firebase_private_key
FIREBASE_SERVICE_ACCOUNT_JSON=
SETTLEMENT_INTERVAL_MS=60000
SIGNALS_WORKER_CONCURRENCY=1
PUSH_WORKER_CONCURRENCY=1
PARSER_INTERNAL_SECRET=replace_with_a_long_random_internal_secret
SMTP_HOST=
SMTP_PORT=2525
SMTP_SECURE=false
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=Alpinbet
PASSWORD_RESET_TTL_MINUTES=60

View File

@@ -0,0 +1,3 @@
VAPID_PUBLIC_KEY=BD96cEhyd-tuZnFVkfBeX1qd3SUY0u1gdzg0WL38R2VXtwULckSLJf6Zb6Xy_cbUfRlJtrBOMzMVCkksP63kH0s
VAPID_PRIVATE_KEY=i1ciBSrobPH6LBUDVWz3vmKHuhK-fJTORmvO1FnGGJ0
VAPID_SUBJECT=mailto:admin@example.com

11
backend/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 4000
CMD ["npm", "run", "start:with-db-url"]

View File

@@ -0,0 +1,8 @@
{
"raketafon": "Ракетафон",
"pobeda-1-comand": "Победа",
"raketabas": "Ракетабас",
"sol-1www": "Сол 1WW",
"fon-stb": "Фон СТБ",
"fonat": "Фонат"
}

56
backend/package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "betting-signals-backend",
"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:api": "node dist/src/index.js",
"start:signals-worker": "node dist/src/workers/signals.worker.js",
"start:push-worker": "node dist/src/workers/push.worker.js",
"start:with-db-url": "node scripts/with-db-url.mjs node dist/src/index.js",
"start:api:with-db-url": "node scripts/with-db-url.mjs node dist/src/index.js",
"start:signals-worker:with-db-url": "node scripts/with-db-url.mjs node dist/src/workers/signals.worker.js",
"start:push-worker:with-db-url": "node scripts/with-db-url.mjs node dist/src/workers/push.worker.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:db-push:with-db-url": "node scripts/with-db-url.mjs npx prisma db push",
"prisma:seed:with-db-url": "node scripts/with-db-url.mjs tsx prisma/seed.ts",
"prisma:seed": "tsx prisma/seed.ts",
"smtp:test": "node scripts/with-db-url.mjs tsx scripts/test-smtp.mjs"
},
"dependencies": {
"@prisma/client": "^6.6.0",
"bcryptjs": "^2.4.3",
"bullmq": "^5.71.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express-rate-limit": "^8.3.1",
"firebase-admin": "^13.5.0",
"helmet": "^8.1.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"nodemailer": "^8.0.4",
"web-push": "^3.6.7",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@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",
"@types/nodemailer": "^7.0.11",
"@types/web-push": "^3.6.4",
"prisma": "^6.6.0",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}

View File

@@ -0,0 +1,14 @@
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('active', 'expired', 'canceled');
-- AlterTable
ALTER TABLE "UserBotAccess"
ADD COLUMN "status" "SubscriptionStatus" NOT NULL DEFAULT 'active',
ADD COLUMN "startsAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "expiresAt" TIMESTAMP(3),
ADD COLUMN "notes" TEXT;
-- Backfill
UPDATE "UserBotAccess"
SET "startsAt" = COALESCE("grantedAt", CURRENT_TIMESTAMP)
WHERE "startsAt" IS NULL;

View File

@@ -0,0 +1,18 @@
CREATE TABLE "PasswordResetToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "PasswordResetToken"("tokenHash");
CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId");
CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "PasswordResetToken"("expiresAt");
ALTER TABLE "PasswordResetToken"
ADD CONSTRAINT "PasswordResetToken_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "User"
ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,210 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
admin
user
}
enum SignalStatus {
pending
win
lose
void
manual_review
unpublished
}
enum SourceType {
manual
provider
}
enum SubscriptionStatus {
active
expired
canceled
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
role UserRole @default(user)
active Boolean @default(true)
sessionVersion Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notificationSetting NotificationSetting?
pushSubscriptions PushSubscription[]
nativePushSubscriptions NativePushSubscription[]
botAccesses UserBotAccess[]
passwordResetTokens PasswordResetToken[]
adminActions AdminActionLog[]
}
model PasswordResetToken {
id String @id @default(cuid())
userId String
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
model Bot {
id String @id @default(cuid())
key String @unique
name String
sourceUrl String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userAccesses UserBotAccess[]
}
model UserBotAccess {
id String @id @default(cuid())
userId String
botId String
status SubscriptionStatus @default(active)
startsAt DateTime @default(now())
expiresAt DateTime?
notes String?
grantedAt DateTime @default(now())
grantedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
@@unique([userId, botId])
}
model Signal {
id String @id @default(cuid())
providerId String?
eventId String
sportType String
leagueName String
homeTeam String
awayTeam String
eventStartTime DateTime
marketType String
selection String
forecast String?
lineValue Float?
odds Float
signalTime DateTime
status SignalStatus @default(pending)
sourceType SourceType
comment String?
published Boolean @default(true)
dedupeKey String @unique
rawPayload Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
settlement Settlement?
notifications NotificationLog[]
}
model EventResult {
id String @id @default(cuid())
eventId String @unique
homeScore Int?
awayScore Int?
status String
payload Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Settlement {
id String @id @default(cuid())
signalId String @unique
result SignalStatus
explanation String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
signal Signal @relation(fields: [signalId], references: [id], onDelete: Cascade)
}
model PushSubscription {
id String @id @default(cuid())
userId String
endpoint String
p256dh String
auth String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, endpoint])
}
model NativePushSubscription {
id String @id @default(cuid())
userId String
token String
platform String
deviceId String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, token])
}
model NotificationSetting {
id String @id @default(cuid())
userId String @unique
signalsPushEnabled Boolean @default(true)
resultsPushEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model NotificationLog {
id String @id @default(cuid())
signalId String?
type String
recipients Int
successCount Int
failedCount Int
payload Json?
createdAt DateTime @default(now())
signal Signal? @relation(fields: [signalId], references: [id], onDelete: SetNull)
}
model AdminActionLog {
id String @id @default(cuid())
adminId String
action String
entityType String
entityId String
metadata Json?
createdAt DateTime @default(now())
admin User @relation(fields: [adminId], references: [id], onDelete: Cascade)
}
model IntegrationLog {
id String @id @default(cuid())
provider String
level String
message String
payload Json?
createdAt DateTime @default(now())
}

79
backend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,79 @@
import bcrypt from "bcryptjs";
import { PrismaClient, UserRole } from "@prisma/client";
const prisma = new PrismaClient();
const defaultBots = [
{
key: "raketafon",
name: "Raketafon",
sourceUrl: "https://alpinbet.com/dispatch/antigol/raketafon"
},
{
key: "pobeda-1-comand",
name: "Pobeda 1 Comand",
sourceUrl: "https://alpinbet.com/dispatch/antigol/pobeda-1-comand"
},
{
key: "raketabas",
name: "Raketabas",
sourceUrl: "https://alpinbet.com/dispatch/antigol/raketabas"
},
{
key: "sol-1www",
name: "Sol 1www",
sourceUrl: "https://alpinbet.com/dispatch/antigol/sol-1www"
},
{
key: "fon-stb",
name: "Fon Stb",
sourceUrl: "https://alpinbet.com/dispatch/antigol/fon-stb"
},
{
key: "fonat",
name: "Fonat",
sourceUrl: "https://alpinbet.com/dispatch/antigol/fonat"
}
];
async function main() {
const adminPasswordHash = await bcrypt.hash("admin12345", 10);
await prisma.user.upsert({
where: { email: "admin@example.com" },
update: {},
create: {
email: "admin@example.com",
passwordHash: adminPasswordHash,
role: UserRole.admin,
notificationSetting: {
create: {
signalsPushEnabled: true,
resultsPushEnabled: true
}
}
}
});
for (const bot of defaultBots) {
await prisma.bot.upsert({
where: { key: bot.key },
update: {
name: bot.name,
sourceUrl: bot.sourceUrl,
active: true
},
create: bot
});
}
}
main()
.finally(async () => {
await prisma.$disconnect();
})
.catch(async (error) => {
console.error(error);
await prisma.$disconnect();
process.exit(1);
});

View File

@@ -0,0 +1,42 @@
import { env } from "../src/config/env.ts";
import { isMailConfigured, sendMail, verifyMailTransport } from "../src/lib/mail.ts";
const to = process.argv[2]?.trim();
if (!to) {
console.error("Usage: npm run smtp:test -- <recipient@example.com>");
process.exit(1);
}
if (!isMailConfigured()) {
console.error("SMTP is not configured.");
console.error({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE,
user: env.SMTP_USER,
from: env.SMTP_FROM_EMAIL
});
process.exit(1);
}
console.log("Verifying SMTP transport...", {
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE,
user: env.SMTP_USER,
from: env.SMTP_FROM_EMAIL,
to
});
await verifyMailTransport();
console.log("SMTP transport verified.");
await sendMail({
to,
subject: "Alpinbet SMTP test",
text: "This is a test email from the Alpinbet backend.",
html: "<p>This is a test email from the Alpinbet backend.</p>"
});
console.log("SMTP test email sent.");

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.POSTGRES_HOST?.trim() || "localhost";
const port = process.env.POSTGRES_PORT?.trim() || "5432";
const database = process.env.POSTGRES_DB?.trim();
const user = process.env.POSTGRES_USER?.trim();
const password = process.env.POSTGRES_PASSWORD ?? "";
const schema = process.env.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.DATABASE_URL?.trim();
if (explicitUrl) {
return explicitUrl;
}
throw new Error("DATABASE_URL is missing and POSTGRES_DB/POSTGRES_USER are not fully configured");
};
try {
process.env.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);
});

99
backend/src/app.ts Normal file
View File

@@ -0,0 +1,99 @@
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";
import rateLimit from "express-rate-limit";
import helmet from "helmet";
import morgan from "morgan";
import { corsOrigins } from "./config/env.js";
import { isMailConfigured, verifyMailTransport } from "./lib/mail.js";
import { errorHandler } from "./middleware/error-handler.js";
import { appVersionRouter } from "./modules/app-version/app-version.routes.js";
import { adminRouter } from "./modules/admin/admin.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { internalRouter } from "./modules/internal/internal.routes.js";
import { pushRouter } from "./modules/push/push.routes.js";
import { rssRouter } from "./modules/rss/rss.routes.js";
import { signalsRouter } from "./modules/signals/signals.routes.js";
import { usersRouter } from "./modules/users/users.routes.js";
export const app = express();
if (isMailConfigured()) {
void verifyMailTransport()
.then(() => {
console.info("SMTP transport verified");
})
.catch((error) => {
console.error("SMTP transport verification failed", error instanceof Error ? error.message : String(error));
});
} else {
console.warn("SMTP is not fully configured. Password reset emails are disabled.");
}
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
})
);
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { message: "Too many authentication requests. Try again later." }
});
const publicWriteLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
message: { message: "Too many requests. Try again later." }
});
const internalLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { message: "Too many internal requests. Try again later." }
});
app.use(apiLimiter);
app.use(express.json({ limit: "1mb" }));
app.use(cookieParser());
app.use(morgan("dev"));
app.use("/auth/login", authLimiter);
app.use("/auth/register", authLimiter);
app.use("/auth/forgot-password", authLimiter);
app.use("/auth/reset-password", authLimiter);
app.use("/public", publicWriteLimiter);
app.use("/internal", internalLimiter);
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.use("/", appVersionRouter);
app.use("/auth", authRouter);
app.use("/signals", signalsRouter);
app.use("/", rssRouter);
app.use("/", pushRouter);
app.use("/", usersRouter);
app.use("/internal", internalRouter);
app.use("/admin", adminRouter);
app.use(errorHandler);

210
backend/src/config/env.ts Normal file
View File

@@ -0,0 +1,210 @@
import dotenv from "dotenv";
import fs from "node:fs";
import path from "node:path";
import webpush from "web-push";
import { z } from "zod";
dotenv.config();
const isMissingOrPlaceholder = (value: string | undefined) =>
!value || value.trim().length === 0 || value.startsWith("replace_");
const isValidVapidConfig = (publicKey: string | undefined, privateKey: string | undefined, subject: string | undefined) => {
if (isMissingOrPlaceholder(publicKey) || isMissingOrPlaceholder(privateKey)) {
return false;
}
const resolvedSubject = isMissingOrPlaceholder(subject) ? "mailto:admin@example.com" : subject!;
try {
webpush.setVapidDetails(resolvedSubject, publicKey!, privateKey!);
return true;
} catch {
return false;
}
};
const parseEnvFile = (content: string) => {
const entries = new Map<string, string>();
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex === -1) continue;
const key = trimmed.slice(0, separatorIndex).trim();
const value = trimmed.slice(separatorIndex + 1).trim();
if (key) entries.set(key, value);
}
return entries;
};
const ensureVapidConfig = () => {
const stateDir = process.env.VAPID_STATE_DIR?.trim() || path.join(process.cwd(), ".runtime");
const stateFile = path.join(stateDir, "vapid.env");
const currentPublic = process.env.VAPID_PUBLIC_KEY;
const currentPrivate = process.env.VAPID_PRIVATE_KEY;
const currentSubject = process.env.VAPID_SUBJECT;
const readState = () => {
try {
if (!fs.existsSync(stateFile)) return null;
return parseEnvFile(fs.readFileSync(stateFile, "utf8"));
} catch {
return null;
}
};
const saveState = (publicKey: string, privateKey: string, subject: string) => {
try {
fs.mkdirSync(stateDir, { recursive: true });
const payload = [
`VAPID_PUBLIC_KEY=${publicKey}`,
`VAPID_PRIVATE_KEY=${privateKey}`,
`VAPID_SUBJECT=${subject}`
].join("\n");
fs.writeFileSync(stateFile, `${payload}\n`, "utf8");
} catch {
// Ignore write errors and continue with in-memory env values.
}
};
if (isValidVapidConfig(currentPublic, currentPrivate, currentSubject)) {
const subject = isMissingOrPlaceholder(currentSubject) ? "mailto:admin@example.com" : currentSubject!;
if (subject !== currentSubject) {
process.env.VAPID_SUBJECT = subject;
}
saveState(currentPublic!, currentPrivate!, subject);
return;
}
const state = readState();
const statePublic = state?.get("VAPID_PUBLIC_KEY");
const statePrivate = state?.get("VAPID_PRIVATE_KEY");
const stateSubject = state?.get("VAPID_SUBJECT");
if (isValidVapidConfig(statePublic, statePrivate, stateSubject || currentSubject)) {
process.env.VAPID_PUBLIC_KEY = statePublic;
process.env.VAPID_PRIVATE_KEY = statePrivate;
process.env.VAPID_SUBJECT =
!isMissingOrPlaceholder(currentSubject) ? currentSubject : stateSubject || "mailto:admin@example.com";
return;
}
const generated = webpush.generateVAPIDKeys();
const subject =
!isMissingOrPlaceholder(currentSubject) && currentSubject ? currentSubject : "mailto:admin@example.com";
process.env.VAPID_PUBLIC_KEY = generated.publicKey;
process.env.VAPID_PRIVATE_KEY = generated.privateKey;
process.env.VAPID_SUBJECT = subject;
saveState(generated.publicKey, generated.privateKey, subject);
console.warn(`Generated new VAPID keys and saved them to ${stateFile}`);
};
ensureVapidConfig();
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, appPublicUrl: string) => {
const defaults = [
appPublicUrl,
"https://antigol.ru",
"http://localhost:3000",
"http://localhost:5173",
"http://localhost",
"https://localhost"
];
const configured = rawCorsOrigin
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
return Array.from(new Set([...configured, ...defaults]));
};
const parseBotNameAliasesObject = (parsed: unknown) => {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return new Map<string, string>();
}
return new Map(
Object.entries(parsed)
.filter((entry): entry is [string, string] => {
const [key, value] = entry;
return key.trim().length > 0 && typeof value === "string" && value.trim().length > 0;
})
.map(([key, value]) => [key.trim(), value.trim()])
);
};
const loadBotNameAliases = (aliasesFilePath: string) => {
try {
const content = fs.readFileSync(aliasesFilePath, "utf8");
return parseBotNameAliasesObject(JSON.parse(content) as unknown);
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== "ENOENT") {
console.warn(`Failed to load bot aliases from ${aliasesFilePath}: ${nodeError.message}`);
}
return new Map<string, string>();
}
};
const envSchema = z.object({
PORT: z.coerce.number().default(4000),
REDIS_URL: stringWithDefault("redis://127.0.0.1:6379"),
DATABASE_URL: trimmedString().pipe(z.string().min(1)),
JWT_SECRET: trimmedString().pipe(z.string().min(8)),
JWT_EXPIRES_IN: stringWithDefault("7d"),
CORS_ORIGIN: stringWithDefault(""),
APP_PUBLIC_URL: stringWithDefault("https://antigol.ru").pipe(z.string().url()),
SMTP_HOST: stringWithDefault(""),
SMTP_PORT: z.coerce.number().default(587),
SMTP_SECURE: z
.union([z.boolean(), z.string()])
.transform((value) => {
if (typeof value === "boolean") return value;
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes";
})
.default(false),
SMTP_USER: stringWithDefault(""),
SMTP_PASSWORD: stringWithDefault(""),
SMTP_FROM_EMAIL: stringWithDefault(""),
SMTP_FROM_NAME: stringWithDefault("Alpinbet"),
PASSWORD_RESET_TTL_MINUTES: z.coerce.number().default(60),
APP_LATEST_VERSION: stringWithDefault(""),
APP_MIN_SUPPORTED_VERSION: stringWithDefault(""),
APP_UPDATE_URL: stringWithDefault(""),
BOT_NAME_ALIASES_FILE: stringWithDefault("./config/bot-name-aliases.json"),
APP_UPDATE_MESSAGE: z.string().default("Доступна новая версия приложения"),
VAPID_PUBLIC_KEY: stringWithDefault(""),
VAPID_PRIVATE_KEY: stringWithDefault(""),
VAPID_SUBJECT: stringWithDefault("mailto:admin@example.com"),
FIREBASE_PROJECT_ID: stringWithDefault(""),
FIREBASE_CLIENT_EMAIL: stringWithDefault(""),
FIREBASE_PRIVATE_KEY: stringWithDefault(""),
FIREBASE_SERVICE_ACCOUNT_JSON: stringWithDefault(""),
SETTLEMENT_INTERVAL_MS: z.coerce.number().default(60000),
SIGNALS_WORKER_CONCURRENCY: z.coerce.number().default(1),
PUSH_WORKER_CONCURRENCY: z.coerce.number().default(1),
PARSER_INTERNAL_SECRET: trimmedString().pipe(z.string().min(16))
});
export const env = envSchema.parse(process.env);
export const corsOrigins = buildCorsOrigins(env.CORS_ORIGIN, env.APP_PUBLIC_URL);
export const botNameAliases = loadBotNameAliases(path.resolve(process.cwd(), env.BOT_NAME_ALIASES_FILE));

4
backend/src/db/prisma.ts Normal file
View File

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

22
backend/src/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import { env } from "./config/env.js";
import { prisma } from "./db/prisma.js";
import { closeRedisConnection } from "./lib/redis.js";
import { app } from "./app.js";
import { startSettlementWorker } from "./modules/workers/settlement.worker.js";
const server = app.listen(env.PORT, () => {
console.log(`API запущен на порту ${env.PORT}`);
});
const interval = startSettlementWorker();
async function shutdown() {
clearInterval(interval);
server.close();
await closeRedisConnection();
await prisma.$disconnect();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

26
backend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,26 @@
import bcrypt from "bcryptjs";
import jwt, { type SignOptions } from "jsonwebtoken";
import { env } from "../config/env.js";
type JwtPayload = {
userId: string;
role: "admin" | "user";
};
export async function hashPassword(password: string) {
return bcrypt.hash(password, 10);
}
export async function verifyPassword(password: string, passwordHash: string) {
return bcrypt.compare(password, passwordHash);
}
export function signToken(payload: JwtPayload) {
return jwt.sign(payload, env.JWT_SECRET, {
expiresIn: env.JWT_EXPIRES_IN as SignOptions["expiresIn"]
});
}
export function verifyToken(token: string) {
return jwt.verify(token, env.JWT_SECRET) as JwtPayload;
}

16
backend/src/lib/dedupe.ts Normal file
View File

@@ -0,0 +1,16 @@
export function createSignalDedupeKey(input: {
providerId?: string | null;
eventId: string;
marketType: string;
selection: string;
lineValue?: number | null;
}) {
return [
input.providerId ?? "manual",
input.eventId.trim().toLowerCase(),
input.marketType.trim().toLowerCase(),
input.selection.trim().toLowerCase(),
input.lineValue ?? "na"
].join(":");
}

View File

@@ -0,0 +1,9 @@
export class HttpError extends Error {
statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
}
}

86
backend/src/lib/mail.ts Normal file
View File

@@ -0,0 +1,86 @@
import nodemailer from "nodemailer";
import { env } from "../config/env.js";
let transporterPromise: Promise<nodemailer.Transporter> | null = null;
let verifyPromise: Promise<void> | null = null;
export function isMailConfigured() {
return Boolean(
env.SMTP_HOST.trim() &&
env.SMTP_PORT > 0 &&
env.SMTP_USER.trim() &&
env.SMTP_PASSWORD.trim() &&
env.SMTP_FROM_EMAIL.trim()
);
}
async function getTransporter() {
if (!isMailConfigured()) {
throw new Error("SMTP is not configured");
}
if (!transporterPromise) {
transporterPromise = Promise.resolve(
nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD
}
})
);
}
return transporterPromise;
}
export async function verifyMailTransport() {
if (!isMailConfigured()) {
throw new Error("SMTP is not configured");
}
if (!verifyPromise) {
verifyPromise = getTransporter().then(async (transporter) => {
await transporter.verify();
});
}
return verifyPromise;
}
export async function sendMail(options: {
to: string;
subject: string;
html: string;
text: string;
}) {
const transporter = await getTransporter();
const from = env.SMTP_FROM_NAME.trim()
? `"${env.SMTP_FROM_NAME.replace(/"/g, '\\"')}" <${env.SMTP_FROM_EMAIL}>`
: env.SMTP_FROM_EMAIL;
try {
await transporter.sendMail({
from,
to: options.to,
subject: options.subject,
html: options.html,
text: options.text
});
} catch (error) {
console.error("SMTP send failed", {
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE,
user: env.SMTP_USER,
from: env.SMTP_FROM_EMAIL,
to: options.to,
subject: options.subject,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}

View File

@@ -0,0 +1,87 @@
import { cert, getApp, getApps, initializeApp } from "firebase-admin/app";
import { getMessaging } from "firebase-admin/messaging";
import { env } from "../config/env.js";
type NativePushResult =
| { ok: true; messageId: string }
| { ok: false; reason: string; code?: string };
function getFirebaseApp() {
if (
env.FIREBASE_SERVICE_ACCOUNT_JSON.trim().length === 0 &&
(env.FIREBASE_PROJECT_ID.trim().length === 0 ||
env.FIREBASE_CLIENT_EMAIL.trim().length === 0 ||
env.FIREBASE_PRIVATE_KEY.trim().length === 0)
) {
return null;
}
if (getApps().length > 0) {
return getApp();
}
const credentials =
env.FIREBASE_SERVICE_ACCOUNT_JSON.trim().length > 0
? JSON.parse(env.FIREBASE_SERVICE_ACCOUNT_JSON)
: {
projectId: env.FIREBASE_PROJECT_ID,
clientEmail: env.FIREBASE_CLIENT_EMAIL,
privateKey: env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n")
};
return initializeApp({
credential: cert(credentials)
});
}
export function isNativePushConfigured() {
return getFirebaseApp() !== null;
}
export async function sendNativePush(
token: string,
payload: {
title: string;
body: string;
data?: Record<string, string>;
}
): Promise<NativePushResult> {
const app = getFirebaseApp();
if (!app) {
return { ok: false, reason: "Firebase is not configured", code: "messaging/not-configured" };
}
try {
const messageId = await getMessaging(app).send({
token,
notification: {
title: payload.title,
body: payload.body
},
data: payload.data,
android: {
priority: "high",
notification: {
channelId: "signals",
clickAction: "OPEN_SIGNAL"
}
},
apns: {
payload: {
aps: {
sound: "default"
}
}
}
});
return { ok: true, messageId };
} catch (error) {
const details = error as { code?: string; message?: string };
return {
ok: false,
reason: details.message || "Native push delivery failed",
code: details.code
};
}
}

56
backend/src/lib/push.ts Normal file
View File

@@ -0,0 +1,56 @@
import webpush from "web-push";
import { env } from "../config/env.js";
type PushErrorLike = Error & {
statusCode?: number;
body?: unknown;
};
let pushConfigured = false;
try {
webpush.setVapidDetails(env.VAPID_SUBJECT, env.VAPID_PUBLIC_KEY, env.VAPID_PRIVATE_KEY);
pushConfigured = true;
} catch (error) {
console.warn(
"Web Push disabled: failed to initialize VAPID configuration",
error instanceof Error ? error.message : String(error)
);
}
export function getVapidPublicKey() {
return env.VAPID_PUBLIC_KEY;
}
export async function sendWebPush(
subscription: { endpoint: string; p256dh: string; auth: string },
payload: Record<string, unknown>
) {
if (!pushConfigured) {
return { ok: false, reason: "VAPID not configured" } as const;
}
try {
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth
}
},
JSON.stringify(payload)
);
return { ok: true, statusCode: 201 as const } as const;
} catch (error) {
const pushError = error as PushErrorLike;
return {
ok: false,
reason: error instanceof Error ? error.message : "Push error",
statusCode: pushError?.statusCode,
details: typeof pushError?.body === "string" ? pushError.body : undefined
} as const;
}
}

10
backend/src/lib/redis.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Redis } from "ioredis";
import { env } from "../config/env.js";
export const redisConnection = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: null
});
export async function closeRedisConnection() {
await redisConnection.quit();
}

View File

@@ -0,0 +1,52 @@
import { Signal, SignalStatus } from "@prisma/client";
type EventScore = {
homeScore: number;
awayScore: number;
};
function evaluate1X2(selection: string, score: EventScore): SignalStatus {
const normalized = selection.trim().toUpperCase();
if (score.homeScore === score.awayScore) return normalized === "X" ? SignalStatus.win : SignalStatus.lose;
const homeWon = score.homeScore > score.awayScore;
if (normalized === "1") return homeWon ? SignalStatus.win : SignalStatus.lose;
if (normalized === "2") return homeWon ? SignalStatus.lose : SignalStatus.win;
return SignalStatus.manual_review;
}
function evaluateTotal(selection: string, lineValue: number | null, score: EventScore): SignalStatus {
if (lineValue === null) return SignalStatus.manual_review;
const total = score.homeScore + score.awayScore;
const normalized = selection.trim().toLowerCase();
if (total === lineValue) return SignalStatus.void;
if (normalized.includes("over")) return total > lineValue ? SignalStatus.win : SignalStatus.lose;
if (normalized.includes("under")) return total < lineValue ? SignalStatus.win : SignalStatus.lose;
return SignalStatus.manual_review;
}
function evaluateBtts(selection: string, score: EventScore): SignalStatus {
const bothScored = score.homeScore > 0 && score.awayScore > 0;
const normalized = selection.trim().toLowerCase();
if (normalized.includes("yes")) return bothScored ? SignalStatus.win : SignalStatus.lose;
if (normalized.includes("no")) return bothScored ? SignalStatus.lose : SignalStatus.win;
return SignalStatus.manual_review;
}
function evaluateHandicap(selection: string, lineValue: number | null, score: EventScore): SignalStatus {
if (lineValue === null) return SignalStatus.manual_review;
const normalized = selection.trim().toLowerCase();
const isHome = normalized.includes("1") || normalized.includes("home");
const value = isHome ? score.homeScore + lineValue - score.awayScore : score.awayScore + lineValue - score.homeScore;
if (value === 0) return SignalStatus.void;
return value > 0 ? SignalStatus.win : SignalStatus.lose;
}
export function settleSignal(signal: Signal, score: EventScore) {
const market = signal.marketType.trim().toLowerCase();
if (market === "1x2") return evaluate1X2(signal.selection, score);
if (market.includes("total")) return evaluateTotal(signal.selection, signal.lineValue, score);
if (market.includes("both teams to score") || market.includes("btts")) return evaluateBtts(signal.selection, score);
if (market.includes("handicap")) return evaluateHandicap(signal.selection, signal.lineValue, score);
return SignalStatus.manual_review;
}

View File

@@ -0,0 +1,35 @@
import { NextFunction, Request, Response } from "express";
import { prisma } from "../db/prisma.js";
import { verifyToken } from "../lib/auth.js";
export type AuthenticatedRequest = Request & {
user?: {
id: string;
role: "admin" | "user";
};
};
export async 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);
const user = await prisma.user.findUnique({ where: { id: payload.userId } });
if (!user || !user.active) return res.status(401).json({ message: "Пользователь недоступен" });
req.user = { id: user.id, role: user.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,14 @@
import { NextFunction, Request, Response } from "express";
import { ZodError } from "zod";
import { HttpError } from "../lib/errors.js";
export function errorHandler(error: unknown, _req: Request, res: Response, _next: NextFunction) {
if (error instanceof ZodError) {
return res.status(400).json({ message: "Ошибка валидации", issues: error.flatten() });
}
if (error instanceof HttpError) {
return res.status(error.statusCode).json({ message: error.message });
}
console.error(error);
return res.status(500).json({ message: "Внутренняя ошибка сервера" });
}

View File

@@ -0,0 +1,314 @@
import { Router } from "express";
import { Prisma, SignalStatus } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { HttpError } from "../../lib/errors.js";
import { requireAdmin, requireAuth, type AuthenticatedRequest } from "../../middleware/auth.js";
import { enqueueBroadcastPush, enqueueSignalPush } from "../queues/push.queue.js";
import { signalCreateSchema, signalStatusSchema, signalUpdateSchema } from "../signals/signals.schemas.js";
import { createSignal, updateSignal } from "../signals/signals.service.js";
export const adminRouter = Router();
adminRouter.use(requireAuth, requireAdmin);
type PushEventPayload = {
subscriptionId?: string;
endpoint?: string;
endpointHost?: string;
ok?: boolean;
statusCode?: number | null;
reason?: string | null;
notificationType?: string | null;
};
async function logAdminAction(
adminId: string,
action: string,
entityType: string,
entityId: string,
metadata?: Record<string, unknown>
) {
await prisma.adminActionLog.create({
data: {
adminId,
action,
entityType,
entityId,
metadata: metadata ? (metadata as Prisma.InputJsonObject) : undefined
}
});
}
adminRouter.post("/signals", async (req: AuthenticatedRequest, res, next) => {
try {
const payload = signalCreateSchema.parse(req.body);
const signal = await createSignal(payload);
await logAdminAction(req.user!.id, "create", "signal", signal.id);
res.status(201).json(signal);
} catch (error) {
next(error);
}
});
adminRouter.patch("/signals/:id", async (req: AuthenticatedRequest, res, next) => {
try {
const payload = signalUpdateSchema.parse(req.body);
const signal = await updateSignal(String(req.params.id), payload);
await logAdminAction(req.user!.id, "update", "signal", signal.id, payload);
res.json(signal);
} catch (error) {
next(error);
}
});
adminRouter.delete("/signals/:id", async (req: AuthenticatedRequest, res) => {
await prisma.signal.delete({ where: { id: String(req.params.id) } });
await logAdminAction(req.user!.id, "delete", "signal", String(req.params.id));
res.status(204).send();
});
adminRouter.post("/signals/:id/duplicate", async (req: AuthenticatedRequest, res, next) => {
try {
const source = await prisma.signal.findUnique({ where: { id: String(req.params.id) } });
if (!source) {
throw new HttpError(404, "Сигнал не найден");
}
const duplicate = await createSignal({
providerId: source.providerId,
eventId: `${source.eventId}-${Date.now()}`,
sportType: source.sportType,
leagueName: source.leagueName,
homeTeam: source.homeTeam,
awayTeam: source.awayTeam,
eventStartTime: source.eventStartTime.toISOString(),
marketType: source.marketType,
selection: source.selection,
lineValue: source.lineValue,
odds: source.odds,
signalTime: new Date().toISOString(),
sourceType: source.sourceType,
comment: source.comment,
rawPayload: source.rawPayload as Record<string, unknown> | null,
published: false
});
await logAdminAction(req.user!.id, "duplicate", "signal", duplicate.id, { sourceId: source.id });
res.status(201).json(duplicate);
} catch (error) {
next(error);
}
});
adminRouter.post("/signals/:id/send-push", async (req: AuthenticatedRequest, res, next) => {
try {
const job = await enqueueSignalPush({
type: "signal",
signalId: String(req.params.id),
force: true
});
await logAdminAction(req.user!.id, "send_push", "signal", String(req.params.id), {
queued: true,
jobId: job.id
});
res.status(202).json({ queued: true, jobId: job.id });
} catch (error) {
next(error);
}
});
adminRouter.post("/signals/:id/set-status", async (req: AuthenticatedRequest, res, next) => {
try {
const payload = signalStatusSchema.parse(req.body);
await prisma.signal.update({
where: { id: String(req.params.id) },
data: { status: payload.status as SignalStatus }
});
const settlement = await prisma.settlement.upsert({
where: { signalId: String(req.params.id) },
update: { result: payload.status as SignalStatus, explanation: payload.explanation },
create: {
signalId: String(req.params.id),
result: payload.status as SignalStatus,
explanation: payload.explanation
}
});
await logAdminAction(req.user!.id, "set_status", "signal", String(req.params.id), payload);
res.json(settlement);
} catch (error) {
next(error);
}
});
adminRouter.post("/broadcast", async (req: AuthenticatedRequest, res, next) => {
try {
const title = String(req.body.title ?? "").trim();
const body = String(req.body.body ?? "").trim();
if (!title || !body) {
throw new HttpError(400, "Нужны title и body");
}
const job = await enqueueBroadcastPush({
type: "broadcast",
title,
body,
url: req.body.url ? String(req.body.url) : "/",
tag: req.body.tag ? String(req.body.tag) : undefined,
renotify: Boolean(req.body.renotify)
});
await logAdminAction(req.user!.id, "broadcast", "notification", "broadcast", {
title,
body,
url: req.body.url,
tag: req.body.tag,
renotify: Boolean(req.body.renotify),
queued: true,
jobId: job.id
});
res.status(202).json({ queued: true, jobId: job.id });
} catch (error) {
next(error);
}
});
adminRouter.get("/logs", async (_req, res) => {
const [pushLogs, adminActions, integrationLogs] = await Promise.all([
prisma.notificationLog.findMany({ orderBy: { createdAt: "desc" }, take: 50 }),
prisma.adminActionLog.findMany({ orderBy: { createdAt: "desc" }, take: 50, include: { admin: true } }),
prisma.integrationLog.findMany({ orderBy: { createdAt: "desc" }, take: 50 })
]);
res.json({ pushLogs, adminActions, integrationLogs });
});
adminRouter.get("/push-subscriptions", async (_req, res) => {
const [subscriptions, recentEvents, notificationLogs] = await Promise.all([
prisma.pushSubscription.findMany({
orderBy: { updatedAt: "desc" },
include: {
user: {
select: {
id: true,
email: true,
role: true,
active: true,
notificationSetting: true
}
}
}
}),
prisma.integrationLog.findMany({
where: { provider: "web-push" },
orderBy: { createdAt: "desc" },
take: 500
}),
prisma.notificationLog.findMany({
orderBy: { createdAt: "desc" },
take: 20
})
]);
const latestEventBySubscription = new Map<string, (typeof recentEvents)[number]>();
for (const event of recentEvents) {
const payload =
event.payload && typeof event.payload === "object" ? (event.payload as unknown as PushEventPayload) : null;
const subscriptionId = payload?.subscriptionId;
if (!subscriptionId || latestEventBySubscription.has(subscriptionId)) {
continue;
}
latestEventBySubscription.set(subscriptionId, event);
}
const items = subscriptions.map((subscription) => {
const latestEvent = latestEventBySubscription.get(subscription.id);
const payload =
latestEvent?.payload && typeof latestEvent.payload === "object"
? (latestEvent.payload as unknown as PushEventPayload)
: null;
const status = !subscription.active
? "inactive"
: payload?.ok === false
? "error"
: payload?.ok === true
? "ok"
: "ready";
return {
id: subscription.id,
endpoint: subscription.endpoint,
endpointHost: payload?.endpointHost ?? (() => {
try {
return new URL(subscription.endpoint).hostname;
} catch {
return "unknown";
}
})(),
active: subscription.active,
createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt,
status,
user: {
id: subscription.user.id,
email: subscription.user.email,
role: subscription.user.role,
active: subscription.user.active,
notificationSetting: subscription.user.notificationSetting
},
latestEvent: latestEvent
? {
createdAt: latestEvent.createdAt,
level: latestEvent.level,
message: latestEvent.message,
ok: payload?.ok ?? null,
statusCode: payload?.statusCode ?? null,
reason: payload?.reason ?? null,
notificationType: payload?.notificationType ?? null
}
: null
};
});
const summary = {
total: items.length,
active: items.filter((item) => item.active).length,
inactive: items.filter((item) => !item.active).length,
ok: items.filter((item) => item.status === "ok").length,
ready: items.filter((item) => item.status === "ready").length,
error: items.filter((item) => item.status === "error").length
};
res.json({
summary,
items,
recentNotificationLogs: notificationLogs
});
});
adminRouter.post("/event-results", async (req: AuthenticatedRequest, res) => {
const result = await prisma.eventResult.upsert({
where: { eventId: req.body.eventId },
update: {
homeScore: req.body.homeScore,
awayScore: req.body.awayScore,
status: req.body.status ?? "finished",
payload: req.body.payload ?? null
},
create: {
eventId: req.body.eventId,
homeScore: req.body.homeScore,
awayScore: req.body.awayScore,
status: req.body.status ?? "finished",
payload: req.body.payload ?? null
}
});
await logAdminAction(req.user!.id, "upsert_event_result", "event_result", result.id, req.body);
res.status(201).json(result);
});

View File

@@ -0,0 +1,13 @@
import { Router } from "express";
import { env } from "../../config/env.js";
export const appVersionRouter = Router();
appVersionRouter.get("/app-version", (_req, res) => {
res.json({
latestVersion: env.APP_LATEST_VERSION || null,
minSupportedVersion: env.APP_MIN_SUPPORTED_VERSION || null,
updateUrl: env.APP_UPDATE_URL || null,
message: env.APP_UPDATE_MESSAGE || "Доступна новая версия приложения"
});
});

View File

@@ -0,0 +1,361 @@
import crypto from "node:crypto";
import { type Response, Router } from "express";
import { Prisma, SubscriptionStatus } from "@prisma/client";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
import { hashPassword, signToken, verifyPassword } from "../../lib/auth.js";
import { HttpError } from "../../lib/errors.js";
import { isMailConfigured, sendMail } from "../../lib/mail.js";
import { requireAuth, type AuthenticatedRequest } from "../../middleware/auth.js";
import {
forgotPasswordSchema,
loginSchema,
registerSchema,
resetPasswordSchema
} from "./auth.schemas.js";
export const authRouter = Router();
type PasswordResetTokenRow = {
id: string;
userId: string;
expiresAt: Date;
usedAt: Date | null;
userActive: boolean;
};
function logForgotPassword(event: string, payload: Record<string, unknown>) {
console.info("[auth/forgot-password]", event, payload);
}
function isSecureCookie() {
try {
return new URL(env.APP_PUBLIC_URL).protocol === "https:";
} catch {
return true;
}
}
function getAuthCookieDomain() {
try {
const { hostname } = new URL(env.APP_PUBLIC_URL);
if (!hostname || hostname === "localhost" || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
return undefined;
}
return `.${hostname}`;
} catch {
return undefined;
}
}
function setAuthCookie(res: Response, token: string) {
res.cookie("auth_token", token, {
httpOnly: true,
secure: isSecureCookie(),
sameSite: "lax",
path: "/",
domain: getAuthCookieDomain()
});
}
function clearAuthCookie(res: Response) {
res.clearCookie("auth_token", {
httpOnly: true,
secure: isSecureCookie(),
sameSite: "lax",
path: "/",
domain: getAuthCookieDomain()
});
}
function isSubscriptionActive(access: {
status: SubscriptionStatus;
startsAt: Date;
expiresAt: Date | null;
}) {
const now = Date.now();
return (
access.status === SubscriptionStatus.active &&
access.startsAt.getTime() <= now &&
(access.expiresAt === null || access.expiresAt.getTime() >= now)
);
}
function hashResetToken(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}
function createPasswordResetUrl(token: string) {
const url = new URL("/reset-password", env.APP_PUBLIC_URL);
url.searchParams.set("token", token);
return url.toString();
}
async function sendPasswordResetEmail(email: string, token: string) {
const resetUrl = createPasswordResetUrl(token);
await sendMail({
to: email,
subject: "Восстановление пароля",
text: [
"Вы запросили восстановление пароля.",
`Перейдите по ссылке: ${resetUrl}`,
`Ссылка действует ${env.PASSWORD_RESET_TTL_MINUTES} минут.`
].join("\n"),
html: `
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #10203b;">
<h2 style="margin: 0 0 16px;">Восстановление пароля</h2>
<p style="margin: 0 0 12px;">Вы запросили смену пароля для вашего аккаунта.</p>
<p style="margin: 0 0 20px;">
<a
href="${resetUrl}"
style="display: inline-block; padding: 12px 18px; border-radius: 12px; background: #21c58b; color: #ffffff; text-decoration: none; font-weight: 700;"
>
Сбросить пароль
</a>
</p>
<p style="margin: 0 0 12px;">Или откройте ссылку вручную:</p>
<p style="margin: 0 0 12px; word-break: break-all;">${resetUrl}</p>
<p style="margin: 0; color: #5f7395;">Ссылка действует ${env.PASSWORD_RESET_TTL_MINUTES} минут.</p>
</div>
`
});
}
authRouter.post("/register", async (req, res, next) => {
try {
const data = registerSchema.parse(req.body);
const existingUser = await prisma.user.findUnique({ where: { email: data.email } });
if (existingUser) throw new HttpError(409, "Пользователь уже существует");
const user = await prisma.user.create({
data: {
email: data.email,
passwordHash: await hashPassword(data.password),
notificationSetting: {
create: { signalsPushEnabled: true, resultsPushEnabled: false }
}
},
select: { id: true, email: true, role: true }
});
const token = signToken({ userId: user.id, role: user.role });
setAuthCookie(res, token);
res.status(201).json({ token, user: { id: user.id, email: user.email, role: user.role } });
} catch (error) {
next(error);
}
});
authRouter.post("/login", async (req, res, next) => {
try {
const data = loginSchema.parse(req.body);
const user = await prisma.user.findUnique({ where: { email: data.email } });
if (!user || !(await verifyPassword(data.password, user.passwordHash))) {
throw new HttpError(401, "Неверный email или пароль");
}
const token = signToken({ userId: user.id, role: user.role });
setAuthCookie(res, token);
res.json({
token,
user: { id: user.id, email: user.email, role: user.role }
});
} catch (error) {
next(error);
}
});
authRouter.post("/logout", (_req, res) => {
clearAuthCookie(res);
res.status(204).send();
});
authRouter.post("/forgot-password", async (req, res, next) => {
try {
const data = forgotPasswordSchema.parse(req.body);
logForgotPassword("requested", { email: data.email });
if (!isMailConfigured()) {
logForgotPassword("smtp_not_configured", { email: data.email });
throw new HttpError(503, "Восстановление пароля по email пока не настроено");
}
const user = await prisma.user.findUnique({
where: { email: data.email },
select: { id: true, email: true, active: true }
});
if (!user) {
logForgotPassword("user_not_found", { email: data.email });
} else if (!user.active) {
logForgotPassword("user_inactive", { email: user.email, userId: user.id });
} else {
const token = crypto.randomBytes(32).toString("hex");
const tokenHash = hashResetToken(token);
const expiresAt = new Date(Date.now() + env.PASSWORD_RESET_TTL_MINUTES * 60 * 1000);
await prisma.$executeRaw`
DELETE FROM "PasswordResetToken"
WHERE "userId" = ${user.id} AND "usedAt" IS NULL
`;
await prisma.$executeRaw`
INSERT INTO "PasswordResetToken" ("id", "userId", "tokenHash", "expiresAt")
VALUES (${crypto.randomUUID()}, ${user.id}, ${tokenHash}, ${expiresAt})
`;
logForgotPassword("token_created", {
email: user.email,
userId: user.id,
expiresAt: expiresAt.toISOString()
});
await sendPasswordResetEmail(user.email, token);
logForgotPassword("email_sent", { email: user.email, userId: user.id });
}
res.json({
message: "Если аккаунт с таким email существует, мы отправили письмо для восстановления пароля"
});
} catch (error) {
next(error);
}
});
authRouter.post("/reset-password", async (req, res, next) => {
try {
const data = resetPasswordSchema.parse(req.body);
const tokenHash = hashResetToken(data.token);
const [resetToken] = await prisma.$queryRaw<PasswordResetTokenRow[]>(Prisma.sql`
SELECT
prt."id",
prt."userId",
prt."expiresAt",
prt."usedAt",
u."active" AS "userActive"
FROM "PasswordResetToken" prt
INNER JOIN "User" u ON u."id" = prt."userId"
WHERE prt."tokenHash" = ${tokenHash}
LIMIT 1
`);
if (
!resetToken ||
resetToken.usedAt ||
resetToken.expiresAt.getTime() < Date.now() ||
!resetToken.userActive
) {
throw new HttpError(400, "Ссылка для восстановления недействительна или уже истекла");
}
const passwordHash = await hashPassword(data.password);
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: resetToken.userId },
data: {
passwordHash
}
});
await tx.$executeRaw`
UPDATE "PasswordResetToken"
SET "usedAt" = ${new Date()}
WHERE "id" = ${resetToken.id}
`;
await tx.$executeRaw`
DELETE FROM "PasswordResetToken"
WHERE "userId" = ${resetToken.userId}
AND "usedAt" IS NULL
AND "id" <> ${resetToken.id}
`;
});
res.json({ message: "Пароль успешно обновлен" });
} catch (error) {
next(error);
}
});
authRouter.get("/me", requireAuth, async (req: AuthenticatedRequest, res) => {
await prisma.userBotAccess.updateMany({
where: {
userId: req.user!.id,
status: SubscriptionStatus.active,
expiresAt: {
lt: new Date()
}
},
data: {
status: SubscriptionStatus.expired
}
});
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
id: true,
email: true,
role: true,
active: true,
createdAt: true,
notificationSetting: true,
botAccesses: {
include: {
bot: {
select: {
id: true,
key: true,
name: true,
sourceUrl: true,
active: true
}
}
},
orderBy: [{ startsAt: "desc" }, { createdAt: "desc" }]
}
}
});
if (!user) {
throw new HttpError(404, "Пользователь не найден");
}
const activeBotAccesses = user.botAccesses.filter(isSubscriptionActive);
if (user.role === "admin" && user.botAccesses.length === 0) {
const bots = await prisma.bot.findMany({
where: { active: true },
select: {
id: true,
key: true,
name: true,
sourceUrl: true,
active: true
},
orderBy: { name: "asc" }
});
res.json({
...user,
botAccesses: bots.map((bot) => ({
id: `admin-${bot.id}`,
grantedAt: user.createdAt,
bot
}))
});
return;
}
res.json({
...user,
botAccesses: activeBotAccesses.map((access) => ({
...access,
isActiveNow: true
}))
});
});

View File

@@ -0,0 +1,24 @@
import { z } from "zod";
export const registerSchema = z.object({
email: z.string().email("Некорректный email"),
password: z.string().min(8, "Минимум 8 символов")
});
export const loginSchema = registerSchema;
export const forgotPasswordSchema = z.object({
email: z.string().email("Некорректный email")
});
export const resetPasswordSchema = z
.object({
token: z.string().min(1, "Токен восстановления обязателен"),
password: z.string().min(8, "Минимум 8 символов"),
confirmPassword: z.string().min(8, "Минимум 8 символов")
})
.refine((data) => data.password === data.confirmPassword, {
message: "Пароли не совпадают",
path: ["confirmPassword"]
});

View File

@@ -0,0 +1,58 @@
import { Router } from "express";
import { env } from "../../config/env.js";
import { HttpError } from "../../lib/errors.js";
import { enqueueBroadcastPush, enqueueSignalPush } from "../queues/push.queue.js";
export const internalRouter = Router();
internalRouter.use((req, res, next) => {
const secret = req.header("x-parser-secret");
if (!env.PARSER_INTERNAL_SECRET || secret !== env.PARSER_INTERNAL_SECRET) {
return res.status(403).json({ message: "Forbidden" });
}
next();
});
internalRouter.post("/parser/signals-changed", async (req, res, next) => {
try {
const signalIds = Array.isArray(req.body?.signalIds)
? req.body.signalIds.filter((value: unknown): value is string => typeof value === "string")
: [];
const jobs = await Promise.all(
signalIds.map((signalId: string) =>
enqueueSignalPush({
type: "signal",
signalId
})
)
);
res.json({
processed: signalIds.length,
queued: jobs.length,
jobs: jobs.map((job) => ({ id: job.id, signalId: job.data.signalId }))
});
} catch (error) {
next(error);
}
});
internalRouter.post("/broadcast", async (req, res, next) => {
try {
const title = String(req.body?.title ?? "").trim();
const body = String(req.body?.body ?? "").trim();
const url = req.body?.url ? String(req.body.url) : "/";
const tag = req.body?.tag ? String(req.body.tag) : undefined;
const renotify = Boolean(req.body?.renotify);
if (!title || !body) {
throw new HttpError(400, "Нужны title и body");
}
const job = await enqueueBroadcastPush({ type: "broadcast", title, body, url, tag, renotify });
res.status(202).json({ queued: true, jobId: job.id });
} catch (error) {
next(error);
}
});

View File

@@ -0,0 +1,428 @@
import { Router } from "express";
import { prisma } from "../../db/prisma.js";
import { getVapidPublicKey } from "../../lib/push.js";
import { requireAuth, type AuthenticatedRequest } from "../../middleware/auth.js";
import {
deactivateNativePushSubscriptionSchema,
deactivatePushSubscriptionSchema,
anonymousNativePushSubscriptionSchema,
anonymousPushSubscriptionSchema,
nativePushSubscriptionSchema,
notificationSettingsSchema,
pushSubscriptionSchema,
replaceableAnonymousPushSubscriptionSchema,
replaceablePushSubscriptionSchema
} from "./push.schemas.js";
export const pushRouter = Router();
function normalizeSubscriptionPayload(body: unknown) {
if (!body || typeof body !== "object") {
return body;
}
const payload = body as Record<string, unknown>;
const nestedKeys =
payload.keys && typeof payload.keys === "object" ? (payload.keys as Record<string, unknown>) : undefined;
return {
...payload,
keys: {
p256dh: nestedKeys?.p256dh ?? payload.p256dh,
auth: nestedKeys?.auth ?? payload.auth
}
};
}
async function getOrCreateAnonymousUser(clientId: string) {
const email = `anon+${clientId}@push.local`;
const user = await prisma.user.upsert({
where: { email },
update: { active: true },
create: {
email,
passwordHash: "anonymous",
role: "user",
active: true,
notificationSetting: {
create: {
signalsPushEnabled: true,
resultsPushEnabled: false
}
}
}
});
await prisma.notificationSetting.upsert({
where: { userId: user.id },
update: {
signalsPushEnabled: true
},
create: {
userId: user.id,
signalsPushEnabled: true,
resultsPushEnabled: false
}
});
return user;
}
async function upsertNativePushSubscription(userId: string, payload: { token: string; platform: "android" | "ios"; deviceId?: string }) {
return prisma.nativePushSubscription.upsert({
where: {
userId_token: {
userId,
token: payload.token
}
},
update: {
active: true,
platform: payload.platform,
deviceId: payload.deviceId
},
create: {
userId,
token: payload.token,
platform: payload.platform,
deviceId: payload.deviceId,
active: true
}
});
}
pushRouter.get("/vapid-public-key", (_req, res) => {
res.json({ publicKey: getVapidPublicKey() });
});
pushRouter.post("/public/push-subscriptions", async (req, res, next) => {
try {
const payload = replaceableAnonymousPushSubscriptionSchema.parse(normalizeSubscriptionPayload(req.body));
const anonymousUser = await getOrCreateAnonymousUser(payload.clientId);
const subscription = await prisma.$transaction(async (tx) => {
if (payload.previousEndpoint && payload.previousEndpoint !== payload.endpoint) {
await tx.pushSubscription.updateMany({
where: {
userId: anonymousUser.id,
endpoint: payload.previousEndpoint
},
data: { active: false }
});
}
return tx.pushSubscription.upsert({
where: {
userId_endpoint: {
userId: anonymousUser.id,
endpoint: payload.endpoint
}
},
update: {
active: true,
p256dh: payload.keys.p256dh,
auth: payload.keys.auth
},
create: {
userId: anonymousUser.id,
endpoint: payload.endpoint,
p256dh: payload.keys.p256dh,
auth: payload.keys.auth,
active: true
}
});
});
res.status(201).json(subscription);
} catch (error) {
console.error("Public push subscription failed", {
body: req.body,
error
});
next(error);
}
});
pushRouter.post("/public/native-push-subscriptions", async (req, res, next) => {
try {
const payload = anonymousNativePushSubscriptionSchema.parse(req.body);
const anonymousUser = await getOrCreateAnonymousUser(payload.clientId);
const subscription = await upsertNativePushSubscription(anonymousUser.id, payload);
res.status(201).json(subscription);
} catch (error) {
console.error("Public native push subscription failed", {
body: req.body,
error
});
next(error);
}
});
pushRouter.get("/public/push-subscriptions/:clientId", async (req, res, next) => {
try {
const payload = anonymousPushSubscriptionSchema.pick({ clientId: true }).parse(req.params);
const email = `anon+${payload.clientId}@push.local`;
const anonymousUser = await prisma.user.findUnique({
where: { email },
include: {
pushSubscriptions: {
where: { active: true },
select: { id: true, endpoint: true, active: true, createdAt: true, updatedAt: true }
}
}
});
const items = anonymousUser?.pushSubscriptions ?? [];
res.json({
items,
hasActiveSubscription: items.length > 0
});
} catch (error) {
next(error);
}
});
pushRouter.get("/public/native-push-subscriptions/:clientId", async (req, res, next) => {
try {
const payload = anonymousPushSubscriptionSchema.pick({ clientId: true }).parse(req.params);
const email = `anon+${payload.clientId}@push.local`;
const anonymousUser = await prisma.user.findUnique({
where: { email },
include: {
nativePushSubscriptions: {
where: { active: true },
select: { id: true, platform: true, active: true, createdAt: true, updatedAt: true }
}
}
});
const items = anonymousUser?.nativePushSubscriptions ?? [];
res.json({
items,
hasActiveSubscription: items.length > 0
});
} catch (error) {
next(error);
}
});
pushRouter.delete("/public/push-subscriptions/:clientId/:id", async (req, res, next) => {
try {
const payload = anonymousPushSubscriptionSchema.pick({ clientId: true }).parse(req.params);
const email = `anon+${payload.clientId}@push.local`;
const anonymousUser = await prisma.user.findUnique({ where: { email } });
if (!anonymousUser) {
return res.status(204).send();
}
await prisma.pushSubscription.updateMany({
where: { id: String(req.params.id), userId: anonymousUser.id },
data: { active: false }
});
res.status(204).send();
} catch (error) {
next(error);
}
});
pushRouter.delete("/public/native-push-subscriptions/:clientId/:id", async (req, res, next) => {
try {
const payload = anonymousPushSubscriptionSchema.pick({ clientId: true }).parse(req.params);
const email = `anon+${payload.clientId}@push.local`;
const anonymousUser = await prisma.user.findUnique({ where: { email } });
if (!anonymousUser) {
return res.status(204).send();
}
await prisma.nativePushSubscription.updateMany({
where: { id: String(req.params.id), userId: anonymousUser.id },
data: { active: false }
});
res.status(204).send();
} catch (error) {
next(error);
}
});
pushRouter.post("/me/push-subscriptions", requireAuth, async (req: AuthenticatedRequest, res, next) => {
try {
const payload = replaceablePushSubscriptionSchema.parse(normalizeSubscriptionPayload(req.body));
const subscription = await prisma.$transaction(async (tx) => {
if (payload.previousEndpoint && payload.previousEndpoint !== payload.endpoint) {
await tx.pushSubscription.updateMany({
where: {
userId: req.user!.id,
endpoint: payload.previousEndpoint
},
data: { active: false }
});
}
return tx.pushSubscription.upsert({
where: {
userId_endpoint: {
userId: req.user!.id,
endpoint: payload.endpoint
}
},
update: {
active: true,
p256dh: payload.keys.p256dh,
auth: payload.keys.auth
},
create: {
userId: req.user!.id,
endpoint: payload.endpoint,
p256dh: payload.keys.p256dh,
auth: payload.keys.auth,
active: true
}
});
});
res.status(201).json(subscription);
} catch (error) {
console.error("Authenticated push subscription failed", {
userId: req.user?.id,
body: req.body,
error
});
next(error);
}
});
pushRouter.post("/me/native-push-subscriptions", requireAuth, async (req: AuthenticatedRequest, res, next) => {
try {
const payload = nativePushSubscriptionSchema.parse(req.body);
const subscription = await upsertNativePushSubscription(req.user!.id, payload);
res.status(201).json(subscription);
} catch (error) {
console.error("Authenticated native push subscription failed", {
userId: req.user?.id,
body: req.body,
error
});
next(error);
}
});
pushRouter.get("/me/push-subscriptions", requireAuth, async (req: AuthenticatedRequest, res) => {
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: req.user!.id, active: true },
select: {
id: true,
endpoint: true,
active: true,
createdAt: true,
updatedAt: true
}
});
res.json({
items: subscriptions,
hasActiveSubscription: subscriptions.length > 0
});
});
pushRouter.get("/me/native-push-subscriptions", requireAuth, async (req: AuthenticatedRequest, res) => {
const subscriptions = await prisma.nativePushSubscription.findMany({
where: { userId: req.user!.id, active: true },
select: {
id: true,
platform: true,
active: true,
createdAt: true,
updatedAt: true
}
});
res.json({
items: subscriptions,
hasActiveSubscription: subscriptions.length > 0
});
});
pushRouter.delete("/me/push-subscriptions/:id", requireAuth, async (req: AuthenticatedRequest, res) => {
await prisma.pushSubscription.updateMany({
where: { id: String(req.params.id), userId: req.user!.id },
data: { active: false }
});
res.status(204).send();
});
pushRouter.delete("/me/native-push-subscriptions/:id", requireAuth, async (req: AuthenticatedRequest, res) => {
await prisma.nativePushSubscription.updateMany({
where: { id: String(req.params.id), userId: req.user!.id },
data: { active: false }
});
res.status(204).send();
});
pushRouter.post("/me/push-subscriptions/deactivate", requireAuth, async (req: AuthenticatedRequest, res, next) => {
try {
const payload = deactivatePushSubscriptionSchema.parse(req.body);
await prisma.pushSubscription.updateMany({
where: {
userId: req.user!.id,
endpoint: payload.endpoint,
active: true
},
data: { active: false }
});
res.status(204).send();
} catch (error) {
next(error);
}
});
pushRouter.post("/me/native-push-subscriptions/deactivate", requireAuth, async (req: AuthenticatedRequest, res, next) => {
try {
const payload = deactivateNativePushSubscriptionSchema.parse(req.body);
await prisma.nativePushSubscription.updateMany({
where: {
userId: req.user!.id,
token: payload.token,
active: true
},
data: { active: false }
});
res.status(204).send();
} catch (error) {
next(error);
}
});
pushRouter.get("/me/notification-settings", requireAuth, async (req: AuthenticatedRequest, res) => {
const settings = await prisma.notificationSetting.findUnique({
where: { userId: req.user!.id }
});
res.json(settings);
});
pushRouter.patch("/me/notification-settings", requireAuth, async (req: AuthenticatedRequest, res, next) => {
try {
const payload = notificationSettingsSchema.parse(req.body);
const settings = await prisma.notificationSetting.upsert({
where: { userId: req.user!.id },
update: payload,
create: {
userId: req.user!.id,
...payload
}
});
res.json(settings);
} catch (error) {
next(error);
}
});

View File

@@ -0,0 +1,44 @@
import { z } from "zod";
export const pushSubscriptionSchema = z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string().min(1),
auth: z.string().min(1)
})
});
export const replaceablePushSubscriptionSchema = pushSubscriptionSchema.extend({
previousEndpoint: z.string().url().optional()
});
export const anonymousPushSubscriptionSchema = pushSubscriptionSchema.extend({
clientId: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/)
});
export const replaceableAnonymousPushSubscriptionSchema = anonymousPushSubscriptionSchema.extend({
previousEndpoint: z.string().url().optional()
});
export const nativePushSubscriptionSchema = z.object({
token: z.string().min(16),
platform: z.enum(["android", "ios"]),
deviceId: z.string().min(1).max(255).optional()
});
export const anonymousNativePushSubscriptionSchema = nativePushSubscriptionSchema.extend({
clientId: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/)
});
export const notificationSettingsSchema = z.object({
signalsPushEnabled: z.boolean(),
resultsPushEnabled: z.boolean()
});
export const deactivatePushSubscriptionSchema = z.object({
endpoint: z.string().url()
});
export const deactivateNativePushSubscriptionSchema = z.object({
token: z.string().min(16)
});

View File

@@ -0,0 +1,569 @@
import { SubscriptionStatus } from "@prisma/client";
import { botNameAliases } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
import { sendNativePush } from "../../lib/native-push.js";
import { sendWebPush } from "../../lib/push.js";
type BroadcastInput = {
title: string;
body: string;
url?: string;
tag?: string;
renotify?: boolean;
};
type DeliveryContext = {
notificationType: "signal" | "broadcast";
title: string;
body: string;
signalId?: string;
tag?: string;
};
type NotifySignalOptions = {
force?: boolean;
};
type PushSubscriptionRecord = {
id: string;
endpoint: string;
p256dh: string;
auth: string;
userId: string;
active: boolean;
};
type NativePushSubscriptionRecord = {
id: string;
token: string;
platform: string;
deviceId: string | null;
userId: string;
active: boolean;
};
function buildActiveSubscriptionWhere(botKey: string) {
const now = new Date();
return {
status: SubscriptionStatus.active,
startsAt: {
lte: now
},
OR: [
{
expiresAt: null
},
{
expiresAt: {
gte: now
}
}
],
bot: {
key: botKey
}
};
}
function isInactiveSignal(rawPayload: unknown) {
if (!rawPayload || typeof rawPayload !== "object" || Array.isArray(rawPayload)) {
return false;
}
const payload = rawPayload as Record<string, unknown>;
return payload.forecastInactive === true || payload.activeTab === 2 || payload.activeTab === "2";
}
function getAliasedBotName(rawPayload: Record<string, unknown> | null) {
const rawBotKey = typeof rawPayload?.botKey === "string" ? rawPayload.botKey.trim() : "";
const rawBotName = typeof rawPayload?.botName === "string" ? rawPayload.botName.trim() : "";
if (rawBotKey) {
const aliasByKey = botNameAliases.get(rawBotKey);
if (aliasByKey) {
return aliasByKey;
}
}
if (rawBotName) {
const aliasByName = botNameAliases.get(rawBotName);
if (aliasByName) {
return aliasByName;
}
}
return rawBotName || "New signal";
}
function getSignalPushPayload(signal: {
id: string;
eventId: string;
homeTeam: string;
awayTeam: string;
selection: string;
odds: unknown;
forecast: string | null;
rawPayload: unknown;
}) {
const rawPayload = signal.rawPayload && typeof signal.rawPayload === "object" && !Array.isArray(signal.rawPayload)
? signal.rawPayload as Record<string, unknown>
: null;
const botName = getAliasedBotName(rawPayload);
const teams = [signal.homeTeam, signal.awayTeam]
.map((team) => team.trim())
.filter((team) => team.length > 0)
.join(" - ");
const forecast = signal.forecast?.trim() || "";
const odds = typeof signal.odds === "number" && Number.isFinite(signal.odds)
? signal.odds.toFixed(2)
: String(signal.odds).trim();
const bodyParts = [odds, forecast, botName].filter((part) => part.length > 0);
const body = bodyParts.join(" - ");
return {
title: teams || botName,
body
};
}
function getSignalBotKey(signal: { rawPayload: unknown }) {
if (!signal.rawPayload || typeof signal.rawPayload !== "object" || Array.isArray(signal.rawPayload)) {
return null;
}
const payload = signal.rawPayload as Record<string, unknown>;
if (typeof payload.botKey !== "string") {
return null;
}
const botKey = payload.botKey.trim();
return botKey.length > 0 ? botKey : null;
}
function getSignalNotificationFingerprint(signal: {
eventId: string;
homeTeam: string;
awayTeam: string;
selection: string;
odds: unknown;
forecast: string | null;
rawPayload: unknown;
}) {
const rawPayload = signal.rawPayload && typeof signal.rawPayload === "object" && !Array.isArray(signal.rawPayload)
? signal.rawPayload as Record<string, unknown>
: null;
return JSON.stringify({
eventId: signal.eventId,
homeTeam: signal.homeTeam,
awayTeam: signal.awayTeam,
selection: signal.selection,
odds: Number(signal.odds),
forecast: signal.forecast?.trim() || null,
botName: typeof rawPayload?.botName === "string" ? rawPayload.botName.trim() : null,
rawForecast: typeof rawPayload?.forecast === "string" ? rawPayload.forecast.trim() : null
});
}
function getEndpointHost(endpoint: string) {
try {
return new URL(endpoint).hostname;
} catch {
return "unknown";
}
}
async function recordDeliveryEvent(
subscription: PushSubscriptionRecord,
result:
| { ok: true; statusCode?: number }
| { ok: false; reason: string; statusCode?: number; details?: string },
context: DeliveryContext
) {
await prisma.integrationLog.create({
data: {
provider: "web-push",
level: result.ok ? "info" : "error",
message: result.ok ? "Web push delivered" : "Web push delivery failed",
payload: {
subscriptionId: subscription.id,
userId: subscription.userId,
endpoint: subscription.endpoint,
endpointHost: getEndpointHost(subscription.endpoint),
active: subscription.active,
notificationType: context.notificationType,
signalId: context.signalId ?? null,
title: context.title,
body: context.body,
tag: context.tag ?? null,
ok: result.ok,
statusCode: result.statusCode ?? null,
reason: result.ok ? null : result.reason,
details: result.ok ? null : result.details ?? null
}
}
});
}
async function deactivateInvalidSubscription(
subscription: Pick<PushSubscriptionRecord, "id" | "endpoint" | "userId">,
result: { ok: false; reason: string; statusCode?: number; details?: string },
context: DeliveryContext
) {
if (result.statusCode !== 404 && result.statusCode !== 410) {
return;
}
await prisma.pushSubscription.updateMany({
where: { id: subscription.id, active: true },
data: { active: false }
});
await prisma.integrationLog.create({
data: {
provider: "web-push",
level: "warn",
message: "Push subscription deactivated after provider rejection",
payload: {
subscriptionId: subscription.id,
userId: subscription.userId,
endpoint: subscription.endpoint,
endpointHost: getEndpointHost(subscription.endpoint),
notificationType: context.notificationType,
signalId: context.signalId ?? null,
tag: context.tag ?? null,
statusCode: result.statusCode,
reason: result.reason,
details: result.details ?? null
}
}
});
}
async function deliverToSubscription(
subscription: PushSubscriptionRecord,
payload: Record<string, unknown>,
context: DeliveryContext
) {
const result = await sendWebPush(subscription, payload);
await recordDeliveryEvent(subscription, result, context);
if (!result.ok) {
await deactivateInvalidSubscription(subscription, result, context);
}
return result;
}
async function recordNativeDeliveryEvent(
subscription: NativePushSubscriptionRecord,
result: { ok: true; messageId: string } | { ok: false; reason: string; code?: string },
context: DeliveryContext
) {
await prisma.integrationLog.create({
data: {
provider: "native-push",
level: result.ok ? "info" : "error",
message: result.ok ? "Native push delivered" : "Native push delivery failed",
payload: {
subscriptionId: subscription.id,
userId: subscription.userId,
platform: subscription.platform,
deviceId: subscription.deviceId,
active: subscription.active,
notificationType: context.notificationType,
signalId: context.signalId ?? null,
title: context.title,
body: context.body,
tag: context.tag ?? null,
ok: result.ok,
code: result.ok ? null : result.code ?? null,
reason: result.ok ? null : result.reason
}
}
});
}
async function deactivateInvalidNativeSubscription(
subscription: Pick<NativePushSubscriptionRecord, "id" | "userId" | "token">,
result: { ok: false; reason: string; code?: string },
context: DeliveryContext
) {
if (result.code !== "messaging/registration-token-not-registered") {
return;
}
await prisma.nativePushSubscription.updateMany({
where: { id: subscription.id, active: true },
data: { active: false }
});
await prisma.integrationLog.create({
data: {
provider: "native-push",
level: "warn",
message: "Native push subscription deactivated after provider rejection",
payload: {
subscriptionId: subscription.id,
userId: subscription.userId,
token: subscription.token,
notificationType: context.notificationType,
signalId: context.signalId ?? null,
tag: context.tag ?? null,
code: result.code ?? null,
reason: result.reason
}
}
});
}
async function deliverToNativeSubscription(
subscription: NativePushSubscriptionRecord,
payload: Record<string, string>,
context: DeliveryContext
) {
const result = await sendNativePush(subscription.token, {
title: context.title,
body: context.body,
data: payload
});
await recordNativeDeliveryEvent(subscription, result, context);
if (!result.ok) {
await deactivateInvalidNativeSubscription(subscription, result, context);
}
return result;
}
export async function notifyUsersForSignal(signalId: string, options: NotifySignalOptions = {}) {
const signal = await prisma.signal.findUnique({ where: { id: signalId } });
if (!signal) return { recipients: 0, successCount: 0, failedCount: 0 };
if (isInactiveSignal(signal.rawPayload)) {
return {
recipients: 0,
successCount: 0,
failedCount: 0,
skipped: true,
reason: "inactive_signal"
};
}
const fingerprint = getSignalNotificationFingerprint(signal);
if (!options.force) {
const previousNotification = await prisma.notificationLog.findFirst({
where: {
signalId,
type: "signal"
},
orderBy: {
createdAt: "desc"
}
});
const previousPayload = previousNotification?.payload
&& typeof previousNotification.payload === "object"
&& !Array.isArray(previousNotification.payload)
? previousNotification.payload as Record<string, unknown>
: null;
if (previousPayload?.fingerprint === fingerprint) {
return {
recipients: 0,
successCount: 0,
failedCount: 0,
skipped: true,
reason: "duplicate_signal_notification"
};
}
}
const botKey = getSignalBotKey(signal);
const users = await prisma.user.findMany({
where: {
active: true,
notificationSetting: { is: { signalsPushEnabled: true } },
...(botKey
? {
OR: [
{
role: "admin"
},
{
botAccesses: {
some: buildActiveSubscriptionWhere(botKey)
}
}
]
}
: {})
},
include: {
pushSubscriptions: { where: { active: true } },
nativePushSubscriptions: { where: { active: true } }
}
});
const { title, body } = getSignalPushPayload(signal);
const tag = `event-${signal.eventId}`;
let recipients = 0;
let successCount = 0;
let failedCount = 0;
for (const user of users) {
for (const subscription of user.pushSubscriptions) {
recipients += 1;
const result = await deliverToSubscription(
subscription,
{
title,
body,
url: `/signals/${signal.id}`,
tag,
data: {
url: `/signals/${signal.id}`,
eventId: signal.eventId,
signalId: signal.id,
type: "signal"
}
},
{
notificationType: "signal",
title,
body,
signalId: signal.id,
tag
}
);
if (result.ok) {
successCount += 1;
} else {
failedCount += 1;
}
}
for (const subscription of user.nativePushSubscriptions) {
recipients += 1;
const result = await deliverToNativeSubscription(
subscription,
{
url: `/signals/${signal.id}`,
eventId: signal.eventId,
signalId: signal.id,
type: "signal"
},
{
notificationType: "signal",
title,
body,
signalId: signal.id,
tag
}
);
if (result.ok) {
successCount += 1;
} else {
failedCount += 1;
}
}
}
await prisma.notificationLog.create({
data: {
signalId,
type: "signal",
recipients,
successCount,
failedCount,
payload: { title, body, fingerprint }
}
});
return { recipients, successCount, failedCount };
}
export async function broadcastNotification({ title, body, url = "/", tag, renotify = false }: BroadcastInput) {
const subscriptions = await prisma.pushSubscription.findMany({
where: { active: true, user: { active: true } }
});
const nativeSubscriptions = await prisma.nativePushSubscription.findMany({
where: { active: true, user: { active: true } }
});
let successCount = 0;
let failedCount = 0;
const finalTag = tag || `broadcast-${Date.now()}`;
for (const subscription of subscriptions) {
const result = await deliverToSubscription(
subscription,
{
title,
body,
url,
tag: finalTag,
renotify,
data: {
url,
type: "broadcast"
}
},
{
notificationType: "broadcast",
title,
body,
tag: finalTag
}
);
if (result.ok) {
successCount += 1;
} else {
failedCount += 1;
}
}
for (const subscription of nativeSubscriptions) {
const result = await deliverToNativeSubscription(
subscription,
{
url,
type: "broadcast",
tag: finalTag,
renotify: String(renotify)
},
{
notificationType: "broadcast",
title,
body,
tag: finalTag
}
);
if (result.ok) {
successCount += 1;
} else {
failedCount += 1;
}
}
await prisma.notificationLog.create({
data: {
type: "broadcast",
recipients: subscriptions.length + nativeSubscriptions.length,
successCount,
failedCount,
payload: { title, body, url, tag: finalTag, renotify }
}
});
return { recipients: subscriptions.length + nativeSubscriptions.length, successCount, failedCount };
}

View File

@@ -0,0 +1,33 @@
import { Queue } from "bullmq";
import { redisConnection } from "../../lib/redis.js";
import {
PUSH_BROADCAST_JOB_NAME,
PUSH_QUEUE_NAME,
PUSH_SIGNAL_JOB_NAME
} from "./queue.constants.js";
import type {
PushBroadcastJobData,
PushJobData,
PushSignalJobData
} from "./queue.types.js";
export const pushQueue = new Queue<PushJobData>(PUSH_QUEUE_NAME, {
connection: redisConnection,
defaultJobOptions: {
attempts: 5,
backoff: {
type: "exponential",
delay: 5_000
},
removeOnComplete: 500,
removeOnFail: 500
}
});
export async function enqueueSignalPush(payload: PushSignalJobData) {
return pushQueue.add(PUSH_SIGNAL_JOB_NAME, payload);
}
export async function enqueueBroadcastPush(payload: PushBroadcastJobData) {
return pushQueue.add(PUSH_BROADCAST_JOB_NAME, payload);
}

View File

@@ -0,0 +1,6 @@
export const SIGNALS_QUEUE_NAME = "signals";
export const SIGNALS_SNAPSHOT_JOB_NAME = "signals.snapshot";
export const PUSH_QUEUE_NAME = "push";
export const PUSH_SIGNAL_JOB_NAME = "push.signal";
export const PUSH_BROADCAST_JOB_NAME = "push.broadcast";

View File

@@ -0,0 +1,45 @@
export type QueueSignalPayload = {
providerId: string;
eventId: string;
sportType: string;
leagueName: string;
homeTeam: string;
awayTeam: string;
eventStartTime: string;
marketType: string;
selection: string;
forecast: string | null;
lineValue: number | null;
odds: number;
signalTime: string;
status: "pending" | "won" | "lost" | "void" | "unpublished";
sourceType: "provider" | "manual";
comment: string | null;
published: boolean;
dedupeKey: string;
rawPayload: Record<string, unknown> | null;
};
export type SignalsSnapshotJobData = {
providerId: string;
items: QueueSignalPayload[];
meta?: Record<string, unknown>;
syncEligibleBotKeys: string[];
};
export type PushSignalJobData = {
type: "signal";
signalId: string;
force?: boolean;
};
export type PushBroadcastJobData = {
type: "broadcast";
title: string;
body: string;
url?: string;
tag?: string;
renotify?: boolean;
};
export type PushJobData = PushSignalJobData | PushBroadcastJobData;

View File

@@ -0,0 +1,21 @@
import { Queue } from "bullmq";
import { redisConnection } from "../../lib/redis.js";
import { SIGNALS_QUEUE_NAME, SIGNALS_SNAPSHOT_JOB_NAME } from "./queue.constants.js";
import type { SignalsSnapshotJobData } from "./queue.types.js";
export const signalsQueue = new Queue<SignalsSnapshotJobData>(SIGNALS_QUEUE_NAME, {
connection: redisConnection,
defaultJobOptions: {
attempts: 5,
backoff: {
type: "exponential",
delay: 5_000
},
removeOnComplete: 200,
removeOnFail: 500
}
});
export async function enqueueSignalsSnapshot(payload: SignalsSnapshotJobData) {
return signalsQueue.add(SIGNALS_SNAPSHOT_JOB_NAME, payload);
}

View File

@@ -0,0 +1,118 @@
import { Router } from "express";
import type { Request } from "express";
import { prisma } from "../../db/prisma.js";
import { env } from "../../config/env.js";
export const rssRouter = Router();
function escapeXml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function toRfc822(value: Date) {
return value.toUTCString();
}
function buildFrontendUrl(path: string) {
return new URL(path, env.APP_PUBLIC_URL).toString();
}
function buildRequestUrl(req: Request) {
const protoHeader = req.get("x-forwarded-proto");
const hostHeader = req.get("x-forwarded-host") || req.get("host");
const protocol = protoHeader?.split(",")[0]?.trim() || req.protocol || "http";
const host = hostHeader?.split(",")[0]?.trim();
if (!host) {
return "";
}
return `${protocol}://${host}${req.originalUrl}`;
}
function formatSignalDescription(signal: {
leagueName: string;
marketType: string;
selection: string;
lineValue: number | null;
odds: number;
status: string;
comment: string | null;
eventStartTime: Date;
signalTime: Date;
}) {
const parts = [
`League: ${signal.leagueName}`,
`Market: ${signal.marketType}`,
`Selection: ${signal.selection}${signal.lineValue !== null ? ` ${signal.lineValue}` : ""}`,
`Odds: ${signal.odds}`,
`Status: ${signal.status}`,
`Event start: ${signal.eventStartTime.toISOString()}`,
`Signal time: ${signal.signalTime.toISOString()}`
];
if (signal.comment) {
parts.push(`Comment: ${signal.comment}`);
}
return parts.join(" | ");
}
rssRouter.get("/rss.xml", async (req, res, next) => {
try {
const signals = await prisma.signal.findMany({
where: { published: true },
orderBy: [{ signalTime: "desc" }, { createdAt: "desc" }],
take: 50
});
const siteUrl = env.APP_PUBLIC_URL;
const selfUrl = buildRequestUrl(req);
const latestDate = signals[0]?.updatedAt ?? new Date();
const itemsXml = signals
.map((signal) => {
const title = `${signal.homeTeam} vs ${signal.awayTeam}: ${signal.selection} @ ${signal.odds}`;
const link = buildFrontendUrl(`/signals/${signal.id}`);
const description = formatSignalDescription(signal);
return [
"<item>",
`<title>${escapeXml(title)}</title>`,
`<link>${escapeXml(link)}</link>`,
`<guid isPermaLink="false">${escapeXml(signal.id)}</guid>`,
`<pubDate>${toRfc822(signal.signalTime)}</pubDate>`,
`<description>${escapeXml(description)}</description>`,
"</item>"
].join("");
})
.join("");
const channelParts = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
"<channel>",
"<title>Betting Signals</title>",
"<description>Latest published betting signals</description>",
`<link>${escapeXml(siteUrl)}</link>`,
`<lastBuildDate>${toRfc822(latestDate)}</lastBuildDate>`,
"<language>ru</language>"
];
if (selfUrl) {
channelParts.push(`<atom:link href="${escapeXml(selfUrl)}" rel="self" type="application/rss+xml" />`);
}
channelParts.push(itemsXml, "</channel>", "</rss>");
res.setHeader("Content-Type", "application/rss+xml; charset=utf-8");
res.send(channelParts.join(""));
} catch (error) {
next(error);
}
});

View File

@@ -0,0 +1,271 @@
import crypto from "node:crypto";
import { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { enqueueSignalPush } from "../queues/push.queue.js";
import type { QueueSignalPayload, SignalsSnapshotJobData } from "../queues/queue.types.js";
function parseActiveTab(value: unknown) {
if (value === null || value === undefined || value === "") return null;
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : null;
}
function normalizeDateValue(value: unknown) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value as string);
const time = date.getTime();
return Number.isNaN(time) ? null : new Date(time).toISOString();
}
function getMeaningfulRawPayload(rawPayload: QueueSignalPayload["rawPayload"]) {
if (!rawPayload || typeof rawPayload !== "object" || Array.isArray(rawPayload)) {
return rawPayload ?? null;
}
return {
botKey: rawPayload.botKey || null,
botName: rawPayload.botName || null,
botUrl: rawPayload.botUrl || null,
id: rawPayload.id || null,
eventUrl: rawPayload.eventUrl || null,
publicationType: rawPayload.publicationType || null,
selectionText: rawPayload.selectionText || null,
publicationTimer: rawPayload.publicationTimer || null,
forecast: rawPayload.forecast || null,
forecastRaw: rawPayload.forecastRaw || null,
forecastImageUrl: rawPayload.forecastImageUrl || null,
stake: rawPayload.stake || null,
stakePercent: rawPayload.stakePercent || null,
activeTab: parseActiveTab(rawPayload.activeTab),
forecastInactive: rawPayload.forecastInactive === true,
parserDetectedInactive: rawPayload.parserDetectedInactive === true
};
}
function hasMeaningfulSignalChanges(existingSignal: {
sportType: string;
leagueName: string;
homeTeam: string;
awayTeam: string;
forecast: string | null;
eventStartTime: Date;
odds: unknown;
published: boolean;
rawPayload: unknown;
} | null, nextSignal: QueueSignalPayload) {
if (!existingSignal) return true;
return (
existingSignal.sportType !== nextSignal.sportType ||
existingSignal.leagueName !== nextSignal.leagueName ||
existingSignal.homeTeam !== nextSignal.homeTeam ||
existingSignal.awayTeam !== nextSignal.awayTeam ||
existingSignal.forecast !== nextSignal.forecast ||
normalizeDateValue(existingSignal.eventStartTime) !== normalizeDateValue(nextSignal.eventStartTime) ||
Number(existingSignal.odds) !== Number(nextSignal.odds) ||
existingSignal.published !== nextSignal.published ||
JSON.stringify(getMeaningfulRawPayload(existingSignal.rawPayload as QueueSignalPayload["rawPayload"])) !==
JSON.stringify(getMeaningfulRawPayload(nextSignal.rawPayload))
);
}
async function upsertSignalWithTransaction(
tx: Prisma.TransactionClient,
signal: QueueSignalPayload
) {
const existingRows = await tx.$queryRawUnsafe<
Array<{
id: string;
published: boolean;
sportType: string;
leagueName: string;
homeTeam: string;
awayTeam: string;
forecast: string | null;
eventStartTime: Date;
odds: unknown;
rawPayload: unknown;
}>
>(
`
SELECT
id,
"published",
"sportType",
"leagueName",
"homeTeam",
"awayTeam",
"forecast",
"eventStartTime",
"odds",
"rawPayload"
FROM "Signal"
WHERE "dedupeKey" = $1
LIMIT 1
`,
signal.dedupeKey
);
const existingSignal = existingRows[0] || null;
const result = await tx.$queryRawUnsafe<Array<{ id: string }>>(
`
INSERT INTO "Signal" (
id, "providerId", "eventId", "sportType", "leagueName", "homeTeam", "awayTeam",
"eventStartTime", "marketType", "selection", "forecast", "lineValue", "odds", "signalTime",
"status", "sourceType", "comment", "published", "dedupeKey", "rawPayload",
"createdAt", "updatedAt"
)
VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12, $13, $14,
$15::"SignalStatus", $16::"SourceType", $17, $18, $19, $20::jsonb,
NOW(), NOW()
)
ON CONFLICT ("dedupeKey")
DO UPDATE SET
"sportType" = EXCLUDED."sportType",
"leagueName" = EXCLUDED."leagueName",
"homeTeam" = EXCLUDED."homeTeam",
"awayTeam" = EXCLUDED."awayTeam",
"eventStartTime" = EXCLUDED."eventStartTime",
"forecast" = EXCLUDED."forecast",
"odds" = EXCLUDED."odds",
"signalTime" = CASE
WHEN ABS(EXTRACT(EPOCH FROM ("Signal"."updatedAt" - "Signal"."signalTime"))) <= 120
THEN EXCLUDED."signalTime"
ELSE "Signal"."signalTime"
END,
"comment" = EXCLUDED."comment",
"published" = true,
"status" = 'pending'::"SignalStatus",
"rawPayload" = EXCLUDED."rawPayload",
"updatedAt" = NOW()
RETURNING id
`,
existingSignal?.id || crypto.randomUUID(),
signal.providerId,
signal.eventId,
signal.sportType,
signal.leagueName,
signal.homeTeam,
signal.awayTeam,
new Date(signal.eventStartTime),
signal.marketType,
signal.selection,
signal.forecast,
signal.lineValue,
signal.odds,
new Date(signal.signalTime),
signal.status,
signal.sourceType,
signal.comment,
signal.published,
signal.dedupeKey,
JSON.stringify(signal.rawPayload)
);
return {
signalId: result[0]?.id || null,
existed: Boolean(existingSignal),
wasPublished: existingSignal?.published === true,
changed: hasMeaningfulSignalChanges(existingSignal, signal)
};
}
export async function syncParserSignalsSnapshot(job: SignalsSnapshotJobData) {
const currentDedupeKeys = job.items.map((signal) => signal.dedupeKey);
const changedSignalIds = await prisma.$transaction(async (tx) => {
const collectedChangedSignalIds: string[] = [];
for (const signal of job.items) {
const result = await upsertSignalWithTransaction(tx, signal);
if (result.signalId && (!result.existed || !result.wasPublished || result.changed)) {
collectedChangedSignalIds.push(result.signalId);
}
}
if (job.syncEligibleBotKeys.length > 0 && currentDedupeKeys.length > 0) {
await tx.$executeRawUnsafe(
`
UPDATE "Signal"
SET
"published" = true,
"status" = CASE
WHEN "status" = 'unpublished'::"SignalStatus" THEN 'pending'::"SignalStatus"
ELSE "status"
END,
"comment" = 'Signal marked inactive by parser',
"rawPayload" = COALESCE("rawPayload", '{}'::jsonb) || '{"parserDetectedInactive":true,"forecastInactive":true,"activeTab":2}'::jsonb,
"updatedAt" = NOW()
WHERE "sourceType" = 'provider'::"SourceType"
AND COALESCE("providerId", '') = $1
AND COALESCE("rawPayload"->>'botKey', '') = ANY($2::text[])
AND NOT ("dedupeKey" = ANY($3::text[]))
AND (
"published" = false
OR COALESCE("rawPayload"->>'forecastInactive', 'false') <> 'true'
OR COALESCE("rawPayload"->>'activeTab', '') <> '2'
OR "status" = 'unpublished'::"SignalStatus"
)
`,
job.providerId,
job.syncEligibleBotKeys,
currentDedupeKeys
);
} else if (job.syncEligibleBotKeys.length > 0) {
await tx.$executeRawUnsafe(
`
UPDATE "Signal"
SET
"published" = true,
"status" = CASE
WHEN "status" = 'unpublished'::"SignalStatus" THEN 'pending'::"SignalStatus"
ELSE "status"
END,
"comment" = 'Signal marked inactive by parser',
"rawPayload" = COALESCE("rawPayload", '{}'::jsonb) || '{"parserDetectedInactive":true,"forecastInactive":true,"activeTab":2}'::jsonb,
"updatedAt" = NOW()
WHERE "sourceType" = 'provider'::"SourceType"
AND COALESCE("providerId", '') = $1
AND COALESCE("rawPayload"->>'botKey', '') = ANY($2::text[])
AND (
"published" = false
OR COALESCE("rawPayload"->>'forecastInactive', 'false') <> 'true'
OR COALESCE("rawPayload"->>'activeTab', '') <> '2'
OR "status" = 'unpublished'::"SignalStatus"
)
`,
job.providerId,
job.syncEligibleBotKeys
);
}
await tx.integrationLog.create({
data: {
provider: job.providerId,
level: "info",
message: "Parser snapshot synchronized from queue",
payload: {
meta: job.meta ?? null,
itemsCount: job.items.length,
syncEligibleBotKeys: job.syncEligibleBotKeys
} as Prisma.InputJsonValue
}
});
return collectedChangedSignalIds;
});
for (const signalId of changedSignalIds) {
await enqueueSignalPush({
type: "signal",
signalId
});
}
return {
processed: job.items.length,
changedSignalIds
};
}

View File

@@ -0,0 +1,99 @@
import { Router } from "express";
import { SubscriptionStatus } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { requireAuth, type AuthenticatedRequest } from "../../middleware/auth.js";
import { signalListSchema } from "./signals.schemas.js";
import { listActiveSignalCountsByBot, listSignals } from "./signals.service.js";
export const signalsRouter = Router();
signalsRouter.use(requireAuth);
function withCreatedAtAsSignalTime<T extends { signalTime: Date; createdAt: Date }>(signal: T) {
return {
...signal,
signalTime: signal.createdAt
};
}
function activeSubscriptionWhere(userId: string, botKey: string) {
const now = new Date();
return {
userId,
status: SubscriptionStatus.active,
startsAt: {
lte: now
},
OR: [
{
expiresAt: null
},
{
expiresAt: {
gte: now
}
}
],
bot: {
key: botKey
}
};
}
signalsRouter.get("/", async (req: AuthenticatedRequest, res, next) => {
try {
const filters = signalListSchema.parse(req.query);
const signals = await listSignals(filters, {
userId: req.user?.id,
role: req.user?.role
});
res.json(signals);
} catch (error) {
next(error);
}
});
signalsRouter.get("/active-counts", async (req: AuthenticatedRequest, res, next) => {
try {
const result = await listActiveSignalCountsByBot({
userId: req.user?.id,
role: req.user?.role
});
res.json(result);
} catch (error) {
next(error);
}
});
signalsRouter.get("/:id", async (req: AuthenticatedRequest, res) => {
const signal = await prisma.signal.findUnique({
where: { id: String(req.params.id) },
include: { settlement: true, notifications: true }
});
if (!signal) {
return res.status(404).json({ message: "Сигнал не найден" });
}
if (req.user?.role !== "admin" && signal.sourceType !== "manual") {
const payload = signal.rawPayload && typeof signal.rawPayload === "object"
? (signal.rawPayload as Record<string, unknown>)
: null;
const botKey = payload?.botKey ? String(payload.botKey) : "";
if (!botKey) {
return res.status(403).json({ message: "Нет доступа к сигналу" });
}
const access = await prisma.userBotAccess.findFirst({
where: activeSubscriptionWhere(req.user!.id, botKey)
});
if (!access) {
return res.status(403).json({ message: "Нет доступа к сигналу" });
}
}
return res.json(withCreatedAtAsSignalTime(signal));
});

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
export const signalCreateSchema = z.object({
providerId: z.string().optional().nullable(),
eventId: z.string().min(1),
sportType: z.string().min(1),
leagueName: z.string().min(1),
homeTeam: z.string().min(1),
awayTeam: z.string().min(1),
eventStartTime: z.string().datetime(),
marketType: z.string().min(1),
selection: z.string().min(1),
forecast: z.string().optional().nullable(),
lineValue: z.number().optional().nullable(),
odds: z.number().positive(),
signalTime: z.string().datetime(),
sourceType: z.enum(["manual", "provider"]).default("manual"),
comment: z.string().optional().nullable(),
rawPayload: z.record(z.any()).optional().nullable(),
published: z.boolean().default(true)
});
export const signalUpdateSchema = signalCreateSchema.partial().extend({
status: z.enum(["pending", "win", "lose", "void", "manual_review", "unpublished"]).optional()
});
export const signalStatusSchema = z.object({
status: z.enum(["pending", "win", "lose", "void", "manual_review", "unpublished"]),
explanation: z.string().min(1)
});
export const signalListSchema = z.object({
status: z.string().optional(),
sportType: z.string().optional(),
sourceType: z.string().optional(),
q: z.string().optional(),
published: z.string().optional(),
botKey: z.string().optional(),
activeTab: z.coerce.number().int().min(1).max(2).optional(),
page: z.coerce.number().int().min(1).default(1),
perPage: z.coerce.number().int().min(1).max(200).default(20)
});

View File

@@ -0,0 +1,420 @@
import { Prisma, SignalStatus, SourceType, SubscriptionStatus } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { createSignalDedupeKey } from "../../lib/dedupe.js";
import { HttpError } from "../../lib/errors.js";
import { settleSignal } from "../../lib/settlement.js";
type UpsertSignalInput = {
providerId?: string | null;
eventId: string;
sportType: string;
leagueName: string;
homeTeam: string;
awayTeam: string;
eventStartTime: string;
marketType: string;
selection: string;
forecast?: string | null;
lineValue?: number | null;
odds: number;
signalTime: string;
sourceType: "manual" | "provider";
comment?: string | null;
rawPayload?: Record<string, unknown> | null;
published?: boolean;
};
function normalizeJson(value: Record<string, unknown> | null | undefined): Prisma.InputJsonValue | typeof Prisma.JsonNull | undefined {
if (value === undefined) return undefined;
if (value === null) return Prisma.JsonNull;
return value as Prisma.InputJsonObject;
}
function asWhereArray(value: Prisma.SignalWhereInput["AND"]): Prisma.SignalWhereInput[] {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
const PARSER_INACTIVE_COMMENT = "Signal marked inactive by parser";
function withCreatedAtAsSignalTime<T extends { signalTime: Date; createdAt: Date }>(signal: T) {
return {
...signal,
signalTime: signal.createdAt
};
}
function buildActiveSubscriptionWhere(userId: string): Prisma.UserBotAccessWhereInput {
const now = new Date();
return {
userId,
status: SubscriptionStatus.active,
startsAt: {
lte: now
},
OR: [
{
expiresAt: null
},
{
expiresAt: {
gte: now
}
}
]
};
}
function buildInactiveSignalWhere(): Prisma.SignalWhereInput {
return {
comment: PARSER_INACTIVE_COMMENT
};
}
function buildActiveSignalWhere(): Prisma.SignalWhereInput {
return {
OR: [
{
comment: null
},
{
comment: {
not: PARSER_INACTIVE_COMMENT
}
}
]
};
}
export async function createSignal(input: UpsertSignalInput) {
const dedupeKey = createSignalDedupeKey(input);
try {
return await prisma.signal.create({
data: {
providerId: input.providerId ?? null,
eventId: input.eventId,
sportType: input.sportType,
leagueName: input.leagueName,
homeTeam: input.homeTeam,
awayTeam: input.awayTeam,
eventStartTime: new Date(input.eventStartTime),
marketType: input.marketType,
selection: input.selection,
forecast: input.forecast ?? null,
lineValue: input.lineValue ?? null,
odds: input.odds,
signalTime: new Date(input.signalTime),
status: SignalStatus.pending,
sourceType: input.sourceType as SourceType,
comment: input.comment ?? null,
published: input.published ?? true,
dedupeKey,
rawPayload: normalizeJson(input.rawPayload)
}
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
throw new HttpError(409, "Такой сигнал уже существует");
}
throw error;
}
}
export async function updateSignal(id: string, input: Partial<UpsertSignalInput> & { status?: SignalStatus }) {
const current = await prisma.signal.findUnique({ where: { id } });
if (!current) throw new HttpError(404, "Сигнал не найден");
const dedupeKey = createSignalDedupeKey({
providerId: input.providerId ?? current.providerId,
eventId: input.eventId ?? current.eventId,
marketType: input.marketType ?? current.marketType,
selection: input.selection ?? current.selection,
lineValue: input.lineValue ?? current.lineValue
});
return prisma.signal.update({
where: { id },
data: {
providerId: input.providerId ?? undefined,
eventId: input.eventId,
sportType: input.sportType,
leagueName: input.leagueName,
homeTeam: input.homeTeam,
awayTeam: input.awayTeam,
eventStartTime: input.eventStartTime ? new Date(input.eventStartTime) : undefined,
marketType: input.marketType,
selection: input.selection,
forecast: input.forecast === null ? null : input.forecast,
lineValue: input.lineValue === null ? null : input.lineValue,
odds: input.odds,
signalTime: input.signalTime ? new Date(input.signalTime) : undefined,
sourceType: input.sourceType ? (input.sourceType as SourceType) : undefined,
comment: input.comment === null ? null : input.comment,
published: input.published,
status: input.status,
dedupeKey,
rawPayload: normalizeJson(input.rawPayload)
}
});
}
export async function listSignals(filters: {
status?: string;
sportType?: string;
sourceType?: string;
q?: string;
published?: string;
botKey?: string;
activeTab?: number;
page: number;
perPage: number;
}, options: { userId?: string; role?: "admin" | "user" } = {}) {
const publishedFilter = filters.published === undefined ? true : filters.published === "true";
const page = filters.page;
const perPage = filters.perPage;
const where: Prisma.SignalWhereInput = {
status: filters.status as SignalStatus | undefined,
sportType: filters.sportType,
sourceType: filters.sourceType as "manual" | "provider" | undefined,
published: publishedFilter,
OR: filters.q
? [
{ homeTeam: { contains: filters.q, mode: "insensitive" } },
{ awayTeam: { contains: filters.q, mode: "insensitive" } },
{ leagueName: { contains: filters.q, mode: "insensitive" } }
]
: undefined
};
const andConditions: Prisma.SignalWhereInput[] = [];
let activeTabCondition: Prisma.SignalWhereInput | null = null;
if (filters.activeTab !== undefined) {
activeTabCondition = filters.activeTab === 2
? buildInactiveSignalWhere()
: buildActiveSignalWhere();
}
if (filters.botKey) {
andConditions.push({
OR: [
{
rawPayload: {
path: ["botKey"],
equals: filters.botKey
}
},
{
rawPayload: {
path: ["botKey"],
equals: String(filters.botKey)
}
}
]
});
}
if (options.role === "user" && options.userId) {
const accesses = await prisma.userBotAccess.findMany({
where: buildActiveSubscriptionWhere(options.userId),
select: {
bot: {
select: {
key: true
}
}
}
});
const allowedBotKeys = accesses.map((entry) => entry.bot.key);
andConditions.push({
OR: [
{
sourceType: SourceType.manual
},
...allowedBotKeys.flatMap((key) => [
{
rawPayload: {
path: ["botKey"],
equals: key
}
},
{
rawPayload: {
path: ["botKey"],
equals: String(key)
}
}
])
]
});
}
if (activeTabCondition) {
andConditions.push(activeTabCondition);
}
if (andConditions.length > 0) {
where.AND = andConditions;
}
const tabCountAndConditions = activeTabCondition
? andConditions.filter((condition) => condition !== activeTabCondition)
: andConditions;
const tabCountsWhere: Prisma.SignalWhereInput | null = filters.botKey || options.role === "user"
? {
status: undefined,
sportType: filters.sportType,
sourceType: filters.sourceType as "manual" | "provider" | undefined,
published: publishedFilter,
OR: undefined
}
: null;
if (tabCountsWhere && tabCountAndConditions.length > 0) {
tabCountsWhere.AND = tabCountAndConditions;
}
const activeTab1Where: Prisma.SignalWhereInput | null = tabCountsWhere
? {
...tabCountsWhere,
AND: [
...asWhereArray(tabCountsWhere.AND),
buildActiveSignalWhere()
]
}
: null;
const activeTab2Where: Prisma.SignalWhereInput | null = tabCountsWhere
? {
...tabCountsWhere,
AND: [
...asWhereArray(tabCountsWhere.AND),
buildInactiveSignalWhere()
]
}
: null;
const [items, total, allTabTotal, activeTabTotal, inactiveTabTotal] = await Promise.all([
prisma.signal.findMany({
where,
orderBy: [{ eventStartTime: "desc" }, { signalTime: "desc" }],
skip: (page - 1) * perPage,
take: perPage
}),
prisma.signal.count({ where }),
tabCountsWhere ? prisma.signal.count({ where: tabCountsWhere }) : Promise.resolve(0),
activeTab1Where ? prisma.signal.count({ where: activeTab1Where }) : Promise.resolve(0),
activeTab2Where ? prisma.signal.count({ where: activeTab2Where }) : Promise.resolve(0)
]);
return {
items: items.map(withCreatedAtAsSignalTime),
pagination: {
page,
perPage,
total,
totalPages: Math.max(1, Math.ceil(total / perPage))
},
tabCounts: tabCountsWhere
? {
all: allTabTotal,
"1": activeTabTotal,
"2": inactiveTabTotal
}
: undefined
};
}
export async function listActiveSignalCountsByBot(options: { userId?: string; role?: "admin" | "user" } = {}) {
const botWhere: Prisma.BotWhereInput = {
active: true
};
if (options.role === "user" && options.userId) {
botWhere.userAccesses = {
some: buildActiveSubscriptionWhere(options.userId)
};
}
const bots = await prisma.bot.findMany({
where: botWhere,
select: {
id: true,
key: true,
name: true
},
orderBy: {
name: "asc"
}
});
const items = await Promise.all(
bots.map(async (bot) => {
const activeSignals = await prisma.signal.count({
where: {
published: true,
OR: [
{
rawPayload: {
path: ["botKey"],
equals: bot.key
}
},
{
rawPayload: {
path: ["botKey"],
equals: String(bot.key)
}
}
],
AND: [
buildActiveSignalWhere()
]
}
});
return {
botId: bot.id,
botKey: bot.key,
botName: bot.name,
activeSignals
};
})
);
return {
items
};
}
export async function settlePendingSignals() {
const pendingSignals = await prisma.signal.findMany({
where: { status: SignalStatus.pending, published: true }
});
for (const signal of pendingSignals) {
const eventResult = await prisma.eventResult.findUnique({ where: { eventId: signal.eventId } });
if (!eventResult || eventResult.homeScore === null || eventResult.awayScore === null) continue;
const result = settleSignal(signal, {
homeScore: eventResult.homeScore,
awayScore: eventResult.awayScore
});
await prisma.signal.update({ where: { id: signal.id }, data: { status: result } });
await prisma.settlement.upsert({
where: { signalId: signal.id },
update: { result, explanation: `Автоматический settlement по счету ${eventResult.homeScore}:${eventResult.awayScore}` },
create: {
signalId: signal.id,
result,
explanation: `Автоматический settlement по счету ${eventResult.homeScore}:${eventResult.awayScore}`
}
});
}
}

View File

@@ -0,0 +1,296 @@
import { Router } from "express";
import { SubscriptionStatus } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { requireAdmin, requireAuth, type AuthenticatedRequest } from "../../middleware/auth.js";
export const usersRouter = Router();
function isSubscriptionActive(access: {
status: SubscriptionStatus;
startsAt: Date;
expiresAt: Date | null;
}) {
const now = Date.now();
const startsAt = access.startsAt.getTime();
const expiresAt = access.expiresAt?.getTime() ?? null;
return access.status === SubscriptionStatus.active && startsAt <= now && (expiresAt === null || expiresAt >= now);
}
async function refreshExpiredSubscriptions(userId?: string) {
await prisma.userBotAccess.updateMany({
where: {
...(userId ? { userId } : {}),
status: SubscriptionStatus.active,
expiresAt: {
lt: new Date()
}
},
data: {
status: SubscriptionStatus.expired
}
});
}
usersRouter.get("/admin/users", requireAuth, requireAdmin, async (req, res) => {
await refreshExpiredSubscriptions();
const query = String(req.query.q ?? "").trim();
const where = {
AND: [
{
NOT: {
email: {
startsWith: "anon+"
}
}
},
{
NOT: {
email: {
endsWith: "@push.local"
}
}
},
...(query
? [
{
email: {
contains: query,
mode: "insensitive" as const
}
}
]
: [])
]
};
const users = await prisma.user.findMany({
where,
include: {
pushSubscriptions: true,
notificationSetting: true,
botAccesses: {
include: {
bot: true
},
orderBy: [
{
status: "asc"
},
{
createdAt: "desc"
}
]
}
},
orderBy: { createdAt: "desc" }
});
res.json(
users.map((user) => ({
...user,
botAccesses: user.botAccesses.map((access) => ({
...access,
isActiveNow: isSubscriptionActive(access)
}))
}))
);
});
usersRouter.patch("/admin/users/:id/active", requireAuth, requireAdmin, async (req, res) => {
const user = await prisma.user.update({
where: { id: String(req.params.id) },
data: { active: Boolean(req.body.active) }
});
res.json(user);
});
usersRouter.get("/admin/bots", requireAuth, requireAdmin, async (_req, res) => {
const bots = await prisma.bot.findMany({
where: { active: true },
orderBy: { name: "asc" }
});
res.json(bots);
});
usersRouter.put("/admin/users/:id/bot-access", requireAuth, requireAdmin, async (req: AuthenticatedRequest, res) => {
const userId = String(req.params.id);
const requestedBotIds = Array.isArray(req.body?.botIds) ? req.body.botIds.map((entry: unknown) => String(entry)) : [];
const availableBots = await prisma.bot.findMany({
where: {
id: {
in: requestedBotIds
}
},
select: {
id: true
}
});
const validBotIds = availableBots.map((bot) => bot.id);
await prisma.$transaction(async (tx) => {
await tx.userBotAccess.updateMany({
where: {
userId,
botId: {
notIn: validBotIds.length > 0 ? validBotIds : ["__none__"]
}
},
data: {
status: SubscriptionStatus.canceled,
expiresAt: new Date()
}
});
for (const botId of validBotIds) {
await tx.userBotAccess.upsert({
where: {
userId_botId: {
userId,
botId
}
},
update: {
status: SubscriptionStatus.active,
startsAt: new Date(),
expiresAt: null,
notes: null,
grantedById: req.user!.id
},
create: {
userId,
botId,
grantedById: req.user!.id,
status: SubscriptionStatus.active,
startsAt: new Date()
}
});
}
});
const updatedUser = await prisma.user.findUnique({
where: { id: userId },
include: {
pushSubscriptions: true,
notificationSetting: true,
botAccesses: {
include: {
bot: true
},
orderBy: {
bot: {
name: "asc"
}
}
}
}
});
res.json(
updatedUser
? {
...updatedUser,
botAccesses: updatedUser.botAccesses.map((access) => ({
...access,
isActiveNow: isSubscriptionActive(access)
}))
}
: null
);
});
usersRouter.patch("/admin/users/:id/subscriptions/:botId", requireAuth, requireAdmin, async (req: AuthenticatedRequest, res) => {
const userId = String(req.params.id);
const botId = String(req.params.botId);
const status = String(req.body?.status ?? SubscriptionStatus.active) as SubscriptionStatus;
const startsAt = req.body?.startsAt ? new Date(String(req.body.startsAt)) : new Date();
const expiresAt = req.body?.expiresAt ? new Date(String(req.body.expiresAt)) : null;
const notes = typeof req.body?.notes === "string" ? req.body.notes.trim() || null : null;
if (!Object.values(SubscriptionStatus).includes(status)) {
return res.status(400).json({ message: "Некорректный статус подписки" });
}
const bot = await prisma.bot.findUnique({
where: { id: botId },
select: { id: true }
});
if (!bot) {
return res.status(404).json({ message: "Бот не найден" });
}
if (Number.isNaN(startsAt.getTime()) || (expiresAt && Number.isNaN(expiresAt.getTime()))) {
return res.status(400).json({ message: "Некорректная дата подписки" });
}
if (expiresAt && expiresAt < startsAt) {
return res.status(400).json({ message: "Дата окончания не может быть раньше даты начала" });
}
const access = await prisma.userBotAccess.upsert({
where: {
userId_botId: {
userId,
botId
}
},
update: {
status,
startsAt,
expiresAt,
notes,
grantedById: req.user!.id,
grantedAt: new Date()
},
create: {
userId,
botId,
status,
startsAt,
expiresAt,
notes,
grantedById: req.user!.id
},
include: {
bot: true
}
});
res.json({
...access,
isActiveNow: isSubscriptionActive(access)
});
});
usersRouter.get("/me/subscriptions", requireAuth, async (req: AuthenticatedRequest, res) => {
await refreshExpiredSubscriptions(req.user!.id);
const subscriptions = await prisma.userBotAccess.findMany({
where: {
userId: req.user!.id
},
include: {
bot: true
},
orderBy: [
{
status: "asc"
},
{
startsAt: "desc"
}
]
});
res.json(
subscriptions.map((subscription) => ({
...subscription,
isActiveNow: isSubscriptionActive(subscription)
}))
);
});

View File

@@ -0,0 +1,29 @@
export type ProviderSignal = {
providerId: string;
eventId: string;
sportType: string;
leagueName: string;
homeTeam: string;
awayTeam: string;
eventStartTime: string;
marketType: string;
selection: string;
lineValue?: number | null;
odds: number;
signalTime: string;
rawPayload?: Record<string, unknown>;
};
export interface SignalProvider {
providerName: string;
fetchSignals(): Promise<ProviderSignal[]>;
}
export class StubProvider implements SignalProvider {
providerName = "stub";
async fetchSignals() {
return [];
}
}

View File

@@ -0,0 +1,16 @@
import { env } from "../../config/env.js";
import { settlePendingSignals } from "../signals/signals.service.js";
export function startSettlementWorker() {
const run = async () => {
try {
await settlePendingSignals();
} catch (error) {
console.error("Settlement worker error", error);
}
};
void run();
return setInterval(run, env.SETTLEMENT_INTERVAL_MS);
}

2
backend/src/types/web-push.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare module "web-push";

View File

@@ -0,0 +1,59 @@
import { Worker } from "bullmq";
import { prisma } from "../db/prisma.js";
import { closeRedisConnection, redisConnection } from "../lib/redis.js";
import {
PUSH_BROADCAST_JOB_NAME,
PUSH_QUEUE_NAME,
PUSH_SIGNAL_JOB_NAME
} from "../modules/queues/queue.constants.js";
import type { PushJobData } from "../modules/queues/queue.types.js";
import { broadcastNotification, notifyUsersForSignal } from "../modules/push/push.service.js";
import { env } from "../config/env.js";
const worker = new Worker<PushJobData>(
PUSH_QUEUE_NAME,
async (job) => {
if (job.name === PUSH_SIGNAL_JOB_NAME && job.data.type === "signal") {
return notifyUsersForSignal(job.data.signalId, { force: job.data.force });
}
if (job.name === PUSH_BROADCAST_JOB_NAME && job.data.type === "broadcast") {
return broadcastNotification({
title: job.data.title,
body: job.data.body,
url: job.data.url,
tag: job.data.tag,
renotify: job.data.renotify
});
}
throw new Error(`Unsupported push job: ${job.name}`);
},
{
connection: redisConnection,
concurrency: env.PUSH_WORKER_CONCURRENCY
}
);
worker.on("completed", (job) => {
console.log(`[push-worker] completed ${job.id}`);
});
worker.on("failed", (job, error) => {
console.error(`[push-worker] failed ${job?.id ?? "unknown"}`, error);
});
async function shutdown() {
await worker.close();
await closeRedisConnection();
await prisma.$disconnect();
process.exit(0);
}
process.on("SIGINT", () => {
void shutdown();
});
process.on("SIGTERM", () => {
void shutdown();
});

View File

@@ -0,0 +1,47 @@
import { Worker } from "bullmq";
import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { closeRedisConnection, redisConnection } from "../lib/redis.js";
import {
SIGNALS_QUEUE_NAME,
SIGNALS_SNAPSHOT_JOB_NAME
} from "../modules/queues/queue.constants.js";
import { syncParserSignalsSnapshot } from "../modules/signals/parser-signals.service.js";
const worker = new Worker(
SIGNALS_QUEUE_NAME,
async (job) => {
if (job.name !== SIGNALS_SNAPSHOT_JOB_NAME) {
throw new Error(`Unsupported signals job: ${job.name}`);
}
return syncParserSignalsSnapshot(job.data);
},
{
connection: redisConnection,
concurrency: env.SIGNALS_WORKER_CONCURRENCY
}
);
worker.on("completed", (job) => {
console.log(`[signals-worker] completed ${job.id}`);
});
worker.on("failed", (job, error) => {
console.error(`[signals-worker] failed ${job?.id ?? "unknown"}`, error);
});
async function shutdown() {
await worker.close();
await closeRedisConnection();
await prisma.$disconnect();
process.exit(0);
}
process.on("SIGINT", () => {
void shutdown();
});
process.on("SIGTERM", () => {
void shutdown();
});

16
backend/tsconfig.json Normal file
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", "prisma/seed.ts"]
}

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"]
}

354
docker-compose.yml Normal file
View File

@@ -0,0 +1,354 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
restart: unless-stopped
chat-postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${CHAT_POSTGRES_DB}
POSTGRES_USER: ${CHAT_POSTGRES_USER}
POSTGRES_PASSWORD: ${CHAT_POSTGRES_PASSWORD}
volumes:
- chat_postgres_data:/var/lib/postgresql/data
postgres-backup:
image: postgres:16-alpine
restart: unless-stopped
depends_on:
- postgres
- chat-postgres
environment:
TZ: ${BACKUP_TZ:-Europe/Moscow}
BACKUP_DIR: /backups
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
BACKUP_GZIP_LEVEL: ${BACKUP_GZIP_LEVEL:-6}
BACKUP_INCLUDE_CHAT_DB: ${BACKUP_INCLUDE_CHAT_DB:-true}
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
CHAT_POSTGRES_HOST: chat-postgres
CHAT_POSTGRES_PORT: 5432
CHAT_POSTGRES_DB: ${CHAT_POSTGRES_DB}
CHAT_POSTGRES_USER: ${CHAT_POSTGRES_USER}
CHAT_POSTGRES_PASSWORD: ${CHAT_POSTGRES_PASSWORD}
command: ["/bin/sh", "/scripts/pg-backup.sh"]
volumes:
- ./scripts/pg-backup.sh:/scripts/pg-backup.sh:ro
- ./backups:/backups
adminer:
image: adminer:5
restart: unless-stopped
depends_on:
- postgres
expose:
- "8080"
networks:
- default
- proxy
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.alpinbet-adminer.rule=Host(`db.antigol.ru`)
- traefik.http.routers.alpinbet-adminer.entrypoints=websecure
- traefik.http.routers.alpinbet-adminer.tls.certresolver=letsencrypt
- traefik.http.services.alpinbet-adminer.loadbalancer.server.port=8080
dockmon:
image: darthnorse/dockmon:latest
restart: unless-stopped
environment:
TZ: Europe/Moscow
PYTHONPATH: /app/backend
PYTHONUNBUFFERED: 1
REVERSE_PROXY_MODE: "true"
DOCKMON_EXTERNAL_URL: https://monitor.antigol.ru
DOCKMON_CORS_ORIGINS: https://monitor.antigol.ru
expose:
- "80"
volumes:
- dockmon_data:/app/data
- /var/run/docker.sock:/var/run/docker.sock
networks:
- default
- proxy
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.alpinbet-dockmon.rule=Host(`monitor.antigol.ru`)
- traefik.http.routers.alpinbet-dockmon.entrypoints=websecure
- traefik.http.routers.alpinbet-dockmon.tls.certresolver=letsencrypt
- traefik.http.services.alpinbet-dockmon.loadbalancer.server.port=80
backend:
build:
context: ./backend
restart: unless-stopped
depends_on:
- postgres
- redis
command: npm run start:api:with-db-url
environment:
REDIS_URL: redis://redis:6379
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_SCHEMA: public
PORT: 4000
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
CORS_ORIGIN: ${CORS_ORIGIN}
APP_PUBLIC_URL: ${APP_PUBLIC_URL}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Alpinbet}
PASSWORD_RESET_TTL_MINUTES: ${PASSWORD_RESET_TTL_MINUTES:-60}
APP_LATEST_VERSION: ${APP_LATEST_VERSION:-}
APP_MIN_SUPPORTED_VERSION: ${APP_MIN_SUPPORTED_VERSION:-}
APP_UPDATE_URL: ${APP_UPDATE_URL:-}
APP_UPDATE_MESSAGE: ${APP_UPDATE_MESSAGE:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com}
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-}
FIREBASE_CLIENT_EMAIL: ${FIREBASE_CLIENT_EMAIL:-}
FIREBASE_PRIVATE_KEY: ${FIREBASE_PRIVATE_KEY:-}
FIREBASE_SERVICE_ACCOUNT_JSON: ${FIREBASE_SERVICE_ACCOUNT_JSON:-}
SETTLEMENT_INTERVAL_MS: ${SETTLEMENT_INTERVAL_MS:-60000}
PARSER_INTERNAL_SECRET: ${PARSER_INTERNAL_SECRET}
VAPID_STATE_DIR: /app/runtime
expose:
- "4000"
volumes:
- backend_runtime:/app/runtime
networks:
- default
- proxy
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.alpinbet-backend.rule=Host(`api.antigol.ru`)
- traefik.http.routers.alpinbet-backend.entrypoints=websecure
- traefik.http.routers.alpinbet-backend.tls.certresolver=letsencrypt
- traefik.http.services.alpinbet-backend.loadbalancer.server.port=4000
backend-signals-worker:
build:
context: ./backend
restart: unless-stopped
depends_on:
- postgres
- redis
command: npm run start:signals-worker:with-db-url
environment:
REDIS_URL: redis://redis:6379
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_SCHEMA: public
PORT: 4000
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
CORS_ORIGIN: ${CORS_ORIGIN}
APP_PUBLIC_URL: ${APP_PUBLIC_URL}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Alpinbet}
PASSWORD_RESET_TTL_MINUTES: ${PASSWORD_RESET_TTL_MINUTES:-60}
APP_LATEST_VERSION: ${APP_LATEST_VERSION:-}
APP_MIN_SUPPORTED_VERSION: ${APP_MIN_SUPPORTED_VERSION:-}
APP_UPDATE_URL: ${APP_UPDATE_URL:-}
APP_UPDATE_MESSAGE: ${APP_UPDATE_MESSAGE:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com}
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-}
FIREBASE_CLIENT_EMAIL: ${FIREBASE_CLIENT_EMAIL:-}
FIREBASE_PRIVATE_KEY: ${FIREBASE_PRIVATE_KEY:-}
FIREBASE_SERVICE_ACCOUNT_JSON: ${FIREBASE_SERVICE_ACCOUNT_JSON:-}
SETTLEMENT_INTERVAL_MS: ${SETTLEMENT_INTERVAL_MS:-60000}
PARSER_INTERNAL_SECRET: ${PARSER_INTERNAL_SECRET}
VAPID_STATE_DIR: /app/runtime
volumes:
- backend_runtime:/app/runtime
backend-push-worker:
build:
context: ./backend
restart: unless-stopped
depends_on:
- postgres
- redis
command: npm run start:push-worker:with-db-url
environment:
REDIS_URL: redis://redis:6379
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_SCHEMA: public
PORT: 4000
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
CORS_ORIGIN: ${CORS_ORIGIN}
APP_PUBLIC_URL: ${APP_PUBLIC_URL}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Alpinbet}
PASSWORD_RESET_TTL_MINUTES: ${PASSWORD_RESET_TTL_MINUTES:-60}
APP_LATEST_VERSION: ${APP_LATEST_VERSION:-}
APP_MIN_SUPPORTED_VERSION: ${APP_MIN_SUPPORTED_VERSION:-}
APP_UPDATE_URL: ${APP_UPDATE_URL:-}
APP_UPDATE_MESSAGE: ${APP_UPDATE_MESSAGE:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com}
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-}
FIREBASE_CLIENT_EMAIL: ${FIREBASE_CLIENT_EMAIL:-}
FIREBASE_PRIVATE_KEY: ${FIREBASE_PRIVATE_KEY:-}
FIREBASE_SERVICE_ACCOUNT_JSON: ${FIREBASE_SERVICE_ACCOUNT_JSON:-}
SETTLEMENT_INTERVAL_MS: ${SETTLEMENT_INTERVAL_MS:-60000}
PARSER_INTERNAL_SECRET: ${PARSER_INTERNAL_SECRET}
VAPID_STATE_DIR: /app/runtime
volumes:
- backend_runtime:/app/runtime
chat-service:
build:
context: ./chat-service
restart: unless-stopped
depends_on:
- chat-postgres
command: npm run start:with-db-url
environment:
CHAT_POSTGRES_HOST: chat-postgres
CHAT_POSTGRES_PORT: 5432
CHAT_POSTGRES_DB: ${CHAT_POSTGRES_DB}
CHAT_POSTGRES_USER: ${CHAT_POSTGRES_USER}
CHAT_POSTGRES_PASSWORD: ${CHAT_POSTGRES_PASSWORD}
CHAT_POSTGRES_SCHEMA: public
PORT: 4050
JWT_SECRET: ${JWT_SECRET}
CORS_ORIGIN: ${CORS_ORIGIN}
expose:
- "4050"
networks:
- default
- proxy
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.alpinbet-chat.rule=Host(`chat.antigol.ru`)
- traefik.http.routers.alpinbet-chat.entrypoints=websecure
- traefik.http.routers.alpinbet-chat.tls.certresolver=letsencrypt
- traefik.http.services.alpinbet-chat.loadbalancer.server.port=4050
parser:
build:
context: .
dockerfile: ./parser/Dockerfile
depends_on:
- redis
- forecast-ocr
command: npm run start
env_file:
- ./parser/.env
environment:
REDIS_URL: redis://redis:6379
BACKEND_INTERNAL_URL: http://backend:4000
PARSER_INTERNAL_SECRET: ${PARSER_INTERNAL_SECRET}
FORECAST_OCR_URL: http://forecast-ocr:4010
volumes:
- ./parser/data:/app/parser/data
restart: unless-stopped
forecast-ocr:
build:
context: ./forecast-ocr-service
restart: unless-stopped
expose:
- "4010"
frontend:
build:
context: ./frontend
depends_on:
- backend
- chat-service
environment:
NUXT_PUBLIC_API_BASE: ${NUXT_PUBLIC_API_BASE}
NUXT_PUBLIC_CHAT_API_BASE: ${NUXT_PUBLIC_CHAT_API_BASE}
NUXT_API_BASE_INTERNAL: http://backend:4000
expose:
- "3000"
networks:
- default
- proxy
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.alpinbet-frontend.rule=Host(`antigol.ru`)
- traefik.http.routers.alpinbet-frontend.entrypoints=websecure
- traefik.http.routers.alpinbet-frontend.tls.certresolver=letsencrypt
- traefik.http.services.alpinbet-frontend.loadbalancer.server.port=3000
downloads:
build:
context: ./downloads-service
restart: unless-stopped
expose:
- "8080"
volumes:
- ./downloads:/app/downloads:ro
networks:
- default
- proxy
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.alpinbet-downloads.rule=Host(`files.antigol.ru`)
- traefik.http.routers.alpinbet-downloads.entrypoints=websecure
- traefik.http.routers.alpinbet-downloads.tls.certresolver=letsencrypt
- traefik.http.services.alpinbet-downloads.loadbalancer.server.port=8080
volumes:
postgres_data:
chat_postgres_data:
backend_runtime:
dockmon_data:
networks:
proxy:
external: true

49
docs/BACKUP.md Normal file
View File

@@ -0,0 +1,49 @@
# PostgreSQL Auto Backup
В проект добавлен сервис `postgres-backup` в [docker-compose.yml](/C:/Users/vlad/Documents/Projects/alpinbet-parser/docker-compose.yml).
Что делает сервис:
- по расписанию запускает `pg_dump` основной БД
- при `BACKUP_INCLUDE_CHAT_DB=true` также архивирует chat-БД
- сохраняет архивы в папку `./backups`
- удаляет архивы старше `BACKUP_RETENTION_DAYS`
Настройки в `.env`:
```env
BACKUP_TZ=Europe/Moscow
BACKUP_INTERVAL_SECONDS=86400
BACKUP_RETENTION_DAYS=7
BACKUP_GZIP_LEVEL=6
BACKUP_INCLUDE_CHAT_DB=true
```
Запуск:
```bash
docker compose up -d postgres-backup
```
Проверка логов:
```bash
docker compose logs --tail=100 postgres-backup
```
Проверка архивов:
```bash
ls -lah backups
```
Имена файлов:
- `backups/main_YYYY-MM-DD_HH-MM-SS.sql.gz`
- `backups/chat_YYYY-MM-DD_HH-MM-SS.sql.gz`
Ручной backup при необходимости:
```bash
docker compose exec -T postgres pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" | gzip > backups/manual_$(date +%F_%H-%M-%S).sql.gz
```

118
docs/COMMERCIAL_PROPOSAL.md Normal file
View File

@@ -0,0 +1,118 @@
# Коммерческое предложение
## Проект
Система сигналов на базе Alpinbet с парсером, backend API, PWA-клиентом, push-уведомлениями и административной панелью.
## 1. Цель проекта
Разработка системы для получения сигналов из источника Alpinbet, их обработки и доставки пользователям в режиме, близком к реальному времени, с разграничением доступа по подписке и по ботам.
## 2. Состав работ
### 2.1 Backend
Будет реализовано:
- регистрация и авторизация пользователей;
- роли пользователей и базовое разграничение прав;
- система подписок с датой окончания и проверкой доступа;
- разграничение доступа к ботам;
- API для PWA-клиента и административной панели;
- прием и обработка сигналов от парсера;
- логика отправки уведомлений;
- базовое логирование системных событий.
Ограничения:
- backend не гарантирует 100% доставку уведомлений, так как доставка зависит от внешних push-провайдеров и настроек устройства пользователя;
- при изменении бизнес-логики доступа, тарифов или ролей требуется отдельная доработка.
### 2.2 Интеграция с Alpinbet
Будет реализовано:
- подключение к источнику данных Alpinbet по учетным данным заказчика;
- регулярный опрос страниц с интервалом около 20 секунд;
- сохранение сигналов в систему;
- защита от дублирования сигналов;
- привязка сигнала к конкретному боту/источнику;
- поддержка встроенного списка ботов в парсере.
На текущем этапе в парсер зашиваются следующие боты:
- `raketafon``https://alpinbet.com/dispatch/antigol/raketafon`
- `pobeda-1-comand``https://alpinbet.com/dispatch/antigol/pobeda-1-comand`
- `raketabas``https://alpinbet.com/dispatch/antigol/raketabas`
- `sol-1www``https://alpinbet.com/dispatch/antigol/sol-1www`
- `fon-stb``https://alpinbet.com/dispatch/antigol/fon-stb`
Важно:
- доступ к Alpinbet, учетные данные и фактическая структура страниц находятся на стороне заказчика;
- при изменении API, HTML-структуры, механизма авторизации или адресов страниц Alpinbet требуется отдельная доработка;
- при недоступности источника система не генерирует сигналы;
- качество и стабильность парсинга напрямую зависят от стабильности внешнего источника.
### 2.3 Push-уведомления
Будет реализовано:
- отправка уведомлений при появлении новых сигналов;
- регистрация устройства пользователя;
- доставка через web push;
- базовая обработка невалидных push-подписок.
Ограничения:
- на iPhone push-уведомления работают через PWA и зависят от Safari, разрешений пользователя и системных настроек;
- уведомления могут приходить с задержкой;
- гарантируется попытка отправки уведомления, но не гарантируется фактическое получение на устройстве.
### 2.4 Клиентское приложение (PWA)
Будет реализовано:
- доступ через браузер как web-приложение;
- установка на телефон как PWA;
- авторизация пользователя;
- просмотр списка сигналов;
- просмотр срока действия подписки;
- отображение доступных пользователю ботов;
- работа с push-уведомлениями в рамках возможностей браузера.
Ограничения:
- приложение является PWA, а не нативным приложением;
- публикация в App Store и Google Play не входит в данный этап;
- функциональность ограничена возможностями браузера и платформенных ограничений PWA.
### 2.5 Административная панель
Будет реализовано:
- просмотр списка пользователей;
- управление сроками доступа и подписками;
- выдача и продление доступа;
- управление доступом к ботам;
- базовый просмотр сигналов и статусов доставки уведомлений.
Не входит:
- сложная аналитика;
- финансовая отчетность;
- CRM-функции и продвинутая история изменений.
### 2.6 Запуск системы
Будет выполнено:
- настройка серверного окружения;
- деплой проекта;
- базовая проверка работоспособности backend, parser, frontend и push-механизма;
- проверка получения сигналов из подключенных ботов.
## 3. Что не входит в проект
- публикация в App Store / Google Play;
- интеграция платежных систем;
- юридические документы и правовая часть;
- техническая поддержка сторонних сервисов и поставщиков;
- доработки, вызванные изменениями на стороне Alpinbet, Firebase, браузеров или операционных систем;
- SLA на внешние сервисы и гарантии бесперебойной работы источника данных.
## 4. Технические ограничения и допущения
- система работает по модели best effort для внешних интеграций;
- парсер зависит от доступности Alpinbet и сохранности учетной сессии;
- при изменении структуры страниц могут потребоваться срочные корректировки селекторов и логики парсинга;
- скорость появления сигнала у пользователя зависит от источника, интервала опроса, сети, браузера и push-провайдера;
- доступ к ботам, список ботов и учетные данные предоставляются заказчиком.

194
docs/DEPLOY.md Normal file
View File

@@ -0,0 +1,194 @@
# Deploy Runbook
Короткая инструкция по выкладке проекта на сервер.
## 1. Подготовка сервера
- Установить `git`, `docker`, `docker compose`.
- Открыть наружу только порты `80` и `443`.
- Закрыть наружу `5432`, `6379`, `3000`, `4000`, `4010`, `8080`.
- Создать каталог проекта, например:
```bash
mkdir -p ~/alpinbet-parser
cd ~/alpinbet-parser
```
## 2. Получение кода
Если репозиторий уже инициализирован:
```bash
git remote -v
git pull
```
Если это первый деплой:
```bash
git clone git@gitlab.com:talorr/alpinbet-parser.git ~/alpinbet-parser
cd ~/alpinbet-parser
```
## 3. Настройка секретов
Скопировать шаблон env:
```bash
cp .env.example .env
```
Заполнить в `.env`:
- `POSTGRES_DB`
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `JWT_SECRET`
- `PARSER_INTERNAL_SECRET`
- `NUXT_PUBLIC_API_BASE`
- `APP_PUBLIC_URL`
- `CORS_ORIGIN`
- `SMTP_*`
- `VAPID_*`
- `FIREBASE_*`
Важно:
- Не оставлять `change_me_*`.
- Использовать длинные случайные значения для `JWT_SECRET` и `PARSER_INTERNAL_SECRET`.
- `adminer` и `dockmon` должны оставаться закомментированными в `docker-compose.yml`.
## 4. Доступ к Traefik Dashboard
Создать файл с логином и паролем:
```bash
mkdir -p traefik/secrets
cp traefik/secrets/dashboard-users.example traefik/secrets/dashboard-users
```
Сгенерировать `htpasswd`:
```bash
htpasswd -nb admin 'CHANGE_ME_STRONG_PASSWORD' > traefik/secrets/dashboard-users
```
## 5. Проверка конфига
Перед запуском проверить итоговый compose:
```bash
docker compose config
```
Если здесь ошибка, не запускать деплой, пока она не исправлена.
## 6. Первый запуск
Собрать и поднять стек:
```bash
docker compose up -d --build
```
Применить схему базы:
```bash
docker compose exec backend npx prisma db push
```
Проверить статус:
```bash
docker compose ps
docker compose logs --tail=100 backend
docker compose logs --tail=100 frontend
docker compose logs --tail=100 parser
```
## 7. Smoke Check После Деплоя
Проверить:
```bash
curl https://api.antigol.ru/health
```
И вручную в браузере:
- `https://antigol.ru`
- логин
- открытие сигналов
- админка, если нужна
## 8. Бэкап Базы
Создать sql-бэкап:
```bash
docker compose exec -T postgres pg_dump -U postgres -d betting_signals > backup_$(date +%F_%H-%M-%S).sql
```
Или сразу архив:
```bash
docker compose exec -T postgres pg_dump -U postgres -d betting_signals | gzip > backup_$(date +%F_%H-%M-%S).sql.gz
```
Если имена БД и пользователя другие, подставить свои значения из `.env`.
Скачать бэкап на локальную машину:
```bash
scp root@your-server:~/alpinbet-parser/backup_2026-03-25_20-30-00.sql.gz .
```
## 9. Обновление Проекта
Обычный порядок после `git pull`:
```bash
git pull
docker compose build backend frontend parser forecast-ocr
docker compose up -d
docker compose exec backend npx prisma db push
```
Если менялись только env или Traefik:
```bash
docker compose up -d
```
## 10. Полезные Команды
Логи:
```bash
docker compose logs -f backend
docker compose logs -f parser
docker compose logs -f traefik
```
Перезапуск одного сервиса:
```bash
docker compose restart backend
docker compose restart parser
```
Остановить проект:
```bash
docker compose down
```
## 11. Минимальный Продовый Чеклист
- `.env` заполнен реальными значениями
- `dashboard-users` создан
- `docker compose config` проходит без ошибок
- наружу открыты только `80` и `443`
- `adminer` и `dockmon` не включены
- после запуска проходит `/health`
- сделан свежий backup БД

View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY server.mjs ./server.mjs
ENV PORT=8080
CMD ["node", "server.mjs"]

834
downloads-service/package-lock.json generated Normal file
View File

@@ -0,0 +1,834 @@
{
"name": "downloads-service",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "downloads-service",
"dependencies": {
"express": "^5.1.0",
"helmet": "^8.1.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "downloads-service",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs"
},
"dependencies": {
"express": "^5.1.0",
"helmet": "^8.1.0"
}
}

View File

@@ -0,0 +1,58 @@
import express from "express";
import helmet from "helmet";
import { extname, resolve } from "node:path";
const app = express();
const port = Number.parseInt(process.env.PORT || "8080", 10);
const downloadsRoot = resolve("/app/downloads");
app.disable("x-powered-by");
app.set("trust proxy", true);
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: "cross-origin" }
})
);
app.get("/health", (_request, response) => {
response.setHeader("Cache-Control", "no-store");
response.json({ ok: true });
});
app.use(
"/downloads",
express.static(downloadsRoot, {
dotfiles: "deny",
fallthrough: false,
index: false,
setHeaders(response, filePath) {
const extension = extname(filePath).toLowerCase();
if (extension === ".apk") {
response.setHeader("Content-Type", "application/vnd.android.package-archive");
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
return;
}
response.setHeader("Cache-Control", "public, max-age=300");
}
})
);
app.use((request, response) => {
const isDownloadsPath = request.path === "/downloads" || request.path.startsWith("/downloads/");
response
.status(isDownloadsPath ? 404 : 404)
.setHeader("Cache-Control", "no-store")
.json({ message: isDownloadsPath ? "File not found" : "Not found" });
});
app.listen(port, "0.0.0.0", () => {
console.log(`Downloads service listening on ${port}`);
});

1
downloads/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,12 @@
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY src ./src
EXPOSE 4010
CMD ["npm", "start"]

Binary file not shown.

1702
forecast-ocr-service/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "forecast-ocr-service",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"axios": "^1.8.4",
"express": "^4.21.2",
"multer": "^2.0.2",
"sharp": "^0.34.4",
"tesseract.js": "^6.0.1"
}
}

Binary file not shown.

View File

@@ -0,0 +1,43 @@
const express = require('express');
const multer = require('multer');
const { recognizeImage } = require('./ocr');
const app = express();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
const port = Number(process.env.PORT || 4010);
app.use(express.json({ limit: '10mb' }));
app.get('/health', (_req, res) => {
res.json({ ok: true });
});
app.post('/ocr/forecast', upload.single('image'), async (req, res) => {
try {
const imageBuffer = req.file?.buffer || null;
if (!imageBuffer) {
return res.status(400).json({
error: 'Provide multipart field image'
});
}
const result = await recognizeImage({
imageBuffer
});
return res.json({
rawForecast: result.rawForecast,
forecast: result.forecast,
cached: result.cached
});
} catch (error) {
return res.status(500).json({
error: error.message
});
}
});
app.listen(port, () => {
console.log(`Forecast OCR service listening on port ${port}`);
});

View File

@@ -0,0 +1,292 @@
const sharp = require('sharp');
const { createWorker, PSM } = require('tesseract.js');
let workerPromise = null;
const resultCache = new Map();
const CYRILLIC_WHITELIST =
'\u0410\u0411\u0412\u0413\u0414\u0415\u0401\u0416\u0417\u0418\u0419\u041a\u041b\u041c\u041d\u041e\u041f\u0420\u0421\u0422\u0423\u0424\u0425\u0426\u0427\u0428\u0429\u042a\u042b\u042c\u042d\u042e\u042f' +
'\u0430\u0431\u0432\u0433\u0434\u0435\u0451\u0436\u0437\u0438\u0439\u043a\u043b\u043c\u043d\u043e\u043f\u0440\u0441\u0442\u0443\u0444\u0445\u0446\u0447\u0448\u0449\u044a\u044b\u044c\u044d\u044e\u044f';
const LATIN_TO_CYRILLIC_LOOKALIKES = [
[/A/g, '\u0410'],
[/B/g, '\u0412'],
[/C/g, '\u0421'],
[/E/g, '\u0415'],
[/H/g, '\u041d'],
[/K/g, '\u041a'],
[/M/g, '\u041c'],
[/O/g, '\u041e'],
[/P/g, '\u0420'],
[/T/g, '\u0422'],
[/X/g, '\u0425'],
[/Y/g, '\u0423']
];
async function getWorker() {
if (!workerPromise) {
workerPromise = (async () => {
const worker = await createWorker('rus+eng', 1, {
logger: () => undefined
});
await worker.setParameters({
tessedit_pageseg_mode: String(PSM.SINGLE_BLOCK),
preserve_interword_spaces: '1',
tessedit_char_whitelist: `${CYRILLIC_WHITELIST}ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789().,+-/: `
});
return worker;
})();
}
return workerPromise;
}
function normalizeLine(line) {
let normalized = line
.replace(/^[0-9]+\s+/, '')
.replace(/\|/g, '/')
.replace(/\u2122/g, '\u041c');
for (const [pattern, replacement] of LATIN_TO_CYRILLIC_LOOKALIKES) {
normalized = normalized.replace(pattern, replacement);
}
return normalized;
}
function normalizeOcrText(value) {
if (!value) return null;
const normalized = String(value)
.replace(/\r/g, '\n')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map(normalizeLine)
.join(' ')
.replace(/\s+/g, ' ')
.replace(/\s+([),.:])/g, '$1')
.replace(/([(])\s+/g, '$1')
.replace(/^[A-Za-z\u0400-\u04FF0-9]\s+(?=[A-Za-z\u0400-\u04FF]{2,})/, '')
.replace(/(\d)\s*[,.]\s*(\d)/g, '$1.$2')
.trim();
return normalized || null;
}
function canonicalizeTotals(value) {
return value
.replace(/\b[t\u0442][m\u043c]\b/gi, '\u0422\u041c')
.replace(/\b[t\u0442][b\u0432]\b/gi, '\u0422\u0411')
.replace(/\b[u\u0443]\s*([0-9]+(?:\.[0-9]+)?)\b/gi, '\u0422\u041c ($1)')
.replace(/\b[o\u043e]\s*([0-9]+(?:\.[0-9]+)?)\b/gi, '\u0422\u0411 ($1)')
.replace(/\b[m\u043c]\s*([0-9]+(?:\.[0-9]+)?)\b/gi, '\u0422\u041c ($1)')
.replace(/\b[b\u0432]\s*([0-9]+(?:\.[0-9]+)?)\b/gi, '\u0422\u0411 ($1)')
.replace(/(^|[^\u0418])(\u0422[\u041c\u0411])\s*([0-9]+(?:\.[0-9]+)?)/g, '$1$2 ($3)');
}
function normalizeTeamTotalToken(token) {
if (!token) return token;
const compact = String(token)
.replace(/\s+/g, '')
.toUpperCase()
.replace(/[|!IL]/g, '1')
.replace(/Z/g, '2')
.replace(/[\u0418N]/g, '\u0418')
.replace(/[M\u041c]/g, '\u041c')
.replace(/[T\u0422]/g, '\u0422')
.replace(/[B\u0412]/g, '\u0411');
if (/^[\u0418\u041c]\u0422\u041c[12]$/.test(compact)) {
return `\u0418\u0422\u041c${compact.slice(-1)}`;
}
if (/^[\u0418\u041c]\u0422\u0411[12]$/.test(compact)) {
return `\u0418\u0422\u0411${compact.slice(-1)}`;
}
return token;
}
function canonicalizeTeamTotals(value) {
return value.replace(/(^|\s)([A-Za-z\u0400-\u04FF|!1]{3,4}[12Z])(?=\s|\(|$)/g, (match, lead, token) => {
const normalized = normalizeTeamTotalToken(token);
return `${lead}${normalized}`;
});
}
function dedupeEdgeLineValue(value) {
const match = value.match(/^\((\d+(?:\.\d+)?)\)\s+(.+?)\s+\(\1\)$/);
if (!match) {
return value;
}
return `${match[2]} (${match[1]})`;
}
function canonicalizeOutcomePrefixes(value) {
let normalized = value
.replace(/\b[c\u0441]\s+[o\u043e][t\u0442]\b/gi, '\u0441 \u041e\u0422')
.replace(/\b[\u0438u]\s+[o\u043e][t\u0442]\b/gi, '\u0438 \u041e\u0422')
.replace(/\b[c\u0441]\s+[n\u043d][e\u0435][t\u0442]\b/gi, '\u0441 \u041d\u0415\u0422')
.replace(/\b[c\u0441]\s+[\u0434d][a\u0430]\b/gi, '\u0441 \u0414\u0410');
if (/^\u041e\u0422\s+\u0422\u041c\b/.test(normalized)) {
normalized = `\u0441 ${normalized}`;
}
return normalized;
}
function canonicalizeBothTeamsToScore(value) {
return value
.replace(/\b[o\u043e][3\u0437]\b/gi, '\u041e\u0417')
.replace(/\b\u041e\u0417\s*[\u0434d][\u0430a]\b/gi, '\u041e\u0417 \u0434\u0430')
.replace(/\b\u041e\u0417\s*[\u043dh][\u0435e][\u0442t]\b/gi, '\u041e\u0417 \u043d\u0435\u0442');
}
function normalizeResultSelectionToken(token) {
if (!token) return token;
const compact = String(token)
.replace(/\s+/g, '')
.toUpperCase()
.replace(/[|!IL]/g, '1')
.replace(/Z/g, '2')
.replace(/[\u0425\u0445]/g, 'X')
.replace(/[\u041c\u043c]/g, 'M');
if (/^(?:\u041f|P)?1$/.test(compact)) return '\u041f1';
if (/^(?:\u041f|P)?2$/.test(compact)) return '\u041f2';
if (/^X$/.test(compact)) return 'X';
if (/^1X$/.test(compact)) return '1X';
if (/^X2$/.test(compact)) return 'X2';
if (/^12$/.test(compact)) return '12';
if (/^M[1M]$/.test(compact)) return '\u041f1';
if (/^M2$/.test(compact)) return '\u041f2';
return token;
}
function canonicalizeMainGameSelections(value) {
return value.replace(/(\u041e\u0441\u043d\u043e\u0432\u043d\u0430\u044f\s+\u0438\u0433\u0440\u0430\s+)([A-Za-z\u0400-\u04FF0-9|!]+)/gi, (_match, prefix, token) => {
return `${prefix}${normalizeResultSelectionToken(token)}`;
});
}
function canonicalizeResultMarket(value) {
return value
.replace(/\b[\u041fP\u0420]\s*1\b/gi, '\u041f1')
.replace(/\b[\u041fP\u0420]\s*2\b/gi, '\u041f2')
.replace(/\b[M\u041c]\s*[1M\u041c]\b/g, '\u041f1')
.replace(/\b[M\u041c]\s*2\b/g, '\u041f2')
.replace(/\b[x\u0445]\b/gi, 'X')
.replace(/\b1[x\u0445]\b/gi, '1X')
.replace(/\b[x\u0445]2\b/gi, 'X2')
.replace(/\b12\b/gi, '12');
}
function canonicalizeHandicapMarketLabels(value) {
return value
.replace(/(^|\s)(\u0424\u041e\u0420\u0410)\s*[\u041bLIl|!1](?=\s|\(|$)/gi, (_match, lead, prefix) => `${lead}${prefix}1`)
.replace(/(^|\s)(\u0424\u041e\u0420\u0410)\s*2(?=\s|\(|$)/gi, (_match, lead, prefix) => `${lead}${prefix}2`);
}
function canonicalizeHandicap(value) {
return value
.replace(/(^|\s)[\u0424F][\u041eO][\u0420P][A\u0410][\u041bLIl|!1](?=\s|\(|$)/gi, '$1\u0424\u041e\u0420\u04101')
.replace(/(^|\s)[\u0424F][\u041eO][\u0420P][A\u0410]2(?=\s|\(|$)/gi, '$1\u0424\u041e\u0420\u04102')
.replace(/\b[\u0444f]\s*1\b/gi, '\u0424\u041e\u0420\u04101')
.replace(/\b[\u0444f]\s*2\b/gi, '\u0424\u041e\u0420\u04102')
.replace(/(^|\s)\u0424\u041e\u0420\u0410([12])\s*([+-]?\d+(?:\.\d+)?)/g, '$1\u0424\u041e\u0420\u0410$2 ($3)')
.replace(/(^|\s)\u0424\u041e\u0420\u0410([12])\s*\(/g, '$1\u0424\u041e\u0420\u0410$2 (');
}
function canonicalizeForecast(value) {
if (!value) return null;
const normalized = [
canonicalizeOutcomePrefixes,
canonicalizeBothTeamsToScore,
canonicalizeMainGameSelections,
canonicalizeResultMarket,
canonicalizeHandicapMarketLabels,
canonicalizeTeamTotals,
canonicalizeTotals,
canonicalizeHandicap
].reduce((current, transform) => transform(current), value)
.replace(/\s+/g, ' ')
.replace(/\s+([),.:])/g, '$1')
.replace(/([(])\s+/g, '$1')
.replace(/^(?:[c\u0441]\s+)?\u041e\u0422\s+\u0422\u041c\b/, '\u0441 \u041e\u0422 \u0422\u041c')
.trim();
return dedupeEdgeLineValue(normalized) || null;
}
async function preprocessImage(inputBuffer) {
return sharp(inputBuffer)
.flatten({ background: '#ffffff' })
.grayscale()
.blur(0.3)
.threshold(165, { grayscale: true })
.trim()
.resize({
width: 900,
kernel: sharp.kernel.nearest,
fit: 'inside',
withoutEnlargement: false
})
.png()
.toBuffer();
}
async function recognizeBuffer(buffer) {
const preprocessed = await preprocessImage(buffer);
const worker = await getWorker();
const {
data: { text }
} = await worker.recognize(preprocessed);
const rawForecast = normalizeOcrText(text);
return {
rawForecast,
forecast: canonicalizeForecast(rawForecast)
};
}
async function recognizeImage({ imageBuffer }) {
const cacheKey = imageBuffer ? `buffer:${imageBuffer.length}:${imageBuffer.subarray(0, 32).toString('hex')}` : null;
if (cacheKey && resultCache.has(cacheKey)) {
return {
...resultCache.get(cacheKey),
cached: true
};
}
const sourceBuffer = imageBuffer || null;
if (!sourceBuffer) {
throw new Error('Image payload is missing');
}
const result = await recognizeBuffer(sourceBuffer);
if (cacheKey) {
resultCache.set(cacheKey, result);
}
return {
...result,
cached: false
};
}
module.exports = {
recognizeImage,
canonicalizeForecast
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

15
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

101
frontend/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

3
frontend/android/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
frontend/android/.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

10
frontend/android/.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
frontend/android/.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

2
frontend/android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

View File

@@ -0,0 +1,63 @@
apply plugin: 'com.android.application'
android {
namespace "com.alpinbet.app"
compileSdk rootProject.ext.compileSdkVersion
signingConfigs {
release {
storeFile file(ALPINBET_UPLOAD_STORE_FILE)
storePassword ALPINBET_UPLOAD_STORE_PASSWORD
keyAlias ALPINBET_UPLOAD_KEY_ALIAS
keyPassword ALPINBET_UPLOAD_KEY_PASSWORD
}
}
defaultConfig {
applicationId "com.alpinbet.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 5
versionName "1.0.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View File

@@ -0,0 +1,23 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-device')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-splash-screen')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
frontend/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

Some files were not shown because too many files have changed in this diff Show More